"""D6 T2 (+ T2-fix + T2-fix2) — entity_extractor metadata_json full contract.

Layered contract:
1. ENTITY_DETAIL_SCHEMA 가 metadata_json 을 strict-compliant nested schema 로
   요구 (B4 #1 + T2-fix2 I3): properties.location anyOf null pattern,
   provider strict mode (OpenAI structured outputs) 호환.
2. entity_t2i checkpoint shape 에 metadata_json field 보존 (B4 #2).
   schema_version 3 (T2-fix B1) — 기존 v2 cp 는 stale 처리.
3. entity_t2i._gen_t2i location post-validation (T2-fix I1):
   space_profile shape (kind enum, controlled vocab) 위반 시 retry → fail-fast
   (location 만 done 제외 → failed_count++).
4. EntitySyncService:
   - location entity 의 metadata_json 을 EntityCanon column 에 write (I7).
   - character/prop 은 default '{}' 유지 (column 미터치).
   - schema v3+ cp 에서 location missing/invalid metadata reject (T2-fix I2).
   - legacy / no-schema cp 는 호환 path 유지.

spec: docs/superpowers/specs/2026-05-09-deterministic-bg-id-and-catalog-lineage.md §4.3
plan: docs/superpowers/plans/2026-05-09-d6-deterministic-bg-id-and-catalog-lineage-implementation.md T2
"""
from __future__ import annotations

import json
import uuid
from datetime import datetime, timezone
from pathlib import Path

import pytest


# ───────────────────────────────────────────────
# Layer 1 — ENTITY_DETAIL_SCHEMA
# ───────────────────────────────────────────────


def test_entity_detail_schema_includes_metadata_json_required():
    """metadata_json required + location/visual_identity 둘 다 key 의무 (Area B)."""
    from app.modules.pipeline.entity_extractor_v3 import ENTITY_DETAIL_SCHEMA
    assert "metadata_json" in ENTITY_DETAIL_SCHEMA["required"]
    meta = ENTITY_DETAIL_SCHEMA["properties"]["metadata_json"]
    assert meta["required"] == ["location", "visual_identity"]
    loc_null = meta["properties"]["location"]["anyOf"][0]
    vi_null = meta["properties"]["visual_identity"]["anyOf"][0]
    assert loc_null == {"type": "null"}
    assert vi_null == {"type": "null"}


def test_entity_detail_schema_metadata_json_has_visual_identity_key():
    """Area B: metadata_json schema 가 location + visual_identity 둘 다 nullable key 로 포함."""
    from app.modules.pipeline.entity_extractor_v3 import ENTITY_DETAIL_SCHEMA
    meta = ENTITY_DETAIL_SCHEMA["properties"]["metadata_json"]
    assert meta["required"] == ["location", "visual_identity"]
    assert meta["additionalProperties"] is False
    assert "visual_identity" in meta["properties"]
    vi_schema = meta["properties"]["visual_identity"]
    assert vi_schema["anyOf"][0] == {"type": "null"}
    obj_branch = vi_schema["anyOf"][1]
    assert obj_branch["type"] == "object"
    assert obj_branch["required"] == ["reference_required"]
    assert obj_branch["additionalProperties"] is False
    assert obj_branch["properties"]["reference_required"] == {"type": "boolean"}


def test_entity_extractor_v2_v9_pack_active_for_area_b():
    """Area B: v9 prompt pack 생성 + prompt_loader 가 v9 선택 + 의미 문구 + closed-list 금지 가이드."""
    from app.modules.prompt_loader import load_prompt
    system_text = load_prompt("entity_extractor_v2", "system")
    # production path simulation — entity_name/entity_type kwargs (EntityT2iStep._gen_t2i).
    detail_text = load_prompt(
        "entity_extractor_v2", "turn_entity_detail",
        entity_name="x", entity_type="prop",
    )

    assert "reference_required" in system_text, (
        "system.md must define reference_required (Area B SOT field)"
    )
    assert "reference_required" in detail_text, (
        "turn_entity_detail.md must guide reference_required per entity_type"
    )
    assert "fixed object category list" in detail_text, (
        "turn_entity_detail.md must explicitly forbid 'fixed object category list' "
        "inference (Area B closed-world principle)"
    )


def test_entity_extractor_v2_v9_pack_stem_completeness():
    """Area B: v9 pack 의 stem file 수가 v8 과 동일 (pack drift 차단)."""
    from pathlib import Path

    base = Path(__file__).resolve().parents[3] / "prompts" / "_base" / "entity_extractor_v2"
    v8_dirs = sorted([d for d in base.iterdir() if d.is_dir() and d.name.startswith("8.")])
    v9_dirs = sorted([d for d in base.iterdir() if d.is_dir() and d.name.startswith("9.")])
    assert v8_dirs, "v8 prompt pack must exist"
    assert v9_dirs, "v9 prompt pack must exist (Area B Task 1 Step 9)"

    latest_v8 = v8_dirs[-1]
    latest_v9 = v9_dirs[-1]

    v8_files = sorted(p.name for p in latest_v8.iterdir() if p.is_file())
    v9_files = sorted(p.name for p in latest_v9.iterdir() if p.is_file())
    assert v8_files == v9_files, (
        f"v9 pack stem files must match v8 (pack drift). "
        f"missing in v9: {set(v8_files) - set(v9_files)}, "
        f"extra in v9: {set(v9_files) - set(v8_files)}"
    )


def test_version_registry_entity_extractor_bumped_for_area_b():
    """entity_extractor registry current-state pin — Wave3 (2026-05-21, e2e-review-fix-v1): 1.5.0 + entity_extractor_v2/v12 (active stem hygiene). 이전: C8 1.4.0 / v11."""
    from app.core.version_registry import MODULE_VERSIONS, _MODULE_INFO
    assert MODULE_VERSIONS["entity_extractor"] == "1.5.0"
    info = _MODULE_INFO["entity_extractor"]
    assert info["prompt_dependency"] == "entity_extractor_v2/v12"


