//! SGP4 satellite propagation with 0 ULP parity to the Vallado C++ reference
//! implementation (v2020-07-13).
//!
//! This module is a faithful pure-Rust port of Vallado's SGP4 verified bit-for-bit
//! against the canonical 33-satellite / 198-propagation-point Vallado verification
//! suite. TLEs are curve-fit parameters generated by Vallado's SGP4; using a
//! different propagator introduces errors. This module preserves the exact
//! floating-point computation order of the C++ reference so output matches
//! Python's `sgp4` C extension (which compiles the same source) bit-for-bit.
//!
//! ## Quick start
//!
//! ```
//! use sidereon_core::astro::sgp4::{Satellite, MinutesSinceEpoch};
//!
//! let line1 = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9993";
//! let line2 = "2 25544  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
//!
//! let sat = Satellite::from_tle(line1, line2).unwrap();
//! let pred = sat.propagate(MinutesSinceEpoch(0.0)).unwrap();
//!
//! // position in km, velocity in km/s, TEME frame
//! let _ = pred.position;
//! let _ = pred.velocity;
//! ```

pub mod fit;
#[allow(
    dead_code,
    unused_variables,
    unused_assignments,
    unused_mut,
    non_snake_case,
    non_camel_case_types,
    clippy::approx_constant,
    clippy::excessive_precision,
    clippy::too_many_arguments,
    clippy::needless_return,
    clippy::assign_op_pattern,
    clippy::manual_range_contains,
    clippy::collapsible_if,
    clippy::collapsible_else_if,
    clippy::float_cmp,
    clippy::needless_late_init,
    clippy::field_reassign_with_default
)]
mod vallado;

use crate::astro::tle;
use crate::validate::{self, FieldError};
use thiserror::Error;

pub use fit::{
    fit_tle, FitConfig, FitEpoch, FitSample, FitStatistics, Loss, TleFit, TleFitError, TleMetadata,
    XScale,
};

const MAX_VALLADO_SATNUM: u32 = 99_999;

// ── Error ────────────────────────────────────────────────────────────

/// Validation failure category for public SGP4 inputs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sgp4InputErrorKind {
    /// A numeric input was NaN or infinite.
    NonFinite,
    /// A positive physical input was zero or negative.
    NotPositive,
    /// A non-negative physical input was negative.
    Negative,
    /// A numeric input was finite but outside the SGP4 domain.
    OutOfRange,
    /// A required input field was absent.
    Missing,
    /// A text field could not be parsed as a float.
    FloatParse,
    /// A text field could not be parsed as an integer.
    IntParse,
    /// A civil date field was out of range.
    InvalidCivilDate,
    /// A civil time field was out of range.
    InvalidCivilTime,
}

impl core::fmt::Display for Sgp4InputErrorKind {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        let label = match self {
            Self::NonFinite => "not finite",
            Self::NotPositive => "not positive",
            Self::Negative => "negative",
            Self::OutOfRange => "out of range",
            Self::Missing => "missing",
            Self::FloatParse => "invalid float",
            Self::IntParse => "invalid integer",
            Self::InvalidCivilDate => "invalid civil date",
            Self::InvalidCivilTime => "invalid civil time",
        };
        f.write_str(label)
    }
}

impl From<&FieldError> for Sgp4InputErrorKind {
    fn from(error: &FieldError) -> Self {
        match error {
            FieldError::Missing { .. } => Self::Missing,
            FieldError::NonFinite { .. } => Self::NonFinite,
            FieldError::NotPositive { .. } => Self::NotPositive,
            FieldError::Negative { .. } => Self::Negative,
            FieldError::OutOfRange { .. } => Self::OutOfRange,
            FieldError::FloatParse { .. } => Self::FloatParse,
            FieldError::IntParse { .. } => Self::IntParse,
            FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
            FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
        }
    }
}

/// Error from SGP4 initialization or propagation.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum Error {
    /// A public SGP4 input was malformed, non-finite, or outside the model
    /// domain. Boundary validation rejects this before the Vallado kernel runs.
    #[error("invalid SGP4 input {field}: {kind}")]
    InvalidInput {
        /// The invalid input field.
        field: &'static str,
        /// The validation failure category.
        kind: Sgp4InputErrorKind,
    },
    /// The Vallado step kernel returned a non-finite state vector.
    #[error("SGP4 returned non-finite {field}")]
    NonFiniteOutput {
        /// The output vector that was non-finite.
        field: &'static str,
    },
    /// TLE line has invalid format.
    #[error("invalid TLE: {0}")]
    InvalidTle(String),
    /// SGP4 returned a non-zero error code.
    ///
    /// Codes: 1 = mean elements, 2 = mean motion, 3 = perturbed elements,
    /// 4 = semi-latus rectum, 5 = epoch elements sub-orbital,
    /// 6 = satellite decayed.
    #[error("SGP4 error code {code}")]
    Sgp4 { code: i32 },
}

/// Error from opt-in decay-latched SGP4 propagation.
///
/// A latch reports the first observed decay-like epoch for one satellite and
/// prevents later requests from returning a raw SGP4 state after the satellite
/// has already been observed below the valid Earth-clearance domain.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum DecayLatchedError {
    /// The satellite has already reached a decay-like SGP4 failure.
    #[error(
        "SGP4 decay latch first failed at {first_failing_epoch:?}; requested {requested_epoch:?}"
    )]
    Decayed {
        /// First epoch in minutes since element epoch that returned a
        /// decay-like SGP4 failure through this latch.
        first_failing_epoch: MinutesSinceEpoch,
        /// Epoch requested by the current call.
        requested_epoch: MinutesSinceEpoch,
    },
    /// The underlying raw SGP4 propagation failed for a non-latched reason.
    #[error(transparent)]
    Propagation(#[from] Error),
}

const MAX_MINUTES_SINCE_EPOCH: f64 = 10_000_000.0;

// ── Types ────────────────────────────────────────────────────────────

/// Minutes since the TLE epoch. Newtype to prevent mixing with raw `f64`.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MinutesSinceEpoch(pub f64);

/// Propagation result in the TEME (True Equator, Mean Equinox) frame.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Prediction {
    /// Position in km, TEME frame.
    pub position: [f64; 3],
    /// Velocity in km/s, TEME frame.
    pub velocity: [f64; 3],
}

/// Julian date split as `(whole, fraction)` for high-precision time input.
///
/// Skyfield convention: `whole = floor(JD)`, `fraction = remainder`.
/// For example, 2018-07-04 00:00:00 UTC = `JulianDate(2458303.0, 0.5)`.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct JulianDate(pub f64, pub f64);

/// Per-satellite latch for decay-like SGP4 propagation failures.
///
/// The latch is intentionally separate from [`Satellite`]. Passing it to
/// [`Satellite::propagate_with_decay_latch`] opts one caller into stateful
/// post-decay validity checks while [`Satellite::propagate`] remains a raw,
/// stateless Vallado call.
#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct DecayLatch {
    first_failing_epoch: Option<MinutesSinceEpoch>,
}

impl DecayLatch {
    /// Construct an empty decay latch.
    pub const fn new() -> Self {
        Self {
            first_failing_epoch: None,
        }
    }

    /// First observed decay-like epoch, in minutes since element epoch.
    pub const fn first_failing_epoch(self) -> Option<MinutesSinceEpoch> {
        self.first_failing_epoch
    }

    /// Clear the recorded decay state.
    pub fn clear(&mut self) {
        self.first_failing_epoch = None;
    }

    fn decayed_error(self, requested_epoch: MinutesSinceEpoch) -> Option<DecayLatchedError> {
        let first_failing_epoch = self.first_failing_epoch?;
        if requested_epoch.0 >= first_failing_epoch.0 {
            Some(DecayLatchedError::Decayed {
                first_failing_epoch,
                requested_epoch,
            })
        } else {
            None
        }
    }

