"""W21B-wave-4 C2: ShotProjectionCardStep.

opt-in (default OFF) producer of the per-shot projection card — the
common shot-observation SOT that BG (``background_prompt`` vNext, C4) and
the final shot t2i (``scene_detail`` vNext, C5) both consume so they
follow the same contract (design brief §3; wiring brief §3).

Per ``(bg_id, shot_id)`` target (one card per shot in the background's
``applies_to_shots``; anchor / plate grouping is deferred to the plan
vNext, C3) the step:
  - Assembles the marker inventory + union registry from the overlay
    payload (base ∪ transient ∪ ignored), and the source_hashes
    provenance from the upstream checkpoints.
  - Computes a projection-card VLM output. With the real-provider
    selector OFF (default) it uses the synthetic fixture — a
    non-authoritative placeholder that NEVER auto-passes the gate, NO
    real VLM call.
  - Builds the card envelope and runs the v0 deterministic gate
    (pass / needs_review / blocked).
  - Records per-card envelope + validation in the manifest.

Gates (all must be true):
  - ``settings.background_mode`` ∈ {"on", "floor_plan_anchored"}.
  - ``settings.shot_projection_card_enabled`` = True.
  - ``settings.base_location_dossier_enabled`` = True.

Anything else → ``not_applicable`` with a byte-stable empty payload.

The semantic readback (W21B-wave-4) is an OPTIONAL gate, not a hard dep:
when its checkpoint is present the per-fp ``gate_state`` is carried into
the card; otherwise the card records ``semantic_gate_state=not_available``
and the gate does not treat semantic as a hard precondition (design brief
§10 decision 1). LLM / image / VLM API call is 0 unless the real-provider
selector is explicitly flipped (or a mock provider is injected in tests).
DB / ImageAsset write 0.
"""
from __future__ import annotations

import hashlib
import json
import logging
from pathlib import Path
from typing import Any, Callable, Dict, Optional

from app.core.step_runner import StepRunner
from app.modules.pipeline.shot_projection_card import (
    PROVIDER_MODEL,
    PROVIDER_NAME,
    ProjectionCardError,
    build_card_envelope,
    build_shot_context,
    build_union_registry,
    compute_card_cache_key,
    compute_projection_card,
    compute_source_hashes,
    iter_card_targets,
    resolve_pack_version,
    validate_shot_projection_card,
)

logger = logging.getLogger(__name__)

SCHEMA_VERSION = 1
PROMPT_VERSION = "1"


