"""Area D-next-min + C7 — shot_dependency_t2i v8 atomic (producer + manifest + registry + helper module) tests.

Area D-next-min (2026-05-15) supersedes Area D-next (2026-05-14). v6 +
immobilized_character enum 폐기 후 v7 + 2-enum (environment / static_prop)
로 좁힘. C7 (2026-05-20) — keep_elements[] 에 subject_kind structured signal
(non_human_visual_element / human_or_character_reference) 신설 → v8 +
schema_version=4. character / person / body 묘사는 keep_elements 가
다루지 않음 (별도 layer 책임: scene_consistency / character_state_variant /
semantic_contract_router).

B1 schema.json kind/subject_kind enum == KEEP_ELEMENT_* (single SOT alignment)
B2 validate_keep_elements_entry helper 동작 + legacy immobilized_character
   거부 + subject_kind enum/fail-fast (C7 신규)
B3 broad except 가 AppError absorb 안 함 + provider failure fallback 행위 검증
B4 STEP_MANIFEST shot_dependency_t2i.schema_version == 4
B5 MODULE_VERSIONS shot_dependency_t2i == "1.5.0" + prompt_dependency v8
B6 v8 system.md 가 ENUM_2 모두 명시 + immobilized_character 의미적 retention 0
B7 (신규) legacy v6 immobilized_character entry 거부 — Area D-next-min 도입
   확인
"""
from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import patch

import pytest

from app.core.errors import AppError
from app.core.keep_elements import (
    KEEP_ELEMENT_KINDS,
    KEEP_ELEMENT_SUBJECT_KINDS,
    validate_keep_elements_entry,
    validate_keep_elements,
)


# pytest 기본 gate 는 CWD=backend 로 실행되므로 prompts/_base 상대경로는 깨진다.
# repo root 를 __file__ 에서 resolve (sibling test_c1/test_c2 와 동일 패턴).
_REPO_ROOT = Path(__file__).resolve().parents[3]
_SHOT_DEP_T2I_PROMPT_ROOT = _REPO_ROOT / "prompts" / "_base" / "shot_dependency_t2i"


# ─────────────────────────────────────────────
# B1 — schema.json kind/subject_kind enum == KEEP_ELEMENT_* (SOT alignment)
# ─────────────────────────────────────────────

def test_b1_v8_schema_kind_enum_matches_single_sot():
    """Area D-next-min + C7 B1 — v8 schema.json kind enum 값 set ==
    KEEP_ELEMENT_KINDS frozenset + subject_kind enum 값 set ==
    KEEP_ELEMENT_SUBJECT_KINDS frozenset. single SOT 일관성 — drift 방지."""
    schema_dir = _SHOT_DEP_T2I_PROMPT_ROOT
    v8_dirs = sorted([d for d in schema_dir.iterdir() if d.is_dir() and d.name.startswith("8.")])
    assert v8_dirs, "v8 prompt 디렉토리 없음 — C7 미완"
    schema_path = v8_dirs[-1] / "schema.json"
    schema = json.loads(schema_path.read_text(encoding="utf-8"))
    keep_items = (
        schema["properties"]["dependencies"]["items"]["properties"]
        ["location_refs"]["items"]["properties"]["keep_elements"]["items"]
    )
    assert keep_items["type"] == "object"
    assert set(keep_items["required"]) == {"label", "kind", "subject_kind"}
    assert keep_items["additionalProperties"] is False
    kind_enum = keep_items["properties"]["kind"]["enum"]
    assert set(kind_enum) == set(KEEP_ELEMENT_KINDS), (
        f"v8 schema kind enum {set(kind_enum)} != KEEP_ELEMENT_KINDS "
        f"{set(KEEP_ELEMENT_KINDS)} — single SOT drift"
    )
    assert set(kind_enum) == {"environment", "static_prop"}, (
        f"v8 schema kind enum 은 2종 (environment / static_prop) 만 — "
        f"immobilized_character 등은 폐기. got {set(kind_enum)}"
    )
    subject_kind_enum = keep_items["properties"]["subject_kind"]["enum"]
    assert set(subject_kind_enum) == set(KEEP_ELEMENT_SUBJECT_KINDS), (
        f"v8 schema subject_kind enum {set(subject_kind_enum)} != "
        f"KEEP_ELEMENT_SUBJECT_KINDS {set(KEEP_ELEMENT_SUBJECT_KINDS)} — "
        f"single SOT drift"
    )