    fn record_decay(&mut self, epoch: MinutesSinceEpoch) -> MinutesSinceEpoch {
        match self.first_failing_epoch {
            Some(first) if first.0 <= epoch.0 => first,
            _ => {
                self.first_failing_epoch = Some(epoch);
                epoch
            }
        }
    }
}

pub(crate) fn sgp4_julian_date_from_calendar(
    year: i32,
    mon: i32,
    day: i32,
    hr: i32,
    minute: i32,
    sec: f64,
) -> JulianDate {
    let (jd, jdfrac) = vallado::jday_SGP4(year, mon, day, hr, minute, sec);
    JulianDate(jd, jdfrac)
}

pub(crate) fn sgp4_julian_date_from_day_of_year(year: i32, days: f64) -> JulianDate {
    let (mon, day, hr, minute, sec) = vallado::days2mdhms_SGP4(year, days);
    let JulianDate(jd, jdfrac_raw) =
        sgp4_julian_date_from_calendar(year, mon, day, hr, minute, sec);
    let jdfrac = (jdfrac_raw * 100_000_000.0).round() / 100_000_000.0;
    JulianDate(jd, jdfrac)
}

/// Vallado SGP4 operation mode. Controls which initialization branch
/// `sgp4init` follows. The two modes produce subtly different results;
/// the divergence grows with propagation time and can reach hundreds of
/// millimeters over a few orbits at LEO.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum OpsMode {
    /// Improved formulation (Vallado `'i'`). Default for new work and
    /// matches the default of Python's `sgp4` package. Recommended unless
    /// you specifically need AFSPC operational parity.
    #[default]
    Improved,
    /// AFSPC operational compatibility mode (Vallado `'a'`). Use this
    /// when reproducing outputs from operational AFSPC systems or matching
    /// reference values from older crates / catalogs that ran in AFSPC mode.
    Afspc,
}

impl OpsMode {
    fn as_char(self) -> char {
        match self {
            OpsMode::Improved => 'i',
            OpsMode::Afspc => 'a',
        }
    }
}

/// Pre-parsed Vallado SGP4 element set.
///
/// Use this when the TLE has already been parsed externally (e.g. from an
/// OMM message, JSON catalog, or another system) and you want to feed the
/// element values directly into the SGP4 initializer instead of going through
/// the TLE string parser.
///
/// Field units match the Vallado SGP4 reference inputs:
/// angles in **degrees**, mean motion in **revolutions per day**, drag term
/// in the dimensionless TLE convention.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ElementSet {
    /// Epoch as a split Julian date `(whole, fraction)`.
    ///
    /// This is format-agnostic and loss-free for the SGP4 initializer: TLE
    /// conversion stores the exact split JD produced by Vallado's
    /// `days2mdhms`/`jday` path with the legacy 8-decimal fraction rounding,
    /// while OMM conversion stores the split JD from its full calendar
    /// timestamp directly.
    pub epoch: JulianDate,
    /// SGP4 drag term (Vallado B\*). Dimensionless TLE convention.
    pub bstar: f64,
    /// First derivative of mean motion in rev/day². TLE "ndot".
    pub mean_motion_dot: f64,
    /// Second derivative of mean motion in rev/day³. TLE "nddot".
    pub mean_motion_double_dot: f64,
    /// Eccentricity, dimensionless, in [0, 1).
    pub eccentricity: f64,
    /// Argument of perigee, degrees.
    pub argument_of_perigee_deg: f64,
    /// Inclination, degrees.
    pub inclination_deg: f64,
    /// Mean anomaly, degrees.
    pub mean_anomaly_deg: f64,
    /// Mean motion, revolutions per day.
    pub mean_motion_rev_per_day: f64,
    /// Right ascension of ascending node (RAAN), degrees.
    pub right_ascension_deg: f64,
    /// Catalog (NORAD) number for this object. Used only for diagnostic
    /// reporting inside SGP4 - propagation results do not depend on it.
    /// Pass `0` if unknown.
    pub catalog_number: u32,
}

// ── Satellite ────────────────────────────────────────────────────────

/// A parsed TLE ready for propagation.
///
/// Holds the raw TLE lines plus the initialized SGP4 satellite record. The
/// TLE is parsed and `sgp4init` is run exactly once during `from_tle`, so
/// subsequent `propagate` calls just invoke the propagation kernel directly:
/// fast, and crucially, with no precision loss from JD round-tripping.
#[derive(Clone)]
pub struct Satellite {
    line1: String,
    line2: String,
    /// Source elements used to initialize the cached SGP4 record. Kept so
    /// element-built satellites can serialize without inventing TLE text.
    elements: ElementSet,
    opsmode: OpsMode,
    /// Pre-initialized satellite record. Boxed to keep `Satellite` small on
    /// the stack - `ElsetRec` has ~150 fields.
    satrec: Box<vallado::ElsetRec>,
}

impl std::fmt::Debug for Satellite {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Satellite")
            .field("line1", &self.line1)
            .field("line2", &self.line2)
            .field("elements", &self.elements)
            .field("opsmode", &self.opsmode)
            .finish_non_exhaustive()
    }
}

impl Satellite {
    /// Parse a two-line element set using the default `Improved` opsmode.
    ///
    /// Lines should be the standard 69-character TLE format. Leading and
    /// trailing whitespace is trimmed before length validation. Runs the full
    /// SGP4 initialization (`sgp4init`) once and caches the resulting state
    /// so propagation calls are pure step kernels.
    pub fn from_tle(line1: &str, line2: &str) -> Result<Self, Error> {
        Self::from_tle_with_opsmode(line1, line2, OpsMode::Improved)
    }

    /// Parse a two-line element set with an explicit `OpsMode`.
    ///
    /// Use `OpsMode::Afspc` to reproduce results from operational AFSPC
    /// systems or older crates that ran in AFSPC compatibility mode.
    ///
    /// The TLE flows through the canonical element-set IR: it is parsed by the
    /// forgiving [`crate::astro::tle`] grammar into [`crate::astro::tle::TleElements`],
    /// converted to an [`ElementSet`], and initialized through the single
    /// [`Satellite::from_elements`] path - the same path OMM and any other input
    /// format use. There is no separate TLE-direct initialization. The parse is
    /// lenient on cosmetics (trailing content past column 69, leading-dot and
    /// assumed-decimal field forms, an advisory checksum) but still rejects
    /// genuinely corrupt input (non-ASCII, wrong line structure, mismatched
    /// satellite numbers).
    pub fn from_tle_with_opsmode(
        line1: &str,
        line2: &str,
        opsmode: OpsMode,
    ) -> Result<Self, Error> {
        let l1 = line1.trim();
        let l2 = line2.trim();

        let parsed = tle::parse(l1, l2).map_err(|e| Error::InvalidTle(e.to_string()))?;
        let elements = parsed
            .elements
            .to_element_set()
            .map_err(map_tle_bridge_error)?;
        let satrec = init_satrec_from_elements(&elements, opsmode)?;

        Ok(Satellite {
            line1: l1.to_string(),
            line2: l2.to_string(),
            elements,
            opsmode,
            satrec: Box::new(satrec),
        })
    }

    /// Parse a satellite from a TLE block that may carry a leading name line.
    ///
    /// CelesTrak and Space-Track serve TLEs in the common "3-line" (TLE/3LE)
    /// form: an object-name line followed by the two element lines. This accepts
    /// that block, strips an optional leading name line, and parses the first
    /// `1 `/`2 ` element-line pair. A plain 2-line block (no name line) also
    /// parses. Uses the default `Improved` opsmode.
    pub fn from_3line(block: &str) -> Result<Self, Error> {
        Self::from_3line_with_opsmode(block, OpsMode::Improved)
    }