class ShotProjectionCardStep(StepRunner):
    # Test-only injection slot for a mock projection VLM provider. Default
    # None — production resolves the provider via the settings selector.
    _vlm_provider_override: Optional[Callable[..., Dict[str, Any]]] = None

    def set_vlm_provider_for_testing(
        self, provider: Optional[Callable[..., Dict[str, Any]]]
    ) -> None:
        """Mocking helper. Production callers must NOT touch this."""
        self._vlm_provider_override = provider

    def _resolve_vlm_provider(self) -> Optional[Callable[..., Dict[str, Any]]]:
        """Resolve the projection VLM provider for this run.

        Resolution order (only one path active per run):
          1. ``set_vlm_provider_for_testing`` injection — bypasses
             settings (mock-provider tests).
          2. ``settings.shot_projection_card_real_provider_enabled`` True
             → the production-adjacent ``litellm_projection_card_provider``.
          3. Default → ``None``; the core emits the synthetic fixture.
             Zero external API calls.
        """
        from app.core.config import settings

        if self._vlm_provider_override is not None:
            return self._vlm_provider_override
        if not bool(
            getattr(settings, "shot_projection_card_real_provider_enabled", False)
        ):
            return None
        from app.modules.pipeline.shot_projection_card_provider import (
            litellm_projection_card_provider,
        )
        return litellm_projection_card_provider

    def _load_prev_checkpoint(self, step_id: str) -> Optional[Dict[str, Any]]:
        """Read a prior step's ``manifest.json`` from the checkpoint dir.

        Mirrors the W21B-wave-4 semantic / W20A2 geometry helpers —
        StepRunner does not provide this, so the step defines it.
        """
        from app.core.config import settings

        cp = (
            Path(settings.projects_dir)
            / self.project_id
            / "checkpoints"
            / "episodes"
            / self.episode_id
            / step_id
            / "manifest.json"
        )
        if cp.exists():
            try:
                return json.loads(cp.read_text(encoding="utf-8"))
            except Exception as exc:
                logger.warning(
                    "shot_projection_card: %s parse failed: %s", step_id, exc
                )
        return None

    def _config_hash(self) -> str:
        from app.core.config import settings

        payload = {
            "background_mode": settings.background_mode,
            "base_location_dossier_enabled": bool(
                settings.base_location_dossier_enabled
            ),
            "shot_projection_card_enabled": bool(
                getattr(settings, "shot_projection_card_enabled", False)
            ),
            "shot_projection_card_real_provider_enabled": bool(
                getattr(
                    settings, "shot_projection_card_real_provider_enabled", False
                )
            ),
            "shot_projection_card_prompt_version": str(
                getattr(settings, "shot_projection_card_prompt_version", PROMPT_VERSION)
            ),
            "schema_version": SCHEMA_VERSION,
            "prompt_version": PROMPT_VERSION,
        }
        return hashlib.sha256(
            json.dumps(payload, sort_keys=True).encode("utf-8")
        ).hexdigest()[:16]

    def _not_applicable(self) -> Dict[str, Any]:
        return {
            "applicable_count": 0,
            "completed_count": 0,
            "failed_count": 0,
            "schema_version": SCHEMA_VERSION,
            "config_hash": self._config_hash(),
            "data": {},
        }

    @staticmethod
    def _semantic_gate_for_fp(
        semantic_cp: Optional[Dict[str, Any]], fp_id: str
    ) -> str:
        """Optional semantic gate carry: per-fp gate_state or not_available."""
        if not semantic_cp:
            return "not_available"
        per_fp = (semantic_cp.get("data") or {}).get("per_fp") or {}
        entry = per_fp.get(fp_id) or {}
        state = entry.get("gate_state")
        return state if isinstance(state, str) and state else "not_available"

    def _execute(self, mode: str = "resume") -> Dict[str, Any]:
        from app.core.config import settings

        if settings.background_mode not in {"on", "floor_plan_anchored"}:
            return self._not_applicable()
        if not bool(getattr(settings, "shot_projection_card_enabled", False)):
            return self._not_applicable()
        if not bool(settings.base_location_dossier_enabled):
            return self._not_applicable()

        overlay_cp = self._load_prev_checkpoint("floor_plan_overlay_payload")
        master_cp = self._load_prev_checkpoint("background_master_plan")
        overlay_payload = (overlay_cp or {}).get("data", {}) or {}
        master_plan = (master_cp or {}).get("data", {}) or {}
        targets = iter_card_targets(
            overlay_payload=overlay_payload, master_plan=master_plan
        )
        if not targets:
            return self._not_applicable()

        dossier_cp = self._load_prev_checkpoint("base_location_dossier")
        geometry_cp = self._load_prev_checkpoint("floor_plan_geometry_readback")
        semantic_cp = self._load_prev_checkpoint("floor_plan_semantic_readback")
        fp_prompt_cp = self._load_prev_checkpoint("floor_plan_prompt")
        fp_render_cp = self._load_prev_checkpoint("floor_plan_render")
        shot_staging_cp = self._load_prev_checkpoint("shot_staging")
        scene_save_cp = self._load_prev_checkpoint("scene_save")

        prompt_version = str(
            getattr(settings, "shot_projection_card_prompt_version", PROMPT_VERSION)
        )
        # Required 3: record the SAME resolved pack/model/provider the real
        # provider is invoked with — no provenance drift.
        pack_version = resolve_pack_version(prompt_version)
        provider_name = PROVIDER_NAME
        model_name = PROVIDER_MODEL
        shot_staging_data = (shot_staging_cp or {}).get("data", {}) or {}
        scene_save_data = (scene_save_cp or {}).get("data", {}) or {}

        base_provider = self._resolve_vlm_provider()
        cards: Dict[str, Dict[str, Any]] = {}
        completed = 0
        failed = 0
        blocked = 0
        needs_review = 0
        passed = 0
        vlm_call_total = 0

        catalog = (master_plan.get("background_catalog") or {})
        fp_render_data = (fp_render_cp or {}).get("data", {}) or {}
        fp_prompt_data = (fp_prompt_cp or {}).get("data", {}) or {}
        geometry_data = (geometry_cp or {}).get("data", {}) or {}
        dossier_data = (dossier_cp or {}).get("data", {}) or {}
        semantic_data = (semantic_cp or {}).get("data", {}) or {}

        for tgt in targets:
            bg_id = tgt["bg_id"]
            shot_id = tgt["shot_id"]
            fp_id = tgt["fp_id"]
            ov_entry = tgt["ov_entry"]
            card_key = f"{bg_id}::{shot_id}"

            _counter = [0]
            counted_provider: Optional[Callable[..., Dict[str, Any]]] = None
            if base_provider is not None:
                def counted(**kw):  # noqa: E306 — defined per-iter
                    _counter[0] += 1
                    return base_provider(**kw)
                counted_provider = counted

            try:
                inventory, registry = build_union_registry(ov_entry)
                semantic_gate_state = self._semantic_gate_for_fp(
                    semantic_cp, fp_id
                )
                # FP detailed render is the substrate in C2 (light_fp
                # sidecar is a later decision, brief §10/5).
                fp_render_entry = (
                    fp_render_data.get("floor_plans", {}).get(fp_id, {})
                )
                substrate_status = "ok" if fp_render_entry else "missing"
                fp_prompt_entry = (
                    fp_prompt_data.get("floor_plans", {}).get(fp_id, {})
                )
                camera_rec = None
                for cr in fp_prompt_entry.get("camera_recommendations") or []:
                    if cr.get("bg_id") == bg_id:
                        camera_rec = cr
                        break
                dossier_entry = (dossier_data.get("dossiers") or {}).get(fp_id)
                geometry_entry = (geometry_data.get("per_fp") or {}).get(fp_id)

                # Hard-dep preconditions (manifest depends_on + brief §3.1):
                # render / overlay / dossier / geometry / camera pose. With
                # any of these missing the VLM cannot produce a faithful
                # shot-conditioned card, so it must NOT be produced as
                # pass/needs_review and the real provider must NOT be called.
                # Record a deterministic blocked card with the explicit
                # fallback_reason (§9 fail-closed).
                precondition_reason = None
                if not fp_render_entry:
                    precondition_reason = "fp_render_missing"
                elif camera_rec is None:
                    precondition_reason = "camera_recommendation_missing"
                elif not dossier_entry:
                    precondition_reason = "dossier_missing"
                elif not geometry_entry:
                    precondition_reason = "geometry_missing"
                if precondition_reason is not None:
                    cards[card_key] = {
                        "bg_id": bg_id,
                        "shot_id": shot_id,
                        "fp_id": fp_id,
                        "card_state": "blocked",
                        "validator_state": precondition_reason,
                        "fallback_reason": precondition_reason,
                        "real_vlm_call": False,
                        "real_vlm_call_count": 0,
                    }
                    completed += 1
                    blocked += 1
                    continue

                # Required 1: the shot_context (shot_staging intent + scene
                # text) conditions the card. It feeds BOTH the source_hash
                # (so a shot/scene content change invalidates the card) AND
                # the provider prompt_context (so the real VLM writes
                # shot-conditioned prose).
                shot_context = build_shot_context(
                    shot_id=shot_id,
                    shot_staging=shot_staging_data,
                    scene_save=scene_save_data,
                )
                shot_context["bg_meta"] = {
                    k: (catalog.get(bg_id) or {}).get(k)
                    for k in (
                        "surface_role",
                        "sub_location_label",
                        "state_label_raw",
                        "applies_to_shots",
                    )
                }
                source_hashes = compute_source_hashes(
                    fp_render=fp_render_entry,
                    substrate=fp_render_entry,
                    overlay=ov_entry,
                    geometry=geometry_data.get("per_fp", {}).get(fp_id),
                    semantic=(
                        semantic_data.get("per_fp", {}).get(fp_id)
                        if semantic_cp
                        else None
                    ),
                    camera_rec=camera_rec,
                    shot_context=shot_context,
                    prompt_version=prompt_version,
                    schema_version=SCHEMA_VERSION,
                    model=model_name,
                    provider=provider_name,
                    pack_version=pack_version,
                    dossier=dossier_entry,
                )
                card_id = compute_card_cache_key(
                    source_hashes=source_hashes, bg_id=bg_id, shot_id=shot_id
                )
                vlm_output = compute_projection_card(
                    inventory=inventory,
                    fp_id=fp_id,
                    bg_id=bg_id,
                    shot_id=shot_id,
                    vlm_provider=counted_provider,
                    fp_image_path=fp_render_entry.get("png_path"),
                    prompt_context={
                        "marker_registry": registry,
                        "camera_recommendation": camera_rec,
                        "shot_intent": shot_context.get("shot_intent"),
                        "scene": shot_context.get("scene"),
                        "shot_context": shot_context,
                        "bg_meta": catalog.get(bg_id) or {},
                        "pack_version": pack_version,
                        "model": model_name,
                        "provider": provider_name,
                    },
                )
                envelope = build_card_envelope(
                    card_id=card_id,
                    schema_version=SCHEMA_VERSION,
                    prompt_version=prompt_version,
                    model=model_name,
                    provider=provider_name,
                    bg_id=bg_id,
                    shot_id=shot_id,
                    fp_id=fp_id,
                    semantic_gate_state=semantic_gate_state,
                    substrate_kind="detailed_fp",
                    substrate_status=substrate_status,
                    source_hashes=source_hashes,
                    vlm_output=vlm_output,
                    marker_registry=registry,
                )
                gate = validate_shot_projection_card(
                    card=envelope, current_source_hashes=source_hashes
                )
                per_vlm = _counter[0] if counted_provider is not None else 0
                vlm_call_total += per_vlm
                cards[card_key] = {
                    "bg_id": bg_id,
                    "shot_id": shot_id,
                    "fp_id": fp_id,
                    "card": envelope,
                    "validation": gate,
                    "card_state": gate["card_state"],
                    "validator_state": gate["validator_state"],
                    "real_vlm_call": bool(per_vlm),
                    "real_vlm_call_count": per_vlm,
                }
                completed += 1
                if gate["card_state"] == "blocked":
                    blocked += 1
                elif gate["card_state"] == "needs_review":
                    needs_review += 1
                else:
                    passed += 1
            except ProjectionCardError as exc:
                logger.error("shot_projection_card %s: %s", card_key, exc)
                per_vlm = _counter[0] if counted_provider is not None else 0
                vlm_call_total += per_vlm
                cards[card_key] = {
                    "bg_id": bg_id,
                    "shot_id": shot_id,
                    "fp_id": fp_id,
                    "error": str(exc)[:300],
                    "real_vlm_call": bool(per_vlm),
                    "real_vlm_call_count": per_vlm,
                }
                failed += 1
            except Exception as exc:  # pragma: no cover — defensive
                logger.exception(
                    "shot_projection_card %s unexpected: %s", card_key, exc
                )
                cards[card_key] = {
                    "bg_id": bg_id,
                    "shot_id": shot_id,
                    "fp_id": fp_id,
                    "error": f"unexpected: {type(exc).__name__}: {exc}"[:300],
                    "real_vlm_call": False,
                    "real_vlm_call_count": 0,
                }
                failed += 1

        return {
            "applicable_count": 1 if cards else 0,
            "completed_count": completed,
            "failed_count": failed,
            "schema_version": SCHEMA_VERSION,
            "config_hash": self._config_hash(),
            "data": {
                "cards": cards,
                "card_state_counts": {
                    "pass": passed,
                    "needs_review": needs_review,
                    "blocked": blocked,
                },
                "real_vlm_call_count": vlm_call_total,
                "image_api_call_count": 0,
                "llm_call_count": 0,
            },
        }