# ─────────────────────────────────────────────
# B2 — validate_keep_elements_entry helper 동작
# ─────────────────────────────────────────────

@pytest.mark.parametrize("bad_entry,expected_msg_fragment", [
    ("plain string", "must be dict"),
    ({"label": "missing kind", "subject_kind": "non_human_visual_element"}, "missing required keys"),
    ({"kind": "environment", "subject_kind": "non_human_visual_element"}, "missing required keys"),
    # C7 — subject_kind 누락 → presence check
    ({"label": "x", "kind": "environment"}, "missing required keys"),
    ({"label": "x", "kind": "unknown_kind", "subject_kind": "non_human_visual_element"}, "must be one of"),
    ({"label": "x", "kind": "", "subject_kind": "non_human_visual_element"}, "must be one of"),
    # Area D-next-min — legacy v6 enum 거부
    ({"label": "unconscious man", "kind": "immobilized_character", "subject_kind": "non_human_visual_element"}, "must be one of"),
    # C7 — invalid subject_kind enum 값
    ({"label": "x", "kind": "environment", "subject_kind": "unknown_subject"}, "subject_kind must be one of"),
    # C7 — human_or_character_reference fail-fast (routing layer 안내)
    ({"label": "the standing man", "kind": "environment", "subject_kind": "human_or_character_reference"}, "semantic_contract_router"),
    # Area D-next v3 (Codex I-4 흡수) — label type check.
    ({"label": 123, "kind": "environment", "subject_kind": "non_human_visual_element"}, "label must be str"),
    ({"label": None, "kind": "environment", "subject_kind": "non_human_visual_element"}, "label must be str"),
    ({"label": ["nested"], "kind": "environment", "subject_kind": "non_human_visual_element"}, "label must be str"),
])
def test_b2_validate_entry_rejects_malformed(bad_entry, expected_msg_fragment):
    """Area D-next-min + C7 B2 — helper 가 keep_elements entry shape 위반 시 AppError raise."""
    with pytest.raises(AppError) as excinfo:
        validate_keep_elements_entry(
            bad_entry, label_source="S1_Shot2 loc_ref[0]",
            error_code="step.shot_dependency_t2i.keep_elements_shape_invalid",
        )
    assert excinfo.value.code == "step.shot_dependency_t2i.keep_elements_shape_invalid"
    assert expected_msg_fragment in str(excinfo.value.message), (
        f"expected message fragment {expected_msg_fragment!r} not in {excinfo.value.message!r}"
    )


def test_b2_validate_entry_accepts_valid():
    """Area D-next-min + C7 B2 — 유효 entry 통과 (kind 2종 + subject_kind non_human)."""
    for entry in [
        {"label": "wooden bench against the wall", "kind": "environment", "subject_kind": "non_human_visual_element"},
        {"label": "broken vase on the floor", "kind": "static_prop", "subject_kind": "non_human_visual_element"},
        {"label": "warm ceiling lamp casting yellow light", "kind": "environment", "subject_kind": "non_human_visual_element"},
        {"label": "open book on the desk", "kind": "static_prop", "subject_kind": "non_human_visual_element"},
    ]:
        validate_keep_elements_entry(
            entry, label_source="S1_Shot2 loc_ref[0]",
            error_code="step.shot_dependency_t2i.keep_elements_shape_invalid",
        )


def test_b2_validate_list_legacy_str_uses_legacy_code():
    """Area D-next-min B2 — validate_keep_elements (list) 가 str entry 만나면
    legacy_str error code 사용 (loader 에서 명시적 운영자 안내)."""
    with pytest.raises(AppError) as excinfo:
        validate_keep_elements(
            ["legacy string"],
            label_source="cp dep[1_2] loc_ref[0]",
            error_code_entry="step.scene_checkpoint_loaders.keep_elements_entry_invalid",
            error_code_legacy_str="step.scene_checkpoint_loaders.keep_elements_legacy_str",
        )
    assert excinfo.value.code == "step.scene_checkpoint_loaders.keep_elements_legacy_str"


# ─────────────────────────────────────────────
# B3 — broad except absorb 차단 + provider failure fallback (M2 흡수)
# ─────────────────────────────────────────────

def _shape_violation_payload(*, entry):
    return {
        "dependencies": [{
            "scene_index": 1,
            "shot_index": 2,
            "location_refs": [{
                "scene_index": 1,
                "shot_index": 1,
                "reason": "test",
                "ref_usage": "exact_background",
                "ignore_elements": "",
                "keep_elements": [entry],
            }],
        }],
    }


