//! Per-system contents generation: planets, asteroid belts, stations,
//! anomalies, gas clouds, and stargates.
//!
//! Invoked by [`super`] during galaxy generation. Data-only at first
//! ([`SystemContents`]), then spawned as child entities of each
//! [`super::StarSystem`] with the appropriate POI components from
//! [`super::poi`].
//!
//! ## Scale
//!
//! All distances in this file are in **galaxy units** (the same scale as the
//! system positions themselves). Contents are kept in the 1–10 unit range so
//! they fit inside the typical system-to-system spacing (~10–24 units) without
//! overflowing into neighbouring systems.
//!
//! ## Determinism
//!
//! Each call to [`generate_system_contents`] advances the provided RNG.
//! Re-running with the same seed and the same iteration order produces
//! identical contents.

use bevy::prelude::*;
use bevy::render::mesh::VertexAttributeValues;
use rand::rngs::StdRng;
use rand::Rng;
use std::time::Duration;

use super::poi::*;

// ─── POI variant data ───────────────────────────────────────────────────────

/// Classification of a planet's surface composition. Drives visual color and
/// habitability.
///
/// Variant order is significant: [`ContentAssets::planet_material`] indexes
/// the material cache by `as usize`. Adding / reordering variants requires
/// updating the cache.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanetType {
    Barren = 0,
    Rocky = 1,
    Terrestrial = 2,
    Gas = 3,
    Ice = 4,
    Lava = 5,
    Oceanic = 6,
}

impl PlanetType {
    /// Visual color (sRGB) used by the spawning code to pick a material.
    pub fn color(self) -> [f32; 3] {
        match self {
            Self::Barren => [0.54, 0.48, 0.42],
            Self::Rocky => [0.66, 0.62, 0.58],
            Self::Terrestrial => [0.20, 0.77, 0.33],
            Self::Gas => [0.83, 0.65, 0.38],
            Self::Ice => [0.58, 0.77, 0.99],
            Self::Lava => [0.94, 0.27, 0.16],
            Self::Oceanic => [0.22, 0.74, 0.97],
        }
    }

    pub fn display_name(self) -> &'static str {
        match self {
            Self::Barren => "Barren",
            Self::Rocky => "Rocky",
            Self::Terrestrial => "Terrestrial",
            Self::Gas => "Gas Giant",
            Self::Ice => "Ice",
            Self::Lava => "Lava",
            Self::Oceanic => "Oceanic",
        }
    }

    pub fn is_gas(self) -> bool {
        matches!(self, Self::Gas)
    }

    pub fn is_habitable(self) -> bool {
        matches!(self, Self::Terrestrial | Self::Oceanic)
    }

    fn random(rng: &mut StdRng) -> Self {
        match rng.gen_range(0..7) {
            0 => Self::Barren,
            1 => Self::Rocky,
            2 => Self::Terrestrial,
            3 => Self::Gas,
            4 => Self::Ice,
            5 => Self::Lava,
            _ => Self::Oceanic,
        }
    }

    /// All variants in declared order — used by [`ContentAssets::new`] to
    /// allocate one material per type.
    pub const ALL: [Self; 7] = [
        Self::Barren,
        Self::Rocky,
        Self::Terrestrial,
        Self::Gas,
        Self::Ice,
        Self::Lava,
        Self::Oceanic,
    ];
}

#[derive(Debug, Clone)]
pub struct GeneratedPlanet {
    pub name: String,
    pub planet_type: PlanetType,
    pub orbit: f32,
    pub period: f32,
    pub phase: f32,
    pub radius: f32,
    pub mass: f32,
    pub population: u32,
    pub habitable: bool,
}

/// One rock instance inside a [`GeneratedAsteroidBelt`]. Generated at galaxy
/// build time (deterministic from the system RNG) so the same seed always
/// yields the same belt layout.
#[derive(Debug, Clone)]
pub struct GeneratedAsteroid {
    /// Position relative to the star (XZ plane, with small Y jitter).
    pub position: Vec3,
    /// Uniform scale.
    pub scale: f32,
    /// Random rotation — gives visual variety even though every rock shares
    /// the same mesh asset.
    pub rotation: Quat,
    /// Index into [`ContentAssets::asteroid_materials`].
    pub material_index: usize,
}

#[derive(Debug, Clone)]
pub struct GeneratedAsteroidBelt {
    pub name: String,
    pub inner_orbit: f32,
    pub outer_orbit: f32,
    pub yield_remaining: f32,
    /// Per-rock visual instances — spawned as children of the belt entity.
    pub asteroids: Vec<GeneratedAsteroid>,
}

#[derive(Debug, Clone)]
pub struct GeneratedStation {
    pub name: String,
    pub orbit: f32,
    pub phase: f32,
    /// Seconds for one full revolution around the star. `0.0` = stationary.
    pub period: f32,
    pub population: u32,
}

#[derive(Debug, Clone)]
pub struct GeneratedAnomaly {
    pub name: String,
    pub orbit: f32,
    pub phase: f32,
    /// Seconds for one full revolution around the star. `0.0` = stationary.
    pub period: f32,
    pub difficulty: Difficulty,
    pub signal_kind: SignalKind,
    pub requires_probe: bool,
    pub remaining: Duration,
}

#[derive(Debug, Clone)]
pub struct GeneratedStargate {
    pub name: String,
    /// [`Identifiable::id`] of the destination system.
    pub destination_id: String,
    pub destination_name: String,
    pub orbit: f32,
    pub phase: f32,
}

#[derive(Debug, Clone)]
pub struct GeneratedGasCloud {
    pub name: String,
    pub orbit: f32,
    pub phase: f32,
    /// Seconds for one full revolution around the star. `0.0` = stationary.
    pub period: f32,
    pub gas_kind: GasKind,
    pub flow_rate: f32,
}

#[derive(Debug, Clone, Default)]
pub struct SystemContents {
    pub planets: Vec<GeneratedPlanet>,
    pub belts: Vec<GeneratedAsteroidBelt>,
    pub stations: Vec<GeneratedStation>,
    pub anomalies: Vec<GeneratedAnomaly>,
    pub stargates: Vec<GeneratedStargate>,
    pub gas_clouds: Vec<GeneratedGasCloud>,
}

impl SystemContents {
    /// Total number of POIs (planets + everything else) in this system.
    pub fn total(&self) -> usize {
        self.planets.len()
            + self.belts.len()
            + self.stations.len()
            + self.anomalies.len()
            + self.stargates.len()
            + self.gas_clouds.len()
    }

