#!/usr/bin/env python3
"""Decides, deterministically, what output path A does with each candidate:
open a new issue, comment on the one that already exists, or skip it — and
renders the exact body text for each (FR-6.1..FR-6.4, design/plan.md §10).

Everything here is a `pure-script` step by the plugin's own rubric: the verdicts
were already graded by bin/boundary-classify, the marker match is a literal
string comparison, and rendering a template is a total function of its inputs.
None of it may be done by inference — an audit that decides duplicates by
"looking at" the tracker is exactly the shape this plugin reports on, and
getting it wrong spams a real human's tracker.

This script never touches Gitea. It consumes the results of a search that
`anxious:issuer-agent` -> `cluster:gitea-agent` performed, and emits a plan that
the same chain executes. Two subcommands, in order:

    filing-plan queries --classified c.json --judgments j.json [--target-path P]
    filing-plan plan    --classified c.json --judgments j.json --existing e.json ...

`queries` resolves the target repo (or reports that filing is unavailable) and
emits the marker searches to run. `plan` consumes their merged results.
"""

import argparse
import json
import os
import re
import subprocess
import sys

PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_TEMPLATE = os.path.join(PLUGIN_ROOT, "references", "issue-template.md")
DEFAULT_WORKSPACE = os.path.expanduser("~/projects/claude-plugins")
DEFAULT_GITEA_HOST = "git.oleks.space"
DEFAULT_WIKI_REPO = "kotkan/claude-plugin-inference-arbitrage"
OFFLOAD_LABEL = "token-offload"

TEMPLATE_SECTIONS = {"issue-title", "issue-body", "update-comment"}


# --------------------------------------------------------------------------
# template loading and rendering
# --------------------------------------------------------------------------

def load_templates(path):
    """Same contract as signals-catalog.md: one fenced block per recognized
    `## <section>` heading, everything else prose."""
    with open(path, encoding="utf-8") as fh:
        text = fh.read()

    out = {}
    for m in re.finditer(r"^##\s+([a-z-]+)\s*$", text, re.MULTILINE):
        name = m.group(1)
        if name not in TEMPLATE_SECTIONS:
            continue
        block = re.search(r"```\S*\n(.*?)```", text[m.end():], re.DOTALL)
        if block:
            out[name] = block.group(1).strip("\n")
    missing = TEMPLATE_SECTIONS - out.keys()
    if missing:
        sys.exit(f"error: {path} is missing section(s): {', '.join(sorted(missing))}")
    return out


PLACEHOLDER = re.compile(r"\{\{([a-z_]+)\}\}")


def render(template, values):
    """Strict substitution. An unsupplied placeholder is an error, never an
    empty string — a half-filled issue body is worse than a crashed audit."""
    missing = sorted(
        {k for k in PLACEHOLDER.findall(template) if values.get(k) in (None, "")}
    )
    if missing:
        sys.exit(f"error: template placeholder(s) unsupplied: {', '.join(missing)}")
    return PLACEHOLDER.sub(lambda m: str(values[m.group(1)]), template)


# --------------------------------------------------------------------------
# the marker (FR-6.3)
# --------------------------------------------------------------------------

def marker(candidate_id):
    return f"<!-- ia-candidate: {candidate_id} -->"


def marker_re(candidate_id):
    """Tolerates whitespace drift inside the comment, and tolerates the
    HTML-comment wrapper (`<!-- -->`) being absent entirely, nothing else. The
    id itself must match exactly — a fuzzy id match would let two genuinely
    different candidates collapse onto one issue.

    The bare form (no `<!-- -->`) is a backward-compatibility case, not the
    contract: kotkan/claude-plugin-inference-arbitrage#23 found that issues
    filed on 2026-07-29 (oleks/claude-plugin-cluster#56, #57) lost the wrapper
    somewhere between `filing-plan`'s render and the Gitea write — the
    template and this script always render the wrapped form (see `marker()`
    below and references/issue-template.md). Matching the bare line here is
    purely so a *later* audit still recognizes those already-filed issues and
    doesn't duplicate-file them; it does not license filing new issues without
    the wrapper.
    """
    wrapped = r"<!--\s*ia-candidate:\s*" + re.escape(candidate_id) + r"\s*-->"
    # End-of-line anchored (not \b): candidate_id contains "/" and "-", so a
    # word-boundary check would treat "…label-derivation" as a match inside
    # "…label-derivation-longer" (the "n"/"-" transition IS a \b). Anchoring
    # to end-of-line (re.MULTILINE) instead requires an exact id, matching
    # what marker() actually renders as the line's sole content.
    bare = r"^ia-candidate:\s*" + re.escape(candidate_id) + r"[ \t]*$"
    return re.compile(wrapped + "|" + bare, re.MULTILINE)


