#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""G4.5a canary — camera/frame consistency (Rule F, Task 4.1).

Detect Rule F violations: `camera_direction` vertical token (low / high /
hip) + frame-edge surface position token (chest / waist / floor / ceiling)
co-occurrence in t2i_prompt with **±50 chars proximity window** that forms
a contradiction pattern (low-camera + chest/waist height frame-edge ⇒
camera height contradicts frame-edge surface presence).

Scope: pinned shots whose `render_strategy.camera_direction` contains 1+
camera vertical token (low / high / hip). Other shots are out-of-scope
(分母 from total_shots_with_camera_vertical only).

PR4-B7 / RO-8 binding: STRICT 0 — exit 1 on any violation, NOT soft warning.

PR-fix-iter-1-3 disambiguation: ±50 chars proximity window is THIS script's
algorithm (G4.4 carry style — char-level, not sentence regex). RO-8 ±20
tokens applies ONLY to `g4_5a_primary_framing.py`.

Trap #1 silent-fallback ban — NO `.get(key, default)` with implicit default;
only `_DEFAULT_SENTINEL` is permitted (PR-fix-iter-1-1 strengthened).
Trap #3 CP shape `scene["render_prompt_card"]["render_strategy"]` (NOT flat).
Trap #4 pinned-tuple post-loop `seen_in_cp` validation (Wave 3 carry).
Trap #8 module-level constants — direct import via render_prompt_card_imports.

Override O-17 — regex/substring detector is 1차 only. baseline 비0 시 P1
follow-up: LLM-validator (gpt-5.4-mini judge) 전환. brittle pattern carry.

Usage (config mode):
    python scripts/canary/g4_5a_camera_frame_consistency.py \\
        --config canary_config.json \\
        --cp-root data/projects/<pid>/checkpoints/scene_detail \\
        --prompt-version 20.<timestamp> \\
        --role candidate \\
        --output results/g4_5a_camera_frame_candidate.json
