"""Auditable pairwise camera-cut transition scoring.

The generator already decides *when* to cut from music and performance data.
This module evaluates the complementary editorial question: whether the end of
one selected camera and the beginning of the next selected camera form a clear,
intentional cut.  It deliberately uses only compact rendered boundary state so
the same policy can be applied to a full generation and to both neighbours of a
single-shot regeneration.
"""

from __future__ import annotations

import math
from typing import Mapping

import numpy as np


TRANSITION_STATE_SCHEMA_VERSION = "camera-transition-boundary-state-v1"
TRANSITION_SCORE_SCHEMA_VERSION = "pairwise-camera-transition-score-v1"
TRANSITION_POLICY_VERSION = "rendered-end-start-editorial-continuity-v3"

_MINIMUM_MOVING_SPEED_MPS = 0.12
_OPPOSING_MOTION_START_DEGREES = 110.0


def _finite_array(
    value: object,
    shape: tuple[int, ...],
    label: str,
) -> np.ndarray:
    result = np.asarray(value, dtype=np.float64)
    if result.shape != shape or not np.isfinite(result).all():
        raise ValueError(f"{label} must be finite with shape {shape}.")
    return result


def _normalized(value: np.ndarray) -> np.ndarray:
    length = float(np.linalg.norm(value))
    if length <= 1e-9:
        return np.zeros_like(value, dtype=np.float64)
    return np.asarray(value, dtype=np.float64) / length


def _vector_angle_degrees(first: np.ndarray, second: np.ndarray) -> float:
    first_unit = _normalized(first)
    second_unit = _normalized(second)
    if not np.any(first_unit) or not np.any(second_unit):
        return 0.0
    cosine = float(np.clip(np.dot(first_unit, second_unit), -1.0, 1.0))
    return float(np.degrees(np.arccos(cosine)))


def _quaternion_angle_degrees(first: np.ndarray, second: np.ndarray) -> float:
    first_unit = _normalized(first)
    second_unit = _normalized(second)
    if not np.any(first_unit) or not np.any(second_unit):
        return 0.0
    cosine = float(np.clip(abs(np.dot(first_unit, second_unit)), 0.0, 1.0))
    return float(np.degrees(2.0 * np.arccos(cosine)))


def _robust_boundary_velocity(
    values: np.ndarray,
    sample_rate: float,
    *,
    entering: bool,
    window_frames: int,
) -> np.ndarray:
    if len(values) < 2:
        return np.zeros(3, dtype=np.float64)
    steps = np.diff(values, axis=0) * sample_rate
    width = min(max(1, int(window_frames) - 1), len(steps))
    selected = steps[:width] if entering else steps[-width:]
    return np.median(selected, axis=0).astype(np.float64)


def _boundary_summary(
    position: np.ndarray,
    rotation: np.ndarray,
    fov: np.ndarray,
    target: np.ndarray,
    composition: np.ndarray,
    sample_rate: float,
    *,
    entering: bool,
    window_frames: int,
) -> dict[str, object]:
    width = min(max(1, int(window_frames)), len(position))
    frame_slice = slice(0, width) if entering else slice(len(position) - width, None)
    endpoint = 0 if entering else -1
    camera_position = np.median(position[frame_slice], axis=0)
    target_position = np.median(target[frame_slice], axis=0)
    relative_path = position - target
    subject_values = composition[frame_slice]
    valid_subject = (
        np.isfinite(subject_values[:, :3]).all(axis=1)
        & (subject_values[:, 3] >= 0.5)
    )
    if np.any(valid_subject):
        subject_uv = np.median(subject_values[valid_subject, :2], axis=0)
        subject_height = float(np.median(subject_values[valid_subject, 2]))
    else:
        subject_uv = np.asarray([0.5, 0.5], dtype=np.float64)
        subject_height = 0.0
    world_velocity = _robust_boundary_velocity(
        position,
        sample_rate,
        entering=entering,
        window_frames=window_frames,
    )
    relative_velocity = _robust_boundary_velocity(
        relative_path,
        sample_rate,
        entering=entering,
        window_frames=window_frames,
    )
    return {
        "cameraPosition": camera_position.tolist(),
        "cameraRotation": _normalized(rotation[endpoint]).tolist(),
        "targetPosition": target_position.tolist(),
        "cameraRelativePosition": (camera_position - target_position).tolist(),
        "fovDegrees": float(np.median(fov[frame_slice])),
        "subjectUv": subject_uv.tolist(),
        "subjectHeightRatio": subject_height,
        "worldVelocityMps": world_velocity.tolist(),
        "relativeVelocityMps": relative_velocity.tolist(),
        "worldSpeedMps": float(np.linalg.norm(world_velocity)),
        "relativeSpeedMps": float(np.linalg.norm(relative_velocity)),
    }


