"""C1 perception_mode enum SOT v1 — integrated test (G1-G7).

Spec ref: docs/superpowers/specs/2026-05-20-c1-perception-mode-enum-sot-v1-design.md
Plan ref: docs/superpowers/plans/2026-05-20-c1-perception-mode-enum-sot-v1-implementation.md
Codex APPROVED_FOR_EXECUTION.

no live LLM + NO VLM + no regex semantic.
"""
import ast
import json
import pathlib

import pytest

from app.core.errors import AppError
from app.core.perception_mode import (
    PERCEPTION_MODES,
    REPRODUCTION_PERCEPTION_MODES,
    _PERCEPTION_GUIDES,
    _validate_perception_mode,
    is_reproduction_perception,
    get_perception_guide,
)
from app.core.steps.render_prompt_card import build_id_policy


REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]


def _resolve_latest_major_pack(stem: str, major: str) -> pathlib.Path:
    """N-3 fix: W1_UTC와 W3_UTC 분리 — glob resolve로 actual dir 찾기."""
    candidates = sorted((REPO_ROOT / "prompts/_base" / stem).glob(f"{major}.*"))
    assert candidates, f"no prompt-pack found for {stem}/{major}.*"
    return candidates[-1]


SHOT_STAGING_V14_DIR = _resolve_latest_major_pack("shot_staging", "14")
SCENE_DETAIL_V29_DIR = _resolve_latest_major_pack("scene_detail", "29")


# ── G1 — Producer schema enum 8-token strict (N-2: set equality) ──
def test_g1_producer_schema_enum_8_token_strict():
    schema = json.loads((SHOT_STAGING_V14_DIR / "schema.json").read_text())
    shot_props = schema["properties"]["shots"]["items"]["properties"]
    enum_set = set(shot_props["perception_mode"]["enum"])
    assert enum_set == PERCEPTION_MODES, f"enum mismatch: {enum_set} vs {PERCEPTION_MODES}"


# ── G2 — mirror presence (N-5: helper-owned mapping + behavioral) ──
def test_g2_mirror_presence_schema():
    schema = json.loads((SHOT_STAGING_V14_DIR / "schema.json").read_text())
    shot_props = schema["properties"]["shots"]["items"]["properties"]
    assert "mirror" in shot_props["perception_mode"]["enum"]


def test_g2_mirror_presence_helper():
    assert "mirror" in PERCEPTION_MODES
    assert "mirror" in REPRODUCTION_PERCEPTION_MODES
    assert "mirror" in _PERCEPTION_GUIDES


def test_g2_mirror_behavioral():
    guide = get_perception_guide("mirror")
    assert isinstance(guide, str) and len(guide) > 0


# ── G3 — Consumer code helper consume (no inline 4-tuple literal) ──
def test_g3_render_prompt_card_no_inline_literal():
    import re
    src = (REPO_ROOT / "backend/app/core/steps/render_prompt_card.py").read_text()
    forbidden = re.search(
        r'"reflection",\s*"mirror",\s*"through_device",\s*"projection"', src
    )
    assert forbidden is None, "render_prompt_card.py still has 4-tuple inline literal"
    assert "is_reproduction_perception" in src


# ── G4 — Cascade no generic fallback + AppError on unknown ──
def test_g4_detail_steps_no_generic_fallback():
    src = (REPO_ROOT / "backend/app/core/steps/detail_steps.py").read_text()
    assert "_perception_guides = {" not in src
    assert "_perception_guides.get(perception" not in src
    assert "get_perception_guide" in src


# ── G5 — Consumer prompt cross-reference (no 4-token literal, both orders) ──
def test_g5_scene_detail_v29_cross_reference():
    sys_md = (SCENE_DETAIL_V29_DIR / "system.md").read_text()
    # N-13: 양쪽 order full-list residue 모두 fail
    assert "mirror / reflection / through_device / projection" not in sys_md, (
        "old-order 4-token inline list residue"
    )
    assert "reflection / mirror / through_device / projection" not in sys_md, (
        "new-order 4-token inline list residue (N-13)"
    )
    assert "REPRODUCTION_PERCEPTION_MODES" in sys_md or "perception_mode.py" in sys_md


