"""consumes_downstream 통합 테스트 — 실 STEP_MANIFEST + step_catalog + invalidate_downstream 흐름.

v0.5.23 1급 필드 전환이 manifest·catalog·runner 3 레이어에 걸쳐 일관되게 반영됐는지 검증.
(단위 테스트는 monkeypatch로 격리 — 선언·데이터 드리프트는 이 integration에서만 잡힌다.)
"""
from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import MagicMock

import pytest

from app.core.step_catalog import STEP_CATALOG, get_consumers_of, get_consumes_downstream
from app.core.step_manifest import STEP_MANIFEST, get_all_downstream_recursive
from app.core.step_runner import StepRunner


# ── 선언(데이터) 레이어 ─────────────────────────────────────────────────────


def test_scene_detail_declares_shot_dependency_t2i_as_consumed():
    """설계 계약: scene_detail.consumes_downstream 선언이 실 manifest에 있어야 한다.

    scene_context_loader가 shot_dependency_t2i 체크포인트를 역참조로 소비하는
    경로(9956c27 이후)가 있는 한, 이 선언이 빠지면 scene_detail force 시
    shot_dependency_t2i가 일반 삭제되어 dead code가 된다.
    """
    scene_detail = STEP_MANIFEST["scene_detail"]
    assert scene_detail.get("consumes_downstream") == ["shot_dependency_t2i"]


def test_legacy_preserve_flag_fully_removed():
    """v0.5.15의 preserve_on_upstream_force는 전부 제거됐어야 한다.

    코드 경로 `STEP_MANIFEST[sid].get("preserve_on_upstream_force")`가 남아 있으면
    신 로직이 읽지 않아 silent invalid — 선언 잔존은 CHANGELOG·로그 주석을 오염시킨다.
    """
    offenders = [
        sid for sid, info in STEP_MANIFEST.items()
        if "preserve_on_upstream_force" in info
    ]
    assert offenders == [], f"legacy flag 잔존: {offenders}"


def test_catalog_helper_returns_manifest_declaration():
    """step_catalog.get_consumes_downstream은 STEP_MANIFEST 선언과 일치해야 한다."""
    assert get_consumes_downstream("scene_detail") == ["shot_dependency_t2i"]
    # 선언 없는 step은 빈 리스트 — 기본 동작
    assert get_consumes_downstream("text_cleanup") == []
    # 존재하지 않는 step_id는 빈 리스트 (catalog miss)
    assert get_consumes_downstream("__nonexistent__") == []


def test_get_consumers_of_reverse_lookup():
    """get_consumers_of는 consumes_downstream의 역방향 조회 — UI가 drift stale을 일반
    stale과 구분하는 데 필요. shot_dependency_t2i는 scene_detail 과
    visual_continuity_anchor (W21B-W7 W-B, refined ref_usage 역참조) 에 의해 소비된다.
    """
    # 정방향과 역방향 일관성 검증 (순서 무관)
    assert sorted(get_consumers_of("shot_dependency_t2i")) == [
        "scene_detail", "visual_continuity_anchor"]
    # 역참조 소비되지 않는 step은 빈 리스트
    assert get_consumers_of("text_cleanup") == []
    # 존재하지 않는 step_id도 빈 리스트 (안전)
    assert get_consumers_of("__nonexistent__") == []


def test_consumes_downstream_targets_are_actual_downstream():
    """의미 무결성: 각 step의 consumes_downstream 항목은 실제 재귀 downstream 안에 있어야 한다.

    오타(예: shot_dependency_t2i → shot_dep_t2i) 또는 DAG 재설계로 더 이상 하류가 아닌
    step을 가리키는 경우 cascade 보존이 무의미해진다 — 그런 드리프트를 이 테스트가 잡는다.
    """
    for sid, entry in STEP_CATALOG.items():
        if not entry.consumes_downstream:
            continue
        actual_downstream = set(get_all_downstream_recursive(sid))
        for consumed in entry.consumes_downstream:
            assert consumed in actual_downstream, (
                f"{sid}.consumes_downstream에 선언된 '{consumed}'는 "
                f"{sid}의 재귀 downstream({sorted(actual_downstream)})이 아님 — "
                "manifest depends_on 변경 후 업데이트 누락 가능"
            )


# ── 실행(runner) 레이어 ────────────────────────────────────────────────────


@pytest.fixture
def fake_runner_with_real_manifest(tmp_path, monkeypatch):
    """실 STEP_MANIFEST를 그대로 사용하는 StepRunner (DB/파일만 tmp로 치환)."""
    inst = StepRunner.__new__(StepRunner)
    inst.step_id = "scene_detail"
    inst.project_id = "p-int"
    inst.episode_id = "e-int"
    inst.db = MagicMock()
    inst.run_id = "r-int"
    inst.manifest = {}
    inst.project_config = {}
    from app.core.config import settings
    monkeypatch.setattr(settings, "projects_dir", str(tmp_path))
    return inst, tmp_path


def _make_cp(tmp_path: Path, pid: str, eid: str, sid: str) -> Path:
    cp_dir = tmp_path / pid / "checkpoints" / "episodes" / eid / sid
    cp_dir.mkdir(parents=True, exist_ok=True)
    p = cp_dir / "manifest.json"
    p.write_text(json.dumps({"data": {}}), encoding="utf-8")
    return p


def test_scene_detail_force_preserves_shot_dependency_t2i_real_manifest(
    fake_runner_with_real_manifest,
):
    """시나리오: scene_detail force → invalidate_downstream cascade.
    shot_dependency_t2i 파일은 consumes_downstream 선언으로 보존,
    t2i_review(다른 하류)는 일반 삭제.
    """
    inst, tmp_path = fake_runner_with_real_manifest

    # 선행 검증: 두 step이 실제 scene_detail의 재귀 downstream이어야 한다.
    # DAG 재설계로 더 이상 downstream이 아니게 되면 이 테스트 의도가 무효 — false-green 방지.
    downstream = set(get_all_downstream_recursive("scene_detail"))
    assert "shot_dependency_t2i" in downstream, (
        "shot_dependency_t2i가 scene_detail 재귀 downstream이 아님 — manifest 재설계 의심"
    )
    assert "t2i_review" in downstream, (
        "t2i_review가 scene_detail 재귀 downstream이 아님 — manifest 재설계 의심"
    )

    # 두 하류 모두 존재 상태로 준비
    cp_consumed = _make_cp(tmp_path, "p-int", "e-int", "shot_dependency_t2i")
    cp_normal = _make_cp(tmp_path, "p-int", "e-int", "t2i_review")

    # 실 manifest 사용 (monkeypatch 없음)
    inst.invalidate_downstream()

    # 보존된 파일 검증
    assert cp_consumed.exists(), (
        "scene_detail.consumes_downstream=['shot_dependency_t2i']이므로 보존되어야 함"
    )
    # 일반 삭제 검증
    assert not cp_normal.exists(), (
        "t2i_review는 consumes_downstream 선언 밖 — 일반 cascade 삭제 대상"
    )
