"""W21B-wave-4 C2: real-VLM provider for the shot projection card.

Production-adjacent helper that, when explicitly invoked behind an opt-in
selector, performs a single vision-LLM inspection of a rendered detailed
floor-plan PNG and returns a validated projection-card VLM output — for
the markers that fall inside the supplied camera pose, what is visible
and roughly where on the screen, as two Korean prose descriptions (full
scene + background-plate-only) plus structured per-marker bands.

This module mirrors ``floor_plan_semantic_vlm_provider`` in structure and
fail-closed discipline. The pack at
``prompts/_base/shot_projection_card/<version>/`` is the SOT for the
system / user-template / strict schema (matching the proven dry runner);
no scenario tokens live in code.

**This wave wires the function body but invokes no real API by default.**
``litellm`` is imported lazily inside the function, so importing this
module touches no external SDK. The step wrapper resolves to this helper
only when ``shot_projection_card_real_provider_enabled`` is True; the
default keeps it out of every code path. Output is validated by the core
``validate_vlm_output`` — the same exact-ID / shape / enum contract the
deterministic tests pin. Any non-conforming model output raises
``ProjectionCardError`` (the core fail-closed signal).
"""
from __future__ import annotations

import base64
import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, List, Optional

from app.modules.pipeline.shot_projection_card import (
    ProjectionCardError,
    validate_vlm_output,
)

logger = logging.getLogger(__name__)

# NOTE: 'gpt55' 는 역사적 안정 식별자(provenance 매칭/lineage 호환) — 모델 교체와
# 무관하게 유지. 실제 물리 모델은 별도 model 인자/PROVIDER_MODEL_DEFAULT 에 기록되며
# 이 식별자에서 추론하지 않는다.
WIRED_PROVIDER_NAME: str = "litellm_gpt55_projection_card_vision_v1"

PROVIDER_MODEL_DEFAULT: str = "openai/gpt-5.6-sol"
MAX_COMPLETION_TOKENS_DEFAULT: int = 16000
PROMPT_PACK_VERSION_DEFAULT: str = "1.202605302212"


def _pack_dir(version: str) -> Path:
    # backend/app/modules/pipeline/<this> → repo root is parents[4].
    root = Path(__file__).resolve().parents[4]
    return root / "prompts" / "_base" / "shot_projection_card" / version


def _load_pack(version: str) -> Dict[str, Any]:
    pack = _pack_dir(version)
    try:
        schema = json.loads((pack / "schema.json").read_text(encoding="utf-8"))
        system = (pack / "system.md").read_text(encoding="utf-8")
        user_template = (pack / "user_template.md").read_text(encoding="utf-8")
    except Exception as exc:
        raise ProjectionCardError(
            f"projection-card pack {version!r} load failed: "
            f"{type(exc).__name__}: {exc}"
        ) from exc
    return {"schema": schema, "system": system, "user_template": user_template}


def _j(x: Any) -> str:
    return json.dumps(x, ensure_ascii=False, sort_keys=True)


def _render_user_text(
    *,
    user_template: str,
    bg_id: str,
    shot_id: str,
    fp_id: str,
    inventory: List[Dict[str, Any]],
    prompt_context: Dict[str, Any],
) -> str:
    reg = prompt_context.get("marker_registry") or {}
    inv_lines = "\n".join(
        f"- number {m.get('number')} | layer {m.get('marker_layer')} | "
        f"label {m.get('expected_label')}"
        for m in inventory
    ) or "(none)"
    return user_template.format(
        bg_id=bg_id,
        shot_id=shot_id,
        fp_id=fp_id,
        marker_inventory_block=inv_lines,
        base_numbers=_j(reg.get("base_numbers")),
        transient_numbers=_j(reg.get("transient_numbers")),
        ignored_numbers=_j(reg.get("ignored_numbers")),
        union_numbers=_j(reg.get("union_numbers")),
        camera_referenced_numbers=_j(reg.get("camera_referenced_numbers")),
        out_of_union_referenced=_j(reg.get("out_of_union_referenced")),
        camera_recommendation_block=_j(prompt_context.get("camera_recommendation")),
        shot_intent_block=_j(prompt_context.get("shot_intent")),
        scene_block=_j(prompt_context.get("scene")),
        bg_meta_block=_j(prompt_context.get("bg_meta")),
    )


