"""Dispatcher mode 의미 명문화 + resume 자동 회복 단위 테스트 (Fix 3 / M3 + M4 해소).

Fix 3.1 (M3): resume 모드가 stale step을 자동 회복.
- 이전 동작: config_hash mismatch → AppError "step.resume_invalid" → dispatcher 영구 멈춤
- 현재 동작 (default): mismatch → force-like 자동 재실행. logger.warning에 config diff.
- 사용자가 strict_resume=True 명시 시 legacy 동작 유지.

Fix 3.2: _diff_project_config — 어느 key가 변경되었는지 진단. 민감 정보(api_key 등) 마스킹.

Fix 3.3 (M4): force=image transitive auto-include 제거.
- 이전 동작: image step의 analysis dep 자동 포함 → 사용자가 "image만" 의도해도 43 step 폭발.
- 현재 동작: image step만 직접 반환. analysis dep 미완료 시 prerequisite 검증 에러.
"""
from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest

from app.core.applicability import _diff_project_config
from app.core.errors import AppError
from app.services import analysis_dispatch_service as ds


# ── Fix 3.1: resume 자동 회복 ────────────────────────────────────────────


def _make_runner_for_resume_test(monkeypatch, *, cp_data, project_config, manifest=None):
    """resume 흐름 테스트용 StepRunner stub.

    Heavy dependencies (DB, file I/O) 를 monkeypatch로 격리.
    """
    from app.core.step_runner import StepRunner

    inst = StepRunner.__new__(StepRunner)
    inst.step_id = "background_classify"
    inst.project_id = "p1"
    inst.episode_id = "e1"
    inst.db = MagicMock()
    inst.run_id = "r1"
    inst.manifest = manifest or {"schema_version": 1}
    inst.project_config = project_config
    inst.opik_context = {}
    inst._cp_dir = MagicMock()

    # gate / applicability 통과
    monkeypatch.setattr(inst, "check_gate", lambda: None)
    monkeypatch.setattr(inst, "check_applicability", lambda: True)
    # step_run = completed
    monkeypatch.setattr(inst, "_get_step_run", lambda sid: {
        "status": "completed",
        "run_id": "r-test",
        "started_at": None,
        "completed_count": 1,
        "applicable_count": 1,
        "recovery_count": 0,
        "updated_at": None,
    })
    # cp 로드
    monkeypatch.setattr(inst, "load_checkpoint", lambda: cp_data)
    # auto-recovery 시 호출되는 부수효과
    inst._invalidate_called = False
    inst._clear_called = False
    inst._executed = False

    def _fake_invalidate():
        inst._invalidate_called = True

    def _fake_clear():
        inst._clear_called = True

    monkeypatch.setattr(inst, "invalidate_downstream", _fake_invalidate)
    monkeypatch.setattr(inst, "clear_checkpoint", _fake_clear)
    # Block C: _update_step_run 는 bool 반환 (True=row 매칭). _update_step_run_strict
    # 는 require_owner=True 자동 적용 + False → AppError. claim 후 transition 모두
    # owner mismatch 없는 success path 로 mock.
    monkeypatch.setattr(inst, "_update_step_run", lambda *a, **kw: True)
    monkeypatch.setattr(inst, "_update_step_run_strict", lambda *a, **kw: None)
    monkeypatch.setattr(inst, "_try_claim_running", lambda *a, **kw: True)
    monkeypatch.setattr(inst, "save_checkpoint", lambda data: None)
    monkeypatch.setattr(inst, "_resolve_model", lambda: "test-model")
    monkeypatch.setattr(inst, "build_opik_metadata", lambda **kw: {})

    # Task 6 / Task 14: recovery counter는 raw SQL → MagicMock db에서 int 반환 보장.
    # 기본값은 "신규 사이클" (count=0). 개별 테스트가 필요하면 override.
    monkeypatch.setattr(inst, "_get_recovery_count", lambda: 0)
    monkeypatch.setattr(inst, "_get_last_recovery_reason", lambda: "(없음)")
    monkeypatch.setattr(inst, "_record_recovery", lambda reason: 1)
    monkeypatch.setattr(inst, "_reset_recovery_counter", lambda: None)

    def _fake_execute(mode):
        inst._executed = True
        return {"completed_count": 1, "applicable_count": 1, "failed_count": 0}

    monkeypatch.setattr(inst, "_execute", _fake_execute)
    return inst


