//! Galaxy inspection scene.
//!
//! Procedural spiral galaxy viewer with editable parameters. The 3D scene is
//! regenerated whenever [`GalaxyParams`] changes (via the `generation` counter);
//! click-to-select is handled in [`super::selection`]; the slider/info panels
//! live in [`super::ui`].

mod axes;
pub mod contents;
pub mod orbits;
mod params;
pub mod poi;
mod selection;
mod ui;

pub use poi::*;

use bevy::prelude::*;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};

use crate::camera::apply_orbit_reset;
use crate::gameplay::campaign::{GeneratedGalaxy, GeneratedGalaxySystem};
use crate::state::AppState;

pub use contents::{GeneratedPlanet, GeneratedStation, SystemContents, SystemContext, SystemSummary};
pub use params::{BeamParams, CoreParams, DiskParams};
pub use params::{GalaxyParams, SelectedStar};
use params::{MIN_SYSTEM_SPACING, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS};

pub struct GalaxyPlugin;

impl Plugin for GalaxyPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<GalaxyParams>()
            .init_resource::<SelectedStar>()
            .add_systems(
                OnEnter(AppState::Galaxy),
                (setup_galaxy_scene, ui::setup_galaxy_ui),
            )
            .add_systems(OnExit(AppState::Galaxy), (despawn_galaxy, reset_selection))
            .add_systems(
                Update,
                (
                    escape_to_main_menu,
                    ui::rebuild_scroll_content,
                    ui::param_button_handler,
                    ui::refresh_control_panel_values,
                    ui::scroll_control_panel,
                    ui::reset_view_button_handler,
                    regenerate_galaxy_on_param_change,
                    selection::select_star_on_click,
                    selection::animate_selected_star,
                    selection::refresh_info_panel,
                    apply_orbit_reset,
                    orbits::advance_orbital_paths,
                )
                    .chain()
                    .run_if(in_state(AppState::Galaxy)),
            );
    }
}

// ── Markers ─────────────────────────────────────────────────────────────────

/// Tag for *anything* spawned during galaxy creation so it can be cleanly
/// despawned on state exit. Applies to both 3D scene roots and UI panel roots.
#[derive(Component)]
pub struct GalaxySpawned;

/// Tag for the 3D scene root only — despawned on regeneration, so we can
/// rebuild the galaxy without disturbing the UI panels.
#[derive(Component)]
pub struct GalaxyScene;

/// A star system in the procedural galaxy. Acts as a parent entity for the
/// system's [`Star`] (visual marker) and all POI children (planets, belts,
/// stations, anomalies, stargates, gas clouds).
#[derive(Component, Debug)]
pub struct StarSystem {
    pub id: String,
    pub name: String,
    pub faction: &'static str,
    pub security: f32,
    /// Cached POI count for quick display in the info panel without walking
    /// the child hierarchy. Updated whenever [`contents::SystemContents`] is
    /// regenerated.
    pub poi_count: usize,
}

/// The anchor celestial of a star system — its primary star. Distinct from
/// POIs (which are destinations within a system): a star is the body the
/// system is named after and the parent that planets/moons/belts orbit.
///
/// Requires components that describe its physical nature; mass-locks nearby
/// warp drives, emits light (used to compute habitable zones for orbiting
/// planets).
#[derive(Component, Debug, Clone, Copy, Reflect)]
#[reflect(Component)]
#[require(Massive, Luminosity, MassLock, BoundingVolume, Identifiable)]
pub struct Star;

// POI components, markers, and events live in [`poi`] (re-exported as `pub use`).

// ── Faction palette (matches docs GalaxyScene) ──────────────────────────────

const FACTIONS: &[(&str, [f32; 3])] = &[
    ("Concord", [0.13, 0.83, 0.93]),  // cyan — core faction
    ("Amarr", [0.96, 0.62, 0.04]),    // amber
    ("Minmatar", [0.94, 0.27, 0.27]), // red
    ("Gallente", [0.66, 0.33, 0.97]), // purple
    ("Caldari", [0.22, 0.74, 0.97]),  // blue
];

