#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""G4.2 canary — STRICT forbidden close-framing wording counter (Rule E lift).

Counts forbidden phrasing in close-framing shots' t2i_prompt strings. v17 must
hit zero — Rule E lift to background_binding.constraints removes the previous
prose; if any forbidden form leaks through, the lift is incomplete.

Patterns (10 total):
  R1-I2: 6 ground-truth forms historically violated by Rule E.
  R2-B3: 4 Rule A close-framing invalidation forms.

Exit criteria (analyzer, OUT-OF-BAND):
  candidate.metrics.rule_e_forbidden_wording_count_close_only == 0  (STRICT)
  baseline.metrics.rule_e_forbidden_wording_count_close_only is informational

Usage:
    python scripts/canary/g4_2_close_forbidden.py \\
        --config canary_config.json --role candidate \\
        --output results/close_forbidden_candidate.json
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

# R1-I2: 6 ground-truth + R2-B3: 4 Rule A close-framing invalidation = 10.
CLOSE_FORBIDDEN_PATTERNS: List[str] = [
    # R1-I2: 6 ground-truth
    r"the existing \w",
    r"from the reference",
    r"use the \w+ from the reference",
    r"preserving the same room perspective",
    r"maintaining the reference'?s framing",
    r"do not generate a new \w",
    # R2-B3: 4 Rule A close-framing invalidation forms
    r"matching the reference camera",
    r"deviation from reference",
    r"same\s+[^\n]{0,20}angle as the reference",
    r"match the reference framing",
]
_COMPILED_PATTERNS = [re.compile(p, re.IGNORECASE) for p in CLOSE_FORBIDDEN_PATTERNS]


def iso8601_now() -> str:
    return datetime.utcnow().isoformat() + "Z"


def _fail(msg: str, code: int = 1) -> None:
    print(f"[g4_2_close_forbidden] ERROR: {msg}", file=sys.stderr)
    sys.exit(code)


def load_pinning(args: argparse.Namespace) -> Dict[str, Any]:
    if args.config:
        cfg_path = Path(args.config)
        if not cfg_path.exists():
            _fail(f"--config file not found: {cfg_path}")
        try:
            with cfg_path.open("r", encoding="utf-8") as fp:
                cfg = json.load(fp)
        except Exception as exc:
            _fail(f"--config parse error: {exc}")
        return {
            "pid": cfg.get("pid"),
            "scene_index_list": cfg.get("scene_index_list"),
            "shot_index_list_per_scene": cfg.get("shot_index_list_per_scene"),
            "model_routing": cfg.get("model_routing"),
            "prompt_source_mode": cfg.get("prompt_source_mode"),
            "card_commit_hash": cfg.get("card_commit_hash"),
            "chain_bg_card_snapshot_hash": cfg.get("chain_bg_card_snapshot_hash"),
        }
    try:
        scene_index_list = (
            json.loads(args.scene_index_list) if args.scene_index_list else None
        )
        shot_index_list_per_scene = (
            json.loads(args.shot_index_list_per_scene)
            if args.shot_index_list_per_scene else None
        )
    except Exception as exc:
        _fail(f"failed to parse list/dict JSON args: {exc}")
    return {
        "pid": args.pid,
        "scene_index_list": scene_index_list,
        "shot_index_list_per_scene": shot_index_list_per_scene,
        "model_routing": args.model_routing,
        "prompt_source_mode": args.prompt_source_mode,
        "card_commit_hash": args.card_commit_hash,
        "chain_bg_card_snapshot_hash": args.chain_bg_card_snapshot_hash,
    }


def validate_pinning(pinning: Dict[str, Any]) -> None:
    required = [
        "pid", "scene_index_list", "shot_index_list_per_scene",
        "model_routing", "prompt_source_mode",
        "card_commit_hash", "chain_bg_card_snapshot_hash",
    ]
    missing = [k for k in required if pinning.get(k) in (None, "")]
    if missing:
        _fail(
            f"pinning missing required field(s): {missing}. "
            f"Provide via --config <json> or individual flags. NO silent defaults."
        )
    if not isinstance(pinning["scene_index_list"], list):
        _fail("pinning.scene_index_list must be list[int]")
    if not isinstance(pinning["shot_index_list_per_scene"], dict):
        _fail("pinning.shot_index_list_per_scene must be dict[str, list[int]]")


def build_pinning_scene_set(pinning: Dict[str, Any]) -> List[Tuple[int, int]]:
    """Cartesian (scene_index, shot_index) tuples from pinning block.

    Strict — pinning enumerates EXACT (si, shi) tuples. A scene_index without
    a corresponding shot_index_list entry (or with a non-list / empty list)
    is a contract violation, not a silent default. fail-fast via _fail.
    """
    out: List[Tuple[int, int]] = []
    shot_map = pinning["shot_index_list_per_scene"]
    for si in pinning["scene_index_list"]:
        si_int = int(si)
        raw_shots = shot_map.get(str(si_int))
        if raw_shots is None:
            raw_shots = shot_map.get(si_int)
        if raw_shots is None:
            _fail(
                f"pinning.scene_index_list contains scene {si_int} but "
                f"shot_index_list_per_scene has no entry for it. NO silent default."
            )
        if not isinstance(raw_shots, list):
            _fail(
                f"pinning.shot_index_list_per_scene[{si_int}] must be list[int], "
                f"got {type(raw_shots).__name__}. NO silent default."
            )
        if not raw_shots:
            _fail(
                f"pinning.shot_index_list_per_scene[{si_int}] is empty — "
                f"a pinned scene must enumerate at least one shot. NO silent default."
            )
        for shi in raw_shots:
            out.append((si_int, int(shi)))
    return out


