"""Convert TOKEN:-shaped eprintln! sites to the single-write emitter.

Per-site and verified, never a sweep: 200 of the 487 calls span multiple lines,
so a regex over the file would mangle 41% of the population. This edits ONE
macro token per named site, after re-finding it from the census line, and
refuses anything it cannot place exactly.

Three guards, each earned by a real miss:
  * STDOUT rows are skipped BY NAME and printed — the tear surface is the shared
    stderr, and a silent skip is how an exclusion becomes invisible.
  * PHANTOM rows are skipped BY NAME: the census attributes a macro by looking up
    to 4 lines back, and that window can reach an `eprintln!` the literal does not
    belong to (sealverb.rs:456 is a `format!` inside a `map_err`). Containment is
    re-checked here with a balanced-paren span, so a row that is not an emission
    is never edited.
  * ONE MACRO CALL IS CONVERTED ONCE even when it carries two census rows, and an
    already-converted call is recognised rather than refused.
"""
import re
import subprocess
import sys

ROOT = r"C:/Users/decid/Documents/projects/spt-core/.worktrees/emit-single-write"
SITES = r"C:/Users/decid/AppData/Local/Temp/claude/C--Users-decid-Documents-projects-spt-core/43af483b-6d22-46cb-9d99-8b7713a6ff46/scratchpad/cut-4d6007ac/census_sites.txt"
MACRO = re.compile(r"\b(eprintln!|eprint!)\s*\(")
ANY_MACRO_OPEN = re.compile(r"\b(eprintln!|println!|eprint!|print!)\s*\(")
# ANY emitter spelling counts as already-converted, not just the stderr-aimed pair:
# a consumer lane may have converted a site with the writer-taking spelling first
# (hertz did exactly that at autostart.rs), and double-converting it would be a
# silent wrong edit rather than a refusal.
ALREADY = re.compile(r"\b(spt_proto::)?emit_(line|block)(_err)?!\s*\(")

# The BLOCK arm is per-site and named, never inferred: these two emissions are
# multi-line by construction (§2a of the plan), so converting them to emit_line_err!
# would trip the interior-newline debug_assert at runtime instead of carrying the
# text the site exists to carry.
BLOCK_SITES = {
    ("crates/spt-daemon/src/servicehost.rs", "SERVICE_STARTUP_FAULT"),
    ("crates/spt-daemon/src/firewall.rs", "INBOUND_REACHABILITY"),
}

crates = sys.argv[1:]
if not crates:
    sys.exit("usage: convert.py <crate> [crate...]")

rows = [l.split("\t") for l in open(SITES, encoding="utf-8").read().splitlines()]
rows = [r for r in rows if r[0].split("/")[1] in crates]

skipped = [r for r in rows if r[2] in ("println!", "print!")]
for r in skipped:
    print(f"  SKIP (stdout, out of lane): {r[0]}  {r[1]}  {r[2]}")
rows = [r for r in rows if r[2] not in ("println!", "print!")]


def call_span(lines, start):
    """(first, last) line indices of the macro call opening at/after `start`."""
    text = "\n".join(lines[start:start + 80])
    m = ANY_MACRO_OPEN.search(text)
    if not m:
        return None
    i = m.end() - 1
    depth = 0
    instr = False
    esc = False
    for k in range(i, len(text)):
        c = text[k]
        if instr:
            if esc:
                esc = False
            elif c == chr(92):
                esc = True
            elif c == '"':
                instr = False
            continue
        if c == '"':
            instr = True
        elif c == "(":
            depth += 1
        elif c == ")":
            depth -= 1
            if depth == 0:
                return (start, start + text[:k].count("\n"))
    return None


by_file = {}
for loc, tok, mac in rows:
    path, ln = loc.rsplit(":", 1)
    by_file.setdefault(path, []).append((int(ln), tok, mac))

