"""Shared machinery for a round of this tree: anchors, citation snapshots, and
the drill.

A round is a set of checks over a "world" dict. Each check returns a list of
failures (empty = pass) and declares the world keys it reads. The drill plants
defects by changing keys; a plant passes only when exactly its named checks
trip. Controls must trip nothing. Checks are deterministic, so a case
recomputes only the checks whose inputs it changed.
"""
import copy
import datetime
import html
import json
import re
import urllib.parse
import urllib.request

UA = "amral-verification (research)"


# ---------------------------------------------------------------- anchors

def squash(s):
    return re.sub(r"\s+", "", s)


def anchors_check(anchors):
    """anchors: {check id: [fragments]}; each must occur in the whitespace-free text."""
    def check(w):
        text = squash(w["text"])
        return [f"{cid}: anchor not found: {a[:50]}" for cid, frags in anchors.items()
                for a in frags if a not in text]
    return check


# ---------------------------------------------------------------- citations

def _get(url, accept):
    req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": accept})
    return urllib.request.urlopen(req, timeout=60).read()


def fetch_arxiv(aid):
    page = _get(f"https://arxiv.org/abs/{aid}", "text/html").decode("utf-8", "replace")
    meta = lambda k: [html.unescape(x) for x in re.findall(rf'<meta name="{k}" content="(.*?)"', page, re.S)]
    history = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ",
                     re.search(r"Submission history</h2>(.*?)</div>", page, re.S).group(1)))
    v1 = re.search(r"\[v1\]\s*\w{3}, (\d{1,2} \w{3} \d{4})", history).group(1)
    return {"source": "arxiv", "id": aid, "title": meta("citation_title")[0],
            "families": [a.split(",")[0].strip() for a in meta("citation_author")],
            "years": [datetime.datetime.strptime(v1, "%d %b %Y").year]}


def fetch_arxiv_abstract(aid):
    """the abstract as the abs page serves it now, with the version it belongs to"""
    page = _get(f"https://arxiv.org/abs/{aid}", "text/html").decode("utf-8", "replace")
    text = re.search(r'<meta name="citation_abstract" content="(.*?)"', page, re.S).group(1)
    history = re.search(r"Submission history</h2>(.*?)</div>", page, re.S).group(1)
    return {"id": aid, "version": max(int(v) for v in re.findall(r"\[v(\d+)\]", history)),
            "abstract": re.sub(r"\s+", " ", html.unescape(text)).strip()}


def fetch_arxiv_titles(aid):
    """{version: title} over the paper's whole submission history"""
    latest = fetch_arxiv_abstract(aid)["version"]
    out = {}
    for v in range(1, latest + 1):
        page = _get(f"https://arxiv.org/abs/{aid}v{v}", "text/html").decode("utf-8", "replace")
        out[str(v)] = html.unescape(re.search(r'<meta name="citation_title" content="(.*?)"', page, re.S).group(1))
    return out


def fetch_arxiv_facts(aid, version, patterns, strip_invisible=False):
    """short facts from an arXiv paper's HTML full text: {name: the first match of its
    regex, whitespace-normalised, or None}. Only the matched phrases are kept.
    strip_invisible removes zero-width and function-application characters first."""
    page = _get(f"https://arxiv.org/html/{aid}v{version}", "text/html").decode("utf-8", "replace")
    text = html.unescape(re.sub(r"<[^>]+>", " ", page))
    if strip_invisible:
        text = re.sub(r"[​⁡⁢⁣]", "", text)
    text = re.sub(r"\s+", " ", text)
    found = {name: re.search(pat, text) for name, pat in patterns.items()}
    return {"id": aid, "version": version,
            "facts": {name: (m.group(0) if m else None) for name, m in found.items()}}


def fetch_github_file(repo, commit, path):
    """a file of a public GitHub repository at a fixed commit"""
    return _get(f"https://raw.githubusercontent.com/{repo}/{commit}/{path}", "text/plain").decode("utf-8", "replace")


def fetch_crossref(doi=None, query=None):
    if doi is None:
        q = urllib.parse.quote(query)
        doi = json.loads(_get(f"https://api.crossref.org/works?query.bibliographic={q}&rows=1",
                              "application/json"))["message"]["items"][0]["DOI"]
    rec = json.loads(_get(f"https://api.crossref.org/works/{urllib.parse.quote(doi)}", "application/json"))["message"]
    years = sorted({rec[k]["date-parts"][0][0] for k in ("issued", "published-print", "published-online")
                    if k in rec and rec[k].get("date-parts") and rec[k]["date-parts"][0][0]})
    return {"source": "crossref", "doi": doi, "query": query, "type": rec.get("type"),
            "title": (rec.get("title") or [""])[0], "families": [a.get("family", "") for a in rec.get("author", [])],
            "container": (rec.get("container-title") or [""])[0], "publisher": rec.get("publisher"),
            "volume": rec.get("volume"), "issue": rec.get("issue"),
            "article_number": rec.get("article-number"), "years": years}