def find_existing(candidate_id, issues):
    """Returns (issue_or_None, duplicates). Matches the body and every comment,
    so an issue keeps its identity even if a human rewrites the body."""
    pat = marker_re(candidate_id)
    hits = []
    for issue in issues:
        haystacks = [issue.get("body") or ""]
        haystacks += [c if isinstance(c, str) else (c.get("body") or "")
                      for c in issue.get("comments") or []]
        if any(pat.search(h) for h in haystacks):
            hits.append(issue)
    if not hits:
        return None, []
    hits.sort(key=lambda i: i.get("number", 0))
    return hits[0], hits[1:]


# --------------------------------------------------------------------------
# target repo resolution (FR-6.4)
# --------------------------------------------------------------------------

def git_remote(path):
    if not os.path.isdir(os.path.join(path, ".git")):
        return None
    try:
        out = subprocess.run(
            ["git", "-C", path, "remote", "get-url", "origin"],
            capture_output=True, text=True, timeout=10,
        )
    except (OSError, subprocess.SubprocessError):
        return None
    return out.stdout.strip() if out.returncode == 0 else None


def parse_remote(url, host):
    """owner/repo if the remote points at our Gitea, else None."""
    m = re.match(r"^(?:git@|ssh://git@|https://)([^/:]+)[:/](.+?)(?:\.git)?/?$", url)
    if not m or m.group(1) != host:
        return None
    parts = m.group(2).split("/")
    return "/".join(parts[-2:]) if len(parts) >= 2 else None


def resolve_repo(args, target_name):
    """(repo, filing, reason). `filing` is 'available' or 'unavailable'.

    Order: explicit flag, the target checkout's own remote, then the workspace
    sibling checkout — because an audit normally runs against
    ~/.claude/plugins/cache/... which carries no .git at all.
    """
    if args.target_repo:
        return args.target_repo, "available", None

    tried = []
    candidates = []
    if args.target_path:
        candidates.append(os.path.abspath(args.target_path))
    if target_name and args.workspace:
        sibling = os.path.join(args.workspace, target_name)
        if sibling not in candidates:
            candidates.append(sibling)

    for path in candidates:
        if not os.path.isdir(path):
            continue
        url = git_remote(path)
        if url is None:
            tried.append(f"{path}: no git remote")
            continue
        repo = parse_remote(url, args.gitea_host)
        if repo:
            return repo, "available", None
        tried.append(f"{path}: remote {url} is not on {args.gitea_host}")

    if not candidates:
        detail = "no --target-repo, --target-path, or workspace checkout to resolve from"
    else:
        detail = "; ".join(tried) or "no checkout found at " + ", ".join(candidates)
    return None, "unavailable", (
        f"no writable repo for target {target_name!r} ({detail}). "
        "Path A is skipped; the candidate is reported in the run summary only (FR-6.4)."
    )


# --------------------------------------------------------------------------
# rendering values
# --------------------------------------------------------------------------

TICKS = {True: "✓", False: "✗"}


def fmt_tests(tests):
    return " ".join(f"{t} {TICKS[tests.get(t) is True]}" for t in ("T1", "T2", "T3", "T4", "T5"))


def fmt_tokens(n):
    if n >= 1_000_000:
        return f"{n / 1_000_000:.1f}M"
    if n >= 1_000:
        return f"{n / 1_000:.1f}k"
    return str(n)


def fmt_examples(examples):
    return "\n".join(f"- {e}" for e in examples)


def fmt_evidence(strength, m):
    inv, turns = m.get("invocations", 0), m.get("attributed_turns", 0)
    if strength == "measured":
        return f"`measured` — {inv} invocations, {turns} attributed turns."
    if strength == "thin":
        return (
            f"`thin` — only {inv} invocation(s) and {turns} attributed turns in this "
            "window. **The cost figure above is arithmetic, not evidence.** The "
            "correctness case stands on the rubric; the value claim is unproven."
        )
    return (
        "`unmeasured` — no transcript history for this target in the window. This "
        "candidate rests on the plugin definition alone (FR-2.4)."
    )