#[derive(Debug, Clone)]
pub struct StartingBaseCandidate {
    pub id: String,
    pub name: String,
    pub faction: &'static str,
    pub security: f32,
    pub distance_from_core: f32,
    pub contents: SystemContents,
}

#[derive(Debug, Clone)]
pub struct StartingBaseMapSystem {
    pub id: String,
    pub position: Vec3,
    pub color: [f32; 3],
    pub candidate_index: Option<usize>,
}

#[derive(Debug, Clone)]
pub struct StartingBaseMap {
    pub systems: Vec<StartingBaseMapSystem>,
    pub candidates: Vec<StartingBaseCandidate>,
    pub connections: Vec<(usize, usize)>,
}

// ── Setup ───────────────────────────────────────────────────────────────────

fn setup_galaxy_scene(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    params: Res<GalaxyParams>,
) {
    let galaxy = generate_galaxy(&params);
    spawn_galaxy_scene(
        &mut commands,
        &mut meshes,
        &mut materials,
        &galaxy.systems,
        &galaxy.contents,
        &galaxy.connections,
    );
}

/// Generate the full galaxy layout (pure data — no Bevy types).
///
/// Faithful port of the TS reference in
/// `apps/docs/src/prototypes/r3f/galaxy/GalaxyScene.tsx::generateGalaxy`,
/// extended with per-system POI generation (planets, belts, stations,
/// anomalies, gas clouds, stargates).
///
/// Returns a reusable generated galaxy. The contents are returned in parallel
/// with the systems (same index) so the spawner can attach them as children of
/// each [`StarSystem`] entity and later setup screens can reuse the same data.
pub fn generate_galaxy(params: &GalaxyParams) -> GeneratedGalaxy {
    let mut rng = StdRng::seed_from_u64(params.seed);
    let systems = generate_system_positions(params, &mut rng);
    let connections = build_connections(&systems);

    // Per-system contents (planets, belts, stations, anomalies, gas clouds).
    let mut contents: Vec<SystemContents> = systems
        .iter()
        .map(|sys| {
            let ctx = SystemContext {
                id: &sys.id,
                name: &sys.name,
                faction: sys.faction,
                security: sys.security,
                is_core: sys.is_core,
            };
            contents::generate_system_contents(&mut rng, &ctx)
        })
        .collect();

    // Stargates — paired across each connection.
    let summaries: Vec<SystemSummary> = systems
        .iter()
        .map(|sys| SystemSummary {
            id: sys.id.clone(),
            name: sys.name.clone(),
        })
        .collect();
    contents::generate_stargates(&summaries, &mut contents, &connections);

    let systems = systems
        .into_iter()
        .map(|system| GeneratedGalaxySystem {
            id: system.id,
            name: system.name,
            position: system.position,
            faction: system.faction,
            faction_index: system.faction_index,
            color: system.color,
            security: system.security,
            is_core: system.is_core,
            is_beam: matches!(system.origin, SystemOrigin::Beam { .. }),
        })
        .collect();

    GeneratedGalaxy {
        params: params.clone(),
        systems,
        contents,
        connections,
    }
}

