"""Measure the Jimeng pill on the PRODUCT path, not the raw detector.

WHY THIS EXISTS
  `visible_removal_audit.py` calls `get_mark(key).remove` directly, which bypasses
  `_keep_pill`. For the pill that is the wrong path and it reads as a disaster: on a
  300-image sample the raw path was "detector still fires after removal" 68-75% of the
  time. The product does not do that -- `--mark auto` gates the pill hard (never on
  Doubao; unrestricted only when the bottom-right wordmark fired; otherwise only on a
  flat footprint). Measured through the gate on the same sample, 7 of 80 raw detections
  survived to removal and 6 of those were corroborated real.

  That sample was too small to state a precision (95% CI 49-97%). This script runs the
  gated path over EVERY pill positive in the corpus so the interval is usable.

THE CORROBORATION PROXY AND ITS BIAS
  "Real pill" here means: TC260 metadata names Jimeng, OR the bottom-right "★ 即梦AI"
  wordmark is detected. Both are independent of the pill detector, which is the point.
  But the proxy MISSES a real pill on a metadata-stripped screenshot with no visible
  wordmark -- so measured precision is a LOWER BOUND, not a point estimate. Do not quote
  it as if it were exact.

Treat input datasets as sensitive and read-only, and keep output gitignored.

    uv run python scripts/pill_gate_audit.py --jobs 7
"""

from __future__ import annotations

import argparse
import collections
import json
import math
import os
import sys
from concurrent.futures import ProcessPoolExecutor, as_completed
from concurrent.futures import TimeoutError as FutureTimeout
from concurrent.futures.process import BrokenProcessPool
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

REPO = Path(__file__).resolve().parents[1]
POSITIVES = REPO / ".local-eval" / "visible-positives.jsonl"
OUT = REPO / ".local-eval" / "pill-gate-audit.jsonl"


def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
    if n == 0:
        return (0.0, 0.0)
    p = k / n
    d = 1 + z * z / n
    c = (p + z * z / (2 * n)) / d
    h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
    return (100 * (c - h), 100 * (c + h))


def _one(path_str: str) -> dict[str, object]:
    from remove_ai_watermarks.api import visible_provenance
    from remove_ai_watermarks.image_io import imread
    from remove_ai_watermarks.pill_engine import PillEngine
    from remove_ai_watermarks.watermark_registry import detect_marks, get_mark, remove_auto_marks

    rec: dict[str, object] = {"path": Path(path_str).name, "status": "ok"}
    try:
        img = imread(path_str)
        if img is None:
            return {**rec, "status": "unreadable"}
        prov = visible_provenance(Path(path_str))
        dets = {d.key: d for d in detect_marks(img) if d.detected}
        wordmark = "jimeng" in dets
        tc260 = "jimeng" in prov
        eng = PillEngine()
        _, labels = remove_auto_marks(img, sensitivity="auto", provenance=prov, backend="cv2")
        # Match the EXACT pill label. A substring test on "AI生成" also matches Doubao's
        # label ("Doubao 豆包AI生成 text"), which counted Doubao removals as pill removals
        # and inflated the pill's measured precision.
        pill_label = get_mark("jimeng_pill").label
        removed = any(str(x) == pill_label for x in labels)
        # The gate reads the ARBITRATED keys, not strict detection: jimeng can be accepted
        # on its relaxed (provenance-confirmed) arm, which `detect_marks` never shows. Take
        # the arm from what was actually removed, or every such case lands in "other".
        jimeng_decided = any(str(x) == get_mark("jimeng").label for x in labels)
        return {
            **rec,
            "removed_by_product": bool(removed),
            "corroborated": bool(wordmark or tc260),
            "wordmark": bool(wordmark),
            "jimeng_decided": bool(jimeng_decided),
            "tc260": bool(tc260),
            "doubao_fired": "doubao" in dets,
            "footprint_flat": bool(eng.footprint_is_flat(img)),
        }
    except Exception as e:
        return {**rec, "status": f"error:{type(e).__name__}"}


def _batch(paths: list[str], jobs: int, timeout: int) -> list[dict[str, object]]:
    try:
        with ProcessPoolExecutor(max_workers=jobs) as ex:
            futs = [ex.submit(_one, p) for p in paths]
            return [f.result() for f in as_completed(futs, timeout=timeout)]
    except (FutureTimeout, BrokenProcessPool, OSError, RuntimeError):
        out = []
        for p in paths:
            try:
                out.append(_one(p))
            except BaseException:  # a native crash costs one file, not the sweep
                out.append({"path": Path(p).name, "status": "crashed"})
        return out