# ───────────────────────────────────────────────
# Layer 2 — entity_t2i checkpoint shape
# ───────────────────────────────────────────────


def test_entity_t2i_forward_metadata_json_from_llm_success(monkeypatch):
    """_gen_t2i 가 LLM 출력의 metadata_json 을 forward 하는지 검증.

    description/visual_traits 는 source-forward (entity_detail 결과) — anti-
    hallucination. metadata_json 은 entity_detail batch 가 만들지 않는 field 라
    LLM 출력 그대로 take (location 의 space_profile 분류 결과).
    """
    from app.core.steps.entity_steps import EntityT2iStep

    # call_structured 가 metadata_json 포함된 dict 반환하도록 patch.
    # Area B (Task 3 / C3): metadata_json 은 location + visual_identity 양쪽 key
    # 가 의무 (helper 가 keys=={'location','visual_identity'} 검증). location
    # entity 는 visual_identity=None.
    fake_llm_output = {
        "name": "supermarket",
        "entity_type": "location",
        "description": "ignored — source forward",
        "visual_traits": ["ignored"],
        "t2i_prompt": "bright supermarket interior",
        "metadata_json": {
            "location": {
                "space_profile": {
                    "kind": "single_space",
                    "allowed_space_keys": ["main"],
                    "default_space_key": None,
                }
            },
            "visual_identity": None,
        },
    }
    monkeypatch.setattr(
        "app.core.steps.entity_steps.call_structured",
        lambda **kw: fake_llm_output,
    )
    # _load_prompt / system_prompt 에서 prompt module 호출하므로 noop 처리.
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_system",
        lambda: "system stub",
    )
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_prompt",
        lambda *a, **kw: "user stub",
    )

    # _execute 진입 시 entity_detail cp 필요 → minimal stub.
    step = EntityT2iStep.__new__(EntityT2iStep)
    step.project_id = "p-d6-t2"
    step.episode_id = "e-d6-t2"
    step.project_config = {}
    step.update_progress = lambda *a, **kw: None
    step.build_opik_metadata = lambda *a, **kw: {}

    # entity_detail cp + visual_world_rules cp + entity_t2i cp(absent) stub.
    def _load_cp_stub(name):
        if name == "entity_detail":
            return {
                "data": {
                    "entity_queue": [
                        ["supermarket", "location", "L09"],
                    ],
                    "entity_details": {
                        "supermarket:location": {
                            "description": "src desc",
                            "visual_traits": ["bright"],
                        }
                    },
                }
            }
        if name == "visual_world_rules":
            return {"data": {"era": "modern", "region": "kr", "rules": []}}
        return None

    step._load_prev_checkpoint = _load_cp_stub
    step._save_checkpoint = lambda *a, **kw: None
    step.save_checkpoint = lambda *a, **kw: None  # incremental cp save 차단
    step.load_checkpoint = lambda: None  # own cp resume 진입 차단 (cleanup)

    result = step._execute(mode="resume")
    locs = result["data"]["locations"]
    assert len(locs) == 1
    loc = locs[0]
    # description/visual_traits — source-forward (LLM 출력 무시, src 사용)
    assert loc["description"] == "src desc"
    assert loc["visual_traits"] == ["bright"]
    # t2i_prompt — LLM 출력 take
    assert loc["t2i_prompt"] == "bright supermarket interior"
    # D6 T2: metadata_json — LLM 출력 take (location 만 의미 있음)
    assert "metadata_json" in loc, (
        "entity_t2i cp 의 location entity dict 에 metadata_json field 누락 — "
        "B4 #2 contract 위반"
    )
    assert loc["metadata_json"]["location"]["space_profile"]["kind"] == "single_space"


def test_entity_t2i_failure_path_character_keeps_empty_marker(monkeypatch):
    """character entity LLM 3 attempts 모두 실패 → done 에 들어감 (legacy marker).

    T2-fix (review iter3 I1): location 만 fail-fast (data=None → done 제외).
    character/prop 은 기존 빈-marker pattern 유지 — image gen preflight 가 catch.
    """
    from app.core.steps.entity_steps import EntityT2iStep

    def _raise(**kw):
        raise RuntimeError("LLM permanently down")

    monkeypatch.setattr("app.core.steps.entity_steps.call_structured", _raise)
    monkeypatch.setattr("app.core.steps.entity_steps.time.sleep", lambda *a: None)
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_system",
        lambda: "system stub",
    )
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_prompt",
        lambda *a, **kw: "user stub",
    )

    step = EntityT2iStep.__new__(EntityT2iStep)
    step.project_id = "p-d6-t2-char-fail"
    step.episode_id = "e-d6-t2-char-fail"
    step.project_config = {}
    step.update_progress = lambda *a, **kw: None
    step.build_opik_metadata = lambda *a, **kw: {}

    def _load_cp_stub(name):
        if name == "entity_detail":
            return {
                "data": {
                    "entity_queue": [["hero", "character", "C01"]],
                    "entity_details": {
                        "hero:character": {
                            "description": "src desc",
                            "visual_traits": ["x"],
                        }
                    },
                }
            }
        if name == "visual_world_rules":
            return {"data": {"era": "", "region": "", "rules": []}}
        return None

    step._load_prev_checkpoint = _load_cp_stub
    step._save_checkpoint = lambda *a, **kw: None
    step.save_checkpoint = lambda *a, **kw: None
    step.load_checkpoint = lambda: None

    result = step._execute(mode="resume")
    chars = result["data"]["characters"]
    assert len(chars) == 1
    ch = chars[0]
    assert ch["t2i_prompt"] == "", "character failure marker — t2i_prompt empty"
    # Area B (Task 4 review C1 fix): character failure marker 도 normalized closed
    # shape `{"location": None, "visual_identity": None}` 출력 — Task 4 sync 의
    # strict shape contract 정합. 빈 dict 는 sync 의 keys check 에서 raise 발생.
    assert ch["metadata_json"] == {"location": None, "visual_identity": None}


