"""Applicability validator 레지스트리.

Phase 1.2 (architecture-refactor-final/02-final-roadmap.md §Phase 1).

manifest의 `applicability` 필드는 문자열 규칙 이름.
이 파일이 문자열 → 실제 판정 함수 매핑을 관리한다.

원칙 (01-principles-revised.md §4, §5):
- StepRunner.check_applicability는 resolve_applicability(self)를 호출.
- 서브클래스가 check_applicability를 override하면 그쪽이 우선 (레거시 호환).
- 신규 `if_*` 규칙 추가 시 이 파일에만 validator 등록하면 됨.
"""
from __future__ import annotations

from typing import Any, Callable, Dict, Optional, TYPE_CHECKING

if TYPE_CHECKING:
    from app.core.step_runner import StepRunner


# Validator signature: StepRunner 인스턴스를 받아 bool 반환.
ApplicabilityValidator = Callable[["StepRunner"], bool]


# ── 개별 validator ─────────────────────────────────────────────────────


def _if_planning_doc(runner: "StepRunner") -> bool:
    """기획서(text/PDF/checkpoint 중 하나라도) 가 있을 때만 실행.

    판정 단일 source: ``planning_doc_analysis_service.project_has_planning_doc``.
    PDF-only 업로드 케이스에서 dispatcher 가 step 을 skip 하던 drift 차단.
    """
    import logging
    logger = logging.getLogger(__name__)
    try:
        from app.services import planning_doc_analysis_service as _svc
        # _StubRunner(정적 평가)는 db 속성이 없다 — 직접 접근하면 AttributeError 가
        # fallback False 로 흡수돼 ★항상 not_applicable 오판★ (E2E 실측 warning).
        # db=None 이면 project_has_planning_doc 이 자체 세션으로 정확히 판정한다.
        return _svc.project_has_planning_doc(
            runner.project_id, db=getattr(runner, "db", None),
        )
    except Exception as exc:
        logger.warning("_if_planning_doc fallback to False: %s", exc)
        return False


def _if_has_outlooks(runner: "StepRunner") -> bool:
    """outlook_phase3 체크포인트에 아웃룩이 있을 때만 실행.

    `_load_prev_checkpoint`가 예외를 던질 수 있는 구현체(JSON 파싱 직접)라
    read_json_safe로 fallback (Codex Phase 1 리뷰).
    """
    import logging
    logger = logging.getLogger(__name__)
    from pathlib import Path
    from app.core.checkpoint_io import read_json_safe
    from app.core.config import settings

    cp = None
    loader = getattr(runner, "_load_prev_checkpoint", None)
    if loader is not None:
        try:
            cp = loader("outlook_phase3")
        except Exception as exc:
            logger.warning("_if_has_outlooks: _load_prev_checkpoint raised, using file fallback: %s", exc)
            cp = None
    if cp is None:
        try:
            path = (
                Path(settings.projects_dir) / runner.project_id
                / "checkpoints" / "episodes" / runner.episode_id
                / "outlook_phase3" / "manifest.json"
            )
            cp = read_json_safe(path)
        except Exception as exc:
            logger.warning("_if_has_outlooks: file fallback failed: %s", exc)
            return False
    if not cp:
        return False
    data = cp.get("data", {}) if isinstance(cp, dict) else {}
    outlooks = data.get("outlooks") or data.get("outlook_list") or []
    return bool(outlooks)


# ── 레지스트리 ─────────────────────────────────────────────────────────


def _if_shot_essence_enabled(runner: "StepRunner") -> bool:
    """settings.shot_essence_enabled가 True일 때만 실행 (Phase 1b).

    토글 off 시 run-all 정적 필터에서 제외 + not_applicable 체크포인트 생성도 안 함
    (Codex H3 회귀 가드).
    """
    from app.core.config import settings
    return bool(settings.shot_essence_enabled)


def _if_floor_plan_mode(runner: "StepRunner") -> bool:
    """settings.background_mode == 'floor_plan_anchored' 일 때만 실행 (Phase 3).

    'off'/'chain_only' 모드에선 step이 not_applicable로 자동 제외되어
    체크포인트 미생성 + run-all에서 skip.
    """
    from app.core.config import settings
    return settings.background_mode == "floor_plan_anchored"


def _if_background_mode(runner: "StepRunner") -> bool:
    """Phase 7: settings.background_mode in {"on", "floor_plan_anchored"} 일 때만 실행.

    "floor_plan_anchored"은 Phase 5 legacy alias — Phase 7 활성으로 동작.
    """
    from app.core.config import settings
    return settings.background_mode in {"on", "floor_plan_anchored"}


