"""저작된 분해 규칙을 다른 모델에게 검토시킨다 — 코드를 만드는 코드의 점검.

지금까지 규칙은 gpt 가 쓰고, 검증은 기계 계약(C1~C7)과 육안이었다. 그런데
**기계 계약이 못 잡는 결함이 있다**는 것을 실측으로 겪었다 — 번호 붙은 헤딩만
잡고 번호 없는 헤딩 하나를 놓친 규칙이 모든 계약을 통과했다. 놓친 씬은 앞
구간에 합쳐져 흔적도 안 남는다. 그래서 사람 아닌 다른 눈이 필요하다.

## 무엇을 묻는가 — 관찰과 세기만

qwen 은 **관찰·셈은 좋고 종합 판정은 못 믿는다**(이 프로젝트의 앞선 실측).
그래서 "이 규칙이 맞나?" 같은 총평을 묻지 않는다. 두 가지만 묻는다:

  · 이 규칙이 **놓친 줄**이 있나 — 있으면 그 줄을 원문 그대로 옮겨라
  · 잡은 목록에 **씬이 아닌 것**이 있나 — 있으면 그 줄을 옮겨라

둘 다 원문에서 찾아 옮기는 일이라, 지어내면 대조로 바로 걸린다.

## 대본을 자르지 않는다

프로젝트 절대 규칙. 규칙 검토는 특히 그렇다 — 앞부분만 보면 뒤에 나오는 다른
형식을 못 본다.

## 모델을 여럿 쓰는 이유

qwen 은 22개 중 **4개를 콘텐츠 검사로 거부**했다. 하필 가장 긴 것들이라
그대로 두면 정작 중요한 대본이 아무 눈도 못 받는다. 그래서 gpt·gemini 를
프로젝트 표준 경로로 붙였다.

★**되돌리기는 끈다.** `call_structured` 는 막히면 gpt 로 넘어가는데, 그러면
"gemini 가 본 결과"가 아니라 gpt 가 본 결과가 gemini 칸에 들어간다. 거부는
거부로 기록해야 어느 모델이 무엇을 못 봤는지 남는다.

사용:
    cd backend && .venv/bin/python -u segmenter_crosscheck.py --model gpt
    cd backend && .venv/bin/python -u segmenter_crosscheck.py --model gemini-pro
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

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

from segmenter_common import (              # noqa: E402 — 위 경로 설정 뒤라야 한다
    fingerprint, load_json, merge_by_name, norm as _norm, save_json)

try:                                    # 키는 backend/.env 에 있다
    from dotenv import load_dotenv

    load_dotenv(Path(__file__).resolve().parent / ".env")
except ImportError:                     # 없으면 환경변수만 쓴다
    pass

ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "artifact" / "20260808_세그먼테이션_저작"
STEP = "segment_rule_crosscheck"

SYSTEM = """\
You check a scene-splitting rule against the screenplay it was written for.

You are given: the full cleaned screenplay text, a regular expression that was
authored for it, and the list of lines that regex matched.

Report only what you can point at in the text. Do not give an overall verdict.

1. missed — lines in the screenplay that start a scene but are NOT in the
   matched list. Copy each line exactly as it appears in the text.
2. not_scenes — lines in the matched list that are NOT the start of a scene
   (for example an item inside a montage, a page number, a transition note).
   Copy each exactly.

If you find none, return empty lists. Do not invent lines — every line you
report must appear verbatim in the screenplay text.
"""

# qwen 전용 꼬리 — DashScope 는 json schema 를 안 받아 형식을 말로 지시한다.
# 표준 경로(gpt·gemini)는 스키마가 형식을 강제하므로 이 문단을 붙이지 않는다.
JSON_TAIL = """
OUTPUT FORMAT — return one single json object and nothing else. No prose
before or after it, no markdown code fence:

