//! Provider-neutral data-freshness classification: `current` / `stale` /
//! `unknown`, grounded in official product validities.
//!
//! - METAR/SPECI currency from the NWS (the routine hourly observation is
//!   "valid … for 1 hour"): current for [`METAR_MAX_AGE_MINUTES`], then stale.
//! - Forecast/advisory windows from FAA AC 00-45H — TAF (24/30 h), SIGMET
//!   (4 h; 6 h for volcanic ash / tropical cyclone), AIRMET (6 h; 8 h Alaska),
//!   G-AIRMET (to 12 h), CWA (2 h) — are window-based, keyed off the product's
//!   own validity end, never a synthesized age.
//! - NOTAM/TFR windows from FAA Order JO 7930.2U (`[start, end]`; an EST end
//!   still auto-expires; PERM has no end and stays current).
//! - Navdata from the ICAO AIRAC 28-day cycle (Annex 15 §6.2; AIM 1-1-17):
//!   current until the next cycle's effective date.
//! - PIREPs carry no official validity (AC 00-45H), so they classify
//!   `unknown` rather than guess an age.
//!
//! Pure: the caller injects the reference instant (`now`/`today`); this crate
//! has no clock, so every rule is deterministic and unit-tested against the
//! cited sources.

use chrono::{DateTime, NaiveDate, TimeDelta, Utc};
use serde::{Deserialize, Serialize};

use crate::model::ProductKind;

/// Currency horizon of a METAR/SPECI observation, minutes. The routine ob is
/// issued hourly and treated as current for one hour (NWS); strict, no padding.
pub const METAR_MAX_AGE_MINUTES: i64 = 60;

/// How current a datum is relative to a reference instant.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Freshness {
    /// Within its validity window or currency horizon.
    Current,
    /// Past its validity window or currency horizon.
    Stale,
    /// No timestamp to judge by (the default).
    #[default]
    Unknown,
}

impl Freshness {
    /// Stable lowercase wire label.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Freshness::Current => "current",
            Freshness::Stale => "stale",
            Freshness::Unknown => "unknown",
        }
    }

    /// Concern rank: a report is only as fresh as its least-fresh item, and
    /// known-expired (`stale`) outranks can't-tell (`unknown`) outranks
    /// `current`.
    fn rank(self) -> u8 {
        match self {
            Freshness::Current => 0,
            Freshness::Unknown => 1,
            Freshness::Stale => 2,
        }
    }

    /// The least-fresh of `self` and `other`.
    #[must_use]
    pub fn worse(self, other: Freshness) -> Freshness {
        if other.rank() > self.rank() {
            other
        } else {
            self
        }
    }

    /// The least-fresh state across `items`; `unknown` when empty.
    #[must_use]
    pub fn worst(items: impl IntoIterator<Item = Freshness>) -> Freshness {
        items
            .into_iter()
            .reduce(Freshness::worse)
            .unwrap_or(Freshness::Unknown)
    }
}

/// Classify by expiry: `stale` once `now` is past `valid_to`, `current` while
/// `valid_to` is still ahead, `unknown` when there is no end to bound it.
///
/// Used for every windowed product — TAF, the SIGMET/AIRMET/CWA advisories,
/// NOTAMs, TFRs, route alerts — keyed off the product's own validity end. A
/// still-future `valid_from` is `current` (valid data, not yet active); a NOTAM
/// with no end (PERM) is `unknown` here, so callers that can prove PERM map it
/// to `current` themselves.
#[must_use]
pub fn classify_expiry(now: DateTime<Utc>, valid_to: Option<DateTime<Utc>>) -> Freshness {
    match valid_to {
        Some(end) if now > end => Freshness::Stale,
        Some(_) => Freshness::Current,
        None => Freshness::Unknown,
    }
}

/// Classify an observation by age: `current` while no older than `max_age`,
/// `stale` once older, `unknown` with no observation time. METAR/SPECI use this
/// with [`METAR_MAX_AGE_MINUTES`].
#[must_use]
pub fn classify_observation(
    now: DateTime<Utc>,
    observed_at: Option<DateTime<Utc>>,
    max_age: TimeDelta,
) -> Freshness {
    match observed_at {
        Some(at) if now.signed_duration_since(at) <= max_age => Freshness::Current,
        Some(_) => Freshness::Stale,
        None => Freshness::Unknown,
    }
}

/// Classify a weather [`Product`](crate::Product) by kind: METAR/SPECI by
/// observation age, PIREP as `unknown` (no official validity), everything else
/// (TAF, SIGMET, AIRMET, G-AIRMET, CWA) by its validity-window end.
#[must_use]
pub fn classify_product(
    now: DateTime<Utc>,
    kind: &ProductKind,
    issued_at: Option<DateTime<Utc>>,
    valid_to: Option<DateTime<Utc>>,
) -> Freshness {
    match kind {
        ProductKind::Metar => {
            classify_observation(now, issued_at, TimeDelta::minutes(METAR_MAX_AGE_MINUTES))
        }
        ProductKind::Pirep => Freshness::Unknown,
        _ => classify_expiry(now, valid_to),
    }
}

/// Classify a navdata snapshot from its AIRAC cycle: `current` until the next
/// cycle's effective date, `stale` on or after it — the half-open
/// `[effective_on, next_effective_on)` ICAO AIRAC model (Annex 15 §6.2).
#[must_use]
pub fn classify_cycle(today: NaiveDate, next_effective_on: NaiveDate) -> Freshness {
    if today >= next_effective_on {
        Freshness::Stale
    } else {
        Freshness::Current
    }
}

#[cfg(test)]
mod tests;