def test_b3_process_llm_result_raises_apperror_on_shape_violation():
    """Area D-next-min B3 — _process_llm_result 가 shape 위반 시 AppError raise."""
    from app.core.steps.shot_dependency_t2i_step import _process_llm_result

    bad_output = _shape_violation_payload(entry="bad string entry")
    shots = [
        {"scene_index": 1, "shot_index": 1},
        {"scene_index": 1, "shot_index": 2},
    ]
    with pytest.raises(AppError) as excinfo:
        _process_llm_result(bad_output, shots=shots, loc_id="test_loc")
    assert excinfo.value.code == "step.shot_dependency_t2i.keep_elements_shape_invalid"


def test_b3b_process_llm_result_raises_on_missing_keep_elements_key():
    """Area D-next-min B3b (v3 Codex I-4 신규) — location_ref 에 keep_elements
    key 자체가 없으면 silent empty fallback 차단 + AppError fail-fast."""
    from app.core.steps.shot_dependency_t2i_step import _process_llm_result

    bad_output = {
        "dependencies": [{
            "scene_index": 1,
            "shot_index": 2,
            "location_refs": [{
                "scene_index": 1,
                "shot_index": 1,
                "reason": "test",
                "ref_usage": "exact_background",
                "ignore_elements": "",
                # keep_elements key 의도적으로 missing
            }],
        }],
    }
    shots = [
        {"scene_index": 1, "shot_index": 1},
        {"scene_index": 1, "shot_index": 2},
    ]
    with pytest.raises(AppError) as excinfo:
        _process_llm_result(bad_output, shots=shots, loc_id="test_loc")
    assert excinfo.value.code == "step.shot_dependency_t2i.keep_elements_shape_invalid"
    assert "'keep_elements' key missing" in str(excinfo.value.message)


def test_b3_step_execute_fallback_on_provider_exception():
    """Area D-next-min B3 (M2 흡수) — call_structured 가 generic Exception
    (transport/JSON parse failure) 던지면 broad except 가 catch + warning
    log + failed_count++ + empty refs fallback. AppError 아닌 Exception 만.
    """
    from app.core.steps.shot_dependency_t2i_step import _handle_llm_call_for_loc

    with patch(
        "app.core.steps.shot_dependency_t2i_step.call_structured",
        side_effect=ConnectionError("simulated transport failure"),
    ):
        result, failed_inc = _handle_llm_call_for_loc(
            system="sys", user_prompt="user", schema={},
            project_config={}, opik_metadata={},
            shots=[
                {"scene_index": 1, "shot_index": 1},
                {"scene_index": 1, "shot_index": 2},
            ],
            loc_id="test_loc",
        )

    # provider failure → fallback empty refs
    assert failed_inc == 1
    assert all(v == {"location_refs": []} for v in result.values())
    assert len(result) == 2  # 모든 shot 에 empty refs


def test_b3_step_execute_reraises_apperror():
    """Area D-next-min B3 — _handle_llm_call_for_loc 가 shape AppError 는 catch
    안 하고 caller 까지 re-raise (silent absorb 차단)."""
    from app.core.steps.shot_dependency_t2i_step import _handle_llm_call_for_loc

    bad_output = _shape_violation_payload(entry="bad string entry")

    with patch(
        "app.core.steps.shot_dependency_t2i_step.call_structured",
        return_value=bad_output,
    ):
        with pytest.raises(AppError) as excinfo:
            _handle_llm_call_for_loc(
                system="sys", user_prompt="user", schema={},
                project_config={}, opik_metadata={},
                shots=[
                    {"scene_index": 1, "shot_index": 1},
                    {"scene_index": 1, "shot_index": 2},
                ],
                loc_id="test_loc",
            )
    assert excinfo.value.code == "step.shot_dependency_t2i.keep_elements_shape_invalid"


# ─────────────────────────────────────────────
# B4 — STEP_MANIFEST shot_dependency_t2i.schema_version == 4
# ─────────────────────────────────────────────

def test_b4_step_manifest_schema_version_four():
    from app.core.step_manifest import STEP_MANIFEST

    info = STEP_MANIFEST.get("shot_dependency_t2i")
    assert info is not None
    assert info.get("schema_version") == 4, (
        f"shot_dependency_t2i.schema_version 가 4 여야 함 (C7 v8 bump) — got "
        f"{info.get('schema_version')!r}"
    )