def load_cp_manifest(cp_root: Path) -> Dict[str, Any]:
    manifest_path = cp_root / "manifest.json"
    if not manifest_path.exists():
        _fail(f"manifest.json not found at {manifest_path}")
    try:
        with manifest_path.open("r", encoding="utf-8") as fp:
            return json.load(fp)
    except Exception as exc:
        _fail(f"manifest.json parse error: {exc}")


def is_close_framing(scene: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
    """Return (cf_flag, error_message).

    Reads from `scene["render_prompt_card"]["background_binding"]` — the actual
    CP shape produced by detail_steps.py (scene["background_binding"] does NOT
    exist as a flat field). When the scene record is missing the
    `render_prompt_card` envelope or its `background_binding` field, returns
    (False, reason) so the caller can append to measurement_failures
    (NO silent absorb — Codex C1 fix).

    error_message is None on success.
    """
    rpc = scene.get("render_prompt_card")
    if not isinstance(rpc, dict):
        return (False, "render_prompt_card missing or not dict")
    bb = rpc.get("background_binding")
    if not isinstance(bb, dict):
        return (False, "render_prompt_card.background_binding missing or not dict")
    if bb.get("mode") == "skipped_close_framing":
        return (True, None)
    if bb.get("close_framing_skips_background_ref") is True:
        return (True, None)
    return (False, None)


def scan_scene_forbidden(
    variations: List[Dict[str, Any]],
    *,
    scene_label: str,
    measurement_failures: List[str],
) -> Tuple[int, List[str]]:
    """Return (occurrence_count, list_of_unique_pattern_strs_that_fired).

    Counts total occurrences across all variations (not unique shots).
    Caller is responsible for validating `variations` is a non-empty list
    (Codex iter2 BLOCKING 2 — silent absorb 차단).

    Per-variation validation (Codex iter3 BLOCKING — `var.get("t2i_prompt") or
    ""` silent absorb 제거):
      - variation 이 dict 아니면 measurement_failures 적재 + skip.
      - t2i_prompt 누락 / non-str / empty 면 measurement_failures 적재 + skip.
    detail_schema.json variation.required 가 t2i_prompt 명시 — 누락은 contract
    위반.
    """
    occurrences = 0
    matched: List[str] = []
    for idx, var in enumerate(variations):
        if not isinstance(var, dict):
            measurement_failures.append(
                f"{scene_label}/var{idx}: variation not a dict"
            )
            continue
        prompt = var.get("t2i_prompt")
        if not isinstance(prompt, str) or not prompt.strip():
            measurement_failures.append(
                f"{scene_label}/var{idx}: t2i_prompt missing, empty, or not str"
            )
            continue
        for raw, compiled in zip(CLOSE_FORBIDDEN_PATTERNS, _COMPILED_PATTERNS):
            hits = compiled.findall(prompt)
            if hits:
                occurrences += len(hits)
                if raw not in matched:
                    matched.append(raw)
    return (occurrences, matched)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="G4.2 canary: STRICT forbidden close-framing wording counter."
    )
    parser.add_argument("--config", type=str, default=None)
    parser.add_argument("--pid", type=str, default=None)
    parser.add_argument("--episode", type=str, default=None)
    parser.add_argument("--cp-root", type=str, default=None,
                        help="Directory containing scene_detail manifest.json")
    parser.add_argument("--prompt-version", type=str, required=True)
    parser.add_argument("--role", type=str, default="candidate",
                        choices=["baseline", "candidate"])
    parser.add_argument("--scene-index-list", type=str, default=None)
    parser.add_argument("--shot-index-list-per-scene", type=str, default=None)
    parser.add_argument("--model-routing", type=str, default=None)
    parser.add_argument("--prompt-source-mode", type=str, default=None,
                        choices=[None, "file", "db"])
    parser.add_argument("--card-commit-hash", type=str, default=None)
    parser.add_argument("--chain-bg-card-snapshot-hash", type=str, default=None)
    parser.add_argument("--output", type=str, required=True)
    args = parser.parse_args()

    pinning = load_pinning(args)
    validate_pinning(pinning)

    cp_root_str: str | None = None
    if args.config:
        with Path(args.config).open("r", encoding="utf-8") as fp:
            cfg = json.load(fp)
        cp_root_str = cfg.get("cp_root")
    if not cp_root_str:
        cp_root_str = args.cp_root
    if not cp_root_str:
        _fail("--cp-root (or config.cp_root) is required")
    cp_root = Path(cp_root_str)
    if not cp_root.exists():
        _fail(f"--cp-root path does not exist: {cp_root}")

    cp = load_cp_manifest(cp_root)
    # Codex 2 (silent absorption fix): reject malformed CP shape explicitly.
    # `(cp.get("data") or {}).get("scenes") or []` would silently produce
    # empty scenes list → STRICT gate trivially passes. fail-fast instead.
    data = cp.get("data")
    if not isinstance(data, dict):
        _fail("cp['data'] is missing or not dict")
    scenes = data.get("scenes")
    if not isinstance(scenes, list):
        _fail("cp['data']['scenes'] is missing or not list")

    pinning_set = set(build_pinning_scene_set(pinning))
    seen_in_cp: set[Tuple[int, int]] = set()

    total = 0
    ncf_count = 0
    cf_count = 0
    forbidden_total = 0
    forbidden_per_shot: Dict[str, int] = {}
    matched_patterns_union: List[str] = []
    scene_set_out: List[Dict[str, Any]] = []
    measurement_failures: List[str] = []

    for scene in scenes:
        if not isinstance(scene, dict):
            continue
        si = scene.get("scene_index")
        shi = scene.get("_shot_index")
        if si is None or shi is None:
            continue
        if (si, shi) not in pinning_set:
            continue
        seen_in_cp.add((int(si), int(shi)))
        total += 1
        cf, cf_err = is_close_framing(scene)
        if cf_err:
            measurement_failures.append(
                f"s{si}_sh{shi}: {cf_err}"
            )
        scene_set_out.append({
            "scene_index": si, "shot_index": shi, "is_close_framing": cf,
        })
        if not cf:
            ncf_count += 1
            continue  # CF-only scope (R2-B4)
        cf_count += 1
        # t2i_variations presence/shape validation — silent absorb 차단.
        # Codex iter2 BLOCKING 2.
        variations = scene.get("t2i_variations")
        if not isinstance(variations, list) or not variations:
            measurement_failures.append(
                f"s{si}_sh{shi}: t2i_variations missing, empty, or not list"
            )
            continue
        occ, matched = scan_scene_forbidden(
            variations,
            scene_label=f"s{si}_sh{shi}",
            measurement_failures=measurement_failures,
        )
        forbidden_total += occ
        forbidden_per_shot[f"{si}_{shi}"] = occ
        for p in matched:
            if p not in matched_patterns_union:
                matched_patterns_union.append(p)

    # Pinned tuple presence check — fail-fast if pinning enumerates a (si, shi)
    # that the CP doesn't contain. silent absorb (data.scenes 가 짧거나 비어 있어도
    # false-pass) 차단. Codex iter2 BLOCKING 1.
    for (si, shi) in pinning_set:
        if (si, shi) not in seen_in_cp:
            measurement_failures.append(
                f"pinning (s{si}_sh{shi}): not present in CP data.scenes"
            )

    out = {
        "timestamp": iso8601_now(),
        "prompt_version": args.prompt_version,
        "role": args.role,
        "pinning": pinning,
        "scene_set": scene_set_out,
        "metrics": {
            "total_shots": total,
            "non_close_framing_shots": ncf_count,
            "close_framing_shots": cf_count,
            "rule_e_forbidden_wording_count_close_only": forbidden_total,
            "rule_e_forbidden_wording_per_shot": forbidden_per_shot,
            "rule_e_forbidden_patterns_matched": matched_patterns_union,
        },
        "measurement_failures": measurement_failures,
        "measurement_scripts": {
            "close_forbidden": "scripts/canary/g4_2_close_forbidden.py (R1-I2 6-pattern + R2-B3 4-pattern, CF-only scope R2-B4)"
        },
    }

    if args.output == "-":
        json.dump(out, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
        sys.stdout.write("\n")
    else:
        out_path = Path(args.output)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        with out_path.open("w", encoding="utf-8") as fp:
            json.dump(out, fp, ensure_ascii=False, indent=2, sort_keys=True)

    print(
        f"[g4_2_close_forbidden] role={args.role} prompt_version={args.prompt_version}\n"
        f"  total_shots={total}  ncf={ncf_count}  cf={cf_count}\n"
        f"  rule_e_forbidden_wording_count_close_only={forbidden_total}\n"
        f"  matched_patterns={matched_patterns_union}\n"
        f"  measurement_failures: {len(measurement_failures)}\n"
        f"Exit criteria (STRICT):\n"
        f"  candidate.metrics.rule_e_forbidden_wording_count_close_only == 0\n"
        f"  baseline value is informational only.",
        file=sys.stderr,
    )

    # Codex 1 fix — non-zero exit on STRICT gate failure (candidate role only)
    # OR when measurement_failures is non-empty (cannot judge gate). Baseline
    # role is informational measurement only — always exit 0 unless
    # measurement integrity itself is broken.
    if measurement_failures:
        return 1
    if args.role == "candidate" and forbidden_total > 0:
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