def test_entity_t2i_failure_path_location_excluded_from_done(monkeypatch):
    """location entity LLM 3 attempts 모두 실패 → done 에서 제외 (failed_count++).

    T2-fix (review iter3 I1): SOT (space_profile) 손실은 silent absorb 안 함.
    """
    from app.core.steps.entity_steps import EntityT2iStep

    def _raise(**kw):
        raise RuntimeError("LLM permanently down")

    monkeypatch.setattr("app.core.steps.entity_steps.call_structured", _raise)
    monkeypatch.setattr("app.core.steps.entity_steps.time.sleep", lambda *a: None)
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_system",
        lambda: "system stub",
    )
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_prompt",
        lambda *a, **kw: "user stub",
    )

    step = EntityT2iStep.__new__(EntityT2iStep)
    step.project_id = "p-d6-t2-loc-fail"
    step.episode_id = "e-d6-t2-loc-fail"
    step.project_config = {}
    step.update_progress = lambda *a, **kw: None
    step.build_opik_metadata = lambda *a, **kw: {}

    def _load_cp_stub(name):
        if name == "entity_detail":
            return {
                "data": {
                    "entity_queue": [["someplace", "location", "L01"]],
                    "entity_details": {
                        "someplace:location": {
                            "description": "src desc",
                            "visual_traits": ["x"],
                        }
                    },
                }
            }
        if name == "visual_world_rules":
            return {"data": {"era": "", "region": "", "rules": []}}
        return None

    step._load_prev_checkpoint = _load_cp_stub
    step._save_checkpoint = lambda *a, **kw: None
    step.save_checkpoint = lambda *a, **kw: None
    step.load_checkpoint = lambda: None

    result = step._execute(mode="resume")
    assert result["completed_count"] == 0, (
        f"location fail 시 done 제외 의무 — got {result}"
    )
    assert result["failed_count"] == 1
    assert result["data"]["locations"] == [], "location fail entity 가 cp 에 누출"


# ───────────────────────────────────────────────
# Layer 3 — EntitySyncService write (all entity_type normalized)
#
# Area B (Task 4 / C4, 2026-05-13): location-only path → all entity_type
# normalized {"location": ..., "visual_identity": ...} shape DB write.
# DB write 전 validate_entity_metadata_shape (L2 fail-fast).
# ───────────────────────────────────────────────


@pytest.fixture
def project_seeded(tmp_path, monkeypatch):
    """test DB 에 fresh user/project + tmp projects_dir + rollback."""
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))

    from app.models.catalog import UserAccount, ProjectRegistry
    from app.models.project import Episode
    from app.core.database import SessionLocal, init_db

    init_db()
    session = SessionLocal()
    try:
        now = datetime.now(timezone.utc).isoformat()
        uid = f"test-user-{uuid.uuid4()}"
        pid = f"test-d6-t2-{uuid.uuid4()}"
        eid = f"test-d6-t2-ep-{uuid.uuid4()}"

        session.add(UserAccount(
            id=uid, username=f"u_{uid}", display_name="t",
            password_hash="x", role="creator", is_active=1,
            created_at=now, updated_at=now,
        ))
        session.flush()
        session.add(ProjectRegistry(
            id=pid, name="d6-t2-test", description="",
            created_by=uid, created_at=now, updated_at=now,
        ))
        session.flush()
        session.add(Episode(
            id=eid, project_id=pid, episode_number=1,
            title="t", source_filename="f.txt", source_path="/tmp/f.txt",
            language="en", status="uploaded", created_at=now, updated_at=now,
        ))
        session.flush()
        yield session, pid, eid, tmp_path
    finally:
        session.rollback()
        session.close()


def _write_entity_t2i_cp(tmp_path: Path, pid: str, eid: str, payload: dict) -> None:
    cp_dir = tmp_path / pid / "checkpoints" / "episodes" / eid / "entity_t2i"
    cp_dir.mkdir(parents=True, exist_ok=True)
    (cp_dir / "manifest.json").write_text(json.dumps(payload), encoding="utf-8")


def test_entity_sync_service_writes_metadata_json_for_location(project_seeded):
    """location entity 의 metadata_json 이 EntityCanon column 에 write.

    Area B (Task 4 / C4): normalized shape — visual_identity=None 명시 의무.
    """
    from app.services.checkpoint_sync.entity_sync_service import EntitySyncService
    from app.models.project import EntityCanon

    db, pid, eid, tmp = project_seeded
    payload = {
        "status": "completed",
        "data": {
            "characters": [],
            "locations": [{
                "name": "Supermarket Sales Floor",
                "short_id": "L09",
                "description": "bright sales floor",
                "visual_traits": ["bright", "wide"],
                "t2i_prompt": "bright supermarket sales floor",
                "metadata_json": {
                    "location": {
                        "space_profile": {
                            "kind": "single_space",
                            "allowed_space_keys": ["main"],
                            "default_space_key": None,
                        }
                    },
                    "visual_identity": None,
                },
            }],
            "props": [],
        },
    }
    _write_entity_t2i_cp(tmp, pid, eid, payload)

    svc = EntitySyncService(db, pid, eid)
    svc.sync_from_checkpoint()
    db.flush()

    loc = db.query(EntityCanon).filter_by(project_id=pid, short_id="L09").first()
    assert loc is not None, "EntitySyncService 가 location entity 를 만들지 않음"
    assert loc.metadata_json is not None and loc.metadata_json != "{}", (
        f"Area B contract 위반: location.metadata_json 누락 — got {loc.metadata_json!r}"
    )
    parsed = json.loads(loc.metadata_json)
    assert parsed["location"]["space_profile"]["kind"] == "single_space"
    assert parsed["location"]["space_profile"]["allowed_space_keys"] == ["main"]
    assert parsed["visual_identity"] is None, (
        f"Area B normalized shape: location 의 visual_identity 는 None — got {parsed['visual_identity']!r}"
    )


