#!/usr/bin/env python3
"""exp15 — trap curation -> ledger (design §D1). Takes mined candidates + A0 greedy boundaries and:
  - locates each candidate's anchor & dependent spans in S2' (exact .find; drop if not locatable)
  - COMMON DENOMINATOR: keep candidates whose dependency crosses an A0 greedy cut (A0 might fail them);
    record whether it ALSO crosses a scene-shift seam (covariate, not filter)
  - stratify by antecedent distance: <=3 sentences (carryover zone) vs >3 (window/Ф2 zone)
  - dedupe near-identical spans; assign type; report quota status (T-ana/T-ent/T-tense >=20, others >=10)
  - emit the trap ledger with per-trap EXPECTED BEHAVIOR (recorded BEFORE any arm/judge spend)

Output is a REVIEW ARTIFACT for the checkpoint (manual curation follows: the polygon signs off each trap).
Run: python trap_curate.py
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from chunker import split_source_sentences, greedy_chunks

S2 = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime.txt")
CANDS = Path("/home/ubuntu/books/gu-zhenren/exp15/trap_candidates.json")
SEAMS = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_seams.json")
ANCHORS = Path("/home/ubuntu/books/gu-zhenren/exp15/anchors/anchors_analysis.json")
LABELS = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_seam_labels.json")
OUT = Path("/home/ubuntu/books/gu-zhenren/exp15/trap_ledger.json")

QUOTA = {"T-ana": 20, "T-ent": 20, "T-tense": 20, "T-cat": 10, "T-ell": 10, "T-gen": 10, "T-reg": 10}


def sentence_index(text):
    """Return list of (start_rune, end_rune) for each source sentence, for distance computation."""
    spans, cur = [], 0
    for s in split_source_sentences(text):
        spans.append((cur, cur + len(s)))
        cur += len(s)
    return spans


def sentence_of_offset(spans, off):
    for i, (a, b) in enumerate(spans):
        if a <= off < b:
            return i
    return len(spans) - 1


def main():
    text = S2.read_text(encoding="utf-8")
    spans = sentence_index(text)
    seams = json.load(open(SEAMS))["seams"]
    seam_offs = {s["seam_id"]: s["rune_offset"] for s in seams}
    labels = json.load(open(LABELS))["labels"]
    # A0 (flat, blind-greedy) cut offsets in S2prime.txt SPACE — locate each greedy chunk head in `text`
    # so cuts and trap spans (also located via text.find) share one coordinate system.
    gch = greedy_chunks(text)
    a0_cuts, pos = [], 0
    for i, c in enumerate(gch):
        idx = text.find(c[:25], max(0, pos - 5))
        if idx < 0:
            idx = text.find(c[:25])
        if i > 0 and idx >= 0:
            a0_cuts.append(idx)
        pos = idx + len(c) if idx >= 0 else pos + len(c)
    cands_raw = json.load(open(CANDS))["results"] if CANDS.exists() else []

    ledger = []
    dropped = {"not_located": 0, "no_a0_cross": 0, "dup": 0}
    seen_keys = set()
    for res in cands_raw:
        for c in res.get("candidates", []):
            a_span = (c.get("anchor_span") or "").strip()
            d_span = (c.get("dependent_span") or "").strip()
            if not a_span or not d_span:
                dropped["not_located"] += 1
                continue
            ai = text.find(a_span[:30])
            di = text.find(d_span[:30])
            if ai < 0 or di < 0:
                dropped["not_located"] += 1
                continue
            a_end = ai + len(a_span)
            # dependency region = [lo, hi); a cut at offset c splits it iff lo < c < hi STRICTLY (a cut
            # exactly at hi lands right AFTER the dependency -> A0 kept it intact -> NOT crossing). BUG1 fix.
            lo, hi = min(ai, di), max(a_end, di + len(d_span))
            crosses_a0 = any(lo < cut < hi for cut in a0_cuts)
            crossed_cuts = [cut for cut in a0_cuts if lo < cut < hi]
            crosses_seam = [sid for sid, off in seam_offs.items() if lo < off < hi]
            # BUG2 fix: dedup on span PROXIMITY (same type + anchor & dependent within 15 runes of a kept
            # trap), not a fixed 20-rune grid (grid mis-buckets across edges -> false keep AND false drop).
            typ = c.get("type")
            if any(t["type"] == typ and abs(t["anchor_offset"] - ai) < 15 and abs(t["dependent_offset"] - di) < 15
                   for t in ledger):
                dropped["dup"] += 1
                continue
            # antecedent distance in sentences
            si_a = sentence_of_offset(spans, ai)
            si_d = sentence_of_offset(spans, di)
            dist = abs(si_d - si_a)
            stratum = "near(<=3)" if dist <= 3 else "far(>3)"
            seam_classes = [labels[str(s)]["class"] for s in crosses_seam if str(s) in labels]
            ledger.append({
                "id": f"{c.get('type')}-{len(ledger)+1}",
                "type": c.get("type"),
                "seam_id": c.get("seam_id"),
                "anchor_span": a_span, "dependent_span": d_span,
                "anchor_offset": ai, "dependent_offset": di,
                "antecedent_distance_sentences": dist, "stratum": stratum,
                "crosses_A0_cut": crosses_a0, "crossed_A0_cut_offsets": crossed_cuts,
                "crosses_seam_ids": crosses_seam, "seam_classes": seam_classes,
                "phenomenon": c.get("phenomenon"), "expected_ru": c.get("expected_ru"),
                "failure_if_split": c.get("failure_if_split"),
                # expected behavior (pre-registered before scoring):
                "expected_behavior": {
                    "A0_at_risk": crosses_a0,   # A0's greedy cut splits the dependency -> may fail
                    "logical_helps_if": "cut aligns to scene-shift seam and keeps the dependency intact"
                                        if any(cl == "scene-shift" for cl in seam_classes) else
                                        "avoids cutting a continuation seam mid-dependency"
                                        if any(cl == "continuation" for cl in seam_classes) else
                                        "boundary alignment orthogonal to this trap (weak Q1 discriminator)",
                },
            })

    # quota report
    from collections import Counter
    by_type_all = Counter(t["type"] for t in ledger)
    by_type_cross = Counter(t["type"] for t in ledger if t["crosses_A0_cut"])
    quota_status = {}
    for typ, need in QUOTA.items():
        have_cross = by_type_cross.get(typ, 0)
        quota_status[typ] = {"need": need, "have_all": by_type_all.get(typ, 0),
                             "have_crossing_A0": have_cross, "meets_quota": have_cross >= need}
    strata = Counter((t["type"], t["stratum"]) for t in ledger if t["crosses_A0_cut"])
    result = {
        "n_candidates_mined": sum(len(r.get("candidates", [])) for r in cands_raw),
        "n_in_ledger": len(ledger), "dropped": dropped,
        "a0_cut_offsets": a0_cuts,
        "quota_status": quota_status,
        "crossing_A0_by_type": dict(by_type_cross),
        "strata_crossing_A0": {f"{k[0]}/{k[1]}": v for k, v in strata.items()},
        "ledger": ledger,
    }
    OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"candidates mined: {result['n_candidates_mined']}  -> ledger: {len(ledger)}  dropped: {dropped}")
    print("\nquota status (crossing an A0 cut = common denominator):")
    for typ, q in quota_status.items():
        mark = "OK" if q["meets_quota"] else "SHORT"
        print(f"  {typ:8} need>={q['need']:2}  have_all={q['have_all']:2}  crossing_A0={q['have_crossing_A0']:2}  [{mark}]")
    print(f"\nstrata (crossing A0): {result['strata_crossing_A0']}")
    print(f"-> {OUT}")
    print("\nNOTE: any SHORT type -> mine more windows OR mark 'Q undecidable a priori for this type' "
          "BEFORE paid arm/judge spend (design §D1).")


if __name__ == "__main__":
    main()
