"""ORBIT ROS 2 Live Monitor — Real-time data quality feedback during demonstration collection.

This module contains the core logic for the ORBIT live monitor, designed to be
importable both as a ROS 2 node (via orbit-ros package) and for unit testing
without ROS 2 installed.

Architecture:
    - JointStateAnalyzer: Per-timestep joint health checks (dead, clipping, jerk)
    - CameraAnalyzer: Subsampled image quality checks (blur, exposure, frozen)
    - EpisodeBoundaryDetector: Detects episode start/end from velocity or external signals
    - EpisodeAnalyzer: Per-episode quality scoring (reuses coach.py internals)
    - SessionAggregator: Running session-level quality metrics
    - FeedbackEngine: Generates coaching messages from analysis results

The ROS 2 node wrapper (in orbit-ros package) subscribes to topics and feeds
data into these analyzers. This module has NO ROS 2 dependency.
"""

from __future__ import annotations

import json
import logging
import time
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path

import numpy as np

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------


@dataclass
class MonitorConfig:
    """Configuration for the live monitor."""

    # Target
    policy: str = "act"
    target_episodes: int = 50

    # Episode boundary detection
    boundary_method: str = "auto"  # "auto", "velocity", "topic", "manual"
    idle_timeout: float = 2.0  # seconds of no motion = episode end
    velocity_threshold: float = 0.01  # rad/s below = "idle"

    # Quality thresholds
    jerk_alert_multiplier: float = 3.0
    dead_joint_ratio: float = 0.80
    clip_margin: float = 0.05
    blur_threshold: float = 100.0

    # Joint configuration (optional)
    joint_names: list[str] | None = None
    joint_limits: list[tuple[float, float]] | None = None

    # Performance
    joint_window_size: int = 100
    camera_check_rate: float = 2.0  # Hz

    # Feedback
    audio_enabled: bool = False
    speech_enabled: bool = False


# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------


@dataclass
class JointAlert:
    """Alert from per-timestep joint analysis."""

    timestamp: float
    joint_index: int
    joint_name: str | None
    alert_type: str  # "dead", "clipping", "jerk_spike"
    severity: str  # "info", "warning", "error"
    message: str
    value: float = 0.0


@dataclass
class EpisodeBuffer:
    """Buffers timestep data for a single episode."""

    positions: list[np.ndarray] = field(default_factory=list)
    timestamps: list[float] = field(default_factory=list)
    camera_blur_scores: list[float] = field(default_factory=list)
    camera_brightness_scores: list[float] = field(default_factory=list)

    def as_numpy(self) -> np.ndarray:
        """Convert buffered positions to (T, D) array."""
        if not self.positions:
            return np.empty((0, 0))
        return np.array(self.positions)

    @property
    def length(self) -> int:
        return len(self.positions)

    @property
    def duration(self) -> float:
        if len(self.timestamps) < 2:
            return 0.0
        return self.timestamps[-1] - self.timestamps[0]


@dataclass
class EpisodeVerdict:
    """Quality verdict for a completed episode."""

    episode_index: int
    verdict: str  # "good", "review", "redo"
    consistency_score: float
    smoothness_score: float
    composite_score: float
    issues: list[str] = field(default_factory=list)
    suggestions: list[str] = field(default_factory=list)
    coaching_message: str = ""
    duration: float = 0.0
    frame_count: int = 0


@dataclass
class SessionStatus:
    """Running session-level quality status."""

    total_episodes: int = 0
    good_episodes: int = 0
    review_episodes: int = 0
    redo_episodes: int = 0
    grade: str = "—"
    mean_consistency: float = 0.0
    coverage_estimate: float = 0.0
    policy: str = "act"
    target_episodes: int = 50
    estimated_remaining: int = 50
    fatigue_warning: str = ""
    trend: str = "stable"  # "improving", "stable", "degrading"


# ---------------------------------------------------------------------------
# JointStateAnalyzer — per-timestep checks
# ---------------------------------------------------------------------------