    /// Parse a name-line-prefixed TLE block with an explicit `OpsMode`. See
    /// [`Satellite::from_3line`].
    pub fn from_3line_with_opsmode(block: &str, opsmode: OpsMode) -> Result<Self, Error> {
        let mut l1 = None;
        let mut l2 = None;
        for line in block.lines() {
            let line = line.trim();
            if l1.is_none() && line.starts_with("1 ") {
                l1 = Some(line.to_string());
            } else if l2.is_none() && line.starts_with("2 ") {
                l2 = Some(line.to_string());
            }
        }
        let l1 = l1.ok_or_else(|| Error::InvalidTle("no line 1 in TLE block".into()))?;
        let l2 = l2.ok_or_else(|| Error::InvalidTle("no line 2 in TLE block".into()))?;
        Self::from_tle_with_opsmode(&l1, &l2, opsmode)
    }

    /// Construct a `Satellite` from pre-parsed Vallado SGP4 elements using
    /// the default `Improved` opsmode.
    ///
    /// Useful when TLE data has already been parsed externally (OMM, JSON
    /// catalog, another system) and you only need the element values to flow
    /// into SGP4 initialization. Equivalent to `from_tle` for propagation
    /// behavior, but bypasses the TLE string parser.
    ///
    /// Note: a `Satellite` constructed this way has empty `line1()` and
    /// `line2()` accessors since there is no source TLE to return.
    pub fn from_elements(elements: &ElementSet) -> Result<Self, Error> {
        Self::from_elements_with_opsmode(elements, OpsMode::Improved)
    }

    /// Construct a `Satellite` from pre-parsed elements with an explicit
    /// `OpsMode`. See `from_tle_with_opsmode` for the rationale.
    pub fn from_elements_with_opsmode(
        elements: &ElementSet,
        opsmode: OpsMode,
    ) -> Result<Self, Error> {
        let satrec = init_satrec_from_elements(elements, opsmode)?;
        Ok(Satellite {
            line1: String::new(),
            line2: String::new(),
            elements: elements.clone(),
            opsmode,
            satrec: Box::new(satrec),
        })
    }

    /// Propagate to a time given as minutes since the TLE epoch.
    ///
    /// Calls the SGP4 step kernel directly with the supplied tsince - no JD
    /// round-trip, no precision loss.
    pub fn propagate(&self, t: MinutesSinceEpoch) -> Result<Prediction, Error> {
        // Clone the satrec so propagation doesn't mutate the cached state
        // (sgp4 writes back into the satrec - error code, atime, etc.).
        propagate_satrec((*self.satrec).clone(), t)
    }

    /// Propagate with an opt-in post-decay validity latch.
    ///
    /// On the first SGP4 decay-like failure (Vallado code 5 or 6) the latch
    /// records the requested epoch and this method returns
    /// [`DecayLatchedError::Decayed`]. Later calls at the same or a later epoch
    /// return the same latched state without invoking the raw kernel. Earlier
    /// epochs are still propagated normally. Use [`Satellite::propagate`] when
    /// raw Vallado behavior is required.
    pub fn propagate_with_decay_latch(
        &self,
        t: MinutesSinceEpoch,
        latch: &mut DecayLatch,
    ) -> Result<Prediction, DecayLatchedError> {
        validate_minutes_since_epoch(t)?;
        if let Some(error) = latch.decayed_error(t) {
            return Err(error);
        }
        match self.propagate(t) {
            Ok(prediction) => Ok(prediction),
            Err(error) if is_decay_like_sgp4_error(&error) => {
                let first_failing_epoch = latch.record_decay(t);
                Err(DecayLatchedError::Decayed {
                    first_failing_epoch,
                    requested_epoch: t,
                })
            }
            Err(error) => Err(DecayLatchedError::Propagation(error)),
        }
    }

    /// Propagate to a Julian date, split as `(whole, fraction)`.
    ///
    /// Computes the tsince from the cached epoch via the same subtraction
    /// the C++ wrapper uses:
    ///
    /// ```text
    /// tsince = (jd - jdsatepoch) * 1440 + (fr - jdsatepochF) * 1440
    /// ```
    pub fn propagate_jd(&self, jd: JulianDate) -> Result<Prediction, Error> {
        self.propagate(minutes_since_epoch_from_jd(&self.satrec, jd)?)
    }

    /// Propagate to a Julian date with an opt-in post-decay validity latch.
    ///
    /// The Julian date is converted to minutes since element epoch by the same
    /// split-JD subtraction used by [`Satellite::propagate_jd`], then processed
    /// by [`Satellite::propagate_with_decay_latch`].
    pub fn propagate_jd_with_decay_latch(
        &self,
        jd: JulianDate,
        latch: &mut DecayLatch,
    ) -> Result<Prediction, DecayLatchedError> {
        let t = minutes_since_epoch_from_jd(&self.satrec, jd)?;
        self.propagate_with_decay_latch(t, latch)
    }

    pub(crate) fn mean_motion_rad_per_min(&self) -> f64 {
        self.satrec.no_kozai
    }

    pub(crate) fn eccentricity(&self) -> f64 {
        self.satrec.ecco
    }

    /// Raw TLE line 1. Returns an empty string when this `Satellite` was
    /// constructed via `from_elements` (no source TLE).
    pub fn line1(&self) -> &str {
        &self.line1
    }

    /// Raw TLE line 2. Returns an empty string when this `Satellite` was
    /// constructed via `from_elements` (no source TLE).
    pub fn line2(&self) -> &str {
        &self.line2
    }

    /// Cached TLE epoch as a split Julian date `(jdsatepoch, jdsatepochF)`.
    ///
    /// Useful for computing time offsets between two TLEs without losing
    /// precision through floating-point round-trips.
    pub fn epoch_jd(&self) -> JulianDate {
        JulianDate(self.satrec.jdsatepoch, self.satrec.jdsatepochF)
    }

    fn has_source_tle(&self) -> bool {
        !self.line1.is_empty() && !self.line2.is_empty()
    }
}

/// One-shot SGP4 propagation from pre-parsed elements using the default
/// `Improved` opsmode.
///
/// Equivalent to `Satellite::from_elements(&e)?.propagate(t)` but without
/// allocating a cached `Satellite`. Suitable for one-call use cases where
/// the satellite record is not reused (e.g. NIF entry points that get
/// elements + a single time per call).
pub fn propagate_elements(
    elements: &ElementSet,
    t: MinutesSinceEpoch,
) -> Result<Prediction, Error> {
    propagate_elements_with_opsmode(elements, t, OpsMode::Improved)
}

/// One-shot SGP4 propagation with an explicit `OpsMode`.
pub fn propagate_elements_with_opsmode(
    elements: &ElementSet,
    t: MinutesSinceEpoch,
    opsmode: OpsMode,
) -> Result<Prediction, Error> {
    let satrec = init_satrec_from_elements(elements, opsmode)?;
    propagate_satrec(satrec, t)
}

// ── Batch propagation ─────────────────────────────────────────────────

/// Propagate one already-initialized satellite across every time in `times`.
///
/// Element `i` of the returned arc is `satellite.propagate(times[i])`, in input
/// order. This is the single-satellite building block both batch entry points
/// fan out over; it does not catch a per-time failure, so the first erroring
/// time in the arc becomes the arc's `Err` (matching `Iterator::collect` into
/// `Result`). Use the per-satellite `Result` from [`propagate_batch`] to isolate
/// which satellite failed.
fn propagate_arc(
    satellite: &Satellite,
    times: &[MinutesSinceEpoch],
) -> Result<Vec<Prediction>, Error> {
    times.iter().map(|&t| satellite.propagate(t)).collect()
}

