mawaqit 0.2.4

Islamic prayer times for Rust with high-latitude & polar-region support.
Documentation
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};

use crate::astronomy::solar::SolarTime;
use crate::astronomy::unit::{Angle, Coordinates};
use crate::models::parameters::Parameters;

/// Rule for approximating Fajr and Isha at high latitudes
#[derive(PartialEq, Debug, Copy, Clone)]
#[non_exhaustive]
pub enum HighLatitudeRule {
    /// Fajr won't be earlier than the midpoint of the night and Isha
    /// won't be later than the midpoint of the night. This is the default
    /// value to prevent Fajr and Isha crossing boundaries.
    MiddleOfTheNight,

    /// Fajr will never be earlier than the beginning of the last seventh of
    /// the night and Isha will never be later than the end of the first seventh of the night.
    ///
    /// This is recommended for locations above 48° latitude to prevent prayer
    /// times that would be difficult to perform.
    SeventhOfTheNight,

    /// The fajr/isha angle α determines a fraction t = α ÷ 60 of the night.
    /// Isha begins after the first t part; Fajr before the last t part.
    /// Example: 15° → t = 0.25 → Isha after the first quarter of the night.
    ///
    /// This can be used to prevent difficult fajr and isha times at certain locations.
    TwilightAngle,

    /// MWL 2009 Local Relative Estimation.
    ///
    /// Designed for the Muslim World League's 2009 high-latitude
    /// methodology (between 48.6° and 66.6° latitude) using their
    /// standard angles (18° Fajr / 17° Isha).  The percentage is
    /// resolved automatically inside [`PrayerTimes::try_new`] by
    /// scanning a full year at the working latitude.
    LocalRelativeEstimation,

    /// Deferred variant resolved inside [`PrayerTimes::try_new`].
    ///
    /// Evaluated via [`recommended()`](HighLatitudeRule::recommended) against
    /// the working latitude (original or fallback-resolved).
    Recommended,
}

impl HighLatitudeRule {
    /// Return the recommended [`HighLatitudeRule`] for the given coordinates.
    ///
    /// Based on MWL 2009 latitude zones:
    /// - |latitude| ≤ 48.6° → [`MiddleOfTheNight`](HighLatitudeRule::MiddleOfTheNight) (Zone 1 — no effect, angles always reachable)
    /// - 48.6° < |latitude| ≤ 66.6° → [`LocalRelativeEstimation`](HighLatitudeRule::LocalRelativeEstimation) (Zone 2)
    /// - |latitude| > 66.6° → [`MiddleOfTheNight`](HighLatitudeRule::MiddleOfTheNight) (Zone 3 — LRE not designed for polar)
    #[must_use]
    pub fn recommended(coordinates: Coordinates) -> Self {
        let abs_lat = coordinates.latitude.abs();
        if abs_lat > 66.6 {
            Self::MiddleOfTheNight
        } else if abs_lat > 48.6 {
            Self::LocalRelativeEstimation
        } else {
            Self::MiddleOfTheNight
        }
    }

    /// Scan a full calendar year and return the average Isha proportion
    /// of the night (`ratio = isha_length / night_length`).
    ///
    /// Per the MWL 2009 Arabic spec, only days where the sign is present
    /// AND not disturbed (day-to-day jump ≤ 10 min) are included in the
    /// average — days of disappearance or disturbance are excluded.
    pub fn compute_pct(coordinates: Coordinates, params: &Parameters) -> f64 {
        let year = Utc::now().year();
        let days_in_year = if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
            366
        } else {
            365
        };
        let jan1 = NaiveDate::from_ymd_opt(year, 1, 1).expect("valid date");

        let mut total = 0.0;
        let mut count = 0usize;
        let mut prev_isha: Option<DateTime<Utc>> = None;
        let mut prev_was_reachable = false;

