"""
Final pre-submission audit.

Runs every check that can be run without human judgement, in four blocks:

  A. Package integrity          (delegates to verify_package.py)
  B. Numerical consistency      (delegates to audit_numbers.py)
  C. JME compliance             (limits, required sections, required elements)
  D. Cross-document consistency (manuscript vs supplementary vs data)

Everything it cannot check is listed at the end as HUMAN CHECKS REQUIRED.
"""

import os as _os
HERE = _os.path.dirname(_os.path.abspath(__file__))
ROOT_DIR = _os.path.dirname(HERE)

import os
import re
import subprocess
import sys
import zipfile

ROOT = ROOT_DIR
MS = os.path.join(ROOT, "manuscript")
OUT = os.path.join(ROOT, "manuscript")

results = []


def chk(block, label, ok, detail=""):
    results.append((block, label, ok, detail))
    mark = "PASS" if ok else "FAIL"
    print(f"  [{mark}] {label}" + (f"  — {detail}" if detail else ""))


def read(name):
    return open(os.path.join(MS, name), encoding="utf8").read()


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


def words(text):
    return len(text.split())


def strip_md(text):
    text = re.sub(r"```.*?```", "", text, flags=re.S)
    text = re.sub(r"^\|.*$", "", text, flags=re.M)
    text = re.sub(r"^>.*$", "", text, flags=re.M)
    text = re.sub(r"^#.*$", "", text, flags=re.M)
    text = re.sub(r"`[^`]*`", "", text)
    return text


print("=" * 74)
print("FINAL PRE-SUBMISSION AUDIT")
print("=" * 74)

# ----------------------------------------------------------------- A
print("\nA. PACKAGE INTEGRITY")
r = subprocess.run([sys.executable, os.path.join(ROOT, "code",
                                                 "verify_package.py")],
                   capture_output=True, text=True, cwd=ROOT)
ok = ("Configured integrity checks passed" in r.stdout
      or "Package verified" in r.stdout)
chk("A", "verify_package.py", ok,
    r.stdout.strip().splitlines()[-3] if r.stdout else (r.stderr.strip()[:80]
                                                       or "no output"))
# A silent crash leaves stdout empty; an empty failure list must not read as
# success, so the structural check is conditioned on the run having succeeded.
fails = [l for l in r.stdout.splitlines() if "[FAIL]" in l]
chk("A", "no structural failures", ok and not fails,
    f"{len(fails)} failure(s)" if ok else "verifier did not run")

# ----------------------------------------------------------------- B
print("\nB. NUMERICAL CONSISTENCY")
env = dict(os.environ)
r = subprocess.run([sys.executable, os.path.join(ROOT, "code", "audit_numbers.py")],
                   capture_output=True, text=True, env=env, cwd=os.path.join(ROOT, "code"))
m = re.search(r"(\d+) checks \| (\d+) OK \| (\d+) requiring attention", r.stdout)
if not m and r.stderr:
    print("   audit_numbers.py stderr:", r.stderr.strip().splitlines()[-1][:90])
if m:
    total, ok_n, bad = (int(x) for x in m.groups())
    chk("B", "all numeric checks pass", bad == 0, f"{ok_n}/{total} OK")
    skipped = len(re.findall(r"^   - ", r.stdout, flags=re.M))
    chk("B", "checks skipped for missing external data", True,
        f"{skipped} skipped" if skipped else "none skipped")
else:
    chk("B", "audit_numbers.py ran", False, "could not parse output")

# ----------------------------------------------------------------- C
# build_esm1.py creates manuscript/ to write its PDF into, so the directory
# existing does not mean the markdown sources are there. Test for the files.
_SOURCES = ("00_front_matter.md", "00_title_page_jme.md", "01_introduction.md",
            "02_methods.md", "03_results.md", "04_discussion.md",
            "05_references.md")
HAS_MS = all(os.path.isfile(os.path.join(MS, f)) for f in _SOURCES)
if not HAS_MS:
    print("\nC/C2/D. SKIPPED - manuscript sources are not part of the public")
    print("       reproduction package; run this audit from the full package.")
    print("\n" + "=" * 74)
    bad = [r for r in results if not r[2]]
    print(f"{len(results)} automated checks | {len(results)-len(bad)} PASS "
          f"| {len(bad)} FAIL")
    raise SystemExit(1 if bad else 0)

print("\nC. JME COMPLIANCE")
fm = read("00_front_matter.md")
tp = read("00_title_page_jme.md")
intro = read("01_introduction.md")
methods = read("02_methods.md")
res = read("03_results.md")
disc = read("04_discussion.md")
refs = read("05_references.md")
ai = read("02_methods.md")   # Springer: LLM use documented in Methods
# Online Resource 1 is now built from esm1.txt at the package root rather
# than from a markdown source under manuscript/.
_supp_path = os.path.join(ROOT, "esm1.txt")
supp = (open(_supp_path, encoding="utf8").read()
        if os.path.isfile(_supp_path) else "")