/// Propagate many already-initialized satellites across a shared set of times,
/// serially.
///
/// This is the most-requested throughput primitive: given `N` satellites and `M`
/// epochs it returns one arc per satellite, each arc the `M` TEME states for that
/// satellite. The result is indexed by satellite, so element `i` corresponds to
/// `satellites[i]`.
///
/// ## Bit-identity
///
/// Each state is produced by calling [`Satellite::propagate`] directly, the exact
/// single-satellite path. There is no batched algorithm, no shared mutable state,
/// and no reordering inside a satellite's arc, so
/// `propagate_batch(sats, times)[i]` is byte-for-byte identical to mapping
/// `sats[i].propagate(t)` over `times` by hand. The batch is pure iteration over
/// the proven kernel.
///
/// ## Per-satellite error isolation
///
/// One satellite that fails to propagate (an SGP4 error code, a non-finite state,
/// an out-of-domain time) yields an `Err` in *its* slot only; every other
/// satellite still returns its full arc. A bad satellite never aborts the batch.
/// Within a single satellite's arc the first erroring time short-circuits that
/// arc (the rest of that satellite's times are not propagated).
///
/// [`propagate_batch_parallel`] is the data-parallel variant and is proven
/// bit-identical to this serial reference.
///
/// ```
/// use sidereon_core::astro::sgp4::{propagate_batch, MinutesSinceEpoch, Satellite};
///
/// let l1 = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9993";
/// let l2 = "2 25544  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
/// let sats = [Satellite::from_tle(l1, l2).unwrap()];
/// let times = [MinutesSinceEpoch(0.0), MinutesSinceEpoch(90.0)];
///
/// let batch = propagate_batch(&sats, &times);
/// let arc = batch[0].as_ref().unwrap();
/// assert_eq!(arc.len(), times.len());
/// ```
pub fn propagate_batch(
    satellites: &[Satellite],
    times: &[MinutesSinceEpoch],
) -> Vec<Result<Vec<Prediction>, Error>> {
    satellites
        .iter()
        .map(|satellite| propagate_arc(satellite, times))
        .collect()
}

/// Propagate many already-initialized satellites across a shared set of times,
/// fanning the independent per-satellite arcs across a rayon thread pool.
///
/// Each satellite's arc is computed by the same serial [`propagate_arc`] kernel
/// (i.e. the same [`Satellite::propagate`] calls as [`propagate_batch`]), and the
/// indexed parallel collect preserves input order. Satellites are independent
/// (no cross-satellite state, no reduction), so element `i` of the result is
/// byte-for-byte identical to element `i` of [`propagate_batch`] while throughput
/// scales with available cores. Per-satellite error isolation is identical to the
/// serial path.
pub fn propagate_batch_parallel(
    satellites: &[Satellite],
    times: &[MinutesSinceEpoch],
) -> Vec<Result<Vec<Prediction>, Error>> {
    use rayon::prelude::*;
    satellites
        .par_iter()
        .map(|satellite| propagate_arc(satellite, times))
        .collect()
}

impl serde::Serialize for Satellite {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        use serde::ser::SerializeStruct;
        let mut st = s.serialize_struct("Satellite", 2)?;
        if self.has_source_tle() {
            st.serialize_field("line1", &self.line1)?;
            st.serialize_field("line2", &self.line2)?;
        } else {
            st.serialize_field("elements", &self.elements)?;
            st.serialize_field("opsmode", &self.opsmode)?;
        }
        st.end()
    }
}

impl<'de> serde::Deserialize<'de> for Satellite {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        #[derive(serde::Deserialize)]
        struct Wire {
            line1: Option<String>,
            line2: Option<String>,
            elements: Option<ElementSet>,
            opsmode: Option<OpsMode>,
        }
        let w = Wire::deserialize(d)?;
        let opsmode = w.opsmode.unwrap_or_default();
        let has_tle_line = w
            .line1
            .as_deref()
            .is_some_and(|line| !line.trim().is_empty())
            || w.line2
                .as_deref()
                .is_some_and(|line| !line.trim().is_empty());
        if let Some(elements) = w.elements {
            if has_tle_line {
                Err(serde::de::Error::custom(
                    "ambiguous Satellite wire format: use either TLE lines or elements",
                ))
            } else {
                Satellite::from_elements_with_opsmode(&elements, opsmode)
                    .map_err(serde::de::Error::custom)
            }
        } else if let (Some(line1), Some(line2)) = (w.line1, w.line2) {
            if line1.trim().is_empty() || line2.trim().is_empty() {
                Err(serde::de::Error::custom(
                    "Satellite wire format requires non-empty line1/line2 or elements",
                ))
            } else {
                Satellite::from_tle_with_opsmode(&line1, &line2, opsmode)
                    .map_err(serde::de::Error::custom)
            }
        } else {
            Err(serde::de::Error::custom(
                "Satellite wire format requires non-empty line1/line2 or elements",
            ))
        }
    }
}

// ── Internal helpers ─────────────────────────────────────────────────

fn propagate_satrec(
    mut satrec: vallado::ElsetRec,
    t: MinutesSinceEpoch,
) -> Result<Prediction, Error> {
    validate_minutes_since_epoch(t)?;

    let mut r = [0.0_f64; 3];
    let mut v = [0.0_f64; 3];
    let ok = vallado::sgp4(&mut satrec, t.0, &mut r, &mut v);
    if !ok || satrec.error != 0 {
        return Err(Error::Sgp4 { code: satrec.error });
    }
    validate_prediction(r, v)?;
    Ok(Prediction {
        position: r,
        velocity: v,
    })
}

fn validate_minutes_since_epoch(t: MinutesSinceEpoch) -> Result<(), Error> {
    validate::finite(t.0, "minutes_since_epoch").map_err(map_input_error)?;
    if t.0.abs() > MAX_MINUTES_SINCE_EPOCH {
        return Err(invalid_domain("minutes_since_epoch"));
    }
    Ok(())
}

fn minutes_since_epoch_from_jd(
    satrec: &vallado::ElsetRec,
    jd: JulianDate,
) -> Result<MinutesSinceEpoch, Error> {
    validate::finite(jd.0, "julian_date.whole").map_err(map_input_error)?;
    validate::finite_in_range_exclusive_upper(jd.1, 0.0, 1.0, "julian_date.fraction")
        .map_err(map_input_error)?;
    let tsince = (jd.0 - satrec.jdsatepoch) * 1440.0 + (jd.1 - satrec.jdsatepochF) * 1440.0;
    let t = MinutesSinceEpoch(tsince);
    validate_minutes_since_epoch(t)?;
    Ok(t)
}

fn is_decay_like_sgp4_error(error: &Error) -> bool {
    matches!(error, Error::Sgp4 { code: 5 | 6 })
}

fn validate_prediction(position: [f64; 3], velocity: [f64; 3]) -> Result<(), Error> {
    validate::finite_vec3(position, "position_km").map_err(map_output_error)?;
    validate::finite_vec3(velocity, "velocity_km_s").map_err(map_output_error)?;
    Ok(())
}