# ── G6 — Area C boundary preserve (scoped diff, N-3) ──
def test_g6_area_c_boundary_anchors_preserve():
    """Scoped marker/slice diff: directionality_class block + key_bg_elements required list."""
    src = (REPO_ROOT / "backend/app/core/steps/render_prompt_card.py").read_text()
    assert "_ALLOWED_DIRECTIONALITY_CLASSES" in src
    assert "_REPRODUCTION_SURFACE_CLASSES" in src
    assert "directionality_class_missing" in src or "directionality_class_invalid" in src

    schema_v13 = json.loads(
        (REPO_ROOT / "prompts/_base/shot_staging/13.202605170422/schema.json").read_text()
    )
    schema_v14 = json.loads((SHOT_STAGING_V14_DIR / "schema.json").read_text())

    # N-2: schema nested under shots.items.properties
    try:
        v13_shot_props = schema_v13["properties"]["shots"]["items"]["properties"]
        v14_shot_props = schema_v14["properties"]["shots"]["items"]["properties"]
        dc_v13 = v13_shot_props["key_bg_elements"]["items"]["properties"]["directionality_class"]
        dc_v14 = v14_shot_props["key_bg_elements"]["items"]["properties"]["directionality_class"]
    except (KeyError, TypeError):
        pytest.fail("directionality_class block extraction failed; schema structure changed")

    assert dc_v13 == dc_v14, (
        f"Area C directionality_class block diff non-empty: {dc_v13} vs {dc_v14}"
    )

    req_v13 = set(v13_shot_props["key_bg_elements"]["items"]["required"])
    req_v14 = set(v14_shot_props["key_bg_elements"]["items"]["required"])
    assert "directionality_class" in req_v13
    assert "directionality_class" in req_v14
    assert req_v13 == req_v14


# ── G7 — NO VLM canary (AST-based, N-8) + N-1 invalid input + N-7 direct ──
def test_g7_no_vlm_helper_module():
    """AST-based import/call check (N-8: stricter than substring grep)."""
    tree = ast.parse((REPO_ROOT / "backend/app/core/perception_mode.py").read_text())
    forbidden = {"vlm", "vision_lm", "vision_language", "lvm_judge", "lvm_validator"}
    for node in ast.walk(tree):
        if isinstance(node, (ast.Import, ast.ImportFrom)):
            for alias in node.names:
                name_lower = (alias.name or "").lower()
                for kw in forbidden:
                    assert kw not in name_lower, f"VLM import leak in helper: {alias.name}"
            if isinstance(node, ast.ImportFrom):
                mod_lower = (node.module or "").lower()
                for kw in forbidden:
                    assert kw not in mod_lower, f"VLM module leak: {node.module}"
        if isinstance(node, ast.Call):
            if isinstance(node.func, ast.Name):
                for kw in forbidden:
                    assert kw not in node.func.id.lower(), f"VLM call leak: {node.func.id}"


def test_g7_no_vlm_test_module():
    """AST-based check on this test file itself (N-8: tautological substring 회피)."""
    tree = ast.parse(pathlib.Path(__file__).read_text())
    forbidden = {"vlm", "vision_lm", "vision_language", "lvm_judge", "lvm_validator"}
    for node in ast.walk(tree):
        if isinstance(node, (ast.Import, ast.ImportFrom)):
            for alias in node.names:
                name_lower = (alias.name or "").lower()
                for kw in forbidden:
                    assert kw not in name_lower, f"VLM import leak in test: {alias.name}"
            if isinstance(node, ast.ImportFrom):
                mod_lower = (node.module or "").lower()
                for kw in forbidden:
                    assert kw not in mod_lower, f"VLM module leak: {node.module}"
        if isinstance(node, ast.Call):
            if isinstance(node.func, ast.Name):
                for kw in forbidden:
                    assert kw not in node.func.id.lower(), f"VLM call leak: {node.func.id}"


