//! Motion curve definitions for card animations.
//!
//! All curves map `t ∈ [0, 1]` to a position ratio. Curves with overshoot
//! (`SmoothSnap`, `SoftBounce`, `Expressive`) may return values slightly
//! outside `[0, 1]` near the destination — callers should not clamp the output
//! before applying it to a lerp, as the overshoot is intentional.
//!
//! # Curve selection guide
//!
//! | Interaction          | Recommended curve |
//! |----------------------|-------------------|
//! | Standard card move   | `SmoothSnap`      |
//! | Foundation placement | `SoftBounce`      |
//! | Invalid snap-back    | `Responsive`      |
//! | Win cascade          | `Expressive`      |

use std::f32::consts::PI;

/// Motion curve variant controlling animation easing behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MotionCurve {
    /// Cubic ease-out with a 1.5 % terminal overshoot.
    ///
    /// Overshoot is a sine arch in the final 25 % of the animation that peaks
    /// ~1.5 % beyond the target, settling cleanly to 1.0 at `t = 1`. Gives a
    /// lively, slightly "alive" feel without feeling heavy.
    #[default]
    SmoothSnap,

    /// Underdamped spring (ζ = 0.65, ω = 20 rad/s).
    ///
    /// One visible overshoot of ~8 % followed by fast decay. Good for
    /// satisfying "thud" feedback when placing cards on foundations or tableau.
    SoftBounce,

    /// Quintic ease-out — aggressive deceleration, zero overshoot.
    ///
    /// Starts extremely fast and decelerates hard. Best for snap-back on
    /// invalid drops: the card returns instantly without any bounce.
    Responsive,

    /// Underdamped spring (ζ = 0.45, ω = 18 rad/s).
    ///
    /// Two visible bounces before settling. High visual energy — reserved for
    /// win cascade animations where expressivity matters more than subtlety.
    Expressive,
}

/// Samples `curve` at normalised time `t ∈ [0, 1]`.
///
/// The return value is the interpolation factor to pass to `Vec2::lerp` /
/// `Vec3::lerp`. Values may slightly exceed 1.0 for curves with overshoot.
#[inline]
pub fn sample_curve(curve: MotionCurve, t: f32) -> f32 {
    let t = t.clamp(0.0, 1.0);
    match curve {
        MotionCurve::SmoothSnap => smooth_snap(t),
        MotionCurve::SoftBounce => soft_bounce(t),
        MotionCurve::Responsive => responsive(t),
        MotionCurve::Expressive => expressive(t),
    }
}

/// Cubic ease-out with a sine-arch overshoot in the final 25 % of `t`.
///
/// The overshoot term is `sin(tail * π) * 0.015` where `tail` is `t` linearly
/// rescaled from `[0.75, 1.0]` to `[0, 1]`. At `t = 0.875` the card is ~1.5 %
/// past its target; at `t = 1` the card is exactly on target.
#[inline]
fn smooth_snap(t: f32) -> f32 {
    let base = 1.0 - (1.0 - t).powi(3);
    let tail = ((t - 0.75) / 0.25).clamp(0.0, 1.0);
    let overshoot = (tail * PI).sin() * 0.015;
    base + overshoot
}

/// Underdamped spring response (ζ = 0.65, ω₀ = 20 rad/s).
///
/// Derived from the exact closed-form solution:
/// `x(t) = 1 − e^{−ζω₀t}[cos(ωd·t) + (ζω₀/ωd)·sin(ωd·t)]`
/// where `ωd = ω₀·√(1 − ζ²)`.
#[inline]
fn soft_bounce(t: f32) -> f32 {
    const OMEGA: f32 = 20.0;
    const ZETA: f32 = 0.65;
    let omega_d = OMEGA * (1.0 - ZETA * ZETA).sqrt();
    let decay = (-ZETA * OMEGA * t).exp();
    1.0 - decay * ((omega_d * t).cos() + (ZETA * OMEGA / omega_d) * (omega_d * t).sin())
}

/// Quintic ease-out: `f(t) = 1 − (1 − t)^5`.
///
/// Reaches ~97 % of the target by `t = 0.5`. No overshoot.
#[inline]
fn responsive(t: f32) -> f32 {
    1.0 - (1.0 - t).powi(5)
}