def _if_visual_continuity_anchor_enabled(runner: "StepRunner") -> bool:
    """W21B-W7: settings.visual_continuity_anchor_enabled 가 True일 때만 실행.

    토글 off 시 run-all 정적 필터에서 제외 — default 경로 영향 0.
    """
    from app.core.config import settings
    return bool(getattr(settings, "visual_continuity_anchor_enabled", False))


def _if_zoom_continuity_anchor_enabled(runner: "StepRunner") -> bool:
    """W21B-W7 W-C1: settings.zoom_continuity_anchor_enabled 가 True일 때만 실행."""
    from app.core.config import settings
    return bool(getattr(settings, "zoom_continuity_anchor_enabled", False))


def _if_outdoor_site_layout_enabled(runner: "StepRunner") -> bool:
    """W21B-W8: settings.outdoor_site_layout_enabled 가 True일 때만 실행.

    W22 W4b (2026-07-10): 직행 모드(outdoor_direct_compose_enabled)가 켜지면
    야외 라인은 캐논+grounding 이 대체하므로 site_layout 은 not_applicable —
    소비자 전원(load_*_context/merge_prompt_overrides)이 cp 부재에 빈 dict
    no-op (flag-OFF 프로젝트에서 검증된 default 경로).
    """
    from app.core.config import settings
    return bool(getattr(settings, "outdoor_site_layout_enabled", False)) and not bool(
        getattr(settings, "outdoor_direct_compose_enabled", False)
    )


def _if_outdoor_direct_compose(runner: "StepRunner") -> bool:
    """W22: settings.outdoor_direct_compose_enabled 가 True일 때만 실행."""
    from app.core.config import settings
    return bool(getattr(settings, "outdoor_direct_compose_enabled", False))


def _if_outdoor_direct_or_map(runner: "StepRunner") -> bool:
    """W22 직행 OR 레시피 맵 분기 (Codex 1차 리뷰 BLOCKING-4).

    outdoor_place_spec/canon 은 두 소비자(직행 체인, outdoor_map_conti)의
    공용 생산자 — 어느 쪽이든 켜지면 실행. 둘 다 OFF = not_applicable.
    """
    from app.core.config import settings
    return bool(
        getattr(settings, "outdoor_direct_compose_enabled", False)
    ) or bool(getattr(settings, "outdoor_map_conti_enabled", False))


def _if_outdoor_lane_plan(runner: "StepRunner") -> bool:
    """야외 3레인 재설계 Stage A (2026-07-14, 설계 v2):
    settings.outdoor_lane_plan_enabled 가 True일 때만 실행.

    OFF(default) 시 run-all 정적 필터에서 제외 — 기존 경로 byte-identical.
    """
    from app.core.config import settings
    return bool(getattr(settings, "outdoor_lane_plan_enabled", False))


def outdoor_lane_pipe_on() -> bool:
    """Stage D lane pipe 공용 predicate — applicability 와 producer 스텝
    (_execute 이중 게이트/_config_hash)이 반드시 같은 판정을 공유한다
    (Codex Stage D BLOCKING-1: manifest 게이트만 열리고 _execute 가
    닫혀 있던 결함의 재발 방지 단일 SOT)."""
    from app.core.config import settings
    return bool(
        getattr(settings, "outdoor_lane_pipe_enabled", False)
    ) and bool(getattr(settings, "outdoor_lane_plan_enabled", False))


def _if_outdoor_lane_pipe(runner: "StepRunner") -> bool:
    """야외 3레인 재설계 Stage D (2026-07-15): lane plan+pipe 둘 다 ON 일
    때만 실행 (outdoor_structure_seed 등 lane 이미지 파이프 스텝).

    OFF(default) 시 run-all 정적 필터에서 제외 — 기존 경로 byte-identical.
    """
    return outdoor_lane_pipe_on()


def _if_outdoor_direct_or_map_or_lane(runner: "StepRunner") -> bool:
    """W22 스펙/캐논 스텝 게이트 확장 (Stage D5): 기존 direct/map 조건 OR
    lane pipe — fresh-run 에서 lane 파이프가 스펙·캐논을 소비할 수 있게.
    기존 flag 조합에서는 byte-identical (OR 확장만)."""
    return _if_outdoor_direct_or_map(runner) or _if_outdoor_lane_pipe(runner)