def litellm_projection_card_provider(
    *,
    inventory: List[Dict[str, Any]],
    fp_id: str,
    bg_id: str,
    shot_id: str,
    fp_image_path: Optional[str],
    prompt_context: Optional[Dict[str, Any]] = None,
    model: str = PROVIDER_MODEL_DEFAULT,
    pack_version: str = PROMPT_PACK_VERSION_DEFAULT,
    max_completion_tokens: int = MAX_COMPLETION_TOKENS_DEFAULT,
    timeout_seconds: int = 180,
) -> Dict[str, Any]:
    """Real-VLM projection-card output via a single litellm vision call.

    Fail-closed at every joint. The completion call count is 0 whenever
    the helper fail-closes during preflight (arg/env/capability/fs).
    Output is validated through the core ``validate_vlm_output``; any
    non-conforming model output raises ``ProjectionCardError``.
    """
    prompt_context = prompt_context or {}
    # Required 3: prefer the step-resolved pack/model threaded via
    # prompt_context over this helper's own defaults, so the actual call
    # matches the card's recorded provenance (no drift).
    effective_pack_version = prompt_context.get("pack_version") or pack_version
    effective_model = prompt_context.get("model") or model

    # 1. Arg-shape guards (pre-env, pre-network).
    if fp_image_path is None:
        raise ProjectionCardError(
            "litellm_projection_card_provider requires fp_image_path; got None"
        )
    if not isinstance(fp_id, str) or not fp_id:
        raise ProjectionCardError("fp_id missing or non-string")
    if not inventory:
        raise ProjectionCardError(
            f"empty inventory for {bg_id}/{shot_id}; refusing the VLM call (no-op)"
        )

    # 2. Pack preflight (local fs, pre-env) — a bad pack_version fails
    # closed citing that version regardless of env, and proves the
    # provider used the resolved pack.
    pack = _load_pack(effective_pack_version)

    # 3. Env preflight.
    from app.core.openai_keys import has_openai_key
    if not has_openai_key():
        raise ProjectionCardError(
            "OPENAI_API_KEY missing or empty; refusing to call litellm"
        )

    # 4. Lazy import litellm.
    try:
        import litellm  # type: ignore
        from app.core.openai_keys import (  # type: ignore
            llm_completion as _llm_completion,
        )
    except Exception as exc:  # pragma: no cover — env-dependent
        raise ProjectionCardError(
            f"litellm import failed: {type(exc).__name__}: {exc}"
        ) from exc

    # 4. Capability preflight.
    try:
        supports_ok = bool(litellm.supports_response_schema(model=effective_model))
    except Exception as exc:
        raise ProjectionCardError(
            f"litellm.supports_response_schema raised: {type(exc).__name__}: {exc}"
        ) from exc
    if not supports_ok:
        raise ProjectionCardError(
            f"model {effective_model!r} does not advertise response_schema support"
        )

    # 5. Filesystem preflight.
    path_obj = Path(fp_image_path)
    if not path_obj.exists() or not path_obj.is_file():
        raise ProjectionCardError(
            f"fp_image_path does not exist on disk: {fp_image_path!r}"
        )
    image_bytes = path_obj.read_bytes()
    if not image_bytes:
        raise ProjectionCardError(f"fp_image_path is empty: {fp_image_path!r}")

    # 6. Base64 data URL + request assembly (pack is SOT).
    image_data_url = (
        "data:image/png;base64," + base64.b64encode(image_bytes).decode("ascii")
    )
    user_text = _render_user_text(
        user_template=pack["user_template"],
        bg_id=bg_id,
        shot_id=shot_id,
        fp_id=fp_id,
        inventory=inventory,
        prompt_context=prompt_context,
    )
    messages = [
        {"role": "system", "content": pack["system"]},
        {
            "role": "user",
            "content": [
                {"type": "text", "text": user_text},
                {"type": "image_url", "image_url": {"url": image_data_url, "detail": "high"}},
            ],
        },
    ]
    response_format = {
        "type": "json_schema",
        "json_schema": {
            "name": "shot_projection_card",
            "strict": True,
            "schema": pack["schema"],
        },
    }

    # 7. Single completion call (num_retries=0, no temperature).
    try:
        response = _llm_completion(
            model=effective_model,
            messages=messages,
            response_format=response_format,
            timeout=timeout_seconds,
            max_completion_tokens=max_completion_tokens,
            num_retries=0,
        )
    except Exception as exc:
        raise ProjectionCardError(
            f"litellm.completion raised: {type(exc).__name__}: {exc}"
        ) from exc

    # 8. Response guards (fail-closed at every joint).
    choices = getattr(response, "choices", None) or []
    if not choices:
        raise ProjectionCardError("response.choices is empty")
    choice = choices[0]
    msg = getattr(choice, "message", None)
    if msg is None:
        raise ProjectionCardError("response.choices[0].message is missing")
    if getattr(msg, "refusal", None):
        raise ProjectionCardError(
            f"VLM emitted a refusal: {str(getattr(msg, 'refusal'))[:200]!r}"
        )
    content = getattr(msg, "content", None)
    if not content:
        raise ProjectionCardError("response.choices[0].message.content is empty")
    if getattr(choice, "finish_reason", None) != "stop":
        raise ProjectionCardError(
            f"finish_reason must be 'stop' exactly "
            f"(got {getattr(choice, 'finish_reason', None)!r})"
        )

    # 9. Parse + validate against the exact-ID / shape contract.
    try:
        parsed = json.loads(content)
    except (TypeError, ValueError) as exc:
        raise ProjectionCardError(
            f"response content is not valid JSON: {type(exc).__name__}: {exc}"
        ) from exc
    res = validate_vlm_output(
        output=parsed, inventory=inventory, fp_id=fp_id, bg_id=bg_id, shot_id=shot_id
    )
    if not res["ok"]:
        raise ProjectionCardError(f"validator_failed: {'; '.join(res['blockers'])[:400]}")
    return parsed