    /// Outermost orbit radius across all non-stargate POIs. Stargates are
    /// excluded because they're placed *after* this is computed (their orbit
    /// is derived from this value). Used by [`generate_stargates`] to put
    /// gates clearly outside the inner POI ring.
    pub fn max_orbit(&self) -> f32 {
        let mut max = 0.0_f32;
        for p in &self.planets {
            max = max.max(p.orbit);
        }
        for b in &self.belts {
            max = max.max(b.outer_orbit);
        }
        for s in &self.stations {
            max = max.max(s.orbit);
        }
        for a in &self.anomalies {
            max = max.max(a.orbit);
        }
        for g in &self.gas_clouds {
            max = max.max(g.orbit);
        }
        max
    }
}

// ─── Input context ──────────────────────────────────────────────────────────

/// Read-only view of a [`super::GeneratedSystem`] consumed by the contents
/// generator. Kept separate so this module doesn't need to name its parent's
/// private system struct (and so the generator stays unit-testable).
#[derive(Debug, Clone, Copy)]
pub struct SystemContext<'a> {
    pub id: &'a str,
    pub name: &'a str,
    pub faction: &'a str,
    pub security: f32,
    pub is_core: bool,
}

/// Minimal read-only summary used by [`generate_stargates`]. The caller
/// (galaxy generator) constructs one per system alongside its contents.
#[derive(Debug, Clone)]
pub struct SystemSummary {
    pub id: String,
    pub name: String,
}

// ─── Tunables ───────────────────────────────────────────────────────────────

/// Orbit of the first planet. Keeps planets clear of the central star visual.
const PLANET_ORBIT_START: f32 = 1.8;
/// Minimum gap between successive planets. Actual gap is sampled above this.
/// Kept compact to mirror the TS reference (planet display orbit grows by
/// ~0.42 per planet) and to leave breathing room for the outer POI ring.
const PLANET_ORBIT_STEP: f32 = 0.4;
/// Radius beyond which non-planet POIs (belts, stations, …) live.
const OUTER_RING_START: f32 = 5.0;
/// Minimum stargate orbit — lower bound even for sparse systems. Picked to
/// stay inside the typical system bubble (~12 units, half the inter-system
/// spacing) so gates from neighboring systems never overlap.
const STARGATE_MIN_ORBIT: f32 = 7.0;
/// Padding added to a system's outermost POI orbit before placing stargates.
/// Keeps gates clearly outside the inner POI ring so they never visually
/// overlap planets / stations / anomalies / gas clouds.
const STARGATE_PADDING: f32 = 3.0;
/// Number of asteroid instances to scatter per belt. Tuned for visual density
/// at galaxy-inspection scale (~1 unit per belt) without crowding the renderer
/// (60 rocks × ~120 belts ≈ 7,200 entities — well under Bevy's comfort zone).
const ASTEROIDS_PER_BELT: usize = 60;
/// Size of the [`ContentAssets::asteroid_materials`] palette. Picked to give
/// visible per-rock color variation without exploding material asset count.
const ASTEROID_COLOR_COUNT: usize = 5;

// ─── Orbital math ───────────────────────────────────────────────────────────

/// Approximate orbital period (seconds) for a body at radius `orbit`.
///
/// Linear in radius — outer bodies orbit slower, but not as dramatically
/// as Kepler's T² ∝ r³ would dictate. Adequate for visual orbit motion at
/// galaxy-inspection scale. The caller supplies a jitter value (typically
/// sampled from the system RNG) so the period stays deterministic from the
/// seed.
///
/// Used by planets, stations, anomalies, gas clouds, and individual
/// asteroid rocks. Stargates are intentionally excluded — they stay
/// stationary as navigational aids. Periods are floored at
/// [`MIN_ORBITAL_PERIOD`] so very tight orbits don't become a blur.
///
/// [`ORBITAL_PERIOD_SCALE`] then multiplies the raw period so all bodies
/// orbit visibly slower — at galaxy-inspection scale the un-scaled periods
/// (≈2.5 s per unit of radius) made planets spin like tops. Tuned for
/// readable motion when the player is browsing the galaxy map.
const MIN_ORBITAL_PERIOD: f32 = 2.0;
const ORBITAL_PERIOD_SCALE: f32 = 4.0;
fn orbital_period(orbit: f32, jitter: f32) -> f32 {
    ((orbit * 2.5 + jitter) * ORBITAL_PERIOD_SCALE).max(MIN_ORBITAL_PERIOD)
}

// ─── Generation ─────────────────────────────────────────────────────────────