def test_resume_skips_completed(monkeypatch):
    """status=completed + cp 정상 → skip (legacy 정상 case 유지)."""
    from app.core.step_runner import compute_config_hash

    project_config = {"foo": "bar"}
    cp = {
        "status": "completed",
        "schema_version": 1,
        "config_hash": compute_config_hash(project_config),
    }
    inst = _make_runner_for_resume_test(monkeypatch, cp_data=cp, project_config=project_config)

    result = inst.run(mode="resume")

    assert result["status"] == "skipped"
    assert not inst._executed
    assert not inst._invalidate_called


def test_resume_config_hash_mismatch_blocks_after_b5(monkeypatch):
    """Block B B5 (plan v2.1.3 / spec V5 §4.5): config_hash mismatch → BLOCK
    by default. M3 의 auto-recover 정책은 B5 에서 reverse — schema/config drift
    는 자동 force-like 차단, 사용자 명시 force / 진단 강제.

    이전 동작 (M3 fix): config_hash mismatch → AppError 없이 자동 force-like 실행
    이후 동작 (B5): config_hash mismatch → AppError(step.resume_invalid, origin=
    contract_drift). entity_t2i 도 config_hash 는 BLOCK (allowlist 미적용).
    """
    cp = {
        "status": "completed",
        "schema_version": 1,
        "config_hash": "stale_hash_12345",
    }
    project_config = {"new_key": "new_value"}  # hash가 cp와 다름
    inst = _make_runner_for_resume_test(monkeypatch, cp_data=cp, project_config=project_config)

    with pytest.raises(AppError) as exc_info:
        inst.run(mode="resume")

    assert exc_info.value.code == "step.resume_invalid"
    # contract_drift origin → run() 메시지 형식 검증
    assert "contract drift" in exc_info.value.message.lower()
    # 자동 재실행 안 함 (auto-force 차단 가드)
    assert not inst._executed
    assert not inst._invalidate_called
    assert not inst._clear_called


def test_resume_schema_mismatch_entity_t2i_auto_recovers_via_allowlist(monkeypatch):
    """B5 + B12: entity_t2i schema_version mismatch → RERUN_SELF (allowlist) →
    `_execute_rerun_self()` (cleanup/invalidate 호출 X — downstream 보존).

    이전 (B5 직후): mode='force' 격상 → cleanup + invalidate + execute('force').
    B12 후: execute('resume'), cleanup/invalidate skip.

    expiry: D3 (manifest.allow_auto_rerun_on_schema_bump flag) 도입 후 제거 예정.
    """
    cp = {
        "status": "completed",
        "schema_version": 1,  # current = 2
        "config_hash": "ignored",
    }
    project_config = {}
    inst = _make_runner_for_resume_test(
        monkeypatch,
        cp_data=cp,
        project_config=project_config,
        manifest={"schema_version": 2},
    )
    # allowlist 적용: step_id 를 entity_t2i 로 변경
    inst.step_id = "entity_t2i"

    result = inst.run(mode="resume")
    assert result["status"] == "completed"
    assert inst._executed
    # B12: RERUN_SELF 는 invalidate_downstream 호출 X (downstream cp 보존)
    assert not inst._invalidate_called, (
        "B12: allowlist RERUN_SELF 는 invalidate_downstream 호출 안 함"
    )