def test_entity_sync_service_writes_normalized_null_metadata_for_character(project_seeded):
    """Area B (Task 4 / C4): character 도 normalized {'location': null, 'visual_identity': null} 저장.

    기존 D6 character-skip 계약 (`metadata_json == '{}'`) 갱신 — Area B 가
    all entity_type normalized shape 으로 통일. character LLM 출력의 schema
    (ENTITY_DETAIL_SCHEMA, Task 1) 가 normalized shape 강제하므로 cp 가 항상
    `{"location": None, "visual_identity": None}` 들고 옴 → DB column 에 그대로
    serialize.
    """
    from app.services.checkpoint_sync.entity_sync_service import EntitySyncService
    from app.models.project import EntityCanon

    db, pid, eid, tmp = project_seeded
    payload = {
        "schema_version": 3,
        "status": "completed",
        "data": {
            "characters": [{
                "name": "Bob",
                "short_id": "C01",
                "description": "조연",
                "visual_traits": [],
                "t2i_prompt": "",
                "metadata_json": {"location": None, "visual_identity": None},
            }],
            "locations": [],
            "props": [],
        },
    }
    _write_entity_t2i_cp(tmp, pid, eid, payload)

    svc = EntitySyncService(db, pid, eid)
    svc.sync_from_checkpoint()
    db.flush()

    row = db.query(EntityCanon).filter_by(project_id=pid, short_id="C01").first()
    assert row is not None
    md = json.loads(row.metadata_json)
    assert md == {"location": None, "visual_identity": None}, (
        f"Area B normalized shape contract 위반 — got {row.metadata_json!r}"
    )


def test_entity_sync_service_writes_normalized_metadata_for_all_entity_types(project_seeded):
    """Area B (Task 4 / C4): character/location/prop 모두 normalized shape DB write."""
    from app.services.checkpoint_sync.entity_sync_service import EntitySyncService
    from app.models.project import EntityCanon

    db, pid, eid, tmp = project_seeded
    payload = {
        "schema_version": 3,
        "status": "completed",
        "data": {
            "characters": [{
                "name": "Alice",
                "short_id": "C02",
                "description": "주인공",
                "visual_traits": ["20대"],
                "t2i_prompt": "Photorealistic ID photo of a woman ...",
                "metadata_json": {"location": None, "visual_identity": None},
            }],
            "locations": [{
                "name": "Sales Floor",
                "short_id": "L20",
                "description": "x",
                "visual_traits": [],
                "t2i_prompt": "x",
                "metadata_json": {
                    "location": {
                        "space_profile": {
                            "kind": "single_space",
                            "allowed_space_keys": ["main"],
                            "default_space_key": None,
                        }
                    },
                    "visual_identity": None,
                },
            }],
            "props": [{
                "name": "오래된 사진",
                "short_id": "P05",
                "description": "어린 시절 가족 사진",
                "visual_traits": ["빛바랜 흑백"],
                "t2i_prompt": "Product photo of a faded family photograph ...",
                "metadata_json": {
                    "location": None,
                    "visual_identity": {"reference_required": True},
                },
            }],
        },
    }
    _write_entity_t2i_cp(tmp, pid, eid, payload)

    svc = EntitySyncService(db, pid, eid, now="2026-05-13T00:00:00Z")
    svc.sync_from_checkpoint()
    db.flush()

    char_row = db.query(EntityCanon).filter_by(project_id=pid, short_id="C02").one()
    loc_row = db.query(EntityCanon).filter_by(project_id=pid, short_id="L20").one()
    prop_row = db.query(EntityCanon).filter_by(project_id=pid, short_id="P05").one()

    char_md = json.loads(char_row.metadata_json)
    assert char_md == {"location": None, "visual_identity": None}

    loc_md = json.loads(loc_row.metadata_json)
    assert loc_md["location"]["space_profile"]["kind"] == "single_space"
    assert loc_md["visual_identity"] is None

    prop_md = json.loads(prop_row.metadata_json)
    assert prop_md == {
        "location": None,
        "visual_identity": {"reference_required": True},
    }


def test_entity_sync_service_rejects_invalid_prop_metadata(project_seeded):
    """Area B (Task 4 / C4): invalid prop metadata 는 sync 에서 fail-fast (L2 boundary)."""
    from app.services.checkpoint_sync.entity_sync_service import EntitySyncService
    from app.core.errors import AppError

    db, pid, eid, tmp = project_seeded
    payload = {
        "schema_version": 3,
        "status": "completed",
        "data": {
            "characters": [],
            "locations": [],
            "props": [{
                "name": "BadProp",
                "short_id": "P09",
                "description": "bad",
                "visual_traits": [],
                "t2i_prompt": "",
                # missing reference_required — L2 validate 가 catch.
                "metadata_json": {"location": None, "visual_identity": {}},
            }],
        },
    }
    _write_entity_t2i_cp(tmp, pid, eid, payload)

    svc = EntitySyncService(db, pid, eid, now="2026-05-13T00:00:00Z")
    with pytest.raises(AppError) as exc:
        svc.sync_from_checkpoint()
    assert exc.value.code == "entity_metadata.shape_violation"


def test_entity_sync_service_update_path_writes_metadata_json(project_seeded):
    """기존 location row 가 있을 때 UPDATE path 에서도 metadata_json 갱신."""
    from app.services.checkpoint_sync.entity_sync_service import EntitySyncService
    from app.models.project import EntityCanon, EntityEpisodeLink

    db, pid, eid, tmp = project_seeded
    now = datetime.now(timezone.utc).isoformat()

    # 기존 entity_canon row 사전 INSERT (metadata_json default '{}')
    canon = EntityCanon(
        id=str(uuid.uuid4()), project_id=pid, short_id="L05",
        entity_type="location", name="Existing Loc",
        description="old", t2i_prompt="old prompt",
        stable_traits='["old"]',
        created_at=now, updated_at=now,
    )
    db.add(canon)
    db.flush()
    assert canon.metadata_json == "{}"

    payload = {
        "schema_version": 3,
        "status": "completed",
        "data": {
            "characters": [],
            "locations": [{
                "name": "Existing Loc",
                "short_id": "L05",
                "description": "new desc",
                "visual_traits": ["new"],
                "t2i_prompt": "new prompt",
                "metadata_json": {
                    "location": {
                        "space_profile": {
                            "kind": "multi_space",
                            "allowed_space_keys": ["main", "kitchen"],
                            "default_space_key": "main",
                        }
                    },
                    "visual_identity": None,
                },
            }],
            "props": [],
        },
    }
    _write_entity_t2i_cp(tmp, pid, eid, payload)

    svc = EntitySyncService(db, pid, eid)
    svc.sync_from_checkpoint()
    db.flush()

    db.refresh(canon)
    assert canon.description == "new desc"
    parsed = json.loads(canon.metadata_json)
    assert parsed["location"]["space_profile"]["kind"] == "multi_space"
    assert "kitchen" in parsed["location"]["space_profile"]["allowed_space_keys"]
    assert parsed["visual_identity"] is None


