"""PromptService 단위 테스트 — Phase 3b.1.

`_build_final_scene_prompt` 211줄을 4단계로 분리한 뒤 각 단계의 계약 검증.
translate_if_korean은 LLM mocking 필요.
"""
from __future__ import annotations

from unittest.mock import patch

from app.services import prompt_service as ps


# ── resolve_ref_roles ──────────────────────────────────────────


# Area #11 v1 W3: resolve_ref_roles 의 substring-routing 가정 base 7 test 폐기 —
# `test_prompt_service_label_routing.py` 에 11-enum dispatch 기반 신 test set 으로
# 이관 (Codex iter 4 W2 review 권고 verbatim: "_classify_label direct tests
# 폐기 + resolve_ref_roles(payload) 11 enum dispatch tests 로 rewrite").
# 폐기 list: test_resolve_ref_roles_empty_returns_no_reference_marker /
# _outfit_label_generates_standalone_instruction / _character_label_generates_appearance_instruction /
# _previous_shot_same_room_keeps_background / _previous_scene_limits_to_atmosphere /
# _background_label_uses_architecture / _background_chain_ref_uses_strong_directive.


# ── replace_entity_ids ─────────────────────────────────────────


def test_replace_entity_ids_substitutes_composite_id_with_ref_num():
    """C01O02가 labeled_refs 1번이면 'the character shown in image 1'.

    2026-05-10 D2 phantom guard 충돌 회피 — 'from Reference image N'
    표현 사용 금지 (validator regex 와 매칭).
    """
    t2i = "A man C01O02 walks down the street."
    refs = [("C01O02 wearing 검은 정장", None)]
    result = ps.replace_entity_ids(t2i, refs, entity_text_map={})
    assert "the character shown in image 1" in result
    assert "C01O02" not in result
    assert "from Reference image" not in result
    assert "from the reference" not in result.lower()


def test_replace_entity_ids_falls_back_to_entity_text_map_when_no_ref():
    """reference 없으면 entity_text_map에서 치환."""
    t2i = "A man C05 approaches."
    refs = []
    etm = {"C05": "a middle-aged Korean man in a gray coat"}
    result = ps.replace_entity_ids(t2i, refs, entity_text_map=etm)
    assert "a middle-aged Korean man" in result
    assert "C05" not in result


def test_replace_entity_ids_strips_bracketed_location_descriptor():
    """[L01: rainy alley] → rainy alley."""
    t2i = "In [L01: rainy alley] at night."
    result = ps.replace_entity_ids(t2i, [], entity_text_map={})
    assert "rainy alley" in result
    assert "[L01:" not in result


def test_replace_entity_ids_removes_in_oxx_tail():
    """'in O02'은 composite ID에 이미 반영되었다고 가정하고 제거."""
    t2i = "the character from Reference image 1, in O02 stands tall"
    result = ps.replace_entity_ids(t2i, [], entity_text_map={})
    assert "in O02" not in result


def test_replace_entity_ids_handles_standalone_prop_with_ref():
    t2i = "holding P03 carefully"
    refs = [("P03 prop — antique key", None)]
    result = ps.replace_entity_ids(t2i, refs, entity_text_map={})
    assert "the object shown in image 1" in result
    assert "from Reference image" not in result


def test_replace_entity_ids_mixed_composite_and_standalone(
):
    """C01O02와 단독 C03이 같이 등장 — 각각 올바른 ref 번호로 치환 (Codex Important #1)."""
    t2i = "C01O02 faces C03 in the alley."
    refs = [
        ("C01O02 wearing suit", None),
        ("C03 character ref", None),
    ]
    result = ps.replace_entity_ids(t2i, refs, entity_text_map={})
    assert (
        "the character shown in image 1 faces the character shown in image 2"
        in result
    )
    assert "C01" not in result
    assert "C03" not in result
    assert "from Reference image" not in result


def test_replace_entity_ids_standalone_location_outside_bracket():
    """단독 L01이 대괄호 밖에서 등장하면 entity_text_map으로 치환 (Codex Important #1)."""
    t2i = "They walk through L01 at dusk."
    etm = {"L01": "a foggy forest path"}
    result = ps.replace_entity_ids(t2i, [], entity_text_map=etm)
    assert "a foggy forest path" in result
    assert "L01" not in result


def test_replace_entity_ids_multiple_refs_numbered_correctly():
    """labeled_refs 3개 → 각각 1/2/3번으로 매핑되는지 (Codex Important #1)."""
    t2i = "C01 holds P02 in [L03: cave]."
    refs = [
        ("C01 character", None),
        ("P02 prop", None),
        ("L03 background", None),
    ]
    result = ps.replace_entity_ids(t2i, refs, entity_text_map={})
    assert "the character shown in image 1" in result
    assert "the object shown in image 2" in result
    # L03은 bracket 내부이므로 descriptor만 남음
    assert "cave" in result
    assert "[L03:" not in result
    assert "from Reference image" not in result