def build_transition_state(
    position: np.ndarray,
    rotation: np.ndarray,
    fov: np.ndarray,
    target: np.ndarray,
    composition: np.ndarray,
    shot_type: str,
    motion_type: str,
    *,
    sample_rate: float = 60.0,
    boundary_window_frames: int = 6,
) -> dict[str, object]:
    """Build JSON-safe rendered boundary state for one camera candidate."""

    position = np.asarray(position, dtype=np.float64)
    rotation = np.asarray(rotation, dtype=np.float64)
    fov = np.asarray(fov, dtype=np.float64)
    target = np.asarray(target, dtype=np.float64)
    composition = np.asarray(composition, dtype=np.float64)
    frame_count = len(position)
    if frame_count < 1:
        raise ValueError("A transition state requires at least one frame.")
    expected = {
        "position": (frame_count, 3),
        "rotation": (frame_count, 4),
        "fov": (frame_count,),
        "target": (frame_count, 3),
        "composition": (frame_count, 4),
    }
    observed = {
        "position": position.shape,
        "rotation": rotation.shape,
        "fov": fov.shape,
        "target": target.shape,
        "composition": composition.shape,
    }
    if expected != observed:
        raise ValueError(
            "Transition arrays have incompatible shapes: " + repr(observed)
        )
    if not all(
        np.isfinite(values).all()
        for values in (position, rotation, fov, target, composition)
    ):
        raise ValueError("Transition arrays must not contain NaN or Inf.")
    if not math.isfinite(sample_rate) or sample_rate <= 0.0:
        raise ValueError("sample_rate must be finite and positive.")
    if int(boundary_window_frames) < 1:
        raise ValueError("boundary_window_frames must be positive.")
    return {
        "schemaVersion": TRANSITION_STATE_SCHEMA_VERSION,
        "shotType": str(shot_type),
        "motionType": str(motion_type),
        "frameCount": frame_count,
        "start": _boundary_summary(
            position,
            rotation,
            fov,
            target,
            composition,
            sample_rate,
            entering=True,
            window_frames=boundary_window_frames,
        ),
        "end": _boundary_summary(
            position,
            rotation,
            fov,
            target,
            composition,
            sample_rate,
            entering=False,
            window_frames=boundary_window_frames,
        ),
    }


def _validated_endpoint(state: Mapping[str, object], endpoint: str) -> dict[str, object]:
    if state.get("schemaVersion") != TRANSITION_STATE_SCHEMA_VERSION:
        raise ValueError("Unsupported camera transition-state schema.")
    value = state.get(endpoint)
    if not isinstance(value, Mapping):
        raise ValueError(f"Transition state is missing {endpoint!r}.")
    result = dict(value)
    for key, shape in (
        ("cameraPosition", (3,)),
        ("cameraRotation", (4,)),
        ("targetPosition", (3,)),
        ("cameraRelativePosition", (3,)),
        ("subjectUv", (2,)),
        ("worldVelocityMps", (3,)),
        ("relativeVelocityMps", (3,)),
    ):
        _finite_array(result.get(key), shape, key)
    for key in (
        "fovDegrees",
        "subjectHeightRatio",
        "worldSpeedMps",
        "relativeSpeedMps",
    ):
        raw = result.get(key)
        if isinstance(raw, bool) or not isinstance(raw, (int, float)) or not math.isfinite(raw):
            raise ValueError(f"{key} must be finite.")
    return result


