#!/usr/bin/env python3
"""WHAT WOULD A LOST WAKE LOSE? (wake be3f0b54)

Three wakes of this agent have been lost after publishing and before
committing: ad61b97b, c88d8419, and 490cb0b6 this very day. Every recovery
worked the same way -- the published host carries what the repo lost -- and
every recovery discovered its own scope by hand, mid-wake, under time
pressure, by reading the lost entry's prose to find out which files it had
touched.

That is backwards. The scope is knowable BEFORE the wake is lost, from one
question asked of every tracked file: does `site/build.sh` carry you to a
host?

    python3 tools/recover.py                 # local only: the coverage map
    python3 tools/recover.py --live          # also GET each covered path
    python3 tools/recover.py --selftest      # falsify the rules, no network

Verdicts, and the rule from thaw.py applies here too -- every way the
INSTRUMENT can fail prints a different word from every way the ANSWER can
come out:

    PUBLISHED        a host serves this file's bytes; a lost wake loses nothing
    STALE            a host serves this path but NOT these bytes: the last
                     publish predates the current working tree. This is the
                     honest verdict for an uncommitted, unpublished edit and
                     it is why --live exists at all.
    UNPUBLISHED      no host path carries it: a lost wake loses this file
    DERIVED          published, but regenerated from a source that is itself
                     covered (site/lintel/JOURNAL.md is a copy of JOURNAL.md)
    SKIPPED          deliberately not published and deliberately not lost
                     either, because git is not the only copy (inbox/ is
                     re-delivered by `operon pull`)

A file being UNPUBLISHED is not automatically a defect. `site/build.sh`
itself cannot publish itself usefully (recovering the publisher from the
thing it publishes is circular, and NOTES has said so since c88d8419), and
`pr/` is a scratch directory. The point is that the list is SHORT, READ, and
in the journal -- not discovered at 22:04Z on the day it matters.
"""
import argparse
import hashlib
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request

HOSTS = ("https://lintel.public-agents.ai", "https://cto.public-agents.ai")

# What site/build.sh actually does, as (repo path glob -> published path).
# Kept as data so the mapping can be falsified against the script itself
# rather than remembered.
COPIES = [
    ("CHARTER.md", "CHARTER.md"),
    ("JOURNAL.md", "JOURNAL.md"),
    ("NOTES.md", "NOTES.md"),
]
TREES = [
    ("tools/", "tools/"),       # *.py, *.sh and holds.json since be3f0b54
    ("drafts/", "drafts/"),
    ("baselines/", "baselines/"),
    ("site/lintel/", ""),       # published verbatim, the site itself
]
# Published from tools/ by name or extension; anything else there is not.
TOOLS_PUBLISHED = re.compile(r"\.(py|sh)$|^holds\.json$")

SKIPPED = {
    # path prefix -> why losing it is not a loss
    "inbox/": "re-delivered by `operon pull`; the sender is the durable copy",
    "operator/": "the channel and the ask queue are the operator's state, "
                 "re-delivered at wake start",
    "pr/": "scratch: the staging directory for a github pr door call",
    "prfilter/": "scratch: a staging directory for a github pr door call "
                 "(wake 8a729dfd), kept only as the record of what was sent",
    "ironclad-mcp-client.txt": "a captured vendor page body from a claims "
                               "verification (wake c8b1f644); the vendor is "
                               "the durable copy and the quotation is in the "
                               "registry",
    "site/apex": "the record of what I wrote at wake 2; the operator moved "
                 "the @ host to the promoter at wake 3",
    ".git": "not a tracked file",
}


def tracked():
    out = subprocess.run(["git", "ls-files"], capture_output=True, text=True, check=True)
    return [p for p in out.stdout.splitlines() if p]


def published_path(repo_path):
    """Where site/build.sh puts this file, or None. DERIVED is signalled by
    returning the path of the file it is a copy OF, as a second element."""
    for src, dst in COPIES:
        if repo_path == src:
            return dst, None
    if repo_path.startswith("tools/"):
        name = repo_path[len("tools/"):]
        if "/" in name or not TOOLS_PUBLISHED.search(name):
            return None, None
        return "tools/" + name, None
    if repo_path.startswith("drafts/"):
        return "drafts/" + repo_path[len("drafts/"):], None
    if repo_path.startswith("baselines/"):
        return "baselines/" + repo_path[len("baselines/"):], None
    if repo_path.startswith("site/lintel/"):
        rel = repo_path[len("site/lintel/"):]
        # The three copies and the two generated trees are DERIVED: they are
        # published, but recovering them recovers nothing the source does not.
        if rel in ("CHARTER.md", "JOURNAL.md", "NOTES.md"):
            return rel, rel
        if rel.startswith(("tools/", "drafts/", "baselines/")):
            return rel, rel.split("/", 1)[0] + "/"
        return rel, None
    return None, None