impl GeneratedGalaxy {
    pub fn starting_base_map(&self) -> StartingBaseMap {
        let mut candidates = Vec::new();
        let mut map_systems = Vec::with_capacity(self.systems.len());

        if self.systems.len() != self.contents.len() {
            bevy::log::error!(
                "Saved galaxy has mismatched systems/content arrays: systems={}, contents={}",
                self.systems.len(),
                self.contents.len()
            );
            return StartingBaseMap {
                systems: map_systems,
                candidates,
                connections: Vec::new(),
            };
        }

        for (system, contents) in self.systems.iter().zip(self.contents.iter()) {
            let distance_from_core = system.position.length();
            // A system is valid for starting if it has:
            // - At least one station (stations are always dockable)
            // - OR at least one inhabited planet (population > 0)
            let has_dockable = !contents.stations.is_empty()
                || contents.planets.iter().any(|p| p.population > 0);

            if !system.is_core && has_dockable {
                candidates.push(StartingBaseCandidate {
                    id: system.id.clone(),
                    name: system.name.clone(),
                    faction: system.faction,
                    security: system.security,
                    distance_from_core,
                    contents: contents.clone(),
                });
            }

            map_systems.push(StartingBaseMapSystem {
                id: system.id.clone(),
                position: system.position,
                color: system.color,
                candidate_index: None,
            });
        }

        candidates.sort_by(|a, b| {
            b.distance_from_core
                .partial_cmp(&a.distance_from_core)
                .unwrap_or(std::cmp::Ordering::Equal)
                .then_with(|| a.name.cmp(&b.name))
        });

        for (index, candidate) in candidates.iter().enumerate() {
            if let Some(system) = map_systems
                .iter_mut()
                .find(|system| system.id == candidate.id)
            {
                system.candidate_index = Some(index);
            }
        }

        StartingBaseMap {
            systems: map_systems,
            candidates,
            connections: self.connections.clone(),
        }
    }
}

/// Position-only galaxy generation. Composes multiple structural layers —
/// a [`CoreParams`] cluster, up to [`MAX_DISKS`] independent [`DiskParams`]
/// spiral disks, and two [`BeamParams`] columns along ±Y — into a single
/// flat list of systems ready for the connection graph and the spawner.
///
/// Each layer owns its own system count; the total galaxy population is
/// `core.count + Σ disks[].count + beam_top.count + beam_bottom.count`.
/// System indices are global across all layers (used for `g-{n}` IDs and
/// name suffixes) so the connection graph and POI generator see one
/// consistent vector.
fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec<GeneratedSystem> {
    // Total budget — pre-allocation only; each pass appends as many systems
    // as it manages to place.
    let disk_total: usize = params.disks.iter().map(|d| d.count).sum();
    let total = params.core.count + disk_total + params.beam_top.count + params.beam_bottom.count;
    let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(total);

    // Spacing scales with overall density: tighter for crowded galaxies,
    // looser for sparse ones. Same heuristic as the previous single-pass
    // generator, but keyed off the combined layer count.
    let base_spacing = (params.size / (total.max(1) as f32).sqrt() * 1.25).clamp(9.0, 24.0);

    // Counter shared across passes for `g-{n}` IDs and name suffixes.
    let mut next_index = 0usize;

    generate_core(
        &mut systems,
        rng,
        &params.core,
        params.size,
        base_spacing,
        &mut next_index,
    );

    for (disk_index, disk) in params.disks.iter().enumerate() {
        generate_disk(
            &mut systems,
            rng,
            disk,
            params.size,
            base_spacing,
            &mut next_index,
            disk_index,
        );
    }

    if params.beam_top.enabled {
        generate_beam(
            &mut systems,
            rng,
            &params.beam_top,
            BeamSide::Top,
            params.size,
            base_spacing,
            &mut next_index,
        );
    }
    if params.beam_bottom.enabled {
        generate_beam(
            &mut systems,
            rng,
            &params.beam_bottom,
            BeamSide::Bottom,
            params.size,
            base_spacing,
            &mut next_index,
        );
    }

    systems
}

/// Which side of the galactic plane a beam occupies.
enum BeamSide {
    Top,
    Bottom,
}