def test_entity_sync_service_legacy_cp_no_schema_version_rejects_missing_metadata(project_seeded):
    """Area B (Task 4 / C4): legacy cp (no schema_version + missing metadata_json) → strict raise.

    기존 D6 forward-compat 안전망 (silent default '{}') 은 Area B "No Silent
    Fallback" 원칙 위반 → 폐기. legacy cp 진입은 _LEGACY_SCHEMA_BUMP_ALLOWLIST
    가 RERUN_SELF transition 으로 차단하지만 (entity_t2i schema_version bump),
    fallback path 에서도 strict raise 가 contract. force re-run 의무.
    """
    from app.services.checkpoint_sync.entity_sync_service import EntitySyncService
    from app.core.errors import AppError

    db, pid, eid, tmp = project_seeded
    payload = {
        # schema_version 부재 — legacy cp simulate.
        "status": "completed",
        "data": {
            "characters": [],
            "locations": [{
                "name": "No Meta Loc",
                "short_id": "L10",
                "description": "x",
                "visual_traits": [],
                "t2i_prompt": "x",
                # metadata_json 부재 — strict raise.
            }],
            "props": [],
        },
    }
    _write_entity_t2i_cp(tmp, pid, eid, payload)

    svc = EntitySyncService(db, pid, eid)
    with pytest.raises(AppError) as exc:
        svc.sync_from_checkpoint()
    assert exc.value.code == "entity_metadata.shape_violation"


def test_entity_sync_service_v3_cp_missing_metadata_for_location_rejects(project_seeded):
    """D6 v3+ cp 에서 location 의 metadata_json 누락 → sync raise (review iter3 I2).

    schema_version >= 3 cp 는 D6 contract 적용. location 의 SOT 인 space_profile
    이 누락되면 master_plan 후처리 의 normalize_space_key 가 모든 bg_id 부여 fail.
    silent default '{}' 가 SOT 결손 은폐 — fail-fast.
    """
    from app.services.checkpoint_sync.entity_sync_service import EntitySyncService

    db, pid, eid, tmp = project_seeded
    payload = {
        "schema_version": 3,
        "status": "completed",
        "data": {
            "characters": [],
            "locations": [{
                "name": "No Meta Loc V3",
                "short_id": "L11",
                "description": "x",
                "visual_traits": [],
                "t2i_prompt": "x",
                # metadata_json 부재 — D6 contract 위반.
            }],
            "props": [],
        },
    }
    _write_entity_t2i_cp(tmp, pid, eid, payload)

    svc = EntitySyncService(db, pid, eid)
    with pytest.raises(Exception, match="(?i)metadata_json|space_profile"):
        svc.sync_from_checkpoint()


def test_entity_sync_service_v3_cp_invalid_kind_rejects(project_seeded):
    """v3 cp 에서 location.space_profile.kind 가 enum 밖 → reject.

    Area B (Task 4 / C4): metadata_json 은 normalized shape — visual_identity=None
    의무. D6 SpaceProfileError 가 그대로 propagate (validate_entity_metadata_shape
    helper 가 D6 helper 호출).
    """
    from app.services.checkpoint_sync.entity_sync_service import EntitySyncService

    db, pid, eid, tmp = project_seeded
    payload = {
        "schema_version": 3,
        "status": "completed",
        "data": {
            "characters": [],
            "locations": [{
                "name": "Bad Kind Loc",
                "short_id": "L12",
                "description": "x",
                "visual_traits": [],
                "t2i_prompt": "x",
                "metadata_json": {
                    "location": {
                        "space_profile": {
                            "kind": "freeform_unknown",  # enum 밖
                            "allowed_space_keys": ["main"],
                            "default_space_key": None,
                        }
                    },
                    "visual_identity": None,
                },
            }],
            "props": [],
        },
    }
    _write_entity_t2i_cp(tmp, pid, eid, payload)

    svc = EntitySyncService(db, pid, eid)
    with pytest.raises(Exception, match="(?i)kind|space_profile"):
        svc.sync_from_checkpoint()


# ───────────────────────────────────────────────
# Layer 4 — entity_t2i schema_version + post-validation
# ───────────────────────────────────────────────


def test_entity_t2i_manifest_schema_version_bumped_to_3():
    """entity_t2i.schema_version 이 3 (D6) — 기존 v2 cp 가 stale 처리되도록.

    `_LEGACY_SCHEMA_BUMP_ALLOWLIST` 안 (entity_t2i 만) 에서 mismatch RERUN_SELF auto.
    """
    from app.core.step_manifest import STEP_MANIFEST
    assert STEP_MANIFEST["entity_t2i"]["schema_version"] >= 3, (
        f"entity_t2i.schema_version 이 D6 (≥3) 으로 bump 안 됨. "
        f"현재: {STEP_MANIFEST['entity_t2i']['schema_version']!r}"
    )


def test_entity_t2i_old_v2_cp_triggers_stale_mismatch():
    """이전 v2 cp 가 _check_cp_mismatch 에서 stale 신호 반환."""
    from app.core.step_manifest import STEP_MANIFEST
    from app.core.step_runner import StepRunner

    runner = StepRunner.__new__(StepRunner)
    runner.step_id = "entity_t2i"
    runner.manifest = STEP_MANIFEST["entity_t2i"]
    runner.project_config = {}

    # 이전 v2 cp simulate.
    old_cp = {"schema_version": 2, "data": {"completed": {}}, "config_hash": None}
    reason = runner._check_cp_mismatch(old_cp)
    assert reason and "schema_version mismatch" in reason, (
        f"old v2 cp stale 처리 실패 — got {reason!r}"
    )


