"""야외 3레인 게이트 — place_segment 분할+샷 바인딩+레인 판정 (설계 v2 Stage A).

LLM 은 evidence-bound 구조 JSON 만 저작한다. 코드는 스키마 잠금·완전성
검증·보수 라우팅(저신뢰/혼합→structure_plate)만 담당 — substring/개수
휴리스틱으로 의미를 판단하지 않는다.
"""
from __future__ import annotations

import copy
import re
from typing import Any, Dict, List, Optional, Sequence

LANES = ("map_marker", "structure_plate", "none")
_MODES = ("movement", "structure")
_CONF = ("high", "medium", "low")

_MODULE = "outdoor_lane_plan"

PROMPT_VERSION_MAP = {
    "1": "1.202607142200",
    # 2026-07-19 재설계 C: lane "none"(일반 파이프) 신설 — 맵=배치·거리·
    # 방향이 샷의 요점인 개활지만, 애매하면 none (맵 과적용 축소)
    "2": "2.202607190157",
    # 2026-07-21 E2E10 fix①: 맵=구도·배치가 중요한 "개활지" 한정 계약
    # 명문화 — 구조물 지배 샷·완전 자연(숲 내부/수면/균질 식생) 샷의
    # map_marker 금지(S11sh3 옥탑 철문·S28sh1 바다 실측). v3 저작은
    # validate_lane_plan(forbid_structure_map=True) 결정론 가드 동반.
    "3": "3.202607211500",
    # 2026-07-25 사용자 지적②(옥탑 마당이 map_marker 로 샘): 샷 단위
    # "구조물 지배" 판단은 흔들린다 — 실측 rationale "계단 아래의 열린
    # 지상 공간"처럼 한 귀퉁이의 개활함만 보고 맵을 고른다. v4=**장소
    # 단위 선판정**(site.has_complex_structure)을 먼저 시키고, 참이면
    # 그 장소의 어떤 샷도 map_marker 불가(코드가 결정론 강등).
    "4": "4.202607251321",
}

# 장소 단위 복잡 구조물 게이트가 실리는 팩 (스키마·검증·강등 3층)
_SITE_GATE_PACKS = {"4"}


def resolve_prompt_version(version: str) -> str:
    if version not in PROMPT_VERSION_MAP:
        raise ValueError(f"outdoor_lane_plan 프롬프트 버전 없음: {version}")
    return PROMPT_VERSION_MAP[version]


def build_lane_schema(
    spec: Dict[str, Any], *, include_site: bool = False,
) -> Dict[str, Any]:
    """segments+shot_bindings 스키마 — lane/mode/confidence enum 잠금.

    include_site (팩 v4+): 장소 단위 복잡 구조물 선판정(site) 필드를
    required 로 추가. False=기존 팩 byte-identical.
    """
    evidence = {
        "type": "object",
        "properties": {
            "scene_index": {"type": "integer"},
            "quote_ko": {"type": "string", "minLength": 1},
        },
        "required": ["scene_index", "quote_ko"],
        "additionalProperties": False,
    }
    segment = {
        "type": "object",
        "properties": {
            "segment_id": {"type": "string", "minLength": 1},
            "label_en": {"type": "string", "minLength": 1},
            "dominant_mode": {"enum": list(_MODES)},
            "evidence": {"type": "array", "items": evidence, "minItems": 1},
            "confidence": {"enum": list(_CONF)},
        },
        "required": ["segment_id", "label_en", "dominant_mode",
                     "evidence", "confidence"],
        "additionalProperties": False,
    }
    binding = {
        "type": "object",
        "properties": {
            "scene_index": {"type": "integer"},
            "shot_index": {"type": "integer"},
            "segment_id": {"type": "string", "minLength": 1},
            "lane": {"enum": list(LANES)},
            "confidence": {"enum": list(_CONF)},
            "rationale_ko": {"type": "string", "minLength": 1},
            "evidence": evidence,
        },
        "required": ["scene_index", "shot_index", "segment_id", "lane",
                     "confidence", "rationale_ko", "evidence"],
        "additionalProperties": False,
    }
    props: Dict[str, Any] = {
        "segments": {"type": "array", "items": segment, "minItems": 1},
        "shot_bindings": {"type": "array", "items": binding,
                          "minItems": 1},
    }
    required = ["segments", "shot_bindings"]
    if include_site:
        # v4: 장소 단위 선판정 — 근거는 씬 인용이 아니라 **맵 요소**
        # (place spec) 서술이라 evidence 스키마와 분리한다.
        props["site"] = {
            "type": "object",
            "properties": {
                "has_complex_structure": {"type": "boolean"},
                "structure_evidence_en": {
                    "type": "string", "minLength": 3},
            },
            "required": ["has_complex_structure", "structure_evidence_en"],
            "additionalProperties": False,
        }
        required = ["site"] + required
    return {
        "type": "object",
        "properties": props,
        "required": required,
        "additionalProperties": False,
    }