class JointStateAnalyzer:
    """Streaming per-joint health checks.

    Maintains ring buffers of recent joint positions and computes:
    - Velocity (position diff / time diff)
    - Zero-velocity ratio (per joint, sliding window)
    - Jerk magnitude (second derivative of velocity)
    - Clipping detection (position near configured limits)

    Call `update()` with each new joint state message. Check `alerts` for
    any new alerts since last call.
    """

    def __init__(self, config: MonitorConfig):
        self.config = config
        self.window_size = config.joint_window_size

        # Ring buffers (initialized on first message)
        self._positions: deque[np.ndarray] = deque(maxlen=self.window_size)
        self._timestamps: deque[float] = deque(maxlen=self.window_size)
        self._velocities: deque[np.ndarray] = deque(maxlen=self.window_size)

        # Running statistics
        self._jerk_medians: np.ndarray | None = None  # per-joint running median
        self._zero_vel_counts: np.ndarray | None = None  # per-joint count in window
        self._n_dims: int = 0
        self._initialized: bool = False

        # Alert output
        self.alerts: list[JointAlert] = []

    def update(self, positions: np.ndarray, timestamp: float) -> list[JointAlert]:
        """Process a new joint state message.

        Args:
            positions: Joint positions array, shape (D,).
            timestamp: Message timestamp in seconds.

        Returns:
            List of alerts generated by this update (may be empty).
        """
        positions = np.asarray(positions, dtype=np.float64)
        self.alerts = []

        if not self._initialized:
            self._n_dims = len(positions)
            self._zero_vel_counts = np.zeros(self._n_dims)
            self._jerk_medians = np.ones(self._n_dims) * 0.01  # initial estimate
            self._initialized = True

        self._positions.append(positions)
        self._timestamps.append(timestamp)

        # Need at least 2 samples for velocity
        if len(self._positions) < 2:
            return self.alerts

        # Compute velocity
        dt = self._timestamps[-1] - self._timestamps[-2]
        if dt <= 0:
            return self.alerts

        velocity = (self._positions[-1] - self._positions[-2]) / dt
        self._velocities.append(velocity)

        # --- Check 1: Zero velocity (per joint) ---
        is_zero = np.abs(velocity) < self.config.velocity_threshold
        # Update sliding window count
        if self._zero_vel_counts is not None:
            self._zero_vel_counts += is_zero.astype(float)
            # Remove oldest contribution if window is full
            if len(self._velocities) > self.window_size:
                # Approximate: decay the count
                self._zero_vel_counts *= (self.window_size - 1) / self.window_size

            window_len = min(len(self._velocities), self.window_size)
            if window_len > 10:
                zero_ratios = self._zero_vel_counts / window_len
                for j in range(self._n_dims):
                    if zero_ratios[j] > self.config.dead_joint_ratio:
                        self._emit_alert(
                            timestamp, j, "dead", "warning",
                            f"Joint {self._joint_name(j)} appears dead "
                            f"({zero_ratios[j]:.0%} zero velocity)",
                            value=float(zero_ratios[j]),
                        )

        # --- Check 2: Clipping ---
        if self.config.joint_limits:
            for j in range(min(self._n_dims, len(self.config.joint_limits))):
                lo, hi = self.config.joint_limits[j]
                margin = self.config.clip_margin * (hi - lo)
                if positions[j] <= lo + margin or positions[j] >= hi - margin:
                    self._emit_alert(
                        timestamp, j, "clipping", "warning",
                        f"Joint {self._joint_name(j)} near limit "
                        f"(pos={positions[j]:.3f}, limits=[{lo:.2f}, {hi:.2f}])",
                        value=float(positions[j]),
                    )

        # --- Check 3: Jerk spikes ---
        if len(self._velocities) >= 3:
            acc_now = self._velocities[-1] - self._velocities[-2]
            acc_prev = self._velocities[-2] - self._velocities[-3]
            jerk = np.abs(acc_now - acc_prev) / (dt * dt)

            # Update running jerk median (exponential moving average)
            if self._jerk_medians is not None:
                alpha = 0.01
                self._jerk_medians = (1 - alpha) * self._jerk_medians + alpha * jerk

                threshold = self._jerk_medians * self.config.jerk_alert_multiplier
                spikes = jerk > threshold
                for j in range(self._n_dims):
                    if spikes[j] and self._jerk_medians[j] > 0.001:
                        ratio = jerk[j] / max(self._jerk_medians[j], 1e-8)
                        self._emit_alert(
                            timestamp, j, "jerk_spike", "info",
                            f"Joint {self._joint_name(j)} jerk spike "
                            f"({ratio:.1f}x median)",
                            value=float(jerk[j]),
                        )

        return self.alerts

    def _joint_name(self, index: int) -> str:
        if self.config.joint_names and index < len(self.config.joint_names):
            return self.config.joint_names[index]
        return f"joint_{index}"

    def _emit_alert(
        self,
        timestamp: float,
        joint_index: int,
        alert_type: str,
        severity: str,
        message: str,
        value: float = 0.0,
    ) -> None:
        self.alerts.append(JointAlert(
            timestamp=timestamp,
            joint_index=joint_index,
            joint_name=self._joint_name(joint_index),
            alert_type=alert_type,
            severity=severity,
            message=message,
            value=value,
        ))

    @property
    def current_velocities(self) -> np.ndarray | None:
        """Most recent velocity vector."""
        return self._velocities[-1] if self._velocities else None

    @property
    def joint_health_summary(self) -> list[str]:
        """Per-joint health status: 'OK', 'DEAD', 'CLIP', 'JITTER'."""
        if not self._initialized or self._zero_vel_counts is None:
            return []
        window_len = min(len(self._velocities), self.window_size)
        if window_len < 5:
            return ["..." for _ in range(self._n_dims)]

        result = []
        zero_ratios = self._zero_vel_counts / max(window_len, 1)
        for j in range(self._n_dims):
            if zero_ratios[j] > self.config.dead_joint_ratio:
                result.append("DEAD")
            else:
                # Check recent jerk alerts for this joint
                recent_jerk = sum(
                    1 for a in self.alerts
                    if a.joint_index == j and a.alert_type == "jerk_spike"
                )
                if recent_jerk > 0:
                    result.append("JITTER")
                else:
                    result.append("OK")
        return result