/// Generate the Concord core cluster near the origin. Spherically sampled:
/// uniform on a disk (XZ) plus modest Y jitter so the core has thickness.
fn generate_core(
    systems: &mut Vec<GeneratedSystem>,
    rng: &mut StdRng,
    core: &CoreParams,
    galaxy_size: f32,
    base_spacing: f32,
    next_index: &mut usize,
) {
    for _ in 0..core.count {
        let mut position = Option::<Vec3>::None;
        for attempt in 0..SPACING_ATTEMPTS {
            let r = rng.gen::<f32>().sqrt() * core.radius;
            let theta = rng.gen::<f32>() * std::f32::consts::TAU;
            // Modest vertical thickness — 20% of core radius — so the core
            // reads as a 3D blob rather than a flat puck.
            let y = (rng.gen::<f32>() - 0.5) * core.radius * 0.4;
            let candidate = Vec3::new(r * theta.cos(), y, r * theta.sin());
            let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING) * 0.9;
            if systems
                .iter()
                .all(|s| s.position.distance(candidate) >= spacing)
            {
                position = Some(candidate);
                break;
            }
        }
        let Some(position) = position else {
            // Could not find a clear spot — skip this system rather than
            // placing it at the origin on top of other systems.
            continue;
        };
        let final_radius = position.length();
        // Core is always Concord (faction_index 0).
        let (faction, color) = FACTIONS[0];
        let security = security_for_radius(final_radius, galaxy_size);
        let idx = *next_index;
        *next_index += 1;
        systems.push(GeneratedSystem {
            id: format!("g-{idx}"),
            name: format!("COR-{}", 100 + idx),
            position,
            faction,
            faction_index: 0,
            color,
            security,
            is_core: true,
            origin: SystemOrigin::Core,
        });
    }
}

/// Generate one disk layer of spiral arms. Systems sweep continuously from
/// the origin to `galaxy_size` with a `powf(0.62)` density bias packing them
/// near the core — no inner/outer radius gap, so arms flow naturally from the
/// dense center outward. Supports tilt (rotation around X, capped at 45°) and
/// Y-axis rotation offset.
fn generate_disk(
    systems: &mut Vec<GeneratedSystem>,
    rng: &mut StdRng,
    disk: &DiskParams,
    galaxy_size: f32,
    base_spacing: f32,
    next_index: &mut usize,
    disk_index: usize,
) {
    let arm_count = disk.arms.max(1);

    // Pre-compute the rotation quaternion for this disk's tilt + Y rotation.
    // Tilt is applied first (around X), then the Y rotation offset.
    let disk_rotation = if disk.tilt.abs() > 0.001 || disk.rotation_offset.abs() > 0.001 {
        let tilt = Quat::from_rotation_x(disk.tilt);
        let yaw = Quat::from_rotation_y(disk.rotation_offset);
        Some(yaw * tilt)
    } else {
        None
    };

    for i in 0..disk.count {
        let arm = (i as u32) % arm_count;
        // Non-core disk factions cycle through Amarr/Minmatar/Gallente/Caldari.
        // Offset by disk_index so different disks don't all share the same
        // faction assignment pattern.
        let faction_index = 1 + ((arm as usize + disk_index) % (FACTIONS.len() - 1));
        let (faction, color) = FACTIONS[faction_index];

        let mut position = Option::<Vec3>::None;
        let mut final_radius = 0.0f32;
        for attempt in 0..SPACING_ATTEMPTS {
            let r = rng.gen::<f32>().powf(0.62) * galaxy_size;
            let angle = std::f32::consts::TAU * arm as f32 / arm_count as f32
                + (r / galaxy_size) * disk.twist
                + (rng.gen::<f32>() - 0.5) * arm_scatter(arm_count);
            let y = (rng.gen::<f32>() - 0.5) * 20.0;
            let mut candidate = Vec3::new(angle.cos() * r, y, angle.sin() * r);

            // Apply tilt + rotation for non-flat disks.
            if let Some(rot) = disk_rotation {
                candidate = rot * candidate;
            }

            final_radius = candidate.length();
            let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING);
            if systems
                .iter()
                .all(|s| s.position.distance(candidate) >= spacing)
            {
                position = Some(candidate);
                break;
            }
        }

        let Some(position) = position else {
            continue;
        };

        let security = security_for_radius(final_radius, galaxy_size);
        let idx = *next_index;
        *next_index += 1;
        systems.push(GeneratedSystem {
            id: format!("g-{idx}"),
            name: format!("{}-{}", &faction[..3].to_uppercase(), 100 + idx),
            position,
            faction,
            faction_index,
            color,
            security,
            is_core: false,
            origin: SystemOrigin::Disk {
                disk: disk_index,
                arm,
            },
        });
    }
}