def ws_normalize(text: str) -> str:
    """연속 whitespace(개행 포함)→단일 공백. 원문 줄바꿈을 LLM 이 공백으로
    인용하는 정상 케이스(5회차 실측 2건) 허용 — 의미 판단 없음.

    2026-07-26: outdoor_marker_map 의 camera_facing evidence provenance 도
    같은 정규화를 쓴다 — 사설 이름 교차 import 를 피해 공개로 승격.
    """
    return re.sub(r"\s+", " ", text).strip()


# 기존 호출부·테스트 호환 별칭 (동일 객체)
_ws_normalize = ws_normalize


def _check_evidence(
    label: str, ev: Any, scene_texts: Dict[int, str]
) -> List[str]:
    """evidence 무결성 — 공급 씬 범위 + literal 인용 실재 (Codex BLOCKING).

    substring 으로 의미를 판정하는 것이 아니라, LLM 이 제시한 literal
    인용이 실제 원문에 존재하는지(증거 진위)만 확인하는 결정론 게이트.
    """
    if not isinstance(ev, dict) or not (ev.get("quote_ko") or "").strip():
        return [f"{label} evidence(씬 인용) 누락"]
    si = ev.get("scene_index")
    if si not in scene_texts:
        return [
            f"{label} evidence.scene_index={si} 는 제공된 씬"
            f"({sorted(scene_texts)}) 밖 — 제공된 씬 원문에서만 인용할 것"
        ]
    quote = _ws_normalize(ev["quote_ko"])
    if quote not in _ws_normalize(scene_texts[si]):
        return [
            f"{label} evidence 인용이 씬 {si} 원문에 없음 — 원문 그대로 "
            f"인용할 것: {quote!r}"
        ]
    return []


