# backend/tests/test_step_manifest_v3.py
"""step_manifest v4 + shot-more + shot-validator — 49단계 정의, 의존성 DAG, 순서 검증.

Phase 0 baseline: architecture-refactor-final/02-final-roadmap.md 기준 갱신.
"""
import pytest
from app.core.step_manifest import (
    STEP_MANIFEST, get_depends_on, get_downstream_steps,
    get_all_downstream_recursive, get_ordered_steps,
)


def test_total_step_count():
    """2026-05-23 baseline: 60단계 (Phase 7 59 + reference-necessity Phase 2 +1).
    2026-05-27 W19/W20 cleanup wave: W19B-1 floor_plan_overlay_payload +1 /
    W20A base_location_dossier +1 / W20A2 floor_plan_geometry_readback +1 /
    W20B shot_aware_bg_render_plan +1 = +4 → 64단계.
    2026-05-29 W21B-wave-4: floor_plan_semantic_readback +1 → 65단계.
    2026-05-30 W21B-wave-4 C2: shot_projection_card +1 → 66단계.
    2026-05-31 W21B-w5: bg_space_partition +1 → 67단계.
    2026-05-31 W21B-w5 STEP5-B: floor_plan_light_sidecar +1 → 68단계.
    2026-06-08 W21B: dwelling_zone_map +1 → 69단계."""
    # 2026-06-10 W21B: space_set_bg +1 → 70.
    # 2026-06-12 W21B-W8: outdoor_site_layout +1 → 73.
    assert len(STEP_MANIFEST) == 73


def test_analysis_steps_count():
    # 2026-05-27 W19/W20 cleanup wave: 4 신규 analysis step (overlay/dossier/
    # geometry_readback/shot_aware) 추가, 49 → 53.
    # 2026-05-29 W21B-wave-4: floor_plan_semantic_readback +1 → 54.
    # 2026-05-30 W21B-wave-4 C2: shot_projection_card +1 → 55.
    # 2026-05-31 W21B-w5: bg_space_partition +1 → 56.
    # 2026-06-08 W21B: dwelling_zone_map +1 → 57.
    # 2026-06-10 W21B: space_set_bg +1 → 58.
    # 2026-06-10 W21B fresh-E2E fix: space_set_bg analysis→image 이동 -1 → 57
    # (world_guide(image,22) hard dep 이 미래를 가리켜 fresh run 차단 — 순서 모순 해소).
    # 2026-06-12 W21B-W8: outdoor_site_layout +1 → 60.
    analysis = [s for s in STEP_MANIFEST.values() if s["category"] == "analysis"]
    assert len(analysis) == 60


def test_image_steps_count():
    # 2026-05-31 W21B-w5 STEP5-B: floor_plan_light_sidecar +1 (category=image) → 10.
    # 2026-06-10 W21B fresh-E2E fix: space_set_bg analysis→image 이동 +1 → 11.
    image = [s for s in STEP_MANIFEST.values() if s["category"] == "image"]
    assert len(image) == 11


def test_auxiliary_steps_count():
    aux = [s for s in STEP_MANIFEST.values() if s["category"] == "auxiliary"]
    assert len(aux) == 2


def test_v4_core_steps_exist():
    """v4 beat→shot 및 shot-more 핵심 단계가 모두 등록되어 있는지."""
    core_steps = [
        # 기본 분석
        "text_cleanup", "episode_summary", "visual_world_rules",
        "scene_save", "scene_summary",
        # beat→shot 계층 (v4)
        "beat_extract", "shot_extract", "shot_selection",
        # shot 상세 (shot-more)
        "shot_staging", "shot_director", "shot_dependency",
        "scene_consistency", "character_state_variant", "scene_camera_flow",
        "shot_dependency_t2i",
        # outlook 3단계
        "outlook_phase1", "outlook_phase2", "outlook_phase3",
        # entity
        "entity_t2i", "entity_merge", "entity_relation",
        # 이미지/기타
        "t2i_review", "scene_image_pipeline",
    ]
    for sid in core_steps:
        assert sid in STEP_MANIFEST, f"Missing step: {sid}"


def test_order_is_unique():
    """order 값 중복 없어야 함."""
    orders = [s["order"] for s in STEP_MANIFEST.values()]
    assert len(orders) == len(set(orders))


def test_order_monotonic():
    """ordered list는 order 오름차순."""
    ordered = get_ordered_steps()
    for i in range(len(ordered) - 1):
        assert ordered[i]["order"] <= ordered[i + 1]["order"]


def test_depends_on_points_to_earlier_order():
    """active step 의 hard dep 은 실행 순서(order)상 반드시 과거를 가리켜야 한다.

    2026-06-10 fresh full E2E 실측 결함의 일반화 가드: space_set_bg
    (당시 analysis, order 21.5946)가 world_guide(image, order 22)를 hard dep
    으로 선언 → category=all fresh run 에서 '선행 단계 미완료'로 항상 blocked
    (resume run 에서는 world_guide 기완료라 가려짐). dep 이 미래 order 를
    가리키면 dispatcher 가 영원히 충족 못 하는 gate 가 된다."""
    for sid, s in STEP_MANIFEST.items():
        if s.get("lifecycle") != "active":
            continue
        for dep in get_depends_on(sid):
            d = STEP_MANIFEST[dep]
            if d.get("lifecycle") != "active":
                continue
            assert d["order"] < s["order"], (
                f"{sid}(order={s['order']}) hard dep {dep}(order={d['order']}) "
                "— dep 이 실행 순서상 미래를 가리킴 (fresh run 영구 차단)"
            )


