"""Tier B2: detector response curves -- recall vs mark size, contrast, background and aspect.

WHY THIS EXISTS
  Every recall number the project quotes rests on found positives: doubao 89% on n=240,
  jimeng 71% on n=14, the pill 50% on n=6. Those tell you how the detectors do on the
  marks a corpus happened to contain, and nothing about WHERE they fall over. Two bugs
  have now shipped in exactly that blind spot:

    * `scale_basis` -- doubao detected 0 of 435 LANDSCAPE marks, a 100% miss, because
      every fraction was calibrated on portrait captures where width == short side.
    * the front-end mismatch -- detection moved to the continuous `tophat` response while
      the removal mask still came from the BINARIZED blob, so ~8% of doubao detections
      produced an empty mask and removal was a silent no-op.

  Both are geometry/plumbing failures at the edge of the operating range, and both were
  invisible to a corpus sweep because the corpus is concentrated in the middle of it.
  This harness constructs the edges instead of waiting for them to turn up.

THE AGGREGATE HERE IS NOT RECALL
  The grid deliberately visits sizes and opacities the engines were never calibrated for,
  so a mark counted as missed at size=0.6 is not evidence of a weak detector -- it is the
  point of the sweep. Quote the NOMINAL cell (size=1.0, alpha=1.0) as the reference, and
  read the per-axis tables for shape. Production recall comes from an unbiased corpus
  sample (`scripts/visible_recall_sample.py`), never from here.

WHAT IT MEASURES -- TWO NUMBERS, NOT ONE
  `detected`  the detector fires.
  `maskable`  the same call path then yields a NON-EMPTY removal mask.

  The second is the one that has no natural home anywhere else. A mark that is detected
  but not maskable is reported by `identify` and silently skipped by `visible` -- the user
  sees a mark the tool says it removed. Anything that measures only detection scores that
  bug as a success. They are reported side by side and their GAP is the headline.

THE CONSTRUCTION
  Take a verified-clean corpus image, stamp a real mark onto it with the forward model the
  reverse-alpha work established (`stamped = (1-a)*bg + a*white`), and sweep two knobs the
  engine's own geometry normally pins:

    size    multiplies the glyph box the engine would use (a re-rendered or rescaled mark)
    alpha   multiplies the captured opacity (bold stamp -> faint translucent overlay)

  Background texture and frame aspect are not swept -- they come from the corpus and are
  recorded, so the grid is crossed with real backgrounds rather than synthetic flats.

WHY EVERY SOURCE ALSO RUNS UNSTAMPED
  A recall curve with no false-fire baseline is uninterpretable in the same way a fill
  PSNR with no damage baseline is: "detected on 70% of faint marks" is a triumph or a
  scandal depending on whether the detector also fires on 70% of the clean frames those
  marks were stamped onto. The control runs the identical call path on the identical
  image with no stamp.

READING `mask_hit`
  Fraction of the stamped glyph box the removal mask covers -- real localization accuracy
  for the text marks and gemini, because the mask has to be placed from a detection.
  For `jimeng_pill` it is CONSTRUCTED and means nothing: the pill's footprint is a fixed
  top-left geometry box, so it covers the stamp by definition. Reported, and flagged.

DATA SAFETY
  Treat input datasets as sensitive and read-only. Keep generated reports under
  .local-eval/. Reports record source filenames and measurements, never image content.

    uv run python scripts/detector_response.py --n 12          # trial, measures throughput
    uv run python scripts/detector_response.py --n 150         # the real run, resumable
"""

from __future__ import annotations

import argparse
import collections
import json
import math
import os
import sys
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Any

import numpy as np

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

from fill_quality import SLOT_STAMPABLE, STAMPABLE, clean_sources, stamp_any, texture_of

REPO = Path(__file__).resolve().parents[1]
OUT = REPO / ".local-eval" / "detector-response.jsonl"

# 1.0 = the geometry/opacity the engine's own constants assume. The sweep reaches below
# it (a mark rendered smaller, or a faint translucent overlay -- the class the tophat
# front-end was introduced for) and above it (a larger re-render, or an upscaled upload).
SIZES = (0.6, 0.8, 1.0, 1.3)
ALPHAS = (0.3, 0.5, 0.75, 1.0)
MARKS = (*STAMPABLE, *SLOT_STAMPABLE)


def aspect_of(image: np.ndarray) -> str:
    h, w = image.shape[:2]
    r = w / max(1, h)
    return "portrait" if r < 0.9 else ("landscape" if r > 1.1 else "square")