def validate_lane_plan(
    result: Dict[str, Any],
    group_shots: Sequence[Dict[str, Any]],
    scene_texts: Dict[int, str],
    *,
    forbid_structure_map: bool = False,
    require_site: bool = False,
) -> List[str]:
    """결정론 완전성·증거 무결성 검증 — 위반 리스트 반환 (fail-closed 재시도용).

    forbid_structure_map (팩 v3, E2E10 fix①): LLM 자신이 structure 로
    선언한 세그먼트에 lane=map_marker 바인딩 금지 — 맵은 개활지 전용.
    세그먼트 mode enum 대조만 하는 결정론 가드(의미 판단 없음).
    default False = 기존 팩(v1/v2) 동작 불변.

    require_site (팩 v4, 2026-07-25 지적②): 장소 단위 선판정 필드의
    **실재**만 검증한다 — 판정 내용에 따른 lane 강등은 재시도가 아니라
    apply_site_complexity_gate 의 결정론 몫(게이트 입력이 없으면 게이트
    자체가 무력화되므로 여기서 fail-closed).
    """
    violations: List[str] = []
    if require_site:
        site = result.get("site")
        if not isinstance(site, dict):
            violations.append(
                "site(장소 단위 복잡 구조물 선판정) 결손 — 팩 v4 필수")
        else:
            if not isinstance(site.get("has_complex_structure"), bool):
                violations.append(
                    "site.has_complex_structure 가 boolean 아님")
            if not str(site.get("structure_evidence_en") or "").strip():
                violations.append("site.structure_evidence_en 비어 있음")
    seg_ids: set = set()
    seg_mode_by_id: Dict[Any, Any] = {}
    for s in result.get("segments") or []:
        sid = s.get("segment_id")
        if sid in seg_ids:
            violations.append(f"segment_id '{sid}' 중복")
        seg_ids.add(sid)
        seg_mode_by_id[sid] = s.get("dominant_mode")
        evs = s.get("evidence") or []
        if not evs:
            violations.append(f"segment '{sid}' evidence(씬 인용) 누락")
        for ev in evs:
            violations.extend(
                _check_evidence(f"segment '{sid}'", ev, scene_texts))
    expected = {(s["scene_index"], s["shot_index"]) for s in group_shots}
    seen: set = set()
    for b in result.get("shot_bindings") or []:
        key = (b.get("scene_index"), b.get("shot_index"))
        if key in seen:
            violations.append(f"샷 {key} 바인딩 중복")
        seen.add(key)
        if b.get("segment_id") not in seg_ids:
            violations.append(
                f"샷 {key} 가 미정의 segment '{b.get('segment_id')}' 참조")
        if (
            forbid_structure_map
            and b.get("lane") == "map_marker"
            and seg_mode_by_id.get(b.get("segment_id")) == "structure"
        ):
            violations.append(
                f"샷 {key}: structure 세그먼트에 lane=map_marker 금지 — "
                "맵은 개활지 전용, structure_plate 또는 none 으로"
            )
        violations.extend(
            _check_evidence(f"샷 {key}", b.get("evidence"), scene_texts))
    missing = expected - seen
    if missing:
        violations.append(f"바인딩 누락 샷: {sorted(missing)}")
    extra = seen - expected
    if extra:
        violations.append(f"대상 밖 샷 바인딩: {sorted(extra)}")
    return violations


def revalidate_persisted_plan(
    plan: Dict[str, Any],
    scene_texts: Dict[int, str],
    group_shots: Optional[Sequence[Dict[str, Any]]] = None,
) -> List[str]:
    """persisted plan 소비 직전 재검증 (Stage D — Codex 배선 조건).

    저장된 plan 을 신뢰하지 않는다: 증거 인용 실재·씬 범위·segment 참조·
    중복을 전부 재검사. group_shots 미제공 시 plan 자신의 바인딩으로
    커버리지 집합을 구성(커버리지 외 무결성 검사는 전부 동일하게 수행) —
    실제 그룹 샷 목록을 가진 소비자는 명시 전달로 커버리지까지 잠근다.
    """
    if group_shots is None:
        group_shots = [
            {"scene_index": b.get("scene_index"),
             "shot_index": b.get("shot_index")}
            for b in plan.get("shot_bindings") or []
        ]
    violations = validate_lane_plan(plan, group_shots, scene_texts)
    # v4 소비 시점 안전망: 장소 게이트가 참인데 map_marker 가 남아 있으면
    # 게이트 이전에 저장된 plan — 맵 경로로 조용히 진행 금지(fail-closed).
    violations.extend(site_gate_violations(plan))
    return violations


