"""Hierarchical shot and camera-motion planning.

The planner intentionally predicts discrete editorial intent rather than camera
coordinates.  ``generate_hybrid.py`` can use the resulting directives to
retrieve and retarget a suitable camera template.
"""

from __future__ import annotations

import math
from collections import Counter
from dataclasses import dataclass, field
from typing import Iterable, Sequence

import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import f1_score
from sklearn.model_selection import GroupKFold


BASE_FEATURE_DIM = 9
PLANNER_VERSION = "hierarchical-rf-beam-v2"
SHOT_TYPES = ("wide", "medium", "close")
MOTION_FAMILIES = (
    "static",
    "drift",
    "orbit",
    "dolly",
    "lateral_vertical",
)
CONTEXT_FEATURE_DIM = (
    BASE_FEATURE_DIM + 1 + len(SHOT_TYPES) + len(MOTION_FAMILIES)
)
SEQUENCE_CONTEXT_LENGTH = 3
MOTION_FEATURE_DIM = CONTEXT_FEATURE_DIM + len(SHOT_TYPES)

_SHOT_INDEX = {label: index for index, label in enumerate(SHOT_TYPES)}
_MOTION_INDEX = {
    label: index for index, label in enumerate(MOTION_FAMILIES)
}


@dataclass
class PlannerSample:
    """One labelled shot in an ordered source sequence.

    ``evaluation_group`` must identify records that may not be split across
    train and validation folds (for example, one song or one captured
    performance). ``source_id`` identifies the sequence whose previous shot
    supplies the autoregressive context.
    """

    base_features: np.ndarray
    progress: float
    duration: float
    shot_type: str
    motion_family: str
    source_id: str
    evaluation_group: str
    order: int

    def __post_init__(self) -> None:
        self.base_features = _validate_base_features(self.base_features)
        self.progress = _validate_progress(self.progress)
        self.duration = _validate_duration(self.duration)
        self.shot_type = normalize_shot_type(self.shot_type)
        self.motion_family = normalize_motion_family(self.motion_family)
        self.source_id = str(self.source_id).strip()
        self.evaluation_group = str(self.evaluation_group).strip()
        self.order = int(self.order)
        if not self.source_id:
            raise ValueError("source_id must not be empty.")
        if not self.evaluation_group:
            raise ValueError("evaluation_group must not be empty.")


@dataclass(frozen=True)
class ShotDirective:
    """A discrete intent selected for one generated shot."""

    shot_type: str
    motion_family: str
    shot_probability: float
    motion_probability: float
    score: float = 0.0
    shot_probabilities: dict[str, float] = field(default_factory=dict)
    motion_probabilities: dict[str, float] = field(default_factory=dict)

    def as_dict(self) -> dict[str, object]:
        """Return a JSON-serializable representation."""

        return {
            "shotType": self.shot_type,
            "motionFamily": self.motion_family,
            "shotProbability": self.shot_probability,
            "motionProbability": self.motion_probability,
            "plannerScore": self.score,
            "shotProbabilities": dict(self.shot_probabilities),
            "motionProbabilities": dict(self.motion_probabilities),
        }


@dataclass(frozen=True)
class _BeamState:
    score: float
    directives: tuple[ShotDirective, ...]


def normalize_shot_type(label: str) -> str:
    """Validate and normalize a semantic shot-size label."""

    normalized = str(label).strip().lower()
    if normalized not in _SHOT_INDEX:
        raise ValueError(
            f"Unknown shot type {label!r}; expected one of {SHOT_TYPES}."
        )
    return normalized


def normalize_motion_family(label: str) -> str:
    """Collapse detailed camera motion labels into planner families."""

    normalized = str(label).strip().lower().replace("-", "_")
    if normalized in _MOTION_INDEX:
        return normalized
    if normalized in {"fixed", "locked", "stationary", "still"}:
        return "static"
    if normalized in {"handheld", "float", "floating"}:
        return "drift"
    if normalized.startswith(("orbit", "arc")):
        return "orbit"
    if normalized.startswith(("dolly", "push", "pull", "zoom")):
        return "dolly"
    if normalized.startswith(
        (
            "truck",
            "crane",
            "pedestal",
            "lateral",
            "vertical",
            "boom",
            "jib",
        )
    ):
        return "lateral_vertical"
    raise ValueError(
        f"Unknown motion family {label!r}; expected one of "
        f"{MOTION_FAMILIES} or a recognized detailed motion label."
    )