# ─────────────────────────────────────────────
# B5 — version_registry MODULE_VERSIONS + prompt_dependency
# ─────────────────────────────────────────────

def test_b5_version_registry():
    from app.core.version_registry import MODULE_VERSIONS, get_module_info

    # 모듈 버전은 그대로 — v9 는 schema description 을 system.md 포인터로
    # 줄인 것뿐이라 출력 shape 도 소비 계약도 바뀌지 않는다 (prompt diet ⑦).
    assert MODULE_VERSIONS["shot_dependency_t2i"] == "1.5.0"
    info = get_module_info("shot_dependency_t2i")
    assert info["prompt_dependency"] == "shot_dependency_t2i/v9"
    assert info["updated_at"] == "2026-08-04"


# ─────────────────────────────────────────────
# B6 — v8 system.md ENUM_2 grep + immobilized_character semantic retention 0
# ─────────────────────────────────────────────

def test_b6_v8_system_md_lists_enum_two():
    """Area D-next-min + C7 B6 — v8 system.md 가 ENUM_2 만 명시. immobilized_character
    는 negation 인용 (=enum 외 값 금지 명시용) 만 허용."""
    schema_dir = _SHOT_DEP_T2I_PROMPT_ROOT
    v8_dirs = sorted([d for d in schema_dir.iterdir() if d.is_dir() and d.name.startswith("8.")])
    assert v8_dirs, "v8 prompt 디렉토리 없음 — C7 미완"
    sys_md = (v8_dirs[-1] / "system.md").read_text(encoding="utf-8")
    # 2종 enum 명시 (예시 다수 + 정의)
    for kind in ("environment", "static_prop"):
        assert sys_md.count(kind) >= 3, (
            f"v7 system.md 가 kind '{kind}' 를 충분히 명시 안 함 — "
            f"got {sys_md.count(kind)} hits, expected >= 3"
        )
    # immobilized_character: negation 인용 1 회 허용, 의미 retention 0
    immob_hits = sys_md.count("immobilized_character")
    assert immob_hits <= 1, (
        f"v7 system.md 에 immobilized_character 단어가 {immob_hits}회 — "
        f"polled negation (\"enum 외 값 금지\") 1회만 허용, 나머지는 폐기 의무"
    )


# ─────────────────────────────────────────────
# B7 — Area D-next-min 신규: legacy v6 immobilized_character entry 거부
# ─────────────────────────────────────────────

def test_b7_legacy_v6_immobilized_character_rejected_at_entry():
    """Area D-next-min B7 (신규) — legacy v6 kind=immobilized_character entry 가
    producer-side validate_keep_elements_entry 에서 fail-fast 거부."""
    legacy_v6_entry = {
        "label": "unconscious man lying on the floor",
        "kind": "immobilized_character",  # v6 enum, v7+ 에서 폐기
        "subject_kind": "non_human_visual_element",  # C7 — kind 위반에 도달하도록
    }
    with pytest.raises(AppError) as excinfo:
        validate_keep_elements_entry(
            legacy_v6_entry, label_source="S5_Shot7 legacy v6",
            error_code="step.shot_dependency_t2i.keep_elements_shape_invalid",
        )
    assert excinfo.value.code == "step.shot_dependency_t2i.keep_elements_shape_invalid"
    assert "immobilized_character" in str(excinfo.value.message)
    assert "environment" in str(excinfo.value.message)
    assert "static_prop" in str(excinfo.value.message)


def test_b7_legacy_v6_immobilized_character_rejected_at_list():
    """Area D-next-min B7 (신규) — validate_keep_elements list-level 에서도
    동일하게 거부."""
    entries = [
        {"label": "wooden bench", "kind": "environment", "subject_kind": "non_human_visual_element"},  # OK
        {"label": "the dead detective", "kind": "immobilized_character", "subject_kind": "non_human_visual_element"},  # REJECT (kind)
    ]
    with pytest.raises(AppError) as excinfo:
        validate_keep_elements(
            entries,
            label_source="cp dep[5_7] loc_ref[0]",
            error_code_entry="step.scene_checkpoint_loaders.keep_elements_entry_invalid",
            error_code_legacy_str="step.scene_checkpoint_loaders.keep_elements_legacy_str",
        )
    assert excinfo.value.code == "step.scene_checkpoint_loaders.keep_elements_entry_invalid"