def build_group_shot_reconstructor(
    *,
    staging_cp: Optional[Dict[str, Any]],
    validator_cp: Optional[Dict[str, Any]],
    selection_cp: Optional[Dict[str, Any]],
    director_cp: Optional[Dict[str, Any]],
):
    """Stage A 와 동일한 '현재 선택 그룹 샷' 재구성 클로저 (Stage D 공용).

    persisted lane plan 소비자(seed/conti/canon lane filter)가 plan 자기
    바인딩이 아니라 **현재 authoritative 선택 샷 집합**으로 커버리지를
    재검증하기 위한 것 (Codex Stage D BLOCKING-2). 반환 클로저는
    spec 그룹 entry({spec, outdoor_loc_ids, scene_indices})를 받아
    Stage A 저작 입력과 동일한 샷 dict 리스트를 돌려준다
    (staging 샷 + validator description/characters 병합).
    """
    from app.modules.pipeline.outdoor_direct_common import (
        build_selected_keys,
        build_shot_loc_map,
        filter_group_shots,
    )

    staging_shots = (
        (staging_cp or {}).get("data", {}).get("shots", []) or []
    )
    selected_keys = build_selected_keys(selection_cp)
    shot_loc_by_key = build_shot_loc_map(validator_cp)
    scene_primary: Dict[int, str] = {}
    for sc in (director_cp or {}).get("data", {}).get("scenes", []) or []:
        si = sc.get("scene_index")
        primary = sc.get("primary_location", "") or ""
        if si is not None and primary:
            scene_primary[int(si)] = primary
    shot_desc_by_key: Dict[tuple, Dict[str, Any]] = {}
    for sc in (validator_cp or {}).get("data", {}).get("scenes", []) or []:
        v_si = sc.get("scene_index")
        if v_si is None:
            continue
        for sh in sc.get("shots", []) or []:
            v_shi = sh.get("shot_index")
            if v_shi is None:
                continue
            extra: Dict[str, Any] = {}
            if sh.get("description"):
                extra["description"] = sh["description"]
            if sh.get("characters"):
                extra["characters"] = sh["characters"]
            if extra:
                shot_desc_by_key[(int(v_si), int(v_shi))] = extra

    def group_shots_for(spec_entry: Dict[str, Any]) -> List[Dict[str, Any]]:
        shots = filter_group_shots(
            staging_shots,
            scene_indices=(spec_entry or {}).get("scene_indices") or [],
            loc_ids=set((spec_entry or {}).get("outdoor_loc_ids") or []),
            scene_primary=scene_primary,
            shot_loc_by_key=shot_loc_by_key,
            selected_keys=selected_keys,
        )
        return [
            {**sh, **shot_desc_by_key.get(
                (int(sh["scene_index"]), int(sh["shot_index"])), {})}
            for sh in shots
        ]

    return group_shots_for


def validate_group_parity(
    *,
    lane_data: Dict[str, Any],
    spec_groups: Dict[str, Any],
    reconstruct,
) -> List[str]:
    """그룹 단위 parity 검증 (Codex 재리뷰 BLOCKING-1).

    현재 spec 그룹 중 authoritative 재구성 샷이 1개 이상인 **모든** 그룹은
    ①spec 실재 + ②lane plan entry status=ok 여야 한다 — 그룹 누락/failed
    를 통과시키면 해당 샷이 일반 콘티/canon 경로로 조용히 하강한다(핵심
    fail-closed 계약 위반). ①은 재재리뷰 지적: spec 그룹 LLM 실패
    ({error,...})가 lane plan 의 'spec missing' skip 으로 세탁되어 lane
    3분류를 우회하던 상류 fail-open — 실패 entry 의 보존 구조키
    (outdoor_loc_ids/scene_indices)로 현재 샷을 재구성해 검사한다.
    현재 선택 샷 0인 그룹만 entry 부재/skip 허용.
    반환 = 위반 리스트 (빈 리스트 = parity 성립).
    """
    violations: List[str] = []
    lane_groups = (lane_data or {}).get("groups", {}) or {}
    for gid in sorted(spec_groups or {}):
        entry = spec_groups[gid]
        if not isinstance(entry, dict):
            continue
        shots = reconstruct(entry)
        if not shots:
            continue  # 선택 샷 0 — 진짜 비적용 그룹 (허용)
        if not entry.get("spec"):
            violations.append(
                f"그룹 '{gid}' outdoor_place_spec 실패/결측"
                f"(error={entry.get('error')!r}) — 선택 샷 "
                f"{len(shots)}개가 lane 분류 밖"
            )
            continue
        lane_entry = lane_groups.get(gid)
        status = (lane_entry.get("status")
                  if isinstance(lane_entry, dict) else None)
        if status != "ok":
            state = "누락" if lane_entry is None else f"status={status}"
            violations.append(
                f"그룹 '{gid}' lane plan {state} — 선택 샷 "
                f"{len(shots)}개가 lane 미배정"
            )
    return violations


