"""의상 합성 시트를 실어도 **옷을 글로도 말한다** (2026-09-20).

실측(컨트리로드 2판 전수조사): 찰리가 나오는 선택 샷 63개 중 의상이 배정된
58개에서 **의상이 보이는 건 25개뿐**이었다. 문안의 권위는 `SHOT TEXT` 인데
그 58개 중 **55개가 「입고 있다」를 한 번도 말하지 않고**, 22개는 오히려
「낡은 금속 상체」·「고철 로봇」이라 적는다. 시트 한 장은 그 글을 못 이긴다.

종전 코드는 `if key: (시트만) / elif: (시트 없으면 글로 잠금)` 이라,
**시트가 있으면 글을 아예 안 썼다.**

잠그는 것:
- 합성 시트를 받은 인물은 이름 옆에 그 아웃룩 서술이 붙는다
- **기본 시트**(배정 없음·O00=옷 없음)에는 안 붙는다 — 없는 옷이 생기면 안 된다
- 의상 잠금이 꺼져 있으면 종전 그대로
"""
from __future__ import annotations

import json
from contextlib import ExitStack
from unittest.mock import MagicMock, patch

PID, EID = "SAMPLE_P", "SAMPLE_E"
OUTFIT_DESC = "SAMPLE LONG COAT AND WIDE HAT"

_FLAG_PATCHES = [
    ("still_confined_fp_enabled", False),
    ("still_plate_select_enabled", False),
    ("still_bgfirst_enabled", False),
    ("still_bgfirst_full_enabled", False),
    ("still_variants_enabled", False),
    ("still_conti_ab_enabled", False),
    ("still_cine_transform_enabled", False),
    ("still_recipe_critique_enabled", False),
    ("multiroll_fix_rejudge_enabled", False),
    ("multiroll_gpt_composition_enabled", False),
    ("still_recipe_camera_frame_enabled", False),
    ("still_recipe_lighting_enabled", False),
    ("outdoor_lane_pipe_enabled", False),
    ("outdoor_lane_plan_enabled", False),
    ("background_share_plan_enabled", False),
    ("still_lane_prev_bgfirst_enabled", False),
    ("signage_author_enabled", False),
    ("era_research_enabled", False),
]


def _write_cp(tmp_path, step_id, data):
    d = tmp_path / PID / "checkpoints" / "episodes" / EID / step_id
    d.mkdir(parents=True, exist_ok=True)
    (d / "manifest.json").write_text(
        json.dumps({"status": "completed", "data": data}, ensure_ascii=False),
        encoding="utf-8")


def _fake_db(ve):
    from app.models.project import EntityCanon, SceneStill

    rows = {
        EntityCanon: [
            EntityCanon(id="E1", project_id=PID, short_id="C01",
                        entity_type="character", name="SAMPLE ROBOT"),
            # ★아웃룩 정본 — 여기 description 이 문안에 실려야 한다
            EntityCanon(id="O1", project_id=PID, short_id="O01",
                        entity_type="outlook", name="SAMPLE OUTFIT",
                        description=OUTFIT_DESC),
            # ★명시 O00(옷 없음) — 서술까지 줘도 **쓰이면 안 된다**
            EntityCanon(id="O0", project_id=PID, short_id="O00",
                        entity_type="outlook", name="Null Outlook",
                        description="SAMPLE NULL OUTFIT TEXT")],
        SceneStill: [SceneStill(
            id="st_1", project_id=PID, episode_id=EID, scene_index=1,
            shot_index=1, is_selected=True,
            visible_entities_json=json.dumps(ve))],
    }

    def _query(model, *a, **k):
        q = MagicMock()
        for m in ("filter", "filter_by", "join", "order_by", "options"):
            getattr(q, m).return_value = q
        q.all.return_value = list(rows.get(model, []))
        q.first.return_value = None
        q.count.return_value = 0
        return q

    db = MagicMock()
    db.query.side_effect = _query
    return db