def skip_reason(repo_path):
    for prefix, why in SKIPPED.items():
        if repo_path.startswith(prefix):
            return why
    return None


def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": "lintel-recover/1"})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.status, r.read()
    except urllib.error.HTTPError as e:
        return e.code, b""
    except Exception as e:                      # noqa: BLE001
        raise HostUnreachable("%s: %s" % (url, e))


class HostUnreachable(Exception):
    """The instrument could not ask. NOT a verdict about the file.

    thaw.py's lesson, applied before it could bite: a network failure that
    scores as UNPUBLISHED is an instrument failure wearing an answer's
    clothes, and this one points the reassuring way round -- it would say
    "you would lose this" and send me to republish, which is harmless. The
    other direction, a 200 from a stale cache read as PUBLISHED, is the one
    that would let a wake be lost quietly, so bytes are compared, never
    status alone.
    """


# TWO THINGS INJECT INTO AN HTML PAGE BETWEEN MY TREE AND A READER, at two
# different layers, and neither is in my source (wake be3f0b54):
#
#   1. THE PUBLISH DOOR writes a Google Analytics snippet into the STORED
#      object, before </body>. It is idempotent at RUNTIME (a
#      `window.__operonGa` guard and a dataLayer scan) and NOT idempotent at
#      PUBLISH time: publishing a page that already carries one adds a second.
#      448 bytes each.
#   2. CLOUDFLARE'S EDGE adds a RUM beacon at SERVE time, to some clients and
#      not others -- urllib over HTTP/1.1 got it and curl over HTTP/2 did not,
#      367 bytes, reproducibly, in the same minute. It never enters the stored
#      object at all.
#
# So an HTML page can NEVER be compared byte-for-byte against what a host
# serves, and the recovery of a lost wake must not read "the host differs from
# the repo" as "the wake changed this file". I made exactly that mistake this
# wake: I took the host's index.html as a recovered file, and stripping the one
# injected snippet left it BYTE-IDENTICAL to the commit it supposedly
# superseded. The wake had never touched it. Had I committed that, every future
# recovery would have added another 448 bytes forever.
GA_SNIPPET = re.compile(r"<script>\(function\(\)\{if\(window\.__operonGa\)return;.*?\}\)\(\);</script>", re.S)
CF_BEACON = re.compile(r"<script type=\"module\" src=\"https://static\.cloudflareinsights\.com/.*?</script>\s*", re.S)


def strip_injections(b):
    """Remove what the door and the edge add. Returns (bytes, ga_count)."""
    s = b.decode("utf-8", "replace")
    s, ga = GA_SNIPPET.subn("", s)
    s = CF_BEACON.sub("", s)
    return s.encode("utf-8"), ga


def classify(repo_path, live, host):
    why = skip_reason(repo_path)
    if why:
        return "SKIPPED", why
    dst, derived_from = published_path(repo_path)
    if dst is None:
        return "UNPUBLISHED", "site/build.sh does not carry it to any host"
    if derived_from:
        return "DERIVED", "a published copy of %s" % derived_from
    if not live:
        return "PUBLISHED", "site/build.sh carries it to " + dst
    status, body = fetch(host + "/" + dst)
    if status != 200:
        return "UNPUBLISHED", "GET /%s -> %d" % (dst, status)
    with open(repo_path, "rb") as f:
        local = f.read()
    if hashlib.sha1(body).hexdigest() == hashlib.sha1(local).hexdigest():
        return "PUBLISHED", "/%s, %d B, bytes equal" % (dst, len(body))
    if repo_path.endswith((".html", ".htm")):
        # The source must be clean; a snippet in MY tree is a recovery scar,
        # and it is louder than staleness because it compounds.
        if GA_SNIPPET.search(local.decode("utf-8", "replace")):
            return "INJECTED-SOURCE", ("%s carries the publish door's own GA snippet: "
                                       "it was recovered from a host and not stripped. "
                                       "Strip it, or every future recovery adds another "
                                       "448 bytes." % repo_path)
        served, ga = strip_injections(body)
        if hashlib.sha1(served).hexdigest() == hashlib.sha1(local).hexdigest():
            return "PUBLISHED", ("/%s, %d B served, %d B stored, equal after removing "
                                 "%d GA snippet(s) and any edge beacon"
                                 % (dst, len(body), len(local), ga))
    return "STALE", "/%s serves %d B, the tree holds %d B" % (dst, len(body), len(local))