def lane_bindings_by_tag(
    lane_cp_data: Dict[str, Any],
) -> Dict[str, Dict[str, Any]]:
    """CP data.groups → {"S{si}sh{shi}": binding+group/segment 컨텍스트}.

    status=ok 그룹만. 반환 항목 = {lane, segment_id, group_id,
    segment_label_en, confidence, routed_conservatively?} (Stage D 소비용
    결정론 조인 — LLM 판정 재해석 없음).
    """
    out: Dict[str, Dict[str, Any]] = {}
    for gid in sorted((lane_cp_data or {}).get("groups", {}) or {}):
        entry = lane_cp_data["groups"][gid]
        if not isinstance(entry, dict) or entry.get("status") != "ok":
            continue
        plan = entry.get("plan") or {}
        seg_by_id = {
            s.get("segment_id"): s
            for s in plan.get("segments") or []
            if isinstance(s, dict)
        }
        for b in plan.get("shot_bindings") or []:
            if not isinstance(b, dict):
                continue
            si, shi = b.get("scene_index"), b.get("shot_index")
            if si is None or shi is None:
                continue
            seg = seg_by_id.get(b.get("segment_id")) or {}
            out[f"S{int(si)}sh{int(shi)}"] = {
                "lane": b.get("lane"),
                "segment_id": b.get("segment_id"),
                "group_id": gid,
                "segment_label_en": seg.get("label_en") or "",
                "confidence": b.get("confidence"),
                "routed_conservatively": bool(
                    b.get("routed_conservatively")),
            }
    return out


def site_has_complex_structure(plan: Dict[str, Any]) -> bool:
    """장소 단위 선판정 소비 — 필드 부재(구 팩)는 False."""
    site = plan.get("site")
    return (
        isinstance(site, dict)
        and site.get("has_complex_structure") is True
    )


def site_gate_violations(plan: Dict[str, Any]) -> List[str]:
    """장소 게이트 잔존 위반 — 복잡 구조물 장소에 map_marker 가 남음."""
    if not site_has_complex_structure(plan):
        return []
    leaked = sorted(
        (b.get("scene_index"), b.get("shot_index"))
        for b in plan.get("shot_bindings") or []
        if b.get("lane") == "map_marker"
    )
    if not leaked:
        return []
    return [
        f"복잡 구조물 장소인데 lane=map_marker 잔존: {leaked} — 맵은 "
        "구조물 없는 개활 장소 전용 (structure_plate/none 으로)"
    ]


def apply_site_complexity_gate(result: Dict[str, Any]) -> Dict[str, Any]:
    """장소(그룹) 단위 복잡 구조물 게이트 — 2026-07-25 사용자 확정.

    site.has_complex_structure 가 참인 장소에서는 **샷이 아무리 개활해
    보여도** map_marker 를 쓰지 않는다(옥탑 마당 실측: LLM 이 "계단
    아래의 열린 지상 공간"을 근거로 conf=high 맵 배정). 샷별 판단을
    믿지 않고 장소 단위로 결정론 강등한다 — 재시도 비용 0, 차단 확실.
    """
    if not site_has_complex_structure(result):
        return result
    out = copy.deepcopy(result)
    for b in out.get("shot_bindings") or []:
        if b.get("lane") == "map_marker":
            b["lane"] = "structure_plate"
            b["routed_conservatively"] = True
            b["routing_reason"] = "site_complex_structure"
    return out


def apply_conservative_routing(result: Dict[str, Any]) -> Dict[str, Any]:
    """저신뢰/혼합 판정 보수 라우팅 — structure_plate 로 강등+감사 마킹.

    강등 조건(둘 중 하나):
    - binding.confidence == "low" 또는 소속 segment.confidence == "low"
    - binding.lane 과 소속 segment.dominant_mode 가 상충
      (movement↔structure_plate 는 보수 방향이라 허용,
       structure↔map_marker 만 혼합으로 본다)
    """
    out = copy.deepcopy(result)
    seg_by_id = {s["segment_id"]: s for s in out.get("segments") or []}
    for b in out.get("shot_bindings") or []:
        if b.get("lane") == "none":
            # 재설계 C: none=기존 일반 파이프(가장 보수적인 검증 경로) —
            # 저신뢰여도 강등·승격 없음(승격=맵 과적용 재발 경로)
            continue
        seg = seg_by_id.get(b.get("segment_id")) or {}
        low = b.get("confidence") == "low" or seg.get("confidence") == "low"
        mixed = (seg.get("dominant_mode") == "structure"
                 and b.get("lane") == "map_marker")
        if low or mixed:
            b["lane"] = "structure_plate"
            b["routed_conservatively"] = True
            b["routing_reason"] = "low_confidence" if low else "mixed_mode"
    return out