"""
from __future__ import annotations

import argparse
import sys
from datetime import datetime
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from _g4_5a_common import (  # noqa: E402
    _load_scenes_or_fail,
    _validate_scene_preflight,
    build_pinning_scene_set,
    emit_output,
    load_cp_manifest,
    load_pinning_from_args,
    render_prompt_card_imports,
    resolve_cp_root,
    validate_pinning,
)

_CONSTS = render_prompt_card_imports()
_SPATIAL_CAMERA_LOW_TOKENS = _CONSTS["_SPATIAL_CAMERA_LOW_TOKENS"]
_SPATIAL_CAMERA_HIGH_TOKENS = _CONSTS["_SPATIAL_CAMERA_HIGH_TOKENS"]
_SPATIAL_CAMERA_HIP_TOKENS = _CONSTS["_SPATIAL_CAMERA_HIP_TOKENS"]
_SPATIAL_FRAME_EDGE_POSITION_TOKENS = _CONSTS[
    "_SPATIAL_FRAME_EDGE_POSITION_TOKENS"
]
# Direct import for parity with spec §5.4 single-source rule (Trap #8) — the
# helper is invoked transitively by `_validate_scene_preflight` but we re-import
# here so the module-level dependency is auditable via grep.
from app.core.steps.render_prompt_card import (  # noqa: E402,F401
    compute_render_strategy_snapshot_hash,
)

# G4.4 carry: ±50 chars proximity window (NOT sentence regex).
# `feedback_no_regex_postprocessing.md` carry.
_WINDOW_CHARS = 50

# Rule F contradiction patterns (per spec §5.1 + Plan Task 4.1).
# (a) low camera + chest/waist height frame-edge surface → contradiction.
_LOW_CONTRADICTION_TOKENS: tuple[str, ...] = (
    "chest", "waist", "가슴", "허리",
)
# (b) overhead + floor below feet → contradiction.
_HIGH_CONTRADICTION_TOKENS: tuple[str, ...] = (
    "floor", "바닥", "below feet", "아래",
)
# (c) hip-height + ceiling/floor (mismatched vertical extreme) → contradiction.
_HIP_CONTRADICTION_TOKENS: tuple[str, ...] = (
    "ceiling", "천장", "floor", "바닥",
)


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


def _has_any_token(text: str, tokens: tuple) -> bool:
    """case-insensitive substring presence check."""
    text_lower = text.lower()
    return any(t.lower() in text_lower for t in tokens)


def _detect_violations_in_window(
    prompt: str,
    cam_dir: str,
) -> int:
    """Detect Rule F contradiction patterns within ±50 char windows.

    For each frame-edge position token occurrence in prompt, scan ±50 chars
    around it for a contradicting camera-direction-implied position. Returns
    count of distinct violation occurrences in the t2i_prompt.

    Note: cam_dir tells us camera intent (low/high/hip), but the contradiction
    must be observed inside the t2i_prompt itself — the window centred on each
    frame-edge token.
    """
    text_lower = prompt.lower()
    cam_dir_lower = cam_dir.lower()

    # Determine which contradiction set applies given camera_direction.
    contradictions: list[tuple[str, tuple[str, ...]]] = []
    if any(t.lower() in cam_dir_lower for t in _SPATIAL_CAMERA_LOW_TOKENS):
        contradictions.append(("low", _LOW_CONTRADICTION_TOKENS))
    if any(t.lower() in cam_dir_lower for t in _SPATIAL_CAMERA_HIGH_TOKENS):
        contradictions.append(("high", _HIGH_CONTRADICTION_TOKENS))
    if any(t.lower() in cam_dir_lower for t in _SPATIAL_CAMERA_HIP_TOKENS):
        contradictions.append(("hip", _HIP_CONTRADICTION_TOKENS))

    if not contradictions:
        return 0

    violations = 0
    # Walk each frame-edge token occurrence in prompt; ±50 chars window
    # check for any contradiction token nearby.
    for fe_tok in _SPATIAL_FRAME_EDGE_POSITION_TOKENS:
        fe_lower = fe_tok.lower()
        idx = 0
        while True:
            pos = text_lower.find(fe_lower, idx)
            if pos == -1:
                break
            win_start = max(0, pos - _WINDOW_CHARS)
            win_end = min(len(prompt), pos + len(fe_lower) + _WINDOW_CHARS)
            window = text_lower[win_start:win_end]
            for _camera_kind, contradict_tokens in contradictions:
                # Skip self-match: if frame-edge token IS the contradiction
                # token (e.g. "floor" appears in both lists for hip), the
                # bare presence is not a contradiction by itself — we need
                # ALSO a camera-vertical token in the window OR distinct
                # contradiction token. Use distinct-token rule: if any
                # contradiction token != fe_tok exists in window, count it.
                for ct in contradict_tokens:
                    if ct.lower() == fe_lower:
                        continue
                    if ct.lower() in window:
                        violations += 1
                        break
                else:
                    continue
                break
            idx = pos + len(fe_lower)
    return violations


def main() -> int:
    parser = argparse.ArgumentParser(
        description=(
            "G4.5a canary: camera/frame-consistency detection (camera "
            "vertical token shots only, Rule F, ±50 chars window, STRICT 0)."
        )
    )
    parser.add_argument("--config", type=str, default=None)
    parser.add_argument("--pid", type=str, default=None)
    parser.add_argument("--cp-root", type=str, default=None)
    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(
        "--render-strategy-card-snapshot-hash", type=str, default=None
    )
    parser.add_argument("--output", type=str, required=True)
    args = parser.parse_args()

    pinning = load_pinning_from_args(args)
    validate_pinning(pinning)
    cp_root = resolve_cp_root(args)
    cp = load_cp_manifest(cp_root)

    measurement_failures: list[str] = []
    scenes = _load_scenes_or_fail(cp, measurement_failures)
    if scenes is None:
        out = _empty_output(args, pinning, measurement_failures)
        emit_output(out, args)
        return 1

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

    total_shots_with_camera_vertical = 0
    camera_frame_violation_count = 0
    camera_frame_violation_per_shot: dict[str, int] = {}
    scene_set_out: list[dict] = []

    for scene in scenes:
        if not isinstance(scene, dict):
            measurement_failures.append("scene entry is not dict")
            continue
        if "scene_index" not in scene or "_shot_index" not in scene:
            measurement_failures.append(
                "scene missing scene_index/_shot_index"
            )
            continue
        si = scene["scene_index"]
        shi = scene["_shot_index"]
        if si is None or shi is None:
            continue
        si_int = int(si)
        shi_int = int(shi)
        if (si_int, shi_int) not in pinning_set:
            continue
        seen_in_cp.add((si_int, shi_int))

        variations = _validate_scene_preflight(
            scene, si_int, shi_int, measurement_failures
        )
        if variations is None:
            continue

        rpc = scene["render_prompt_card"]
        rs = rpc["render_strategy"]
        if "camera_direction" not in rs:
            measurement_failures.append(
                f"s{si_int}_sh{shi_int}: render_strategy.camera_direction "
                f"key missing"
            )
            continue
        cam_dir = rs["camera_direction"]
        if not isinstance(cam_dir, str):
            measurement_failures.append(
                f"s{si_int}_sh{shi_int}: render_strategy.camera_direction "
                f"not str"
            )
            continue

        has_vert = (
            _has_any_token(cam_dir, _SPATIAL_CAMERA_LOW_TOKENS)
            or _has_any_token(cam_dir, _SPATIAL_CAMERA_HIGH_TOKENS)
            or _has_any_token(cam_dir, _SPATIAL_CAMERA_HIP_TOKENS)
        )
        scene_set_out.append({
            "scene_index": si_int,
            "shot_index": shi_int,
            "has_camera_vertical_token": has_vert,
        })
        if not has_vert:
            continue
        total_shots_with_camera_vertical += 1

        shot_count = 0
        for vi, var in enumerate(variations):
            if not isinstance(var, dict):
                measurement_failures.append(
                    f"s{si_int}_sh{shi_int}_v{vi}: variation not a dict"
                )
                continue
            if "t2i_prompt" not in var:
                measurement_failures.append(
                    f"s{si_int}_sh{shi_int}_v{vi}: t2i_prompt key missing"
                )
                continue
            prompt = var["t2i_prompt"]
            if not isinstance(prompt, str) or not prompt.strip():
                measurement_failures.append(
                    f"s{si_int}_sh{shi_int}_v{vi}: t2i_prompt missing, "
                    f"empty, or not str"
                )
                continue
            shot_count += _detect_violations_in_window(prompt, cam_dir)

        camera_frame_violation_count += shot_count
        if shot_count > 0:
            camera_frame_violation_per_shot[
                f"{si_int}_{shi_int}"
            ] = shot_count

    # Trap #4 — pinned-tuple post-loop validation.
    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_with_camera_vertical": (
                total_shots_with_camera_vertical
            ),
            "camera_frame_violation_count": camera_frame_violation_count,
            "camera_frame_violation_per_shot": (
                camera_frame_violation_per_shot
            ),
        },
        "measurement_failures": measurement_failures,
        "exit_criteria_threshold": 0,
        "exit_criteria_pass": (
            not measurement_failures
            and camera_frame_violation_count == 0
        ),
        "measurement_scripts": {
            "camera_frame_consistency": (
                "scripts/canary/g4_5a_camera_frame_consistency.py "
                "(camera vertical token shots only, ±50 chars proximity, "
                "Rule F, Override O-17 regex 1차 only)"
            )
        },
    }

    emit_output(out, args)

    print(
        f"[g4_5a_camera_frame_consistency] role={args.role} "
        f"prompt_version={args.prompt_version}\n"
        f"  total_shots_with_camera_vertical="
        f"{total_shots_with_camera_vertical}  "
        f"camera_frame_violation_count={camera_frame_violation_count}\n"
        f"  measurement_failures: {len(measurement_failures)}\n"
        f"Exit criteria (STRICT 0): "
        f"candidate.metrics.camera_frame_violation_count == 0",
        file=sys.stderr,
    )

    if measurement_failures:
        return 1
    if args.role == "candidate" and camera_frame_violation_count > 0:
        return 1
    return 0


def _empty_output(
    args: argparse.Namespace,
    pinning: dict,
    measurement_failures: list,
) -> dict:
    return {
        "timestamp": iso8601_now(),
        "prompt_version": args.prompt_version,
        "role": args.role,
        "pinning": pinning,
        "scene_set": [],
        "metrics": {
            "total_shots_with_camera_vertical": 0,
            "camera_frame_violation_count": 0,
            "camera_frame_violation_per_shot": {},
        },
        "measurement_failures": measurement_failures,
        "exit_criteria_threshold": 0,
        "exit_criteria_pass": False,
        "measurement_scripts": {
            "camera_frame_consistency": (
                "scripts/canary/g4_5a_camera_frame_consistency.py "
                "(camera vertical token shots only, Rule F)"
            )
        },
    }


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