# ───────────────────────────────────────────────
# Layer 5 — deprecated path scope guard (T2-fix2 review iter4 I2)
# ───────────────────────────────────────────────


def test_production_registry_uses_new_entity_t2i_step():
    """production registry 의 entity_t2i 가 새 EntityT2iStep — D6 contract 적용 path.

    review iter4 I2: 두 deprecated path 가 D6 validation 미적용 — production blocker
    아니지만 dead-code 회귀 가드:
    - `app.modules.pipeline.entity_extractor_v3._gen_t2i` (legacy script entry,
      `extract_entities` 함수에서만 사용)
    - `app.core.steps.analysis_steps_legacy.EntityT2iStep` (legacy registry, 미사용)

    production registry (`STEP_CLASSES["entity_t2i"]`) 가 새 EntityT2iStep
    (entity_steps.py) 인지 검증 — registry drift 회귀 가드.
    """
    from app.core.steps import STEP_CLASSES
    from app.core.steps.entity_steps import EntityT2iStep

    assert STEP_CLASSES["entity_t2i"] is EntityT2iStep, (
        f"production entity_t2i registry drift — got {STEP_CLASSES['entity_t2i']!r}, "
        f"expected EntityT2iStep (entity_steps.py). D6 post-validation 적용 path."
    )


def test_deprecated_paths_marked_out_of_scope():
    """deprecated path 두 개 — 명시적 dead-code marker (D6 적용 안 함).

    review iter4 I2: 두 path 의 D6 적용은 out-of-scope. production 영향 0 검증
    + 미래에 production 로 승격 시 본 test 삭제 + D6 validator wiring 의무.
    """
    # 1. entity_extractor_v3.extract_entities — script entry only.
    # 내부 _gen_t2i 는 closure 로 정의 (line 357+) — 모듈 attr X. extract_entities 함수
    # 만 import 가능. 본 함수가 production registry 에 등록 안 됨을 다른 test 가 보장.
    from app.modules.pipeline.entity_extractor_v3 import extract_entities  # noqa: F401

    # 2. analysis_steps_legacy.EntityT2iStep
    from app.core.steps import analysis_steps_legacy
    legacy_cls = analysis_steps_legacy.EntityT2iStep
    # production registry 와 다른 class 임을 검증 (drift 가드).
    from app.core.steps.entity_steps import EntityT2iStep as ProdEntityT2iStep
    assert legacy_cls is not ProdEntityT2iStep, (
        "analysis_steps_legacy.EntityT2iStep == production EntityT2iStep — "
        "registry merge 회귀 (둘 중 하나 삭제 의무)"
    )


def test_entity_t2i_location_post_validation_rejects_missing_metadata(monkeypatch):
    """location entity LLM 응답에 metadata_json.location.space_profile 누락 → retry path."""
    from app.core.steps.entity_steps import EntityT2iStep

    call_count = {"n": 0}

    def _fake_llm(**kw):
        call_count["n"] += 1
        # location entity — metadata_json 부재
        return {
            "name": "supermarket",
            "entity_type": "location",
            "description": "x",
            "visual_traits": ["x"],
            "t2i_prompt": "x",
            "metadata_json": {},  # location.space_profile 부재
        }

    monkeypatch.setattr("app.core.steps.entity_steps.call_structured", _fake_llm)
    monkeypatch.setattr("app.core.steps.entity_steps.time.sleep", lambda *a: None)
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_system",
        lambda: "system stub",
    )
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_prompt",
        lambda *a, **kw: "user stub",
    )

    step = EntityT2iStep.__new__(EntityT2iStep)
    step.project_id = "p-d6-postvalidate"
    step.episode_id = "e-d6-postvalidate"
    step.project_config = {}
    step.update_progress = lambda *a, **kw: None
    step.build_opik_metadata = lambda *a, **kw: {}
    step._save_checkpoint = lambda *a, **kw: None
    step.save_checkpoint = lambda *a, **kw: None
    step.load_checkpoint = lambda: None

    def _load_cp_stub(name):
        if name == "entity_detail":
            return {
                "data": {
                    "entity_queue": [["supermarket", "location", "L09"]],
                    "entity_details": {
                        "supermarket:location": {
                            "description": "src", "visual_traits": ["x"]
                        }
                    },
                }
            }
        if name == "visual_world_rules":
            return {"data": {"era": "", "region": "", "rules": []}}
        return None

    step._load_prev_checkpoint = _load_cp_stub
    result = step._execute(mode="resume")
    # 3 attempts 모두 invalid metadata_json → location 은 done 에 안 들어가야 함
    assert result["completed_count"] == 0, (
        f"location post-validation 실패 — invalid metadata_json 도 completed: {result}"
    )
    assert result["failed_count"] == 1
    assert call_count["n"] == 3, (
        f"3 retries 안 함 — only {call_count['n']} attempts"
    )


# ───────────────────────────────────────────────
# Area B test 축 4 — entity_t2i prop post-validation (Task 3 / C3)
# ───────────────────────────────────────────────


