"""Tests for BackgroundRenderStep — Phase 7 Step 6 step entry."""
from __future__ import annotations

from types import SimpleNamespace
from typing import Any, List
from unittest.mock import MagicMock, patch

from app.core.steps.background_render_step import BackgroundRenderStep


def test_step_disabled_when_mode_off():
    """background_mode='off' 이면 applicable_count=0 (step skip).

    D6 T5c: disabled path 도 `consumed_bg_catalog_hash` field 보유 (preflight 일관성).
    """
    with patch("app.core.config.settings.background_mode", "off"):
        step = BackgroundRenderStep.__new__(BackgroundRenderStep)
        step.project_id = "p"
        step.episode_id = "e"
        result = step._execute()
        assert result["applicable_count"] == 0
        assert result["completed_count"] == 0
        assert result["failed_count"] == 0
        # D6 T5-fix: data 가 빈 dict 가 아닌 stamp field 만 — catalog + binding 둘 다.
        assert result["data"] == {
            "consumed_bg_catalog_hash": "",
            "consumed_shot_binding_hash": "",
        }


# ─────────────────────────────────────────────
# B4 — ImageAsset UPSERT 단위 테스트
# ─────────────────────────────────────────────

def _make_step_with_db(monkeypatch, tmp_path, *, canons=None, existing=None):
    """ImageAsset UPSERT 테스트용 step + mock db.

    canons: List of mock EntityCanon. 기본 [L01].
    existing: query(ImageAsset).filter_by(...).first() 반환값. 기본 None.
    """
    if canons is None:
        canon = MagicMock()
        canon.short_id = "L01"
        canon.id = "canon-l01"
        canons = [canon]

    monkeypatch.setattr(
        "app.core.config.settings.projects_dir", str(tmp_path / "p")
    )
    (tmp_path / "p").mkdir(parents=True, exist_ok=True)

    step = BackgroundRenderStep.__new__(BackgroundRenderStep)
    step.project_id = "proj-test"
    step.episode_id = "ep1"
    step.db = MagicMock()

    canon_query = MagicMock()
    canon_query.filter.return_value.all.return_value = canons
    asset_query = MagicMock()
    asset_query.filter_by.return_value.first.return_value = existing

    def _query_router(model):
        from app.models.project import EntityCanon as _EC, ImageAsset as _IA
        if model is _EC:
            return canon_query
        if model is _IA:
            return asset_query
        return MagicMock()

    step.db.query.side_effect = _query_router
    return step


def test_b4_register_chain_bg_image_asset_basic_insert(tmp_path, monkeypatch):
    """ok 1개 bg → ImageAsset insert 1회.

    variant_index=1 (v00은 floor_plan 예약), variant_type=bg_id, is_primary=0,
    t2i_guide는 shot_guides newline join.
    """
    step = _make_step_with_db(monkeypatch, tmp_path)

    png = tmp_path / "p" / "bg.png"
    png.write_bytes(b"PNG")

    groups = {
        "cb_living_day": {
            "status": "ok",
            "location_id": "L01",
            "png_path": str(png),
            "t2i_prompt": "photo of living room day",
            "shot_guides": [
                {"shot_id": "S1_Shot1", "guide": "TV upper-left; sofa center"},
                {"shot_id": "S1_Shot2", "guide": "Window right; door mid"},
            ],
            "shot_ids": ["S1_Shot1", "S1_Shot2"],
            "variant_label": "day",
            "parent_id": "",
        },
    }
    order = ["cb_living_day"]

    added: List[Any] = []
    step.db.add = lambda obj: added.append(obj)

    step._register_image_assets(groups, order)

    assert len(added) == 1
    asset = added[0]
    assert asset.asset_type == "chain_bg"
    assert asset.entity_id == "canon-l01"
    assert asset.episode_id == "ep1"
    assert asset.project_id == "proj-test"
    assert asset.variant_index == 1  # v00 = floor_plan reserved
    assert asset.variant_label == "day"
    assert asset.variant_type == "cb_living_day"
    assert asset.is_primary == 0
    assert asset.t2i_guide is not None
    assert "TV upper-left" in asset.t2i_guide
    assert "Window right" in asset.t2i_guide
    assert "[S1_Shot1]" in asset.t2i_guide
    assert "[S1_Shot2]" in asset.t2i_guide
    assert asset.prompt_used == "photo of living room day"
    assert asset.generation_model == "gpt-image-2.5-sunburst"
    assert asset.status == "generated"
    step.db.commit.assert_called_once()