def test_resume_with_strict_flag_raises_legacy(monkeypatch):
    """strict_resume=True 시 legacy 동작 (AppError raise)."""
    cp = {
        "status": "completed",
        "schema_version": 1,
        "config_hash": "stale_hash",
    }
    project_config = {"strict_resume": True, "foo": "changed"}
    inst = _make_runner_for_resume_test(monkeypatch, cp_data=cp, project_config=project_config)

    with pytest.raises(AppError) as exc_info:
        inst.run(mode="resume")
    assert exc_info.value.code == "step.resume_invalid"
    assert "strict_resume" in exc_info.value.message
    # 자동 재실행 안 함
    assert not inst._executed
    assert not inst._invalidate_called


def test_resume_schema_mismatch_non_allowlisted_blocks_after_b5(monkeypatch):
    """B5 (plan v2.1.3 §4.5): schema_version mismatch + non-allowlisted step
    → BLOCK (이전 auto-recover 정책 reverse).

    background_classify (fixture default step) 는 _LEGACY_SCHEMA_BUMP_ALLOWLIST
    에 미포함 → contract_drift BLOCK. entity_t2i 만 allowlist (위
    test_resume_schema_mismatch_entity_t2i_auto_recovers_via_allowlist 참조).
    """
    cp = {
        "status": "completed",
        "schema_version": 1,  # current = 2
        "config_hash": "ignored",
    }
    project_config = {}
    inst = _make_runner_for_resume_test(
        monkeypatch,
        cp_data=cp,
        project_config=project_config,
        manifest={"schema_version": 2},
    )

    with pytest.raises(AppError) as exc_info:
        inst.run(mode="resume")
    assert exc_info.value.code == "step.resume_invalid"
    assert "contract drift" in exc_info.value.message.lower()
    assert not inst._executed


def test_resume_with_no_cp_triggers_rerun_self(monkeypatch):
    """D1 + B12: status=completed + cp=None (manifest 폐기) → RERUN_SELF +
    origin='artifact_missing' → `_execute_rerun_self()` (silent miss 차단 +
    cleanup/invalidate skip).

    이전 동작 (T1~T3 직후): mode='force' 격상 → cleanup + invalidate.
    이후 (B12): rerun_self path — recovery 기록 후 _execute(mode='resume'),
    cleanup/invalidate skip (downstream cp 보존). silent miss 차단은 그대로
    유지 (skip 대신 재실행).
    """
    inst = _make_runner_for_resume_test(monkeypatch, cp_data=None, project_config={})
    # _record_recovery는 raw SQL UPDATE — MagicMock db가 흡수해줌.
    inst._recorded_reason = None
    inst._recorded_count = 0

    def _fake_record(reason: str) -> int:
        inst._recorded_reason = reason
        inst._recorded_count += 1
        return inst._recorded_count

    monkeypatch.setattr(inst, "_record_recovery", _fake_record)

    result = inst.run(mode="resume")
    assert result["status"] == "completed"
    assert inst._executed
    # B12: RERUN_SELF 는 invalidate_downstream / clear_checkpoint 호출 X
    assert not inst._invalidate_called, (
        "B12: artifact_missing RERUN_SELF 는 invalidate_downstream 호출 X"
    )
    assert not inst._clear_called, (
        "B12: artifact_missing RERUN_SELF 는 clear_checkpoint 호출 X"
    )
    # recovery 기록은 그대로 — 가시성 유지
    assert "checkpoint missing" in inst._recorded_reason
    assert inst._recorded_count == 1


# ── Fix 3.2: _diff_project_config ─────────────────────────────────────


def test_diff_project_config_no_old_snapshot():
    """legacy cp(snapshot 없음) → 진단 메시지."""
    diff = _diff_project_config(None, {"foo": "bar"})
    assert "_changed" in diff
    assert "snapshot 없음" in diff["_changed"]


def test_diff_project_config_no_changes():
    """변경 없으면 빈 dict."""
    cfg = {"foo": "bar", "x": 1}
    diff = _diff_project_config(cfg, dict(cfg))
    assert diff == {}