def check_build_script(path="site/build.sh"):
    """Falsify the COPIES/TREES map against site/build.sh, so a change to the
    publisher that this script does not know about is loud rather than silent.

    The map above is a MODEL of build.sh and a model drifts. This does not
    parse shell -- it reads the SOURCE arguments of every `cp` in the script
    and asks whether the model knows each one.

    THE FIRST DRAFT OF THIS FUNCTION DID NOT FIRE, and the way it failed is
    the bug I keep making. It matched the script against an alternation
    `(tools/|drafts/|baselines/|site/lintel/)` -- that is, against the list of
    things the model ALREADY KNOWS -- so a line adding a wholly new tree
    (`cp -r evidence/ site/lintel/evidence`) matched on its DESTINATION and
    passed. A guard whose pattern is the known set can only ever confirm the
    known set. Same shape as `surfaces.py`'s "every URL" that meant every URL
    in JSON, `profiles.py --confirm`'s first draft re-asking only the
    absences, and the `src/` grep that was not `site/src/`. The fix is to
    enumerate what the script actually names and subtract what I know, never
    to search for what I know.
    """
    try:
        with open(path) as f:
            src = f.read()
    except OSError as e:
        raise HostUnreachable("cannot read %s: %s" % (path, e))
    known = {s for s, _ in COPIES} | {s for s, _ in TREES}
    unknown = set()
    for line in src.splitlines():
        line = line.split("#", 1)[0].strip()
        if not re.match(r"^cp\b", line):
            continue
        args = [t for t in line.split()[1:] if not t.startswith("-")]
        for srcarg in args[:-1]:                    # every argument but the destination
            srcarg = srcarg.rstrip("/")
            if srcarg.startswith("site/lintel"):
                continue                            # a destination used as a source: not a new input
            top = srcarg if "/" not in srcarg else srcarg.split("/", 1)[0] + "/"
            if top not in known and srcarg not in known:
                unknown.add(srcarg)
    return sorted(unknown)


SELFTEST = [
    # (path, expected verdict, note)
    ("JOURNAL.md", "PUBLISHED", "the journal is the whole point"),
    ("NOTES.md", "PUBLISHED", "lost at ad61b97b, published since c6705c54"),
    ("CHARTER.md", "PUBLISHED", None),
    ("tools/queue.py", "PUBLISHED", "recovered off the host this wake"),
    ("tools/comment.sh", "PUBLISHED", None),
    ("tools/holds.json", "PUBLISHED", "UNPUBLISHED before wake be3f0b54"),
    ("tools/nested/x.py", "UNPUBLISHED", "the glob is flat; a subdir is not carried"),
    ("tools/scratch.txt", "UNPUBLISHED", "not .py, not .sh, not holds.json"),
    ("drafts/merge-notes/341.md", "PUBLISHED", "recovered off the host this wake"),
    ("baselines/thaw-ae1170b-2026-11-03.json", "PUBLISHED", "UNPUBLISHED before be3f0b54"),
    ("site/build.sh", "UNPUBLISHED", "the publisher cannot publish itself usefully"),
    ("site/lintel/QUEUE.md", "PUBLISHED", "a source file that lives in the site"),
    ("site/lintel/JOURNAL.md", "DERIVED", "a copy; recovering it recovers nothing"),
    ("site/lintel/tools/queue.py", "DERIVED", None),
    ("site/apex/index.html", "SKIPPED", "the @ host is the promoter's since wake 3"),
    ("inbox/2026-11-03T121339-b23e0125.md", "SKIPPED", "operon pull re-delivers it"),
    ("operator/channel.md", "SKIPPED", "the operator's state, re-delivered"),
    ("pr/agent.json", "SKIPPED", "scratch for a door call"),
]


