//! FIT `date_time` conversion.
//!
//! FIT counts seconds since **1989-12-31 00:00:00 UTC**, not the Unix epoch.
//! Feeding a Unix timestamp straight into a FIT file lands the activity in
//! 1989, which is one of the more common ways a hand-rolled encoder produces a
//! file that parses fine and is still useless.

use chrono::{DateTime, TimeZone, Utc};

use crate::FitError;

/// The FIT epoch expressed as a Unix timestamp: 1989-12-31T00:00:00Z.
pub const FIT_EPOCH_UNIX_SECS: i64 = 631_065_600;

/// The `date_time` invalid value. Also the boundary below which a raw value is
/// interpreted as a system time rather than a UTC timestamp.
pub const DATE_TIME_INVALID: u32 = 0xFFFF_FFFF;

/// `date_time` values below this are "system time" (seconds since power-on),
/// not wall-clock. We never emit one, but the check keeps us honest.
pub const DATE_TIME_MIN: u32 = 0x1000_0000;

/// Convert a UTC instant to a FIT `date_time`.
///
/// Fails for instants before the FIT epoch or beyond the `u32` range.
pub fn to_fit(dt: DateTime<Utc>) -> Result<u32, FitError> {
    from_unix_secs(dt.timestamp())
}

/// Convert Unix seconds to a FIT `date_time`.
pub fn from_unix_secs(unix_secs: i64) -> Result<u32, FitError> {
    let secs = unix_secs - FIT_EPOCH_UNIX_SECS;
    if secs < 0 {
        return Err(FitError::TimestampOutOfRange { unix_secs });
    }
    u32::try_from(secs).map_err(|_| FitError::TimestampOutOfRange { unix_secs })
}

/// Convert Unix milliseconds to a FIT `date_time`, rounding to the nearest
/// second. Sub-second resolution has no representation in `date_time`.
pub fn from_unix_millis(unix_millis: i64) -> Result<u32, FitError> {
    from_unix_secs(unix_millis.div_euclid(1000) + i64::from(unix_millis.rem_euclid(1000) >= 500))
}

/// Convert a FIT `date_time` back to a UTC instant. The inverse of [`to_fit`].
pub fn to_utc(fit: u32) -> DateTime<Utc> {
    Utc.timestamp_opt(i64::from(fit) + FIT_EPOCH_UNIX_SECS, 0)
        .single()
        .expect("every u32 offset from the FIT epoch is a representable instant")
}

/// Build a `local_timestamp`: the same instant expressed in the rider's local
/// time zone, still counted from the FIT epoch. Garmin Connect uses this to
/// show the ride at the time of day it actually happened.
pub fn to_local(fit_utc: u32, utc_offset_secs: i32) -> u32 {
    fit_utc.saturating_add_signed(utc_offset_secs)
}

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

    #[test]
    fn epoch_constant_is_1989_12_31_utc() {
        let epoch = Utc.with_ymd_and_hms(1989, 12, 31, 0, 0, 0).unwrap();
        assert_eq!(epoch.timestamp(), FIT_EPOCH_UNIX_SECS);
        assert_eq!(to_fit(epoch).unwrap(), 0);
    }

    #[test]
    fn is_not_the_unix_epoch() {
        // The whole point: a Unix timestamp is ~631 million seconds larger
        // than the FIT value for the same instant.
        let dt = Utc.with_ymd_and_hms(2026, 8, 5, 12, 0, 0).unwrap();
        let fit = to_fit(dt).unwrap();
        assert_eq!(i64::from(fit), dt.timestamp() - FIT_EPOCH_UNIX_SECS);
        assert_ne!(i64::from(fit), dt.timestamp());
    }

    #[test]
    fn known_values() {
        // 1990-01-01T00:00:00Z is exactly one day after the FIT epoch.
        assert_eq!(
            to_fit(Utc.with_ymd_and_hms(1990, 1, 1, 0, 0, 0).unwrap()).unwrap(),
            86_400
        );
        // 2020-01-01T00:00:00Z, computed independently:
        // (2020-01-01 unix 1577836800) - 631065600 = 946771200.
        assert_eq!(
            to_fit(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()).unwrap(),
            946_771_200
        );
    }

    #[test]
    fn round_trips_over_a_wide_range() {
        for &unix in &[
            FIT_EPOCH_UNIX_SECS,
            FIT_EPOCH_UNIX_SECS + 1,
            946_684_800,   // 2000-01-01
            1_600_000_000, // 2020-09
            1_785_000_000, // 2026-07
            4_000_000_000, // 2096
        ] {
            let fit = from_unix_secs(unix).unwrap();
            assert_eq!(to_utc(fit).timestamp(), unix, "round trip failed for {unix}");
        }
    }

    #[test]
    fn round_trips_through_datetime() {
        let dt = Utc.with_ymd_and_hms(2026, 8, 5, 9, 41, 17).unwrap();
        assert_eq!(to_utc(to_fit(dt).unwrap()), dt);
    }

    #[test]
    fn a_modern_timestamp_is_above_the_system_time_boundary() {
        // Decoders treat date_time < 0x10000000 as system (uptime) time. Any
        // ride recorded this decade must be well above it.
        let fit = to_fit(Utc.with_ymd_and_hms(2026, 8, 5, 0, 0, 0).unwrap()).unwrap();
        assert!(fit > DATE_TIME_MIN);
        assert!(fit < DATE_TIME_INVALID);
    }

    #[test]
    fn rejects_pre_epoch_and_reports_the_offending_value() {
        let err = to_fit(Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap()).unwrap_err();
        assert!(matches!(err, FitError::TimestampOutOfRange { unix_secs: 0 }));
        assert!(from_unix_secs(FIT_EPOCH_UNIX_SECS - 1).is_err());
    }

    #[test]
    fn millis_round_to_nearest_second() {
        let base = FIT_EPOCH_UNIX_SECS * 1000;
        assert_eq!(from_unix_millis(base).unwrap(), 0);
        assert_eq!(from_unix_millis(base + 499).unwrap(), 0);
        assert_eq!(from_unix_millis(base + 500).unwrap(), 1);
        assert_eq!(from_unix_millis(base + 1499).unwrap(), 1);
        assert_eq!(from_unix_millis(base + 1500).unwrap(), 2);
    }

    #[test]
    fn local_timestamp_applies_the_offset() {
        let utc = to_fit(Utc.with_ymd_and_hms(2026, 8, 5, 12, 0, 0).unwrap()).unwrap();
        assert_eq!(to_local(utc, 7200), utc + 7200); // CEST
        assert_eq!(to_local(utc, -18000), utc - 18000); // EST
        assert_eq!(to_local(utc, 0), utc);
    }
}