def _run(tmp_path, *, ve, ref_map, wardrobe_lock=True,
         state_sids=None):
    plate_png = tmp_path / "plate_BG1.png"
    plate_png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"PLATE")
    _write_cp(tmp_path, "background_render", {"groups": {"BG1": {
        "status": "ok", "png_path": str(plate_png),
        "shot_ids": ["S1_Shot1"]}}})
    _write_cp(tmp_path, "shot_ref_classify", {
        "shots": {"S1sh1": {"person_visible": True, "prev": None}},
        "scenes": {}, "world_anchor_en": ""})
    _write_cp(tmp_path, "shot_continuity", {"pose_canon": []})
    _write_cp(tmp_path, "shot_conti_light", {"contis": {}})

    from app.modules.pipeline import still_recipe as _sr
    from app.services.still_recipe_service import run_still_recipe_generation

    seen = {"names": []}
    real_prompt = _sr.build_still_prompt

    def spy_prompt(*a, **kw):
        seen["names"].append(list(kw.get("char_names") or []))
        return real_prompt(*a, **kw)

    class _Row:
        """`episode_outlook_rows` 가 돌려주는 행 모양 — 이 화의 배정."""

        def __init__(self, cid, oid):
            self.character_id, self.outlook_id = cid, oid

    ref_svc = MagicMock()
    ref_svc.detect_state_variant_sids.return_value = dict(state_sids or {})
    ref_svc.get_visible_entities.return_value = []

    patches = [
        patch("app.core.config.settings.projects_dir", str(tmp_path)),
        patch("app.core.config.settings.still_cast_wardrobe_lock_enabled",
              wardrobe_lock, create=True),
        patch("app.core.entity_identity.episode_outlook_rows",
              return_value=[_Row("E1", "O1"), _Row("E1", "O0")]),
        patch("app.modules.pipeline.multiroll_gemini.make_nb2_gen_fn",
              return_value=MagicMock()),
        patch("app.modules.pipeline.multiroll_gemini.make_gemini_judge_fn",
              return_value=MagicMock()),
        patch("app.modules.pipeline.multiroll_gemini.make_gemini_critique_fn",
              return_value=MagicMock()),
        patch("app.modules.pipeline.multiroll_select.run_multiroll_select",
              side_effect=RuntimeError("SAMPLE generation blocked")),
        patch("app.modules.pipeline.still_recipe.build_still_prompt",
              side_effect=spy_prompt),
    ] + [patch(f"app.core.config.settings.{n}", v, create=True)
         for n, v in _FLAG_PATCHES]

    scene_cp = MagicMock()
    with ExitStack() as stack:
        for pch in patches:
            stack.enter_context(pch)
        run_still_recipe_generation(
            db=_fake_db(ve), project_id=PID, episode_id=EID,
            stills=[{
                "id": "st_1", "still_index": 0, "scene_index": 1,
                "shot_index": 1, "screenplay_scene_heading": "S#1. SAMPLE",
                "beat_title": "", "still_frame_prompt": "SAMPLE",
                "camera_json": "{}", "lighting_json": "{}",
                "visible_entities_json": json.dumps(ve),
                "dependent_scene_id": None}],
            stills_orm=[],
            entity_lookup={"E1": {"id": "E1", "name": "SAMPLE ROBOT",
                                  "entity_type": "character",
                                  "short_id": "C01"}},
            ref_image_map={}, reference_svc=ref_svc,
            scene_ref_image_map=ref_map,
            scene_ref_asset_id_map={k: f"asset-{k}" for k in ref_map},
            staging_map={}, scene_cp=scene_cp,
            persistence_svc=MagicMock(), progress=MagicMock(),
            project_config=None, scene_dir=tmp_path / "scene",
            already_done_stills=set(), target_scenes=None,
        )
    failed = [str(c.args[1]) if len(c.args) > 1 else ""
              for c in scene_cp.mark_failed.call_args_list]
    assert len(failed) == 1 and "SAMPLE generation blocked" in failed[0], failed
    return seen


def test_a_composite_sheet_also_says_the_outfit_in_words(tmp_path):
    seen = _run(
        tmp_path,
        ve=[{"id": "E1", "short_id": "C01", "outlook_id": "O1"}],
        ref_map={"E1": b"BASE", "composite:E1:O1": b"COMP"})
    joined = " ".join(seen["names"][-1])
    assert "SAMPLE ROBOT" in joined
    assert f"wearing: {OUTFIT_DESC}" in joined, joined


def test_the_base_sheet_does_not_invent_an_outfit(tmp_path):
    """대조 — 배정이 없으면(기본 시트) 옷 문구가 붙으면 안 된다.

    실제 결함의 반대 방향이다: 배정이 `O00`(옷 없음)인 씬에 옷이 붙어 왔다.
    """
    seen = _run(tmp_path,
                ve=[{"id": "E1", "short_id": "C01"}],
                ref_map={"E1": b"BASE"})
    joined = " ".join(seen["names"][-1])
    assert "SAMPLE ROBOT" in joined
    assert "wearing:" not in joined, joined


def test_the_lock_being_off_keeps_the_old_text(tmp_path):
    """꺼져 있으면 종전 그대로 — 끄는 스위치가 실제로 끈다."""
    seen = _run(
        tmp_path,
        ve=[{"id": "E1", "short_id": "C01", "outlook_id": "O1"}],
        ref_map={"E1": b"BASE", "composite:E1:O1": b"COMP"},
        wardrobe_lock=False)
    assert "wearing:" not in " ".join(seen["names"][-1])


def test_an_explicit_null_outlook_does_not_get_a_wearing_line(tmp_path):
    """★대조 — 배정이 **명시 O00(옷 없음)** 이면 옷 문구가 붙으면 안 된다.

    「배정이 아예 없음」과 다른 갈래다. 실제 결함이 바로 이 자리였다 —
    O00 으로 배정된 씬에 코트가 붙어 왔다. O00 에 서술을 줘도 안 쓴다.
    """
    seen = _run(tmp_path,
                ve=[{"id": "E1", "short_id": "C01", "outlook_id": "O0"}],
                ref_map={"E1": b"BASE", "composite:E1:O1": b"COMP"})
    joined = " ".join(seen["names"][-1])
    assert "SAMPLE ROBOT" in joined
    assert "wearing:" not in joined, joined
    assert "SAMPLE NULL OUTFIT TEXT" not in joined, joined


def test_a_state_variant_wins_and_brings_no_outfit_text(tmp_path):
    """★대조 — 상태 변형(쓰러짐·죽음)이 이기면 옷 문구를 안 붙인다.

    우선순위는 state_variant > 합성 > 기본이다. 실린 그림이 상태 변형인데
    문안만 옷을 말하면 글과 그림이 어긋난다.
    """
    seen = _run(
        tmp_path,
        ve=[{"id": "E1", "short_id": "C01", "outlook_id": "O1"}],
        ref_map={"E1": b"BASE", "composite:E1:O1": b"COMP",
                 "state_variant:E1:dead": b"SV"},
        state_sids={"C01": {"key": "state_variant:E1:dead", "state": "dead"}})
    joined = " ".join(seen["names"][-1])
    assert "SAMPLE ROBOT" in joined
    assert "wearing:" not in joined, joined
