"""Semantic shot labels derived from screen-space and relative camera motion."""

from __future__ import annotations

import math
import re
from dataclasses import asdict, dataclass
from typing import Iterable

import numpy as np


SHOT_TYPES = ("wide", "medium", "close")
SHOT_LABEL_VERSION = "semantic-shot-label-v2"
MOTION_FAMILIES = (
    "static",
    "drift",
    "orbit",
    "dolly",
    "lateral_vertical",
)


@dataclass(frozen=True)
class FramingLabel:
    """A fixed-semantics shot-size label and its evidence."""

    shot_type: str
    subject_height_ratio: float
    valid_frame_ratio: float
    confidence: float
    confidence_tier: str

    def to_dict(self) -> dict:
        return asdict(self)


@dataclass(frozen=True)
class MotionLabel:
    """Dominant motion plus all continuous components used to derive it."""

    motion_type: str
    motion_family: str
    confidence: float
    confidence_tier: str
    orbit_degrees: float
    radial_delta_meters: float
    lateral_delta_meters: float
    vertical_delta_meters: float
    net_motion_meters: float
    path_length_meters: float

    def to_dict(self) -> dict:
        return asdict(self)


def confidence_tier(value: float) -> str:
    """Map a continuous confidence to an auditable coarse tier."""

    if value >= 0.85:
        return "high"
    if value >= 0.6:
        return "medium"
    return "low"


def parse_recorder_aspect_ratio(
    resolutions: Iterable[str],
) -> tuple[float | None, str]:
    """Return a ratio only when all parseable recorder resolutions agree."""

    ratios: list[float] = []
    for value in resolutions:
        match = re.fullmatch(r"\s*(\d+)\s*[xX]\s*(\d+)\s*", str(value))
        if match is None:
            continue
        width = int(match.group(1))
        height = int(match.group(2))
        if width > 0 and height > 0:
            ratios.append(width / height)
    if not ratios:
        return None, "unknown"
    if max(ratios) - min(ratios) > 1e-4:
        return None, "ambiguous_recorder_resolutions"
    return float(np.mean(ratios)), "catalog_recorder_resolution"


def aspect_confidence(aspect_source: str) -> float:
    """Quantify how strongly the aspect ratio is supported by metadata."""

    if aspect_source in {
        "catalog_recorder_resolution",
        "manifest",
        "command_line",
    }:
        return 1.0
    if aspect_source.startswith("assumed"):
        return 0.5
    return 0.35


def derive_framing_label(
    composition_path: np.ndarray,
    aspect_source: str,
) -> FramingLabel:
    """Classify shot size from the projected head-to-pelvis screen height.

    ``composition_path`` has columns ``focus_u, focus_v, figure_height,
    visible``. Fixed thresholds keep labels stable when the corpus changes.
    The height is a 2D head-to-pelvis distance measured in viewport-height
    units. The horizontal viewport delta is therefore scaled by width/height
    before combining it with the vertical delta. This remains stable for
    Dutch angles and leaning poses.
    """

    if composition_path.ndim != 2 or composition_path.shape[1] < 4:
        raise ValueError("composition_path must have shape [frame, >=4].")
    heights = composition_path[:, 2].astype(np.float64)
    visible = composition_path[:, 3] >= 0.5
    finite = np.isfinite(heights) & (heights > 0.0) & (heights < 2.5)
    valid = visible & finite
    valid_frame_ratio = float(np.mean(valid)) if len(valid) else 0.0

    evidence = heights[valid]
    if len(evidence) == 0:
        evidence = heights[finite]
    subject_height = (
        float(np.median(evidence))
        if len(evidence)
        else float("nan")
    )

    if not math.isfinite(subject_height):
        shot_type = "medium"
        confidence = 0.0
    elif subject_height <= 0.18:
        shot_type = "wide"
        confidence = valid_frame_ratio
    elif subject_height <= 0.36:
        shot_type = "medium"
        confidence = valid_frame_ratio
    else:
        shot_type = "close"
        confidence = valid_frame_ratio

    confidence *= aspect_confidence(aspect_source)
    confidence = float(np.clip(confidence, 0.0, 1.0))
    return FramingLabel(
        shot_type=shot_type,
        subject_height_ratio=subject_height,
        valid_frame_ratio=valid_frame_ratio,
        confidence=confidence,
        confidence_tier=confidence_tier(confidence),
    )


