"""Import board members from a WordPress "Board of Directors" page.

The old pages are hand-authored Gutenberg markup: an <h1> name, a bolded role,
a right-aligned photo, then bio paragraphs. The first importer only guessed at
names, so members were brought in inactive. This reads the real structure —
name, role, photo and bio — and can merge a separate contact table for email
addresses (one site keeps them that way).

    python manage.py import_board --html /data/board/palms.html \
        --uploads /data/board/uploads --contacts /data/board/palms-contact.html
"""
import html as html_lib
import os
import re

from django.core.files import File
from django.core.management.base import BaseCommand, CommandError

from documents.models import BoardMember

TAG = re.compile(r"<[^>]+>")
GUTENBERG_COMMENT = re.compile(r"<!--.*?-->", re.S)
HEADING = "<h{n}[^>]*>(.*?)</h{n}>"
IMG = re.compile(r'<img[^>]+src="([^"]+)"', re.I)
PARA = re.compile(r"<p[^>]*>(.*?)</p>", re.S | re.I)
STRONG = re.compile(r"<strong[^>]*>(.*?)</strong>", re.S | re.I)
ROW = re.compile(r"<tr[^>]*>(.*?)</tr>", re.S | re.I)
CELL = re.compile(r"<td[^>]*>(.*?)</td>", re.S | re.I)
MAILTO = re.compile(r"mailto:([^\"'?>\s]+)", re.I)
EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")

# Bold labels that structure a biography rather than name a role.
BIO_LABELS = {
    "education", "employment", "background", "volunteer activities", "experience",
    "career", "professional", "personal", "qualifications", "biography",
    "background at the sands", "background at the palms", "volunteer",
}


BREAK = re.compile(r"<br\s*/?>", re.I)


def text_of(fragment):
    """Visible text, with <br> kept as a line break.

    Stripping <br> outright welds the words on either side together — "…Board
    of Directors" + "The Sands at Grace Bay" became one 54-character run, which
    then failed the length test for a role and was filed as biography instead.
    """
    fragment = BREAK.sub("\n", fragment or "")
    return html_lib.unescape(TAG.sub("", fragment)).strip()


def parse_contacts(markup):
    """name -> (role, email) from a simple contact table."""
    contacts = {}
    for row in ROW.findall(markup):
        cells = CELL.findall(row)
        if len(cells) < 2:
            continue
        name = text_of(cells[0])
        if not name or name.lower() in {"name", "member"}:
            continue
        role = text_of(cells[1]) if len(cells) > 1 else ""
        email_match = MAILTO.search(row)
        contacts[name.lower()] = (role, email_match.group(1) if email_match else "")
    return contacts


def _plausible_name(text):
    return bool(text) and 3 <= len(text) <= 80 and not EMAIL_RE.search(text)


def parse_members(markup):
    """Split a board page into one record per name heading.

    The three sites were built at different times and use different heading
    levels for names (h1 on one, h3 on the others), so the level is detected
    rather than assumed.
    """
    markup = GUTENBERG_COMMENT.sub("", markup)

    headings, best = [], 0
    for level in (1, 2, 3, 4):
        found = list(re.finditer(HEADING.format(n=level), markup, re.S | re.I))
        usable = [m for m in found if _plausible_name(text_of(m.group(1)))]
        if len(usable) > best:
            headings, best = usable, len(usable)

    members = []

    for index, heading in enumerate(headings):
        name = text_of(heading.group(1))
        if not _plausible_name(name):
            continue
        end = headings[index + 1].start() if index + 1 < len(headings) else len(markup)
        section = markup[heading.end():end]

        role, bio_parts = "", []
        for paragraph in PARA.findall(section):
            bold = STRONG.search(paragraph)
            plain = text_of(paragraph)
            if not plain:
                continue
            # The role is the first short bolded line under the name — but not
            # a bio section label ("Education") and never an email address.
            if not role and bold:
                candidate = text_of(bold.group(1))
                if (candidate and len(candidate) <= 60
                        and candidate.lower().strip(":") not in BIO_LABELS
                        and not EMAIL_RE.search(candidate)):
                    role = candidate
                    continue
            first = plain.split("\n")[0].strip()
            if (not role and first and len(first) <= 48 and not first.endswith(".")
                    and not EMAIL_RE.search(first)
                    and first.lower().strip(":") not in BIO_LABELS):
                role = first
                # Whatever followed the break (a property name, a company) is
                # not the role, but it is not nothing either — keep it.
                rest = plain[len(first):].strip()
                if rest:
                    bio_parts.append(" ".join(rest.split()))
                continue
            bio_parts.append(" ".join(plain.split()))

        members.append({
            "name": name,
            "role": role,
            "bio": "\n\n".join(bio_parts).strip(),
            "photo": None,
        })

    attach_photos(members, markup)
    return members