/// Procedurally populate one star system.
///
/// Stargates are NOT generated here — they depend on the connection graph
/// which is computed globally. Use [`generate_stargates`] after generating
/// base contents for every system.
pub fn generate_system_contents(rng: &mut StdRng, ctx: &SystemContext) -> SystemContents {
    let mut contents = SystemContents::default();
    let mut orbit_cursor = PLANET_ORBIT_START;

    // ── Planets ─────────────────────────────────────────────────────────────
    let planet_count = planet_count_for_security(rng, ctx.security, ctx.is_core);
    for i in 0..planet_count {
        let planet_type = PlanetType::random(rng);
        let radius = if planet_type.is_gas() {
            rng.gen_range(0.18..=0.28)
        } else {
            rng.gen_range(0.08..=0.16)
        };
        let period = orbital_period(orbit_cursor, rng.gen_range(0.0..3.0));
        let phase = rng.gen::<f32>() * std::f32::consts::TAU;
        let mass = radius * 100.0;
        let habitable = planet_type.is_habitable() && rng.gen_bool(0.6);
        let population = if habitable && rng.gen_bool(0.7) {
            rng.gen_range(100..1_000_000)
        } else {
            0
        };
        let name = format!("{} {}", ctx.name, romanize(i + 1));

        contents.planets.push(GeneratedPlanet {
            name,
            planet_type,
            orbit: orbit_cursor,
            period,
            phase,
            radius,
            mass,
            population,
            habitable,
        });
        orbit_cursor += PLANET_ORBIT_STEP + rng.gen_range(0.1..0.4);
    }

    // ── Asteroid belt ───────────────────────────────────────────────────────
    let belt_chance = if ctx.security < 0.4 {
        0.85
    } else if ctx.security < 0.7 {
        0.55
    } else {
        0.25
    };
    if rng.gen::<f32>() < belt_chance {
        let inner = orbit_cursor.max(OUTER_RING_START);
        let outer = inner + rng.gen_range(0.6..=1.2);
        let asteroids = generate_asteroids(rng, inner, outer);
        contents.belts.push(GeneratedAsteroidBelt {
            name: format!("{} Belt", ctx.name),
            inner_orbit: inner,
            outer_orbit: outer,
            yield_remaining: rng.gen_range(1000.0..5000.0),
            asteroids,
        });
        orbit_cursor = outer + 0.4;
    }

    // ── Stations ────────────────────────────────────────────────────────────
    let station_count = if ctx.is_core {
        rng.gen_range(2..=3)
    } else if ctx.security > 0.7 {
        rng.gen_range(1..=2)
    } else if ctx.security > 0.4 {
        usize::from(rng.gen_bool(0.5))
    } else {
        0
    };
    // Distribute stations across the outer ring with unique orbits
    for i in 0..station_count {
        // Start stations at OUTER_RING_START, spread them evenly
        let base_orbit = orbit_cursor.max(OUTER_RING_START);
        // Add increasing offset for each station to ensure spacing
        let orbit = base_orbit + (i as f32 * 0.5) + rng.gen_range(0.1..0.3);
        let phase = rng.gen::<f32>() * std::f32::consts::TAU;
        let population = if ctx.is_core {
            rng.gen_range(1_000_000..10_000_000)
        } else {
            rng.gen_range(10_000..1_000_000)
        };
        let name = if station_count == 1 {
            format!("{} Hub", ctx.name)
        } else {
            format!("{} Station {}", ctx.name, romanize(i + 1))
        };
        contents.stations.push(GeneratedStation {
            name,
            orbit,
            phase,
            period: orbital_period(orbit, rng.gen_range(0.0..3.0)),
            population,
        });
        // Update orbit_cursor to after the last station for subsequent POIs
        orbit_cursor = orbit + 0.4;
    }

    // ── Gas clouds (rare, slightly more common in low-sec) ──────────────────
    let gas_chance = if ctx.security < 0.4 { 0.35 } else { 0.20 };
    if rng.gen::<f32>() < gas_chance {
        let count = rng.gen_range(1..=2);
        for _ in 0..count {
            orbit_cursor = orbit_cursor.max(OUTER_RING_START) + rng.gen_range(0.2..0.6);
            let phase = rng.gen::<f32>() * std::f32::consts::TAU;
            let gas_kind = match rng.gen_range(0..4) {
                0 => GasKind::Hydrogen,
                1 => GasKind::Helium,
                2 => GasKind::Nitrogen,
                _ => GasKind::Exotic,
            };
            contents.gas_clouds.push(GeneratedGasCloud {
                name: format!("{} Cloud", gas_kind_display(gas_kind)),
                orbit: orbit_cursor,
                phase,
                period: orbital_period(orbit_cursor, rng.gen_range(0.0..3.0)),
                gas_kind,
                flow_rate: rng.gen_range(1.0..5.0),
            });
            orbit_cursor += 0.3;
        }
    }

    // ── Anomalies (low-sec only) ────────────────────────────────────────────
    if ctx.security < 0.4 {
        let count = rng.gen_range(1..=3);
        for i in 0..count {
            orbit_cursor = orbit_cursor.max(OUTER_RING_START) + rng.gen_range(0.3..0.7);
            let phase = rng.gen::<f32>() * std::f32::consts::TAU;
            let difficulty = match rng.gen_range(0..5) {
                0 => Difficulty::Trivial,
                1 => Difficulty::Easy,
                2 => Difficulty::Moderate,
                3 => Difficulty::Hard,
                _ => Difficulty::Expert,
            };
            let signal_kind = match rng.gen_range(0..4) {
                0 => SignalKind::Infrared,
                1 => SignalKind::Gravimetric,
                2 => SignalKind::Radar,
                _ => SignalKind::Magnetometric,
            };
            let requires_probe = matches!(difficulty, Difficulty::Hard | Difficulty::Expert);
            contents.anomalies.push(GeneratedAnomaly {
                name: anomaly_name(ctx.security, i),
                orbit: orbit_cursor,
                phase,
                period: orbital_period(orbit_cursor, rng.gen_range(0.0..3.0)),
                difficulty,
                signal_kind,
                requires_probe,
                remaining: Duration::from_secs(rng.gen_range(600..3600)),
            });
            orbit_cursor += 0.3;
        }
    }

    // Stargates are added later — see [`generate_stargates`].
    contents
}

/// Planet count bias: high-sec / core systems have more charted planets,
/// low-sec systems are sparser.
fn planet_count_for_security(rng: &mut StdRng, security: f32, is_core: bool) -> usize {
    if is_core {
        rng.gen_range(5..=8)
    } else if security > 0.7 {
        rng.gen_range(3..=7)
    } else if security > 0.4 {
        rng.gen_range(2..=5)
    } else {
        rng.gen_range(1..=4)
    }
}

/// Scatter [`ASTEROIDS_PER_BELT`] rock instances uniformly across the annulus
/// between `inner_orbit` and `outer_orbit`. Returned data is consumed verbatim
/// by the spawner — keeping generation and rendering decoupled preserves the
/// "same seed = same galaxy" invariant.
///
/// Each rock gets a random radius within the belt, a random angle around the
/// star, and small Y jitter proportional to belt width for thickness. Scale
/// and rotation are randomized so the single shared mesh asset reads as a
/// field of unique rocks at dev scale.
fn generate_asteroids(
    rng: &mut StdRng,
    inner_orbit: f32,
    outer_orbit: f32,
) -> Vec<GeneratedAsteroid> {
    let mut out = Vec::with_capacity(ASTEROIDS_PER_BELT);
    // Vertical half-thickness: 20% of the radial width. Reads as a flat ring
    // at inspection scale without being perfectly planar.
    let half_thickness = (outer_orbit - inner_orbit) * 0.2;

    for _ in 0..ASTEROIDS_PER_BELT {
        let t: f32 = rng.gen();
        let radius = inner_orbit + t * (outer_orbit - inner_orbit);
        let angle = rng.gen::<f32>() * std::f32::consts::TAU;
        let y = (rng.gen::<f32>() - 0.5) * half_thickness;
        let position = Vec3::new(angle.cos() * radius, y, angle.sin() * radius);
        let scale = rng.gen_range(0.06..=0.14);
        let rotation = Quat::from_euler(
            EulerRot::XYZ,
            rng.gen::<f32>() * std::f32::consts::TAU,
            rng.gen::<f32>() * std::f32::consts::TAU,
            rng.gen::<f32>() * std::f32::consts::TAU,
        );
        let material_index = rng.gen_range(0..ASTEROID_COLOR_COUNT);
        out.push(GeneratedAsteroid {
            position,
            scale,
            rotation,
            material_index,
        });
    }
    out
}