def test_b4_register_chain_bg_skips_failed(tmp_path, monkeypatch):
    """status=failed 그룹은 ImageAsset 생성 X."""
    step = _make_step_with_db(monkeypatch, tmp_path)

    groups = {
        "cb_failed": {
            "status": "failed",
            "location_id": "L01",
            "png_path": "",
            "t2i_prompt": "x",
            "shot_guides": [],
            "shot_ids": [],
            "variant_label": "day",
            "parent_id": "",
        },
    }
    order = ["cb_failed"]
    added: List[Any] = []
    step.db.add = lambda obj: added.append(obj)

    step._register_image_assets(groups, order)
    assert added == []
    step.db.commit.assert_not_called()


def test_b4_register_chain_bg_idempotent_upsert(tmp_path, monkeypatch):
    """existing row 있을 때 UPDATE만 (db.add 호출 0)."""
    existing = MagicMock()
    existing.file_path = "old"
    step = _make_step_with_db(monkeypatch, tmp_path, existing=existing)

    png = tmp_path / "p" / "bg.png"
    png.write_bytes(b"PNG")

    groups = {
        "cb_living_day": {
            "status": "ok",
            "location_id": "L01",
            "png_path": str(png),
            "t2i_prompt": "new prompt",
            "shot_guides": [{"shot_id": "S1", "guide": "TV upper"}],
            "shot_ids": ["S1"],
            "variant_label": "day",
            "parent_id": "",
        },
    }
    order = ["cb_living_day"]
    added: List[Any] = []
    step.db.add = lambda obj: added.append(obj)

    step._register_image_assets(groups, order)

    assert added == []  # UPDATE path — no db.add
    assert existing.prompt_used == "new prompt"
    assert existing.variant_label == "day"
    assert existing.variant_index == 1
    assert existing.is_primary == 0
    assert "TV upper" in (existing.t2i_guide or "")
    assert existing.status == "generated"
    step.db.commit.assert_called_once()


def test_b4_register_chain_bg_multi_state_per_location(tmp_path, monkeypatch):
    """동일 location_id의 multiple bg → variant_index 1/2 (order 순)."""
    step = _make_step_with_db(monkeypatch, tmp_path)

    png_d = tmp_path / "p" / "d.png"
    png_d.write_bytes(b"D")
    png_n = tmp_path / "p" / "n.png"
    png_n.write_bytes(b"N")

    groups = {
        "cb_living_dusk": {
            "status": "ok", "location_id": "L01", "png_path": str(png_n),
            "t2i_prompt": "dusk", "shot_guides": [], "shot_ids": [],
            "variant_label": "dusk", "parent_id": "cb_living_day",
        },
        "cb_living_day": {
            "status": "ok", "location_id": "L01", "png_path": str(png_d),
            "t2i_prompt": "day", "shot_guides": [], "shot_ids": [],
            "variant_label": "day", "parent_id": "",
        },
    }
    # order = master_plan gen_order에서 도출 — day가 먼저
    order = ["cb_living_day", "cb_living_dusk"]
    added: List[Any] = []
    step.db.add = lambda obj: added.append(obj)

    step._register_image_assets(groups, order)

    assert len(added) == 2
    by_bg = {a.variant_type: a for a in added}
    # order 순으로 1, 2
    assert by_bg["cb_living_day"].variant_index == 1
    assert by_bg["cb_living_day"].variant_label == "day"
    assert by_bg["cb_living_dusk"].variant_index == 2
    assert by_bg["cb_living_dusk"].variant_label == "dusk"
    # 모두 chain_bg는 is_primary=0
    assert all(a.is_primary == 0 for a in added)