def test_replace_entity_ids_composite_fallback_to_char_entity_text():
    """C01O02에 ref도 없고 C01O02 text map도 없으면 C01 text로 fallback (기존 동작)."""
    t2i = "C01O02 enters."
    etm = {"C01": "a young Korean man"}
    result = ps.replace_entity_ids(t2i, [], entity_text_map=etm)
    assert "a young Korean man" in result
    assert "C01O02" not in result


# ── characterization (wrapper equivalence with fixed expected output) ─────


def test_build_final_scene_prompt_deterministic_snapshot_no_korean():
    """고정 입력에 대해 고정 출력을 반환 — Codex Important #2 regression 방지.

    2026-05-10 D2: ID 치환 phrase 가 'shown in image N' 으로 변경됨
    (phantom guard 충돌 회피).
    """
    # Area #11 v1 W3: labeled_refs kwarg → payload (LabeledRefPayload).
    payload = ps.make_labeled_ref_payload(
        labeled_refs=[("C01 wearing regular clothes", b"\x89")],
        ref_roles=["outfit_ref_inline"],
        ref_role_metadata=[{}],
        attached_meta=[("character", "C01")],
    )
    out = ps.build_final_scene_prompt(
        t2i_prompt="C01 stands in [L01: forest] at dawn.",
        payload=payload,
        style_context="film noir",
    )
    expected_body = (
        "Photorealistic cinematic still.\n"
        "\n"
        "Reference image 1: C01 wearing regular clothes\n"
        "\n"
        "the character shown in image 1 stands in forest at dawn.\n"
        "\n"
        "Generate one image:\n"
        "- use image 1 as character appearance reference — "
        "match the person's identity and outfit where visible in the scene\n"
        "- do not copy poses or compositions from reference images\n"
        "- do not alter character identities where their face is visible in the scene\n"
        "- each character's clothing/outfit must match that character's appearance "
        "reference image EXACTLY; if any clothing wording in the text conflicts with "
        "the reference image, follow the reference image and ignore that wording\n"
        "- only render what the scene description asks for — "
        "if only a hand or wrist is described, do NOT add the character's face\n"
        "- CRITICAL: Only render what the scene description explicitly describes. "
        "If a character's face is visible in the scene, match it to the reference. "
        "If only a body part is shown, do NOT add the face.\n"
        "- CRITICAL: Output exactly ONE single continuous photograph of ONE moment "
        "— never a panel grid, collage, contact sheet, split-screen, storyboard "
        "sheet, or multiple sub-frames inside one image.\n"
        # W-H (2026-07-03): 물리 접지/무게 계약 스냅샷 반영.
        "- CRITICAL: Every person must be physically grounded and weight-bearing — "
        "in real contact with a supporting surface, with body weight visibly carried "
        "by that contact. For a figure caught mid-action (falling, stumbling, "
        "collapsing, jumping off something), render a physically plausible instant: "
        "joints bent, weight clearly transferring, at least one believable point of "
        "contact or support — never a rigid body hovering, tilted or suspended in "
        "mid-air without support."
    )
    assert out == expected_body


# ── translate_if_korean ────────────────────────────────────────


def test_translate_if_korean_passes_english_unchanged():
    """영어만 있으면 LLM 호출 없이 그대로 반환."""
    out = ps.translate_if_korean(
        "A man walks down the street.",
        ref_roles_text="", ref_instructions_text="", style_context="",
    )
    assert out == "A man walks down the street."


def _setup_translate_prompt_dir(tmp_path, monkeypatch):
    """translate_prompt.md가 있는 최소 버전 디렉토리를 tmp_path에 구성 후 PROMPTS_BASE 리디렉션.

    problems.md #14 흡수 후 갱신 (review I3): 옛 ``Path.resolve`` monkeypatch 는
    더 이상 effect 없음 — prompt_service.translate_if_korean 가 prompt_loader.
    load_prompt 사용. ``prompt_loader.PROMPTS_BASE`` monkeypatch 패턴으로 통합.
    """
    from app.modules import prompt_loader

    fake_prompts_root = tmp_path / "scene_image" / "1.202600010000"
    fake_prompts_root.mkdir(parents=True)
    template = fake_prompts_root / "translate_prompt.md"
    template.write_text(
        "SYS: translate.\n"
        "Roles:\n{ref_roles_text}\n"
        "Instr:\n{ref_instructions}\n"
        "Style: {style_context}\n"
        "Prompt: {t2i_prompt}\n",
        encoding="utf-8",
    )
    monkeypatch.setattr(prompt_loader, "PROMPTS_BASE", tmp_path)


def test_translate_if_korean_invokes_llm_when_korean_detected(tmp_path, monkeypatch):
    """한국어 감지 시 call_text가 호출된다."""
    _setup_translate_prompt_dir(tmp_path, monkeypatch)

    with patch("app.modules.llm.llm_client.call_text", return_value="A Korean man walks.") as mock:
        out = ps.translate_if_korean(
            "한국인 남자가 걷는다.",
            ref_roles_text="Reference image 1: face",
            ref_instructions_text="- use ref 1",
            style_context="film noir",
        )

    assert mock.called
    assert out == "A Korean man walks."