fn anomaly_name(security: f32, index: usize) -> String {
    if security < 0.2 {
        format!("Unstable Deadspace Rift {}", romanize(index + 1))
    } else {
        format!("Hidden Combat Site {}", romanize(index + 1))
    }
}

fn gas_kind_display(kind: GasKind) -> &'static str {
    match kind {
        GasKind::Hydrogen => "Hydrogen",
        GasKind::Helium => "Helium",
        GasKind::Nitrogen => "Nitrogen",
        GasKind::Exotic => "Exotic",
    }
}

/// Tiny int → roman numeral helper for numbered planet/station names.
/// Covers I–IX which is enough for any plausible per-system count.
fn romanize(n: usize) -> &'static str {
    match n {
        1 => "I",
        2 => "II",
        3 => "III",
        4 => "IV",
        5 => "V",
        6 => "VI",
        7 => "VII",
        8 => "VIII",
        _ => "IX+",
    }
}

// ─── Stargate generation ────────────────────────────────────────────────────

/// Add a pair of stargates (one in each endpoint system) for every connection.
///
/// Stargates sit on an outer ring placed just outside the system's outermost
/// POI (see [`SystemContents::max_orbit`] + [`STARGATE_PADDING`], floored at
/// [`STARGATE_MIN_ORBIT`]) so they never collide with inner-system POIs.
/// Their phase angle is derived deterministically from `(system_id,
/// gate_index)` so the same galaxy seed yields the same gate layout across
/// reruns.
///
/// Takes parallel `summaries` / `contents` slices rather than a slice of
/// tuples — this keeps the caller's data layout free and avoids split-borrow
/// gymnastics at the call site.
pub fn generate_stargates(
    summaries: &[SystemSummary],
    contents: &mut [SystemContents],
    connections: &[(usize, usize)],
) {
    assert_eq!(summaries.len(), contents.len());
    // Track how many gates have already been placed in each system so we can
    // distribute them around the ring instead of stacking on top of each other.
    let mut gate_counts = vec![0usize; summaries.len()];

    for &(a, b) in connections {
        if a >= summaries.len() || b >= summaries.len() || a == b {
            continue;
        }

        // Snapshot the identities of both endpoints so we never have two
        // simultaneous borrows into the same slice index.
        let a_id = summaries[a].id.clone();
        let a_name = summaries[a].name.clone();
        let b_id = summaries[b].id.clone();
        let b_name = summaries[b].name.clone();

        let idx_a = gate_counts[a];
        let idx_b = gate_counts[b];
        gate_counts[a] += 1;
        gate_counts[b] += 1;

        // Dynamic outer orbit per system so stargates never overlap the
        // inner POI ring (planets / stations / anomalies / gas clouds).
        let max_orbit_a = contents[a].max_orbit();
        let max_orbit_b = contents[b].max_orbit();

        // Gate in A → B (destination = b_id)
        let (orbit_a, phase_a) = stargate_orbit_phase(idx_a, &a_id, max_orbit_a);
        contents[a].stargates.push(GeneratedStargate {
            name: format!("{a_name} → {b_name} Gate"),
            destination_id: b_id.clone(),
            destination_name: b_name.clone(),
            orbit: orbit_a,
            phase: phase_a,
        });

        // Gate in B → A (destination = a_id)
        let (orbit_b, phase_b) = stargate_orbit_phase(idx_b, &b_id, max_orbit_b);
        contents[b].stargates.push(GeneratedStargate {
            name: format!("{b_name} → {a_name} Gate"),
            destination_id: a_id,
            destination_name: a_name,
            orbit: orbit_b,
            phase: phase_b,
        });
    }
}

/// Orbit + phase angle for a stargate at index `gate_index` in system
/// `system_id`. Golden-angle distribution avoids clumping when there are few
/// gates; deterministic hash keeps layout stable across reruns.
///
/// The orbit is derived from `system_max_orbit` (the outermost non-stargate
/// POI) plus [`STARGATE_PADDING`], floored at [`STARGATE_MIN_ORBIT`] for
/// sparse systems. This guarantees gates sit clearly outside the inner POI
/// ring regardless of how busy the system is.
fn stargate_orbit_phase(gate_index: usize, system_id: &str, system_max_orbit: f32) -> (f32, f32) {
    let orbit = (system_max_orbit + STARGATE_PADDING).max(STARGATE_MIN_ORBIT);
    let golden = std::f32::consts::TAU * 0.381_966;
    let base = (system_hash(system_id) % 360) as f32 * std::f32::consts::TAU / 360.0;
    let phase = (base + gate_index as f32 * golden) % std::f32::consts::TAU;
    (orbit, phase)
}

fn system_hash(s: &str) -> u64 {
    let mut h: u64 = 0;
    for byte in s.bytes() {
        h = h.wrapping_mul(31).wrapping_add(byte as u64);
    }
    h
}

// ─── Spawn-time asset cache ─────────────────────────────────────────────────

/// Per-system visual asset cache. One mesh + one material per *kind* of
/// POI; per-entity variation comes from Transform scale and (for asteroids)
/// per-instance material picks from a small palette.
///
/// Built once per galaxy regeneration. Sized meshes are scaled per-entity via
/// `Transform::scale` from a unit primitive; stargate torus meshes use the
/// same trick. Asteroid rocks share a single jittered icosphere mesh.
pub struct ContentAssets {
    // Meshes
    pub planet_mesh: Handle<Mesh>,   // unit sphere
    pub station_mesh: Handle<Mesh>,  // unit cube
    pub anomaly_mesh: Handle<Mesh>,  // low-poly sphere (angular/crystalline)
    pub gas_mesh: Handle<Mesh>,      // unit sphere (used with translucent mat)
    pub stargate_mesh: Handle<Mesh>, // unit torus
    /// Jittered icosphere shared by every asteroid instance across all belts.
    /// Per-rock variation comes from per-instance Transform + material pick.
    pub asteroid_mesh: Handle<Mesh>,

