#!/usr/bin/env python3
"""Do the repo paths cited in `docs/port/*.md` actually resolve?

    tools/port/check-citations              # assert
    tools/port/check-citations --selftest   # can it fail?

`audit-kinds` checks citations in `authored/`. Nothing checked the PROSE, and
prose is where this port explains itself. A first run found **37 of 91**
non-resolving, 41 %, in two very different classes:

  * **19 on the Decoder's topic branch** — real files, not merged here. Not
    errors. A reader in this checkout still cannot follow them, which is worth
    reporting and not worth failing on; the fix is a merge, not an edit.
  * **7 that resolve NOWHERE** — `docs/BLOCKED.md`, `docs/FORMAT.md`,
    `port/manifest.json`, `port/screens/title/*.json`. Left behind by the
    monorepo move and the `export/` rename. Those are simply wrong: a reader
    following one gets nothing, and nothing had ever told anyone.

So the two classes are separated and only the second fails. A check that failed
on the first would be red for a state nobody in this container can fix, which is
the shape the display guard exists to prevent.

⚠️ THE PEER-BRANCH CLASS IS THE OTHER AGENT'S POINT, TURNED ON MYSELF. They
observed that everything they hand over links into `docs/re/` files that live
only on their branch, so every link they send is dangling from here. The same is
true in reverse and neither of us was counting.
"""
import os
import re
import subprocess
import sys
import glob

# A repo path with a file extension, optionally in backticks or a markdown link.
CITE = re.compile(
    r"`?((?:docs|crates|port|tools|authored|export)/[\w./-]+"
    r"\.(?:md|rs|gd|json|txt|py|tsv|csv))`?"
)
# ⚠️ `origin/auto/frame-blend-draw-path` used to head this list and NO LONGER
# EXISTS -- the consolidation merged it and the branch was removed. The class is
# kept because it is about the workflow, not about that one branch: the next
# topic branch that lands a finding recreates the condition exactly. What must
# not happen again is the selftest asserting the class against a fixture path
# that has since become an ordinary local file, which is how it came to print
# 🔴 BROKEN on a correct checkout. It now finds its own fixture, or says the
# condition does not exist here.
PEER_REFS = ("origin/main",)


def a_peer_only_path() -> str | None:
    """A path carried by a PEER_REF but absent from this working tree.

    The selftest needs a REAL one: a hardcoded fixture silently stops testing
    the moment that file lands locally, and then reports the scanner broken
    instead of itself. On a clean, up-to-date checkout there is usually no such
    path at all -- which is not a failure, it is the class being empty here.
    """
    for ref in PEER_REFS:
        out = subprocess.run(["git", "ls-tree", "-r", "--name-only", ref],
                             capture_output=True, text=True)
        for f in out.stdout.splitlines():
            if f.endswith((".md", ".rs", ".gd", ".json", ".txt", ".py",
                           ".tsv", ".csv")) and not os.path.exists(f):
                return f
    return None


def gitignored(path: str) -> bool:
    """Is this path deliberately untracked? Pattern match -- existence not needed."""
    return subprocess.run(["git", "check-ignore", "-q", path],
                          capture_output=True).returncode == 0


def unbuilt(path: str) -> bool:
    """A citation of GENERATED output whose tree has not been built here.

    🔴 THE THIRD ABSENCE, AND IT IS NOT AN ERROR. `export/` is the exporter's
    output and is gitignored (`.gitignore` `/export*/`). A checkout where nobody
    has run the exporter has no `export/` at all, so the four `DECISIONS.md` and
    `BLOCKED.md` citations of `export/manifest.json` and `export/screens/...`
    were counted as "resolve NOWHERE" and this check was red on a clean tree --
    for a state no edit can fix, which is precisely the shape its own docstring
    says it exists to avoid.

    `check-capture-citations` already learned this for `docs/re/captures/`.
    Same rule here: absent BECAUSE UNBUILT is reported; absent while the tree
    IS built is a real broken citation and still fails.
    """
    if not gitignored(path):
        return False
    root = path.split("/")[0]
    return not os.path.exists(root)


def on_a_ref(path: str) -> str | None:
    """The first ref that carries `path`, or None."""
    for ref in PEER_REFS:
        if subprocess.run(["git", "cat-file", "-e", f"{ref}:{path}"],
                          capture_output=True).returncode == 0:
            return ref
    return None


def scan(files):
    resolves, peer, nowhere, ungenerated = 0, {}, {}, {}
    for p in files:
        try:
            text = open(p, encoding="utf-8").read()
        except OSError:
            continue
        for m in sorted(set(CITE.findall(text))):
            if os.path.exists(m):
                resolves += 1
            elif unbuilt(m):
                ungenerated.setdefault(m, p)
            elif (ref := on_a_ref(m)):
                peer.setdefault(m, (p, ref))
            else:
                nowhere.setdefault(m, p)
    return resolves, peer, nowhere, ungenerated