converted = 0
phantom = 0
already = 0
for path, sites in sorted(by_file.items()):
    full = ROOT + "/" + path
    lines = open(full, encoding="utf-8", newline="").read().split("\n")
    done_lines = set()
    # PASS A resolves every target against ONE snapshot, PASS B edits. Resolving
    # while editing shrinks the candidate set mid-pairing: the first conversion of
    # a three-call token leaves two calls against three rows and the pairing then
    # refuses itself.
    resolved = []
    for ln, tok, mac in sorted(sites):
        j = ln - 1
        start = None
        for k in range(j, max(-1, j - 5), -1):
            if MACRO.search(lines[k]) or ALREADY.search(lines[k]):
                start = k
                break
        if start is None:
            # The census line is a HINT from the cut sha, not an identity: a
            # consumer lane may have moved the site since (hertz's autostart
            # rework shifted these by ~19 lines). Re-find by the TOKEN, which is
            # the identity, and accept it only when the file answers UNAMBIGUOUSLY.
            cands = [
                i
                for i, l in enumerate(lines)
                # the DELIMITER is part of the token's identity: a bare prefix test
                # makes "ENDPOINT_AUTOSTART match "ENDPOINT_AUTOSTART_SKIP: as well,
                # which is how a re-find turns into three wrong candidates.
                if '"' + tok + ":" in l and not l.lstrip().startswith("//")
            ]
            unconverted = {}
            already_seen = 0
            for c in cands:
                for k in range(c, max(-1, c - 5), -1):
                    if MACRO.search(lines[k]):
                        unconverted[k] = c
                        break
                    if ALREADY.search(lines[k]):
                        already_seen += 1
                        break
            if not unconverted and already_seen:
                # Every live occurrence already emits through the seam — a consumer
                # lane got here first. Absorb it rather than refusing.
                already += 1
                continue
            if len(unconverted) == 1:
                start, j = next(iter(unconverted.items()))
                print(f"  RE-FOUND by token (line hint stale): {path}:{ln} -> :{start+1}  {tok}")
            elif len(unconverted) == sum(1 for _, t, _ in sites if t == tok):
                # N census rows and N unconverted calls for the SAME token in the
                # SAME file: pair them in source order. The rows were collected in
                # source order too, so the correspondence is the file's own, not a
                # guess — and each call is still edited exactly once.
                calls = sorted(unconverted)
                mine = sorted(l for l, t, _ in sites if t == tok)
                start = calls[mine.index(ln)]
                j = unconverted[start]
                print(f"  RE-FOUND positionally ({len(calls)} calls, {len(mine)} rows): {path}:{ln} -> :{start+1}  {tok}")
            else:
                sys.exit(
                    f"REFUSE: {path}:{ln} ({tok}) — line hint stale and the token "
                    f"resolves to {len(unconverted)} unconverted macro calls; place it by hand"
                )
        span = call_span(lines, start) if MACRO.search(lines[start]) else None
        if span is not None and not (span[0] <= j <= span[1]):
            print(f"  SKIP (phantom, literal outside the call span): {path}:{ln}  {tok}")
            phantom += 1
            continue
        if ALREADY.search(lines[start]) or start in done_lines:
            already += 1
            continue
        done_lines.add(start)
        resolved.append((start, ln, tok))

    for start, ln, tok in sorted(resolved, reverse=True):
        spelling = (
            "spt_proto::emit_block_err!("
            if (path, tok) in BLOCK_SITES
            else "spt_proto::emit_line_err!("
        )
        if (path, tok) in BLOCK_SITES:
            print(f"  BLOCK ARM (multi-line by construction): {path}:{start+1}  {tok}")
        new = MACRO.sub(spelling, lines[start], count=1)
        if new == lines[start]:
            sys.exit(f"REFUSE: substitution changed nothing at {path}:{start+1}")
        lines[start] = new
        done_lines.add(start)
        converted += 1
    open(full, "w", encoding="utf-8", newline="").write("\n".join(lines))

print(
    f"converted {converted} calls across {len(by_file)} files in {crates}; "
    f"{already} rows shared an already-converted call, {phantom} phantom rows skipped, "
    f"{len(skipped)} stdout rows skipped"
)