    // Materials
    /// Indexed by `PlanetType as usize`. Length == 7.
    pub planet_materials: Vec<Handle<StandardMaterial>>,
    pub station_material: Handle<StandardMaterial>,
    pub anomaly_material: Handle<StandardMaterial>,
    pub stargate_material: Handle<StandardMaterial>,
    /// Indexed by `GasKind as usize`. Length == 4.
    pub gas_materials: Vec<Handle<StandardMaterial>>,
    /// Greyscale / brown palette indexed by [`GeneratedAsteroid::material_index`].
    /// Length == [`ASTEROID_COLOR_COUNT`] (5).
    pub asteroid_materials: Vec<Handle<StandardMaterial>>,
}

impl ContentAssets {
    pub fn new(meshes: &mut Assets<Mesh>, materials: &mut Assets<StandardMaterial>) -> Self {
        let planet_mesh = meshes.add(Sphere::new(1.0).mesh().ico(2).unwrap());
        let station_mesh = meshes.add(Cuboid::new(1.0, 1.0, 1.0).mesh());
        let anomaly_mesh = meshes.add(Sphere::new(1.0).mesh().ico(1).unwrap());
        let gas_mesh = meshes.add(Sphere::new(1.0).mesh().ico(2).unwrap());
        // Unit torus shared by every stargate. Scaled per entity to a small
        // ring marker (~0.45 final radius) at the gate's orbital position —
        // NOT a giant ring around the star. Chunkier tube (0.18) so the ring
        // still reads at the small marker scale.
        let stargate_mesh = meshes.add(Torus::new(1.0, 0.18).mesh());

        let planet_materials = PlanetType::ALL
            .iter()
            .map(|t| unlit_material(materials, t.color()))
            .collect();
        let gas_materials = vec![
            translucent_material(materials, [0.85, 0.85, 0.95]), // Hydrogen
            translucent_material(materials, [0.95, 0.85, 0.65]), // Helium
            translucent_material(materials, [0.65, 0.85, 0.95]), // Nitrogen
            translucent_material(materials, [0.85, 0.45, 0.95]), // Exotic
        ];

        let asteroid_mesh = build_asteroid_mesh(meshes);
        let asteroid_materials = asteroid_material_palette(materials);

        Self {
            planet_mesh,
            station_mesh,
            anomaly_mesh,
            gas_mesh,
            stargate_mesh,
            asteroid_mesh,
            station_material: unlit_material(materials, [0.13, 0.83, 0.93]),
            anomaly_material: unlit_material(materials, [0.94, 0.63, 0.19]),
            stargate_material: unlit_material(materials, [0.66, 0.33, 0.97]),
            planet_materials,
            gas_materials,
            asteroid_materials,
        }
    }

    fn planet_material(&self, planet_type: PlanetType) -> Handle<StandardMaterial> {
        self.planet_materials[planet_type as usize].clone()
    }

    fn gas_material(&self, kind: GasKind) -> Handle<StandardMaterial> {
        self.gas_materials[kind as usize].clone()
    }
}

// ─── Spawning ───────────────────────────────────────────────────────────────