# ---------------------------------------------------------------------------
# CameraAnalyzer — subsampled image quality
# ---------------------------------------------------------------------------


class CameraAnalyzer:
    """Subsampled camera quality checks.

    Checks blur (Laplacian variance), exposure (mean brightness), and
    frozen frames (frame-to-frame diff). Runs at a configurable rate
    (default 2 Hz) regardless of camera FPS.
    """

    def __init__(self, config: MonitorConfig):
        self.config = config
        self._last_check_time: float = 0.0
        self._prev_gray: np.ndarray | None = None
        self._frozen_start: float = 0.0

        # Results
        self.blur_score: float = 0.0
        self.brightness: float = 128.0
        self.is_frozen: bool = False
        self.alerts: list[str] = []

    def update(self, frame: np.ndarray, timestamp: float) -> list[str]:
        """Process a camera frame (throttled).

        Args:
            frame: Image as numpy array (H, W, C) or (H, W) grayscale.
            timestamp: Frame timestamp in seconds.

        Returns:
            List of alert messages (may be empty).
        """
        # Throttle
        if timestamp - self._last_check_time < 1.0 / self.config.camera_check_rate:
            return []

        self._last_check_time = timestamp
        self.alerts = []

        # Convert to grayscale if needed
        if frame.ndim == 3:
            # Simple luminance (avoid OpenCV dependency in core module)
            gray = np.mean(frame, axis=2).astype(np.float32)
        else:
            gray = frame.astype(np.float32)

        # 1. Blur detection (Laplacian variance approximation)
        # Use simple second-derivative kernel instead of cv2.Laplacian
        if gray.shape[0] > 4 and gray.shape[1] > 4:
            # Laplacian via convolution: center - neighbors
            laplacian = (
                4 * gray[1:-1, 1:-1]
                - gray[:-2, 1:-1] - gray[2:, 1:-1]
                - gray[1:-1, :-2] - gray[1:-1, 2:]
            )
            self.blur_score = float(np.var(laplacian))
        else:
            self.blur_score = 0.0

        if self.blur_score < self.config.blur_threshold:
            self.alerts.append(
                f"Camera blur detected (score: {self.blur_score:.0f}, "
                f"threshold: {self.config.blur_threshold:.0f})"
            )

        # 2. Exposure check
        self.brightness = float(np.mean(gray))
        if self.brightness < 30:
            self.alerts.append(f"Camera too dark (brightness: {self.brightness:.0f})")
        elif self.brightness > 225:
            self.alerts.append(f"Camera overexposed (brightness: {self.brightness:.0f})")

        # 3. Frozen frame detection
        if self._prev_gray is not None and self._prev_gray.shape == gray.shape:
            diff = float(np.mean(np.abs(gray - self._prev_gray)))
            if diff < 1.0:
                if self._frozen_start == 0.0:
                    self._frozen_start = timestamp
                elif timestamp - self._frozen_start > 2.0:
                    self.is_frozen = True
                    self.alerts.append(
                        f"Camera frozen for {timestamp - self._frozen_start:.1f}s"
                    )
            else:
                self._frozen_start = 0.0
                self.is_frozen = False
        self._prev_gray = gray.copy()

        return self.alerts

    @property
    def status_string(self) -> str:
        """One-line camera status."""
        if self.is_frozen:
            return "FROZEN"
        if self.blur_score < self.config.blur_threshold:
            return f"BLURRY ({self.blur_score:.0f})"
        return f"OK (blur: {self.blur_score:.0f}, bright: {self.brightness:.0f})"