/// Run `sgp4init` from a pre-parsed element set, returning the initialized
/// satellite record. Performs the same angle/units conversion as
/// `vallado::twoline2rv_propagate` so a `Satellite` constructed from
/// elements is equivalent to one constructed from the matching TLE.
fn init_satrec_from_elements(
    elements: &ElementSet,
    opsmode: OpsMode,
) -> Result<vallado::ElsetRec, Error> {
    validate_elements(elements)?;

    let deg2rad = std::f64::consts::PI / 180.0;
    let xpdotp = 1440.0 / (2.0 * std::f64::consts::PI);

    let inclo = elements.inclination_deg * deg2rad;
    let nodeo = elements.right_ascension_deg * deg2rad;
    let argpo = elements.argument_of_perigee_deg * deg2rad;
    let mo = elements.mean_anomaly_deg * deg2rad;
    let no_kozai = elements.mean_motion_rev_per_day / xpdotp;
    // ndot rev/day² → rad/min², nddot rev/day³ → rad/min³.
    // Matches the conversion in `vallado::twoline2rv_propagate`.
    let ndot = elements.mean_motion_dot / (xpdotp * 1440.0);
    let nddot = elements.mean_motion_double_dot / (xpdotp * 1440.0 * 1440.0);

    let JulianDate(jd, jdfrac) = elements.epoch;
    let epoch_sgp4 = jd + jdfrac - 2433281.5;

    let satnum_str = format!("{:>5}", elements.catalog_number);

    let mut satrec = vallado::ElsetRec {
        jdsatepoch: jd,
        jdsatepochF: jdfrac,
        ..vallado::ElsetRec::default()
    };

    vallado::sgp4init(
        vallado::GravConstType::Wgs72,
        opsmode.as_char(),
        &satnum_str,
        epoch_sgp4,
        elements.bstar,
        ndot,
        nddot,
        elements.eccentricity,
        argpo,
        inclo,
        mo,
        no_kozai,
        nodeo,
        &mut satrec,
    );

    // sgp4init may have rewritten jdsatepoch via initl - restore the split.
    satrec.jdsatepoch = jd;
    satrec.jdsatepochF = jdfrac;

    Ok(satrec)
}

fn validate_elements(elements: &ElementSet) -> Result<(), Error> {
    if elements.catalog_number > MAX_VALLADO_SATNUM {
        return Err(invalid_domain("element.catalog_number"));
    }
    validate_epoch(elements.epoch)?;
    validate::finite(elements.bstar, "element.bstar").map_err(map_input_error)?;
    validate::finite(elements.mean_motion_dot, "element.mean_motion_dot")
        .map_err(map_input_error)?;
    validate::finite(
        elements.mean_motion_double_dot,
        "element.mean_motion_double_dot",
    )
    .map_err(map_input_error)?;
    validate::finite_in_range_exclusive_upper(
        elements.eccentricity,
        0.0,
        1.0,
        "element.eccentricity",
    )
    .map_err(map_input_error)?;
    validate::finite(
        elements.argument_of_perigee_deg,
        "element.argument_of_perigee_deg",
    )
    .map_err(map_input_error)?;
    validate::finite(elements.inclination_deg, "element.inclination_deg")
        .map_err(map_input_error)?;
    validate::finite(elements.mean_anomaly_deg, "element.mean_anomaly_deg")
        .map_err(map_input_error)?;
    validate::finite_positive(
        elements.mean_motion_rev_per_day,
        "element.mean_motion_rev_per_day",
    )
    .map_err(map_input_error)?;
    validate::finite(elements.right_ascension_deg, "element.right_ascension_deg")
        .map_err(map_input_error)?;

    Ok(())
}

fn validate_epoch(epoch: JulianDate) -> Result<(), Error> {
    validate::finite(epoch.0, "element.epoch.whole").map_err(map_input_error)?;
    validate::finite(epoch.1, "element.epoch.fraction").map_err(map_input_error)?;

    let total = epoch.0 + epoch.1;
    validate::finite(total, "element.epoch").map_err(map_input_error)?;
    if !(0.0..=5_000_000.0).contains(&total) {
        return Err(invalid_domain("element.epoch"));
    }
    Ok(())
}

fn map_input_error(error: FieldError) -> Error {
    Error::InvalidInput {
        field: error.field(),
        kind: Sgp4InputErrorKind::from(&error),
    }
}

fn invalid_domain(field: &'static str) -> Error {
    Error::InvalidInput {
        field,
        kind: Sgp4InputErrorKind::OutOfRange,
    }
}

fn map_tle_bridge_error(error: tle::TleError) -> Error {
    match error {
        tle::TleError::InvalidField { field, reason } => Error::InvalidInput {
            field,
            kind: match reason {
                "not finite" => Sgp4InputErrorKind::NonFinite,
                "not positive" => Sgp4InputErrorKind::NotPositive,
                "negative" => Sgp4InputErrorKind::Negative,
                "out of range" => Sgp4InputErrorKind::OutOfRange,
                _ => Sgp4InputErrorKind::OutOfRange,
            },
        },
        other => Error::InvalidTle(other.to_string()),
    }
}

fn map_output_error(error: FieldError) -> Error {
    Error::NonFiniteOutput {
        field: error.field(),
    }
}

/// A satellite parsed from a TLE file, paired with its name line (if any).
#[derive(Debug)]
pub struct NamedSatellite {
    /// The name line preceding the element set in a 3-line set, with a leading
    /// CelesTrak `0 ` marker stripped. Empty when the record is a bare 2-line
    /// set with no name.
    pub name: String,
    /// The initialized satellite.
    pub satellite: Satellite,
}

/// The result of parsing a multi-record TLE file: the satellites that parsed,
/// plus a count of records that were skipped.
#[derive(Debug)]
pub struct TleFile {
    /// The successfully parsed satellites, in file order.
    pub satellites: Vec<NamedSatellite>,
    /// How many complete `(line 1, line 2)` records were found but skipped
    /// because their element set failed SGP4 initialization. Lets callers tell
    /// an empty file (`satellites` empty, `skipped == 0`) apart from a fully
    /// corrupt one (`skipped > 0`), without aborting the whole parse on one bad
    /// entry. Use [`Satellite::from_tle`] per record when you need the error.
    pub skipped: usize,
}

/// Parse a multi-record TLE file (CelesTrak / Space-Track style) into satellites
/// with their names. Uses the default `Improved` opsmode.
///
/// Handles, in a single pass, the common variants: bare 2-line element sets,
/// 3-line sets (a name line followed by lines 1 and 2), and CelesTrak `0 NAME`
/// name lines. Blank lines, CRLF endings, and surrounding whitespace are
/// tolerated. A record whose element set fails SGP4 initialization is skipped
/// and counted in [`TleFile::skipped`] rather than aborting the whole file.
pub fn parse_tle_file(text: &str) -> TleFile {
    parse_tle_file_with_opsmode(text, OpsMode::Improved)
}

/// [`parse_tle_file`] with an explicit [`OpsMode`].
pub fn parse_tle_file_with_opsmode(text: &str, opsmode: OpsMode) -> TleFile {
    let lines: Vec<&str> = text.lines().map(str::trim).collect();
    let mut satellites = Vec::new();
    let mut skipped = 0usize;
    let mut pending_name = String::new();
    let mut i = 0;
    while i < lines.len() {
        let line = lines[i];
        if line.is_empty() {
            i += 1;
            continue;
        }
        if line.starts_with("1 ") {
            // A line 1: the next non-empty line must be its line 2.
            let mut j = i + 1;
            while j < lines.len() && lines[j].is_empty() {
                j += 1;
            }
            if j < lines.len() && lines[j].starts_with("2 ") {
                if let Ok(satellite) = Satellite::from_tle_with_opsmode(line, lines[j], opsmode) {
                    satellites.push(NamedSatellite {
                        name: std::mem::take(&mut pending_name),
                        satellite,
                    });
                } else {
                    skipped += 1;
                    pending_name.clear();
                }
                i = j + 1;
                continue;
            }
            // Stray line 1 with no following line 2: drop the pending name, resync.
            pending_name.clear();
            i += 1;
            continue;
        }
        if line.starts_with("2 ") {
            // Stray line 2 with no preceding line 1: skip it and drop any pending
            // name so it can't attach to a later record.
            pending_name.clear();
            i += 1;
            continue;
        }
        // Any other non-empty line is a name line (3LE name or CelesTrak "0 NAME").
        pending_name = line.strip_prefix("0 ").unwrap_or(line).trim().to_string();
        i += 1;
    }
    TleFile {
        satellites,
        skipped,
    }
}

#[cfg(test)]
mod tests {
    use super::{
        parse_tle_file, propagate_batch, propagate_batch_parallel, propagate_elements, DecayLatch,
        DecayLatchedError, ElementSet, Error, JulianDate, MinutesSinceEpoch, Satellite,
        Sgp4InputErrorKind, MAX_MINUTES_SINCE_EPOCH,
    };