def test_diff_project_config_shows_changes():
    """변경된 key는 old/new 명시."""
    old = {"model": "gpt", "temp": 0.5, "same": "kept"}
    new = {"model": "gemini", "temp": 0.7, "same": "kept"}
    diff = _diff_project_config(old, new)
    assert diff["model"] == {"old": "gpt", "new": "gemini"}
    assert diff["temp"] == {"old": 0.5, "new": 0.7}
    assert "same" not in diff


def test_diff_project_config_masks_sensitive():
    """민감 키(api_key/secret/token 등)는 값 노출 없이 마스킹."""
    old = {"api_key": "sk-OLD", "openai_api_key": "OLD2", "regular": "x"}
    new = {"api_key": "sk-NEW", "openai_api_key": "NEW2", "regular": "y"}
    diff = _diff_project_config(old, new)
    assert diff["api_key"] == "<masked>"
    assert diff["openai_api_key"] == "<masked>"
    assert diff["regular"] == {"old": "x", "new": "y"}


def test_diff_project_config_added_removed_keys():
    """새 key 추가 / 기존 key 제거 모두 표시."""
    old = {"removed_key": "x"}
    new = {"added_key": "y"}
    diff = _diff_project_config(old, new)
    assert diff["removed_key"] == {"old": "x", "new": None}
    assert diff["added_key"] == {"old": None, "new": "y"}


def test_diff_project_config_type_mismatch():
    """비-dict가 들어오면 진단 메시지."""
    diff = _diff_project_config("not a dict", {"x": 1})
    assert "_changed" in diff
    assert "type mismatch" in diff["_changed"]


# ── Fix 3.3: force=image prerequisite 검증 ──────────────────────────────


def _stub_step_completed(completed_steps):
    """_is_step_completed를 dict-based mock으로 대체."""
    def _fake(db, project_id, episode_id, step_id):
        return step_id in completed_steps
    return _fake


def test_force_image_no_silent_transitive():
    """category='image' + episode_id=None → analysis step 자동 포함 안 함.

    이전: transitive로 background_prompt 등 끌어들였음.
    현재: image step만 반환.
    """
    db = MagicMock()
    db.query.return_value.filter.return_value.first.return_value = None

    step_ids = ds.select_steps_for_category(db, "p1", "image")

    from app.core.step_catalog import STEP_CATALOG
    for sid in step_ids:
        assert STEP_CATALOG[sid].category == "image", (
            f"image dispatch에 cross-category step이 포함됨: {sid}"
        )


def test_force_image_blocks_when_analysis_incomplete():
    """M4 핵심: image step의 analysis dep이 미완료면 AppError로 명확히 거부."""
    db = MagicMock()
    db.query.return_value.filter.return_value.first.return_value = None

    # 모든 step_run row가 None → 어떤 step도 completed가 아님
    db.execute.return_value.fetchone.return_value = None

    with pytest.raises(AppError) as exc_info:
        ds.select_steps_for_category(db, "p1", "image", episode_id="e1")

    err = exc_info.value
    assert err.code == "dispatch.deps_incomplete"
    assert err.status_code == 400
    # 메시지에 hint 포함 — 사용자가 다음 행동 결정 가능.
    assert "prerequisite" in err.message
    assert "category=all" in err.message


def test_force_image_succeeds_when_analysis_complete():
    """모든 image의 analysis dep이 completed면 정상 반환."""
    db = MagicMock()
    db.query.return_value.filter.return_value.first.return_value = None

    # 모든 step_run row가 completed status
    completed_row = MagicMock()
    completed_row.__getitem__ = lambda self, idx: "completed"  # row[0] == "completed"
    db.execute.return_value.fetchone.return_value = completed_row

    step_ids = ds.select_steps_for_category(db, "p1", "image", episode_id="e1")

    from app.core.step_catalog import STEP_CATALOG
    assert len(step_ids) > 0
    for sid in step_ids:
        assert STEP_CATALOG[sid].category == "image"


