"""기획서가 **인물·장소에 실제로 닿는지** (2026-09-18 컨트리로드 전수 확인 뒤).

## 무엇이 결함이었나

컨트리로드 1화를 완주하고 기획서 인물 절과 최종 산출을 나란히 놓으니,
형태 계열은 닿았는데 **색·소재·소지품이 통째로 빠졌다**:

    기획서 「세련되게 각진 샌드 베이지 장갑판」 → 최종 「낡고 우그러진 금속」
    기획서 「정교한 미래형 하이테크 방진 마스크」 → 최종 「흰색 천 마스크」
    기획서 「기름때 오버롤 + 공구 가죽 벨트」   → 최종 「낡은 원피스 + 점퍼」
    기획서 「인이어 무전기」                     → 어디에도 없음

자리를 따라가 보니 이유가 셋이었다.

1. **아웃룩 1단계가 기획서를 아예 안 봤다** — 의상·착용물의 주인은 아웃룩인데
   그 단계에 기획서가 안 실렸다.
2. **인물 상세 계약이 「얼굴과 머리만」** — 사람에게는 맞지만 사람 형상이 아닌
   인물(로봇 등)은 **몸체가 곧 영구 외형**이라 색·재질이 갈 곳이 없었다.
3. **기획서에 장소 칸이 없었다** — 인물만 구조로 받고 장소는 세계관 문자열에
   뭉개져, 배경 상세가 기획서를 못 봤다.

## 이 시험이 잠그는 것

① 기획서 분석이 **장소**를 받는다(칸·빈 결과·available 표시)
② `PlanningContext` 가 장소를 **인물과 같은 모양**으로 조립한다
③ 아웃룩 1단계가 기획서 절을 **실제로 발송 본문에 싣는다**(끝점에서 확인)
④ 인물/배경 상세 팩이 기획서를 쓰라고 말한다 + 비인간 몸체 예외가 있다
⑤ 기획서가 없으면 **한 바이트도 안 바뀐다**
"""
from __future__ import annotations

from app.core.planning_doc_context import PlanningContext


# ── ① 기획서 분석이 장소를 받는다 ──────────────────────────────────

def test_the_analysis_schema_has_a_place_slot():
    from app.core.steps.planning_doc_step import (
        _ANALYSIS_SCHEMA, _EMPTY_RESULT, _SYSTEM_PROMPT)

    props = _ANALYSIS_SCHEMA["properties"]
    assert "locations" in props, "기획서에서 장소를 받을 칸이 없다"
    item = props["locations"]["items"]["properties"]
    assert {"name", "description", "visual_traits"} <= set(item)
    assert "locations" in _ANALYSIS_SCHEMA["required"]
    assert _EMPTY_RESULT["locations"] == []
    assert "locations" in _SYSTEM_PROMPT, "묻지도 않으면 안 온다"


def test_available_sections_marks_places():
    """★칸을 더하면 **그 칸을 옮기는 줄**도 있어야 한다 — available 에 안 실리면
    `inject_if_available` 이 영원히 빈 문자열을 돌려준다."""
    from app.core.steps.planning_doc_step import compute_available_sections

    got = compute_available_sections({
        "characters": [{"name": "누구"}],
        "locations": [{"name": "어디", "description": "좁다"}],
        "world_setting": " 어떤 시대 ", "tone_mood": "", "story_arc": "",
        "visual_concepts": "", "key_relationships": [],
    })
    assert "locations" in got
    assert got == ["characters", "locations", "world_setting"], got
    assert compute_available_sections({}) == []


def test_both_writers_use_the_same_rule():
    """★BLOCK 1 (Codex 2026-09-18): 같은 규칙이 두 곳에 있어 **정상 업로드
    경로가 장소를 지웠다**. 스텝만 고치고 서비스를 안 고치면 기획서가 도착해도
    `available` 에 안 실려 영원히 빈 문자열이다. 두 자리가 **같은 함수**를
    부르는지 본다 — 규칙을 베껴 적은 자리가 다시 생기면 여기서 걸린다."""
    import inspect

    from app.core.steps import planning_doc_step
    from app.services import planning_doc_analysis_service as svc

    for mod, name in ((planning_doc_step, "스텝"), (svc, "업로드 서비스")):
        src = inspect.getsource(mod)
        assert "compute_available_sections(" in src, f"{name} 가 공용 함수를 안 쓴다"
        assert 'computed.append("characters")' not in src, (
            f"{name} 에 규칙이 다시 베껴져 있다")
        assert 'computed_available.append("characters")' not in src, (
            f"{name} 에 규칙이 다시 베껴져 있다")