    /// A TLE carrying a multibyte character inside a fixed-width column must
    /// return a typed [`Error::InvalidTle`] rather than panicking. The field
    /// extractor slices by byte column (`l1[18..20]`, ...); a non-ASCII byte
    /// inside such a window used to panic on a non-char-boundary slice. The
    /// ASCII guard now rejects the line with a typed error; valid input parses.
    #[test]
    fn non_ascii_tle_returns_invalid_tle_not_panic() {
        let line1 = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9993";
        let line2 = "2 25544  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
        assert!(
            Satellite::from_tle(line1, line2).is_ok(),
            "clean ASCII TLE must still parse"
        );

        // Drop a 3-byte character into the epoch-year column (bytes 18..20),
        // straddling byte 20 so the pre-guard `l1[18..20]` slice would panic.
        let mut bad1 = String::from(&line1[..18]);
        bad1.push('\u{20ac}');
        bad1.push_str(&line1[19..]);
        assert!(
            !bad1.is_char_boundary(20),
            "corruption must straddle byte 20"
        );

        let err = Satellite::from_tle(&bad1, line2).expect_err("non-ASCII TLE must not parse");
        assert!(
            matches!(err, Error::InvalidTle(_)),
            "expected a typed InvalidTle error, got: {err:?}"
        );
    }

    // ── Forgiving-inbound leniency (now that `from_tle` flows through the
    // canonical IR via the lenient `tle` parser) ───────────────────────────

    const ISS_L1: &str = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9993";
    const ISS_L2: &str = "2 25544  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";

    #[test]
    fn parse_tle_file_three_line_captures_names() {
        let text = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\nSECOND SAT\n{ISS_L1}\n{ISS_L2}\n");
        let f = parse_tle_file(&text);
        assert_eq!(f.satellites.len(), 2);
        assert_eq!(f.skipped, 0);
        assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
        assert_eq!(f.satellites[1].name, "SECOND SAT");
        assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
        assert_eq!(f.satellites[0].satellite.line2(), ISS_L2);
    }

    #[test]
    fn parse_tle_file_bare_two_line_has_empty_name() {
        let f = parse_tle_file(&format!("{ISS_L1}\n{ISS_L2}"));
        assert_eq!(f.satellites.len(), 1);
        assert_eq!(f.satellites[0].name, "");
        assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
    }

    #[test]
    fn parse_tle_file_strips_celestrak_zero_name_marker() {
        let f = parse_tle_file(&format!("0 ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}"));
        assert_eq!(f.satellites.len(), 1);
        assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
    }

    #[test]
    fn parse_tle_file_tolerates_crlf_blanks_and_whitespace() {
        let text = format!("\r\n  ISS (ZARYA)  \r\n{ISS_L1}\r\n\r\n{ISS_L2}\r\n\r\n");
        let f = parse_tle_file(&text);
        assert_eq!(f.satellites.len(), 1);
        assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
        assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
    }

    #[test]
    fn parse_tle_file_skips_malformed_record_and_counts_it() {
        // A bad record (line1/line2 that fail SGP4 init) sits between two good
        // ones; it is skipped, counted, and its name does not attach to the next.
        let text = format!(
            "GOOD ONE\n{ISS_L1}\n{ISS_L2}\nBAD ONE\n1 not a real line\n2 not a real line\nGOOD TWO\n{ISS_L1}\n{ISS_L2}\n"
        );
        let f = parse_tle_file(&text);
        assert_eq!(
            f.satellites.len(),
            2,
            "the malformed record must be skipped"
        );
        assert_eq!(f.skipped, 1, "the skipped record must be counted");
        assert_eq!(f.satellites[0].name, "GOOD ONE");
        assert_eq!(f.satellites[1].name, "GOOD TWO");
    }

    #[test]
    fn parse_tle_file_stray_line2_does_not_leak_name() {
        // A name line followed by a stray line 2 (no line 1), then a bare valid
        // 2-line set: the stray name must NOT attach to the later record.
        let text = format!("ORPHAN NAME\n2 stray line two\n{ISS_L1}\n{ISS_L2}\n");
        let f = parse_tle_file(&text);
        assert_eq!(f.satellites.len(), 1);
        assert_eq!(f.satellites[0].name, "", "stray name must not leak forward");
    }

    fn iss_elements() -> ElementSet {
        crate::astro::tle::parse(ISS_L1, ISS_L2)
            .unwrap()
            .elements
            .to_element_set()
            .expect("valid TLE bridge")
    }

    fn assert_invalid_input<T>(
        result: Result<T, Error>,
        field: &'static str,
        kind: Sgp4InputErrorKind,
    ) {
        match result {
            Err(Error::InvalidInput {
                field: actual_field,
                kind: actual_kind,
            }) => {
                assert_eq!(actual_field, field);
                assert_eq!(actual_kind, kind);
            }
            Err(err) => panic!("expected InvalidInput({field}, {kind}), got {err:?}"),
            Ok(_) => panic!("expected InvalidInput({field}, {kind}), got Ok"),
        }
    }

    /// Assert two satellites are bit-identical (same cached epoch and same
    /// propagated state at several offsets).
    fn assert_same(a: &Satellite, b: &Satellite) {
        let (ea, eb) = (a.epoch_jd(), b.epoch_jd());
        assert_eq!(
            (ea.0.to_bits(), ea.1.to_bits()),
            (eb.0.to_bits(), eb.1.to_bits()),
            "epoch JD differs"
        );
        for &t in &[0.0, 100.0, 1440.0] {
            let pa = a.propagate(MinutesSinceEpoch(t)).unwrap();
            let pb = b.propagate(MinutesSinceEpoch(t)).unwrap();
            for axis in 0..3 {
                assert_eq!(
                    pa.position[axis].to_bits(),
                    pb.position[axis].to_bits(),
                    "position[{axis}] differs at t={t}"
                );
                assert_eq!(
                    pa.velocity[axis].to_bits(),
                    pb.velocity[axis].to_bits(),
                    "velocity[{axis}] differs at t={t}"
                );
            }
        }
    }

    #[test]
    fn serde_round_trips_tle_satellites() {
        let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
        let encoded = serde_json::to_string(&sat).unwrap();
        assert!(encoded.contains("\"line1\""));
        assert!(encoded.contains("\"line2\""));
        assert!(!encoded.contains("\"elements\""));

        let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
        assert_eq!(decoded.line1(), ISS_L1);
        assert_eq!(decoded.line2(), ISS_L2);
        assert_same(&sat, &decoded);
    }

    #[test]
    fn serde_round_trips_element_built_satellites() {
        let elements = iss_elements();
        let sat = Satellite::from_elements(&elements).unwrap();
        let encoded = serde_json::to_string(&sat).unwrap();
        assert!(encoded.contains("\"elements\""));
        assert!(encoded.contains("\"opsmode\""));
        assert!(!encoded.contains("\"line1\""));
        assert!(!encoded.contains("\"line2\""));

        let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
        assert!(decoded.line1().is_empty());
        assert!(decoded.line2().is_empty());
        assert_same(&sat, &decoded);
    }

    #[test]
    fn from_elements_rejects_non_finite_fields_before_sgp4init() {
        let mut elements = iss_elements();
        elements.bstar = f64::NAN;

        assert_invalid_input(
            Satellite::from_elements(&elements),
            "element.bstar",
            Sgp4InputErrorKind::NonFinite,
        );
    }