def test_b4_register_chain_bg_no_canon_skips(tmp_path, monkeypatch):
    """EntityCanon 없으면 graceful skip."""
    step = _make_step_with_db(monkeypatch, tmp_path, canons=[])

    png = tmp_path / "p" / "bg.png"
    png.write_bytes(b"PNG")
    groups = {
        "cb_living_day": {
            "status": "ok", "location_id": "L01", "png_path": str(png),
            "t2i_prompt": "x", "shot_guides": [], "shot_ids": [],
            "variant_label": "day", "parent_id": "",
        },
    }
    order = ["cb_living_day"]
    added: List[Any] = []
    step.db.add = lambda obj: added.append(obj)

    step._register_image_assets(groups, order)
    assert added == []
    step.db.commit.assert_not_called()


def test_b4_register_chain_bg_empty_shot_guides_t2i_guide_none(tmp_path, monkeypatch):
    """shot_guides=[] 이면 t2i_guide=None (Phase 5 동일 동작)."""
    step = _make_step_with_db(monkeypatch, tmp_path)

    png = tmp_path / "p" / "bg.png"
    png.write_bytes(b"PNG")
    groups = {
        "cb_living_day": {
            "status": "ok", "location_id": "L01", "png_path": str(png),
            "t2i_prompt": "x", "shot_guides": [], "shot_ids": [],
            "variant_label": "day", "parent_id": "",
        },
    }
    order = ["cb_living_day"]
    added: List[Any] = []
    step.db.add = lambda obj: added.append(obj)

    step._register_image_assets(groups, order)
    assert len(added) == 1
    assert added[0].t2i_guide is None


def test_verify_completion_reports_extra_chain_bg_diagnostics(tmp_path, monkeypatch):
    """Extra chain_bg rows/files are diagnostic-only, not completion blockers."""
    from app.core.steps.background_render_step import BackgroundRenderStep

    projects_dir = tmp_path / "projects"
    monkeypatch.setattr("app.core.config.settings.background_mode", "on")
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(projects_dir))

    step = BackgroundRenderStep.__new__(BackgroundRenderStep)
    step.project_id = "proj"
    step.episode_id = "ep"
    step.db = MagicMock()

    image_dir = projects_dir / "proj" / "episodes" / "ep" / "images" / "background_chain"
    image_dir.mkdir(parents=True)
    active_png = image_dir / "L01B01.png"
    extra_row_png = image_dir / "L01B99.png"
    extra_file_png = image_dir / "L01B77.png"
    for p in (active_png, extra_row_png, extra_file_png):
        p.write_bytes(b"\x89PNG\r\n\x1a\n")

    plans_cp = {
        "data": {
            "bg_catalog_hash": "h",
            "plans": {
                "g1": {
                    "status": "ok",
                    "plan": {
                        "backgrounds": [
                            {"bg_id": "L01B01", "loc_id": "L01"},
                        ],
                    },
                },
            },
        },
    }
    prompts_cp = {
        "data": {
            "backgrounds": {
                "L01B01": {"status": "ok"},
            },
        },
    }

    def _load_cp(step_id: str):
        return {
            "background_master_plan": plans_cp,
            "background_prompt": prompts_cp,
        }.get(step_id)

    step._load_prev_checkpoint = _load_cp
    rows = [
        SimpleNamespace(variant_type="L01B01", file_path=str(active_png)),
        SimpleNamespace(variant_type="L01B99", file_path=str(extra_row_png)),
        SimpleNamespace(variant_type="L01B98", file_path=str(image_dir / "missing.png")),
    ]
    query = MagicMock()
    query.filter.return_value = query
    query.all.return_value = rows
    step.db.query.return_value = query

    report = step.verify_completion()

    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.metadata["chain_bg_expected"] == 1
    assert report.metadata["chain_bg_found"] == 1
    assert report.metadata["chain_bg_extra_asset_rows"] == 2
    assert report.metadata["chain_bg_extra_asset_bg_ids"] == ["L01B98", "L01B99"]
    assert report.metadata["chain_bg_extra_asset_rows_with_files"] == 1
    assert report.metadata["chain_bg_extra_file_count"] == 2
    assert report.metadata["chain_bg_extra_file_bg_ids"] == ["L01B77", "L01B99"]
    assert report.metadata["chain_bg_extra_overflow"] is False


