#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""G4.5a canary — fg/bg shared-anchor missing detection (Rule G, Task 4.2).

Detect Rule G violations: when t2i_prompt contains 2+ character IDs
(C##/C##O##) AND fg/bg separation token AND interaction verb co-occurrence,
the prompt MUST also include a shared-anchor keyword. Absence is a Rule G
violation.

Scope: per-shot scope (no proximity window). Each violation = 1 t2i variation
that satisfies (2+ char IDs + fg/bg token + interaction verb) but lacks any
shared-anchor keyword.

PR4-B7 binding: STRICT 0 — exit 1 on any violation.

Trap #1 silent-fallback ban / Trap #3 CP shape / Trap #4 pinned-tuple
post-loop validation / Trap #8 module-level constants.

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

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

import argparse
import re
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_FG_BG_SEPARATION_TOKENS = _CONSTS["_SPATIAL_FG_BG_SEPARATION_TOKENS"]
_SPATIAL_INTERACTION_VERBS = _CONSTS["_SPATIAL_INTERACTION_VERBS"]
_SPATIAL_SHARED_ANCHOR_KEYWORDS = _CONSTS["_SPATIAL_SHARED_ANCHOR_KEYWORDS"]
# 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,
)

# C## or C##O## token regex (G4.3 carry — no scenario dependency).
_ID_REGEX = re.compile(r"\bC\d{2}(?:O\d{2})?\b")


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 main() -> int:
    parser = argparse.ArgumentParser(
        description=(
            "G4.5a canary: fg/bg shared-anchor missing detection (fg/bg "
            "interaction shots only, Rule G, per-shot scope, 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_fg_bg_pattern = 0
    fg_bg_shared_anchor_missing_count = 0
    fg_bg_shared_anchor_missing_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

        # per-shot scope: count this shot in denominator if ANY variation
        # exhibits fg/bg pattern. Iterate variations; for each pattern-match
        # variation, check shared-anchor presence.
        shot_has_fg_bg_pattern = False
        shot_violation_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

            char_ids = set(_ID_REGEX.findall(prompt))
            if len(char_ids) < 2:
                continue  # need 2+ distinct character IDs
            if not _has_any_token(prompt, _SPATIAL_FG_BG_SEPARATION_TOKENS):
                continue
            if not _has_any_token(prompt, _SPATIAL_INTERACTION_VERBS):
                continue
            shot_has_fg_bg_pattern = True
            # Rule G violation: shared-anchor keyword absent
            if not _has_any_token(prompt, _SPATIAL_SHARED_ANCHOR_KEYWORDS):
                shot_violation_count += 1

        scene_set_out.append({
            "scene_index": si_int,
            "shot_index": shi_int,
            "has_fg_bg_pattern": shot_has_fg_bg_pattern,
        })
        if shot_has_fg_bg_pattern:
            total_shots_with_fg_bg_pattern += 1
        if shot_violation_count > 0:
            fg_bg_shared_anchor_missing_count += shot_violation_count
            shot_key = f"{si_int}_{shi_int}"
            # PR4-B1 / PR-fix-iter-1-1 — explicit init, NOT silent fallback.
            if shot_key in fg_bg_shared_anchor_missing_per_shot:
                fg_bg_shared_anchor_missing_per_shot[shot_key] += (
                    shot_violation_count
                )
            else:
                fg_bg_shared_anchor_missing_per_shot[shot_key] = (
                    shot_violation_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_fg_bg_pattern": total_shots_with_fg_bg_pattern,
            "fg_bg_shared_anchor_missing_count": (
                fg_bg_shared_anchor_missing_count
            ),
            "fg_bg_shared_anchor_missing_per_shot": (
                fg_bg_shared_anchor_missing_per_shot
            ),
        },
        "measurement_failures": measurement_failures,
        "exit_criteria_threshold": 0,
        "exit_criteria_pass": (
            not measurement_failures
            and fg_bg_shared_anchor_missing_count == 0
        ),
        "measurement_scripts": {
            "fg_bg_shared_anchor": (
                "scripts/canary/g4_5a_fg_bg_shared_anchor.py "
                "(fg/bg interaction shots only, per-shot scope, "
                "Rule G, Override O-17 regex 1차 only)"
            )
        },
    }

    emit_output(out, args)

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

    if measurement_failures:
        return 1
    if args.role == "candidate" and fg_bg_shared_anchor_missing_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_fg_bg_pattern": 0,
            "fg_bg_shared_anchor_missing_count": 0,
            "fg_bg_shared_anchor_missing_per_shot": {},
        },
        "measurement_failures": measurement_failures,
        "exit_criteria_threshold": 0,
        "exit_criteria_pass": False,
        "measurement_scripts": {
            "fg_bg_shared_anchor": (
                "scripts/canary/g4_5a_fg_bg_shared_anchor.py "
                "(fg/bg interaction shots only, Rule G)"
            )
        },
    }


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