//! Time-series generation via `Stream<Error, EciOutput>`.
//!
//! Produces a pull-based stream of orbital states at regular time intervals
//! using `Stream::unfold` from comp-cat-rs.  The stream is lazy: nothing
//! computes until `.collect().run()` or `.fold(...).run()` is called.

use std::rc::Rc;

use comp_cat_rs::effect::io::Io;
use comp_cat_rs::effect::stream::Stream;

use crate::coordinate::category::FrameTransform;
use crate::coordinate::geodetic::GeodeticPosition;
use crate::error::Error;
use crate::propagation::sgp4::{EciOutput, SgpInit};
use crate::time::epoch::{GmstAngle, JulianDate};
use crate::tle::newtypes::MinutesSinceEpoch;

// ---------------------------------------------------------------------------
// ECI time series
// ---------------------------------------------------------------------------

/// Generate a stream of ECI orbital states at regular time intervals.
///
/// Produces `count` states starting at `start` minutes since epoch,
/// advancing by `step_minutes` each iteration.
///
/// The stream is lazy; no propagation occurs until the stream is consumed.
#[must_use]
pub fn propagation_stream(
    init: &SgpInit,
    start: MinutesSinceEpoch,
    step_minutes: f64,
    count: usize,
) -> Stream<Error, EciOutput> {
    let init_clone = init.clone();
    Stream::unfold(
        (start, count),
        Rc::new(move |(time, remaining)| {
            if remaining == 0 {
                Io::pure(None)
            } else {
                crate::propagation::sgp4::propagate(&init_clone, time).map_or_else(
                    |err| Io::suspend(move || Err(err)),
                    |state| {
                        let next_time = MinutesSinceEpoch::new(time.value() + step_minutes);
                        Io::pure(Some((state, (next_time, remaining - 1))))
                    },
                )
            }
        }),
    )
}

// ---------------------------------------------------------------------------
// Geodetic time series
// ---------------------------------------------------------------------------

/// A geodetic position with a time tag (minutes since epoch).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TimedGeodetic {
    position: GeodeticPosition,
    minutes_since_epoch: MinutesSinceEpoch,
}

impl TimedGeodetic {
    #[must_use]
    pub fn position(&self) -> GeodeticPosition {
        self.position
    }

    #[must_use]
    pub fn minutes_since_epoch(&self) -> MinutesSinceEpoch {
        self.minutes_since_epoch
    }
}