    #[test]
    fn from_elements_rejects_sgp4_domain_before_sgp4init() {
        let mut elements = iss_elements();
        elements.mean_motion_rev_per_day = 0.0;
        assert_invalid_input(
            Satellite::from_elements(&elements),
            "element.mean_motion_rev_per_day",
            Sgp4InputErrorKind::NotPositive,
        );

        let mut elements = iss_elements();
        elements.eccentricity = -0.1;
        assert_invalid_input(
            Satellite::from_elements(&elements),
            "element.eccentricity",
            Sgp4InputErrorKind::OutOfRange,
        );

        let mut elements = iss_elements();
        elements.eccentricity = 1.0;
        assert_invalid_input(
            Satellite::from_elements(&elements),
            "element.eccentricity",
            Sgp4InputErrorKind::OutOfRange,
        );

        let mut elements = iss_elements();
        elements.catalog_number = 100_000;
        assert_invalid_input(
            Satellite::from_elements(&elements),
            "element.catalog_number",
            Sgp4InputErrorKind::OutOfRange,
        );
    }

    #[test]
    fn from_elements_rejects_invalid_epoch() {
        let mut elements = iss_elements();
        elements.epoch = JulianDate(f64::NAN, 0.0);
        assert_invalid_input(
            Satellite::from_elements(&elements),
            "element.epoch.whole",
            Sgp4InputErrorKind::NonFinite,
        );

        let mut elements = iss_elements();
        elements.epoch = JulianDate(9_000_000.0, 0.0);
        assert_invalid_input(
            Satellite::from_elements(&elements),
            "element.epoch",
            Sgp4InputErrorKind::OutOfRange,
        );
    }

    #[test]
    fn from_elements_accepts_full_julian_epoch() {
        let mut elements = iss_elements();
        elements.epoch = super::sgp4_julian_date_from_calendar(2057, 1, 1, 0, 0, 0.0);
        Satellite::from_elements(&elements).expect("full 2057 epoch is valid");
    }

    #[test]
    fn from_tle_accepts_epoch_after_parser_conversion_to_full_jd() {
        let mut line1 = ISS_L1.to_string();
        line1.replace_range(18..32, "19366.00000000");

        Satellite::from_tle(&line1, ISS_L2).expect("TLE epoch is converted to full JD");
    }

    #[test]
    fn propagation_rejects_non_finite_time_inputs() {
        let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
        assert_invalid_input(
            sat.propagate(MinutesSinceEpoch(f64::NAN)),
            "minutes_since_epoch",
            Sgp4InputErrorKind::NonFinite,
        );
        assert_invalid_input(
            sat.propagate_jd(JulianDate(f64::INFINITY, 0.0)),
            "julian_date.whole",
            Sgp4InputErrorKind::NonFinite,
        );

        let elements = iss_elements();
        assert_invalid_input(
            propagate_elements(&elements, MinutesSinceEpoch(f64::INFINITY)),
            "minutes_since_epoch",
            Sgp4InputErrorKind::NonFinite,
        );
    }

    #[test]
    fn propagation_rejects_out_of_domain_time_inputs() {
        let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
        assert_invalid_input(
            sat.propagate(MinutesSinceEpoch(MAX_MINUTES_SINCE_EPOCH.next_up())),
            "minutes_since_epoch",
            Sgp4InputErrorKind::OutOfRange,
        );
        assert_invalid_input(
            sat.propagate_jd(JulianDate(2_458_304.0, 1.0)),
            "julian_date.fraction",
            Sgp4InputErrorKind::OutOfRange,
        );
    }

    #[test]
    fn lenient_trailing_whitespace_and_content_past_col_69() {
        let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();

        // Trailing whitespace on both lines.
        let pad = Satellite::from_tle(&format!("{ISS_L1}   "), &format!("{ISS_L2}\t ")).unwrap();
        assert_same(&clean, &pad);

        // Extra content past the 69-column record (CelesTrak/Space-Track blobs
        // sometimes carry it); it is trimmed before parsing.
        let extra =
            Satellite::from_tle(&format!("{ISS_L1} EXTRA-JUNK"), &format!("{ISS_L2} 999999"))
                .unwrap();
        assert_same(&clean, &extra);
    }

    #[test]
    fn lenient_leading_dot_and_assumed_decimal_fields() {
        // The ISS TLE already carries a leading-dot first derivative
        // (` .00001614`), an assumed-decimal B\* (`31745-4`), and the implicit
        // `0.` eccentricity (`0003435`). A successful parse + finite LEO state
        // proves those normalizations run through the public entry point.
        let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
        let p = sat.propagate(MinutesSinceEpoch(0.0)).unwrap();
        let r = (p.position[0].powi(2) + p.position[1].powi(2) + p.position[2].powi(2)).sqrt();
        assert!(
            (6500.0..=7200.0).contains(&r),
            "ISS radius {r} km outside LEO"
        );
    }

    #[test]
    fn lenient_missing_optional_bookkeeping_fields() {
        // Blank element-set number, ephemeris type, and revolution number are
        // cosmetic for propagation and must default rather than reject. Build
        // such a TLE by blanking those columns (cols 63, 65-68 on line 1 and
        // 64-68 on line 2) while leaving the orbital fields intact.
        let l1: String = ISS_L1
            .char_indices()
            .map(|(i, c)| {
                if i == 62 || (64..=67).contains(&i) {
                    ' '
                } else {
                    c
                }
            })
            .collect();
        let l2: String = ISS_L2
            .char_indices()
            .map(|(i, c)| if (63..=67).contains(&i) { ' ' } else { c })
            .collect();
        // Same orbital elements as the clean TLE → bit-identical propagation
        // (the blanked fields do not feed SGP4).
        let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
        let blanked = Satellite::from_tle(&l1, &l2).unwrap();
        assert_same(&clean, &blanked);
    }

    #[test]
    fn three_line_form_strips_name_line() {
        let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();

        let block = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\n");
        let three = Satellite::from_3line(&block).unwrap();
        assert_same(&clean, &three);

        // A plain two-line block (no name line) also parses.
        let two = Satellite::from_3line(&format!("{ISS_L1}\n{ISS_L2}")).unwrap();
        assert_same(&clean, &two);
    }

    #[test]
    fn three_line_form_rejects_block_without_element_lines() {
        assert!(Satellite::from_3line("just a name\nand some text").is_err());
        assert!(Satellite::from_3line("").is_err());
    }

    // ── Batch propagation ───────────────────────────────────────────────

    // A second, distinct clean LEO TLE (Tiangong / CSS) so the batch carries
    // more than one well-behaved satellite.
    const CSS_L1: &str = "1 48274U 21035A   24001.50000000  .00015000  00000-0  18000-3 0  9990";
    const CSS_L2: &str = "2 48274  41.4700 100.0000 0006000  90.0000 270.0000 15.61000000 10000";

    // NORAD 28872 from the Vallado verification set: a fast-decaying object that
    // propagates cleanly at small tsince but returns SGP4 error code 6 (decayed)
    // by t = 1440 min. Used to prove per-satellite error isolation. Trailing
    // content past column 69 is tolerated by the lenient parser.
    const DECAY_L1: &str = "1 28872U 05037B   05333.02012661  .25992681  00000-0  24476-3 0  1534";
    const DECAY_L2: &str = "2 28872  96.4736 157.9986 0303955 244.0492 110.6523 16.46015938 10708";

    fn batch_times() -> Vec<MinutesSinceEpoch> {
        // 33 epochs spaced 45 min over a day: enough work for the parallel pool
        // to interleave many independent arcs.
        (0..33)
            .map(|i| MinutesSinceEpoch(i as f64 * 45.0))
            .collect()
    }

