//! Pure schedule-math: given a `HH:MM` string and a reference instant, compute
//! the next UTC occurrence of that wall-clock time in the configured timezone.
//!
//! No side effects: no filesystem access, no clock reads.  The caller is
//! responsible for supplying the current instant via `now`.

use chrono::{DateTime, Duration, Local, NaiveTime, TimeZone, Utc};
use chrono_tz::Tz;

/// Return the next UTC instant at which the given timezone (or the
/// server-local timezone when `timezone` is `None`) will read `hhmm`.
///
/// If that wall-clock time has already passed today, returns tomorrow's
/// occurrence.  Returns `None` if `hhmm` is not a valid `HH:MM` string.
///
/// `now` is the reference instant.  Callers that want real-time behaviour
/// should pass `Utc::now()` — this is done automatically by the `io` layer's
/// [`crate::service::timer::next_occurrence_of_hhmm`] wrapper.
pub fn next_occurrence_at(
    hhmm: &str,
    timezone: Option<&str>,
    now: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
    let (hh, mm) = hhmm.split_once(':')?;
    let hours: u32 = hh.parse().ok()?;
    let minutes: u32 = mm.parse().ok()?;
    if hours > 23 || minutes > 59 {
        return None;
    }
    let target_time = NaiveTime::from_hms_opt(hours, minutes, 0)?;

    match timezone.and_then(|s| s.parse::<Tz>().ok()) {
        Some(tz) => {
            let now_tz = now.with_timezone(&tz);
            let today = now_tz.date_naive();
            let candidate = today.and_time(target_time);
            let candidate_tz = tz.from_local_datetime(&candidate).single()?;
            if candidate_tz > now_tz {
                Some(candidate_tz.to_utc())
            } else {
                let tomorrow = today + Duration::days(1);
                let tomorrow_candidate = tomorrow.and_time(target_time);
                let tomorrow_tz = tz.from_local_datetime(&tomorrow_candidate).single()?;
                Some(tomorrow_tz.to_utc())
            }
        }
        None => {
            // Fall back to host/container local timezone.
            let now_local = now.with_timezone(&Local);
            let today = now_local.date_naive();
            let candidate = today.and_time(target_time);
            let candidate_local = Local.from_local_datetime(&candidate).single()?;
            if candidate_local > now_local {
                Some(candidate_local.to_utc())
            } else {
                let tomorrow = today + Duration::days(1);
                let tomorrow_candidate = tomorrow.and_time(target_time);
                let tomorrow_local = Local.from_local_datetime(&tomorrow_candidate).single()?;
                Some(tomorrow_local.to_utc())
            }
        }
    }
}

