//! Orbital path integration for POIs (planets, stations, …).
//!
//! Each frame, recomputes the local-space position of every [`Orbital`]
//! entity based on its `semi_major_axis`, `period`, and `phase`, plus
//! accumulated sim time. The orbit lies in the XZ plane (Y up), matching the
//! spawn-time layout in [`super::contents::orbital_position`].
//!
//! ## Stateless
//!
//! The position is recomputed from component data + elapsed time — there is
//! no per-entity angle accumulator. This means:
//!
//! - **No drift** over long sessions (the math is exact, not integrated).
//! - **Trivial save/load**: only the component data needs to persist.
//! - **Time manipulation Just Works™**: pause, slow-mo, and scrub are free
//!   side-effects of Bevy's `Time` speed multiplier, which scales both
//!   `elapsed_secs()` and `delta_secs()`.
//!
//! ## Static POIs
//!
//! POIs with `period <= 0` (asteroid belts, stations, anomalies, stargates,
//! gas clouds in the current generator) keep their spawn-time position. We
//! skip them entirely to avoid spurious change-detection ticks every frame.
//!
//! ## Hierarchy assumption
//!
//! Each [`Orbital`] is currently a child of [`super::StarSystem`] while its
//! `Orbital.parent` is the system's [`super::Star`], which sits at the
//! `StarSystem`'s local origin. The orbital offset is therefore written
//! directly to the POI's local `Transform`.
//!
//! To support nested orbits later (moons orbiting planets), either reparent
//! POIs under their orbital parent in the Bevy hierarchy, or look up the
//! parent's `GlobalTransform` here and compute the local offset against the
//! POI's Bevy parent.

use bevy::prelude::*;

use super::poi::Orbital;

/// Advance every [`Orbital`] along its path.
///
/// See module docs for the math and the hierarchy assumption.
pub fn advance_orbital_paths(time: Res<Time>, mut orbitals: Query<(&Orbital, &mut Transform)>) {
    let t = time.elapsed_secs();
    for (orbital, mut transform) in &mut orbitals {
        if orbital.period <= 0.0 {
            continue;
        }
        let target = orbital_position(orbital.semi_major_axis, orbital.phase, orbital.period, t);
        // Avoid mutating when within float-tolerance — keeps change detection
        // quiet on frames where the position hasn't visibly changed (e.g.
        // when time is paused or the orbit is very slow).
        if (transform.translation.x - target.x).abs() > 1e-6
            || (transform.translation.z - target.z).abs() > 1e-6
        {
            transform.translation.x = target.x;
            transform.translation.z = target.z;
        }
    }
}

/// Local-space position of an orbiting body at simulation time `t`.
///
/// Pure function — exposed for unit tests and for callers that want to
/// preview a POI's position without spawning it (e.g. drawing predicted
/// orbital paths).
///
/// - `semi_major_axis` — orbital radius in world units.
/// - `phase` — initial angle in radians at `t = 0`.
/// - `period` — seconds for one full revolution. Must be > 0.
/// - `t` — elapsed simulation time in seconds.
pub fn orbital_position(semi_major_axis: f32, phase: f32, period: f32, t: f32) -> Vec3 {
    debug_assert!(
        period > 0.0,
        "orbital_position requires period > 0; static POIs should be handled by the caller"
    );
    let angle = phase + (std::f32::consts::TAU / period) * t;
    Vec3::new(
        angle.cos() * semi_major_axis,
        0.0,
        angle.sin() * semi_major_axis,
    )
}

// ─── Tests ──────────────────────────────────────────────────────────────────

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

    const TAU: f32 = std::f32::consts::TAU;

    #[test]
    fn position_at_time_zero_uses_phase() {
        let pos = orbital_position(2.0, 0.5, 10.0, 0.0);
        // angle = phase + 0 = 0.5 rad
        assert!((pos.x - 0.5_f32.cos() * 2.0).abs() < 1e-6);
        assert!((pos.z - 0.5_f32.sin() * 2.0).abs() < 1e-6);
        assert!(pos.y.abs() < 1e-6);
    }

    #[test]
    fn position_returns_to_start_after_one_period() {
        let start = orbital_position(3.0, 1.0, 8.0, 0.0);
        let after_one = orbital_position(3.0, 1.0, 8.0, 8.0);
        assert!((start.x - after_one.x).abs() < 1e-5);
        assert!((start.z - after_one.z).abs() < 1e-5);
    }

    #[test]
    fn position_is_quarter_turn_after_quarter_period() {
        let pos = orbital_position(1.0, 0.0, TAU, TAU / 4.0);
        // period == TAU ⇒ angular velocity == 1 rad/s.
        // After TAU/4 seconds: angle = TAU/4 = π/2 ⇒ (cos, sin) = (0, 1).
        assert!(pos.x.abs() < 1e-5);
        assert!((pos.z - 1.0).abs() < 1e-5);
    }

    #[test]
    fn position_lies_on_circle_of_radius_semi_major_axis() {
        // Sample one full revolution; every point must lie exactly on the
        // orbital circle (radius = semi_major_axis) in the XZ plane.
        let radius = 4.5_f32;
        let phase = 1.25_f32;
        let period = 12.0_f32;
        let samples = 64;
        for i in 0..samples {
            let t = (i as f32 / samples as f32) * period;
            let pos = orbital_position(radius, phase, period, t);
            let r = (pos.x * pos.x + pos.z * pos.z).sqrt();
            assert!((r - radius).abs() < 1e-5, "radius drift at sample {i}: {r}");
            assert!(pos.y.abs() < 1e-6, "non-zero y at sample {i}: {}", pos.y);
        }
    }

    #[test]
    fn position_advances_with_time() {
        let a = orbital_position(1.0, 0.0, 10.0, 0.0);
        let b = orbital_position(1.0, 0.0, 10.0, 0.5);
        assert!((a.x - b.x).abs() > 1e-4 || (a.z - b.z).abs() > 1e-4);
    }

    #[test]
    fn position_respects_phase_offset() {
        let t = 0.0_f32;
        let a = orbital_position(1.0, 0.0, 10.0, t);
        let b = orbital_position(1.0, 1.0, 10.0, t);
        assert!((a.x - b.x).abs() > 1e-4 || (a.z - b.z).abs() > 1e-4);
    }

    #[test]
    #[should_panic(expected = "orbital_position requires period > 0")]
    fn orbital_position_panics_on_non_positive_period() {
        let _ = orbital_position(1.0, 0.0, 0.0, 0.0);
    }
}