{"missed": ["..."], "not_scenes": ["..."], "note": "one sentence, korean"}
"""

CHECK_SCHEMA = {
    "type": "object",
    "properties": {
        "missed": {"type": "array", "items": {"type": "string"},
                   "description": "씬 시작인데 매치 목록에 없는 줄 (원문 그대로)"},
        "not_scenes": {"type": "array", "items": {"type": "string"},
                       "description": "매치됐지만 씬 시작이 아닌 줄 (원문 그대로)"},
        "note": {"type": "string", "description": "한 문장, 한국어"},
    },
    "required": ["missed", "not_scenes", "note"],
    "additionalProperties": False,
}


def qwen_client():
    key = (os.getenv("DASHSCOPE_API_KEY") or "").strip()
    if not key:
        raise RuntimeError("DASHSCOPE_API_KEY 가 비어 있다 — backend/.env 확인")
    from openai import OpenAI

    return OpenAI(
        api_key=key,
        base_url=(os.getenv("DASHSCOPE_BASE_URL")
                  or "https://dashscope-intl.aliyuncs.com/compatible-mode/v1").strip(),
        timeout=600.0,
    )


_FENCE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.S)


def extract_json(text: str) -> dict:
    """추론 텍스트가 앞뒤에 섞여 와도 본체 json 을 꺼낸다."""
    m = _FENCE.search(text or "")
    if m:
        text = m.group(1)
    s = (text or "").strip()
    i = s.find("{")
    if i < 0:
        raise ValueError(f"json 없음: {s[:120]!r}")
    depth = 0
    for j in range(i, len(s)):
        if s[j] == "{":
            depth += 1
        elif s[j] == "}":
            depth -= 1
            if depth == 0:
                return json.loads(s[i:j + 1])
    raise ValueError(f"json 이 안 닫힘: {s[:120]!r}")


def build_user(row: dict, text: str) -> str:
    heads = row.get("headings") or []
    return (
        f"REGEX:\n{row['pattern']}\n\n"
        f"MATCHED LINES ({len(heads)}):\n"
        + "\n".join(f"- {h}" for h in heads)
        + f"\n\nSCREENPLAY (full, {len(text):,} chars):\n{text}"
    )


def check_qwen(client, model: str, row: dict, text: str) -> dict:
    """DashScope 직접 호출 — `json_schema` 를 안 받아 Router 를 못 탄다.

    ★그래서 기록을 손으로 붙인다. litellm 을 우회하는 호출은 콜백이 없어
    llm_call_log 에도 Opik 에도 한 줄이 안 남는다 — 돈은 나가는데 어디에도
    안 보이는 호출이 된다.
    """
    from app.modules.llm.image_tracer import record_provider_call

    user = build_user(row, text)
    t0 = time.monotonic()
    err = None
    try:
        resp = client.chat.completions.create(
            model=model,
            messages=[{"role": "system", "content": SYSTEM + JSON_TAIL},
                      {"role": "user", "content": user}],
            response_format={"type": "json_object"},
        )
        out = extract_json(resp.choices[0].message.content or "")
        return out
    except Exception as exc:               # 기록만 남기고 그대로 올려보낸다
        err = f"{type(exc).__name__}: {exc}"[:500]
        raise
    finally:
        try:
            record_provider_call(
                step=STEP, model=model, prompt=user,
                status="failed" if err else "success",
                duration_ms=int((time.monotonic() - t0) * 1000),
                meta={"script": row.get("name"), "checker_model": model},
                provider="dashscope", error=err,
            )
        except Exception:                  # noqa: BLE001 — 기록이 본 일을 막지 않는다
            pass


def check_standard(alias: str, row: dict, text: str) -> dict:
    """프로젝트 표준 경로 — Router 를 타므로 Opik 기록이 자동으로 붙는다."""
    from app.modules.llm.llm_client import call_structured

    return call_structured(
        step=STEP,
        system_prompt=SYSTEM,
        user_prompt=build_user(row, text),
        response_schema=CHECK_SCHEMA,
        project_config={STEP: {"model": alias}},
        schema_name="segment_crosscheck",
        opik_metadata={"step": STEP, "checker_model": alias,
                       "script": row.get("name")},
        enable_fallback=False,            # ★막히면 막힌 대로 — 위 설명 참고
    )


def verify_verbatim(text: str, lines, *, headings,
                    want_matched: bool) -> tuple[list, list, list]:
    """모델이 옮겼다는 줄이 원문에 있고, **그 주장의 자리에 맞는지** 대조한다.

    돌려주는 것은 (성립, 어긋남, 못 가림) 셋이다.

    ★원문에 있는지만 보면 절반만 재는 것이다(Codex 지적). "놓쳤다"는 주장은
    그 줄이 **잡은 목록에 없어야** 성립하고, "씬이 아니다"는 주장은 그 줄이
    **잡은 목록에 있어야** 성립한다. 원문 아무 데서나 실제 줄 하나를 집어와도
    통과하면, 지어낸 주장을 지어냈다고 못 부른다.

    ★같은 문구가 경계에도 본문에도 있으면 **원리적으로 못 가린다**(Codex
    지적). 모델은 줄 글자만 돌려주고 어느 자리인지는 말하지 않는다. 그때
    성립으로도 어긋남으로도 세지 않고 따로 모은다 — 지어냈다고 부르는 것도,
    진짜라고 확정하는 것도 근거가 없다.

    빈 문자열도 막는다 — 대본에 빈 줄이 있으니 예전 방식으로는 통과했다.
    """
    real, fake, unclear = [], [], []
    in_text = {_norm(ln) for ln in text.split("\n")}
    heads = [_norm(h) for h in (headings or [])]
    in_heads = set(heads)
    # 그 문구가 본문 여러 줄에 나오면서 경계이기도 하면 자리를 못 가린다.
    line_hits: dict = {}
    for ln in text.split("\n"):
        k = _norm(ln)
        line_hits[k] = line_hits.get(k, 0) + 1
    ambiguous = {h for h in in_heads if line_hits.get(h, 0) > heads.count(h)}

    for ln in lines or []:
        key = _norm(ln)
        if not key or key not in in_text:
            fake.append(ln)
        elif key in ambiguous:
            unclear.append(ln)
        elif (key in in_heads) == want_matched:
            real.append(ln)
        else:
            fake.append(ln)
    return real, fake, unclear


def _save(path, rows: list) -> None:
    """새로 잰 것만 갈아 끼우고, 원자적으로 바꿔 끼운다.

    ★`--only` 나 `--rules` 로 일부만 돌려도 예전 코드는 파일을 통째로 덮었다
    (Codex 지적). 틀린 판본을 시험 삼아 검토하고 `--out` 을 빼면, 현행 검토
    기록이 옛 규칙의 판정으로 바뀐다 — 같은 파일을 보면서 다른 것을 보게 된다.
    """
    merged, _ = merge_by_name(path, rows)
    save_json(path, merged)


def run_one(args, qwen, row: dict) -> dict:
    """대본 하나를 검토시키고, 옮겼다는 줄을 원문과 대조해 돌려준다."""
    tf = row.get("text_file")
    if not tf or not (OUT / "used" / tf).exists():
        return {"name": row["name"], "error": "정리본 없음"}
    text = (OUT / "used" / tf).read_text("utf-8")

    # ★규칙이 저작될 때 본 원문과 지금 파일이 같은지 **돈을 쓰기 전에** 본다.
    #  다르면 그 규칙의 경계는 이 원문에서 다른 자리를 가리키므로, 검토를
    #  해봐야 무엇을 본 판단인지 알 수 없다.
    want = row.get("text_sha")
    if want:
        got = hashlib.sha1(text.encode("utf-8")).hexdigest()[:8]
        if got != want:
            return {"name": row["name"],
                    "error": f"정리본이 저작 때와 다르다 (기록 {want} / 지금 {got})"}
    try:
        if args.model == "qwen":
            res = check_qwen(qwen[0], qwen[1], row, text)
        else:
            res = check_standard(args.model, row, text)
    except Exception as exc:  # noqa: BLE001 — 한 대본이 막아도 나머지는 계속
        return {"name": row["name"], "error": f"{type(exc).__name__}: {exc}"[:300]}

    heads = row.get("headings") or []
    # "놓쳤다"는 잡은 목록에 **없어야** 하고, "씬이 아니다"는 **있어야** 한다.
    missed, missed_fake, missed_unclear = verify_verbatim(
        text, res.get("missed"), headings=heads, want_matched=False)
    nots, nots_fake, nots_unclear = verify_verbatim(
        text, res.get("not_scenes"), headings=heads, want_matched=True)
    return {
        "name": row["name"], "count": row["count"], "model": args.model,
        # ★무엇을 검토했는지 남긴다. 개수만으로는 못 가른다 — 오늘 찾은 결함
        #  넷이 전부 "개수는 맞는데 경계가 틀린" 것이었다. 규칙·원문·경계 자리가
        #  바뀌면 지문이 달라져, 옛 검토가 현행 판정으로 읽히는 일을 막는다.
        "checked": fingerprint(row, text),   # ★모델이 본 그 원문으로 접는다
        "missed": missed, "missed_fabricated": missed_fake,
        "not_scenes": nots, "not_scenes_fabricated": nots_fake,
        "unclear": missed_unclear + nots_unclear,
        "note": res.get("note", ""),
    }


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--model", default="qwen", help="qwen / gpt / gemini-pro …")
    ap.add_argument("--only", help="이름에 이 말이 든 대본만")
    ap.add_argument("--rules", default="authored_gpt.json",
                    help="검토할 규칙 파일 — 탐지기 시험엔 틀린 판본을 준다")
    ap.add_argument("--out", help="기록 파일 이름 (기본 crosscheck_<model>.json)")
    ap.add_argument("--workers", type=int, default=4)
    args = ap.parse_args()

    rows = load_json(OUT / args.rules)
    if args.only:
        rows = [r for r in rows if args.only in r["name"]]

    qwen = None
    if args.model == "qwen":
        qwen = (qwen_client(),
                (os.getenv("QWEN_VLM_MODEL") or "qwen3.8-max").strip())

    path = OUT / (args.out or f"crosscheck_{args.model}.json")
    # ★쓸 수 있는 자리인지 **돈을 쓰기 전에** 확인한다(Codex 재현). 전에는 첫
    #  저장 때 처음 읽어서, 기존 파일이 손상돼 있으면 유료 호출을 전부 마친
    #  뒤에 멈췄다 — 돈은 나가고 결과는 한 건도 안 남는다.
    load_json(path)
    print(f"{args.model} · {len(rows)}개 · 동시 {args.workers} → {path.name}",
          flush=True)

    # ★한 건씩 저장한다. 전에는 전부 끝난 뒤 한 번에 썼는데, 그러면 응답 모양
    #  하나가 어긋나 중간에 터질 때 **앞서 돈을 낸 결과까지 통째로 사라진다**.
    out: list = []
    done = 0
    pool = ThreadPoolExecutor(max_workers=args.workers)
    try:
        futures = {pool.submit(run_one, args, qwen, r): r for r in rows}
        for fut in as_completed(futures):
            row = futures[fut]
            try:
                res = fut.result()
            except Exception as exc:       # noqa: BLE001 — 한 건이 전부를 막지 않는다
                res = {"name": row["name"],
                       "error": f"{type(exc).__name__}: {exc}"[:300]}
            out.append(res)
            done += 1
            print(f"  {done}/{len(rows)} {res['name'][:36]}", flush=True)
            _save(path, out)
    finally:
        # ★`with` 로 나가면 아직 시작 안 한 일까지 **기다린다** — 저장이 막혀
        #  멈추는 중에도 남은 유료 호출이 계속 나간다(Codex 지적). 아직 시작
        #  안 한 것은 취소하고 나간다.
        pool.shutdown(wait=True, cancel_futures=True)

    out.sort(key=lambda r: [r["name"] for r in rows].index(r["name"]))
    for i, res in enumerate(out, start=1):
        if res.get("error"):
            print(f"[{i}/{len(out)}] {res['name'][:40]:<40} ✗ {res['error'][:70]}")
            continue
        flag = []
        if res["missed"]:
            flag.append(f"놓침 {len(res['missed'])}")
        if res["not_scenes"]:
            flag.append(f"씬아님 {len(res['not_scenes'])}")
        fake = len(res["missed_fabricated"]) + len(res["not_scenes_fabricated"])
        if fake:
            flag.append(f"★어긋난 줄 {fake}")
        if res.get("unclear"):
            flag.append(f"자리 못 가림 {len(res['unclear'])}")
        print(f"[{i}/{len(out)}] {res['name'][:40]:<40} {res['count']:>4}개  "
              + (" · ".join(flag) if flag else "지적 없음"))

    _save(path, out)
    print(f"\n기록 → {path}")


if __name__ == "__main__":
    main()