// ── Tests ──────────────────────────────────────────────────────────────────

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

    /// Fixed reference instant: 2024-01-15 10:00:00 UTC (winter, GMT = UTC+0).
    fn now_utc() -> DateTime<Utc> {
        Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap()
    }

    // ── parse validation ──────────────────────────────────────────────────

    #[test]
    fn invalid_hhmm_missing_colon_returns_none() {
        assert!(next_occurrence_at("1430", Some("UTC"), now_utc()).is_none());
    }

    #[test]
    fn invalid_hhmm_bad_hours_returns_none() {
        assert!(next_occurrence_at("25:00", Some("UTC"), now_utc()).is_none());
    }

    #[test]
    fn invalid_hhmm_bad_minutes_returns_none() {
        assert!(next_occurrence_at("12:60", Some("UTC"), now_utc()).is_none());
    }

    #[test]
    fn midnight_is_valid() {
        assert!(next_occurrence_at("00:00", Some("UTC"), now_utc()).is_some());
    }

    #[test]
    fn last_minute_of_day_is_valid() {
        assert!(next_occurrence_at("23:59", Some("UTC"), now_utc()).is_some());
    }

    // ── schedule math in UTC ──────────────────────────────────────────────

    #[test]
    fn later_today_when_not_yet_passed_in_utc() {
        // now = 10:00 UTC, target = 14:30 UTC → fires same day
        let now = Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap();
        let result = next_occurrence_at("14:30", Some("UTC"), now).unwrap();
        let expected = Utc.with_ymd_and_hms(2024, 1, 15, 14, 30, 0).unwrap();
        assert_eq!(result, expected);
    }

    #[test]
    fn tomorrow_when_time_already_passed_in_utc() {
        // now = 15:00 UTC, target = 14:30 UTC → fires tomorrow
        let now = Utc.with_ymd_and_hms(2024, 1, 15, 15, 0, 0).unwrap();
        let result = next_occurrence_at("14:30", Some("UTC"), now).unwrap();
        let expected = Utc.with_ymd_and_hms(2024, 1, 16, 14, 30, 0).unwrap();
        assert_eq!(result, expected);
    }

    #[test]
    fn exact_minute_match_fires_tomorrow_utc() {
        // now == target: not strictly in the future → fires tomorrow
        let now = Utc.with_ymd_and_hms(2024, 1, 15, 14, 30, 0).unwrap();
        let result = next_occurrence_at("14:30", Some("UTC"), now).unwrap();
        let expected = Utc.with_ymd_and_hms(2024, 1, 16, 14, 30, 0).unwrap();
        assert_eq!(result, expected);
    }

    #[test]
    fn result_is_always_after_now() {
        let now = now_utc();
        let result = next_occurrence_at("14:30", Some("UTC"), now).unwrap();
        assert!(result > now, "result {result} must be after now {now}");
    }

    // ── timezone round-trips ──────────────────────────────────────────────

    #[test]
    fn europe_london_gmt_no_offset_winter() {
        // Winter: GMT = UTC+0 → 14:30 London = 14:30 UTC
        let now = Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap();
        let result = next_occurrence_at("14:30", Some("Europe/London"), now).unwrap();
        let expected = Utc.with_ymd_and_hms(2024, 1, 15, 14, 30, 0).unwrap();
        assert_eq!(result, expected);
    }

    #[test]
    fn europe_london_bst_plus_one_summer() {
        // Summer: BST = UTC+1 → 14:30 London = 13:30 UTC
        // now = 2024-07-15 10:00 UTC = 11:00 BST (before 14:30 BST)
        let now = Utc.with_ymd_and_hms(2024, 7, 15, 10, 0, 0).unwrap();
        let result = next_occurrence_at("14:30", Some("Europe/London"), now).unwrap();
        let expected = Utc.with_ymd_and_hms(2024, 7, 15, 13, 30, 0).unwrap();
        assert_eq!(result, expected);
    }

    #[test]
    fn america_new_york_est_fires_tomorrow() {
        // EST = UTC-5; now = 2024-01-15 20:00 UTC = 15:00 EST
        // target = 14:30 EST already passed today → fires tomorrow 14:30 EST = 19:30 UTC
        let now = Utc.with_ymd_and_hms(2024, 1, 15, 20, 0, 0).unwrap();
        let result = next_occurrence_at("14:30", Some("America/New_York"), now).unwrap();
        let expected = Utc.with_ymd_and_hms(2024, 1, 16, 19, 30, 0).unwrap();
        assert_eq!(result, expected);
    }

    #[test]
    fn america_new_york_est_fires_today() {
        // EST = UTC-5; now = 2024-01-15 10:00 UTC = 05:00 EST
        // target = 14:30 EST not yet reached → fires today: 14:30 EST = 19:30 UTC
        let now = Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap();
        let result = next_occurrence_at("14:30", Some("America/New_York"), now).unwrap();
        let expected = Utc.with_ymd_and_hms(2024, 1, 15, 19, 30, 0).unwrap();
        assert_eq!(result, expected);
    }

    #[test]
    fn invalid_timezone_falls_back_to_local_and_returns_some() {
        // Unrecognised timezone falls back to chrono::Local — still returns Some.
        let result = next_occurrence_at("14:30", Some("Invalid/Zone"), now_utc());
        assert!(
            result.is_some(),
            "invalid timezone should fall back to local and return Some"
        );
    }
}