title = ("The standard genetic code's rank among randomized alternatives is "
         "not a property of the code")
chk("C", "title <= 150 characters", len(title) <= 150, f"{len(title)}")


def between(text, a, b):
    i = text.index(a) + len(a)
    j = text.index(b, i)
    return text[i:j]


abstract = strip_md(between(fm, "## Abstract", "## Keywords"))
signif = ""
chk("C", "abstract <= 250 words", words(abstract) <= 250, f"{words(abstract)}")
chk("C", "abstract >= 150 words (JME minimum)", words(abstract) >= 150, f"{words(abstract)}")

body = sum(words(strip_md(t)) for t in
           (intro, res, disc, methods))
chk("C", "body text <= 10,000 words", body <= 10000, f"{body}")

n_ref = len(re.findall(r"^[A-ZÀ-Þ][A-Za-zÀ-ÿ'\-]+ [A-Z]{1,3}[,.]", refs, flags=re.M))
chk("C", "references <= 100", n_ref <= 100, f"{n_ref} entries")
chk("C", "no [VERIFY] markers remain", "[VERIFY]" not in refs)

figs = [f for f in os.listdir(os.path.join(ROOT, "figures")) if f.endswith(".pdf")]
n_tables_main = len(re.findall(r"^\| ", res, flags=re.M)) > 0
chk("C", "figures + main tables <= 8", len(figs) <= 8, f"{len(figs)} figures")

try:
    _doc = zipfile.ZipFile(os.path.join(OUT, "manuscript_jme.docx")) \
        .read("word/document.xml").decode("utf8", "replace")
    _txt = re.sub(r"<[^>]+>", "", _doc)
except Exception:
    _doc = _txt = ""

for n in (1, 2, 3, 4):
    chk("C", f"Figure {n} cited in Results", f"**Figure {n}**" in res)
    chk("C", f"Fig {n} caption present",
        f"Fig. {n}" in _txt)

# Alt text lives in the figure captions, which the builder holds, so it is
# counted in the built .docx rather than in the markdown sources.
_dx = os.path.join(MS, "manuscript_jme.docx")
if os.path.isfile(_dx):
    _xml = zipfile.ZipFile(_dx).read("word/document.xml").decode("utf8")
    # Alt text moved from the printed caption into the image metadata, which
    # is where a screen reader looks for it.
    alt = len([d for d in re.findall(r'descr="([^"]*)"', _xml) if d.strip()])
else:
    alt = 0
chk("C", "alt text for every figure", alt == 5, f"{alt}/5")

chk("C", "data availability on title page", "Data availability" in tp)
chk("C", "code availability declared",
    "Code availability" in tp or "Code availability" in refs)
chk("C", "funding statement present", "Funding" in tp)
chk("C", "competing interests present", "Competing interests" in tp)
chk("C", "author contributions present", "Author contributions" in tp)
# The LLM statement moved from Methods to Statements and Declarations, which
# is where Springer publishes it; the check follows it there.
chk("C", "LLM use documented (Springer requirement)",
    bool(re.search(r"AI assistant \(Claude,\s+Anthropic\)",
                   flat(ai) + flat(read("05_references.md")))))

chk("C", "both ORCIDs on title page",
    "0009-0002-1417-0062" in tp and "0009-0000-9076-6410" in tp)
chk("C", "corresponding author marked", "Corresponding author" in tp)

kw = fm[fm.index("## Keywords"):].lower()
title_words = {w for w in re.findall(r"[a-z]{5,}", title.lower())}
clash = sorted(w for w in title_words if w in kw)
chk("C", "keywords do not repeat title words", not clash,
    ", ".join(clash) if clash else "")
nkw = kw.count("\u00b7") + 1
chk("C", "keywords between 4 and 6 (JME)", 4 <= nkw <= 6, str(nkw))

# section order inside the built docx
try:
    z = zipfile.ZipFile(os.path.join(OUT, "manuscript_jme.docx"))
    xml = z.read("word/document.xml").decode("utf8")
    # Headings only: the words also occur in running text — "the Limitations
    # in the Discussion" appears in Methods — and matching those puts the
    # sections in the wrong order.
    # Headings at either level: Statements and Declarations and References are
    # set as Heading 2 by the builder, and the words also occur in running
    # text, so matching the body would put the sections in the wrong order.
    txt = "\n".join(
        re.sub(r"<[^>]+>", "", p_)
        for p_ in re.findall(r"<w:p[ >].*?</w:p>", xml, re.S)
        if "Heading1" in p_ or "Heading2" in p_)
    # Figure captions sit beneath each figure rather than in a closing list,
    # so the section no longer exists.
    order = ["Abstract", "Keywords", "Introduction", "Materials and Methods",
             "Results", "Discussion", "Statements and Declarations",
             "References"]
    pos = [txt.find(h) for h in order]
    chk("C", "JME section order in built .docx",
        all(p >= 0 for p in pos) and pos == sorted(pos))
    chk("C", "five figures embedded in .docx",
        len([n for n in z.namelist()
             if n.startswith("word/media/") and n.endswith(".png")]) == 5)