# ---------------------------------------------------------------------------
# EpisodeBoundaryDetector — episode start/end detection
# ---------------------------------------------------------------------------


class EpisodeBoundaryDetector:
    """Detects episode boundaries from joint velocity or external signals.

    Supports three modes:
    - "velocity": Episode starts when joints move, ends after idle timeout
    - "topic": Episode boundaries from external marker topic
    - "auto": Use topic if available, fall back to velocity
    """

    def __init__(self, config: MonitorConfig):
        self.config = config
        self.state: str = "idle"  # "idle", "recording", "analyzing"
        self._idle_timer: float = 0.0
        self._last_timestamp: float = 0.0
        self._episode_count: int = 0
        self.current_buffer: EpisodeBuffer = EpisodeBuffer()

        # Callbacks
        self._on_episode_start: list = []
        self._on_episode_end: list = []

    def on_episode_start(self, callback) -> None:
        """Register callback for episode start events."""
        self._on_episode_start.append(callback)

    def on_episode_end(self, callback) -> None:
        """Register callback for episode end events."""
        self._on_episode_end.append(callback)

    def update_from_velocity(self, max_velocity: float, timestamp: float) -> None:
        """Update episode state from joint velocity.

        Args:
            max_velocity: Maximum absolute velocity across all joints.
            timestamp: Current timestamp.
        """
        dt = timestamp - self._last_timestamp if self._last_timestamp > 0 else 0.0
        self._last_timestamp = timestamp

        if self.state == "idle":
            if max_velocity > self.config.velocity_threshold:
                self._start_episode(timestamp)
        elif self.state == "recording":
            if max_velocity < self.config.velocity_threshold:
                self._idle_timer += dt
                if self._idle_timer > self.config.idle_timeout:
                    self._end_episode(timestamp)
            else:
                self._idle_timer = 0.0

    def mark_episode_start(self) -> None:
        """External trigger: start a new episode."""
        self._start_episode(time.monotonic())

    def mark_episode_end(self) -> None:
        """External trigger: end current episode."""
        if self.state == "recording":
            self._end_episode(time.monotonic())

    def buffer_timestep(
        self,
        positions: np.ndarray,
        timestamp: float,
        blur_score: float | None = None,
        brightness: float | None = None,
    ) -> None:
        """Buffer a timestep of data for the current episode."""
        if self.state != "recording":
            return
        self.current_buffer.positions.append(positions.copy())
        self.current_buffer.timestamps.append(timestamp)
        if blur_score is not None:
            self.current_buffer.camera_blur_scores.append(blur_score)
        if brightness is not None:
            self.current_buffer.camera_brightness_scores.append(brightness)

    def _start_episode(self, timestamp: float) -> None:
        self.state = "recording"
        self._idle_timer = 0.0
        self._episode_count += 1
        self.current_buffer = EpisodeBuffer()
        logger.info("Episode %d started at %.2f", self._episode_count, timestamp)
        for cb in self._on_episode_start:
            cb(self._episode_count)

    def _end_episode(self, timestamp: float) -> None:
        self.state = "analyzing"
        buffer = self.current_buffer
        episode_idx = self._episode_count
        logger.info(
            "Episode %d ended at %.2f (%d frames, %.1fs)",
            episode_idx, timestamp, buffer.length, buffer.duration,
        )
        for cb in self._on_episode_end:
            cb(episode_idx, buffer)
        self.state = "idle"

    @property
    def episode_count(self) -> int:
        return self._episode_count