def test_force_all_includes_everything():
    """category='all' 동작 변경 없음 — 모든 active step 포함 (analysis + image)."""
    db = MagicMock()
    db.query.return_value.filter.return_value.first.return_value = None

    step_ids_all = ds.select_steps_for_category(db, "p1", "all")
    step_ids_analysis = ds.select_steps_for_category(db, "p1", "analysis")

    from app.core.step_catalog import STEP_CATALOG
    image_in_all = [s for s in step_ids_all if STEP_CATALOG[s].category == "image"]
    assert len(image_in_all) > 0
    assert len(step_ids_all) >= len(step_ids_analysis)


def test_analysis_does_not_include_image_steps_in_direct_return():
    """category='analysis' 직접 반환에는 image category step 이 포함되지 않는다.

    R3 cascade fix (2026-05-05): analysis 도 cross-category(image) prerequisite
    검증 적용. 검증 통과 시 (모든 image dep satisfied) analysis 만 직접 반환.
    이전 동작 (Phase 4.1): analysis 는 self-contained 가정 → 검증 skip — Phase 7
    cross-category dep 도입 후 silent cascade skip + status stuck 사고 trigger.
    """
    db = MagicMock()
    db.query.return_value.filter.return_value.first.return_value = None
    db.execute.return_value.fetchone.return_value = None

    with patch.object(ds, "_is_step_satisfied", return_value=True):
        step_ids = ds.select_steps_for_category(db, "p1", "analysis", episode_id="e1")

    assert len(step_ids) > 0
    from app.core.step_catalog import STEP_CATALOG
    for sid in step_ids:
        assert STEP_CATALOG[sid].category == "analysis"


def test_is_step_completed_helper_returns_false_when_not_found():
    db = MagicMock()
    db.execute.return_value.fetchone.return_value = None
    assert ds._is_step_completed(db, "p1", "e1", "any_step") is False


def test_is_step_completed_helper_returns_true_when_completed():
    db = MagicMock()
    row = MagicMock()
    row.__getitem__ = lambda self, idx: "completed"
    db.execute.return_value.fetchone.return_value = row
    assert ds._is_step_completed(db, "p1", "e1", "any_step") is True


def test_is_step_completed_helper_returns_false_when_partial():
    """partial은 완료 미달로 처리 — silent transitive 대신 명확한 에러."""
    db = MagicMock()
    row = MagicMock()
    row.__getitem__ = lambda self, idx: "partial"
    db.execute.return_value.fetchone.return_value = row
    assert ds._is_step_completed(db, "p1", "e1", "any_step") is False


# ── Codex P1-1: image preflight applicability 무시 fix ─────────────────


def test_evaluate_applicability_returns_not_applicable_when_background_off(monkeypatch):
    """settings.background_mode='off' 일 때 if_background_mode step은 not_applicable.

    P1-1 핵심 — dispatcher 가 이 결과를 prerequisite 통과로 인정해야 한다.
    """
    from app.core import applicability as appl

    # 모든 background_mode 의존 step에서 not_applicable 반환되는지 확인.
    from app.core.config import settings as _settings
    monkeypatch.setattr(_settings, "background_mode", "off")

    for sid in ("background_classify", "floor_plan_prompt", "background_prompt",
                "background_master_plan", "background_render", "floor_plan_render"):
        assert appl.evaluate_step_applicability(sid, "p1", "e1") == "not_applicable", (
            f"{sid} should be not_applicable when background_mode='off'"
        )


def test_evaluate_applicability_returns_applicable_when_background_on(monkeypatch):
    """settings.background_mode='on' 시 if_background_mode step은 applicable."""
    from app.core import applicability as appl

    from app.core.config import settings as _settings
    monkeypatch.setattr(_settings, "background_mode", "on")

    assert appl.evaluate_step_applicability("background_classify", "p1", "e1") == "applicable"
    assert appl.evaluate_step_applicability("background_prompt", "p1", "e1") == "applicable"


