"""outdoor_place_spec 모듈 결정론 테스트 (W22 ①).

LLM 완성도는 검증하지 않는다 — 스펙 계약 검증기/블록 조립/재시도 루프만.
fixture 는 전부 시나리오 중립 SAMPLE 데이터.
"""

import pytest

from app.core.errors import AppError
from app.modules.pipeline.outdoor_place_spec import (
    MARKER_CODE_RE,
    PROMPT_VERSION_MAP,
    build_scenes_block,
    build_user_prompt,
    resolve_prompt_version,
    run_outdoor_place_spec,
    validate_place_spec,
)


def _valid_spec():
    return {
        "layout_narration_en": (
            "A small walled yard sits in front of a two-part structure; "
            "a side path connects the yard to the rear service area."
        ),
        "zone_labels_en": ["Front Yard", "Rear Path"],
        "items": [
            {
                "code": "P1",
                "kind": "gate",
                "name_en": "front entry gate",
                "placement_en": "set into the south wall of the yard",
                "inferred": False,
                "evidence": {"scene_index": 3, "quote_ko": "대문을 밀고 들어선다"},
                "temporal_scope": "persistent_site",
            },
            {
                "code": "P2",
                "kind": "yard",
                "name_en": "walled front yard",
                "placement_en": "enclosed area directly inside the entry gate",
                "inferred": False,
                "evidence": {"scene_index": 3, "quote_ko": "마당을 가로질러"},
                "temporal_scope": "persistent_site",
            },
            {
                "code": "Q1",
                "kind": "approach",
                "name_en": "rear service path",
                "placement_en": "narrow path along the west side toward the rear",
                "inferred": True,
                "evidence": None,
                "temporal_scope": "persistent_site",
            },
        ],
        "excluded_transient_elements": [],
    }


# ── prompt version selector ──────────────────────────────────────────


def test_resolve_prompt_version_known():
    assert resolve_prompt_version("1") == PROMPT_VERSION_MAP["1"]


def test_resolve_prompt_version_unknown_raises():
    with pytest.raises(ValueError):
        resolve_prompt_version("99")


# ── validate_place_spec ──────────────────────────────────────────────


def test_validate_valid_spec_passes():
    assert validate_place_spec(_valid_spec()) == []


@pytest.mark.parametrize("bad_code", ["PP1", "p1", "P12", "1P", "P", ""])
def test_validate_bad_code_format(bad_code):
    spec = _valid_spec()
    spec["items"][0]["code"] = bad_code
    violations = validate_place_spec(spec)
    assert any("형식 위반" in v for v in violations)


def test_marker_code_re_accepts_canonical():
    for code in ("P1", "Q3", "Z9", "A0"):
        assert MARKER_CODE_RE.match(code)


def test_validate_duplicate_code():
    spec = _valid_spec()
    spec["items"][1]["code"] = "P1"
    violations = validate_place_spec(spec)
    assert any("중복" in v and "code" in v for v in violations)


def test_validate_duplicate_name_en():
    spec = _valid_spec()
    spec["items"][1]["name_en"] = "Front Entry Gate"  # 대소문자 무시 중복
    violations = validate_place_spec(spec)
    assert any("name_en" in v and "중복" in v for v in violations)


def test_validate_confirmed_item_requires_evidence():
    spec = _valid_spec()
    spec["items"][0]["evidence"] = None  # inferred=False 인데 evidence 없음
    violations = validate_place_spec(spec)
    assert any("inferred=false" in v for v in violations)


def test_validate_inferred_item_allows_null_evidence():
    spec = _valid_spec()
    assert spec["items"][2]["inferred"] is True
    assert spec["items"][2]["evidence"] is None
    assert validate_place_spec(spec) == []


def test_validate_name_en_marker_code_leak_rejected():
    """ID-free 계약 — name_en 에 마커 코드가 섞이면 reject (NARROW_3)."""
    spec = _valid_spec()
    spec["items"][0]["name_en"] = "P1 front entry gate"
    violations = validate_place_spec(spec)
    assert any("name_en" in v and "마커 코드" in v for v in violations)