def test_verify_completion_truncates_extra_chain_bg_diagnostics(tmp_path, monkeypatch):
    """Extra chain_bg diagnostics expose counts but cap id samples at 50."""
    from app.core.steps.background_render_step import BackgroundRenderStep

    projects_dir = tmp_path / "projects"
    monkeypatch.setattr("app.core.config.settings.background_mode", "on")
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(projects_dir))

    step = BackgroundRenderStep.__new__(BackgroundRenderStep)
    step.project_id = "proj"
    step.episode_id = "ep"
    step.db = MagicMock()

    image_dir = projects_dir / "proj" / "episodes" / "ep" / "images" / "background_chain"
    image_dir.mkdir(parents=True)
    active_png = image_dir / "L01B01.png"
    active_png.write_bytes(b"\x89PNG\r\n\x1a\n")
    for idx in range(55):
        (image_dir / f"L01F{idx:02d}.png").write_bytes(b"\x89PNG\r\n\x1a\n")

    plans_cp = {
        "data": {
            "bg_catalog_hash": "h",
            "plans": {
                "g1": {
                    "status": "ok",
                    "plan": {"backgrounds": [{"bg_id": "L01B01"}]},
                },
            },
        },
    }
    prompts_cp = {"data": {"backgrounds": {"L01B01": {"status": "ok"}}}}
    step._load_prev_checkpoint = lambda step_id: {
        "background_master_plan": plans_cp,
        "background_prompt": prompts_cp,
    }.get(step_id)

    rows = [SimpleNamespace(variant_type="L01B01", file_path=str(active_png))]
    rows.extend(
        SimpleNamespace(variant_type=f"L01R{idx:02d}", file_path=str(image_dir / "missing.png"))
        for idx in range(55)
    )
    query = MagicMock()
    query.filter.return_value = query
    query.all.return_value = rows
    step.db.query.return_value = query

    report = step.verify_completion()

    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.metadata["chain_bg_extra_asset_rows"] == 55
    assert len(report.metadata["chain_bg_extra_asset_bg_ids"]) == 50
    assert report.metadata["chain_bg_extra_file_count"] == 55
    assert len(report.metadata["chain_bg_extra_file_bg_ids"]) == 50
    assert report.metadata["chain_bg_extra_overflow"] is True


# ─────────────────────────────────────────────
# T20 I4 — path traversal guard + Phase 6 contract
# ─────────────────────────────────────────────


def test_unsafe_bg_id_rejected_at_collection():
    """D6 T-pre-1 (marker-gated): bg_id 필터가 D6 marker 따라 분기.

    - D6 cp (sibling `background_catalog` / `bg_catalog_hash`) → BG_ID_RE strict.
    - legacy cp → `_LEGACY_SAFE_BG_RE` (transitional compat).
    """
    from app.core.steps.background_render_step import (
        _select_bg_id_filter, _LEGACY_SAFE_BG_RE,
    )
    from app.core.bg_state_vocab import BG_ID_RE

    # D6 marker → BG_ID_RE strict
    d6_cp = {"data": {"plans": {}, "background_catalog": {}}}
    rx_d6 = _select_bg_id_filter(d6_cp)
    assert rx_d6 is BG_ID_RE
    assert rx_d6.match("L09B01")
    assert rx_d6.match("L113B07")
    assert not rx_d6.match("cb_living_day")  # legacy reject in D6 mode
    assert not rx_d6.match("../etc/passwd")
    assert not rx_d6.match("L09 B01")
    assert not rx_d6.match("")

    # no D6 marker → legacy regex (transitional)
    legacy_cp = {"data": {"plans": {}}}
    rx_legacy = _select_bg_id_filter(legacy_cp)
    assert rx_legacy is _LEGACY_SAFE_BG_RE
    # legacy whitelist
    assert rx_legacy.match("cb_living_day")
    assert rx_legacy.match("a")
    assert rx_legacy.match("a1_b2")
    # legacy 거절 — 대문자 / 특수문자 / 경로 traversal / 공백
    assert not rx_legacy.match("CB_LIVING")
    assert not rx_legacy.match("../etc/passwd")
    assert not rx_legacy.match("/abs/path")
    assert not rx_legacy.match("a-b")
    assert not rx_legacy.match("_starts_with_underscore")
    assert not rx_legacy.match("with space")
    assert not rx_legacy.match("")


