#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""G4.2 canary — camera-consistency wording presence (Rule A lift verification).

Counts shots in scope whose `t2i_prompt` (any variation) contains camera
consistency wording matching one of 4 patterns (R1-I3). Scope = NON-close
framing shots only (R2-B4 metric scope mutual exclusion).

This script is read-only — it loads a pre-computed scene_detail checkpoint
manifest.json and counts pattern matches. NO LLM calls.

Exit criteria (compared baseline vs candidate, OUT-OF-BAND analysis):
  candidate.metrics.camera_consistency_wording_present_non_close_only
    >= baseline.metrics.camera_consistency_wording_present_non_close_only
  (degradation guard — Rule A lift must not erase camera wording from
  prompts where it is structurally required.)

Usage (single config file mode — preferred):
    python scripts/canary/g4_2_camera_wording.py \\
        --config canary_config.json \\
        --role candidate \\
        --output results/camera_wording_candidate.json

Usage (individual flags mode):
    python scripts/canary/g4_2_camera_wording.py \\
        --pid <pid> --episode <ep> \\
        --cp-root <path-to-scene_detail-cp-dir> \\
        --prompt-version 17.202605042018 \\
        --scene-index-list "[1,2,3]" \\
        --shot-index-list-per-scene '{"1":[1,2],"2":[1],"3":[1,2,3]}' \\
        --model-routing default \\
        --prompt-source-mode file \\
        --card-commit-hash <hex> \\
        --chain-bg-card-snapshot-hash <hex> \\
        --output results/camera_wording.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-I3: 4 camera-consistency wording patterns.
CAMERA_WORDING_PATTERNS: List[str] = [
    r"match.*reference.*(camera|framing|position)",
    r"same.*(angle|position).*reference",
    r"(push in|pull out|deviation).*from.*reference",
    r"reference.*camera_position",
]
_COMPILED_PATTERNS = [re.compile(p, re.IGNORECASE) for p in CAMERA_WORDING_PATTERNS]


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


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


def load_pinning(args: argparse.Namespace) -> Dict[str, Any]:
    """Build the 7-field pinning block from config or individual flags.

    No silent defaults. Missing → exit 1 with clear error.
    """
    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}")
        scene_index_list = cfg.get("scene_index_list")
        shot_index_list_per_scene = cfg.get("shot_index_list_per_scene")
        return {
            "pid": cfg.get("pid"),
            "scene_index_list": scene_index_list,
            "shot_index_list_per_scene": 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"),
        }
    # individual-flag mode
    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 --scene-index-list / --shot-index-list-per-scene JSON: {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. "
            f"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(
    variations: List[Dict[str, Any]],
    *,
    scene_label: str,
    measurement_failures: List[str],
) -> Tuple[bool, List[str]]:
    """Return (any_match, list_of_matched_pattern_strs).

    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
    위반.
    """
    matched_patterns: 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(CAMERA_WORDING_PATTERNS, _COMPILED_PATTERNS):
            if compiled.search(prompt):
                if raw not in matched_patterns:
                    matched_patterns.append(raw)
    return (bool(matched_patterns), matched_patterns)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="G4.2 canary: camera-consistency wording presence (NCF-only)."
    )
    parser.add_argument("--config", type=str, default=None,
                        help="JSON file with all 7 pinning fields + cp_root + episode (preferred)")
    # individual-flag fallbacks
    parser.add_argument("--pid", type=str, default=None)
    parser.add_argument("--episode", type=str, default=None,
                        help="Episode key (used in CP path resolution if cp_root not given)")
    parser.add_argument("--cp-root", type=str, default=None,
                        help="Directory containing scene_detail manifest.json (offline canary mode)")
    parser.add_argument("--prompt-version", type=str, required=True,
                        help="Prompt version label, e.g. 16.202605041200 or 17.202605042018")
    parser.add_argument("--role", type=str, default="candidate",
                        choices=["baseline", "candidate"],
                        help="Reporting role label (baseline or candidate)")
    parser.add_argument("--scene-index-list", type=str, default=None,
                        help="JSON list[int] e.g. '[1,2,3]'")
    parser.add_argument("--shot-index-list-per-scene", type=str, default=None,
                        help='JSON dict, e.g. \'{"1":[1,2],"2":[1]}\'')
    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,
                        help="Output JSON path or '-' for stdout")
    args = parser.parse_args()

    pinning = load_pinning(args)
    validate_pinning(pinning)

    # cp_root resolution — config wins, CLI flag fallback.
    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 → degradation guard 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
    ncf_with_wording = 0
    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 cf:
            cf_count += 1
            continue  # NCF-only metric scope (R2-B4)
        ncf_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
        any_match, matched = scan_scene(
            variations,
            scene_label=f"s{si}_sh{shi}",
            measurement_failures=measurement_failures,
        )
        if any_match:
            ncf_with_wording += 1
        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,
            "camera_consistency_wording_present_non_close_only": ncf_with_wording,
            "camera_consistency_wording_patterns_matched": matched_patterns_union,
        },
        "measurement_failures": measurement_failures,
        "measurement_scripts": {
            "camera_wording": "scripts/canary/g4_2_camera_wording.py (R1-I3 4 pattern, NCF-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_camera_wording] role={args.role} prompt_version={args.prompt_version}\n"
        f"  total_shots={total}  ncf={ncf_count}  cf={cf_count}\n"
        f"  camera_wording_present_non_close_only={ncf_with_wording}\n"
        f"  matched_patterns={matched_patterns_union}\n"
        f"  measurement_failures: {len(measurement_failures)}\n"
        f"Exit criteria (degradation guard, manual cross-compare):\n"
        f"  candidate.metrics.camera_consistency_wording_present_non_close_only\n"
        f"    >= baseline.metrics.camera_consistency_wording_present_non_close_only",
        file=sys.stderr,
    )

    # Codex 1 fix — non-zero exit when measurement integrity is broken.
    # The degradation guard itself is OUT-OF-BAND (analyzer compares two
    # files), so the script's exit code only signals measurement-side
    # failures here.
    if measurement_failures:
        return 1
    return 0


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