/// Underdamped spring response (ζ = 0.45, ω₀ = 18 rad/s) — two visible bounces.
///
/// Uses the same closed-form spring formula as `soft_bounce` but with lower
/// damping, producing higher overshoot (~18 %) and two discernible oscillations
/// before settling.
#[inline]
fn expressive(t: f32) -> f32 {
    const OMEGA: f32 = 18.0;
    const ZETA: f32 = 0.45;
    let omega_d = OMEGA * (1.0 - ZETA * ZETA).sqrt();
    let decay = (-ZETA * OMEGA * t).exp();
    1.0 - decay * ((omega_d * t).cos() + (ZETA * OMEGA / omega_d) * (omega_d * t).sin())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn assert_near(a: f32, b: f32, eps: f32, msg: &str) {
        assert!((a - b).abs() < eps, "{msg}: expected ~{b}, got {a}");
    }

    #[test]
    fn all_curves_start_at_zero() {
        for curve in [
            MotionCurve::SmoothSnap,
            MotionCurve::SoftBounce,
            MotionCurve::Responsive,
            MotionCurve::Expressive,
        ] {
            assert_near(
                sample_curve(curve, 0.0),
                0.0,
                1e-5,
                &format!("{curve:?} at t=0"),
            );
        }
    }

    #[test]
    fn all_curves_end_at_one() {
        for curve in [
            MotionCurve::SmoothSnap,
            MotionCurve::SoftBounce,
            MotionCurve::Responsive,
        ] {
            assert_near(
                sample_curve(curve, 1.0),
                1.0,
                1e-4,
                &format!("{curve:?} at t=1"),
            );
        }
        // Spring-based curves have residual oscillation at finite t=1; allow 2 e-3.
        assert_near(
            sample_curve(MotionCurve::Expressive, 1.0),
            1.0,
            2e-3,
            "Expressive at t=1",
        );
    }

    #[test]
    fn responsive_reaches_half_before_midpoint() {
        // Quintic ease-out accelerates fast — >50 % by t=0.5.
        let v = sample_curve(MotionCurve::Responsive, 0.5);
        assert!(v > 0.96, "Responsive should be >96 % at t=0.5, got {v}");
    }

    #[test]
    fn smooth_snap_overshoots_slightly_near_end() {
        // Peak overshoot is around t = 0.875.
        let peak = sample_curve(MotionCurve::SmoothSnap, 0.875);
        assert!(
            peak > 1.0,
            "SmoothSnap should overshoot at t=0.875, got {peak}"
        );
        assert!(
            peak < 1.03,
            "SmoothSnap overshoot should be small (<3 %), got {peak}"
        );
    }

    #[test]
    fn soft_bounce_overshoots_and_returns() {
        let v = sample_curve(MotionCurve::SoftBounce, 1.0);
        assert_near(v, 1.0, 1e-3, "SoftBounce must settle at 1.0");
    }

    #[test]
    fn expressive_has_more_overshoot_than_soft_bounce() {
        // Compare max value in [0,1] range.
        let max_soft: f32 = (0..=100)
            .map(|i| sample_curve(MotionCurve::SoftBounce, i as f32 / 100.0))
            .fold(f32::NEG_INFINITY, f32::max);
        let max_expr: f32 = (0..=100)
            .map(|i| sample_curve(MotionCurve::Expressive, i as f32 / 100.0))
            .fold(f32::NEG_INFINITY, f32::max);
        assert!(
            max_expr > max_soft,
            "Expressive should overshoot more than SoftBounce: {max_expr} vs {max_soft}"
        );
    }

    #[test]
    fn sample_curve_clamps_t_below_zero() {
        assert_near(
            sample_curve(MotionCurve::SmoothSnap, -1.0),
            0.0,
            1e-5,
            "t<0 clamped",
        );
    }

    #[test]
    fn sample_curve_clamps_t_above_one() {
        assert_near(
            sample_curve(MotionCurve::Responsive, 2.0),
            1.0,
            1e-5,
            "t>1 clamped",
        );
    }
}