def report(rows: list[dict[str, object]]) -> None:
    ok = [r for r in rows if r.get("status") == "ok"]
    if not ok:
        print("no usable rows")
        return
    removed = [r for r in ok if r["removed_by_product"]]
    corro = [r for r in ok if r["corroborated"]]
    tp = [r for r in removed if r["corroborated"]]
    missed = [r for r in corro if not r["removed_by_product"]]

    print(f"\n{'=' * 76}\nJIMENG PILL ON THE PRODUCT PATH   n={len(ok)} raw detections\n{'=' * 76}")
    print(f"raw detections corroborated as real : {len(corro):5d}  ({100 * len(corro) / len(ok):.1f}%)")
    print(f"the gate lets through to removal    : {len(removed):5d}  ({100 * len(removed) / len(ok):.1f}%)")
    if removed:
        lo, hi = wilson(len(tp), len(removed))
        pct = 100 * len(tp) / len(removed)
        print(f"  of those, corroborated real       : {len(tp):5d}  -> precision {pct:.1f}% (95% CI {lo:.1f}-{hi:.1f})")
    if corro:
        lo, hi = wilson(len(tp), len(corro))
        pct = 100 * len(tp) / len(corro)
        head = f"corroborated pills removed          : {len(tp):5d}/{len(corro)}"
        print(f"{head}  -> recall {pct:.1f}% (95% CI {lo:.1f}-{hi:.1f})")
    print(f"corroborated pills the gate suppressed: {len(missed):5d}")
    print("\nprecision is a LOWER BOUND: the corroboration proxy cannot see a real pill on a")
    print("metadata-stripped image whose wordmark is absent or missed.\n")

    print("which gate arm let it through:")
    arms: collections.Counter[str] = collections.Counter()
    for r in removed:
        if r.get("jimeng_decided"):
            arms["jimeng wordmark accepted -> unrestricted"] += 1
        elif r["tc260"]:
            arms["tc260 metadata + flat footprint"] += 1
        else:
            arms["other (unexpected -- the gate has no third arm)"] += 1
    for k, v in arms.most_common():
        print(f"   {v:5d}  {k}")

    print("\nwhy the rest were suppressed:")
    sup: collections.Counter[str] = collections.Counter()
    for r in ok:
        if r["removed_by_product"]:
            continue
        if r["doubao_fired"]:
            sup["doubao fired (pill never rides on it)"] += 1
        elif not r["corroborated"]:
            sup["no corroboration (no wordmark, no TC260)"] += 1
        elif not r["footprint_flat"]:
            sup["corroborated but footprint too textured"] += 1
        else:
            sup["other"] += 1
    for k, v in sup.most_common():
        print(f"   {v:5d}  {k}")


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--jobs", type=int, default=max(1, (os.cpu_count() or 4) - 2))
    ap.add_argument("--limit", type=int, default=0)
    ap.add_argument("--batch", type=int, default=200)
    ap.add_argument("--timeout", type=int, default=900)
    ap.add_argument("--out", type=Path, default=OUT)
    ap.add_argument("--restart", action="store_true")
    a = ap.parse_args()

    paths = []
    with open(POSITIVES, encoding="utf-8") as fh:
        for line in fh:
            try:
                r = json.loads(line)
            except Exception:  # noqa: S112
                continue
            if "jimeng_pill" in (r.get("keys") or []):
                paths.append(r["path"])
    paths.sort()
    if a.limit:
        paths = paths[: a.limit]

    done: set[str] = set()
    if a.restart and a.out.exists():
        a.out.unlink()  # --restart must TRUNCATE; appending kept the old rows in the report
    if a.out.exists() and not a.restart:
        with open(a.out, encoding="utf-8") as fh:
            for line in fh:
                try:
                    done.add(json.loads(line)["path"])
                except Exception:  # noqa: S112
                    continue
    todo = [p for p in paths if Path(p).name not in done]
    print(f"pill positives {len(paths)}  done {len(done)}  to do {len(todo)}  jobs {a.jobs}", flush=True)

    rows: list[dict[str, object]] = []
    with open(a.out, "a", encoding="utf-8") as fh:
        for i in range(0, len(todo), a.batch):
            for rec in _batch(todo[i : i + a.batch], a.jobs, a.timeout):
                fh.write(json.dumps(rec) + "\n")
                rows.append(rec)
            fh.flush()
            print(f"  {len(rows)}/{len(todo)}", flush=True)

    with open(a.out, encoding="utf-8") as fh:
        allrows = []
        for line in fh:
            try:
                allrows.append(json.loads(line))
            except Exception:  # noqa: S112
                continue
    report(allrows)
    print(f"records -> {a.out}")


if __name__ == "__main__":
    main()
