"""outdoor_lane_plan 결정론 테스트 — 스키마/검증/보수 라우팅만.

LLM 판정 완성도는 검증하지 않는다. fixture 전부 시나리오 중립 SAMPLE.
"""
import pytest

from app.modules.pipeline.outdoor_lane_plan import (
    LANES,
    apply_conservative_routing,
    build_lane_schema,
    validate_lane_plan,
)


def _spec():
    return {
        "zone_labels_en": ["Open Field", "Structure Front"],
        "items": [
            {"code": "P1", "kind": "gate", "name_en": "front entry gate"},
        ],
    }


def _shots():
    return [
        {"scene_index": 3, "shot_index": 1,
         "description": "대문을 향해 달려가는 인물의 전신"},
        {"scene_index": 3, "shot_index": 2,
         "description": "멀어지는 뒷모습 와이드"},
    ]


def _scene_texts():
    return {3: "그가 달려간다. 멀어진다."}


def _plan(**over):
    base = {
        "segments": [
            {"segment_id": "seg1", "label_en": "open approach path",
             "dominant_mode": "movement",
             "evidence": [{"scene_index": 3, "quote_ko": "달려간다"}],
             "confidence": "high"},
        ],
        "shot_bindings": [
            {"scene_index": 3, "shot_index": 1, "segment_id": "seg1",
             "lane": "map_marker", "confidence": "high",
             "rationale_ko": "이동 위상이 지배",
             "evidence": {"scene_index": 3, "quote_ko": "달려간다"}},
            {"scene_index": 3, "shot_index": 2, "segment_id": "seg1",
             "lane": "map_marker", "confidence": "high",
             "rationale_ko": "동일 세그먼트 연속 이동",
             "evidence": {"scene_index": 3, "quote_ko": "멀어진다"}},
        ],
    }
    base.update(over)
    return base


def test_schema_locks_lane_and_mode_enums():
    schema = build_lane_schema(_spec())
    b = schema["properties"]["shot_bindings"]["items"]["properties"]
    assert b["lane"]["enum"] == list(LANES)
    s = schema["properties"]["segments"]["items"]["properties"]
    assert s["dominant_mode"]["enum"] == ["movement", "structure"]
    assert s["confidence"]["enum"] == ["high", "medium", "low"]


def test_validate_passes_clean():
    assert validate_lane_plan(_plan(), _shots(), _scene_texts()) == []


def test_validate_rejects_unbound_or_duplicate_shot():
    p = _plan()
    p["shot_bindings"] = p["shot_bindings"][:1]  # (3,2) 누락
    assert any("바인딩 누락" in v
               for v in validate_lane_plan(p, _shots(), _scene_texts()))
    p2 = _plan()
    p2["shot_bindings"].append(dict(p2["shot_bindings"][0]))  # (3,1) 중복
    assert any("중복" in v
               for v in validate_lane_plan(p2, _shots(), _scene_texts()))


def test_validate_rejects_binding_to_unknown_segment():
    p = _plan()
    p["shot_bindings"][0]["segment_id"] = "seg9"
    assert any("segment" in v
               for v in validate_lane_plan(p, _shots(), _scene_texts()))


def test_validate_requires_evidence():
    p = _plan()
    p["shot_bindings"][0]["evidence"] = None
    assert any("evidence" in v
               for v in validate_lane_plan(p, _shots(), _scene_texts()))


def test_validate_rejects_duplicate_segment_id():
    """중복 segment_id — dict 조인이 마지막 세그먼트로 조용히 덮이는 것 차단."""
    p = _plan()
    p["segments"].append(dict(p["segments"][0]))
    out = validate_lane_plan(p, _shots(), _scene_texts())
    assert any("segment_id" in v and "중복" in v for v in out)


def test_validate_rejects_out_of_range_evidence_scene():
    """evidence.scene_index 가 공급된 씬 밖 — 발명 인용 차단 (Codex BLOCKING)."""
    p = _plan()
    p["shot_bindings"][0]["evidence"] = {"scene_index": 999, "quote_ko": "달려간다"}
    assert any("밖" in v for v in validate_lane_plan(p, _shots(), _scene_texts()))
    p2 = _plan()
    p2["segments"][0]["evidence"] = [{"scene_index": 999, "quote_ko": "달려간다"}]
    assert any("밖" in v for v in validate_lane_plan(p2, _shots(), _scene_texts()))


