"""Phase 2 — chain_bg shot guide consumer + Phase 1b essence consumer 통합 테스트."""
from __future__ import annotations

import json
from typing import Any, Dict, List
from unittest.mock import patch

import pytest


def test_settings_toggle_default_false():
    """code default off 회귀 0건 보장 — pydantic Field default 직접 검증으로 .env override 영향 격리."""
    from app.core.config import Settings
    assert Settings.model_fields["chain_bg_guide_enabled"].default is False


def test_scene_analysis_context_has_phase2_fields():
    """Phase 2 + Phase 1b consumer 통합용 ctx field 존재 확인."""
    from app.core.dto.scene_analysis import SceneAnalysisContext
    ctx = SceneAnalysisContext()
    # Phase 2: chain_bg_guide consumer
    assert hasattr(ctx, "chain_bg_guide_by_shot")
    assert ctx.chain_bg_guide_by_shot == {}
    # Phase 1b consumer 통합
    assert hasattr(ctx, "essence_by_shot")
    assert ctx.essence_by_shot == {}


def test_chain_bg_render_prompt_v2_loaded():
    """v2 자동 선택 + 새 schema에 shot_guides field 확인."""
    from app.modules.prompt_loader import load_prompt, load_schema
    sys = load_prompt("background_chain_render", "system")
    assert "shot_guides" in sys.lower()
    assert "do not redraw" in sys.lower()

    sch = load_schema("background_chain_render", "schema")
    assert "shot_guides" in sch["properties"]
    assert sch["properties"]["shot_guides"]["type"] == "array"
    items = sch["properties"]["shot_guides"]["items"]
    assert set(items["required"]) == {"shot_id", "guide"}
    assert sch["required"] == ["t2i_prompt", "shot_guides"]


def test_generate_node_prompt_returns_t2i_and_shot_guides():
    """LLM이 t2i_prompt + shot_guides 둘 다 반환하면 둘 다 추출."""
    from app.modules.pipeline import background_chain_render as bcr

    fake_llm_result = {
        "t2i_prompt": "Living room view. ASCII only.",
        "shot_guides": [
            {"shot_id": "S5_Shot1", "guide": "TV upper-left. Sofa center. DO NOT redraw."},
            {"shot_id": "S5_Shot3", "guide": "TV upper-left. Position character on sofa. DO NOT redraw."},
        ],
    }

    with patch.object(bcr, "call_structured", return_value=fake_llm_result):
        with patch("app.modules.pipeline.background_chain_render.load_prompt", return_value="sys"):
            with patch("app.modules.pipeline.background_chain_render.load_schema", return_value={}):
                t2i, guides = bcr.generate_node_prompt(
                    location_id="L01",
                    location_description="Living room",
                    node={"id": "anchor_root", "kind": "anchor_root", "label": "x", "description": "y", "shot_ids": ["S5_Shot1", "S5_Shot3"], "shared_visual_anchors_with_parent": []},
                    parent=None,
                    shots_in_node=[
                        {"shot_id": "S5_Shot1", "description": "..."},
                        {"shot_id": "S5_Shot3", "description": "..."},
                    ],
                )
    assert t2i == "Living room view. ASCII only."
    assert len(guides) == 2
    assert guides[0]["shot_id"] == "S5_Shot1"
    assert "DO NOT redraw" in guides[0]["guide"]


def test_generate_node_prompt_handles_legacy_response_without_shot_guides():
    """v1 prompt response (shot_guides 없음) → 빈 list fallback."""
    from app.modules.pipeline import background_chain_render as bcr

    legacy_response = {"t2i_prompt": "Living room view."}  # no shot_guides

    with patch.object(bcr, "call_structured", return_value=legacy_response):
        with patch("app.modules.pipeline.background_chain_render.load_prompt", return_value="sys"):
            with patch("app.modules.pipeline.background_chain_render.load_schema", return_value={}):
                t2i, guides = bcr.generate_node_prompt(
                    location_id="L01",
                    location_description="x",
                    node={"id": "anchor_root", "kind": "anchor_root", "label": "x", "description": "y", "shot_ids": [], "shared_visual_anchors_with_parent": []},
                    parent=None,
                    shots_in_node=[],
                )
    assert t2i == "Living room view."
    assert guides == []