/// Spawn all POI children inside a [`super::StarSystem`] parent. `star_entity`
/// is the entity ID of the system's [`super::Star`] child — used as the
/// `parent` field of [`Orbital`] so orbital systems can resolve the body
/// being orbited.
pub fn spawn_system_contents(
    parent: &mut ChildSpawnerCommands,
    sys: &SystemContext,
    contents: &SystemContents,
    star_entity: Entity,
    assets: &ContentAssets,
    materials: &mut Assets<StandardMaterial>,
) {
    // ── Planets ─────────────────────────────────────────────────────────────
    for planet in &contents.planets {
        let local = orbital_position(planet.orbit, planet.phase);
        let mut entity = parent.spawn((
            Planet,
            Orbital {
                semi_major_axis: planet.orbit,
                period: planet.period,
                phase: planet.phase,
                parent: star_entity,
            },
            Massive { mass: planet.mass },
            BoundingVolume {
                radius: planet.radius,
            },
            Identifiable {
                id: format!("{}-{}", sys.id, slug(&planet.name)),
                display_name: planet.name.clone(),
                classification: Classification::Celestial,
            },
            Mesh3d(assets.planet_mesh.clone()),
            MeshMaterial3d(assets.planet_material(planet.planet_type)),
            Transform::from_translation(local).with_scale(Vec3::splat(planet.radius)),
        ));
        if planet.population > 0 {
            entity.insert(Inhabited {
                population: planet.population,
            });
        }
        if planet.habitable {
            entity.insert(HabitablePlanet);
        }

        // Add city lights for high-population planets
        if let Some(city_lights_mat) = city_lights_material(materials, planet.planet_type, planet.population) {
            entity.with_children(|planet| {
                planet.spawn((
                    Mesh3d(assets.planet_mesh.clone()),
                    MeshMaterial3d(city_lights_mat),
                    // Slightly larger sphere for city lights glow
                    Transform::from_scale(Vec3::splat(1.02)),
                ));
            });
        }
    }

    // ── Asteroid belts ──────────────────────────────────────────────────────
    // Belt entity = no mesh, just the logical POI (Mineable, Identifiable,
    // BoundingVolume for selection). Individual rock meshes are spawned as
    // children inheriting the (currently static) belt Transform.
    for belt in &contents.belts {
        let mid = (belt.inner_orbit + belt.outer_orbit) * 0.5;
        let half_width = ((belt.outer_orbit - belt.inner_orbit) * 0.5).max(0.05);
        parent
            .spawn((
                Orbital {
                    semi_major_axis: mid,
                    // Belt entity itself is stationary — it's the abstract
                    // mining-site POI at the star. Individual rocks orbit on
                    // their own (see rock spawn below).
                    period: 0.0,
                    phase: 0.0,
                    parent: star_entity,
                },
                // Outer extent of the belt ring from the star (belt entity
                // sits at the star; rocks reach out to `outer_orbit`).
                BoundingVolume {
                    radius: mid + half_width,
                },
                Mineable {
                    yield_remaining: belt.yield_remaining,
                },
                Exhaustible::default(),
                Identifiable {
                    id: format!("{}-belt", sys.id),
                    display_name: belt.name.clone(),
                    classification: Classification::Celestial,
                },
                Transform::default(),
            ))
            .with_children(|belt_parent| {
                for rock in &belt.asteroids {
                    // Each rock orbits the star at its own radius. Inner
                    // rocks complete a revolution faster than outer ones,
                    // producing visible shearing of the belt over time
                    // (mild — linear formula, not Kepler). Y-jitter from
                    // `rock.position.y` is preserved because the orbital
                    // system only overwrites x/z.
                    let rock_radius = Vec2::new(rock.position.x, rock.position.z).length();
                    let rock_phase = rock.position.z.atan2(rock.position.x);
                    belt_parent.spawn((
                        Orbital {
                            semi_major_axis: rock_radius,
                            period: orbital_period(rock_radius, 0.0),
                            phase: rock_phase,
                            parent: star_entity,
                        },
                        Mesh3d(assets.asteroid_mesh.clone()),
                        MeshMaterial3d(assets.asteroid_materials[rock.material_index].clone()),
                        Transform::from_translation(rock.position)
                            .with_scale(Vec3::splat(rock.scale))
                            .with_rotation(rock.rotation),
                    ));
                }
            });
    }

    // ── Stations ────────────────────────────────────────────────────────────
    for station in &contents.stations {
        let local = orbital_position(station.orbit, station.phase);
        let cube_size = 0.16;
        // Use population-based material for visual indicator
        let station_mat = population_station_material(materials, station.population);
        parent.spawn((
            Station,
            Orbital {
                semi_major_axis: station.orbit,
                period: station.period,
                phase: station.phase,
                parent: star_entity,
            },
            BoundingVolume { radius: cube_size },
            Inhabited {
                population: station.population,
            },
            Identifiable {
                id: format!("{}-{}", sys.id, slug(&station.name)),
                display_name: station.name.clone(),
                classification: Classification::Structure,
            },
            Mesh3d(assets.station_mesh.clone()),
            MeshMaterial3d(station_mat),
            Transform::from_translation(local).with_scale(Vec3::splat(cube_size)),
        ));
    }

    // ── Anomalies ───────────────────────────────────────────────────────────
    for anomaly in &contents.anomalies {
        let local = orbital_position(anomaly.orbit, anomaly.phase);
        let radius = 0.14;
        parent.spawn((
            Anomaly,
            Orbital {
                semi_major_axis: anomaly.orbit,
                period: anomaly.period,
                phase: anomaly.phase,
                parent: star_entity,
            },
            BoundingVolume { radius },
            SensorSignature {
                signature_radius: radius,
                signal_type: anomaly.signal_kind,
            },
            Scannable {
                difficulty: anomaly.difficulty,
                requires_probe: anomaly.requires_probe,
            },
            EventTimer {
                remaining: anomaly.remaining,
            },
            Identifiable {
                id: format!("{}-{}", sys.id, slug(&anomaly.name)),
                display_name: anomaly.name.clone(),
                classification: Classification::Anomaly,
            },
            Mesh3d(assets.anomaly_mesh.clone()),
            MeshMaterial3d(assets.anomaly_material.clone()),
            Transform::from_translation(local).with_scale(Vec3::splat(radius)),
        ));
    }

    // ── Stargates ───────────────────────────────────────────────────────────
    // Visual scale of the stargate ring marker — matches the TS reference's
    // `markerSize × 1.25 ≈ 0.4-0.55` range. The unit-torus mesh is scaled to
    // this size at the gate's orbital position, NOT a giant ring around the
    // star. Final torus: major radius ≈ 0.45, tube thickness ≈ 0.08.
    const STARGATE_MARKER_SCALE: f32 = 0.45;
    for gate in &contents.stargates {
        let local = orbital_position(gate.orbit, gate.phase);
        parent.spawn((
            Stargate,
            Orbital {
                semi_major_axis: gate.orbit,
                // Stargates are stationary by design — navigational aids
                // must stay at fixed positions so warp-in routes remain
                // stable across sessions.
                period: 0.0,
                phase: gate.phase,
                parent: star_entity,
            },
            // Outer extent of the marker ring (major + minor tube).
            BoundingVolume {
                radius: STARGATE_MARKER_SCALE * (1.0 + 0.18),
            },
            Transit {
                destination: Some(gate.destination_id.clone()),
                lifetime: None,
            },
            Identifiable {
                id: format!("{}-gate-{}", sys.id, slug(&gate.destination_name)),
                display_name: gate.name.clone(),
                classification: Classification::Structure,
            },
            Mesh3d(assets.stargate_mesh.clone()),
            MeshMaterial3d(assets.stargate_material.clone()),
            // Small ring marker at the gate's orbital position. Scaling by
            // STARGATE_MARKER_SCALE yields a compact jump-gate ring that
            // reads as a distinct POI without dominating the system.
            Transform::from_translation(local)
                .with_scale(Vec3::splat(STARGATE_MARKER_SCALE))
                .with_rotation(Quat::from_rotation_x(std::f32::consts::FRAC_PI_2)),
        ));
    }

    // ── Gas clouds ──────────────────────────────────────────────────────────
    for cloud in &contents.gas_clouds {
        let local = orbital_position(cloud.orbit, cloud.phase);
        let radius = 0.25;
        parent.spawn((
            GasCloud,
            Orbital {
                semi_major_axis: cloud.orbit,
                period: cloud.period,
                phase: cloud.phase,
                parent: star_entity,
            },
            BoundingVolume { radius },
            Harvestable {
                resource: cloud.gas_kind,
                flow_rate: cloud.flow_rate,
            },
            Exhaustible::default(),
            Identifiable {
                id: format!("{}-{}", sys.id, slug(&cloud.name)),
                display_name: cloud.name.clone(),
                classification: Classification::Anomaly,
            },
            Mesh3d(assets.gas_mesh.clone()),
            MeshMaterial3d(assets.gas_material(cloud.gas_kind)),
            Transform::from_translation(local).with_scale(Vec3::splat(radius)),
        ));
    }
}

/// Initial local-space position of an orbiting body at `t=0`.
/// Orbits are in the XZ plane (Y up).
fn orbital_position(orbit: f32, phase: f32) -> Vec3 {
    Vec3::new(phase.cos() * orbit, 0.0, phase.sin() * orbit)
}