def _probe(image: np.ndarray, mark_key: str, box: tuple[int, int, int, int] | None) -> dict[str, Any]:
    """Run the product detect -> localize path once and score it.

    ``box`` is the stamped glyph box, or None for the unstamped control.
    """
    from remove_ai_watermarks.watermark_registry import get_mark

    mark = get_mark(mark_key)
    loc = mark.localize(image, force=False)
    mask_px = 0 if loc.mask is None else int(np.count_nonzero(loc.mask))
    hit = None
    if box is not None and loc.mask is not None:
        x, y, w, h = box
        inside = loc.mask[y : y + h, x : x + w]
        hit = round(float(np.count_nonzero(inside)) / max(1, w * h), 3)
    return {
        "detected": bool(loc.detected),
        "confidence": round(float(loc.confidence), 4),
        "maskable": mask_px > 0,
        "mask_px": mask_px,
        "mask_hit": hit,
    }


def _one_source(path_str: str) -> list[dict[str, Any]]:
    """Every (mark, size, alpha) cell for one clean background, plus its controls."""
    from remove_ai_watermarks.image_io import imread

    base = imread(path_str)
    if base is None:
        return []
    name = Path(path_str).name
    asp = aspect_of(base)
    h, w = base.shape[:2]
    rows: list[dict[str, Any]] = []

    for key in MARKS:
        common = {"src": name, "mark": key, "aspect": asp, "px": w * h}
        # Control first: the same call path on the same frame with nothing stamped.
        rows.append({**common, "cell": "control", "size": None, "alpha": None, **_probe(base, key, None)})
        for size in SIZES:
            for alpha in ALPHAS:
                st = stamp_any(base, key, size_mult=size, alpha_mult=alpha)
                if st is None:
                    continue
                stamped, box = st
                # Texture of the region the mark sits on, from the CLEAN frame -- the
                # same median-Sobel proxy the pill's flatness gate uses.
                x, y, gw, gh = box
                rows.append(
                    {
                        **common,
                        "cell": "stamped",
                        "size": size,
                        "alpha": alpha,
                        "texture": round(texture_of(base[y : y + gh, x : x + gw]), 2),
                        **_probe(stamped, key, box),
                    }
                )
    return rows


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


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--n", type=int, default=150, help="verified-clean source images")
    ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
    ap.add_argument("--out", type=Path, default=OUT)
    ap.add_argument("--restart", action="store_true")
    ap.add_argument("--report-only", action="store_true", help="re-read --out and print the report")
    a = ap.parse_args()

    if a.report_only:
        rows = [json.loads(line) for line in a.out.read_text(encoding="utf-8").splitlines() if line.strip()]
        report(rows)
        return

    if a.restart and a.out.exists():
        a.out.unlink()
    done: set[str] = set()
    if a.out.exists():
        with open(a.out, encoding="utf-8") as fh:
            for line in fh:
                try:
                    done.add(json.loads(line)["src"])
                except Exception:  # noqa: S112 -- tolerate a torn last line
                    continue

    print(f"selecting {a.n} verified-clean sources (already measured: {len(done)})...", flush=True)
    sources = [p for p in clean_sources(a.n) if p.name not in done]
    print(f"to do {len(sources)}  workers {a.workers}  grid {len(MARKS)}x{len(SIZES)}x{len(ALPHAS)}\n", flush=True)

    rows: list[dict[str, Any]] = []
    a.out.parent.mkdir(parents=True, exist_ok=True)
    with open(a.out, "a", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=a.workers) as ex:
        futures = {ex.submit(_one_source, str(p)): p for p in sources}
        for i, fut in enumerate(as_completed(futures), 1):
            try:
                got = fut.result()
            except Exception as e:  # a crashed source must not kill the sweep
                print(f"  source failed: {type(e).__name__}: {e}", flush=True)
                continue
            for r in got:
                fh.write(json.dumps(r) + "\n")
            rows += got
            if i % 10 == 0:
                fh.flush()
                print(f"  {i}/{len(sources)}", flush=True)

    if done:  # merge the resumed rows so the report covers the whole file
        rows = [json.loads(line) for line in a.out.read_text(encoding="utf-8").splitlines() if line.strip()]
    report(rows)


def _rate(rs: list[dict[str, Any]], field: str) -> tuple[float, float, float, int]:
    k, n = sum(bool(r[field]) for r in rs), len(rs)
    lo, hi = wilson(k, n)
    return (100 * k / n if n else 0.0, lo, hi, n)