def test_validate_accepts_quote_across_line_breaks():
    """개행을 공백으로 정규화한 인용은 통과 — 5회차 실측(원문 줄바꿈을 LLM
    이 공백으로 인용, 발명 아님). 정규화는 whitespace 만, 의미 판단 없음."""
    p = _plan()
    st = {3: "그가 달려간다.\n멀어진다."}
    p["shot_bindings"][1]["evidence"] = {
        "scene_index": 3, "quote_ko": "달려간다. 멀어진다"}
    assert validate_lane_plan(p, _shots(), st) == []


def test_validate_rejects_fabricated_quote():
    """quote_ko 가 해당 씬 원문에 실재하지 않음 — 증거 무결성 게이트.

    substring 의미 판정이 아니라 LLM 이 제시한 literal 인용의 진위만 확인.
    """
    p = _plan()
    p["shot_bindings"][0]["evidence"] = {
        "scene_index": 3, "quote_ko": "원문에 없는 조작 인용"}
    assert any("원문에 없" in v
               for v in validate_lane_plan(p, _shots(), _scene_texts()))
    p2 = _plan()
    p2["segments"][0]["evidence"] = [
        {"scene_index": 3, "quote_ko": "조작된 세그먼트 인용"}]
    assert any("원문에 없" in v
               for v in validate_lane_plan(p2, _shots(), _scene_texts()))


def test_conservative_routing_low_confidence_and_mixed():
    p = _plan()
    p["shot_bindings"][0]["confidence"] = "low"
    out = apply_conservative_routing(p)
    b0 = out["shot_bindings"][0]
    assert b0["lane"] == "structure_plate"
    assert b0["routed_conservatively"] is True
    # 세그먼트 dominant_mode=structure 인데 lane=map_marker → 혼합 → 보수
    p2 = _plan()
    p2["segments"][0]["dominant_mode"] = "structure"
    out2 = apply_conservative_routing(p2)
    assert all(b["lane"] == "structure_plate" for b in out2["shot_bindings"])


def test_conservative_routing_high_confidence_untouched():
    out = apply_conservative_routing(_plan())
    assert all(b["lane"] == "map_marker" for b in out["shot_bindings"])
    assert all(not b.get("routed_conservatively") for b in out["shot_bindings"])


def _run_kwargs(fake_fn):
    return dict(
        spec=_spec(),
        group_shots=_shots(),
        scene_texts={3: "그가 달려간다. 멀어진다."},
        call_structured_fn=fake_fn,
    )


def test_run_ok_applies_conservative_routing():
    p = _plan()
    p["shot_bindings"][1]["confidence"] = "low"
    calls = []

    def fake(step, system, user, schema, **kw):
        calls.append(user)
        return p

    from app.modules.pipeline.outdoor_lane_plan import (
        run_outdoor_lane_plan_group,
    )
    out = run_outdoor_lane_plan_group(**_run_kwargs(fake))
    assert out["attempts"] == 1
    assert out["plan"]["shot_bindings"][1]["lane"] == "structure_plate"
    # 샷 서술이 user 프롬프트에 실려야 함 — 5회차 실측 결함(설명 공백
    # → 전 샷 low confidence 양산) 회귀 방지
    user_text = calls[0][0]["text"]
    assert "대문을 향해 달려가는 인물의 전신" in user_text
    assert "멀어지는 뒷모습 와이드" in user_text


def test_run_retries_on_missing_binding_then_ok():
    bad = _plan()
    bad["shot_bindings"] = bad["shot_bindings"][:1]
    responses = [bad, _plan()]
    calls = []

    def fake(step, system, user, schema, **kw):
        calls.append(user)
        return responses[len(calls) - 1]

    from app.modules.pipeline.outdoor_lane_plan import (
        run_outdoor_lane_plan_group,
    )
    out = run_outdoor_lane_plan_group(**_run_kwargs(fake))
    assert out["attempts"] == 2
    assert "재시도" in calls[1][-1]["text"]