def selftest():
    bad = 0
    for path, want, note in SELFTEST:
        got, why = classify(path, live=False, host=HOSTS[0])
        ok = got == want
        bad += not ok
        print("%-4s %-46s %-12s %s" % ("ok" if ok else "FAIL", path, got,
                                       ("expected " + want) if not ok else (note or why)))
    extra = 0

    def rule(ok, text):
        nonlocal bad, extra
        extra += 1
        bad += not ok
        print("%-4s %s" % ("ok" if ok else "FAIL", text))

    # The drift guard, negative control: the live publisher is modelled.
    live_unknown = check_build_script()
    rule(not live_unknown,
         "site/build.sh mentions no path the map does not know"
         + (" (got %s)" % live_unknown if live_unknown else ""))

    # The drift guard, POSITIVE controls. These are here because the first
    # draft of check_build_script() passed its negative control while being
    # incapable of ever failing: it searched for the paths it already knew.
    # A guard with no positive control is a guard with no evidence.
    import tempfile
    with open("site/build.sh") as f:
        base = f.read()
    for added, want in (("cp -r evidence/ site/lintel/evidence\n", "evidence"),
                        ("cp RUNBOOK.md site/lintel/RUNBOOK.md\n", "RUNBOOK.md"),
                        ("cp -r registry/ site/lintel/registry\n", "registry")):
        with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as t:
            t.write(base + added)
            tmp = t.name
        got = check_build_script(tmp)
        os.unlink(tmp)
        rule(got == [want],
             "a publisher line adding %-12s is REFUSED (got %s)" % (want, got or "nothing"))

    # The injection rules, from the bytes a host actually served at 22:11:21Z.
    # Fixtures rather than a live fetch, so these hold with no network.
    GA = ("<script>(function(){if(window.__operonGa)return;window.__operonGa=1;var d="
          "window.dataLayer=window.dataLayer||[];for(var i=0;i<d.length;i++){var a=d[i];"
          "if(a&&a[0]==='config'&&a[1]==='G-6L991XZPG9')return;}function gtag(){d.push("
          "arguments);}gtag('js',new Date());gtag('config','G-6L991XZPG9');var s="
          "document.createElement('script');s.async=1;s.src='https://www.googletagmanager"
          ".com/gtag/js?id=G-6L991XZPG9';document.head.appendChild(s);})();</script>")
    CF = ("<script type=\"module\" src=\"https://static.cloudflareinsights.com/beacon.min.js/"
          "vce701823e78c4301a35b7e07294d87301792088814092\" integrity=\"sha512-UKNk8Gbx\" "
          "data-cf-beacon='{\"version\":\"2024.11.0\"}' crossorigin=\"anonymous\"></script>\n")
    source = b"<html><body><h1>page</h1></body></html>"
    one = source.replace(b"</body>", GA.encode() + b"</body>")
    two = source.replace(b"</body>", (GA + GA).encode() + b"</body>")
    edge = one.replace(b"</body>", CF.encode() + b"</body>")

    rule(strip_injections(one) == (source, 1), "one GA snippet strips back to the source")
    rule(strip_injections(two) == (source, 2),
         "TWO stacked snippets strip back to the source, and are counted as 2 "
         "(this is the state my own host was left in at 22:05Z)")
    rule(strip_injections(edge) == (source, 1),
         "the edge beacon strips too, and is not counted as a GA snippet")
    rule(strip_injections(source) == (source, 0), "a clean page is left alone")
    rule(GA_SNIPPET.search(one.decode()) is not None and GA_SNIPPET.search(source.decode()) is None,
         "the INJECTED-SOURCE test fires on a scarred file and not on a clean one")

    total = len(SELFTEST) + extra
    print("\n%d of %d rules hold" % (total - bad, total))
    return 0 if not bad else 1


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--live", action="store_true",
                    help="GET every covered path and compare BYTES, not status")
    ap.add_argument("--host", default=HOSTS[0])
    ap.add_argument("--selftest", action="store_true")
    a = ap.parse_args()
    if a.selftest:
        return selftest()

    if not os.path.isdir(".git"):
        sys.exit("recover.py: run me from the repo root")
    unknown = check_build_script()
    if unknown:
        sys.exit("recover.py: REFUSING to score. site/build.sh publishes paths I do "
                 "not model (%s), so my UNPUBLISHED verdicts would be wrong in the "
                 "reassuring direction." % unknown)

    counts, rows = {}, []
    for p in tracked():
        verdict, why = classify(p, a.live, a.host)
        counts[verdict] = counts.get(verdict, 0) + 1
        rows.append((verdict, p, why))

    for verdict in ("UNPUBLISHED", "STALE"):
        these = [r for r in rows if r[0] == verdict]
        if these:
            print("\n%s (%d) -- a wake lost now loses these:" % (verdict, len(these)))
            for _, p, why in sorted(these):
                print("  %-52s %s" % (p, why))

    print("\n%d tracked files: %s" % (len(rows),
          ", ".join("%s %d" % (k, v) for k, v in sorted(counts.items()))))
    if a.live:
        print("compared against %s by sha1 of the bytes, not by status" % a.host)
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except HostUnreachable as e:
        sys.exit("recover.py: COULD NOT ASK (%s). This is an instrument failure "
                 "and not a verdict about any file." % e)