def motion_family(motion_type: str) -> str:
    """Collapse signed motion labels into planner-sized families."""

    if motion_type == "static":
        return "static"
    if motion_type == "drift":
        return "drift"
    if motion_type.startswith("orbit_"):
        return "orbit"
    if motion_type.startswith("dolly_"):
        return "dolly"
    if motion_type.startswith(("crane_", "truck_")):
        return "lateral_vertical"
    raise ValueError(f"Unknown motion type: {motion_type}")


def derive_motion_label(
    motion_path: np.ndarray,
    body_scale: float,
) -> MotionLabel:
    """Derive motion without privileging one axis through an if/elif order."""

    if motion_path.ndim != 2 or motion_path.shape[1] != 3:
        raise ValueError("motion_path must have shape [frame, 3].")
    if len(motion_path) < 2:
        raise ValueError("motion_path must contain at least two frames.")

    section = max(3, len(motion_path) // 5)
    early = np.median(motion_path[:section], axis=0)
    late = np.median(motion_path[-section:], axis=0)
    delta = late - early
    steps = np.linalg.norm(np.diff(motion_path, axis=0), axis=1)
    net_motion = float(np.linalg.norm(delta) * body_scale)
    path_length = float(np.sum(steps) * body_scale)

    early_angle = math.atan2(float(early[0]), float(early[2]))
    late_angle = math.atan2(float(late[0]), float(late[2]))
    angle_delta = math.atan2(
        math.sin(late_angle - early_angle),
        math.cos(late_angle - early_angle),
    )
    orbit_degrees = math.degrees(angle_delta)
    radial_delta = (
        float(np.linalg.norm(late[[0, 2]]))
        - float(np.linalg.norm(early[[0, 2]]))
    ) * body_scale
    lateral_delta = float(delta[0] * body_scale)
    vertical_delta = float(delta[1] * body_scale)

    if net_motion < 0.2 and path_length < 0.5:
        motion_type = "static"
        margin = min(
            (0.2 - net_motion) / 0.2,
            (0.5 - path_length) / 0.5,
        )
        confidence = float(np.clip(0.75 + 0.25 * margin, 0.0, 1.0))
    else:
        component_names = (
            "orbit",
            "dolly",
            "crane",
            "truck",
        )
        component_scores = np.asarray(
            [
                abs(orbit_degrees) / 8.0,
                abs(radial_delta) / 0.75,
                abs(vertical_delta) / 0.5,
                abs(lateral_delta) / 0.75,
            ],
            dtype=np.float64,
        )
        order = np.argsort(component_scores)[::-1]
        top = float(component_scores[order[0]])
        second = float(component_scores[order[1]])
        if top < 1.0:
            motion_type = "drift"
            confidence = float(np.clip(1.0 - top * 0.4, 0.45, 0.9))
        else:
            primary = component_names[int(order[0])]
            if primary == "orbit":
                motion_type = (
                    "orbit_right" if orbit_degrees > 0.0 else "orbit_left"
                )
            elif primary == "dolly":
                motion_type = (
                    "dolly_out" if radial_delta > 0.0 else "dolly_in"
                )
            elif primary == "crane":
                motion_type = (
                    "crane_up" if vertical_delta > 0.0 else "crane_down"
                )
            else:
                motion_type = (
                    "truck_right" if lateral_delta > 0.0 else "truck_left"
                )
            dominance = (top - second) / max(top, 1e-8)
            strength = min(1.0, top)
            confidence = float(
                np.clip(0.5 * dominance + 0.5 * strength, 0.0, 1.0)
            )

    family = motion_family(motion_type)
    return MotionLabel(
        motion_type=motion_type,
        motion_family=family,
        confidence=confidence,
        confidence_tier=confidence_tier(confidence),
        orbit_degrees=float(orbit_degrees),
        radial_delta_meters=float(radial_delta),
        lateral_delta_meters=float(lateral_delta),
        vertical_delta_meters=float(vertical_delta),
        net_motion_meters=net_motion,
        path_length_meters=path_length,
    )