    /// The batch result must equal a hand-rolled loop of the single-satellite
    /// `Satellite::propagate` path, bit-for-bit, for every satellite, epoch, and
    /// axis. This anchors the batch to the proven 0-ULP single-shot kernel.
    #[test]
    fn batch_is_bit_identical_to_per_satellite_propagate() {
        let satellites = [
            Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
            Satellite::from_tle(CSS_L1, CSS_L2).unwrap(),
        ];
        let times = batch_times();

        let batch = propagate_batch(&satellites, &times);
        assert_eq!(batch.len(), satellites.len());

        for (sat_idx, satellite) in satellites.iter().enumerate() {
            let arc = batch[sat_idx]
                .as_ref()
                .expect("clean satellite arc must be Ok");
            assert_eq!(arc.len(), times.len());
            for (epoch_idx, &t) in times.iter().enumerate() {
                let reference = satellite.propagate(t).expect("per-sat propagate ok");
                for axis in 0..3 {
                    assert_eq!(
                        arc[epoch_idx].position[axis].to_bits(),
                        reference.position[axis].to_bits(),
                        "position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
                    );
                    assert_eq!(
                        arc[epoch_idx].velocity[axis].to_bits(),
                        reference.velocity[axis].to_bits(),
                        "velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
                    );
                }
            }
        }
    }

    /// The rayon-parallel batch must equal the serial batch to_bits, element by
    /// element. Independent arcs, indexed collect, no reordering inside an arc.
    #[test]
    fn parallel_batch_is_bit_identical_to_serial() {
        let satellites = [
            Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
            Satellite::from_tle(CSS_L1, CSS_L2).unwrap(),
            Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
        ];
        let times = batch_times();

        let serial = propagate_batch(&satellites, &times);
        let parallel = propagate_batch_parallel(&satellites, &times);
        assert_eq!(serial.len(), parallel.len());

        for sat_idx in 0..satellites.len() {
            let s = serial[sat_idx].as_ref().expect("serial arc ok");
            let p = parallel[sat_idx].as_ref().expect("parallel arc ok");
            assert_eq!(s.len(), p.len());
            for epoch_idx in 0..times.len() {
                for axis in 0..3 {
                    assert_eq!(
                        s[epoch_idx].position[axis].to_bits(),
                        p[epoch_idx].position[axis].to_bits(),
                        "position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
                    );
                    assert_eq!(
                        s[epoch_idx].velocity[axis].to_bits(),
                        p[epoch_idx].velocity[axis].to_bits(),
                        "velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
                    );
                }
            }
        }
    }

    /// One failing satellite must not poison the rest of the batch: it yields an
    /// `Err` in its own slot while the clean satellites return full arcs. The
    /// decaying object errors at t = 1440 min but is fine earlier; a clean
    /// satellite spans the same grid without error.
    #[test]
    fn failing_satellite_yields_per_item_error_without_poisoning_batch() {
        let clean_a = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
        let decay = Satellite::from_tle(DECAY_L1, DECAY_L2).unwrap();
        let clean_b = Satellite::from_tle(CSS_L1, CSS_L2).unwrap();

        // Pre-flight: the decaying object really does error somewhere on the grid
        // while a clean satellite does not. Guards the fixture against drift.
        let times: Vec<MinutesSinceEpoch> = (0..=24)
            .map(|i| MinutesSinceEpoch(i as f64 * 120.0))
            .collect();
        assert!(
            times.iter().any(|&t| decay.propagate(t).is_err()),
            "decaying fixture must error on the grid"
        );
        assert!(
            times.iter().all(|&t| clean_a.propagate(t).is_ok()),
            "clean fixture must span the grid"
        );

        let satellites = [clean_a, decay, clean_b];
        for batch in [
            propagate_batch(&satellites, &times),
            propagate_batch_parallel(&satellites, &times),
        ] {
            assert_eq!(batch.len(), 3);
            // The two clean satellites are unaffected by the bad one.
            assert!(batch[0].is_ok(), "clean satellite 0 must survive");
            assert_eq!(batch[0].as_ref().unwrap().len(), times.len());
            assert!(batch[2].is_ok(), "clean satellite 2 must survive");
            assert_eq!(batch[2].as_ref().unwrap().len(), times.len());
            // The decaying satellite surfaces a typed SGP4 error in its own slot.
            assert!(
                matches!(batch[1], Err(Error::Sgp4 { .. })),
                "decaying satellite must yield an SGP4 error, got {:?}",
                batch[1]
            );
        }
    }

    /// Oracle provenance: NORAD 28872 is the Vallado verification-set
    /// high-drag object. The committed `sgp4_verification.json` pins
    /// `tsince = 1440.0` to error code 6. The raw `tsince = 1450.0`
    /// assertions compare stateless calls before and after latch use
    /// bit-for-bit, proving the opt-in latch leaves raw propagation untouched.
    #[test]
    fn decay_latch_reports_later_epochs_decayed_without_changing_raw_propagation() {
        let satellite = Satellite::from_tle(DECAY_L1, DECAY_L2).unwrap();
        let t_decay = MinutesSinceEpoch(1440.0);
        let t_later = MinutesSinceEpoch(1450.0);

        assert_eq!(
            satellite.propagate(t_decay).unwrap_err(),
            Error::Sgp4 { code: 6 },
            "Vallado fixture must report decayed at 1440 min"
        );

        let raw_later = satellite
            .propagate(t_later)
            .expect("raw Vallado propagation resumes at 1450 min");

        let mut latch = DecayLatch::new();
        let first = satellite
            .propagate_with_decay_latch(t_decay, &mut latch)
            .expect_err("latched propagation must report the first decay epoch");
        assert_eq!(
            first,
            DecayLatchedError::Decayed {
                first_failing_epoch: t_decay,
                requested_epoch: t_decay,
            }
        );
        assert_eq!(latch.first_failing_epoch(), Some(t_decay));

        let later = satellite
            .propagate_with_decay_latch(t_later, &mut latch)
            .expect_err("later latched propagation must not emit a raw state");
        assert_eq!(
            later,
            DecayLatchedError::Decayed {
                first_failing_epoch: t_decay,
                requested_epoch: t_later,
            }
        );

        let raw_after_latch = satellite
            .propagate(t_later)
            .expect("raw propagation must remain stateless after latch use");
        assert_eq!(
            raw_after_latch.position.map(f64::to_bits),
            raw_later.position.map(f64::to_bits),
            "raw position bits must not change after latch use"
        );
        assert_eq!(
            raw_after_latch.velocity.map(f64::to_bits),
            raw_later.velocity.map(f64::to_bits),
            "raw velocity bits must not change after latch use"
        );

        let earlier = satellite
            .propagate_with_decay_latch(MinutesSinceEpoch(120.0), &mut latch)
            .expect("earlier epochs remain available through the latch");
        let raw_earlier = satellite
            .propagate(MinutesSinceEpoch(120.0))
            .expect("raw earlier epoch ok");
        assert_eq!(
            earlier.position.map(f64::to_bits),
            raw_earlier.position.map(f64::to_bits)
        );
        assert_eq!(
            earlier.velocity.map(f64::to_bits),
            raw_earlier.velocity.map(f64::to_bits)
        );
    }

    #[test]
    fn batch_handles_empty_inputs() {
        let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
        let times = batch_times();

        // No satellites: empty result.
        assert!(propagate_batch(&[], &times).is_empty());
        assert!(propagate_batch_parallel(&[], &times).is_empty());

        // No times: one empty arc per satellite (still Ok).
        let no_times = propagate_batch(std::slice::from_ref(&sat), &[]);
        assert_eq!(no_times.len(), 1);
        assert!(no_times[0].as_ref().unwrap().is_empty());
    }

    #[test]
    fn rejects_genuine_corruption() {
        // Empty.
        assert!(Satellite::from_tle("", "").is_err());
        // Non-TLE text.
        assert!(Satellite::from_tle("hello world", "goodbye world").is_err());
        // Swapped lines (line 2 first).
        assert!(Satellite::from_tle(ISS_L2, ISS_L1).is_err());
        // Mismatched satellite numbers between line 1 and line 2.
        let l2_wrong = "2 25545  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
        assert!(matches!(
            Satellite::from_tle(ISS_L1, l2_wrong),
            Err(Error::InvalidTle(_))
        ));
    }
}