except Exception as exc:                                        # noqa: BLE001
    chk("C", "built .docx readable", False, str(exc))

# ----------------------------------------------------------------- C2
print("\nC2. RENDERED OUTPUT")
# Checking the Markdown sources is not enough: figure captions and the title
# block are generated inside the builders, so a withdrawn phrase can survive
# there and reach the PDF while every source-level check passes. These checks
# read the rendered document.
WITHDRAWN = ["realized advantage", "realised", "capture most of",
             "See the title page", "orthogonal hydropathy", "pre-registered",
             "genuine between-lineage", "99.7% of the observed",
             "the pattern expected", "57 checks", "three further checks",
             "Freeland & Hurst", "Kyte--Doolittle", "0.628", "1 random code"]
for label, pdf in [("manuscript", "manuscript_jme.pdf"),
                   ("title page", "title_page_jme.pdf"),
                   ("ESM_1", "ESM_1.pdf")]:
    path = os.path.join(OUT, pdf)
    if not os.path.isfile(path):
        chk("C2", f"{label} PDF present", False)
        continue
    txt = subprocess.run(["pdftotext", path, "-"], capture_output=True,
                         text=True).stdout
    chk("C2", f"{label}: no literal markdown", "**" not in txt and "`" not in txt)
    chk("C2", f"{label}: no leaked editorial notes",
        not re.search(r"limit is \d|Suggested placement|To appear in the "
                      r"endmatter|none repeating a word", txt))
    rows = subprocess.run(["pdffonts", path], capture_output=True,
                          text=True).stdout.strip().split("\n")[2:]
    emb = [r.split()[-4] for r in rows if len(r.split()) >= 4]
    chk("C2", f"{label}: fonts embedded",
        bool(emb) and all(e == "yes" for e in emb))
    if label == "manuscript":
        # Monospaced display blocks must keep their line structure: a long
        # single line is aligned with spaces at one page measure and will not
        # survive typesetting at another.
        _x = zipfile.ZipFile(os.path.join(OUT, "manuscript_jme.docx")) \
            .read("word/document.xml").decode("utf8", "replace")
        _ps = re.findall(r"<w:p>.*?</w:p>", _x, re.S)
        _long = 0
        for _p in _ps:
            _rs = re.findall(r"<w:r>(.*?)</w:r>", _p, re.S)
            if _rs and all("Courier New" in _r for _r in _rs):
                _t = "".join(re.findall(r"<w:t[^>]*>([^<]*)</w:t>", _p))
                if len(_t) > 90:
                    _long += 1
        chk("C2", "no over-long monospaced display lines", _long == 0,
            f"{_long} line(s) over 90 characters")
        _omml = _x.count("<m:oMath")
        # Equations are Unicode display blocks, not OMML: the OMML this
        # pipeline produced did not render reliably in Word, and a block
        # that renders everywhere is worth more at submission than one
        # that is native but blank on the editor's screen. The check is
        # that the blocks are present and legible, not that they are OMML.
        chk("C2", "equations present as display blocks", _omml == 0,
            f"{_omml} OMML object(s); display blocks in use")
        # Numbers appear both on the OMML equations and on the monospaced
        # display blocks, so the test is that the full set is 1..n with no
        # repeats rather than that the OMML ones alone are consecutive.
        # Equation numbers appear at the end of a display block, so they are
        # taken in document order and must run 1..n with no repeat. Notation
        # like GF(2) is excluded by requiring the number to close its block.
        _txt = re.sub(r"<[^>]+>", "", re.sub(r"</w:p>", "\n", _x))
        _nums = [m.group(1) for m in
                 re.finditer(r"\((\d)\)\s*(?:\n|$)", _txt)]
        chk("C2", "equations numbered sequentially",
            _nums == [str(i) for i in range(1, len(_nums) + 1)],
            ", ".join(_nums))

    if label in ("manuscript", "title page"):
        for bad in WITHDRAWN:
            chk("C2", f"withdrawn in rendered PDF: '{bad[:24]}'",
                bad.lower() not in txt.lower())