def test_evaluate_applicability_disabled_step_returns_not_applicable():
    """applicability='disabled' 인 step은 prerequisite로도 요구되지 않음."""
    from app.core import applicability as appl

    # background_chain_render: lifecycle=deprecated + applicability=disabled
    assert appl.evaluate_step_applicability("background_chain_render", "p1", "e1") == "not_applicable"


def test_evaluate_applicability_unknown_step_returns_applicable():
    """미지의 step_id는 conservative하게 applicable (prerequisite 정상 동작)."""
    from app.core import applicability as appl

    assert appl.evaluate_step_applicability("does_not_exist_xx", "p1", "e1") == "applicable"


def test_is_step_satisfied_when_completed():
    """status=completed 면 applicability 무관 satisfied."""
    db = MagicMock()
    row = MagicMock()
    row.__getitem__ = lambda self, idx: "completed"
    db.execute.return_value.fetchone.return_value = row
    assert ds._is_step_satisfied(db, "p1", "e1", "scene_save") is True


def test_is_step_satisfied_when_not_completed_but_not_applicable(monkeypatch):
    """미완료라도 applicability=not_applicable 이면 satisfied (StepRunner skip 가능)."""
    from app.core.config import settings as _settings
    monkeypatch.setattr(_settings, "background_mode", "off")

    db = MagicMock()
    db.execute.return_value.fetchone.return_value = None  # step_run row 없음

    # background_classify는 if_background_mode → off 상태에서 not_applicable
    assert ds._is_step_satisfied(db, "p1", "e1", "background_classify") is True


def test_is_step_satisfied_when_neither_completed_nor_not_applicable(monkeypatch):
    """미완료 + applicable 인 step은 satisfied 아님 (prerequisite 미충족)."""
    from app.core.config import settings as _settings
    monkeypatch.setattr(_settings, "background_mode", "on")

    db = MagicMock()
    db.execute.return_value.fetchone.return_value = None  # step_run row 없음

    # background_classify가 on 상태에선 applicable → 미완료 = unsatisfied
    assert ds._is_step_satisfied(db, "p1", "e1", "background_classify") is False