def test_run_retries_on_fabricated_evidence_then_ok():
    """조작 인용 응답 → 위반 힌트 재시도 → 정정본 수용 (Codex BLOCKING 회귀)."""
    bad = _plan()
    bad["shot_bindings"][0]["evidence"] = {
        "scene_index": 999, "quote_ko": "원문에 없는 인용"}
    responses = [bad, _plan()]
    calls = []

    def fake(step, system, user, schema, **kw):
        calls.append(user)
        return responses[len(calls) - 1]

    from app.modules.pipeline.outdoor_lane_plan import (
        run_outdoor_lane_plan_group,
    )
    out = run_outdoor_lane_plan_group(**_run_kwargs(fake))
    assert out["attempts"] == 2
    assert "재시도" in calls[1][-1]["text"]


def test_run_exhausts_raises_app_error():
    bad = _plan()
    bad["shot_bindings"][0]["evidence"] = {"scene_index": 3, "quote_ko": ""}

    def fake(step, system, user, schema, **kw):
        return bad

    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_lane_plan import (
        run_outdoor_lane_plan_group,
    )
    with pytest.raises(AppError) as ei:
        run_outdoor_lane_plan_group(**_run_kwargs(fake), max_attempts=2)
    assert ei.value.code == "step.contract_violation.outdoor_lane_plan"


def test_revalidate_persisted_plan_self_coverage_and_explicit():
    """Stage D: persisted plan 소비 직전 재검증 — 무결성은 항상, 커버리지는
    group_shots 명시 시 잠금."""
    from app.modules.pipeline.outdoor_lane_plan import (
        revalidate_persisted_plan,
    )

    plan = _plan()
    # group_shots 미제공 = 바인딩 자기 커버리지 — 무결성 위반 0
    assert revalidate_persisted_plan(plan, _scene_texts()) == []
    # 가짜 인용(원문에 없는 문구) = 위반 검출
    bad = _plan()
    bad["shot_bindings"][0]["evidence"]["quote_ko"] = "존재하지 않는 인용"
    assert revalidate_persisted_plan(bad, _scene_texts())
    # group_shots 명시 = 커버리지 누락 검출
    extra_shots = _shots() + [{"scene_index": 3, "shot_index": 9,
                               "description": "SAMPLE"}]
    violations = revalidate_persisted_plan(
        plan, _scene_texts(), group_shots=extra_shots)
    assert any("바인딩 누락" in v for v in violations)


def test_group_parity_spec_failure_with_current_shots_violates():
    """validate_group_parity — spec 실패 entry(구조키 보존)도 현재 샷이
    있으면 위반, 선택 샷 0 그룹만 허용 (재재리뷰 BLOCKING-1)."""
    from app.modules.pipeline.outdoor_lane_plan import validate_group_parity

    def reconstruct(entry):
        return ([{"scene_index": 3, "shot_index": 1}]
                if 3 in (entry.get("scene_indices") or []) else [])

    spec_groups = {
        "failed_with_shots": {"error": "spec LLM failed",
                              "outdoor_loc_ids": ["L01"],
                              "scene_indices": [3]},
        "skipped_no_shots": {"skipped": "no scenes mapped",
                             "outdoor_loc_ids": ["L99"]},
    }
    out = validate_group_parity(
        lane_data={"groups": {}}, spec_groups=spec_groups,
        reconstruct=reconstruct)
    assert len(out) == 1
    assert "failed_with_shots" in out[0]
    assert "실패/결측" in out[0]


# ── 2026-07-19 재설계 C: 맵 스케치 축소 — 제3 lane "none"(일반 파이프) ──


def test_lane_none_in_enum_and_schema():
    from app.modules.pipeline.outdoor_lane_plan import (
        LANES,
        build_lane_schema,
    )

    assert "none" in LANES
    schema = build_lane_schema({"zones": []})
    lane_enum = (schema["properties"]["shot_bindings"]["items"]
                 ["properties"]["lane"]["enum"])
    assert set(lane_enum) == set(LANES)


def test_pack_v2_resolves_with_none_contract_and_neutrality():
    from pathlib import Path

    from app.modules.pipeline.outdoor_lane_plan import (
        resolve_prompt_version,
    )

    resolved = resolve_prompt_version("2")
    assert resolved.startswith("2.")
    root = Path(__file__).resolve().parents[3]
    text = (root / "prompts" / "_base" / "outdoor_lane_plan" / resolved
            / "system.md").read_text(encoding="utf-8")
    assert '"none"' in text or "lane \"none\"" in text or "none" in text
    # 축소 계약의 축 — 배치·거리·방향이 핵심일 때만 맵
    low = text.lower()
    assert "map" in low
    # 시나리오 고유명사 중립성 (기존 관례)
    for banned in ("금월", "수리영", "혜수", "옥탑", "정류장"):
        assert banned not in text