def outdoor_frame_mode_on() -> bool:
    """(DORMANT — 2026-07-16 복잡 구조물=A/B 재설계) 항상 False.

    structure_plate 샷의 스케치 경로가 소멸해 frame_mode(structure_
    dominant=스케치 생략) 판정의 소비처가 없다 — Codex 열린쟁점④:
    stale flag(outdoor_frame_mode_enabled)가 hash/스케줄에 영향을 주지
    않도록 게이트 자체를 닫는다. 스텝·모듈·flag 는 보존만(삭제 금지
    원칙), 재활성화는 새 소비 계약 설계가 전제."""
    return False


def _if_outdoor_frame_mode(runner: "StepRunner") -> bool:
    """DORMANT — 항상 not_applicable (위 outdoor_frame_mode_on 참조)."""
    return outdoor_frame_mode_on()


def _if_background_share_plan(runner: "StepRunner") -> bool:
    """B-2 계획 스텝 — still_recipe v1 + flag ON 일 때만."""
    from app.core.config import settings

    return _if_still_recipe(runner) and bool(getattr(
        settings, "background_share_plan_enabled", False))


def _if_still_recipe(runner: "StepRunner") -> bool:
    """s40/s41 레시피 (2026-07-13): settings.still_recipe_mode != "off" 일 때만.

    OFF 시 run-all 정적 필터에서 제외 + 체크포인트 미생성 — 기존 파이프
    byte-identical.
    """
    from app.core.config import settings
    return getattr(settings, "still_recipe_mode", "off") != "off"


APPLICABILITY_VALIDATORS: Dict[str, ApplicabilityValidator] = {
    "if_planning_doc": _if_planning_doc,
    "if_has_outlooks": _if_has_outlooks,
    "if_shot_essence_enabled": _if_shot_essence_enabled,
    "if_floor_plan_mode": _if_floor_plan_mode,
    "if_background_mode": _if_background_mode,
    "if_visual_continuity_anchor_enabled": _if_visual_continuity_anchor_enabled,
    "if_zoom_continuity_anchor_enabled": _if_zoom_continuity_anchor_enabled,
    "if_outdoor_site_layout_enabled": _if_outdoor_site_layout_enabled,
    "if_outdoor_direct_compose": _if_outdoor_direct_compose,
    "if_outdoor_direct_or_map": _if_outdoor_direct_or_map,
    "if_outdoor_lane_plan": _if_outdoor_lane_plan,
    "if_outdoor_lane_pipe": _if_outdoor_lane_pipe,
    "if_outdoor_direct_or_map_or_lane": _if_outdoor_direct_or_map_or_lane,
    "if_outdoor_frame_mode": _if_outdoor_frame_mode,
    "if_still_recipe": _if_still_recipe,
    "if_background_share_plan": _if_background_share_plan,
}


# ── 공용 해석 함수 ─────────────────────────────────────────────────────


def resolve_applicability(runner: "StepRunner") -> bool:
    """runner.manifest의 applicability 규칙을 해석하여 bool 반환.

    - `always`: True
    - `disabled`: False
    - `on_demand`: True (수동 호출 전제)
    - `if_*`: APPLICABILITY_VALIDATORS 조회 후 실행
    - 미지 값: ValueError (신규 규칙은 여기 등록 후 사용)
    """
    rule = runner.manifest.get("applicability", "always")
    if rule == "always":
        return True
    if rule == "disabled":
        return False
    if rule == "on_demand":
        return True
    validator = APPLICABILITY_VALIDATORS.get(rule)
    if validator is None:
        raise ValueError(
            f"Unknown applicability rule: {rule!r} in step {runner.step_id!r}. "
            f"Register in APPLICABILITY_VALIDATORS or use always/disabled/on_demand."
        )
    return validator(runner)


# ── project_config diff ────────────────────────────────────────────────


# 민감 정보 마스킹 키 — diff 출력 시 값을 가리고 변경 여부만 표시.
_SENSITIVE_CONFIG_KEYS = frozenset({
    "api_key",
    "openai_api_key",
    "gemini_api_key",
    "fal_key",
    "fal_ai_key",
    "anthropic_api_key",
    "secret",
    "password",
    "token",
})