/// Generate one beam (top or bottom) along the Y axis. Height-first
/// sampling produces a tapered cone: `t` is chosen first (biased toward
/// the base via `pow(0.62)`), then the cross-section radius at that height
/// is `thickness × (1 − t)^taper`. The `gravity` exponent concentrates
/// systems toward the Y axis within each cross-section, producing a
/// denser-brighter core. The result is a Vercidium-style diffraction
/// spike: bright and wide near the galactic center, thinning to nothing
/// at the tips.
fn generate_beam(
    systems: &mut Vec<GeneratedSystem>,
    rng: &mut StdRng,
    beam: &BeamParams,
    side: BeamSide,
    galaxy_size: f32,
    base_spacing: f32,
    next_index: &mut usize,
) {
    let sign = match side {
        BeamSide::Top => 1.0,
        BeamSide::Bottom => -1.0,
    };
    let name_prefix = match side {
        BeamSide::Top => "BMT",
        BeamSide::Bottom => "BMB",
    };
    let origin_tag = match side {
        BeamSide::Top => BeamTag::Top,
        BeamSide::Bottom => BeamTag::Bottom,
    };

    for i in 0..beam.count {
        // Cycle through non-Concord factions so the beam isn't a single color.
        let faction_index = 1 + (i as usize) % (FACTIONS.len() - 1);
        let (faction, color) = FACTIONS[faction_index];

        let mut position = Option::<Vec3>::None;
        let mut final_radius = 0.0f32;
        for attempt in 0..SPACING_ATTEMPTS {
            // Height-first sampling: pick t ∈ [0, 1] biased toward the base
            // (t=0 is the galactic plane, t=1 is the tip). `pow(0.62)` packs
            // ~62% of systems in the lower half of the beam.
            let t = rng.gen::<f32>().powf(0.62);
            let y = sign * t * beam.length;

            // Tapered cross-section: radius shrinks with height as
            // `thickness × (1 − t)^taper`. taper=1 → linear cone,
            // taper>1 → sharper spike, taper<1 → bulbous.
            let max_r = beam.thickness * (1.0 - t).powf(beam.taper);

            // Radial position within the cross-section, biased toward the
            // axis by the gravity exponent. Higher gravity → tighter core.
            let r = rng.gen::<f32>().powf(beam.gravity) * max_r;
            let theta = rng.gen::<f32>() * std::f32::consts::TAU;

            // Small vertical jitter so the beam doesn't look like a perfect
            // cone when viewed from the side.
            let y_jitter = (rng.gen::<f32>() - 0.5) * 4.0;
            let candidate = Vec3::new(r * theta.cos(), y + y_jitter, r * theta.sin());
            final_radius = candidate.length();
            let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING);
            if systems
                .iter()
                .all(|s| s.position.distance(candidate) >= spacing)
            {
                position = Some(candidate);
                break;
            }
        }

        let Some(position) = position else {
            continue;
        };

        let security = security_for_radius(final_radius, galaxy_size);
        let idx = *next_index;
        *next_index += 1;
        systems.push(GeneratedSystem {
            id: format!("g-{idx}"),
            name: format!("{name_prefix}-{}", 100 + idx),
            position,
            faction,
            faction_index,
            color,
            security,
            is_core: false,
            origin: SystemOrigin::Beam { side: origin_tag },
        });
    }
}

/// Angular scatter width per system, scaled to the inter-arm gap.
/// Each arm occupies `TAU / arm_count` radians; systems scatter within
/// ~30% of that gap so arms remain visually distinct regardless of
/// how many there are. Single-arm disks use a fixed wide scatter
/// since there are no adjacent arms to bleed into.
fn arm_scatter(arm_count: u32) -> f32 {
    if arm_count <= 1 {
        // No adjacent arms — generous scatter for a natural-looking blob.
        1.5
    } else {
        std::f32::consts::TAU / arm_count as f32 * 0.30
    }
}