# ---------------------------------------------------------------------------
# EpisodeAnalyzer — per-episode quality scoring
# ---------------------------------------------------------------------------


class EpisodeAnalyzer:
    """Scores completed episodes against a running baseline.

    Reuses logic from orbit.coach for metric computation and scoring.
    Maintains an incremental baseline that updates as episodes accumulate.
    """

    def __init__(self, config: MonitorConfig):
        self.config = config
        self._episodes: list[np.ndarray] = []
        self._baseline: dict = {}
        self._verdicts: list[EpisodeVerdict] = []

    def analyze(self, episode_index: int, buffer: EpisodeBuffer) -> EpisodeVerdict:
        """Analyze a completed episode and return a verdict.

        Args:
            episode_index: 1-based episode number.
            buffer: Buffered episode data.

        Returns:
            EpisodeVerdict with scores, issues, and coaching.
        """
        actions = buffer.as_numpy()

        if actions.size == 0 or actions.ndim != 2 or len(actions) < 3:
            verdict = EpisodeVerdict(
                episode_index=episode_index,
                verdict="redo",
                consistency_score=0.0,
                smoothness_score=0.0,
                composite_score=0.0,
                issues=["Episode too short or empty"],
                coaching_message="Episode was too short — try again",
                duration=buffer.duration,
                frame_count=buffer.length,
            )
            self._verdicts.append(verdict)
            return verdict

        self._episodes.append(actions)

        # Update baseline every 10 episodes (or when we have 5+)
        if len(self._episodes) >= 5 and (
            not self._baseline or len(self._episodes) % 10 == 0
        ):
            self._update_baseline()

        # Score the episode
        if self._baseline:
            from orbit.coach import _compute_episode_metrics, _score_episode

            feedback = _score_episode(actions, self._baseline)
            metrics = _compute_episode_metrics(actions)

            verdict = EpisodeVerdict(
                episode_index=episode_index,
                verdict=feedback.verdict,
                consistency_score=feedback.consistency_score,
                smoothness_score=0.0,  # TODO: add to coach feedback
                composite_score=feedback.consistency_score,
                issues=list(feedback.issues),
                suggestions=list(feedback.suggestions),
                coaching_message=self._build_coaching_message(feedback),
                duration=buffer.duration,
                frame_count=buffer.length,
            )
        else:
            # Not enough episodes for baseline yet
            verdict = EpisodeVerdict(
                episode_index=episode_index,
                verdict="good",  # assume good until we have baseline
                consistency_score=1.0,
                smoothness_score=1.0,
                composite_score=1.0,
                issues=[],
                coaching_message="Building baseline...",
                duration=buffer.duration,
                frame_count=buffer.length,
            )

        self._verdicts.append(verdict)
        return verdict

    def _update_baseline(self) -> None:
        """Recompute baseline from all episodes so far."""
        from orbit.coach import _compute_baseline
        self._baseline = _compute_baseline(self._episodes)

    def _build_coaching_message(self, feedback) -> str:
        """Build a single-line coaching message for audio/display."""
        if feedback.verdict == "good":
            return "Good"
        parts = []
        if feedback.issues:
            parts.append(feedback.issues[0])
        if feedback.suggestions:
            parts.append(feedback.suggestions[0])
        return " — ".join(parts) if parts else feedback.verdict.upper()

    @property
    def verdicts(self) -> list[EpisodeVerdict]:
        return self._verdicts