def test_entity_t2i_prop_post_validation_rejects_missing_reference_required(monkeypatch):
    """Area B (Task 3 / C3): prop entity LLM 응답에 visual_identity.reference_required 부재 → retry → done 제외."""
    from app.core.steps.entity_steps import EntityT2iStep

    call_count = {"n": 0}

    def _fake_llm(**kw):
        call_count["n"] += 1
        # prop entity — visual_identity.reference_required 부재
        return {
            "name": "Mock Prop",
            "entity_type": "prop",
            "description": "x",
            "visual_traits": ["x"],
            "t2i_prompt": "y",
            "metadata_json": {"location": None, "visual_identity": {}},  # missing reference_required
        }

    monkeypatch.setattr("app.core.steps.entity_steps.call_structured", _fake_llm)
    monkeypatch.setattr("app.core.steps.entity_steps.time.sleep", lambda *a: None)
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_system",
        lambda: "system stub",
    )
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_prompt",
        lambda *a, **kw: "user stub",
    )

    step = EntityT2iStep.__new__(EntityT2iStep)
    step.project_id = "p-area-b-prop-postvalidate"
    step.episode_id = "e-area-b-prop-postvalidate"
    step.project_config = {}
    step.update_progress = lambda *a, **kw: None
    step.build_opik_metadata = lambda *a, **kw: {}
    step._save_checkpoint = lambda *a, **kw: None
    step.save_checkpoint = lambda *a, **kw: None
    step.load_checkpoint = lambda: None

    def _load_cp_stub(name):
        if name == "entity_detail":
            return {
                "data": {
                    "entity_queue": [["Mock Prop", "prop", "P01"]],
                    "entity_details": {
                        "Mock Prop:prop": {
                            "description": "src", "visual_traits": ["x"]
                        }
                    },
                }
            }
        if name == "visual_world_rules":
            return {"data": {"era": "", "region": "", "rules": []}}
        return None

    step._load_prev_checkpoint = _load_cp_stub
    result = step._execute(mode="resume")
    # 3 attempts 모두 invalid metadata_json → prop 은 done 에 안 들어가야 함
    assert result["completed_count"] == 0, (
        f"prop post-validation 실패 — invalid metadata_json 도 completed: {result}"
    )
    assert result["failed_count"] == 1
    assert result["data"]["props"] == [], "prop fail entity 가 cp 에 누출"
    assert call_count["n"] == 3, (
        f"3 retries 안 함 — only {call_count['n']} attempts"
    )


def test_entity_t2i_prop_post_validation_rejects_non_bool(monkeypatch):
    """Area B (Task 3 / C3): prop visual_identity.reference_required non-bool 시 retry → done 제외."""
    from app.core.steps.entity_steps import EntityT2iStep

    call_count = {"n": 0}

    def _fake_llm(**kw):
        call_count["n"] += 1
        return {
            "name": "Mock Prop",
            "entity_type": "prop",
            "description": "x",
            "visual_traits": ["x"],
            "t2i_prompt": "y",
            "metadata_json": {
                "location": None,
                "visual_identity": {"reference_required": "yes"},  # non-bool
            },
        }

    monkeypatch.setattr("app.core.steps.entity_steps.call_structured", _fake_llm)
    monkeypatch.setattr("app.core.steps.entity_steps.time.sleep", lambda *a: None)
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_system",
        lambda: "system stub",
    )
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_prompt",
        lambda *a, **kw: "user stub",
    )

    step = EntityT2iStep.__new__(EntityT2iStep)
    step.project_id = "p-area-b-prop-non-bool"
    step.episode_id = "e-area-b-prop-non-bool"
    step.project_config = {}
    step.update_progress = lambda *a, **kw: None
    step.build_opik_metadata = lambda *a, **kw: {}
    step._save_checkpoint = lambda *a, **kw: None
    step.save_checkpoint = lambda *a, **kw: None
    step.load_checkpoint = lambda: None

    def _load_cp_stub(name):
        if name == "entity_detail":
            return {
                "data": {
                    "entity_queue": [["Mock Prop", "prop", "P02"]],
                    "entity_details": {
                        "Mock Prop:prop": {
                            "description": "src", "visual_traits": ["x"]
                        }
                    },
                }
            }
        if name == "visual_world_rules":
            return {"data": {"era": "", "region": "", "rules": []}}
        return None

    step._load_prev_checkpoint = _load_cp_stub
    result = step._execute(mode="resume")
    assert result["completed_count"] == 0, (
        f"prop post-validation 실패 — non-bool reference_required 도 completed: {result}"
    )
    assert result["failed_count"] == 1
    assert result["data"]["props"] == [], "prop fail entity 가 cp 에 누출"
    assert call_count["n"] == 3, (
        f"3 retries 안 함 — only {call_count['n']} attempts"
    )


def test_entity_t2i_prop_post_validation_valid_passes(monkeypatch):
    """Area B (Task 3 / C3): prop visual_identity.reference_required=bool 시 success path (회귀 가드)."""
    from app.core.steps.entity_steps import EntityT2iStep

    call_count = {"n": 0}

    def _fake_llm(**kw):
        call_count["n"] += 1
        return {
            "name": "Mock Prop",
            "entity_type": "prop",
            "description": "ignored",
            "visual_traits": ["ignored"],
            "t2i_prompt": "valid prompt",
            "metadata_json": {
                "location": None,
                "visual_identity": {"reference_required": True},
            },
        }

    monkeypatch.setattr("app.core.steps.entity_steps.call_structured", _fake_llm)
    monkeypatch.setattr("app.core.steps.entity_steps.time.sleep", lambda *a: None)
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_system",
        lambda: "system stub",
    )
    monkeypatch.setattr(
        "app.modules.pipeline.entity_extractor_v3._load_prompt",
        lambda *a, **kw: "user stub",
    )

    step = EntityT2iStep.__new__(EntityT2iStep)
    step.project_id = "p-area-b-prop-valid"
    step.episode_id = "e-area-b-prop-valid"
    step.project_config = {}
    step.update_progress = lambda *a, **kw: None
    step.build_opik_metadata = lambda *a, **kw: {}
    step._save_checkpoint = lambda *a, **kw: None
    step.save_checkpoint = lambda *a, **kw: None
    step.load_checkpoint = lambda: None

    def _load_cp_stub(name):
        if name == "entity_detail":
            return {
                "data": {
                    "entity_queue": [["Mock Prop", "prop", "P03"]],
                    "entity_details": {
                        "Mock Prop:prop": {
                            "description": "src", "visual_traits": ["x"]
                        }
                    },
                }
            }
        if name == "visual_world_rules":
            return {"data": {"era": "", "region": "", "rules": []}}
        return None

    step._load_prev_checkpoint = _load_cp_stub
    result = step._execute(mode="resume")
    # 1 attempt only — valid → done 에 포함, retry 없음
    assert result["completed_count"] == 1, (
        f"valid prop post-validation 실패 — got {result}"
    )
    assert result["failed_count"] == 0
    assert call_count["n"] == 1, (
        f"valid 응답인데 retry 발생 — {call_count['n']} attempts"
    )
    # cp 안에 metadata_json 보존 + reference_required=True
    props = result["data"]["props"]
    assert len(props) == 1
    assert props[0]["metadata_json"]["visual_identity"]["reference_required"] is True