def test_validate_accepts_none_lane():
    from app.modules.pipeline.outdoor_lane_plan import validate_lane_plan

    shots = [{"scene_index": 2, "shot_index": 3}]
    texts = {2: "골목에서 남자가 고개를 든다"}
    result = {
        "segments": [{
            "segment_id": "seg1", "label_en": "narrow alley",
            "dominant_mode": "movement",
            "evidence": [{"scene_index": 2,
                          "quote_ko": "골목에서 남자가 고개를 든다"}],
        }],
        "shot_bindings": [{
            "scene_index": 2, "shot_index": 3, "segment_id": "seg1",
            "lane": "none", "confidence": "high",
            "evidence": {"scene_index": 2,
                         "quote_ko": "골목에서 남자가 고개를 든다"},
        }],
    }
    assert validate_lane_plan(result, shots, texts) == []


def test_conservative_routing_leaves_none_untouched():
    """none(일반 파이프)=기존 검증 경로 — 저신뢰여도 강등·승격 금지
    (저신뢰 none→map 승격은 맵 과적용 재발 경로)."""
    from app.modules.pipeline.outdoor_lane_plan import (
        apply_conservative_routing,
    )

    plan = {
        "segments": [{"segment_id": "s", "dominant_mode": "structure"}],
        "shot_bindings": [
            {"segment_id": "s", "lane": "none", "confidence": "low"},
        ],
    }
    out = apply_conservative_routing(plan)
    assert out["shot_bindings"][0]["lane"] == "none"


def test_step_prompt_version_is_settings_selector():
    """PROMPT_VERSION 하드코딩 제거 — settings selector 로 팩 선택.

    2026-07-25 (사용자 지적②): 기본값이 장소 게이트 팩이어야 한다 —
    selector 가 낮으면 site 선판정/강등이 저작 자체에 걸리지 않아
    "복잡 구조물 장소는 맵 금지"가 무효가 된다(Codex HIGH-4 실측)."""
    import inspect

    from app.core.config import settings
    from app.core.steps import outdoor_lane_plan_step
    from app.modules.pipeline.outdoor_lane_plan import _SITE_GATE_PACKS

    assert settings.outdoor_lane_plan_prompt_version in _SITE_GATE_PACKS
    src = inspect.getsource(outdoor_lane_plan_step)
    assert "outdoor_lane_plan_prompt_version" in src
    assert 'PROMPT_VERSION = "1"' not in src


# ── 팩 v3 (E2E10 fix①): 구조물 세그먼트 map_marker 결정론 가드 ──────────


def _plan_structure_map():
    return {
        "segments": [
            {"segment_id": "segS", "label_en": "building front",
             "dominant_mode": "structure",
             "evidence": [{"scene_index": 3, "quote_ko": "달려간다"}],
             "confidence": "high"},
        ],
        "shot_bindings": [
            {"scene_index": 3, "shot_index": 1, "segment_id": "segS",
             "lane": "map_marker", "confidence": "high",
             "rationale_ko": "배치",
             "evidence": {"scene_index": 3, "quote_ko": "달려간다"}},
            {"scene_index": 3, "shot_index": 2, "segment_id": "segS",
             "lane": "structure_plate", "confidence": "high",
             "rationale_ko": "구조물",
             "evidence": {"scene_index": 3, "quote_ko": "멀어진다"}},
        ],
    }


def test_default_keeps_structure_map_marker_valid():
    # 기본(구버전 팩) = 기존 동작 불변 — 구조물+map_marker 무위반
    assert validate_lane_plan(
        _plan_structure_map(), _shots(), _scene_texts()) == []


def test_v3_forbids_map_marker_on_structure_segment():
    violations = validate_lane_plan(
        _plan_structure_map(), _shots(), _scene_texts(),
        forbid_structure_map=True,
    )
    assert len(violations) == 1
    assert "map_marker" in violations[0] and "(3, 1)" in violations[0]


def test_v3_allows_map_marker_on_movement_segment():
    # movement 세그먼트 map_marker 는 v3 가드 대상 아님
    assert validate_lane_plan(
        _plan(), _shots(), _scene_texts(), forbid_structure_map=True) == []
