"""Kinematic metrics for mouse trajectory evaluation.

All inputs are 1-D NumPy arrays. Time is in milliseconds, position in pixels.
Velocities are px/ms, accelerations px/ms², jerks px/ms³.
"""
from __future__ import annotations

import numpy as np


def compute_speed(
    xs: np.ndarray, ys: np.ndarray, ts: np.ndarray, eps: float = 1e-6
) -> np.ndarray:
    """Compute scalar speed at each step.

    Args:
        xs: (N,) x coordinates.
        ys: (N,) y coordinates.
        ts: (N,) timestamps in ms.
        eps: minimum dt (ms) to avoid div-by-zero.

    Returns:
        (N-1,) array of speeds (px/ms).
    """
    dx = np.diff(xs)
    dy = np.diff(ys)
    dt = np.maximum(np.diff(ts), eps)
    return np.hypot(dx, dy) / dt


def compute_acceleration(speeds: np.ndarray, ts: np.ndarray, eps: float = 1e-6) -> np.ndarray:
    """Compute scalar acceleration from speeds.

    Args:
        speeds: (M,) speeds (px/ms). Typically M = N-1 from compute_speed.
        ts: (M,) or (M+1,) timestamps in ms.
            If len(ts) == len(speeds): timestamps are treated as the time
            points associated with each speed value directly.
            If len(ts) == len(speeds)+1: timestamps are the original position
            timestamps; midpoints are computed for speed intervals.
        eps: minimum dt (ms) to avoid div-by-zero.

    Returns:
        (M-1,) array of accelerations (px/ms²).
    """
    if len(speeds) < 2:
        return np.array([], dtype=float)
    if len(ts) == len(speeds):
        # ts[i] is already the time associated with speed[i]
        dt = np.maximum(np.diff(ts), eps)
    else:
        # ts has length M+1; speed[i] is between ts[i] and ts[i+1]
        midpoints = (ts[:-1] + ts[1:]) / 2.0
        dt = np.maximum(np.diff(midpoints), eps)
    return np.diff(speeds) / dt


def compute_jerk(accels: np.ndarray, ts: np.ndarray, eps: float = 1e-6) -> np.ndarray:
    """Compute jerk from accelerations.

    Args:
        accels: (K,) accelerations.
        ts: (K+2,) timestamps that produced those accelerations.
            Used to derive midpoint-of-midpoint dts.
        eps: minimum dt to avoid div-by-zero.

    Returns:
        (K-1,) array of jerks (px/ms³).
    """
    if len(accels) < 2:
        return np.array([], dtype=float)
    # Approximate dt for jerks as average dt of original ts (good enough for stats)
    dt_avg = np.maximum(np.diff(ts).mean(), eps)
    return np.diff(accels) / dt_avg


def compute_stats(x: np.ndarray) -> dict[str, float]:
    """Summary statistics for a 1-D distribution.

    Returns:
        dict with keys: mean, std, cv (coef of variation), p25, p50, p75, p95.
    """
    if len(x) == 0:
        return {k: 0.0 for k in ("mean", "std", "cv", "p25", "p50", "p75", "p95")}
    x = np.asarray(x, dtype=float)
    mean = float(x.mean())
    std = float(x.std(ddof=1)) if len(x) > 1 else 0.0
    cv = std / mean if mean != 0 else 0.0
    return {
        "mean": mean,
        "std": std,
        "cv": cv,
        "p25": float(np.percentile(x, 25)),
        "p50": float(np.percentile(x, 50)),
        "p75": float(np.percentile(x, 75)),
        "p95": float(np.percentile(x, 95)),
    }


def fft_spectrum(
    signal: np.ndarray, sample_rate_hz: float
) -> tuple[np.ndarray, np.ndarray]:
    """Compute one-sided FFT magnitude spectrum.

    Args:
        signal: 1-D real-valued signal.
        sample_rate_hz: Sampling rate in Hz.

    Returns:
        (freqs, magnitudes) — positive frequencies only.
        Magnitudes are absolute values of complex FFT coefficients.
    """
    n = len(signal)
    if n == 0:
        return np.array([]), np.array([])
    # Zero-mean to remove DC component which dominates the spectrum
    s = signal - signal.mean()
    fft = np.fft.rfft(s)
    freqs = np.fft.rfftfreq(n, d=1.0 / sample_rate_hz)
    return freqs, np.abs(fft)


def kl_divergence_histograms(
    x: np.ndarray,
    y: np.ndarray,
    bins: int = 50,
    eps: float = 1e-10,
) -> float:
    """KL divergence KL(P_x || P_y) estimated via shared-bin histograms.

    Both arrays are histogrammed over their joint range. Empty bins get
    `eps` mass to avoid log(0) — keeps result finite even for disjoint
    supports.

    Args:
        x: samples from distribution P.
        y: samples from distribution Q (the "reference").
        bins: number of histogram bins.
        eps: smoothing constant for empty bins.

    Returns:
        scalar KL divergence (nats). Always finite, ≥ 0.
    """
    if len(x) == 0 or len(y) == 0:
        return 0.0
    lo = float(min(x.min(), y.min()))
    hi = float(max(x.max(), y.max()))
    if hi <= lo:
        return 0.0
    edges = np.linspace(lo, hi, bins + 1)
    px, _ = np.histogram(x, bins=edges, density=False)
    qy, _ = np.histogram(y, bins=edges, density=False)
    px = px.astype(float) + eps
    qy = qy.astype(float) + eps
    px /= px.sum()
    qy /= qy.sum()
    return float(np.sum(px * np.log(px / qy)))