def _filename_words(url):
    """The identifying part of an image filename, as one lowercase run.

    Strips the directory, extension, WordPress's size suffix (-238x300), and
    any trailing serial (-1) or size letter (…L), so "tommothorpeL.jpg" and
    "harper2017L.jpg" reduce to "tommothorpe" and "harper".
    """
    stem = url.rsplit("/", 1)[-1].split("?")[0].rsplit(".", 1)[0].lower()
    stem = re.sub(r"-\d+x\d+$", "", stem)      # WordPress size variant
    stem = re.sub(r"-\d+$", "", stem)           # duplicate-upload serial
    return re.sub(r"[^a-z]", "", stem)


def attach_photos(members, markup):
    """Give each member the photo whose filename carries their name.

    Position cannot be used. The three board pages float the portrait right,
    and whether it sits above or below its heading varies by site and even
    within a page — so an image between two headings belongs to either of them.
    Reading the filename is the only signal that is actually about the person.

    A photo that matches nobody is dropped rather than given to whoever is
    nearest. These pages outlive their boards: a departed member's portrait can
    still be sitting in the markup, and the failure that motivated this put one
    real director's face under another real director's name. An unmatched
    member shows their initials, which is wrong in a way that is obvious.
    """
    urls = [html_lib.unescape(m.group(1)) for m in IMG.finditer(markup)]
    first_names = [m["name"].split()[0].lower() for m in members if m["name"].split()]

    for url in urls:
        words = _filename_words(url)
        if not words:
            continue
        hits = []
        for member in members:
            parts = [re.sub(r"[^a-z]", "", p.lower()) for p in member["name"].split()]
            parts = [p for p in parts if p]
            if not parts:
                continue
            surname, given = parts[-1], parts[0]
            # Four letters for a surname: shorter runs match by accident inside
            # a longer word. A first name may be three, because it has to clear
            # the uniqueness test below as well.
            if len(surname) >= 4 and surname in words:
                hits.append((2, member))
            # A first name is weaker evidence, and only usable when no other
            # member shares it — this page has two men called Tom.
            elif len(given) >= 3 and given in words and first_names.count(given) == 1:
                hits.append((1, member))
        if not hits:
            continue
        best = max(h[0] for h in hits)
        winners = [m for score, m in hits if score == best]
        if len(winners) == 1 and winners[0]["photo"] is None:
            winners[0]["photo"] = url
    return members