def test_translate_if_korean_returns_original_on_llm_failure(tmp_path, monkeypatch):
    """LLM 예외 시 원본 반환 + error 로그."""
    _setup_translate_prompt_dir(tmp_path, monkeypatch)

    original = "한국인 남자가 걷는다."
    with patch("app.modules.llm.llm_client.call_text", side_effect=RuntimeError("boom")):
        out = ps.translate_if_korean(
            original,
            ref_roles_text="", ref_instructions_text="", style_context="",
        )

    assert out == original  # 번역 실패 → 원본 유지


def test_translate_if_korean_handles_missing_prompt_dir(tmp_path, monkeypatch):
    """scene_image 모듈 디렉토리가 없으면 원본 반환 (problems.md #14 흡수 후 갱신)."""
    from app.modules import prompt_loader

    # PROMPTS_BASE 를 빈 tmp 로 redirect → load_prompt 가 FileNotFoundError raise →
    # translate_if_korean 의 try/except 가 잡고 원본 반환.
    monkeypatch.setattr(prompt_loader, "PROMPTS_BASE", tmp_path)

    original = "한국인 남자가 걷는다."
    out = ps.translate_if_korean(
        original, ref_roles_text="", ref_instructions_text="", style_context="",
    )
    assert out == original


def test_translate_if_korean_handles_empty_version_dir(tmp_path, monkeypatch):
    """scene_image 디렉토리는 있으나 version 없으면 원본 반환 (#14 흡수 후 갱신)."""
    from app.modules import prompt_loader

    (tmp_path / "scene_image").mkdir(parents=True)
    monkeypatch.setattr(prompt_loader, "PROMPTS_BASE", tmp_path)

    original = "한국인 남자가 걷는다."
    out = ps.translate_if_korean(
        original, ref_roles_text="", ref_instructions_text="", style_context="",
    )
    assert out == original


# ── build_scene_text ───────────────────────────────────────────


def test_build_scene_text_composes_in_expected_order():
    final = ps.build_scene_text(
        ref_roles_text="Reference image 1: face",
        cleaned="A man stands.",
        ref_instructions_text="- use ref 1",
    )
    assert final.startswith("Photorealistic cinematic still.")
    assert "Reference image 1: face" in final
    assert "A man stands." in final
    assert "Generate one image:" in final
    assert "- use ref 1" in final
    assert "CRITICAL" in final


def test_build_scene_text_strips_duplicate_photorealistic_prefix():
    """cleaned에 'Photorealistic cinematic still.'가 들어있어도 중복 방지."""
    final = ps.build_scene_text(
        ref_roles_text="No reference images.",
        cleaned="Photorealistic cinematic still. A man.",
        ref_instructions_text="",
    )
    # 최상단 1회만 등장
    assert final.count("Photorealistic cinematic still.") == 1


# ── build_final_scene_prompt (entry point) ─────────────────────


def test_build_final_scene_prompt_pipeline_smoke():
    """public entry point의 smoke test: label에 C01을 포함해 ref_num 매핑 확인."""
    # Area #11 v1 W3: labeled_refs kwarg → payload (LabeledRefPayload).
    payload = ps.make_labeled_ref_payload(
        labeled_refs=[("C01 wearing regular clothes", b"\x89")],
        ref_roles=["outfit_ref_inline"],
        ref_role_metadata=[{}],
        attached_meta=[("character", "C01")],
    )
    out = ps.build_final_scene_prompt(
        t2i_prompt="C01 stands in [L01: forest] at dawn.",
        payload=payload,
        style_context="cinematic film noir",
    )
    assert out.startswith("Photorealistic cinematic still.")
    assert "Reference image 1" in out
    assert "forest" in out
    # 씬 본문에서 C01이 "the character shown in image 1"로 치환
    assert "the character shown in image 1 stands" in out
    assert "CRITICAL" in out


def test_prompt_service_translate_no_phantom_closed_list():
    """Area #5 v1: translate system_prompt 안 phantom phrase closed-list literal 0 (W3 Task 3.7).

    classifier 폐기 + sidecar SOT 후, translate_if_korean 의 system_prompt
    에서 legacy closed-list literal 모두 폐기. principle-only (sidecar mismatch
    차단으로 의미 전달).
    """
    import inspect
    from app.services import prompt_service
    src = inspect.getsource(prompt_service.translate_if_korean)
    forbidden = ["from the reference", "from Reference image N", "phantom guard"]
    for lit in forbidden:
        assert lit not in src, (
            f"closed-list literal {lit!r} present in translate_if_korean — "
            f"Area #5 W3 Task 3.7 wording cleanup violated"
        )