def test_validate_placement_en_marker_code_leak_rejected():
    spec = _valid_spec()
    spec["items"][2]["placement_en"] = "runs from Q1 toward the rear boundary"
    violations = validate_place_spec(spec)
    assert any("placement_en" in v and "마커 코드" in v for v in violations)


def test_validate_evidence_scene_index_outside_allowed_rejected():
    """evidence-bound — 공급된 씬 밖 scene_index 인용 차단 (NARROW_2)."""
    spec = _valid_spec()
    violations = validate_place_spec(spec, allowed_scene_indices={3})
    assert violations == []
    violations = validate_place_spec(spec, allowed_scene_indices={7})
    assert any("scene_index" in v and "밖" in v for v in violations)


def test_validate_evidence_scene_index_unchecked_when_allowed_none():
    spec = _valid_spec()
    spec["items"][0]["evidence"]["scene_index"] = 999
    assert validate_place_spec(spec) == []


def test_validate_duplicate_zone_labels():
    spec = _valid_spec()
    spec["zone_labels_en"] = ["Front Yard", "Front Yard"]
    violations = validate_place_spec(spec)
    assert any("zone_labels_en" in v and "중복" in v for v in violations)


def test_validate_empty_items():
    spec = _valid_spec()
    spec["items"] = []
    violations = validate_place_spec(spec)
    assert any("items" in v for v in violations)


# ── 블록 조립 ────────────────────────────────────────────────────────


def test_build_scenes_block_preserves_full_text():
    """씬 원문은 절대 자르지 않는다 — 초장문도 전문 보존."""
    long_text = "장면 서술 문장. " * 2000  # ~20k chars
    block = build_scenes_block([(7, long_text)])
    assert long_text in block
    assert "### 씬 7" in block


def test_build_user_prompt_replaces_all_placeholders():
    template = (
        "{group_label}|{locations_block}|{world_rules_block}"
        "|{scenes_block}|{shots_block}"
    )
    out = build_user_prompt(
        template,
        group_label="g1",
        locations_block="locs",
        world_rules_block="rules",
        scenes_block="scenes",
        shots_block="shots",
    )
    assert out == "g1|locs|rules|scenes|shots"
    assert "{" not in out


def test_build_user_prompt_keeps_body_braces_safe():
    # 본문에 중괄호가 있어도 (예: 인용 속 JSON) 깨지지 않아야 함
    template = "{scenes_block}"
    out = build_user_prompt(template, scenes_block='원문에 {"k": 1} 이 있다')
    assert out == '원문에 {"k": 1} 이 있다'


# ── run_outdoor_place_spec 재시도 루프 ───────────────────────────────


def _run_kwargs(fake_fn):
    return dict(
        group={"group_id": "sample_site", "anchor_loc": "L01"},
        outdoor_members=[{"loc_id": "L01", "label": "SAMPLE 마당", "is_indoor": False}],
        entity_locations=[
            {"short_id": "L01", "name": "SAMPLE 마당",
             "description": "담으로 둘러싸인 좁은 앞마당", "visual_traits": ["낮은 담"]}
        ],
        scene_texts=[(3, "그가 대문을 밀고 들어선다. 마당을 가로질러 걷는다.")],
        shots=[{"scene_index": 3, "shot_index": 1,
                "description": "대문으로 들어서는 인물", "camera_direction": "정면"}],
        rules_text="",
        creator_corrections_block="",
        call_structured_fn=fake_fn,
    )


def test_run_returns_spec_on_first_valid():
    calls = []

    def fake(step, system, user, schema, **kw):
        calls.append({"user": user})
        return _valid_spec()

    out = run_outdoor_place_spec(**_run_kwargs(fake))
    assert out["attempts"] == 1
    assert validate_place_spec(out["spec"]) == []
    # 씬 원문 전문이 user 프롬프트에 포함
    assert "대문을 밀고 들어선다" in calls[0]["user"]