        for day_offset in 0..days_in_year {
            let date = jan1 + Duration::days(day_offset);
            let tomorrow = date + Duration::days(1);

            let Ok(solar_today) = SolarTime::new(
                date.and_hms_opt(0, 0, 0).expect("valid time").and_utc(),
                coordinates,
            ) else {
                prev_isha = None;
                prev_was_reachable = false;
                continue;
            };
            let Ok(solar_tomorrow) = SolarTime::new(
                tomorrow.and_hms_opt(0, 0, 0).expect("valid time").and_utc(),
                coordinates,
            ) else {
                prev_isha = None;
                prev_was_reachable = false;
                continue;
            };

            let isha_angle = Angle::new(-params.isha_angle);

            let isha_time = solar_today.time_for_solar_angle(isha_angle, true);

            let include = if let Some(current) = isha_time
                && let Some(prev) = prev_isha
            {
                if prev_was_reachable {
                    // Both days reachable — exclude if jump > 10 min
                    let prev_today = date.and_time(prev.time()).and_utc();
                    let raw_diff = (current - prev_today).num_seconds() as f64 / 60.0;
                    let diff = if raw_diff.abs() > 720.0 {
                        let wrapped = if raw_diff > 0.0 {
                            raw_diff - 1440.0
                        } else {
                            raw_diff + 1440.0
                        };
                        wrapped.abs()
                    } else {
                        raw_diff.abs()
                    };
                    diff <= 10.0
                } else {
                    // Previous day was unreachable → reappearance day → exclude
                    false
                }
            } else if isha_time.is_some() {
                // First reachable day of the year — no baseline to judge
                true
            } else {
                false
            };

            if include {
                let night = solar_tomorrow
                    .sunrise
                    .expect("compute_pct only runs where sunrise exists")
                    .signed_duration_since(
                        solar_today
                            .sunset
                            .expect("compute_pct only runs where sunset exists"),
                    );
                let night_secs = night.num_seconds() as f64;
                if night_secs > 0.0 {
                    let isha_len = isha_time
                        .expect("include is only true when isha_time is Some")
                        .signed_duration_since(
                            solar_today
                                .sunset
                                .expect("compute_pct only runs where sunset exists"),
                        );
                    let ratio = isha_len.num_seconds() as f64 / night_secs;
                    total += ratio;
                    count += 1;
                }
            }

            prev_isha = isha_time;
            prev_was_reachable = isha_time.is_some();
        }

        if count == 0 {
            0.5
        } else {
            total / count as f64
        }
    }
}

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

    #[test]
    fn recommended_rule_lre_above_48_degrees() {
        let location = Coordinates::new(48.983226, -3.216649);

        assert_eq!(
            HighLatitudeRule::recommended(location),
            HighLatitudeRule::LocalRelativeEstimation
        );
    }

    #[test]
    fn recommended_rule_middle_of_night_below_48_degrees() {
        let location = Coordinates::new(45.983226, -3.216649);

        assert_eq!(
            HighLatitudeRule::recommended(location),
            HighLatitudeRule::MiddleOfTheNight
        );
    }

    #[test]
    fn recommended_rule_middle_of_night_above_66_degrees() {
        let location = Coordinates::new(70.0, 20.0);

        assert_eq!(
            HighLatitudeRule::recommended(location),
            HighLatitudeRule::MiddleOfTheNight
        );
    }

    #[test]
    fn compute_pct_brussels_is_reasonable() {
        let location = Coordinates::new(50.85, 4.35);
        let params = Parameters::new(18.0, 17.0);
        let pct = HighLatitudeRule::compute_pct(location, &params);

        assert!(
            pct > 0.1 && pct < 0.9,
            "Brussels pct should be between 0.1 and 0.9, got {pct}"
        );
    }

    #[test]
    fn compute_pct_oslo_is_reasonable() {
        let location = Coordinates::new(59.9094, 10.7349);
        let params = Parameters::new(18.0, 17.0);
        let pct = HighLatitudeRule::compute_pct(location, &params);

        assert!(
            pct > 0.1 && pct < 0.9,
            "Oslo pct should be between 0.1 and 0.9, got {pct}"
        );
    }

    #[test]
    fn compute_pct_equator_is_reasonable() {
        let location = Coordinates::new(0.0, 0.0);
        let params = Parameters::new(18.0, 17.0);
        let pct = HighLatitudeRule::compute_pct(location, &params);

        // At the equator night and day are ~12 h year round.
        // 17° Isha is ~(17/60) ≈ 0.28 of the night after sunset.
        // Pct should be reasonable (0 < pct < 1).
        assert!(
            pct > 0.0 && pct < 1.0,
            "Equator pct must be between 0 and 1, got {pct}"
        );
        // At the equator with 17° angle, expect roughly 0.25–0.35
        assert!(pct < 0.5, "Equator pct should be < 0.5, got {pct}");
    }
}