def _shots_block(group_shots: Sequence[Dict[str, Any]]) -> str:
    # 샷 서술+인물+카메라 메모+배경 요소 — grounding 과 동일 조립 (재사용)
    from app.modules.pipeline.outdoor_shot_grounding import build_shot_block

    return "\n\n".join(build_shot_block(s) for s in group_shots)


def _scene_texts_block(scene_texts: Dict[int, str]) -> str:
    parts = []
    for si in sorted(scene_texts):
        # 씬 원문 전문 — 절대 자르지 않는다 (CLAUDE.md 절대 규칙)
        parts.append(f"[Scene {si}]\n{scene_texts[si]}")
    return "\n\n".join(parts)


def run_outdoor_lane_plan_group(
    *,
    spec: Dict[str, Any],
    group_shots: Sequence[Dict[str, Any]],
    scene_texts: Dict[int, str],
    prompt_version: str = "1",
    call_structured_fn=None,
    project_config: Dict[str, Any] | None = None,
    opik_metadata: Dict[str, Any] | None = None,
    max_attempts: int = 3,
) -> Dict[str, Any]:
    """장소 그룹 1개 lane plan — 완전성 위반 시 위반 힌트 재시도.

    반환 {"plan": <보수 라우팅 적용본>, "attempts": n}.
    소진 시 AppError(step.contract_violation.outdoor_lane_plan).
    """
    from app.core.errors import AppError

    if call_structured_fn is None:
        from app.modules.llm.llm_client import call_structured

        call_structured_fn = call_structured

    from app.modules.pipeline.outdoor_shot_grounding import (
        build_legend_block,
    )
    from app.modules.prompt_loader import load_prompt

    resolved = resolve_prompt_version(prompt_version)
    system = load_prompt(_MODULE, "system", version=resolved)
    template = load_prompt(_MODULE, "user_template", version=resolved)
    _site_gate = prompt_version in _SITE_GATE_PACKS
    schema = build_lane_schema(spec, include_site=_site_gate)

    filled = template
    for key, val in {
        "legend_block": build_legend_block(spec),
        "zones_block": "\n".join(
            f"- {z}" for z in spec.get("zone_labels_en", []) or []
        ),
        "shots_block": _shots_block(group_shots),
        "scene_texts_block": _scene_texts_block(scene_texts),
    }.items():
        filled = filled.replace("{" + key + "}", val)
    base_parts = [{"type": "text", "text": filled}]

    attempts = 0
    parts = base_parts
    last: List[str] = []
    while attempts < max_attempts:
        attempts += 1
        result = call_structured_fn(
            _MODULE, system, parts, schema,
            project_config=project_config,
            schema_name=_MODULE,
            opik_metadata=opik_metadata,
        )
        violations = validate_lane_plan(
            result or {}, group_shots, scene_texts,
            # 팩 v3+ 저작 한정 가드 — v1/v2 저작·persisted 재검증은 불변
            forbid_structure_map=(
                prompt_version == "3" or _site_gate),
            require_site=_site_gate,
        )
        if not violations:
            # 장소 게이트(v4)를 마지막에 적용 — 샷 단위 보수 라우팅보다
            # 상위 권위(복잡 구조물 장소는 예외 없이 맵 제외)
            plan = apply_conservative_routing(result)
            if _site_gate:
                plan = apply_site_complexity_gate(plan)
            return {"plan": plan, "attempts": attempts}
        last = violations
        hint = "\n".join(
            ["", "", "[재시도 — 직전 응답이 아래 계약을 위반했습니다. 전부",
             " 고쳐서 전체 결과를 다시 출력하세요:]"]
            + [f"  - {v}" for v in violations]
        )
        parts = base_parts + [{"type": "text", "text": hint}]

    raise AppError(
        code="step.contract_violation.outdoor_lane_plan",
        message=f"lane plan 계약 위반 (attempts={max_attempts}): "
                + "; ".join(last[:8]),
        status_code=422,
    )