def fmt_cost(cand, cls, window):
    """Only states what was actually measured. Absent signals are omitted, never
    guessed at — a fabricated MTR in a filed issue is unrecoverable."""
    m = cand.get("measurement") or {}
    bits = []
    value = cls.get("offload_value") or 0
    share = cls.get("share_of_audited_spend") or 0
    inv = m.get("invocations", 0)
    head = f"{fmt_tokens(value)} weighted tokens"
    if inv:
        head += f" over {inv} invocation{'s' if inv != 1 else ''}"
    if window:
        head += f" ({window})"
    if share:
        head += f", {share:.0%} of this plugin's audited spend"
    bits.append(head + ".")

    stats = []
    if m.get("mtr") is not None:
        stats.append(f"MTR {m['mtr']:.2f}")
    if m.get("judgment_density") is not None:
        stats.append(f"judgment density {m['judgment_density']:.2f}")
    if stats:
        bits.append(" ".join(stats) + ".")

    ngram = m.get("ngram") or {}
    if ngram.get("sig") and ngram.get("recurrences"):
        seq = " -> ".join(ngram["sig"])
        bits.append(f"Recurring sequence observed {ngram['recurrences']}x: {seq}.")
    return " ".join(bits)


def headline(cand):
    if cand.get("headline"):
        return cand["headline"]
    # Last path segment of the candidate id is its slug by construction
    # (design/plan.md §6: "<plugin>/<skill>/<slug>").
    return cand["candidate_id"].rsplit("/", 1)[-1].replace("-", " ")


# --------------------------------------------------------------------------
# the plan
# --------------------------------------------------------------------------

def index_judgments(doc):
    return {c["candidate_id"]: c for c in doc.get("candidates", [])}


def build(args, cls_doc, jud_doc, existing, templates):
    target = cls_doc.get("target") or jud_doc.get("target")
    repo, filing, reason = resolve_repo(args, target)
    judgments = index_judgments(jud_doc)

    actions, warnings = [], []
    for cls in cls_doc.get("candidates", []):
        cid = cls["candidate_id"]
        verdict = cls.get("verdict")
        if verdict != "file":
            actions.append({
                "candidate_id": cid,
                "action": "skip",
                "reason": f"verdict {verdict!r} — path A files only 'file' verdicts "
                          "(FR-4.2 gate, enforced by boundary-classify)",
            })
            continue

        if filing == "unavailable":
            actions.append({
                "candidate_id": cid,
                "action": "skip",
                "filing": "unavailable",
                "reason": reason,
            })
            continue

        cand = judgments.get(cid)
        if cand is None:
            sys.exit(f"error: candidate {cid!r} has verdict 'file' but no judgments entry")

        f = cand.get("falsifiability") or {}
        values = {
            "candidate_id": cid,
            "headline": headline(cand),
            "skill": cand.get("skill") or cls.get("skill"),
            "measured_cost": fmt_cost(cand, cls, args.window),
            "evidence": fmt_evidence(cls.get("measurement_strength"), cand.get("measurement") or {}),
            "position": cls.get("position"),
            "boundary_confidence": cls.get("boundary_confidence"),
            "determinism_tests": fmt_tests(cls.get("determinism_tests") or {}),
            "signature": f.get("signature"),
            "examples": fmt_examples(f.get("examples") or []),
            "overrule_case": f.get("overrule_case"),
            "escalation_path": cand.get("escalation_path"),
            "digest_schema": cand.get("digest_schema") or "n/a — position 1, the whole step moves.",
            "audit_page": args.audit_page,
            "wiki_repo": args.wiki_repo,
            "rubric_version": cls_doc.get("rubric_version", "1.0.0"),
            "run_date": args.run_date,
            "window": args.window,
            "status": args.status,
        }

        found, dupes = find_existing(cid, existing)
        if dupes:
            numbers = ", ".join("#%s" % d.get("number") for d in [found] + dupes)
            warnings.append(
                f"{cid}: marker found on {1 + len(dupes)} issues ({numbers}) — "
                "commenting on the lowest-numbered one; the rest want a human merge"
            )

        if found:
            actions.append({
                "candidate_id": cid,
                "action": "comment",
                "issue": f"{repo}#{found['number']}",
                "issue_number": found["number"],
                "issue_state": found.get("state", "unknown"),
                "marker": marker(cid),
                "body": render(templates["update-comment"], values),
                "note": "existing issue is closed; issuer decides whether to reopen"
                        if found.get("state") == "closed" else None,
            })
        else:
            actions.append({
                "candidate_id": cid,
                "action": "file",
                "marker": marker(cid),
                "proposed_title": render(templates["issue-title"], values),
                "body": render(templates["issue-body"], values),
                "required_labels": [OFFLOAD_LABEL],
                "labels_note": "the four-axis anxious taxonomy is issuer's call; "
                               f"{OFFLOAD_LABEL} is required and issuer creates it if absent",
            })

    counts = {a: sum(1 for x in actions if x["action"] == a) for a in ("file", "comment", "skip")}
    return {
        "target": target,
        "repo": repo,
        "filing": filing,
        "filing_unavailable_reason": reason,
        "run_date": args.run_date,
        "window": args.window,
        "audit_page": args.audit_page,
        "delegation": "offload-analyst -> Agent(anxious:issuer-agent) -> cluster:gitea-agent -> Gitea",
        "summary": {
            "to_file": counts["file"],
            "to_comment": counts["comment"],
            "skipped": counts["skip"],
            "searched_issues": len(existing),
        },
        "warnings": warnings,
        "actions": actions,
    }