/// Spacing relaxation curve — same shape as the previous single-pass
/// generator: full spacing for the first 60 attempts, 82% until attempt
/// 120, then 68% as a fallback to guarantee placement.
fn relax_spacing(base: f32, attempt: usize) -> f32 {
    if attempt > 120 {
        base * 0.68
    } else if attempt > 60 {
        base * 0.82
    } else {
        base
    }
}

/// Security falls off linearly with distance from the origin. The same
/// formula is used for all layers (core, disk, beams) — a beam system at
/// the tip is far from the capital, so it should read as low-sec.
fn security_for_radius(radius: f32, galaxy_size: f32) -> f32 {
    ((1.0 - (radius / galaxy_size) * 2.0) * 100.0).round() / 100.0
}

/// Which structural layer a system belongs to. Currently informational —
/// nothing keys off it yet — but reserved for per-layer styling, POI
/// biasing, or weighted connection graphs in later iterations.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
enum SystemOrigin {
    Core,
    Disk {
        /// Which disk layer this system belongs to (index into [`GalaxyParams::disks`]).
        disk: usize,
        /// Which arm within the disk.
        arm: u32,
    },
    Beam {
        side: BeamTag,
    },
}

#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
enum BeamTag {
    Top,
    Bottom,
}

struct GeneratedSystem {
    id: String,
    name: String,
    position: Vec3,
    faction: &'static str,
    faction_index: usize,
    /// Per-system tint; currently the renderer uses per-faction materials indexed
    /// by `faction_index`, but this is kept for future per-system variation.
    color: [f32; 3],
    security: f32,
    /// True for Concord core systems (see [`CoreParams`]). Used by
    /// [`contents::generate_system_contents`] to bias planet / station counts
    /// toward core systems, and by the spawner for visual differentiation.
    is_core: bool,
    /// Which structural layer this system came from. Informational — reserved
    /// for future per-layer styling or POI biasing.
    #[allow(dead_code)]
    origin: SystemOrigin,
}