@pytest.mark.parametrize("invalid_pm", ["future_unknown_mode", "", "MIRROR"])
def test_g7_invalid_input_fail_fast(invalid_pm):
    """N-1: invalid input → AppError perception_mode.unknown (not silent false/fallback)."""
    with pytest.raises(AppError) as exc_info:
        is_reproduction_perception(invalid_pm)
    assert exc_info.value.code == "perception_mode.unknown"

    with pytest.raises(AppError) as exc_info:
        get_perception_guide(invalid_pm)
    assert exc_info.value.code == "perception_mode.unknown"


def test_g7_direct_guide_not_applicable():
    """N-7: get_perception_guide("direct") → AppError guide_not_applicable (not raw KeyError)."""
    with pytest.raises(AppError) as exc_info:
        get_perception_guide("direct")
    assert exc_info.value.code == "perception_mode.guide_not_applicable", (
        f"expected guide_not_applicable, got {exc_info.value.code}"
    )


def test_g7_non_direct_all_have_guide():
    """Behavioral: non-direct 7-key all return non-empty guide."""
    for pm in PERCEPTION_MODES - {"direct"}:
        guide = get_perception_guide(pm)
        assert isinstance(guide, str) and len(guide) > 0, f"empty guide for {pm}"


# ── Invariants (spec §4) ──
def test_invariant_strict_subset():
    assert REPRODUCTION_PERCEPTION_MODES < PERCEPTION_MODES


def test_invariant_guide_coverage():
    assert set(_PERCEPTION_GUIDES) == PERCEPTION_MODES - {"direct"}


def test_validate_perception_mode_direct_ok():
    """direct is enum-valid (no raise from _validate_perception_mode)."""
    _validate_perception_mode("direct")  # no raise
    assert is_reproduction_perception("direct") is False


# ── G8 — build_id_policy consumer-boundary (Wave4, e2e-review-fix-v1) ──
# render_prompt_card.build_id_policy 가 perception_mode 를 helper 로 넘기는 consumer.
# 이전 render_prompt_card.py 의 `(perception_mode or "direct").lower()` 가 비정규
# casing 을 silent 정규화해 helper fail-fast 를 무력화했음 — Wave4 가 `.lower()` 를
# 제거. 본 G8 은 그 consumer-boundary 동작을 회귀 가드 (helper-level G7 과 별개).


def _call_build_id_policy(perception_mode):
    """build_id_policy minimal-valid 호출 — perception_mode 분기까지 도달."""
    return build_id_policy(
        visible_entities=[],
        outlook_pairs=[],
        perception_mode=perception_mode,
        key_bg_elements=[],
        subject_reference_policies=[],
    )


@pytest.mark.parametrize("bad_casing", ["MIRROR", "Reflection", "DIRECT"])
def test_g8_build_id_policy_noncanonical_casing_fail_fast(bad_casing):
    """Wave4: build_id_policy 가 비정규 casing perception_mode 를 silent 정규화하지
    않고 helper perception_mode.unknown 으로 fail-fast (이전 .lower() 무력화 회귀 가드)."""
    with pytest.raises(AppError) as exc_info:
        _call_build_id_policy(bad_casing)
    assert exc_info.value.code == "perception_mode.unknown"


@pytest.mark.parametrize("legacy_empty", [None, ""])
def test_g8_build_id_policy_legacy_empty_coerced_to_direct(legacy_empty):
    """Wave4: None / empty perception_mode 는 "direct" 로 coerce (legacy caller
    responsibility 보존) — raise 없음 + reproduction-surface constraint 부재."""
    policy = _call_build_id_policy(legacy_empty)
    joined = " ".join(policy["constraints"])
    assert "reflective/projected surface" not in joined, (
        "direct 모드인데 reproduction-surface constraint 가 잘못 부착됨"
    )


@pytest.mark.parametrize(
    "canonical_repro", ["mirror", "reflection", "through_device", "projection"]
)
def test_g8_build_id_policy_canonical_reproduction_still_works(canonical_repro):
    """Wave4: canonical lowercase reproduction perception_mode 는 정상 동작 —
    reproduction-surface constraint 부착 (.lower() 제거가 정상 경로 무손상 회귀 가드)."""
    policy = _call_build_id_policy(canonical_repro)
    joined = " ".join(policy["constraints"])
    assert "reflective/projected surface" in joined, (
        f"{canonical_repro}: reproduction-surface constraint 누락"
    )