def test_phase6_required_keys_present_on_failed_status(tmp_path, monkeypatch):
    """failed status도 Phase 6 contract 8 key 모두 포함 (UI/loader 일관성).

    실제 fp_render 후 bg_render의 status='failed' branch가 emitting하는 dict가
    legacy chain_bg_render와 동일한 shape를 유지하는지 확인.
    """
    step = _make_step_with_db(monkeypatch, tmp_path)

    # _execute 전체를 돌리지 않고, 직접 build해서 Phase 6 key 존재 검증.
    # 실제 step이 만드는 failed entry가 누락 없이 8 key를 채우는지가 핵심.
    REQUIRED_KEYS = {
        "status", "location_id", "png_path", "t2i_prompt",
        "shot_guides", "shot_ids", "parent_id", "ref_used",
    }
    failed_entry = {
        "status": "failed",
        "location_id": "L01",
        "png_path": "",
        "t2i_prompt": "",
        "shot_guides": [],
        "shot_ids": [],
        "parent_id": "",
        "ref_used": "",
        "error": "moderation",
    }
    assert REQUIRED_KEYS.issubset(failed_entry.keys())
    # 실제 step이 동일한 contract을 enforce하는지 — _execute path 검사는
    # tests/integration/test_phase7_e2e.py가 이미 happy path를 verify.


def test_resolve_openai_client_passes_api_key_and_timeout(monkeypatch):
    """_resolve_openai_client는 키를 **명시 전달**해야 한다.

    회귀 가드: bare OpenAI()는 os.environ["OPENAI_API_KEY"]만 읽어 .env에서
    로드된 키도, 2슬롯 failover 로 전환된 키도 인식하지 못한다.
    2026-07-30 부터 키는 `app.core.openai_keys` 브로커가 정하고, 실제 SDK
    클라이언트는 첫 호출 시점에 활성 슬롯 키로 만들어진다.
    """
    import openai

    from app.core.steps import background_render_step

    captured: dict = {}

    class _FakeOpenAI:
        def __init__(self, **kwargs):
            captured.update(kwargs)
            self.images = SimpleNamespace(
                generate=lambda **kw: {"ok": True})

    monkeypatch.setattr(openai, "OpenAI", _FakeOpenAI)
    from types import SimpleNamespace  # noqa: F401 (아래 _FakeOpenAI)
    from app.core import openai_keys

    openai_keys.reset()
    monkeypatch.setattr("app.core.config.settings.openai_api_key", "sk-background-test")
    monkeypatch.setattr(
        "app.core.config.settings.openai_api_key_secondary", "")
    monkeypatch.setattr("app.core.config.settings.llm_timeout_image_gen", 420)

    client = background_render_step._resolve_openai_client()
    # 브로커 프록시는 **첫 호출 시** 실제 SDK 클라이언트를 만든다 —
    # 키가 명시 전달되는지는 그 시점에 드러난다.
    client.images.generate(model="gpt-image-2.5-sunburst")

    assert captured["api_key"] == "sk-background-test"
    assert captured["timeout"] == 420.0


def test_config_hash_stamps_fix_rejudge_only_when_on(monkeypatch):
    """E2E10 fix② Codex BLOCKING-2 — 완료 CP skip 을 뚫는 상위 hash 스탬프.

    plate_multiroll ON+critique ON 전제에서 fix-rejudge flag 토글이 hash
    drift 를 만들고, OFF 복귀 시 원 hash 불변이어야 한다.
    """
    from app.core.config import settings
    from app.core.steps.background_render_step import BackgroundRenderStep

    step = BackgroundRenderStep.__new__(BackgroundRenderStep)
    monkeypatch.setattr(settings, "plate_multiroll_enabled", True)
    monkeypatch.setattr(settings, "still_recipe_critique_enabled", True)
    monkeypatch.setattr(settings, "multiroll_fix_rejudge_enabled", False)
    off = step._config_hash()
    monkeypatch.setattr(settings, "multiroll_fix_rejudge_enabled", True)
    on = step._config_hash()
    assert off != on
    # critique OFF 면 재판정 자체가 없음 — 무스탬프
    monkeypatch.setattr(settings, "still_recipe_critique_enabled", False)
    no_crit_on = step._config_hash()
    monkeypatch.setattr(settings, "multiroll_fix_rejudge_enabled", False)
    assert step._config_hash() == no_crit_on
    monkeypatch.setattr(settings, "still_recipe_critique_enabled", True)
    assert step._config_hash() == off