/// Build the single shared asteroid mesh: a subdivided icosphere with
/// per-vertex radial jitter, giving it an irregular rocky silhouette.
///
/// One mesh is shared across every asteroid instance (one asset handle for
/// thousands of entities). Variation comes from per-entity Transform (scale,
/// rotation) and material pick. Deterministic LCG seed so the shared shape is
/// stable across runs.
fn build_asteroid_mesh(meshes: &mut Assets<Mesh>) -> Handle<Mesh> {
    let mut mesh = Sphere::new(1.0).mesh().ico(2).unwrap();

    // Numerical Recipes LCG — fast, deterministic, no extra crate deps.
    let mut state: u32 = 0xcafe_1234;
    let next_unit = |state: &mut u32| -> f32 {
        *state = state.wrapping_mul(1664525).wrapping_add(1013904223);
        (*state as f32) / (u32::MAX as f32) * 2.0 - 1.0 // [-1, 1]
    };

    if let Some(VertexAttributeValues::Float32x3(positions)) =
        mesh.attribute_mut(Mesh::ATTRIBUTE_POSITION)
    {
        for pos in positions.iter_mut() {
            // Per-vertex radial jitter (±25%) — keeps the silhouette convex
            // and irregular without degenerating into a noise blob.
            let len = (pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2]).sqrt();
            if len > 1e-6 {
                let factor = 1.0 + next_unit(&mut state) * 0.25;
                pos[0] *= factor;
                pos[1] *= factor;
                pos[2] *= factor;
            }
        }
    }

    meshes.add(mesh)
}

/// Greyscale / brown palette for asteroid instances. Indexed by
/// [`GeneratedAsteroid::material_index`] which is sampled per-rock from
/// `0..ASTEROID_COLOR_COUNT`.
fn asteroid_material_palette(
    materials: &mut Assets<StandardMaterial>,
) -> Vec<Handle<StandardMaterial>> {
    const PALETTE: [[f32; 3]; ASTEROID_COLOR_COUNT] = [
        [0.50, 0.46, 0.40], // warm grey
        [0.42, 0.40, 0.38], // neutral grey
        [0.55, 0.50, 0.43], // tan
        [0.38, 0.36, 0.40], // cool grey
        [0.48, 0.42, 0.36], // brown-grey
    ];
    PALETTE
        .iter()
        .map(|c| unlit_material(materials, *c))
        .collect()
}

fn unlit_material(
    materials: &mut Assets<StandardMaterial>,
    [r, g, b]: [f32; 3],
) -> Handle<StandardMaterial> {
    materials.add(StandardMaterial {
        base_color: Color::srgb(r, g, b),
        emissive: LinearRgba::new(r * 1.4, g * 1.4, b * 1.4, 1.0),
        unlit: true,
        ..default()
    })
}

fn translucent_material(
    materials: &mut Assets<StandardMaterial>,
    [r, g, b]: [f32; 3],
) -> Handle<StandardMaterial> {
    materials.add(StandardMaterial {
        base_color: Color::srgba(r, g, b, 0.45),
        emissive: LinearRgba::new(r * 1.2, g * 1.2, b * 1.2, 0.6),
        unlit: true,
        alpha_mode: AlphaMode::Blend,
        ..default()
    })
}

/// Create a population-based emissive material for stations.
/// Higher population = brighter, more cyan glow (civilization lights).
fn population_station_material(
    materials: &mut Assets<StandardMaterial>,
    population: u32,
) -> Handle<StandardMaterial> {
    // Base cyan color for stations
    let (base_r, base_g, base_b) = (0.13, 0.83, 0.93);

    // Scale emissive intensity by population (logarithmic scale)
    // Pop 10k -> 1.0x, Pop 10M -> 2.5x
    let pop_factor = 1.0 + (population as f32).log10() * 0.5;
    let intensity = (pop_factor * 1.4).min(3.5);

    materials.add(StandardMaterial {
        base_color: Color::srgb(base_r, base_g, base_b),
        emissive: LinearRgba::new(base_r * intensity, base_g * intensity, base_b * intensity, 1.0),
        unlit: true,
        ..default()
    })
}

/// Create city lights material for high-population planets.
/// Returns Some material if population > 100,000, None otherwise.
fn city_lights_material(
    materials: &mut Assets<StandardMaterial>,
    planet_type: PlanetType,
    population: u32,
) -> Option<Handle<StandardMaterial>> {
    // Only show city lights on high-population habitable worlds
    const CITY_LIGHTS_THRESHOLD: u32 = 100_000;
    if population < CITY_LIGHTS_THRESHOLD {
        return None;
    }

    // Warm yellow-white lights for cities
    let (r, g, b) = (1.0, 0.95, 0.8);
    // Scale by population
    let pop_factor = 1.0 + (population as f32).log10() * 0.3;
    let intensity = (pop_factor * 0.8).min(2.0).min(population as f32 / 500_000.0);

    Some(materials.add(StandardMaterial {
        base_color: Color::srgba(r, g, b, 0.0), // Transparent base
        emissive: LinearRgba::new(r * intensity, g * intensity, b * intensity, 1.0),
        unlit: true,
        alpha_mode: AlphaMode::Blend,
        ..default()
    }))
}