# ───────────────────────────────────────────────
# Area B test 축 1 — entity_metadata helper module (Task 2 / C2)
# ───────────────────────────────────────────────


class TestEntityMetadataHelper:
    """Area B test 축 1 — metadata schema/helper."""

    def test_validate_prop_valid_true(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        validate_entity_metadata_shape(
            "prop",
            {"location": None, "visual_identity": {"reference_required": True}},
            short_id="P01",
        )

    def test_validate_prop_valid_false(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        validate_entity_metadata_shape(
            "prop",
            {"location": None, "visual_identity": {"reference_required": False}},
            short_id="P02",
        )

    def test_validate_prop_missing_bool_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc:
            validate_entity_metadata_shape(
                "prop",
                {"location": None, "visual_identity": {}},
                short_id="P03",
            )
        assert exc.value.code == "entity_metadata.shape_violation"

    def test_validate_prop_non_bool_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc:
            validate_entity_metadata_shape(
                "prop",
                {"location": None, "visual_identity": {"reference_required": "yes"}},
            )
        assert exc.value.code == "entity_metadata.shape_violation"

    def test_validate_prop_with_non_null_location_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError):
            validate_entity_metadata_shape(
                "prop",
                {"location": {"space_profile": {"kind": "single_space",
                                                "allowed_space_keys": ["main"],
                                                "default_space_key": None}},
                 "visual_identity": {"reference_required": True}},
            )

    def test_validate_character_with_non_null_visual_identity_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError):
            validate_entity_metadata_shape(
                "character",
                {"location": None,
                 "visual_identity": {"reference_required": True}},
            )

    def test_validate_location_with_non_null_visual_identity_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError):
            validate_entity_metadata_shape(
                "location",
                {"location": {"space_profile": {"kind": "single_space",
                                                "allowed_space_keys": ["main"],
                                                "default_space_key": None}},
                 "visual_identity": {"reference_required": True}},
            )

    def test_validate_unknown_entity_type_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError):
            validate_entity_metadata_shape(
                "outlook",
                {"location": None, "visual_identity": None},
            )

    def test_get_visual_identity_reference_required_true(self):
        from app.core.entity_metadata import get_visual_identity_reference_required
        result = get_visual_identity_reference_required(
            {"location": None, "visual_identity": {"reference_required": True}},
            short_id="P01",
        )
        assert result is True

    def test_get_visual_identity_reference_required_false(self):
        from app.core.entity_metadata import get_visual_identity_reference_required
        result = get_visual_identity_reference_required(
            {"location": None, "visual_identity": {"reference_required": False}},
        )
        assert result is False

    def test_get_visual_identity_missing_raises(self):
        from app.core.entity_metadata import get_visual_identity_reference_required
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc:
            get_visual_identity_reference_required({}, short_id="P01")
        assert exc.value.code == "entity_metadata.shape_violation"

    def test_get_visual_identity_none_raises(self):
        from app.core.entity_metadata import get_visual_identity_reference_required
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc:
            get_visual_identity_reference_required(
                {"location": None, "visual_identity": None},
                short_id="P02",
            )
        assert exc.value.code == "entity_metadata.shape_violation"

    def test_get_visual_identity_reference_non_bool_raises(self):
        from app.core.entity_metadata import get_visual_identity_reference_required
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc:
            get_visual_identity_reference_required(
                {"location": None, "visual_identity": {"reference_required": 1}},
                short_id="P03",
            )
        assert exc.value.code == "entity_metadata.shape_violation"

    # ─── I1: location happy path + D6 SpaceProfileError 보존 ───────────────

    def test_validate_location_valid_single_space(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        validate_entity_metadata_shape(
            "location",
            {"location": {"space_profile": {
                "kind": "single_space",
                "allowed_space_keys": ["main"],
                "default_space_key": None,
            }}, "visual_identity": None},
            short_id="L01",
        )

    def test_validate_location_valid_multi_space(self):
        # NOTE: LOCATION_SPACE_KEY_VOCAB 안의 key 만 허용 — main/kitchen/yard 등.
        from app.core.entity_metadata import validate_entity_metadata_shape
        validate_entity_metadata_shape(
            "location",
            {"location": {"space_profile": {
                "kind": "multi_space",
                "allowed_space_keys": ["main", "kitchen"],
                "default_space_key": "main",
            }}, "visual_identity": None},
            short_id="L02",
        )

    def test_validate_location_invalid_space_profile_raises_d6_error(self):
        """D6 SpaceProfileError 가 그대로 propagate — Area B AppError 로 wrap 0 (D6 error code 보존)."""
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.bg_state_vocab import SpaceProfileError
        with pytest.raises(SpaceProfileError):  # NOT AppError
            validate_entity_metadata_shape(
                "location",
                {"location": {"space_profile": {"kind": "invalid_kind",
                                                  "allowed_space_keys": ["main"],
                                                  "default_space_key": None}},
                 "visual_identity": None},
                short_id="L03",
            )

    # ─── I2: character branch message 분리 ──────────────────────────────

    def test_validate_character_with_non_null_location_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc:
            validate_entity_metadata_shape(
                "character",
                {"location": {"space_profile": {
                    "kind": "single_space",
                    "allowed_space_keys": ["main"],
                    "default_space_key": None,
                }}, "visual_identity": None},
            )
        assert exc.value.code == "entity_metadata.shape_violation"
        # 분리 message 검증 — "location=None" 위반만 명시
        assert "location=None" in exc.value.message