def build_queries(args, cls_doc, jud_doc):
    target = cls_doc.get("target") or jud_doc.get("target")
    repo, filing, reason = resolve_repo(args, target)
    ids = [c["candidate_id"] for c in cls_doc.get("candidates", []) if c.get("verdict") == "file"]
    doc = {
        "target": target,
        "repo": repo,
        "filing": filing,
        "filing_unavailable_reason": reason,
        "candidates_to_search": ids,
    }
    if filing == "available" and ids:
        doc["queries"] = {
            "full_text": [
                {"tool": "search_issues", "repo": repo, "state": "all",
                 "q": f"ia-candidate: {cid}"} for cid in ids
            ],
            "label_scan": {"tool": "list_issues", "repo": repo, "state": "all",
                           "labels": [OFFLOAD_LABEL]},
        }
        doc["note"] = (
            "Run BOTH and merge into one array of "
            "{number, state, title, body, comments[]} for `filing-plan plan --existing`. "
            "The label scan is authoritative; the full-text query is the fast path and "
            "may miss on indexer tokenization. See references/issue-template.md."
        )
    return doc


def main():
    ap = argparse.ArgumentParser(prog="filing-plan")
    sub = ap.add_subparsers(dest="cmd", required=True)

    def common(p):
        p.add_argument("--classified", required=True, help="bin/boundary-classify output")
        p.add_argument("--judgments", required=True, help="the analyst's judgments JSON")
        p.add_argument("--target-repo", help="owner/repo, if already known")
        p.add_argument("--target-path", help="the audited plugin's directory")
        p.add_argument("--workspace", default=DEFAULT_WORKSPACE)
        p.add_argument("--gitea-host", default=DEFAULT_GITEA_HOST)
        p.add_argument("--json", action="store_true", help="compact single-line JSON")

    q = sub.add_parser("queries", help="resolve the repo and emit the marker searches")
    common(q)

    p = sub.add_parser("plan", help="decide file/comment/skip per candidate")
    common(p)
    p.add_argument("--existing", required=True,
                   help="JSON array of issues from the marker search. Required even when "
                        "empty ([]): defaulting to 'nothing exists' would duplicate on re-run.")
    p.add_argument("--audit-page", required=True, help="e.g. Audits/anxious/2026-07-29")
    p.add_argument("--window", required=True, help="e.g. 2026-07-22 -> 2026-07-29")
    p.add_argument("--run-date", required=True)
    p.add_argument("--wiki-repo", default=DEFAULT_WIKI_REPO)
    p.add_argument("--status", default="persisting",
                   help="snapshot-diff status for the update comment (audit-snapshot diff)")
    p.add_argument("--template", default=DEFAULT_TEMPLATE)

    args = ap.parse_args()
    with open(args.classified) as fh:
        cls_doc = json.load(fh)
    with open(args.judgments) as fh:
        jud_doc = json.load(fh)

    if args.cmd == "queries":
        out = build_queries(args, cls_doc, jud_doc)
    else:
        with open(args.existing) as fh:
            existing = json.load(fh)
        if not isinstance(existing, list):
            sys.exit("error: --existing must be a JSON array of issues")
        out = build(args, cls_doc, jud_doc, existing, load_templates(args.template))

    json.dump(out, sys.stdout, indent=None if args.json else 2, ensure_ascii=False)
    sys.stdout.write("\n")


if __name__ == "__main__":
    main()
