"""Can one GENERIC template cover the CJK AI labels no per-vendor detector fires on?

THE OPPORTUNITY
  Corpus inspection of doubao-provenance misses turned up `千问AI生成` (Alibaba Qwen) and
  `百度 AI生成` (Baidu) sitting in the same bottom-right corner as the marks we do cover,
  bold and plainly legible, with no detector able to fire on either. `docs/...landscape`
  puts uncovered vendors at ~6% of sampled images -- larger than any tuning gain left in
  the covered ones (a dense scale ladder recovers 1 miss in 26; see ladder_headroom.py).

WHY A SHARED TEMPLATE IS EVEN PLAUSIBLE
  China's GB 45438-2025 mandates the label, and every compliant vendor ends it with the
  same run: `AI生成`. The vendor PREFIX varies (豆包 / 千问 / 百度 / 星绘) and is what makes
  per-vendor silhouettes expensive; the TAIL is guaranteed. So the tail is the part worth
  detecting, and vendor attribution -- which the shared tail cannot provide anyway
  (measured 千问-vs-doubao AUC ~0.5) -- moves to metadata, where it is reliable.

WHY IT IS WORTH RETRYING NOW
  `render_vendor_silhouettes.py` records 千问 as ruled out, and its reasoning was sound:
  the then-current front-end binarized the glyph, and a thin translucent overlay shatters
  into specks that no template can match. It closes by naming what would be needed --
  "a detection front-end that does not depend on binarizing the glyph (grayscale/edge
  correlation on the raw top-hat)". That front-end now EXISTS: `detect_frontend="tophat"`
  was built for doubao and correlates a soft template against the continuous response.
  The blocker was removed by unrelated work, so the ruling deserves re-measurement rather
  than inheritance.

THE MEASUREMENT
  Three arms, one scorer (the tail template on the tophat response, swept over a dense
  size ladder because an unregistered vendor's glyph size is genuinely unknown -- the one
  place a dense ladder earns its cost):

    covered    a registered mark already fires -- a sanity arm; the tail is inside those
               marks too, so a tail template that cannot score THESE is simply broken
    uncovered  TC260 provenance but no detector fires -- the target population
    clean      no metadata signal at all -- the precision arm

  The decision rests on whether `uncovered` carries a high-scoring subpopulation that
  `clean` does not. It does NOT rest on the raw rate: TC260 provenance does not imply a
  visible mark, so a modest fire rate on that arm is expected even if the detector is
  perfect. Any threshold this suggests must then be confirmed by eyeballing the crops it
  selects -- the script writes a contact sheet for exactly that.

DATA SAFETY
  Treat input datasets as sensitive and read-only, and keep output gitignored. The
  template is font-rendered synthetic, never cut from an input image.

    uv run python scripts/cjk_tail_probe.py --n 6000
"""

from __future__ import annotations

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

import cv2
import numpy as np

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

REPO = Path(__file__).resolve().parents[1]
CORPUS = REPO / ".local-eval" / "originals"
OUT = REPO / ".local-eval" / "cjk-tail-probe.jsonl"
# Cached under the gitignored data dir, not in scripts/: this is a probe artifact, not a
# product asset. If the tail mark is ever registered, `render_vendor_silhouettes.py` is
# what writes the committed silhouette into src/.../assets/.
TAIL_PNG = REPO / ".local-eval" / "cjk-tail-silhouette.png"

# The tail is a fraction of a full vendor mark's width (`豆包AI生成` is ~5 CJK widths,
# `AI生成` ~3), and the prefix length differs per vendor, so the size is genuinely
# unknown here -- unlike a registered mark, whose fraction is calibrated. Hence a wide,
# dense ladder: this is the case a dense ladder is actually for.
TAIL_SCALES = tuple(round(0.30 * (1.08**i), 4) for i in range(18))  # ~0.30 .. ~1.15


def build_tail_silhouette() -> np.ndarray:
    """Font-rendered `AI生成` silhouette, cached next to this script (data-safe)."""
    if TAIL_PNG.exists():
        img = cv2.imread(str(TAIL_PNG), cv2.IMREAD_GRAYSCALE)
        if img is not None:
            return img
    from render_vendor_silhouettes import render

    sil = render("AI生成", width=200)
    cv2.imwrite(str(TAIL_PNG), sil)
    return sil


def tail_score(engine: Any, image: np.ndarray, sil: np.ndarray) -> tuple[float, float]:
    """Best tail correlation over the size ladder; returns (score, winning_scale)."""
    loc = engine.locate(image)
    resp = engine.tophat_response(image, loc)
    if resp is None:
        return (0.0, 0.0)
    c = engine.config
    base = engine.scale_base(image)
    ar = sil.shape[0] / max(1, sil.shape[1])
    best, best_s = 0.0, 0.0
    for s in TAIL_SCALES:
        gw = max(12, int(c.alpha_width_frac * base * s))
        gh = max(6, int(gw * ar))
        if gw >= resp.shape[1] or gh >= resp.shape[0]:
            continue
        t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
        v = float(cv2.matchTemplate(resp, t, cv2.TM_CCOEFF_NORMED).max())
        if v > best:
            best, best_s = v, s
    return (best, best_s)