# ---------------------------------------------------------------------------
# SessionAggregator — running session-level quality
# ---------------------------------------------------------------------------


class SessionAggregator:
    """Aggregates episode verdicts into session-level quality metrics."""

    def __init__(self, config: MonitorConfig):
        self.config = config
        self._consistency_scores: list[float] = []
        self._jerk_trend: list[float] = []
        self.status = SessionStatus(
            policy=config.policy,
            target_episodes=config.target_episodes,
            estimated_remaining=config.target_episodes,
        )

    def add_verdict(self, verdict: EpisodeVerdict) -> SessionStatus:
        """Update session status with a new episode verdict."""
        s = self.status
        s.total_episodes += 1

        if verdict.verdict == "good":
            s.good_episodes += 1
        elif verdict.verdict == "review":
            s.review_episodes += 1
        else:
            s.redo_episodes += 1

        self._consistency_scores.append(verdict.consistency_score)
        s.mean_consistency = float(np.mean(self._consistency_scores))

        # Compute grade
        s.grade = self._compute_grade()

        # Estimate remaining
        good_rate = s.good_episodes / max(s.total_episodes, 1)
        if good_rate > 0:
            needed = self.config.target_episodes - s.good_episodes
            s.estimated_remaining = max(0, int(needed / good_rate))
        else:
            s.estimated_remaining = self.config.target_episodes

        # Trend detection
        s.trend = self._detect_trend()

        # Fatigue detection
        s.fatigue_warning = self._detect_fatigue()

        return s

    def _compute_grade(self) -> str:
        """Compute letter grade from session metrics."""
        s = self.status
        if s.total_episodes == 0:
            return "—"

        good_pct = s.good_episodes / s.total_episodes
        score = good_pct * 0.5 + s.mean_consistency * 0.5

        if score >= 0.90:
            return "A"
        elif score >= 0.80:
            return "B+"
        elif score >= 0.70:
            return "B"
        elif score >= 0.60:
            return "C+"
        elif score >= 0.50:
            return "C"
        elif score >= 0.40:
            return "D"
        else:
            return "F"

    def _detect_trend(self) -> str:
        """Detect quality trend from recent consistency scores."""
        if len(self._consistency_scores) < 6:
            return "stable"
        recent = self._consistency_scores[-3:]
        earlier = self._consistency_scores[-6:-3]
        diff = np.mean(recent) - np.mean(earlier)
        if diff > 0.05:
            return "improving"
        elif diff < -0.05:
            return "degrading"
        return "stable"

    def _detect_fatigue(self) -> str:
        """Detect operator fatigue from jerk trends."""
        scores = self._consistency_scores
        if len(scores) < 10:
            return ""
        last_5 = np.mean(scores[-5:])
        first_5 = np.mean(scores[:5])
        if last_5 < first_5 * 0.8:
            return (
                "Quality has dropped compared to your first episodes. "
                "Consider taking a short break."
            )
        return ""


# ---------------------------------------------------------------------------
# LiveMonitor — orchestrates all analyzers
# ---------------------------------------------------------------------------