# ── ② PlanningContext 조립 ─────────────────────────────────────────

def _ctx_with_places() -> PlanningContext:
    ctx = PlanningContext(has_planning_doc=True, is_first_episode=True)
    ctx.locations_text = "- **어떤 공간** : 좁고 낮다 \n  외형: 젖은 콘크리트"
    return ctx


def test_places_inject_like_characters():
    out = _ctx_with_places().inject_if_available(
        "locations_text", "## 기획서 장소 참고 정보")
    assert "## 기획서 장소 참고 정보" in out
    assert "<planning_doc_reference>" in out
    assert "젖은 콘크리트" in out


def test_places_are_not_gated_to_the_first_episode():
    """장소는 한 화에 매인 것이 아니다 — 인물과 달리 뒤 화에서도 나가야 한다."""
    ctx = _ctx_with_places()
    ctx.is_first_episode = False
    assert ctx.inject_if_available("locations_text") != ""
    ctx.characters_text = "- **누군가** : 사람"
    assert ctx.inject_if_available("characters_text") == "", (
        "인물의 첫 화 제한은 종전 그대로여야 한다")


def test_empty_planning_doc_changes_nothing():
    ctx = PlanningContext()
    assert ctx.inject_if_available("locations_text") == ""
    assert ctx.inject_if_available("characters_text") == ""


# ── ③ 아웃룩 1단계가 **실제로 보낸다** ─────────────────────────────

def test_outlook_phase1_sends_the_planning_block(monkeypatch):
    """조립 자리 말고 **나가는 본문**에서 잰다."""
    from app.modules.pipeline import outlook_extractor_v2 as ox

    sent: dict = {}

    def _fake_call(**kw):
        sent.update(kw)
        return {"outlooks": [], "non_humanoid_characters": []}

    monkeypatch.setattr(ox, "call_structured", _fake_call)
    block = "\n\n## 기획서 인물 참고 정보\n<planning_doc_reference>\n- **누군가**: 늘 쓰는 보호구\n</planning_doc_reference>"
    ox.extract_outlooks_phase1(
        segments=[{"scene_index": 1, "text": "어떤 장면"}],
        characters=[{"name": "누군가", "short_id": "C01"}],
        planning_block=block,
    )
    assert block in sent["user_prompt"], "기획서 절이 발송 본문에 없다"


def test_outlook_phase1_without_planning_is_byte_identical(monkeypatch):
    from app.modules.pipeline import outlook_extractor_v2 as ox

    seen: list = []

    def _fake_call(**kw):
        seen.append(kw["user_prompt"])
        return {"outlooks": [], "non_humanoid_characters": []}

    monkeypatch.setattr(ox, "call_structured", _fake_call)
    args = dict(segments=[{"scene_index": 1, "text": "어떤 장면"}],
                characters=[{"name": "누군가", "short_id": "C01"}])
    ox.extract_outlooks_phase1(**args)
    ox.extract_outlooks_phase1(**args, planning_block="")
    assert seen[0] == seen[1], "기획서가 없는 판의 본문이 달라졌다"


def test_the_outlook_step_passes_it():
    """스텝이 읽어서 **넘기는** 줄이 있는지 — 없으면 위 시험은 통과해도 안 닿는다."""
    import inspect

    from app.core.steps import outlook_steps

    src = inspect.getsource(outlook_steps.OutlookPhase1Step._execute)
    assert "get_planning_context" in src
    assert src.count("planning_block=") >= 2, (
        "첫 호출과 orphan 재시도 **둘 다** 실어야 한다")