def score_pairwise_transition(
    previous_state: Mapping[str, object],
    next_state: Mapping[str, object],
) -> dict[str, object]:
    """Score the rendered END -> START relationship of two hard-cut shots.

    A hard cut does not need physically continuous camera velocity.  The score
    instead penalizes edits that look accidental: same-size near-identical
    geometry (jump cut), abrupt subject displacement, an unbridged 180-degree
    axis flip, and two fast trajectories that reverse direction at the cut.
    Subject scale contrast is measured but intentionally not penalized.
    """

    previous = _validated_endpoint(previous_state, "end")
    following = _validated_endpoint(next_state, "start")
    previous_relative = _finite_array(
        previous["cameraRelativePosition"], (3,), "cameraRelativePosition"
    )
    next_relative = _finite_array(
        following["cameraRelativePosition"], (3,), "cameraRelativePosition"
    )
    geometry_angle = _vector_angle_degrees(previous_relative, next_relative)
    rotation_angle = _quaternion_angle_degrees(
        _finite_array(previous["cameraRotation"], (4,), "cameraRotation"),
        _finite_array(following["cameraRotation"], (4,), "cameraRotation"),
    )
    fov_delta = abs(float(previous["fovDegrees"]) - float(following["fovDegrees"]))
    subject_jump = float(
        np.linalg.norm(
            _finite_array(previous["subjectUv"], (2,), "subjectUv")
            - _finite_array(following["subjectUv"], (2,), "subjectUv")
        )
    )
    previous_height = max(float(previous["subjectHeightRatio"]), 1e-6)
    next_height = max(float(following["subjectHeightRatio"]), 1e-6)
    scale_ratio = max(previous_height, next_height) / min(
        previous_height, next_height
    )
    previous_velocity = _finite_array(
        previous["relativeVelocityMps"], (3,), "relativeVelocityMps"
    )
    next_velocity = _finite_array(
        following["relativeVelocityMps"], (3,), "relativeVelocityMps"
    )
    previous_speed = float(previous["relativeSpeedMps"])
    next_speed = float(following["relativeSpeedMps"])
    velocity_angle = _vector_angle_degrees(previous_velocity, next_velocity)

    same_shot_type = str(previous_state.get("shotType")) == str(
        next_state.get("shotType")
    )
    visual_change = max(
        geometry_angle / 30.0,
        rotation_angle / 30.0,
        fov_delta / 8.0,
        abs(math.log(scale_ratio)) / math.log(1.35),
    )
    near_duplicate_penalty = max(0.0, 1.0 - visual_change) * (
        2.0 if same_shot_type else 0.8
    )
    # Use what will actually be seen, not the declared W/M/C label. A declared
    # medium -> close cut is still a jump cut when the rendered subject scale,
    # camera axis, view rotation and screen position barely change. FOV is only
    # diagnostic here because distance can compensate for a large lens change.
    # A mathematically identical boundary is an invisible continuation, not a
    # jump cut.  In the YAMO authored audit, most broadly "similar" edges were
    # in this sub-perceptual band.  Keep them available (with a soft sameness
    # cost) and hard-gate only a small-but-visible discontinuity.
    invisible_continuation = bool(
        geometry_angle < 2.0
        and rotation_angle < 2.0
        and fov_delta < 1.0
        and scale_ratio < 1.08
        and subject_jump < 0.03
    )
    jump_cut_risk = bool(
        geometry_angle < 15.0
        and rotation_angle < 20.0
        and scale_ratio < 1.20
        and subject_jump < 0.12
        and not invisible_continuation
    )
    if jump_cut_risk:
        near_duplicate_penalty = max(near_duplicate_penalty, 1.75)
    screen_position_penalty = max(0.0, subject_jump - 0.14) / 0.20 * 1.25
    neither_is_wide = "wide" not in {
        str(previous_state.get("shotType", "")).lower(),
        str(next_state.get("shotType", "")).lower(),
    }
    axis_crossing_risk = bool(neither_is_wide and geometry_angle > 150.0)
    axis_crossing_penalty = (
        max(0.0, geometry_angle - 150.0) / 30.0 * 0.9
        if neither_is_wide
        else 0.0
    )
    # Large scale contrast is often the editorial *reason* for a cut.  YAMO's
    # authored examples deliberately use roughly 2.6x--6.9x changes on the
    # same axis, so retain the metric/flag without suppressing that energy.
    extreme_scale_penalty = 0.0
    both_moving = (
        previous_speed >= _MINIMUM_MOVING_SPEED_MPS
        and next_speed >= _MINIMUM_MOVING_SPEED_MPS
    )
    opposing_motion_risk = bool(
        both_moving and velocity_angle > _OPPOSING_MOTION_START_DEGREES
    )
    opposing_motion_penalty = (
        max(0.0, velocity_angle - _OPPOSING_MOTION_START_DEGREES)
        / (180.0 - _OPPOSING_MOTION_START_DEGREES)
        * min(1.5, math.sqrt(previous_speed * next_speed) / 0.35)
        * 1.35
        if both_moving
        else 0.0
    )
    orbit_transition = "orbit" in str(
        previous_state.get("motionType", "")
    ).lower() or "orbit" in str(next_state.get("motionType", "")).lower()
    if orbit_transition:
        opposing_motion_penalty *= 0.35
    elif visual_change >= 1.0:
        # A clear angle/rotation/FOV/scale contrast makes the edit legible; the
        # velocity mismatch remains a soft preference instead of a hard brake.
        opposing_motion_penalty *= 0.65
    total_penalty = float(
        near_duplicate_penalty
        + screen_position_penalty
        + axis_crossing_penalty
        + extreme_scale_penalty
        + opposing_motion_penalty
    )
    flags = []
    if jump_cut_risk:
        flags.append("near_duplicate_jump_cut")
    if axis_crossing_risk:
        flags.append("unbridged_axis_crossing")
    if opposing_motion_risk:
        flags.append("opposing_high_speed_motion")
    if subject_jump > 0.22:
        flags.append("large_subject_screen_jump")
    if scale_ratio > 2.5:
        flags.append("extreme_subject_scale_jump")
    return {
        "schemaVersion": TRANSITION_SCORE_SCHEMA_VERSION,
        "policyVersion": TRANSITION_POLICY_VERSION,
        "penalty": total_penalty,
        # A neither-wide 180-degree-axis crossing is not made acceptable by a
        # low numeric penalty.  It needs a bridging wide/neutral shot, so gate
        # it exactly like a rendered near-duplicate jump cut.  The caller's
        # deterministic all-fail fallback still guarantees generation.
        # Screen displacement and boundary-motion disagreement remain useful
        # ranking preferences, but authored YAMO cuts use both expressively.
        # Only the two categorical editorial failures are hard-gated.
        "passed": bool(not jump_cut_risk and not axis_crossing_risk),
        "flags": flags,
        "metrics": {
            "cameraGeometryAngleDegrees": geometry_angle,
            "cameraRotationAngleDegrees": rotation_angle,
            "fovDeltaDegrees": fov_delta,
            "subjectUvJump": subject_jump,
            "subjectScaleRatio": scale_ratio,
            "relativeVelocityAngleDegrees": velocity_angle,
            "previousRelativeSpeedMps": previous_speed,
            "nextRelativeSpeedMps": next_speed,
            "invisibleContinuation": invisible_continuation,
        },
        "components": {
            "nearDuplicate": float(near_duplicate_penalty),
            "screenPosition": float(screen_position_penalty),
            "axisCrossing": float(axis_crossing_penalty),
            "extremeScale": float(extreme_scale_penalty),
            "opposingMotion": float(opposing_motion_penalty),
        },
    }


def score_transition_context(
    candidate_state: Mapping[str, object],
    *,
    previous_state: Mapping[str, object] | None = None,
    next_state: Mapping[str, object] | None = None,
) -> dict[str, object]:
    """Score either or both neighbours of one candidate shot."""

    previous_score = (
        score_pairwise_transition(previous_state, candidate_state)
        if previous_state is not None
        else None
    )
    next_score = (
        score_pairwise_transition(candidate_state, next_state)
        if next_state is not None
        else None
    )
    total = sum(
        float(score["penalty"])
        for score in (previous_score, next_score)
        if score is not None
    )
    edge_scores = [
        score
        for score in (previous_score, next_score)
        if score is not None
    ]
    return {
        "schemaVersion": "camera-transition-context-score-v1",
        "policyVersion": TRANSITION_POLICY_VERSION,
        "previous": previous_score,
        "next": next_score,
        "penalty": float(total),
        "neighbourCount": len(edge_scores),
        "passed": all(bool(score["passed"]) for score in edge_scores),
    }