def test_image_order_range():
    """image 카테고리는 order 19.85~25 범위.

    주의: location_floor_plan(order=19.85, Phase 3, 2026-04-29)을 위해 하한 19.85로 갱신.
    상한 25는 `scene_image_pipeline.order=25`, `character_state_variant.order=24.5`에
    맞춘 하드코딩. 신규 image step이 추가되면 이 범위 갱신 필요.
    """
    image_orders = [s["order"] for s in STEP_MANIFEST.values() if s["category"] == "image"]
    assert min(image_orders) >= 19.85
    assert max(image_orders) <= 25


def test_auxiliary_order_range():
    """auxiliary 카테고리는 order 100+."""
    aux_orders = [s["order"] for s in STEP_MANIFEST.values() if s["category"] == "auxiliary"]
    assert min(aux_orders) >= 100


def test_dependency_graph_no_cycles():
    """의존성 DAG에 순환 없어야 함."""
    visited = set()
    rec_stack = set()

    def _has_cycle(node):
        visited.add(node)
        rec_stack.add(node)
        for dep in get_downstream_steps(node):
            if dep not in visited:
                if _has_cycle(dep):
                    return True
            elif dep in rec_stack:
                return True
        rec_stack.discard(node)
        return False

    for sid in STEP_MANIFEST:
        if sid not in visited:
            assert not _has_cycle(sid), f"Cycle detected at {sid}"


def test_scene_director_depends_on_scene_save_and_entity_t2i():
    """scene_director는 scene_save + entity_t2i 이후."""
    deps = get_depends_on("scene_director")
    assert "scene_save" in deps
    assert "entity_t2i" in deps


def test_scene_detail_depends_on_shot_pipeline():
    """scene_detail은 shot_* + outlook_phase3 + entity_t2i + scene_consistency 이후."""
    deps = get_depends_on("scene_detail")
    expected = {
        "shot_dependency", "shot_director", "outlook_phase3",
        "entity_t2i", "shot_staging", "scene_consistency",
    }
    missing = expected - set(deps)
    assert not missing, f"scene_detail missing deps: {missing}"


def test_text_cleanup_has_no_dependencies():
    assert get_depends_on("text_cleanup") == []


def test_scene_segmentation_depends_on_text_cleanup():
    deps = get_depends_on("scene_segmentation")
    assert "text_cleanup" in deps


def test_entity_extract_character_depends_on_entity_all_character():
    """v4: entity_all_character가 upstream."""
    deps = get_depends_on("entity_extract_character")
    assert "entity_all_character" in deps


def test_entity_extract_location_depends_on_entity_all_location():
    deps = get_depends_on("entity_extract_location")
    assert "entity_all_location" in deps


def test_entity_extract_prop_depends_on_entity_all_prop():
    deps = get_depends_on("entity_extract_prop")
    assert "entity_all_prop" in deps


def test_entity_filter_depends_on_entity_relation():
    """v4: entity_filter는 entity_relation 이후."""
    deps = get_depends_on("entity_filter")
    assert "entity_relation" in deps


def test_scene_summary_depends_on_scene_save_and_episode_summary():
    deps = get_depends_on("scene_summary")
    assert "scene_save" in deps
    assert "episode_summary" in deps
    assert "visual_world_rules" in deps


def test_downstream_of_text_cleanup():
    """text_cleanup 무효화 시 scene_segmentation 이후 전부 영향."""
    downstream = get_all_downstream_recursive("text_cleanup")
    assert "scene_segmentation" in downstream
    assert "scene_detail" in downstream


def test_scene_director_label_is_physical_presence():
    """scene_director label: 물리적 존재 (V/A/H → 물리적 존재 재명명)."""
    assert STEP_MANIFEST["scene_director"]["label"] == "씬 감독 (물리적 존재)"


def test_applicability_values_are_known():
    """모든 applicability 값은 알려진 집합에 속함. Phase 1.2 validator 레지스트리와 동기화."""
    known = {
        "always", "disabled", "on_demand",
        "if_planning_doc", "if_has_outlooks",
        "if_shot_essence_enabled",  # Phase 1b (2026-04-29)
        "if_visual_continuity_anchor_enabled",  # W21B-W7 (2026-06-12)
        "if_zoom_continuity_anchor_enabled",  # W21B-W7 W-C1 (2026-06-12)
        "if_outdoor_site_layout_enabled",  # W21B-W8 (2026-06-12)
        "if_floor_plan_mode",  # Phase 3 (2026-04-29)
        "if_background_mode",  # Phase 7 (2026-04-29)
    }
    for sid, s in STEP_MANIFEST.items():
        app = s.get("applicability", "always")
        if callable(app):
            continue
        assert app in known, f"{sid}: unknown applicability {app!r}"