def _diff_project_config(
    old: Optional[Dict[str, Any]], new: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
    """project_config diff — config_hash mismatch 시 어느 key가 바뀌었는지 로그용.

    민감 정보(api_key 등)는 값 노출 없이 "<masked>" 로 표시.
    legacy snapshot 누락 또는 type mismatch 시 진단 메시지 반환.

    Args:
        old: 체크포인트에 저장된 이전 project_config snapshot (없을 수 있음).
        new: 현재 project_config.

    Returns:
        변경된 key → {"old": ..., "new": ...} dict.
        snapshot 없거나 type 불일치 시 `_changed` 키에 진단 메시지.
        변경 없으면 빈 dict.
    """
    if old is None:
        return {"_changed": "snapshot 없음 (legacy cp)"}
    if not isinstance(old, dict) or not isinstance(new, dict):
        return {
            "_changed": (
                f"type mismatch: old={type(old).__name__}, new={type(new).__name__}"
            )
        }

    diff: Dict[str, Any] = {}
    all_keys = set(old.keys()) | set(new.keys())
    for k in sorted(all_keys):
        old_val = old.get(k)
        new_val = new.get(k)
        if old_val == new_val:
            continue
        if k in _SENSITIVE_CONFIG_KEYS:
            diff[k] = "<masked>"
            continue
        diff[k] = {"old": old_val, "new": new_val}
    return diff


# ── 정적 평가 (StepRunner 인스턴스 없이) ────────────────────────────────


class _StubRunner:
    """validator가 기대하는 최소 인터페이스의 stub.

    `evaluate_step_applicability`가 StepRunner 인스턴스 없이 manifest의
    applicability 규칙을 정적으로 평가하기 위해 사용한다. 대부분의 validator는
    settings/project_id/episode_id 만 참조하므로 stub이면 충분.

    `_load_prev_checkpoint`는 의도적으로 미제공 — _if_has_outlooks는 그 부재를
    감지하면 직접 파일 fallback으로 처리한다 (applicability.py 주석 참고).
    """

    __slots__ = ("project_id", "episode_id", "step_id", "manifest")

    def __init__(self, step_id: str, project_id: str, episode_id: str, manifest: Dict[str, Any]):
        self.step_id = step_id
        self.project_id = project_id
        self.episode_id = episode_id
        self.manifest = manifest


def evaluate_step_applicability(
    step_id: str,
    project_id: str,
    episode_id: Optional[str] = None,
) -> str:
    """Step의 applicability를 정적으로 평가 (StepRunner 인스턴스 없이).

    dispatcher의 prerequisite 검증 등에서 사용. 결과는 두 가지:
        - "applicable": 해당 step은 실행되어야 한다 (prerequisite 검증 대상).
        - "not_applicable": StepRunner가 자동 skip 처리할 step (prerequisite 통과로 간주).

    `disabled` / `on_demand` 도 "not_applicable" 로 매핑된다 — dispatcher가
    이를 prerequisite로 요구하지 않아야 하므로 동일 의미.

    manifest 미존재 또는 미지의 규칙이면 conservative하게 "applicable" 반환
    (prerequisite 검증을 그대로 수행 — silent miss 방지).
    """
    import logging
    from app.core.step_manifest import get_manifest_dict

    logger = logging.getLogger(__name__)

    manifest = get_manifest_dict(step_id) or {}
    if not manifest:
        # 미지의 step — conservative하게 applicable 처리 (prerequisite 검증 정상 동작)
        return "applicable"

    rule = manifest.get("applicability", "always")
    if rule == "always":
        return "applicable"
    if rule in ("disabled", "on_demand"):
        # Dispatcher 관점에서 둘 다 prerequisite로 요구되지 않음 → not_applicable 동일 처리.
        return "not_applicable"

    validator = APPLICABILITY_VALIDATORS.get(rule)
    if validator is None:
        logger.warning(
            "evaluate_step_applicability: unknown rule %r for step %r — treating as applicable",
            rule, step_id,
        )
        return "applicable"

    stub = _StubRunner(
        step_id=step_id,
        project_id=project_id,
        episode_id=episode_id or "",
        manifest=manifest,
    )
    try:
        return "applicable" if validator(stub) else "not_applicable"
    except Exception as exc:
        # validator 실패 시 conservative — applicable 처리 (silent skip 방지).
        logger.warning(
            "evaluate_step_applicability: validator for %r raised: %s — treating as applicable",
            rule, exc,
        )
        return "applicable"


__all__ = [
    "ApplicabilityValidator",
    "APPLICABILITY_VALIDATORS",
    "resolve_applicability",
    "evaluate_step_applicability",
    "_diff_project_config",
]