# ----------------------------------------------------------------- D
print("\nD. CROSS-DOCUMENT CONSISTENCY")
pairs = [
    ("4,972", "confirmatory species count"),
    ("26.5", "range of R"),
    ("0.285%", "counting-error fraction"),
    ("0.992", "Pagel lambda"),
    ("3,455", "delta BIC"),
        # The three out-of-clade aggregates of the previous version are no longer
    # quoted in the main text; §5 reports the decomposition and points to
    # Online Resource 1 for the per-fold figures.
]
allmain = fm + intro + res + disc + methods
for token, label in pairs:
    inmain = token in allmain
    chk("D", f"{label} ({token}) appears in main text", inmain)

supp_only_refs = re.findall(r"\(([A-Z][a-z]+ (?:and|&) [A-Z][a-z]+ \d{4})\)", supp)
chk("D", "no reference cited only in supplementary", not supp_only_refs,
    ", ".join(set(supp_only_refs)) if supp_only_refs else "")

for token in ("+0.0697", "0.02877", "12.4%"):
    chk("D", f"supplementary value {token} traceable", token in supp)

sha = "d35d209a380b3a9db11ac809f0daa120c0ee91b9dbb23299c910aa2e906763e0"
chk("D", "ensemble checksum stated in supplementary", sha in supp)

# Every item of Online Resource 1 must be cited somewhere in the main text.
# Line breaks fall inside citations such as "Tables S8\nand S14", so the text
# is flattened before the supplementary items are looked for.
# Statements and Declarations is part of the article and cites Notes S1-S3,
# which moved there with the protocol and software statements.
_decl = read("05_references.md")
main = re.sub(r"\s+", " ", intro + methods + res + disc + fm + _decl)
def _cited(item, text):
    """Items are cited as "Table S4" and as "Tables S2 and S12" alike."""
    kind, n = item.split(" S")[0], item.split(" S")[1]
    return bool(re.search(rf"\b{kind}s? S{n}\b", text)
                or re.search(rf"{kind}s? S\d+ and S{n}\b", text))


for item in ([f"Table S{i}" for i in range(1, 16)] +
             [f"Note S{i}" for i in range(1, 4)]):
    chk("D", f"{item} cited in the main text", _cited(item, main))

# placeholders
ph = []
for name in os.listdir(MS):
    if not name.endswith(".md"):
        continue
    for m2 in re.finditer(r"\[([A-Z][A-Z \-—]+)\]", read(name)):
        ph.append(f"{name}: [{m2.group(1)}]")
chk("D", "no unresolved placeholders", not ph, f"{len(ph)} found")
for pat, why in [(r"pre-?registered", "superseded by 'prospectively specified'"),
                 (r"orthogonal\s+hydropathy", "metrics are correlated"),
                 (r"99\.7\s*%[^.]{0,80}(biological|genuine)", "overclaim"),
                 (r"genuine\s+between-lineage", "overclaim"),
                 (r"actually\s+realiz", "estimand uses a reference exposure"),
                 (r"\bminimis", "Springer house style: -ize spelling")]:
    hits = [n for n in os.listdir(MS)
            if n.endswith(".md") and re.search(pat, read(n), re.I)]
    chk("D", f"withdrawn phrasing absent: /{pat[:28]}/", not hits,
        ", ".join(hits) if hits else why)

# ----------------------------------------------------------------- summary
print("\n" + "=" * 74)
bad = [r for r in results if not r[2]]
print(f"{len(results)} automated checks | {len(results)-len(bad)} PASS | {len(bad)} FAIL")
if bad:
    print("\nFAILURES:")
    for _, label, _, detail in bad:
        print(f"   - {label}" + (f"  ({detail})" if detail else ""))
if ph:
    print("\nPLACEHOLDERS TO FILL:")
    for p in sorted(set(ph)):
        print(f"   - {p}")

print("""
HUMAN CHECKS REQUIRED — not automatable
   1. Author order agreed with co-author; changes are not accepted after
      acceptance, so settle it before submitting
   2. Both authors have read and approved the final manuscript
   3. Upload the reproduction package as Online Resource 2 (ESM_2.zip) — no
      external deposit is needed; JME publishes it with the article
   4. Whether the methodological reviews used AI — if so, extend the disclosure
      in Materials and Methods §11, which is the single place it now appears,
      and mirror it in the submission interface
   5. Check whether the CORAL preprint has appeared in a journal
   6. Telephone for the corresponding-author line, or omit the field
   7. Fonts are embedded in the PDFs built here; re-check if you re-export
      from Word rather than using these files
   8. Confirm page breaks do not separate a figure from the text that cites it
   9. At acceptance, choose the subscription route — JME is hybrid and the
      subscription option carries no article processing charge
""")
sys.exit(1 if bad else 0)