/// Slugify a display name for use as part of an [`Identifiable::id`]:
/// lowercase, spaces → hyphens. Good enough for stable, human-readable IDs.
fn slug(s: &str) -> String {
    s.chars()
        .map(|c| {
            if c.is_whitespace() {
                '-'
            } else {
                c.to_ascii_lowercase()
            }
        })
        .collect()
}

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

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

    fn ctx<'a>(id: &'a str, name: &'a str, security: f32) -> SystemContext<'a> {
        SystemContext {
            id,
            name,
            faction: "Concord",
            security,
            is_core: false,
        }
    }

    #[test]
    fn generation_is_deterministic_for_same_seed() {
        let mut rng_a = StdRng::seed_from_u64(42);
        let mut rng_b = StdRng::seed_from_u64(42);
        let c = ctx("g-0", "COR-100", 0.9);
        let a = generate_system_contents(&mut rng_a, &c);
        let b = generate_system_contents(&mut rng_b, &c);
        assert_eq!(a.planets.len(), b.planets.len());
        assert_eq!(
            a.planets.first().map(|p| p.name.clone()),
            b.planets.first().map(|p| p.name.clone())
        );
    }

    #[test]
    fn low_sec_systems_spawn_anomalies() {
        let mut rng = StdRng::seed_from_u64(7);
        let c = ctx("g-1", "XYZ-101", 0.1);
        let contents = generate_system_contents(&mut rng, &c);
        assert!(
            !contents.anomalies.is_empty(),
            "low-sec should have anomalies"
        );
    }

    #[test]
    fn high_sec_systems_spawn_no_anomalies() {
        let mut rng = StdRng::seed_from_u64(7);
        let c = ctx("g-2", "COR-101", 0.95);
        let contents = generate_system_contents(&mut rng, &c);
        assert!(
            contents.anomalies.is_empty(),
            "high-sec should have no anomalies"
        );
    }

    #[test]
    fn planet_orbits_are_strictly_increasing() {
        let mut rng = StdRng::seed_from_u64(99);
        let c = ctx("g-3", "ABC-100", 0.8);
        let contents = generate_system_contents(&mut rng, &c);
        for w in contents.planets.windows(2) {
            assert!(w[1].orbit > w[0].orbit, "orbits must increase");
        }
    }

    #[test]
    fn planet_type_colors_are_distinct() {
        for (i, a) in PlanetType::ALL.iter().enumerate() {
            for b in &PlanetType::ALL[(i + 1)..] {
                assert_ne!(a.color(), b.color(), "{:?} and {:?} share a color", a, b);
            }
        }
    }

    #[test]
    fn stargate_orbit_phase_is_deterministic() {
        let a = stargate_orbit_phase(0, "g-0", 5.0);
        let b = stargate_orbit_phase(0, "g-0", 5.0);
        assert_eq!(a, b);
    }

    #[test]
    fn stargate_orbit_phase_advances_with_index() {
        let a = stargate_orbit_phase(0, "g-0", 5.0);
        let b = stargate_orbit_phase(1, "g-0", 5.0);
        assert_ne!(a.1, b.1);
    }

    #[test]
    fn stargate_orbit_respects_system_max() {
        // Sparse system: max_orbit below the min → gates sit at min.
        let (orbit_sparse, _) = stargate_orbit_phase(0, "g-0", 2.0);
        assert!(orbit_sparse >= STARGATE_MIN_ORBIT);

        // Busy system: max_orbit pushes gates beyond the min.
        let (orbit_busy, _) = stargate_orbit_phase(0, "g-0", 12.0);
        assert!((orbit_busy - (12.0 + STARGATE_PADDING)).abs() < 1e-6);
    }

    #[test]
    fn max_orbit_returns_outermost_poi() {
        let mut c = SystemContents::default();
        assert_eq!(c.max_orbit(), 0.0);
        c.planets.push(GeneratedPlanet {
            name: "P1".into(),
            planet_type: PlanetType::Rocky,
            orbit: 3.0,
            period: 1.0,
            phase: 0.0,
            radius: 0.1,
            mass: 1.0,
            population: 0,
            habitable: false,
        });
        c.stations.push(GeneratedStation {
            name: "S1".into(),
            orbit: 6.0,
            phase: 0.0,
            period: 0.0,
            population: 0,
        });
        assert_eq!(c.max_orbit(), 6.0);
        c.belts.push(GeneratedAsteroidBelt {
            name: "B1".into(),
            inner_orbit: 7.0,
            outer_orbit: 8.5,
            yield_remaining: 0.0,
            asteroids: Vec::new(),
        });
        assert_eq!(c.max_orbit(), 8.5);
    }

    #[test]
    fn stargates_pair_across_connections() {
        let summaries = vec![
            SystemSummary {
                id: "g-0".into(),
                name: "Sol".into(),
            },
            SystemSummary {
                id: "g-1".into(),
                name: "Amarr".into(),
            },
        ];
        let mut contents = vec![SystemContents::default(), SystemContents::default()];
        generate_stargates(&summaries, &mut contents, &[(0, 1)]);
        assert_eq!(contents[0].stargates.len(), 1);
        assert_eq!(contents[1].stargates.len(), 1);
        assert_eq!(contents[0].stargates[0].destination_id, "g-1");
        assert_eq!(contents[1].stargates[0].destination_id, "g-0");
    }

    #[test]
    fn romanize_handles_basics() {
        assert_eq!(romanize(1), "I");
        assert_eq!(romanize(4), "IV");
        assert_eq!(romanize(9), "IX+");
    }

    #[test]
    fn slug_replaces_spaces_and_lowercases() {
        assert_eq!(slug("Jita Hub"), "jita-hub");
        assert_eq!(slug("COR-100 II"), "cor-100-ii");
    }

    #[test]
    fn system_hash_is_stable() {
        assert_eq!(system_hash("g-0"), system_hash("g-0"));
        assert_ne!(system_hash("g-0"), system_hash("g-1"));
    }

    #[test]
    fn orbital_period_grows_linearly_with_radius() {
        // No jitter → period = 2.5 × orbit × scale.
        let scale = ORBITAL_PERIOD_SCALE;
        assert!((orbital_period(0.0, 0.0) - MIN_ORBITAL_PERIOD).abs() < 1e-6);
        assert!((orbital_period(2.0, 0.0) - 5.0 * scale).abs() < 1e-5);
        assert!((orbital_period(8.0, 0.0) - 20.0 * scale).abs() < 1e-5);
    }

    #[test]
    fn orbital_period_floors_at_minimum() {
        // Very tight orbit + negative jitter still respects the floor.
        assert!((orbital_period(0.1, -10.0) - MIN_ORBITAL_PERIOD).abs() < 1e-6);
        assert!((orbital_period(0.0, 0.0) - MIN_ORBITAL_PERIOD).abs() < 1e-6);
    }

    #[test]
    fn orbital_period_applies_jitter_additively() {
        // Jitter is added before the scale, so the delta is jitter × scale.
        let base = orbital_period(5.0, 0.0);
        let jittered = orbital_period(5.0, 2.0);
        assert!((jittered - base - 2.0 * ORBITAL_PERIOD_SCALE).abs() < 1e-5);
    }

    #[test]
    fn asteroid_generation_is_deterministic() {
        let mut rng_a = StdRng::seed_from_u64(123);
        let mut rng_b = StdRng::seed_from_u64(123);
        let a = generate_asteroids(&mut rng_a, 5.0, 6.0);
        let b = generate_asteroids(&mut rng_b, 5.0, 6.0);
        assert_eq!(a.len(), b.len());
        assert_eq!(a.len(), ASTEROIDS_PER_BELT);
        assert_eq!(a[0].position, b[0].position);
        assert_eq!(a[0].scale, b[0].scale);
        assert_eq!(a[0].material_index, b[0].material_index);
    }

    #[test]
    fn asteroid_positions_stay_inside_annulus() {
        let mut rng = StdRng::seed_from_u64(7);
        let inner = 5.0_f32;
        let outer = 6.0_f32;
        for rock in generate_asteroids(&mut rng, inner, outer) {
            let horizontal = Vec3::new(rock.position.x, 0.0, rock.position.z).length();
            assert!(
                horizontal >= inner && horizontal <= outer,
                "rock at {:?} outside annulus [{}, {}]",
                rock.position,
                inner,
                outer
            );
        }
    }
}