def test_loader_returns_empty_when_no_chain_bg_render_checkpoint():
    """chain_bg_render manifest 없으면 빈 dict."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return None  # 모든 step manifest 없음

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_chain_bg_guide_by_shot()
    assert result == {}


def test_loader_parses_shot_guides_from_manifest():
    """manifest 파싱 → (si, shi) → guide str."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "locations": {
                "L01": {
                    "nodes": [
                        {
                            "id": "anchor_root",
                            "shot_ids": ["S5_Shot1", "S5_Shot3"],
                            "shot_guides": [
                                {"shot_id": "S5_Shot1", "guide": "TV upper-left. Sofa center."},
                                {"shot_id": "S5_Shot3", "guide": "TV upper-left. Char on sofa."},
                            ],
                        },
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_chain_bg_guide_by_shot()
    assert result == {
        (5, 1): "TV upper-left. Sofa center.",
        (5, 3): "TV upper-left. Char on sofa.",
    }


def test_loader_handles_legacy_checkpoint_without_shot_guides(caplog):
    """v1 manifest (shot_guides 필드 없음) → 빈 dict + warning."""
    import logging
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "locations": {
                "L01": {
                    "nodes": [
                        {"id": "anchor_root", "shot_ids": ["S5_Shot1"]},  # no shot_guides
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    with caplog.at_level(logging.WARNING):
        result = loader._load_chain_bg_guide_by_shot()
    assert result == {}


def test_loader_handles_extra_shot_ids(caplog):
    """입력 shot_ids에 없는 extra shot_id는 무시 + warning."""
    import logging
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "locations": {
                "L01": {
                    "nodes": [
                        {
                            "id": "anchor_root",
                            "shot_ids": ["S5_Shot1"],
                            "shot_guides": [
                                {"shot_id": "S5_Shot1", "guide": "ok"},
                                {"shot_id": "S99_Shot99", "guide": "extra — ignored"},
                            ],
                        },
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    with caplog.at_level(logging.WARNING):
        result = loader._load_chain_bg_guide_by_shot()
    assert result == {(5, 1): "ok"}
    assert (99, 99) not in result


def test_loader_handles_duplicate_shot_ids():
    """중복 shot_id → 첫 번째만 (Phase 1b _merge_with_input_keys 패턴)."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "locations": {
                "L01": {
                    "nodes": [
                        {
                            "id": "anchor_root",
                            "shot_ids": ["S5_Shot1"],
                            "shot_guides": [
                                {"shot_id": "S5_Shot1", "guide": "first"},
                                {"shot_id": "S5_Shot1", "guide": "second — ignored"},
                            ],
                        },
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_chain_bg_guide_by_shot()
    assert result == {(5, 1): "first"}


def test_loader_handles_invalid_shot_id_format(caplog):
    """shot_id가 'S{int}_Shot{int}' 형식이 아니면 skip + warning."""
    import logging
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "locations": {
                "L01": {
                    "nodes": [
                        {
                            "id": "anchor_root",
                            "shot_ids": ["BAD_FORMAT"],
                            "shot_guides": [
                                {"shot_id": "BAD_FORMAT", "guide": "x"},
                            ],
                        },
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    with caplog.at_level(logging.WARNING):
        result = loader._load_chain_bg_guide_by_shot()
    assert result == {}


# ---------------------------------------------------------------------------
# Phase 6 — data.groups (Phase 5 render shape) consumer
# ---------------------------------------------------------------------------


def test_loader_parses_phase5_groups_shape():
    """Phase 5: data.groups[group_id].shot_guides → (si, shi) → guide str."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "groups": {
                "cb_l05_living_day": {
                    "status": "ok",
                    "location_id": "L05",
                    "shot_ids": ["S5_Shot1", "S5_Shot3"],
                    "shot_guides": [
                        {"shot_id": "S5_Shot1", "guide": "Sofa center. DO NOT redraw."},
                        {"shot_id": "S5_Shot3", "guide": "Char on sofa. DO NOT redraw."},
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_chain_bg_guide_by_shot()
    assert result == {
        (5, 1): "Sofa center. DO NOT redraw.",
        (5, 3): "Char on sofa. DO NOT redraw.",
    }


def test_loader_phase5_skips_non_ok_groups():
    """skipped/failed status groups → 무시."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "groups": {
                "cb_skipped": {
                    "status": "skipped_planning",
                    "shot_ids": ["S7_Shot1"],
                    "shot_guides": [{"shot_id": "S7_Shot1", "guide": "ignored"}],
                },
                "cb_failed": {
                    "status": "failed",
                    "shot_ids": ["S8_Shot1"],
                    "shot_guides": [{"shot_id": "S8_Shot1", "guide": "ignored"}],
                },
                "cb_ok": {
                    "status": "ok",
                    "shot_ids": ["S9_Shot1"],
                    "shot_guides": [{"shot_id": "S9_Shot1", "guide": "kept"}],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_chain_bg_guide_by_shot()
    assert result == {(9, 1): "kept"}


def test_loader_phase5_and_legacy_combined():
    """data.groups + data.locations 동시 존재 → 합집합 (Phase 5 우선)."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "groups": {
                "cb_a": {
                    "status": "ok",
                    "shot_ids": ["S5_Shot1"],
                    "shot_guides": [{"shot_id": "S5_Shot1", "guide": "from_phase5"}],
                },
            },
            "locations": {
                "L99": {
                    "nodes": [
                        {
                            "id": "anchor_root",
                            "shot_ids": ["S5_Shot1", "S6_Shot2"],
                            "shot_guides": [
                                {"shot_id": "S5_Shot1", "guide": "from_legacy_dup"},
                                {"shot_id": "S6_Shot2", "guide": "from_legacy"},
                            ],
                        },
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_chain_bg_guide_by_shot()
    # Phase 5 먼저 → S5_Shot1은 phase5, S6_Shot2는 legacy fallback
    assert result == {
        (5, 1): "from_phase5",
        (6, 2): "from_legacy",
    }


def test_loader_phase5_handles_missing_shot_guides_field(caplog):
    """Phase 5 group이지만 shot_guides 필드 자체 없음 → skip + Phase 5 전용 warning."""
    import logging
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "groups": {
                "cb_no_guides": {
                    "status": "ok",
                    "shot_ids": ["S5_Shot1"],
                    # no shot_guides field at all
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    with caplog.at_level(logging.WARNING):
        result = loader._load_chain_bg_guide_by_shot()
    assert result == {}
    # Phase 5 전용 warning이 발생해야 함 (legacy 메시지와 구분)
    assert any(
        "Phase 5 groups had no shot_guides" in rec.getMessage()
        for rec in caplog.records
    ), f"expected Phase 5-specific warning, got: {[r.getMessage() for r in caplog.records]}"


def test_loader_phase5_handles_extra_shot_ids():
    """Phase 5 group의 shot_guides에 group shot_ids 외 shot_id 들어오면 skip."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "groups": {
                "cb_extra": {
                    "status": "ok",
                    "shot_ids": ["S5_Shot1"],
                    "shot_guides": [
                        {"shot_id": "S5_Shot1", "guide": "ok"},
                        {"shot_id": "S99_Shot99", "guide": "extra — ignored"},
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_chain_bg_guide_by_shot()
    assert result == {(5, 1): "ok"}


def test_loader_handles_malformed_shapes_defensively(caplog):
    """방어적 가드 — 잘못된 타입의 shot_guides/node 항목이 들어와도 crash 없음.

    Codex review L1+L2+L3 hardening:
      - shot_guides가 list 아닌 dict/str/int → warning + skip
      - shot_guides 항목이 dict 아닌 str/int → skip
      - nodes 항목이 dict 아닌 list/str → skip
    """
    import logging
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "groups": {
                "cb_bad_shape": {
                    "status": "ok",
                    "shot_ids": ["S5_Shot1"],
                    "shot_guides": "not a list",  # malformed
                },
                "cb_bad_items": {
                    "status": "ok",
                    "shot_ids": ["S6_Shot2"],
                    "shot_guides": [
                        "not a dict",  # malformed item
                        42,            # malformed item
                        {"shot_id": "S6_Shot2", "guide": "valid entry"},
                    ],
                },
            },
            "locations": {
                "L99": {
                    "nodes": [
                        "not a dict",  # malformed node
                        {
                            "id": "anchor_root",
                            "shot_ids": ["S7_Shot3"],
                            "shot_guides": [
                                {"shot_id": "S7_Shot3", "guide": "legacy ok"},
                            ],
                        },
                    ],
                },
            },
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "background_chain_render" else None

    loader = SceneContextLoader(FakeRunner())
    with caplog.at_level(logging.WARNING):
        result = loader._load_chain_bg_guide_by_shot()
    # 유효한 entry만 살아남음
    assert result == {(6, 2): "valid entry", (7, 3): "legacy ok"}
    # 비-list shot_guides 경고 발생
    assert any(
        "shot_guides is str" in rec.getMessage()
        for rec in caplog.records
    ), f"expected non-list warning, got: {[r.getMessage() for r in caplog.records]}"


# ---------------------------------------------------------------------------


def test_loader_parses_essence_by_shot_from_manifest():
    """shot_essence_extraction 체크포인트 → (si, shi) → essence list."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    cp_data = {
        "data": {
            "shots": [
                {"scene_index": 5, "shot_index": 1, "essence": ["여인이 무릎을 꿇고 손을 뻗는다", "선명한 핏자국"], "peripheral": [], "atmospheric": [], "status": "ok"},
                {"scene_index": 5, "shot_index": 3, "essence": ["여인이 일어선다"], "peripheral": [], "atmospheric": [], "status": "ok"},
                {"scene_index": 12, "shot_index": 1, "essence": [], "peripheral": [], "atmospheric": [], "status": "failed"},
            ],
        },
    }

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return cp_data if step_id == "shot_essence_extraction" else None

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_essence_by_shot()
    assert result == {
        (5, 1): ["여인이 무릎을 꿇고 손을 뻗는다", "선명한 핏자국"],
        (5, 3): ["여인이 일어선다"],
        # (12, 1) is status='failed' with empty essence → not mapped
    }


def test_loader_returns_empty_when_no_shot_essence_checkpoint():
    """Phase 1b step 미실행 → 빈 dict."""
    from app.core.steps.scene_context_loader import SceneContextLoader

    class FakeRunner:
        def _load_prev_checkpoint(self, step_id):
            return None

    loader = SceneContextLoader(FakeRunner())
    result = loader._load_essence_by_shot()
    assert result == {}


# ---------------------------------------------------------------------------
# Task 7: _build_phase2_prepend_blocks helper unit tests
# ---------------------------------------------------------------------------


def test_build_prepend_blocks_both_off():
    """두 토글 다 off → 빈 문자열 (회귀 보장)."""
    from app.core.steps.detail_steps import _build_phase2_prepend_blocks
    out = _build_phase2_prepend_blocks(
        si=5, shi=1,
        essence_by_shot={(5, 1): ["x"]},
        chain_bg_guide_by_shot={(5, 1): "g"},
        shot_essence_enabled=False,
        chain_bg_guide_enabled=False,
    )
    assert out == ""


def test_build_prepend_blocks_essence_only():
    """essence on + chain_bg_guide off → essence 블록만."""
    from app.core.steps.detail_steps import _build_phase2_prepend_blocks
    out = _build_phase2_prepend_blocks(
        si=5, shi=1,
        essence_by_shot={(5, 1): ["여인이 무릎을 꿇는다", "핏자국"]},
        chain_bg_guide_by_shot={(5, 1): "ignored"},
        shot_essence_enabled=True,
        chain_bg_guide_enabled=False,
    )
    assert "[샷 핵심 시각 요소" in out
    assert "여인이 무릎을 꿇는다" in out
    assert "핏자국" in out
    assert "[chain_bg" not in out
    assert out.endswith("\n\n")


def test_build_prepend_blocks_chain_bg_only():
    """chain_bg_guide on + essence off → chain_bg 블록만."""
    from app.core.steps.detail_steps import _build_phase2_prepend_blocks
    out = _build_phase2_prepend_blocks(
        si=5, shi=1,
        essence_by_shot={(5, 1): ["ignored"]},
        chain_bg_guide_by_shot={(5, 1): "TV upper-left. DO NOT redraw."},
        shot_essence_enabled=False,
        chain_bg_guide_enabled=True,
    )
    assert "[chain_bg reference에 이미 있음" in out
    assert "TV upper-left" in out
    assert "DO NOT redraw" in out
    assert "[샷 핵심" not in out


def test_build_prepend_blocks_both_on_essence_first():
    """둘 다 on → essence 먼저, chain_bg 뒤."""
    from app.core.steps.detail_steps import _build_phase2_prepend_blocks
    out = _build_phase2_prepend_blocks(
        si=5, shi=1,
        essence_by_shot={(5, 1): ["e1"]},
        chain_bg_guide_by_shot={(5, 1): "g1"},
        shot_essence_enabled=True,
        chain_bg_guide_enabled=True,
    )
    e_idx = out.find("[샷 핵심")
    c_idx = out.find("[chain_bg")
    assert 0 <= e_idx < c_idx, f"essence should appear before chain_bg: {out!r}"


def test_build_prepend_blocks_missing_data_returns_empty():
    """토글 on이지만 data 없음 → 빈 문자열."""
    from app.core.steps.detail_steps import _build_phase2_prepend_blocks
    out = _build_phase2_prepend_blocks(
        si=5, shi=1,
        essence_by_shot={},
        chain_bg_guide_by_shot={},
        shot_essence_enabled=True,
        chain_bg_guide_enabled=True,
    )
    assert out == ""


def test_build_prepend_blocks_handles_failed_status_essence():
    """essence가 빈 list (Phase 1b status='failed' fallback) → essence 블록 0."""
    from app.core.steps.detail_steps import _build_phase2_prepend_blocks
    out = _build_phase2_prepend_blocks(
        si=5, shi=1,
        essence_by_shot={(5, 1): []},  # empty list
        chain_bg_guide_by_shot={(5, 1): "g"},
        shot_essence_enabled=True,
        chain_bg_guide_enabled=True,
    )
    assert "[샷 핵심" not in out
    assert "[chain_bg" in out


# ---------------------------------------------------------------------------
# Phase 7 (T15) — background_render shape 호환성 회귀
# ---------------------------------------------------------------------------


def test_loader_parses_phase7_render_shape(tmp_path):
    """Phase 7 background_render 출력(data.groups[bg_id])을 Phase 5와 동일하게 파싱.

    Phase 7 background_render는 Phase 5 chain_bg_render와 동일한 data.groups
    shape (status / location_id / shot_ids / shot_guides) 를 출력한다.
    따라서 scene_context_loader의 _load_chain_bg_guide_by_shot는 변경 없이도
    Phase 7 결과를 그대로 소비할 수 있다.

    이 테스트는 호환 명세를 코드에 박아두는 회귀 가드 역할이다.
    실제 loader 호출은 기존 test_loader_parses_phase5_groups_shape이 커버한다.
    """
    sample_cp = {
        "data": {
            "groups": {
                "cb_living_day": {
                    "status": "ok",
                    "location_id": "L01",
                    "shot_ids": ["S01_Shot1"],
                    "shot_guides": [
                        {"shot_id": "S01_Shot1", "guide": "Phase 7 marker"},
                    ],
                },
            }
        }
    }
    # 핵심 메시지: Phase 7 shape == Phase 5 shape → loader 변경 0
    assert "groups" in sample_cp["data"]
    assert "cb_living_day" in sample_cp["data"]["groups"]
    assert sample_cp["data"]["groups"]["cb_living_day"]["status"] == "ok"
    assert sample_cp["data"]["groups"]["cb_living_day"]["shot_guides"][0]["shot_id"] == "S01_Shot1"