class LiveMonitor:
    """Main orchestrator for the ORBIT live monitor.

    Wires together all analyzers and provides a single interface for
    feeding data and retrieving results. This class has NO ROS 2 dependency —
    it is the core logic that a ROS 2 node wrapper calls into.

    Usage:
        monitor = LiveMonitor(config)
        monitor.on_joint_state(positions, timestamp)
        monitor.on_image(frame, timestamp)  # optional
        # Results available via:
        #   monitor.joint_analyzer.alerts
        #   monitor.session.status
        #   monitor.episode_analyzer.verdicts
    """

    def __init__(self, config: MonitorConfig | None = None):
        if config is None:
            config = MonitorConfig()
        self.config = config

        # Analyzers
        self.joint_analyzer = JointStateAnalyzer(config)
        self.camera_analyzer = CameraAnalyzer(config)
        self.boundary_detector = EpisodeBoundaryDetector(config)
        self.episode_analyzer = EpisodeAnalyzer(config)
        self.session = SessionAggregator(config)

        # Wire up episode boundary callbacks
        self.boundary_detector.on_episode_end(self._on_episode_end)

        # Verdict callback (set by ROS node or UI)
        self._verdict_callbacks: list = []

    def on_verdict(self, callback) -> None:
        """Register callback for episode verdicts: callback(verdict: EpisodeVerdict)."""
        self._verdict_callbacks.append(callback)

    def on_joint_state(self, positions: np.ndarray, timestamp: float) -> list[JointAlert]:
        """Process a joint state message.

        Returns list of alerts (may be empty).
        """
        # Run per-timestep checks
        alerts = self.joint_analyzer.update(positions, timestamp)

        # Feed episode boundary detector
        vel = self.joint_analyzer.current_velocities
        if vel is not None:
            max_vel = float(np.max(np.abs(vel)))
            self.boundary_detector.update_from_velocity(max_vel, timestamp)

        # Buffer data for current episode
        self.boundary_detector.buffer_timestep(
            positions, timestamp,
            blur_score=self.camera_analyzer.blur_score,
            brightness=self.camera_analyzer.brightness,
        )

        return alerts

    def on_image(self, frame: np.ndarray, timestamp: float) -> list[str]:
        """Process a camera frame (throttled internally).

        Returns list of alert messages (may be empty).
        """
        return self.camera_analyzer.update(frame, timestamp)

    def mark_episode_start(self) -> None:
        """External trigger: start a new episode."""
        self.boundary_detector.mark_episode_start()

    def mark_episode_end(self) -> None:
        """External trigger: end current episode."""
        self.boundary_detector.mark_episode_end()

    def _on_episode_end(self, episode_index: int, buffer: EpisodeBuffer) -> None:
        """Called when an episode ends. Runs analysis and updates session."""
        verdict = self.episode_analyzer.analyze(episode_index, buffer)
        self.session.add_verdict(verdict)

        for cb in self._verdict_callbacks:
            cb(verdict)

    def save_session(self, path: str | Path | None = None) -> Path:
        """Save session data to disk.

        Args:
            path: Directory to save to. Defaults to ~/.orbit/sessions/<timestamp>/.

        Returns:
            Path to the saved session directory.
        """
        if path is None:
            base = Path.home() / ".orbit" / "sessions"
            base.mkdir(parents=True, exist_ok=True)
            dirname = time.strftime("%Y-%m-%d_%H-%M-%S")
            path = base / dirname
        else:
            path = Path(path)

        path = Path(path)
        path.mkdir(parents=True, exist_ok=True)

        # Session metadata
        session_meta = {
            "policy": self.config.policy,
            "target_episodes": self.config.target_episodes,
            "total_episodes": self.session.status.total_episodes,
            "good_episodes": self.session.status.good_episodes,
            "review_episodes": self.session.status.review_episodes,
            "redo_episodes": self.session.status.redo_episodes,
            "grade": self.session.status.grade,
            "mean_consistency": self.session.status.mean_consistency,
            "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
        }
        (path / "session.json").write_text(json.dumps(session_meta, indent=2))

        # Per-episode verdicts
        with open(path / "episodes.jsonl", "w") as f:
            for v in self.episode_analyzer.verdicts:
                entry = {
                    "episode_index": v.episode_index,
                    "verdict": v.verdict,
                    "consistency_score": v.consistency_score,
                    "smoothness_score": v.smoothness_score,
                    "composite_score": v.composite_score,
                    "issues": v.issues,
                    "suggestions": v.suggestions,
                    "duration": v.duration,
                    "frame_count": v.frame_count,
                }
                f.write(json.dumps(entry) + "\n")

        logger.info("Session saved to %s", path)
        return path

    @property
    def state_label(self) -> str:
        """Current state: 'idle', 'recording', or 'analyzing'."""
        return self.boundary_detector.state