def main() -> int:
    if "--selftest" in sys.argv:
        # 🔴 A CHECK THAT CANNOT FAIL IS NOT A CHECK. This plants a citation of a
        # path that exists on no ref and requires the scanner to catch it, and a
        # citation of a real file and requires it NOT to. Both directions,
        # because a scanner that flagged everything would also "pass" the first.
        tmp = os.path.join(os.environ.get("TMPDIR", "/tmp"), "check-citations-selftest")
        os.makedirs(tmp, exist_ok=True)
        bad = os.path.join(tmp, "bad.md")
        open(bad, "w").write("see `docs/port/this-file-does-not-exist-anywhere.md`\n")
        good = os.path.join(tmp, "good.md")
        open(good, "w").write("see `docs/port/PORT-MISSION.md`\n")
        # The THIRD class, which `--for-merge` turns into a failure. It has to be
        # told apart from both others: a peer citation is not dangling (the file
        # exists) and does not resolve here (the reader still gets nothing), and
        # a scanner that collapsed it into either would make the flag meaningless
        # while still passing the two checks above.
        peer_probe = a_peer_only_path()
        peerfile = os.path.join(tmp, "peer.md")
        open(peerfile, "w").write("see `%s`\n" % (peer_probe or "docs/port/PORT-MISSION.md"))
        # The FOURTH class: generated output whose tree is not built here.
        # It has to be told apart from "resolves nowhere", which is the whole
        # point -- a scanner that lumped them together is what made this check
        # red on a clean checkout.
        genfile = os.path.join(tmp, "gen.md")
        open(genfile, "w").write("see `export/screens/title/main_menu.json`\n")

        rp, pp, np_, gp = scan([peerfile])

        _, _, nb, _ = scan([bad])
        r, _, ng, _ = scan([good])
        rg, pg, ng2, gg = scan([genfile])
        caught   = len(nb) == 1
        passed   = len(ng) == 0 and r == 1
        # None == the class is empty in this checkout, not that it is broken.
        peer_ok = (len(pp) == 1 and rp == 0 and len(np_) == 0 and len(gp) == 0) \
                  if peer_probe else None
        # Only meaningful while `export/` is absent; if someone ran the exporter
        # in this checkout the citation legitimately resolves instead.
        gen_ok = (len(gg) == 1 and len(ng2) == 0 and len(pg) == 0) \
                 if not os.path.exists("export") else (rg == 1)
        ok = caught and passed and gen_ok and peer_ok is not False
        print("selftest: planted dangling caught=%s, real citation passed=%s, "
              "peer-branch classed separately=%s, unbuilt-generated classed "
              "separately=%s -> %s"
              % (caught, passed,
                 "n/a (no peer-only path exists here)" if peer_ok is None
                 else peer_ok,
                 gen_ok, "ok" if ok else "🔴 BROKEN"))
        if not gen_ok:
            print("   🔴 a citation of unbuilt generated output must NOT be "
                  "dangling; got resolves=%d peer=%d nowhere=%d ungenerated=%d"
                  % (rg, len(pg), len(ng2), len(gg)))
        if peer_ok is False:
            print("   🔴 --for-merge cannot mean anything if the peer class is "
                  "not distinguished; got resolves=%d peer=%d nowhere=%d"
                  % (rp, len(pp), len(np_)))
        return 0 if ok else 2

    files = sorted(glob.glob("docs/port/*.md"))
    resolves, peer, nowhere, ungenerated = scan(files)
    total = resolves + len(peer) + len(nowhere) + len(ungenerated)
    print("citations of repo paths in docs/port/*.md: %d" % total)
    print("  resolve here                : %d" % resolves)
    # 🔴 --for-merge TURNS THE PEER CLASS INTO A FAILURE.
    #
    # Reporting-not-failing was right when it was written: a peer-branch
    # citation was "a state nobody in this container can fix", so failing on it
    # would have been red for something unactionable. Under the pull-request
    # workflow that stopped being true -- a PR into `main` is EXACTLY where it
    # becomes fixable, by opening the finding's PR first and depending on it.
    # The citation is dead the moment this merges, so the merge is the last
    # place the leniency can still be withdrawn.
    #
    # Left as a flag rather than made unconditional, because both readings are
    # still live: mid-work on a topic branch the peer class really is unfixable
    # noise. The difference the old code could not express is WHERE the code is
    # going, and that is a condition the caller can state.
    merging = "--for-merge" in sys.argv
    label = "🔴 FAILS (--for-merge)" if merging else "reported, not failed"
    print("  on a peer branch, not merged: %d  (%s)" % (len(peer), label))
    for m, (src, ref) in sorted(peer.items()):
        print("      %-52s %s  <- %s" % (m, ref.split("/")[-1], os.path.basename(src)))
    if peer and merging:
        print("\n🔴 %d citation(s) resolve only on a peer branch." % len(peer))
        print("   After this merges they resolve NOWHERE -- the reader gets a dead")
        print("   path. Land the finding first and make it a dependency of this PR.")
        return 1
    if ungenerated:
        print("  not built in this checkout  : %d  (reported, not failed)"
              % len(ungenerated))
        for m, src in sorted(ungenerated.items()):
            print("      %-52s <- %s" % (m, os.path.basename(src)))
        print("      run the exporter and these resolve; a path still missing")
        print("      afterwards IS dangling and fails below.")
    if nowhere:
        print("  🔴 resolve NOWHERE          : %d" % len(nowhere))
        for m, src in sorted(nowhere.items()):
            print("      %-52s <- %s" % (m, os.path.basename(src)))
        print("\n🔴 a reader following those gets nothing. Fix the path or drop the citation.")
        return 1
    print("  🔴 resolve nowhere          : 0")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
