//! Pilot-supplied aircraft performance, the input a route brief needs to
//! turn distance into time, altitude, and fuel.
//!
//! Every figure here is **advisory** and comes from the operator's POH/AFM
//! — this crate computes a simplified, no-wind estimate to drive briefing
//! requests (which forecast hour to ask for, how the route climbs and
//! descends), never a performance or fuel guarantee. The performance ring
//! is the highest-criticality advisory arithmetic in the system; treat its
//! outputs as planning hints, not dispatch numbers.

use serde::{Deserialize, Serialize};

/// Speed/rate/burn figures for one aircraft, loaded from the operator's
/// own data (e.g. a JSON profile file).
///
/// Non-exhaustive: future fields (per-altitude burn tables, weight-based
/// climb, true-vs-indicated correction) are additive.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AircraftProfile {
    /// Cruise true airspeed, knots.
    pub cruise_tas_kt: f64,
    /// Average climb rate to cruise, feet per minute.
    pub climb_rate_fpm: f64,
    /// True airspeed during climb, knots.
    pub climb_tas_kt: f64,
    /// Average descent rate from cruise, feet per minute.
    pub descent_rate_fpm: f64,
    /// True airspeed during descent, knots.
    pub descent_tas_kt: f64,
    /// Cruise fuel burn, gallons (or pounds) per hour — the unit is the
    /// operator's; this crate only divides and multiplies it.
    pub cruise_burn_per_hour: f64,
    /// Fuel burned starting, taxiing, and taking off, same unit.
    pub taxi_fuel: f64,
    /// Usable fuel capacity, same unit.
    pub fuel_capacity: f64,
    /// Required reserve, minutes of cruise burn (91.167/91.151 inputs).
    pub reserve_minutes: f64,
}

impl AircraftProfile {
    /// Hours to climb from `from_ft` to `to_ft` at the profile's climb
    /// rate; zero when already at or above the target.
    #[must_use]
    pub fn climb_hours(&self, from_ft: f64, to_ft: f64) -> f64 {
        if self.climb_rate_fpm <= 0.0 || to_ft <= from_ft {
            return 0.0;
        }
        (to_ft - from_ft) / self.climb_rate_fpm / 60.0
    }

    /// Hours to descend from `from_ft` to `to_ft` at the profile's
    /// descent rate; zero when already at or below the target.
    #[must_use]
    pub fn descent_hours(&self, from_ft: f64, to_ft: f64) -> f64 {
        if self.descent_rate_fpm <= 0.0 || from_ft <= to_ft {
            return 0.0;
        }
        (from_ft - to_ft) / self.descent_rate_fpm / 60.0
    }

    /// Reserve fuel implied by [`Self::reserve_minutes`] at cruise burn.
    #[must_use]
    pub fn reserve_fuel(&self) -> f64 {
        self.cruise_burn_per_hour * self.reserve_minutes / 60.0
    }

    /// Validate the profile for the advisory performance math: every figure
    /// finite, speeds and rates strictly positive, fuel figures non-negative,
    /// and the reserve within a sane bound. Returns the first failure.
    ///
    /// The cross-section builder calls this, so an out-of-range profile yields
    /// a typed error instead of a non-finite estimate or a panicking duration
    /// conversion.
    pub fn validate(&self) -> Result<(), ProfileError> {
        positive("cruise_tas_kt", self.cruise_tas_kt)?;
        positive("climb_tas_kt", self.climb_tas_kt)?;
        positive("descent_tas_kt", self.descent_tas_kt)?;
        positive("climb_rate_fpm", self.climb_rate_fpm)?;
        positive("descent_rate_fpm", self.descent_rate_fpm)?;
        non_negative("cruise_burn_per_hour", self.cruise_burn_per_hour)?;
        non_negative("taxi_fuel", self.taxi_fuel)?;
        positive("fuel_capacity", self.fuel_capacity)?;
        bounded("reserve_minutes", self.reserve_minutes, MAX_RESERVE_MIN)?;
        Ok(())
    }
}

/// Upper bound on a sane fuel reserve, minutes of cruise burn. Speeds and rates
/// carry no upper ceiling — only finite and positive — so an exotic high-speed
/// profile (hypersonic, reentry) is not rejected; the no-panic guarantee comes
/// from finiteness and the checked duration conversion, not a speed limit.
const MAX_RESERVE_MIN: f64 = 24.0 * 60.0;

/// A field failed [`AircraftProfile::validate`].
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ProfileError {
    /// A field was NaN or infinite.
    #[error("{field} must be a finite number, got {value}")]
    NotFinite {
        /// The offending field.
        field: &'static str,
        /// Its value.
        value: f64,
    },
    /// A field that must be strictly positive was zero or negative.
    #[error("{field} must be greater than zero, got {value}")]
    NotPositive {
        /// The offending field.
        field: &'static str,
        /// Its value.
        value: f64,
    },
    /// A field that must be non-negative was negative.
    #[error("{field} must not be negative, got {value}")]
    Negative {
        /// The offending field.
        field: &'static str,
        /// Its value.
        value: f64,
    },
    /// A field exceeded its sane upper bound.
    #[error("{field} {value} exceeds the maximum of {max}")]
    TooLarge {
        /// The offending field.
        field: &'static str,
        /// Its value.
        value: f64,
        /// The bound it broke.
        max: f64,
    },
}

/// Require a finite, strictly positive value (no upper ceiling).
fn positive(field: &'static str, value: f64) -> Result<(), ProfileError> {
    if !value.is_finite() {
        return Err(ProfileError::NotFinite { field, value });
    }
    if value <= 0.0 {
        return Err(ProfileError::NotPositive { field, value });
    }
    Ok(())
}

/// Require a finite, non-negative value (no upper ceiling).
fn non_negative(field: &'static str, value: f64) -> Result<(), ProfileError> {
    if !value.is_finite() {
        return Err(ProfileError::NotFinite { field, value });
    }
    if value < 0.0 {
        return Err(ProfileError::Negative { field, value });
    }
    Ok(())
}

/// Require a finite, non-negative value at most `max`.
fn bounded(field: &'static str, value: f64, max: f64) -> Result<(), ProfileError> {
    non_negative(field, value)?;
    if value > max {
        return Err(ProfileError::TooLarge { field, value, max });
    }
    Ok(())
}

#[cfg(test)]
mod tests;