def report(rows: list[dict[str, Any]]) -> None:
    stamped = [r for r in rows if r["cell"] == "stamped"]
    control = [r for r in rows if r["cell"] == "control"]
    if not stamped:
        print("no measurements")
        return
    srcs = len({r["src"] for r in rows})
    print(f"\n{'=' * 84}\nDETECTOR RESPONSE  {len(stamped)} stamped cells over {srcs} clean backgrounds\n{'=' * 84}")

    # Lead with the nominal cell. The grid deliberately includes sizes and opacities the
    # engines were never calibrated for, so an aggregate over the whole grid is an
    # adversarial score, NOT production recall -- quoting it as recall would understate
    # the detectors badly. The nominal cell is the honest reference point: the mark at the
    # exact geometry and opacity the engine assumes, on a real clean background.
    print("\nNOMINAL CELL (size=1.0, alpha=1.0) -- a mark exactly as the engine expects it.")
    print("Read every other number against THIS, not as production recall: the grid is")
    print("adversarial by design and its aggregate is not a recall figure.\n")
    print(f"{'mark':13s} {'n':>5s} {'detected':>10s} {'maskable':>10s}")
    for key in MARKS:
        rs = [r for r in stamped if r["mark"] == key and r["size"] == 1.0 and r["alpha"] == 1.0]
        if rs:
            print(f"{key:13s} {len(rs):5d} {_rate(rs, 'detected')[0]:9.1f}% {_rate(rs, 'maskable')[0]:9.1f}%")

    print("\n\nFALSE FIRE on the unstamped controls (the baseline every recall below is read against)\n")
    print(f"{'mark':13s} {'n':>5s} {'fires':>8s} {'95% CI':>16s}")
    for key in MARKS:
        rs = [r for r in control if r["mark"] == key]
        if rs:
            pct, lo, hi, n = _rate(rs, "detected")
            print(f"{key:13s} {n:5d} {pct:7.1f}% {f'{lo:.1f}-{hi:.1f}':>16s}")

    print("\n\nDETECTED vs MASKABLE by mark. The GAP is the silent-no-op rate:")
    print("a mark counted here as detected-but-not-maskable is reported by `identify` and")
    print("skipped by `visible`, so the user is told it was removed when it was not.\n")
    print(f"{'mark':13s} {'n':>6s} {'detected':>10s} {'maskable':>10s} {'gap':>7s} {'mask_hit':>9s}")
    for key in MARKS:
        rs = [r for r in stamped if r["mark"] == key]
        if not rs:
            continue
        det = [r for r in rs if r["detected"]]
        d, m = _rate(rs, "detected")[0], _rate(rs, "maskable")[0]
        gap = (100 * sum(1 for r in det if not r["maskable"]) / len(det)) if det else 0.0
        hits = [r["mask_hit"] for r in det if r["mask_hit"] is not None]
        note = "  (constructed)" if key == "jimeng_pill" else ""
        hv = f"{float(np.median(hits)):.2f}" if hits else "-"
        print(f"{key:13s} {len(rs):6d} {d:9.1f}% {m:9.1f}% {gap:6.1f}% {hv:>9s}{note}")

    for axis, label in (("alpha", "CONTRAST (alpha multiplier)"), ("size", "SIZE (glyph box multiplier)")):
        print(f"\n\nRECALL BY {label}  -- 1.0 is the geometry/opacity the engine assumes\n")
        vals = sorted({r[axis] for r in stamped})
        print(f"{'mark':13s}" + "".join(f"{v:>12}" for v in vals))
        for key in MARKS:
            cells = []
            for v in vals:
                rs = [r for r in stamped if r["mark"] == key and r[axis] == v]
                cells.append(f"{_rate(rs, 'detected')[0]:10.0f}%" if rs else f"{'-':>11s}")
            print(f"{key:13s}" + "".join(f"{c:>12}" for c in cells))

    print("\n\nRECALL BY FRAME ASPECT  -- the axis the `scale_basis` bug lived on\n")
    asps = ["portrait", "square", "landscape"]
    print(f"{'mark':13s}" + "".join(f"{x:>14s}" for x in asps))
    for key in MARKS:
        cells = []
        for asp in asps:
            rs = [r for r in stamped if r["mark"] == key and r["aspect"] == asp]
            cells.append(f"{_rate(rs, 'detected')[0]:.0f}% (n={len(rs)})" if rs else "-")
        print(f"{key:13s}" + "".join(f"{c:>14s}" for c in cells))

    tex = sorted(r["texture"] for r in stamped if "texture" in r)
    if tex:
        t1, t2 = tex[len(tex) // 3], tex[2 * len(tex) // 3]
        print(f"\n\nRECALL BY BACKGROUND TEXTURE  (terciles at {t1:.1f} / {t2:.1f} median-Sobel)\n")
        print(f"{'mark':13s}" + "".join(f"{x:>14s}" for x in ("flat", "mid", "textured")))
        for key in MARKS:
            cells = []
            for lo, hi in ((-1, t1), (t1, t2), (t2, 1e9)):
                rs = [r for r in stamped if r["mark"] == key and lo < r.get("texture", -2) <= hi]
                cells.append(f"{_rate(rs, 'detected')[0]:.0f}% (n={len(rs)})" if rs else "-")
            print(f"{key:13s}" + "".join(f"{c:>14s}" for c in cells))

    worst = collections.Counter((r["mark"], r["size"], r["alpha"]) for r in stamped if not r["detected"]).most_common(8)
    if worst:
        print("\n\nWORST CELLS (most misses)\n")
        for (mark, size, alpha), n in worst:
            print(f"  {mark:13s} size={size}  alpha={alpha}   {n} misses")


if __name__ == "__main__":
    main()