fn spawn_galaxy_scene(
    commands: &mut Commands,
    meshes: &mut Assets<Mesh>,
    materials: &mut Assets<StandardMaterial>,
    systems: &[GeneratedGalaxySystem],
    contents: &[SystemContents],
    connections: &[(usize, usize)],
) {
    debug_assert_eq!(
        systems.len(),
        contents.len(),
        "systems and contents must be parallel arrays of the same length"
    );

    // Shared star visuals (Bevy 0.16 required-components model — no bundle needed).
    // Unit spheres, scaled per-faction at the spawn site.
    let star_mesh = meshes.add(Sphere::new(1.0).mesh().ico(3).unwrap());
    let core_star_mesh = meshes.add(Sphere::new(1.0).mesh().ico(4).unwrap());

    let faction_materials: Vec<Handle<StandardMaterial>> = FACTIONS
        .iter()
        .map(|(_, rgb)| {
            materials.add(StandardMaterial {
                base_color: Color::srgb(rgb[0], rgb[1], rgb[2]),
                emissive: LinearRgba::new(rgb[0] * 1.6, rgb[1] * 1.6, rgb[2] * 1.6, 1.0),
                unlit: true,
                ..default()
            })
        })
        .collect();
    // Beam material — distinct bright white-blue tint so beams read as
    // separate structural features from the disk.
    let beam_material = materials.add(StandardMaterial {
        base_color: Color::srgb(0.85, 0.88, 1.0),
        emissive: LinearRgba::new(1.2, 1.25, 1.5, 1.0),
        unlit: true,
        ..default()
    });
    let connection_material = materials.add(StandardMaterial {
        base_color: Color::srgb(0.11, 0.16, 0.25),
        emissive: LinearRgba::new(0.08, 0.13, 0.22, 1.0),
        unlit: true,
        ..default()
    });

    // Cached POI meshes/materials (one per type — entities scale via Transform).
    let content_assets = contents::ContentAssets::new(meshes, materials);

    // Parent group so all galaxy contents despawn together.
    commands
        .spawn((
            Transform::default(),
            Visibility::default(),
            InheritedVisibility::default(),
            GalaxyScene,
            GalaxySpawned,
        ))
        .with_children(|parent| {
            // XYZ reference axes through the origin.
            axes::spawn_axes(parent, meshes, materials);

            // Star systems — each is a parent entity that owns its Star + POI children.
            for (sys, sys_contents) in systems.iter().zip(contents.iter()) {
                let is_core = sys.faction_index == 0 && sys.is_core;
                let (mesh, material, scale) = if is_core {
                    // Visual differentiation for the 7 Concord core systems.
                    (core_star_mesh.clone(), faction_materials[0].clone(), 1.1)
                } else if sys.is_beam {
                    // Beam systems: distinct bright tint + slightly smaller
                    // so they read as a separate structural layer.
                    (star_mesh.clone(), beam_material.clone(), 0.8)
                } else {
                    (
                        star_mesh.clone(),
                        faction_materials[sys.faction_index].clone(),
                        1.0,
                    )
                };

                let poi_count = sys_contents.total();

                parent
                    .spawn((
                        Transform::from_translation(sys.position),
                        Visibility::default(),
                        InheritedVisibility::default(),
                        StarSystem {
                            id: sys.id.clone(),
                            name: sys.name.clone(),
                            faction: sys.faction,
                            security: sys.security,
                            poi_count,
                        },
                    ))
                    .with_children(|sys_parent| {
                        // The anchor celestial — system's primary star.
                        let star_entity = sys_parent
                            .spawn((
                                Star,
                                Massive {
                                    mass: if sys.is_core { 10000.0 } else { 5000.0 },
                                },
                                Luminosity {
                                    value: if sys.is_core { 100.0 } else { 50.0 },
                                },
                                MassLock { radius: 5.0 },
                                BoundingVolume { radius: 1.5 },
                                Identifiable {
                                    id: format!("{}-star", sys.id),
                                    display_name: sys.name.clone(),
                                    classification: Classification::Celestial,
                                },
                                Mesh3d(mesh),
                                MeshMaterial3d(material),
                                Transform::default().with_scale(Vec3::splat(scale)),
                            ))
                            .id();

                        // POI children (planets, belts, stations, anomalies, stargates, gas).
                        let ctx = SystemContext {
                            id: &sys.id,
                            name: &sys.name,
                            faction: sys.faction,
                            security: sys.security,
                            is_core: sys.is_core,
                        };
                        contents::spawn_system_contents(
                            sys_parent,
                            &ctx,
                            sys_contents,
                            star_entity,
                            &content_assets,
                            materials,
                        );
                    });
            }

            // Connections rendered as thin cylinders between linked systems.
            for (a, b) in connections {
                let from = systems[*a].position;
                let to = systems[*b].position;
                let delta = to - from;
                let length = delta.length();
                if length < 0.01 {
                    continue;
                }
                let midpoint = (from + to) * 0.5;
                let direction = delta / length;
                // Cylinder default axis is +Y; rotate so +Y aligns with the link direction.
                let rotation = Quat::from_rotation_arc(Vec3::Y, direction);
                parent.spawn((
                    Mesh3d(meshes.add(Cylinder::new(0.15, length).mesh())),
                    MeshMaterial3d(connection_material.clone()),
                    Transform::from_translation(midpoint).with_rotation(rotation),
                ));
            }
        });
}

fn build_connections(systems: &[GeneratedSystem]) -> Vec<(usize, usize)> {
    let mut connections: Vec<(usize, usize)> = Vec::new();
    for (i, system) in systems.iter().enumerate() {
        let mut distances: Vec<(usize, f32)> = systems
            .iter()
            .enumerate()
            .filter(|(j, _)| *j != i)
            .map(|(j, other)| (j, system.position.distance(other.position)))
            .collect();
        distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
        for &(j, _) in distances.iter().take(NEAREST_NEIGHBOR_CONNECTIONS) {
            let key = if i < j { (i, j) } else { (j, i) };
            if !connections.contains(&key) {
                connections.push(key);
            }
        }
    }
    connections
}