def norm_title(s):
    s = html.unescape(s).lower().replace("\u2019", "'").replace("\u2013", "-")
    s = re.sub(r"\\mathbb\s*\{?\s*([a-z])\s*\}?", r"\1", s)
    return re.sub(r"[^a-z0-9]", "", s)


# "N. Authors, *Title*, venue ..., year." - the part after the title is optional
# (Round 03 cites some books by author and title alone)
REF_LINE = re.compile(r"^(\d+)\.\s+(.+?),\s+\*(.+?)\*(?:,\s+(.+?))?\.?\s*$", re.M)


def parse_refs(text):
    body = text.replace("\r\n", "\n")
    body = body[body.rfind("# 參考文獻"):] if "# 參考文獻" in body else body
    out = {}
    for n, authors, title, rest in REF_LINE.findall(body):
        fam = [re.sub(r"^(?:[A-Z]\.\s*)+", "", a.strip()).strip()
               for a in re.split(r",\s*|\s+and\s+", authors) if a.strip()]
        out[int(n)] = {"families": fam, "title": title, "rest": (rest or "").rstrip(".")}
    return out


def citations_check(expected):
    """expected: {ref number: options}; options may set check_authors=False (for a
    book record without authors) or check_venue=False."""
    def check(w):
        failures, refs, recs = [], parse_refs(w["text"]), w["snapshot"]["records"]
        for n, opt in expected.items():
            ref, rec = refs.get(n), recs.get(str(n))
            if ref is None or rec is None:
                failures.append(f"ref {n}: {'not in the document' if ref is None else 'no external record'}")
                continue
            if norm_title(ref["title"]) != norm_title(rec["title"]):
                failures.append(f"ref {n}: title differs")
            cited = [f.lower() for f in ref["families"]]
            listed = [f.lower() for f in rec["families"]]
            if opt.get("check_authors", True):
                ok = set(cited) <= set(listed) if opt.get("authors_subset") else cited == listed
                if not ok:
                    failures.append(f"ref {n}: authors {ref['families']} vs {rec['families']}")
            # a year is its own token at the end - not the tail of an arXiv id such as 1502.01251
            years = re.findall(r"(?:^|[,\s])((?:19|20)\d{2})(?:-\d{2}-\d{2})?$", ref["rest"])
            if years and int(years[0]) not in rec["years"]:
                failures.append(f"ref {n}: year {years[0]} not in {rec['years']}")
            if rec["source"] == "arxiv":
                if f"arXiv:{rec['id']}" not in ref["rest"]:
                    failures.append(f"ref {n}: arXiv id differs")
                continue
            if opt.get("check_venue", True) and ref["rest"]:
                venue = ref["rest"].rsplit(",", 1)[0] if years else ref["rest"]
                target = rec["container"] or rec.get("publisher") or ""
                if norm_title(target) not in norm_title(venue) and norm_title(venue) not in norm_title(target):
                    failures.append(f"ref {n}: venue {venue!r} vs {target!r}")
        return failures
    return check


# ---------------------------------------------------------------- the drill

def run_drill(build_world, checks, depends, cases):
    """cases: [(id, 'control'|'mutation', changes dict, expected set)]"""
    base_world = build_world()
    base = {c: fn(copy.copy(base_world)) for c, fn in checks.items()}
    out = []
    for cid, kind, changes, expected in cases:
        w = build_world()
        # a plant that changes nothing tests nothing: flag it rather than count it
        vacuous = kind == "mutation" and all(
            not callable(v) and base_world.get(k) == v for k, v in changes.items())
        w.update(changes)
        got = {c: (fn(w) if depends[c] & set(changes) else base[c]) for c, fn in checks.items()}
        tripped = {c for c, f in got.items() if f}
        out.append({"case": cid, "kind": kind, "expected": sorted(expected), "tripped": sorted(tripped),
                    "vacuous": vacuous, "ok": tripped == expected and not vacuous})
    return out


def summarize(drill, checks):
    plants = [x for x in drill if x["kind"] == "mutation"]
    controls = [x for x in drill if x["kind"] == "control"]
    held = sum(1 for x in drill for c in checks if c not in x["expected"] and c not in x["tripped"])
    quiet = sum(1 for x in drill for c in checks if c not in x["expected"])
    # a check no plant names has never been shown to catch anything
    unplanted = sorted(c for c in checks if not any(c in x["expected"] for x in plants))
    return {"plants": len(plants), "plants_caught_by_named_checks": sum(x["ok"] for x in plants),
            "controls": len(controls), "controls_undisturbed": sum(x["ok"] for x in controls),
            "must_stay_green_assertions": quiet, "must_stay_green_held": held,
            "unplanted_checks": unplanted, "cases": drill}


def report_drill(s):
    line = (f"drill: {s['plants_caught_by_named_checks']}/{s['plants']} plants caught by exactly the named checks; "
            f"{s['controls_undisturbed']}/{s['controls']} controls undisturbed; "
            f"{s['must_stay_green_held']}/{s['must_stay_green_assertions']} must-stay-green assertions held")
    if s.get("unplanted_checks"):
        line += f"; UNPLANTED (no plant names them): {', '.join(s['unplanted_checks'])}"
    return line
