"""Validate template-required scene props before asset generation or rendering.

Binding records missingRequiredProps for diagnostics, but a production caller
must reject those scenes instead of paying for audio and rendering placeholders.
Existing RenderPlans need the same check because they bypass binding entirely.
"""

from __future__ import annotations

import math
import sys
from pathlib import Path

# Shared cross-skill lib (see AGENTS.md). Inserted here rather than relying on
# render_video's module-top setup because this module is imported directly by
# tests and by prepare-video-assets.
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "template-registry" / "scripts"))

from countdown_spec import resolve_countdown_spec  # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "template-registry"))
from video_dsl.runtime.payload_contract import required_payload_errors  # noqa: E402


def _payload_prop(props: dict, key: str):
    seen: set[int] = set()
    cursor = props
    for _ in range(9):
        if not isinstance(cursor, dict) or id(cursor) in seen:
            break
        seen.add(id(cursor))
        if cursor.get(key) is not None:
            return cursor[key]
        cursor = cursor.get("templateData")
    return None


def _required_value_errors(value, schema: dict, path: str) -> list[str]:
    """Check required values only, using the template's declared shape and bounds.

    This is not a full JSON Schema validator: optional fields and undeclared
    extensions are left to the existing DSL validators and template runtime.
    """
    if value is None or (isinstance(value, str) and not value.strip()):
        return [path]
    kind = schema.get("type")
    number = type(value) in (int, float) and (not isinstance(value, float) or math.isfinite(value))
    matches = {
        "object": isinstance(value, dict), "array": isinstance(value, list),
        "string": isinstance(value, str), "boolean": isinstance(value, bool),
        "number": number,
        "integer": number and (isinstance(value, int) or value.is_integer()),
    }
    if kind in matches and not matches[kind]:
        return [f"{path} (expected {kind})"]
    if number and (kind in ("number", "integer")):
        if "minimum" in schema and value < schema["minimum"]:
            return [f"{path} (below minimum)"]
        if "maximum" in schema and value > schema["maximum"]:
            return [f"{path} (above maximum)"]
    if isinstance(value, dict) and kind == "object":
        properties = schema.get("properties") or {}
        return [error for key in schema.get("required", [])
                for error in _required_value_errors(value.get(key), properties.get(key, {}), f"{path}.{key}")]
    if isinstance(value, list) and kind == "array" and len(value) < schema.get("minItems", 0):
        return [f"{path} (too few items)"]
    return []


def validate_required_props(template: dict, rows: list[dict]) -> None:
    """Raise with scene IDs and field names only; never include scene content."""
    required_by_composition: dict[str, set[str]] = {}
    # References reuse a concrete slot's composition, so only concrete slots
    # contribute requirements here. Union if multiple slots share a component.
    for slot in (template.get("slotMapping") or {}).values():
        if not isinstance(slot, dict) or not slot.get("compositionId"):
            continue
        required_by_composition.setdefault(slot["compositionId"], set()).update(
            slot.get("requiredProps") or []
        )

    failures = []
    for row in rows:
        required = required_by_composition.get(row.get("compositionId"), set())
        required = required | set(row.get("missingRequiredProps") or [])
        props = row.get("props") or {}
        schema = template.get("customPayloadSchema") or {}
        missing = [error for key in sorted(required)
                   for error in _required_value_errors(_payload_prop(props, key), schema.get(key, {}), key)]
        missing += required_payload_errors(template, props)
        if missing:
            failures.append(f"scene {row.get('sceneId', '?')}: {', '.join(missing)}")

    if failures:
        raise ValueError(
            "Missing or invalid required template props: " + "; ".join(failures[:10])
            + (f"; {len(failures)} scenes total" if len(failures) > 10 else "")
            + ". Fill each scene's customPayload according to the template schema "
            "and rebuild the RenderPlan. Narration or topic text does not populate card data."
        )

    _validate_countdown(template, rows)


def _index_of(row: dict, spec: dict):
    """A beat's countdown index, or None when the row carries no usable item.

    Returning None rather than indexing straight into the payload keeps a scene
    with a missing/malformed item on the countdown error path (which names the
    scene and says what to fix) instead of an AttributeError from deep inside the
    validator. Reachable whenever a countdown template's point slot does not list
    its item prop in requiredProps, so the earlier per-scene check lets it through.
    """
    item = _payload_prop(row.get("props") or {}, spec["item_prop"])
    return item.get(spec["index_prop"]) if isinstance(item, dict) else None


def _validate_countdown(template: dict, rows: list[dict]) -> None:
    """Check a countdown plan's shape before anything is paid for.

    Every prop name here comes from the template's own `capabilities.countdown`
    declaration, so this function holds no knowledge of ranking, GitHub, or any
    template id. That matters beyond tidiness: the names used to be hardcoded,
    which meant a second countdown template calling its count `itemCount` would
    find no counts at all, fall back to "however many beats are present", and
    reduce the scene-count check to comparing that number against itself —
    validation silently gone, with nothing on screen or in the logs to say so.
    """
    spec = resolve_countdown_spec(template)
    if spec is None:
        return
    slots = template.get("slotMapping") or {}
    point_id = (slots.get("point") or {}).get("compositionId")
    opening_id = (slots.get("opening") or {}).get("compositionId")
    ending_id = (slots.get("ending") or {}).get("compositionId")
    points = [row for row in rows if row.get("compositionId") == point_id]
    count_props = [spec["count_prop"], *spec["legacy_count_props"]]
    # Keep old plans usable: infer their count when no explicit count was saved.
    declared = []
    for row in rows:
        props = row.get("props") or {}
        count = next((value for value in (_payload_prop(props, p) for p in count_props)
                      if value is not None), None)
        if count is not None:
            if type(count) is not int or not spec["minimum"] <= count <= spec["maximum"]:
                raise ValueError(
                    f"Invalid countdown {spec['count_prop']}: expected an integer from "
                    f"{spec['minimum']} to {spec['maximum']}"
                )
            declared.append(count)
    count = declared[0] if declared else len(points)
    if any(value != count for value in declared):
        raise ValueError(f"Countdown {spec['count_prop']} must be consistent across all scenes")
    if not spec["minimum"] <= count <= spec["maximum"] or len(points) != count:
        raise ValueError(
            f"Countdown scene count does not match {spec['count_prop']}; rebuild one opening "
            "plus N beats"
        )
    indexes = [_index_of(row, spec) for row in points]
    if indexes != list(range(count, 0, -1)):
        raise ValueError(
            f"Countdown scenes must count down from {count} to 1 in "
            f"{spec['item_prop']}.{spec['index_prop']} without gaps or duplicates"
        )
    if not rows or rows[0].get("compositionId") != opening_id or sum(row.get("compositionId") == opening_id for row in rows) != 1:
        raise ValueError("Countdown requires exactly one opening before the beats")
    # A separate ending may hold the final beat, but cannot add another index.
    endings = [row for row in rows if row.get("compositionId") == ending_id]
    if len(endings) > 1 or (endings and (rows[-1] is not endings[0] or _index_of(endings[0], spec) != 1)):
        raise ValueError("Countdown ending may only hold the #1 scene after the countdown")