// ── Lifecycle systems ───────────────────────────────────────────────────────

fn despawn_galaxy(mut commands: Commands, query: Query<Entity, With<GalaxySpawned>>) {
    for entity in &query {
        // Bevy 0.16: despawn() is recursive by default.
        commands.entity(entity).despawn();
    }
}

fn reset_selection(mut selected: ResMut<SelectedStar>) {
    selected.0 = None;
}

fn escape_to_main_menu(
    keys: Res<ButtonInput<KeyCode>>,
    mut next_state: ResMut<NextState<AppState>>,
) {
    if keys.just_pressed(KeyCode::Escape) {
        next_state.set(AppState::MainMenu);
    }
}

// ── Regeneration ────────────────────────────────────────────────────────────

/// Detects [`GalaxyParams`] generation changes and rebuilds the 3D scene.
///
/// On first run, the OnEnter handler has already spawned the initial scene —
/// we just record the generation and bail. On subsequent changes we despawn
/// every `GalaxyScene` root (recursive by default — kills all star/connection
/// children) and respawn a fresh galaxy.
fn regenerate_galaxy_on_param_change(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    params: Res<GalaxyParams>,
    scene_roots: Query<Entity, With<GalaxyScene>>,
    mut selected: ResMut<SelectedStar>,
    mut last_seen: Local<Option<u64>>,
) {
    match *last_seen {
        Some(g) if g == params.generation => return,
        None => {
            // First run: OnEnter already spawned the scene. Just record + bail.
            *last_seen = Some(params.generation);
            return;
        }
        _ => {}
    }
    *last_seen = Some(params.generation);

    // The previously-selected entity is about to be despawned; clear it so
    // the info panel and animation system don't reference a dangling Entity.
    selected.0 = None;

    for entity in &scene_roots {
        commands.entity(entity).despawn();
    }

    let galaxy = generate_galaxy(&params);
    spawn_galaxy_scene(
        &mut commands,
        &mut meshes,
        &mut materials,
        &galaxy.systems,
        &galaxy.contents,
        &galaxy.connections,
    );
}

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

    #[test]
    fn generate_galaxy_persists_params_and_parallel_arrays() {
        let params = GalaxyParams::default();
        let galaxy = generate_galaxy(&params);

        assert_eq!(galaxy.params.seed, params.seed);
        assert_eq!(galaxy.systems.len(), galaxy.contents.len());
        assert!(!galaxy.systems.is_empty());
        assert!(galaxy
            .connections
            .iter()
            .all(|(a, b)| *a < galaxy.systems.len() && *b < galaxy.systems.len()));
    }

    #[test]
    fn starting_base_map_uses_saved_galaxy_without_regeneration() {
        let galaxy = generate_galaxy(&GalaxyParams::default());
        let map = galaxy.starting_base_map();

        assert!(map.candidates.iter().all(|candidate| {
            galaxy
                .systems
                .iter()
                .any(|system| system.id == candidate.id)
        }));
        // Candidates must have dockable locations (stations or inhabited planets) and not be core systems
        assert!(map.candidates.iter().all(|candidate| {
            let index = galaxy
                .systems
                .iter()
                .position(|system| system.id == candidate.id);
            index.is_some_and(|index| {
                let system = &galaxy.systems[index];
                let contents = &galaxy.contents[index];
                !system.is_core
                    && (!contents.stations.is_empty()
                        || contents.planets.iter().any(|p| p.population > 0))
            })
        }));
        assert!(map.candidates.iter().all(|candidate| {
            let index = galaxy
                .systems
                .iter()
                .position(|system| system.id == candidate.id);
            index.is_some_and(|index| galaxy.contents[index].total() == candidate.contents.total())
        }));
    }
}