def _one(path_str: str) -> dict[str, Any] | None:
    from remove_ai_watermarks.api import visible_provenance
    from remove_ai_watermarks.doubao_engine import DoubaoEngine
    from remove_ai_watermarks.identify import identify
    from remove_ai_watermarks.image_io import imread
    from remove_ai_watermarks.watermark_registry import detect_marks

    path = Path(path_str)
    try:
        prov = visible_provenance(path)
    except Exception:
        return None
    tc260 = bool({"doubao", "jimeng"} & prov)

    if not tc260:
        try:
            if identify(path, check_visible=False).signals:
                return None  # some other provenance: neither target nor clean control
        except Exception:
            return None

    img = imread(path_str)
    if img is None or min(img.shape[:2]) < 200:
        return None
    try:
        fired = [d.key for d in detect_marks(img) if d.detected]
    except Exception:
        return None

    if fired:
        arm = "covered"
    elif tc260:
        arm = "uncovered"
    else:
        arm = "clean"

    score, scale = tail_score(DoubaoEngine(), img, build_tail_silhouette())
    return {
        "src": path.name,
        "arm": arm,
        "fired": fired,
        "tail_score": round(score, 4),
        "tail_scale": scale,
    }


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--n", type=int, default=6000)
    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("--report-only", action="store_true")
    ap.add_argument(
        "--sheet-at", type=float, default=0.0, help="write a contact sheet of `uncovered` crops scoring >= this"
    )
    a = ap.parse_args()

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

    build_tail_silhouette()
    pool = glob.glob(str(CORPUS / "*" / "*"))
    random.Random(19).shuffle(pool)  # noqa: S311 -- deterministic sampling, not crypto
    pool = pool[: a.n]
    print(f"scanning {len(pool)} corpus files  workers={a.workers}")
    print(f"tail ladder {TAIL_SCALES[0]} .. {TAIL_SCALES[-1]} ({len(TAIL_SCALES)} rungs)\n", flush=True)

    rows: list[dict[str, Any]] = []
    a.out.parent.mkdir(parents=True, exist_ok=True)
    with open(a.out, "w", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=a.workers) as ex:
        futures = [ex.submit(_one, p) for p in pool]
        for i, fut in enumerate(as_completed(futures), 1):
            try:
                rec = fut.result()
            except Exception:  # noqa: S112 -- one bad file must not kill the sweep
                continue
            if rec is None:
                continue
            fh.write(json.dumps(rec) + "\n")
            rows.append(rec)
            if i % 500 == 0:
                fh.flush()
                print(f"  {i}/{len(pool)}  usable={len(rows)}", flush=True)
    report(rows)
    if a.sheet_at:
        contact_sheet(rows, a.sheet_at)


def report(rows: list[dict[str, Any]]) -> None:
    arms = {k: [r for r in rows if r["arm"] == k] for k in ("covered", "uncovered", "clean")}
    print(f"\n{'=' * 80}\nGENERIC CJK TAIL PROBE  ({', '.join(f'{k}={len(v)}' for k, v in arms.items())})\n{'=' * 80}")
    print("\nScore distribution of the shared `AI生成` tail on the tophat response\n")
    print(f"{'arm':11s} {'n':>6s} {'p50':>7s} {'p75':>7s} {'p90':>7s} {'p95':>7s} {'p99':>7s} {'max':>7s}")
    for k, v in arms.items():
        if not v:
            continue
        s = np.array([r["tail_score"] for r in v])
        qs = [np.percentile(s, q) for q in (50, 75, 90, 95, 99)]
        print(f"{k:11s} {len(v):6d} " + " ".join(f"{q:7.3f}" for q in qs) + f" {s.max():7.3f}")

    if arms["clean"] and arms["uncovered"]:
        clean = np.array([r["tail_score"] for r in arms["clean"]])
        unc = np.array([r["tail_score"] for r in arms["uncovered"]])
        print("\n\nOPERATING POINTS -- threshold set on the CLEAN arm, yield read on `uncovered`")
        print("`clean` fires are the cost; `uncovered` fires are candidate recoveries that")
        print("still need eyeballing, since TC260 provenance does not imply a visible mark.\n")
        print(f"{'threshold':>10s} {'clean fire':>12s} {'uncovered fire':>16s} {'candidates':>12s}")
        for q in (95, 97.5, 99, 99.5, 99.9):
            t = float(np.percentile(clean, q))
            cf = float((clean >= t).mean())
            uf = float((unc >= t).mean())
            print(f"{t:10.3f} {100 * cf:11.2f}% {100 * uf:15.1f}% {int((unc >= t).sum()):12d}")

    cov = arms["covered"]
    if cov:
        s = np.array([r["tail_score"] for r in cov])
        print(f"\n\nSANITY: on frames where a registered mark fires, the tail scores median {np.median(s):.3f}.")
        print("A tail template that cannot score these is broken, whatever it does elsewhere.")


def contact_sheet(rows: list[dict[str, Any]], thresh: float, limit: int = 30) -> None:
    """Crop the corner of the top-scoring `uncovered` frames so the fires can be judged."""
    from remove_ai_watermarks.doubao_engine import DoubaoEngine
    from remove_ai_watermarks.image_io import imread

    eng = DoubaoEngine()
    picks = sorted(
        (r for r in rows if r["arm"] == "uncovered" and r["tail_score"] >= thresh),
        key=lambda r: -r["tail_score"],
    )[:limit]
    tiles = []
    for r in picks:
        hits = glob.glob(str(CORPUS / "*" / r["src"]))
        if not hits:
            continue
        img = imread(hits[0])
        if img is None:
            continue
        loc = eng.locate(img)
        crop = img[loc.y : loc.y + loc.h, loc.x : loc.x + loc.w]
        if crop.size:
            tiles.append(cv2.resize(crop, (320, 96), interpolation=cv2.INTER_AREA))
    if tiles:
        dest = REPO / ".local-eval" / "cjk-tail-sheet.png"
        cv2.imwrite(str(dest), np.vstack(tiles))
        print(f"\ncontact sheet ({len(tiles)} crops, score >= {thresh:.3f}) -> {dest}")
        print("scores: " + ", ".join(f"{r['tail_score']:.2f}" for r in picks[: len(tiles)]))


if __name__ == "__main__":
    main()