class Command(BaseCommand):
    help = "Import board members (name, role, photo, bio) from a WordPress page."

    def _store_photo(self, record, url, uploads):
        """Copy one uploaded file onto a member, replacing whatever is there."""
        relative = url.split("/uploads/")[-1].split("?")[0]
        source = os.path.join(uploads, relative)
        if not os.path.isfile(source):
            return False
        if record.photo:
            record.photo.delete(save=False)
        with open(source, "rb") as handle:
            record.photo.save(os.path.basename(source), File(handle), save=True)
        return True

    def fix_photos(self, members, options):
        """Re-match portraits without touching the roster.

        One site's board list comes from its contact table rather than its bio
        page, so a full import would rewrite who is on the board. This corrects
        only the faces, for the people already on file.
        """
        if not options.get("uploads"):
            raise CommandError("--photos-only needs --uploads")
        fixed = cleared = 0
        for member in members:
            record = BoardMember.objects.filter(name=member["name"]).first()
            if not record:
                continue
            wanted = os.path.basename(member["photo"].split("?")[0]) if member["photo"] else ""
            current = os.path.basename(record.photo.name) if record.photo else ""
            if wanted and current != wanted:
                if self._store_photo(record, member["photo"], options["uploads"]):
                    fixed += 1
                    self.stdout.write(f"    {record.name}: {current or '(none)'} -> {wanted}")
            elif not wanted and current:
                # The page no longer proves this face belongs to this person.
                record.photo.delete(save=True)
                cleared += 1
                self.stdout.write(f"    {record.name}: {current} -> (none, unmatched)")
        self.stdout.write(self.style.SUCCESS(
            f"  {fixed} photo(s) corrected, {cleared} unmatched photo(s) removed"))

    def add_arguments(self, parser):
        parser.add_argument("--html", required=True, help="board page HTML")
        parser.add_argument("--contacts", help="optional contact-table HTML for emails")
        parser.add_argument("--uploads", help="local wp-content/uploads root for photos")
        parser.add_argument("--photos-only", action="store_true",
                            help="correct photos on the members already on file and "
                                 "change nothing else — no roster, bios, or order")
        parser.add_argument("--refresh-photos", action="store_true",
                            help="replace photos already on file (use after a "
                                 "mis-assignment); otherwise photos are only added")
        parser.add_argument("--activate", action="store_true",
                            help="publish the imported members immediately")
        parser.add_argument("--roster-from-contacts", action="store_true",
                            help="the contact table lists the CURRENT board; the bio "
                                 "page only supplies bios and photos where names match")

    def handle(self, *args, **options):
        if not os.path.isfile(options["html"]):
            raise CommandError(f"Not found: {options['html']}")
        markup = open(options["html"], encoding="utf-8", errors="replace").read()
        members = parse_members(markup)
        if not members:
            raise CommandError("No members found — is that the right page?")

        contacts = {}
        if options.get("contacts") and os.path.isfile(options["contacts"]):
            contacts = parse_contacts(
                open(options["contacts"], encoding="utf-8", errors="replace").read()
            )

        if options["roster_from_contacts"]:
            if not contacts:
                raise CommandError("--roster-from-contacts needs a --contacts file")
            # One site's bio page is years out of date while its contact table is
            # current. Trust the table for who is on the board, and keep the bio
            # page only for the members it still describes.
            bios = {m["name"].lower(): m for m in members}
            members = []
            for lowered, (role, _email) in contacts.items():
                match = bios.get(lowered, {})
                members.append({
                    "name": match.get("name") or lowered.title(),
                    "role": role or match.get("role", ""),
                    "bio": match.get("bio", ""),
                    "photo": match.get("photo"),
                })

        if options["photos_only"]:
            return self.fix_photos(members, options)

        created = updated = photos = 0
        for order, member in enumerate(members):
            role, email = contacts.get(member["name"].lower(), ("", ""))
            record = BoardMember.objects.filter(name=member["name"]).first()
            if record:
                updated += 1
            else:
                record = BoardMember(name=member["name"][:160])
                created += 1

            record.title = (member["role"] or role)[:160]
            record.bio = member["bio"]
            record.contact_email = email or record.contact_email
            record.order = order
            if options["activate"]:
                record.active = True
            record.save()

            wants_photo = not record.photo or options.get("refresh_photos")
            if member["photo"] and options.get("uploads") and wants_photo:
                relative = member["photo"].split("/uploads/")[-1].split("?")[0]
                source = os.path.join(options["uploads"], relative)
                if os.path.isfile(source):
                    if record.photo:
                        record.photo.delete(save=False)
                    with open(source, "rb") as handle:
                        record.photo.save(os.path.basename(source), File(handle), save=True)
                    photos += 1

        # Anyone left over is from the earlier guessy import; drop the ones that
        # this proper parse did not confirm.
        names = {m["name"] for m in members}
        stale = BoardMember.objects.exclude(name__in=names)
        stale_count = stale.count()
        stale.delete()

        self.stdout.write(self.style.SUCCESS(
            f"  {created} added, {updated} updated, {photos} photos, "
            f"{stale_count} unconfirmed removed"))
        for member in members:
            role, email = contacts.get(member["name"].lower(), ("", ""))
            self.stdout.write(
                f"    {member['name'][:28]:30} {(member['role'] or role)[:24]:26} "
                f"{'photo' if member['photo'] else '     '} "
                f"{'email' if email else ''}"
            )