def test_force_image_skips_not_applicable_deps(monkeypatch):
    """P1-1 핵심: background_mode='off' 시 if_background_mode dep이 prerequisite 통과.

    이전: floor_plan_prompt/background_prompt 등 dep이 _is_step_completed=False로
        판정되어 image dispatch가 항상 dispatch.deps_incomplete 발생.
    현재: 정적 applicability=not_applicable 인 dep은 통과 → image dispatch 정상.

    이 테스트는 P1-1 핵심 가정 — `if_background_mode` dep만 미완료고 always-applicable
    dep은 모두 completed인 상황에서 image dispatch가 차단되지 않아야 함.
    """
    from app.core.config import settings as _settings
    monkeypatch.setattr(_settings, "background_mode", "off")
    # 환경 핀 — 운영 .env(recipe v1·outdoor·share_plan ON)가 켜져 있으면
    # 이 시험의 전제("if_background_mode dep 만 미완료")가 깨진다:
    # shot_ref_classify/shot_continuity/background_share_plan/
    # outdoor_lane_plan/outdoor_place_spec 이 적용 대상이 되어
    # prerequisite 미완료 목록에 올라온다. 시험은 코드 계약을 재는
    # 것이지 운영 플래그 상태를 재는 것이 아니다.
    monkeypatch.setattr(_settings, "still_recipe_mode", "off")
    monkeypatch.setattr(
        _settings, "background_share_plan_enabled", False, raising=False)
    monkeypatch.setattr(
        _settings, "outdoor_lane_plan_enabled", False, raising=False)
    monkeypatch.setattr(
        _settings, "outdoor_lane_pipe_enabled", False, raising=False)
    monkeypatch.setattr(
        _settings, "outdoor_direct_compose_enabled", False, raising=False)
    monkeypatch.setattr(
        _settings, "outdoor_map_conti_enabled", False, raising=False)

    db = MagicMock()
    db.query.return_value.filter.return_value.first.return_value = None

    # always-applicable dep (entity_t2i / scene_detail / shot_validator 등) 만 completed.
    # if_background_mode dep는 미완료지만 not_applicable로 satisfied 통과해야 함.
    completed_set = {
        "entity_t2i", "scene_detail", "shot_validator", "shot_selection",
        "shot_staging", "ref_image_gen", "composite_image_gen", "world_guide",
        "character_state_variant", "scene_save", "scene_director",
        "entity_merge", "entity_detail", "visual_world_rules",
        "outlook_phase3",  # if_has_outlooks dep (composite_image_gen)
        "background_render",  # always-completed 포함 (image dep 자체)
    }

    def _fake_completed(db_arg, pid, eid, sid):
        return sid in completed_set

    monkeypatch.setattr(ds, "_is_step_completed", _fake_completed)

    # P1-1 fix 적용 후: deps_incomplete 발생 안 함 (if_background_mode dep는 not_applicable 통과)
    step_ids = ds.select_steps_for_category(db, "p1", "image", episode_id="e1")
    assert isinstance(step_ids, list)
    # 모든 반환 step은 image category
    from app.core.step_catalog import STEP_CATALOG
    for sid in step_ids:
        assert STEP_CATALOG[sid].category == "image"


def test_force_image_blocks_when_required_dep_incomplete(monkeypatch):
    """always applicable 한 dep (e.g. shot_validator)이 미완료면 deps_incomplete 발생 — 변경 없음."""
    # background_mode='on' 으로 두면 if_background_mode dep도 applicable.
    from app.core.config import settings as _settings
    monkeypatch.setattr(_settings, "background_mode", "on")

    db = MagicMock()
    db.query.return_value.filter.return_value.first.return_value = None
    db.execute.return_value.fetchone.return_value = None  # 모두 미완료

    with pytest.raises(AppError) as exc_info:
        ds.select_steps_for_category(db, "p1", "image", episode_id="e1")
    assert exc_info.value.code == "dispatch.deps_incomplete"


def test_force_image_handles_mixed_applicable_and_not(monkeypatch):
    """일부 dep는 not_applicable, 일부는 incomplete — 후자만 보고된다."""
    from app.core.config import settings as _settings
    monkeypatch.setattr(_settings, "background_mode", "off")

    db = MagicMock()
    db.query.return_value.filter.return_value.first.return_value = None
    db.execute.return_value.fetchone.return_value = None  # 모두 미완료

    # background_mode=off 시 if_background_mode dep는 satisfied (not_applicable).
    # 그러나 image step의 다른 dep(예: scene_image_pipeline의 entity_t2i, shot_validator 등 always applicable)이 미완료면 차단.
    # scene_image_pipeline의 always-applicable dep가 존재하므로 deps_incomplete 발생.
    with pytest.raises(AppError) as exc_info:
        ds.select_steps_for_category(db, "p1", "image", episode_id="e1")
    err = exc_info.value
    assert err.code == "dispatch.deps_incomplete"
    # if_background_mode dep는 missing list에 들어가지 않아야 함 (P1-1 핵심).
    msg = err.message
    not_applicable_deps = (
        "background_classify", "background_master_plan",
        "floor_plan_prompt", "background_prompt", "floor_plan_render",
        "background_render",
    )
    for sid in not_applicable_deps:
        assert sid not in msg, (
            f"if_background_mode dep '{sid}' should not be in deps_incomplete missing list "
            f"when background_mode='off' (P1-1 fix)"
        )