/// Generate a stream of geodetic positions at regular time intervals.
///
/// Converts each ECI state to geodetic (lat, lon, alt) using the
/// coordinate frame category transforms.  GMST is computed from the
/// TLE epoch + time offset.
///
/// The stream is lazy; no propagation occurs until consumed.
#[must_use]
pub fn geodetic_stream(
    init: &SgpInit,
    start: MinutesSinceEpoch,
    step_minutes: f64,
    count: usize,
) -> Stream<Error, TimedGeodetic> {
    let init_clone = init.clone();
    let epoch_jd = init.epoch_jd();

    Stream::unfold(
        (start, count),
        Rc::new(move |(time, remaining)| {
            if remaining == 0 {
                Io::pure(None)
            } else {
                crate::propagation::sgp4::propagate(&init_clone, time).map_or_else(
                    |err| Io::suspend(move || Err(err)),
                    |eci_state| {
                        let current_jd = JulianDate::new(epoch_jd + time.value() / 1440.0);
                        let gmst = GmstAngle::from_julian_date(current_jd);
                        let geo_vec =
                            FrameTransform::EciToGeodetic(gmst).apply_position(eci_state.position_km());
                        let timed = TimedGeodetic {
                            position: GeodeticPosition::new(
                                crate::coordinate::geodetic::Latitude::from_radians_unchecked(
                                    geo_vec.x(),
                                ),
                                crate::coordinate::geodetic::Longitude::from_radians_unchecked(
                                    geo_vec.y(),
                                ),
                                crate::coordinate::geodetic::Altitude::new(geo_vec.z()),
                            ),
                            minutes_since_epoch: time,
                        };
                        let next_time = MinutesSinceEpoch::new(time.value() + step_minutes);
                        Io::pure(Some((timed, (next_time, remaining - 1))))
                    },
                )
            }
        }),
    )
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tle::parse::parse_tle;

    const ISS_L0: &str = "ISS (ZARYA)";
    const ISS_L1: &str =
        "1 25544U 98067A   24045.54339622  .00016717  00000-0  10270-3 0  9118";
    const ISS_L2: &str =
        "2 25544  51.6400 208.2546 0006703  44.2624  67.4637 15.50056823 23455";

    #[test]
    fn eci_stream_produces_correct_count() -> Result<(), Error> {
        let tle = parse_tle(ISS_L0, ISS_L1, ISS_L2).run()?;
        let init = SgpInit::from_tle(&tle)?;
        let stream = propagation_stream(
            &init,
            MinutesSinceEpoch::new(0.0),
            10.0,
            5,
        );
        let states = stream.collect().run()?;
        assert_eq!(states.len(), 5);
        Ok(())
    }

    #[test]
    fn eci_stream_states_differ() -> Result<(), Error> {
        let tle = parse_tle(ISS_L0, ISS_L1, ISS_L2).run()?;
        let init = SgpInit::from_tle(&tle)?;
        let stream = propagation_stream(
            &init,
            MinutesSinceEpoch::new(0.0),
            30.0,
            3,
        );
        let states = stream.collect().run()?;
        // 30 minutes apart, ISS positions should be very different
        let s0 = states.get(0).ok_or(Error::PropagationDiverged {
            context: "missing state 0",
        })?;
        let s1 = states.get(1).ok_or(Error::PropagationDiverged {
            context: "missing state 1",
        })?;
        let dist = (s0.position_km() - s1.position_km()).magnitude();
        assert!(dist > 1000.0, "positions too close: {dist} km");
        Ok(())
    }

    #[test]
    fn empty_stream_produces_empty_vec() -> Result<(), Error> {
        let tle = parse_tle(ISS_L0, ISS_L1, ISS_L2).run()?;
        let init = SgpInit::from_tle(&tle)?;
        let stream = propagation_stream(
            &init,
            MinutesSinceEpoch::new(0.0),
            10.0,
            0,
        );
        let states = stream.collect().run()?;
        assert!(states.is_empty());
        Ok(())
    }

    #[test]
    fn geodetic_stream_produces_valid_coordinates() -> Result<(), Error> {
        let tle = parse_tle(ISS_L0, ISS_L1, ISS_L2).run()?;
        let init = SgpInit::from_tle(&tle)?;
        let stream = geodetic_stream(
            &init,
            MinutesSinceEpoch::new(0.0),
            10.0,
            3,
        );
        let positions = stream.collect().run()?;
        assert_eq!(positions.len(), 3);

        // ISS inclination is 51.64 deg, so latitude should be within +/- ~52 deg
        positions.iter().for_each(|tg| {
            let lat_deg = tg.position().latitude().degrees();
            assert!(
                lat_deg.abs() < 55.0,
                "ISS latitude {lat_deg} deg out of expected range"
            );
            // Altitude should be ~400 km
            let alt = tg.position().altitude().km();
            assert!(
                alt > 300.0 && alt < 500.0,
                "ISS altitude {alt} km out of expected range"
            );
        });
        Ok(())
    }

    #[test]
    fn geodetic_stream_carries_time_tags() -> Result<(), Error> {
        let tle = parse_tle(ISS_L0, ISS_L1, ISS_L2).run()?;
        let init = SgpInit::from_tle(&tle)?;
        let stream = geodetic_stream(
            &init,
            MinutesSinceEpoch::new(100.0),
            5.0,
            3,
        );
        let positions = stream.collect().run()?;
        let get = |i: usize| {
            positions.get(i).ok_or(Error::PropagationDiverged {
                context: "missing position",
            })
        };
        assert!((get(0)?.minutes_since_epoch().value() - 100.0).abs() < 1e-10);
        assert!((get(1)?.minutes_since_epoch().value() - 105.0).abs() < 1e-10);
        assert!((get(2)?.minutes_since_epoch().value() - 110.0).abs() < 1e-10);
        Ok(())
    }
}