def group_balanced_sample_weights(
    samples: Sequence[PlannerSample],
) -> np.ndarray:
    """Give every evaluation group equal total fitting weight.

    The inverse-size values are normalized to a mean of one, preserving the
    effective regularization scale expected by scikit-learn estimators.
    """

    if not samples:
        return np.empty(0, dtype=np.float64)
    counts = Counter(sample.evaluation_group for sample in samples)
    group_count = len(counts)
    sample_count = len(samples)
    return np.asarray(
        [
            sample_count / (group_count * counts[sample.evaluation_group])
            for sample in samples
        ],
        dtype=np.float64,
    )


def _validate_base_features(values: np.ndarray) -> np.ndarray:
    result = np.asarray(values, dtype=np.float32)
    if result.shape != (BASE_FEATURE_DIM,):
        raise ValueError(
            f"base_features must have shape ({BASE_FEATURE_DIM},), "
            f"got {result.shape}."
        )
    if not np.all(np.isfinite(result)):
        raise ValueError("base_features must contain only finite values.")
    return result.copy()


def _validate_progress(value: float) -> float:
    result = float(value)
    if not math.isfinite(result) or not 0.0 <= result <= 1.0:
        raise ValueError("progress must be finite and in the range [0, 1].")
    return result


def _validate_duration(value: float) -> float:
    result = float(value)
    if not math.isfinite(result) or result <= 0.0:
        raise ValueError("duration must be a finite positive value.")
    return result


def _one_hot(
    label: str | None,
    indices: dict[str, int],
) -> np.ndarray:
    result = np.zeros(len(indices), dtype=np.float32)
    if label is not None:
        result[indices[label]] = 1.0
    return result


def _majority_label(labels: Iterable[str], ordering: tuple[str, ...]) -> str:
    counts = Counter(labels)
    return max(ordering, key=lambda label: (counts[label], -ordering.index(label)))