def test_run_retries_with_violation_hint_then_succeeds():
    bad = _valid_spec()
    bad["items"][1]["code"] = "P1"  # 중복 코드
    responses = [bad, _valid_spec()]
    calls = []

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

    out = run_outdoor_place_spec(**_run_kwargs(fake))
    assert out["attempts"] == 2
    # 2차 콜 user 에 재시도 힌트(위반 내용) 포함
    assert "재시도" in calls[1]["user"]
    assert "중복" in calls[1]["user"]
    # 1차 콜에는 힌트 없음
    assert "재시도" not in calls[0]["user"]


def test_run_rejects_out_of_range_evidence_then_succeeds():
    """run 이 scene_texts 유래 allowed set 으로 evidence 범위를 잠근다 (NARROW_2)."""
    bad = _valid_spec()
    bad["items"][0]["evidence"]["scene_index"] = 999  # 공급 씬(3) 밖
    responses = [bad, _valid_spec()]
    calls = []

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

    out = run_outdoor_place_spec(**_run_kwargs(fake))
    assert out["attempts"] == 2
    assert "scene_index" in calls[1]


def test_run_exhausts_attempts_raises_app_error():
    bad = _valid_spec()
    bad["items"] = []

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

    with pytest.raises(AppError) as ei:
        run_outdoor_place_spec(**_run_kwargs(fake), max_attempts=2)
    assert ei.value.code == "step.contract_violation.outdoor_place_spec"
    assert ei.value.status_code == 422


def test_run_injects_corrections_into_system():
    captured = {}

    def fake(step, system, user, schema, **kw):
        captured["system"] = system
        return _valid_spec()

    kwargs = _run_kwargs(fake)
    kwargs["creator_corrections_block"] = "\n\n## CREATOR CORRECTIONS\n- SAMPLE 정정"
    run_outdoor_place_spec(**kwargs)
    assert "CREATOR CORRECTIONS" in captured["system"]
    assert captured["system"].endswith("- SAMPLE 정정")


# ── v2 시간 불변 계약 (2026-07-11) ──


def _valid_item(code="A1", **kw):
    base = {
        "code": code, "kind": "gate",
        "name_en": f"{'south' if code.startswith('A') else 'north'} boundary gate",
        "placement_en": "at the south boundary of the site",
        "inferred": True, "evidence": None,
        "temporal_scope": "persistent_site",
    }
    base.update(kw)
    return base


def test_temporal_scope_required_on_items():
    from app.modules.pipeline.outdoor_place_spec import validate_place_spec
    spec = {
        "layout_narration_en": "a" * 50,
        "zone_labels_en": ["Front"],
        "items": [_valid_item(), {**_valid_item("B1"), "temporal_scope": None}],
        "excluded_transient_elements": [],
    }
    v = validate_place_spec(spec)
    assert any("temporal_scope" in x for x in v)


def test_excluded_transient_must_not_have_code():
    from app.modules.pipeline.outdoor_place_spec import validate_place_spec
    spec = {
        "layout_narration_en": "a" * 50,
        "zone_labels_en": ["Front"],
        "items": [_valid_item()],
        "excluded_transient_elements": [
            {"name_en": "temporary object", "reason_en": "moment-specific",
             "code": "Z9"},
        ],
    }
    v = validate_place_spec(spec)
    assert any("code 부여 금지" in x for x in v)


def test_clean_v2_spec_passes():
    from app.modules.pipeline.outdoor_place_spec import validate_place_spec
    spec = {
        "layout_narration_en": "a" * 50,
        "zone_labels_en": ["Front"],
        "items": [_valid_item(), _valid_item("B1")],
        "excluded_transient_elements": [
            {"name_en": "temporary object", "reason_en": "moment-specific",
             "evidence": None},
        ],
    }
    assert validate_place_spec(spec) == []