def test_the_orphan_retry_also_carries_it(monkeypatch):
    """★BLOCK 2 (Codex 2026-09-18): 첫 답에서 빠진 인물의 옷은 **재시도**에서
    만들어진다. 거기에만 기획서가 없으면 그 인물만 옛 결함으로 돌아간다.

    프로덕션 `_execute` 를 태우고 **두 번째 발송의 인자**를 본다 — 「있나」
    검사로는 이 결함을 못 잡았다."""
    import app.core.entity_identity as ident
    import app.core.planning_doc_context as pdc
    import app.core.steps.outlook_steps as os_mod
    from app.modules.pipeline import episode_carry as ec

    block = ("\n\n## 기획서 인물 참고 정보\n<planning_doc_reference>\n"
             "- **갑**: 늘 쓰는 보호구\n</planning_doc_reference>")
    sent: list = []

    class _Ctx:
        def inject_if_available(self, section, header=""):
            return block if section == "characters_text" else ""

    def _fake_extract(**kw):
        sent.append(kw.get("planning_block", "(인자 없음)"))
        # 첫 답은 C02 를 빠뜨린다 → orphan 재시도가 돈다
        cid = "C01" if len(sent) == 1 else "C02"
        return {"outlooks": [{"name": f"옷{len(sent)}", "character_id": cid,
                              "description": "설명"}],
                "non_humanoid_characters": []}

    monkeypatch.setattr(os_mod, "extract_outlooks_phase1", _fake_extract,
                        raising=False)
    monkeypatch.setattr(
        "app.modules.pipeline.outlook_extractor_v2.extract_outlooks_phase1",
        _fake_extract)
    monkeypatch.setattr(pdc, "get_planning_context", lambda *a, **kw: _Ctx())
    monkeypatch.setattr(ec, "build_roster_block", lambda *a, **kw: ("", []))
    monkeypatch.setattr(ident, "assign_short_ids",
                        lambda db, pid, kind, items: None)

    step = os_mod.OutlookPhase1Step.__new__(os_mod.OutlookPhase1Step)
    step.project_id, step.episode_id = "p", "e"
    step.db, step.project_config = None, {}
    step.build_opik_metadata = lambda: {}
    step._load_segments = lambda: [{"scene_index": 1, "text": "가"},
                                   {"scene_index": 2, "text": "나"}]
    step._load_characters_and_scene_map = lambda: (
        [{"short_id": "C01", "name": "갑"}, {"short_id": "C02", "name": "을"}],
        {1: ["C01", "C02"], 2: ["C02"]}, [])
    step._load_visual_rules = lambda: ""
    try:
        step._execute(mode="resume")
    except Exception:
        pass        # 뒤쪽 저장 단계는 이 시험의 관심이 아니다

    assert len(sent) >= 2, f"orphan 재시도가 안 돌았다 (발송 {len(sent)}회)"
    assert sent[1] == block, f"재시도에 기획서 절이 없다: {sent[1]!r}"


# ── ④ 팩 문안 ──────────────────────────────────────────────────────

def test_the_detail_pack_allows_planning_only_permanent_traits():
    from app.modules.prompt_loader import load_prompt

    t = load_prompt("entity_extractor_v2", "turn1_7_detail_batch",
                    entity_list="X", fulltext="Y")
    assert "기획서 참고 정보에만 있는 영구 외형도 쓴다" in t
    assert "사람 형상이 아닌 인물" in t, "비인간 몸체 예외가 없으면 색·재질이 갈 곳이 없다"
    assert "기획서 장소 참고 정보" in t, "배경 쪽 문장이 없다"
    # 일시 상태 금지는 그대로여야 한다
    assert "일시적 상태는 절대 포함 금지" in t


def test_the_outlook_pack_tells_it_to_use_the_planning_doc():
    from app.modules.prompt_loader import load_prompt

    t = load_prompt("outlook_extractor", "phase1")
    assert "기획서 참고 정보" in t
    assert "시나리오를 따릅니다" in t, "둘이 어긋날 때 무엇이 이기는지 없다"


# ── ⑤ 팩을 고쳤으면 지문이 움직여야 한다 ───────────────────────────

def test_the_outlook_step_folds_the_pack_it_actually_uses():
    """★고친 문안이 닿으려면 지문이 그 팩을 봐야 한다(#94·1-E 와 같은 부류)."""
    import inspect

    from app.core.steps import outlook_steps

    src = inspect.getsource(outlook_steps.OutlookPhase1Step._config_hash)
    assert "resolve_effective" in src
    assert "outlook_extractor" in src
    assert "phase1" in src