class HierarchicalShotPlanner:
    """Random-forest shot planner with deterministic autoregressive decoding."""

    def __init__(
        self,
        *,
        random_state: int = 20260729,
        n_estimators: int = 200,
        max_depth: int | None = 8,
        min_samples_leaf: int = 2,
        beam_width: int = 6,
        shot_repeat_penalty: float = 0.54,
        motion_repeat_penalty: float = 0.28,
        extreme_transition_penalty: float = 0.10,
        recent_repeat_penalty: float = 0.16,
    ) -> None:
        self.random_state = int(random_state)
        self.n_estimators = int(n_estimators)
        self.max_depth = max_depth
        self.min_samples_leaf = int(min_samples_leaf)
        self.beam_width = int(beam_width)
        self.shot_repeat_penalty = float(shot_repeat_penalty)
        self.motion_repeat_penalty = float(motion_repeat_penalty)
        self.extreme_transition_penalty = float(
            extreme_transition_penalty
        )
        self.recent_repeat_penalty = float(recent_repeat_penalty)
        if self.n_estimators < 1:
            raise ValueError("n_estimators must be at least one.")
        if self.min_samples_leaf < 1:
            raise ValueError("min_samples_leaf must be at least one.")
        if self.beam_width < 1:
            raise ValueError("beam_width must be at least one.")

        self.shot_classifier: RandomForestClassifier | None = None
        self.motion_classifier: RandomForestClassifier | None = None
        self.fit_summary_: dict[str, object] = {}

    @property
    def is_fitted(self) -> bool:
        return (
            self.shot_classifier is not None
            and self.motion_classifier is not None
        )

    @property
    def context_feature_dim(self) -> int:
        return CONTEXT_FEATURE_DIM

    @property
    def motion_feature_dim(self) -> int:
        return MOTION_FEATURE_DIM

    def build_context_features(
        self,
        base_features: np.ndarray,
        progress: float,
        duration: float,
        previous_shot_type: str | None = None,
        previous_motion_family: str | None = None,
    ) -> np.ndarray:
        """Build the shot-classifier input for one sequence position."""

        base = _validate_base_features(base_features)
        progress_value = _validate_progress(progress)
        _validate_duration(duration)
        previous_shot = (
            normalize_shot_type(previous_shot_type)
            if previous_shot_type is not None
            else None
        )
        previous_motion = (
            normalize_motion_family(previous_motion_family)
            if previous_motion_family is not None
            else None
        )
        result = np.concatenate(
            (
                base,
                np.asarray(
                    [progress_value],
                    dtype=np.float32,
                ),
                _one_hot(previous_shot, _SHOT_INDEX),
                _one_hot(previous_motion, _MOTION_INDEX),
            )
        ).astype(np.float32)
        if result.shape != (CONTEXT_FEATURE_DIM,):
            raise RuntimeError("Unexpected planner context feature shape.")
        return result

    def build_motion_features(
        self,
        context_features: np.ndarray,
        shot_type: str,
    ) -> np.ndarray:
        """Condition a motion query on the proposed semantic shot type."""

        context = np.asarray(context_features, dtype=np.float32)
        if context.shape != (CONTEXT_FEATURE_DIM,):
            raise ValueError(
                "context_features must have shape "
                f"({CONTEXT_FEATURE_DIM},), got {context.shape}."
            )
        result = np.concatenate(
            (
                context,
                _one_hot(normalize_shot_type(shot_type), _SHOT_INDEX),
            )
        ).astype(np.float32)
        if result.shape != (MOTION_FEATURE_DIM,):
            raise RuntimeError("Unexpected planner motion feature shape.")
        return result

    def fit(
        self,
        samples: Sequence[PlannerSample],
    ) -> HierarchicalShotPlanner:
        """Fit both hierarchy levels with evaluation-group-balanced weights."""

        ordered = self._ordered_samples(samples)
        if not ordered:
            raise ValueError("At least one PlannerSample is required.")

        contexts: list[np.ndarray] = []
        shot_labels: list[str] = []
        motion_labels: list[str] = []
        previous_by_source: dict[str, tuple[str, str]] = {}
        for sample in ordered:
            previous = previous_by_source.get(sample.source_id)
            context = self.build_context_features(
                sample.base_features,
                sample.progress,
                sample.duration,
                previous[0] if previous else None,
                previous[1] if previous else None,
            )
            contexts.append(context)
            shot_labels.append(sample.shot_type)
            motion_labels.append(sample.motion_family)
            previous_by_source[sample.source_id] = (
                sample.shot_type,
                sample.motion_family,
            )

        context_matrix = np.stack(contexts)
        motion_matrix = np.stack(
            [
                self.build_motion_features(context, shot_type)
                for context, shot_type in zip(contexts, shot_labels)
            ]
        )
        sample_weights = group_balanced_sample_weights(ordered)
        self.shot_classifier = self._make_classifier(seed_offset=0)
        self.motion_classifier = self._make_classifier(seed_offset=1)
        self.shot_classifier.fit(
            context_matrix,
            np.asarray(shot_labels),
            sample_weight=sample_weights,
        )
        self.motion_classifier.fit(
            motion_matrix,
            np.asarray(motion_labels),
            sample_weight=sample_weights,
        )
        # Parallel tree fitting is useful, but dispatching a job pool for the
        # many single-row probability calls in beam decoding is far slower.
        self.shot_classifier.n_jobs = 1
        self.motion_classifier.n_jobs = 1
        self.fit_summary_ = {
            "sampleCount": len(ordered),
            "sourceCount": len(
                {
                    sample.source_id.split("#run", 1)[0]
                    for sample in ordered
                }
            ),
            "sequenceRunCount": len(
                {sample.source_id for sample in ordered}
            ),
            "evaluationGroupCount": len(
                {sample.evaluation_group for sample in ordered}
            ),
            "shotClassCounts": dict(sorted(Counter(shot_labels).items())),
            "motionClassCounts": dict(
                sorted(Counter(motion_labels).items())
            ),
            "sampleWeightMinimum": float(np.min(sample_weights)),
            "sampleWeightMaximum": float(np.max(sample_weights)),
            "classifierPreviousContextLength": 1,
            "beamSequenceContextLength": SEQUENCE_CONTEXT_LENGTH,
        }
        return self

    def predict(
        self,
        base_features: np.ndarray,
        progress: float,
        duration: float,
        previous: ShotDirective | None = None,
    ) -> ShotDirective:
        """Predict one directive using only a previous predicted directive."""

        return self.plan(
            [base_features],
            [progress],
            [duration],
            beam_width=1,
            initial_previous=previous,
        )[0]

    def plan(
        self,
        base_features_list: Sequence[np.ndarray] | np.ndarray,
        progress: Sequence[float] | np.ndarray,
        durations: Sequence[float] | np.ndarray,
        beam_width: int | None = None,
        initial_previous: ShotDirective | None = None,
        initial_history: Sequence[ShotDirective] | None = None,
    ) -> list[ShotDirective]:
        """Decode a complete directive sequence with deterministic beam search.

        Each expanded beam builds the next context from its own previous
        prediction.  Ground-truth labels are neither accepted nor consulted.
        """

        self._require_fitted()
        features = [
            _validate_base_features(item)
            for item in np.asarray(base_features_list, dtype=np.float32)
        ]
        progress_values = [_validate_progress(value) for value in progress]
        duration_values = [_validate_duration(value) for value in durations]
        if not (
            len(features) == len(progress_values) == len(duration_values)
        ):
            raise ValueError(
                "base_features_list, progress, and durations must have "
                "the same length."
            )
        if not features:
            return []

        width = self.beam_width if beam_width is None else int(beam_width)
        if width < 1:
            raise ValueError("beam_width must be at least one.")
        if initial_previous is not None and initial_history is not None:
            raise ValueError(
                "Use either initial_previous or initial_history, not both."
            )
        initial_directives: tuple[ShotDirective, ...] = ()
        if initial_history is not None:
            initial_directives = tuple(initial_history)
        elif initial_previous is not None:
            initial_directives = (initial_previous,)
        for directive in initial_directives:
            if not isinstance(directive, ShotDirective):
                raise TypeError(
                    "initial_history must contain ShotDirective values."
                )
        initial_count = len(initial_directives)
        beams = [_BeamState(score=0.0, directives=initial_directives)]

        for base, progress, duration in zip(
            features,
            progress_values,
            duration_values,
        ):
            expanded: list[_BeamState] = []
            for state in beams:
                previous = (
                    state.directives[-1] if state.directives else None
                )
                context = self.build_context_features(
                    base,
                    progress,
                    duration,
                    previous.shot_type if previous else None,
                    previous.motion_family if previous else None,
                )
                shot_probabilities = self._shot_probabilities(context)
                for shot_type in SHOT_TYPES:
                    motion_context = self.build_motion_features(
                        context,
                        shot_type,
                    )
                    motion_probabilities = self._motion_probabilities(
                        motion_context
                    )
                    for motion_family in MOTION_FAMILIES:
                        shot_probability = shot_probabilities[shot_type]
                        motion_probability = motion_probabilities[
                            motion_family
                        ]
                        next_score = (
                            state.score
                            + math.log(max(shot_probability, 1e-9))
                            + math.log(max(motion_probability, 1e-9))
                            - self._sequence_penalty(
                                state.directives,
                                shot_type,
                                motion_family,
                            )
                        )
                        directive = ShotDirective(
                            shot_type=shot_type,
                            motion_family=motion_family,
                            shot_probability=shot_probability,
                            motion_probability=motion_probability,
                            score=next_score,
                            shot_probabilities=shot_probabilities,
                            motion_probabilities=motion_probabilities,
                        )
                        expanded.append(
                            _BeamState(
                                score=next_score,
                                directives=state.directives + (directive,),
                            )
                        )
            expanded.sort(key=self._beam_sort_key)
            beams = expanded[:width]

        result = list(beams[0].directives)
        if initial_count:
            result = result[initial_count:]
        return result

    def closed_loop_metrics(
        self,
        samples: Sequence[PlannerSample],
        max_splits: int = 5,
    ) -> dict[str, object]:
        """Evaluate held-out groups using predicted, never true, context.

        With one evaluation group, honest grouped cross-validation is
        impossible.  The method safely falls back to closed-loop
        resubstitution and marks ``cross_validated`` false.
        """

        ordered = self._ordered_samples(samples)
        if not ordered:
            raise ValueError("At least one PlannerSample is required.")
        groups = np.asarray(
            [sample.evaluation_group for sample in ordered],
            dtype=object,
        )
        unique_groups = np.unique(groups)
        split_count = min(max(2, int(max_splits)), len(unique_groups))

        true_shots: list[str] = []
        true_motions: list[str] = []
        predicted_shots: list[str] = []
        predicted_motions: list[str] = []
        baseline_shots: list[str] = []
        baseline_motions: list[str] = []
        evaluated_groups: list[str] = []

        cross_validated = len(unique_groups) >= 2
        if cross_validated:
            splitter = GroupKFold(n_splits=split_count)
            folds = list(
                splitter.split(
                    np.zeros(len(ordered), dtype=np.float32),
                    groups=groups,
                )
            )
        else:
            all_indices = np.arange(len(ordered), dtype=np.int64)
            folds = [(all_indices, all_indices)]

        for train_indices, test_indices in folds:
            train_samples = [ordered[index] for index in train_indices]
            test_samples = [ordered[index] for index in test_indices]
            fold_planner = self._new_unfitted().fit(train_samples)
            fold_predictions = fold_planner._predict_sample_sequences(
                test_samples
            )
            shot_prior = _majority_label(
                (sample.shot_type for sample in train_samples),
                SHOT_TYPES,
            )
            motion_prior = _majority_label(
                (sample.motion_family for sample in train_samples),
                MOTION_FAMILIES,
            )
            for sample, directive in zip(test_samples, fold_predictions):
                true_shots.append(sample.shot_type)
                true_motions.append(sample.motion_family)
                predicted_shots.append(directive.shot_type)
                predicted_motions.append(directive.motion_family)
                baseline_shots.append(shot_prior)
                baseline_motions.append(motion_prior)
                evaluated_groups.append(sample.evaluation_group)

        group_weights = self._evaluation_group_weights(evaluated_groups)

        return {
            "sample_count": len(ordered),
            "evaluation_group_count": len(unique_groups),
            "fold_count": len(folds) if cross_validated else 0,
            "cross_validated": cross_validated,
            "closed_loop": True,
            "uses_teacher_forcing": False,
            "evaluation_uses_teacher_forcing": False,
            "training_motion_conditioning": "authored_shot_label",
            "aggregation": "evaluation_group_balanced",
            "shot_macro_f1": self._macro_f1(
                true_shots,
                predicted_shots,
                SHOT_TYPES,
                sample_weight=group_weights,
            ),
            "motion_macro_f1": self._macro_f1(
                true_motions,
                predicted_motions,
                MOTION_FAMILIES,
                sample_weight=group_weights,
            ),
            "shot_majority_macro_f1": self._macro_f1(
                true_shots,
                baseline_shots,
                SHOT_TYPES,
                sample_weight=group_weights,
            ),
            "motion_majority_macro_f1": self._macro_f1(
                true_motions,
                baseline_motions,
                MOTION_FAMILIES,
                sample_weight=group_weights,
            ),
            "shot_sample_macro_f1": self._macro_f1(
                true_shots,
                predicted_shots,
                SHOT_TYPES,
            ),
            "motion_sample_macro_f1": self._macro_f1(
                true_motions,
                predicted_motions,
                MOTION_FAMILIES,
            ),
        }

    def _make_classifier(
        self,
        seed_offset: int,
    ) -> RandomForestClassifier:
        return RandomForestClassifier(
            n_estimators=self.n_estimators,
            max_depth=self.max_depth,
            min_samples_leaf=self.min_samples_leaf,
            class_weight="balanced",
            random_state=self.random_state + seed_offset,
            n_jobs=-1,
        )

    def _new_unfitted(self) -> HierarchicalShotPlanner:
        return type(self)(
            random_state=self.random_state,
            n_estimators=self.n_estimators,
            max_depth=self.max_depth,
            min_samples_leaf=self.min_samples_leaf,
            beam_width=self.beam_width,
            shot_repeat_penalty=self.shot_repeat_penalty,
            motion_repeat_penalty=self.motion_repeat_penalty,
            extreme_transition_penalty=self.extreme_transition_penalty,
            recent_repeat_penalty=self.recent_repeat_penalty,
        )

    @staticmethod
    def _ordered_samples(
        samples: Sequence[PlannerSample],
    ) -> list[PlannerSample]:
        result = list(samples)
        if not all(isinstance(sample, PlannerSample) for sample in result):
            raise TypeError("samples must contain only PlannerSample values.")
        return sorted(
            result,
            key=lambda sample: (
                sample.source_id,
                sample.order,
                sample.evaluation_group,
            ),
        )

    def _shot_probabilities(
        self,
        context: np.ndarray,
    ) -> dict[str, float]:
        if self.shot_classifier is None:
            raise RuntimeError("The planner has not been fitted.")
        return self._semantic_probabilities(
            self.shot_classifier,
            context,
            SHOT_TYPES,
        )

    def _motion_probabilities(
        self,
        context: np.ndarray,
    ) -> dict[str, float]:
        if self.motion_classifier is None:
            raise RuntimeError("The planner has not been fitted.")
        return self._semantic_probabilities(
            self.motion_classifier,
            context,
            MOTION_FAMILIES,
        )

    @staticmethod
    def _semantic_probabilities(
        classifier: RandomForestClassifier,
        features: np.ndarray,
        labels: tuple[str, ...],
    ) -> dict[str, float]:
        raw = classifier.predict_proba(features[None])[0]
        observed = {
            str(label): float(probability)
            for label, probability in zip(classifier.classes_, raw)
        }
        return {label: observed.get(label, 0.0) for label in labels}

    def _sequence_penalty(
        self,
        history: tuple[ShotDirective, ...],
        shot_type: str,
        motion_family: str,
    ) -> float:
        if not history:
            return 0.0
        previous = history[-1]
        penalty = 0.0
        if previous.shot_type == shot_type:
            penalty += self.shot_repeat_penalty
        if previous.motion_family == motion_family:
            penalty += self.motion_repeat_penalty
        if {previous.shot_type, shot_type} == {"wide", "close"}:
            penalty += self.extreme_transition_penalty
        recent = history[-SEQUENCE_CONTEXT_LENGTH:]
        penalty += self.recent_repeat_penalty * sum(
            directive.shot_type == shot_type for directive in recent[:-1]
        )
        penalty += self.recent_repeat_penalty * sum(
            directive.motion_family == motion_family
            for directive in recent[:-1]
        )
        if (
            motion_family == "static"
            and previous.motion_family == "static"
        ):
            penalty += self.motion_repeat_penalty
        return penalty

    @staticmethod
    def _beam_sort_key(
        state: _BeamState,
    ) -> tuple[float, tuple[tuple[int, int], ...]]:
        signature = tuple(
            (
                _SHOT_INDEX[directive.shot_type],
                _MOTION_INDEX[directive.motion_family],
            )
            for directive in state.directives
        )
        return -state.score, signature

    def _predict_sample_sequences(
        self,
        samples: Sequence[PlannerSample],
    ) -> list[ShotDirective]:
        """Predict samples in source order without exposing their labels."""

        indexed = list(enumerate(samples))
        by_source: dict[str, list[tuple[int, PlannerSample]]] = {}
        for index, sample in indexed:
            by_source.setdefault(sample.source_id, []).append((index, sample))

        predictions: list[ShotDirective | None] = [None] * len(samples)
        for source_id in sorted(by_source):
            sequence = sorted(
                by_source[source_id],
                key=lambda item: item[1].order,
            )
            directives = self.plan(
                [item[1].base_features for item in sequence],
                [item[1].progress for item in sequence],
                [item[1].duration for item in sequence],
            )
            for (original_index, _), directive in zip(
                sequence,
                directives,
            ):
                predictions[original_index] = directive
        if any(directive is None for directive in predictions):
            raise RuntimeError("Failed to predict every planner sample.")
        return [
            directive
            for directive in predictions
            if directive is not None
        ]

    @staticmethod
    def _macro_f1(
        truth: Sequence[str],
        predictions: Sequence[str],
        labels: tuple[str, ...],
        sample_weight: Sequence[float] | None = None,
    ) -> float:
        return float(
            f1_score(
                truth,
                predictions,
                labels=list(labels),
                average="macro",
                zero_division=0,
                sample_weight=sample_weight,
            )
        )

    @staticmethod
    def _evaluation_group_weights(
        groups: Sequence[str],
    ) -> np.ndarray:
        counts = Counter(groups)
        if not counts:
            return np.empty(0, dtype=np.float64)
        total = len(groups)
        group_count = len(counts)
        return np.asarray(
            [
                total / (group_count * counts[group])
                for group in groups
            ],
            dtype=np.float64,
        )

    def _require_fitted(self) -> None:
        if not self.is_fitted:
            raise RuntimeError("Call fit() before predict() or plan().")


# Descriptive alias for callers that treat the record as a sequence sample.
SequenceSample = PlannerSample


__all__ = [
    "BASE_FEATURE_DIM",
    "CONTEXT_FEATURE_DIM",
    "MOTION_FAMILIES",
    "MOTION_FEATURE_DIM",
    "PLANNER_VERSION",
    "SEQUENCE_CONTEXT_LENGTH",
    "PlannerSample",
    "SHOT_TYPES",
    "SequenceSample",
    "ShotDirective",
    "HierarchicalShotPlanner",
    "group_balanced_sample_weights",
    "normalize_motion_family",
    "normalize_shot_type",
]
