use crate::{
    civil::{Date, DateTime, Weekday},
    error::{err, Error},
    util::{
        rangeint::RInto,
        t::{self, ISOWeek, ISOYear, C},
    },
    Zoned,
};

/// A type representing an [ISO 8601 week date].
///
/// The ISO 8601 week date scheme devises a calendar where days are identified
/// by their year, week number and weekday. All years have either precisely
/// 52 or 53 weeks.
///
/// The first week of an ISO 8601 year corresponds to the week containing the
/// first Thursday of the year. For this reason, an ISO 8601 week year can be
/// mismatched with the day's corresponding Gregorian year. For example, the
/// ISO 8601 week date for `1995-01-01` is `1994-W52-7` (with `7` corresponding
/// to Sunday).
///
/// ISO 8601 also considers Monday to be the start of the week, and uses
/// a 1-based numbering system. That is, Monday corresponds to `1` while
/// Sunday corresponds to `7` and is the last day of the week. Weekdays are
/// encapsulated by the [`Weekday`] type, which provides routines for easily
/// converting between different schemes (such as weeks where Sunday is the
/// beginning).
///
/// [ISO 8601 week date]: https://en.wikipedia.org/wiki/ISO_week_date
///
/// # Use case
///
/// Some domains use this method of timekeeping. Otherwise, unless you
/// specifically want a week oriented calendar, it's likely that you'll never
/// need to care about this type.
///
/// # Default value
///
/// For convenience, this type implements the `Default` trait. Its default
/// value is the first day of the zeroth year. i.e., `0000-W1-1`.
///
/// # Example: sample dates
///
/// This example shows a couple ISO 8601 week dates and their corresponding
/// Gregorian equivalents:
///
/// ```
/// use jiff::civil::{ISOWeekDate, Weekday, date};
///
/// let d = date(2019, 12, 30);
/// let weekdate = ISOWeekDate::new(2020, 1, Weekday::Monday).unwrap();
/// assert_eq!(d.to_iso_week_date(), weekdate);
///
/// let d = date(2024, 3, 9);
/// let weekdate = ISOWeekDate::new(2024, 10, Weekday::Saturday).unwrap();
/// assert_eq!(d.to_iso_week_date(), weekdate);
/// ```
///
/// # Example: overlapping leap and long years
///
/// A "long" ISO 8601 week year is a year with 53 weeks. That is, it is a year
/// that includes a leap week. This example shows all years in the 20th
/// century that are both Gregorian leap years and long years.
///
/// ```
/// use jiff::civil::date;
///
/// let mut overlapping = vec![];
/// for year in 1900..=1999 {
///     let date = date(year, 1, 1);
///     if date.in_leap_year() && date.to_iso_week_date().in_long_year() {
///         overlapping.push(year);
///     }
/// }
/// assert_eq!(overlapping, vec![
///     1904, 1908, 1920, 1932, 1936, 1948, 1960, 1964, 1976, 1988, 1992,
/// ]);
/// ```
#[derive(Clone, Copy, Hash)]
pub struct ISOWeekDate {
    year: ISOYear,
    week: ISOWeek,
    weekday: Weekday,
}

impl ISOWeekDate {
    /// The maximum representable ISO week date.
    ///
    /// The maximum corresponds to the ISO week date of the maximum [`Date`]
    /// value. That is, `-9999-01-01`.
    pub const MIN: ISOWeekDate = ISOWeekDate {
        year: ISOYear::new_unchecked(-9999),
        week: ISOWeek::new_unchecked(1),
        weekday: Weekday::Monday,
    };

    /// The minimum representable ISO week date.
    ///
    /// The minimum corresponds to the ISO week date of the minimum [`Date`]
    /// value. That is, `9999-12-31`.
    pub const MAX: ISOWeekDate = ISOWeekDate {
        year: ISOYear::new_unchecked(9999),
        week: ISOWeek::new_unchecked(52),
        weekday: Weekday::Friday,
    };

    /// The first day of the zeroth year.
    ///
    /// This is guaranteed to be equivalent to `ISOWeekDate::default()`. Note
    /// that this is not equivalent to `Date::default()`.
    ///
    /// # Example
    ///
    /// ```
    /// use jiff::civil::{ISOWeekDate, date};
    ///
    /// assert_eq!(ISOWeekDate::ZERO, ISOWeekDate::default());
    /// // The first day of the 0th year in the ISO week calendar is actually
    /// // the third day of the 0th year in the proleptic Gregorian calendar!
    /// assert_eq!(ISOWeekDate::default().date(), date(0, 1, 3));
    /// ```
    pub const ZERO: ISOWeekDate = ISOWeekDate {
        year: ISOYear::new_unchecked(0),
        week: ISOWeek::new_unchecked(1),
        weekday: Weekday::Monday,
    };

    /// Create a new ISO week date from it constituent parts.
    ///
    /// If the given values are out of range (based on what is representable
    /// as a [`Date`]), then this returns an error. This will also return an
    /// error if a leap week is given (week number `53`) for a year that does
    /// not contain a leap week.
    ///
    /// # Example
    ///
    /// This example shows some the boundary conditions involving minimum
    /// and maximum dates:
    ///
    /// ```
    /// use jiff::civil::{ISOWeekDate, Weekday, date};
    ///
    /// // The year 1949 does not contain a leap week.
    /// assert!(ISOWeekDate::new(1949, 53, Weekday::Monday).is_err());
    ///
    /// // Examples of dates at or exceeding the maximum.
    /// let max = ISOWeekDate::new(9999, 52, Weekday::Friday).unwrap();
    /// assert_eq!(max, ISOWeekDate::MAX);
    /// assert_eq!(max.date(), date(9999, 12, 31));
    /// assert!(ISOWeekDate::new(9999, 52, Weekday::Saturday).is_err());
    /// assert!(ISOWeekDate::new(9999, 53, Weekday::Monday).is_err());
    ///
    /// // Examples of dates at or exceeding the minimum.
    /// let min = ISOWeekDate::new(-9999, 1, Weekday::Monday).unwrap();
    /// assert_eq!(min, ISOWeekDate::MIN);
    /// assert_eq!(min.date(), date(-9999, 1, 1));
    /// assert!(ISOWeekDate::new(-10000, 52, Weekday::Sunday).is_err());
    /// ```
    #[inline]
    pub fn new(
        year: i16,
        week: i8,
        weekday: Weekday,
    ) -> Result<ISOWeekDate, Error> {
        let year = ISOYear::try_new("year", year)?;
        let week = ISOWeek::try_new("week", week)?;
        ISOWeekDate::new_ranged(year, week, weekday)
    }

    /// Converts a Gregorian date to an ISO week date.
    ///
    /// The minimum and maximum allowed values of an ISO week date are
    /// set based on the minimum and maximum values of a `Date`. Therefore,
    /// converting to and from `Date` values is non-lossy and infallible.
    ///
    /// This routine is equivalent to [`Date::to_iso_week_date`]. This
    /// routine is also available via a `From<Date>` trait implementation for
    /// `ISOWeekDate`.
    ///
    /// # Example
    ///
    /// ```
    /// use jiff::civil::{ISOWeekDate, Weekday, date};
    ///
    /// let weekdate = ISOWeekDate::from_date(date(1948, 2, 10));
    /// assert_eq!(
    ///     weekdate,
    ///     ISOWeekDate::new(1948, 7, Weekday::Tuesday).unwrap(),
    /// );
    /// ```
    #[inline]
    pub fn from_date(date: Date) -> ISOWeekDate {
        date.iso_week_date()
    }

    // N.B. I tried defining a `ISOWeekDate::constant` for defining ISO week
    // dates as constants, but it was too annoying to do. We could do it if
    // there was a compelling reason for it though.

    /// Returns the year component of this ISO 8601 week date.
    ///
    /// The value returned is guaranteed to be in the range `-9999..=9999`.
    ///
    /// # Example
    ///
    /// ```
    /// use jiff::civil::date;
    ///
    /// let weekdate = date(2019, 12, 30).to_iso_week_date();
    /// assert_eq!(weekdate.year(), 2020);
    /// ```
    #[inline]
    pub fn year(self) -> i16 {
        self.year_ranged().get()
    }

    /// Returns the week component of this ISO 8601 week date.
    ///
    /// The value returned is guaranteed to be in the range `1..=53`. A
    /// value of `53` can only occur for "long" years. That is, years
    /// with a leap week. This occurs precisely in cases for which
    /// [`ISOWeekDate::in_long_year`] returns `true`.
    ///
    /// # Example
    ///
    /// ```
    /// use jiff::civil::date;
    ///
    /// let weekdate = date(2019, 12, 30).to_iso_week_date();
    /// assert_eq!(weekdate.year(), 2020);
    /// assert_eq!(weekdate.week(), 1);
    ///
    /// let weekdate = date(1948, 12, 31).to_iso_week_date();
    /// assert_eq!(weekdate.year(), 1948);
    /// assert_eq!(weekdate.week(), 53);
    /// ```
    #[inline]
    pub fn week(self) -> i8 {
        self.week_ranged().get()
    }

    /// Returns the day component of this ISO 8601 week date.
    ///
    /// One can use methods on `Weekday` such as
    /// [`Weekday::to_monday_one_offset`]
    /// and
    /// [`Weekday::to_sunday_zero_offset`]
    /// to convert the weekday to a number.
    ///
    /// # Example
    ///
    /// ```
    /// use jiff::civil::{date, Weekday};
    ///
    /// let weekdate = date(1948, 12, 31).to_iso_week_date();
    /// assert_eq!(weekdate.year(), 1948);
    /// assert_eq!(weekdate.week(), 53);
    /// assert_eq!(weekdate.weekday(), Weekday::Friday);
    /// assert_eq!(weekdate.weekday().to_monday_zero_offset(), 4);
    /// assert_eq!(weekdate.weekday().to_monday_one_offset(), 5);
    /// assert_eq!(weekdate.weekday().to_sunday_zero_offset(), 5);
    /// assert_eq!(weekdate.weekday().to_sunday_one_offset(), 6);
    /// ```
    #[inline]
    pub fn weekday(self) -> Weekday {
        self.weekday
    }

    /// Returns true if and only if the year of this week date is a "long"
    /// year.
    ///
    /// A long year is one that contains precisely 53 weeks. All other years
    /// contain precisely 52 weeks.
    ///
    /// # Example
    ///
    /// ```
    /// use jiff::civil::{ISOWeekDate, Weekday};
    ///
    /// let weekdate = ISOWeekDate::new(1948, 7, Weekday::Monday).unwrap();
    /// assert!(weekdate.in_long_year());
    /// let weekdate = ISOWeekDate::new(1949, 7, Weekday::Monday).unwrap();
    /// assert!(!weekdate.in_long_year());
    /// ```
    #[inline]
    pub fn in_long_year(self) -> bool {
        is_long_year(self.year_ranged())
    }

    /// Converts this ISO week date to a Gregorian [`Date`].
    ///
    /// The minimum and maximum allowed values of an ISO week date are
    /// set based on the minimum and maximum values of a `Date`. Therefore,
    /// converting to and from `Date` values is non-lossy and infallible.
    ///
    /// This routine is equivalent to [`Date::from_iso_week_date`].
    ///
    /// # Example
    ///
    /// ```
    /// use jiff::civil::{ISOWeekDate, Weekday, date};
    ///
    /// let weekdate = ISOWeekDate::new(1948, 7, Weekday::Tuesday).unwrap();
    /// assert_eq!(weekdate.date(), date(1948, 2, 10));
    /// ```
    #[inline]
    pub fn date(self) -> Date {
        Date::from_iso_week_date(self)
    }
}

/// Deprecated APIs.
impl ISOWeekDate {
    /// A deprecated equivalent to [`ISOWeekDate::date`].
    ///
    /// This method will be removed in `jiff 0.2`. This was done to make naming
    /// more consistent throughout the crate.
    #[deprecated(since = "0.1.26", note = "use ISOWeekDate::date instead")]
    #[inline]
    pub fn to_date(self) -> Date {
        Date::from_iso_week_date(self)
    }
}

impl ISOWeekDate {
    /// Creates a new ISO week date from ranged values.
    ///
    /// While the ranged values given eliminate some error cases, not all
    /// combinations of year/week/weekday values are valid ISO week dates
    /// supported by this crate. For example, a week of `53` for short years,
    /// or more niche, a week date that would be bigger than what is supported
    /// by our `Date` type.
    #[inline]
    pub(crate) fn new_ranged(
        year: impl RInto<ISOYear>,
        week: impl RInto<ISOWeek>,
        weekday: Weekday,
    ) -> Result<ISOWeekDate, Error> {
        let year = year.rinto();
        let week = week.rinto();
        // All combinations of years, weeks and weekdays allowed by our
        // range types are valid ISO week dates with one exception: a week
        // number of 53 is only valid for "long" years. Or years with an ISO
        // leap week. It turns out this only happens when the last day of the
        // year is a Thursday.
        //
        // Note that if the ranges in this crate are changed, this could be
        // a little trickier if the range of ISOYear is different from Year.
        debug_assert_eq!(t::Year::MIN, ISOYear::MIN);
        debug_assert_eq!(t::Year::MAX, ISOYear::MAX);
        if week == 53 && !is_long_year(year) {
            return Err(err!(
                "ISO week number `{week}` is invalid for year `{year}`"
            ));
        }
        // And also, the maximum Date constrains what we can utter with
        // ISOWeekDate so that we can preserve infallible conversions between
        // them. So since 9999-12-31 maps to 9999 W52 Friday, it follows that
        // Saturday and Sunday are not allowed. So reject them.
        //
        // We don't need to worry about the minimum because the minimum date
        // (-9999-01-01) corresponds also to the minimum possible combination
        // of an ISO week date's fields: -9999 W01 Monday. Nice.
        if year == ISOYear::MAX_SELF
            && week == 52
            && weekday.to_monday_zero_offset()
                > Weekday::Friday.to_monday_zero_offset()
        {
            return Err(Error::range(
                "weekday",
                weekday.to_monday_zero_offset(),
                Weekday::Monday.to_monday_one_offset(),
                Weekday::Friday.to_monday_one_offset(),
            ));
        }
        Ok(ISOWeekDate { year, week, weekday })
    }

    /// Like `ISOWeekDate::new_ranged`, but constrains out-of-bounds values
    /// to their closest valid equivalent.
    ///
    /// For example, given 9999 W52 Saturday, this will return 9999 W52 Friday.
    #[cfg(test)]
    #[inline]
    pub(crate) fn new_ranged_constrain(
        year: impl RInto<ISOYear>,
        week: impl RInto<ISOWeek>,
        mut weekday: Weekday,
    ) -> ISOWeekDate {
        let year = year.rinto();
        let mut week = week.rinto();
        debug_assert_eq!(t::Year::MIN, ISOYear::MIN);
        debug_assert_eq!(t::Year::MAX, ISOYear::MAX);
        if week == 53 && !is_long_year(year) {
            week = ISOWeek::new(52).unwrap();
        }
        if year == ISOYear::MAX_SELF
            && week == 52
            && weekday.to_monday_zero_offset()
                > Weekday::Friday.to_monday_zero_offset()
        {
            weekday = Weekday::Friday;
        }
        ISOWeekDate { year, week, weekday }
    }

    #[inline]
    pub(crate) fn year_ranged(self) -> ISOYear {
        self.year
    }

    #[inline]
    pub(crate) fn week_ranged(self) -> ISOWeek {
        self.week
    }
}

impl Default for ISOWeekDate {
    fn default() -> ISOWeekDate {
        ISOWeekDate::ZERO
    }
}

impl core::fmt::Debug for ISOWeekDate {
    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
        f.debug_struct("ISOWeekDate")
            .field("year", &self.year_ranged().debug())
            .field("week", &self.week_ranged().debug())
            .field("weekday", &self.weekday)
            .finish()
    }
}

impl Eq for ISOWeekDate {}

impl PartialEq for ISOWeekDate {
    #[inline]
    fn eq(&self, other: &ISOWeekDate) -> bool {
        // We roll our own so that we can call 'get' on our ranged integers
        // in order to provoke panics for bugs in dealing with boundary
        // conditions.
        self.weekday == other.weekday
            && self.week.get() == other.week.get()
            && self.year.get() == other.year.get()
    }
}

impl Ord for ISOWeekDate {
    #[inline]
    fn cmp(&self, other: &ISOWeekDate) -> core::cmp::Ordering {
        (self.year.get(), self.week.get(), self.weekday.to_monday_one_offset())
            .cmp(&(
                other.year.get(),
                other.week.get(),
                other.weekday.to_monday_one_offset(),
            ))
    }
}

impl PartialOrd for ISOWeekDate {
    #[inline]
    fn partial_cmp(&self, other: &ISOWeekDate) -> Option<core::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl From<Date> for ISOWeekDate {
    #[inline]
    fn from(date: Date) -> ISOWeekDate {
        ISOWeekDate::from_date(date)
    }
}

impl From<DateTime> for ISOWeekDate {
    #[inline]
    fn from(dt: DateTime) -> ISOWeekDate {
        ISOWeekDate::from(dt.date())
    }
}

impl From<Zoned> for ISOWeekDate {
    #[inline]
    fn from(zdt: Zoned) -> ISOWeekDate {
        ISOWeekDate::from(zdt.date())
    }
}

impl<'a> From<&'a Zoned> for ISOWeekDate {
    #[inline]
    fn from(zdt: &'a Zoned) -> ISOWeekDate {
        ISOWeekDate::from(zdt.date())
    }
}

#[cfg(test)]
impl quickcheck::Arbitrary for ISOWeekDate {
    fn arbitrary(g: &mut quickcheck::Gen) -> ISOWeekDate {
        let year = ISOYear::arbitrary(g);
        let week = ISOWeek::arbitrary(g);
        let weekday = Weekday::arbitrary(g);
        ISOWeekDate::new_ranged_constrain(year, week, weekday)
    }

    fn shrink(&self) -> alloc::boxed::Box<dyn Iterator<Item = ISOWeekDate>> {
        alloc::boxed::Box::new(
            (self.year_ranged(), self.week_ranged(), self.weekday())
                .shrink()
                .map(|(year, week, weekday)| {
                    ISOWeekDate::new_ranged_constrain(year, week, weekday)
                }),
        )
    }
}

/// Returns true if the given ISO year is a "long" year or not.
///
/// A "long" year is a year with 53 weeks. Otherwise, it's a "short" year
/// with 52 weeks.
fn is_long_year(year: ISOYear) -> bool {
    // Inspired by: https://en.wikipedia.org/wiki/ISO_week_date#Weeks_per_year
    let last = Date::new_ranged(year, C(12), C(31))
        .expect("last day of year is always valid");
    let weekday = last.weekday();
    weekday == Weekday::Thursday
        || (last.in_leap_year() && weekday == Weekday::Friday)
}

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

    quickcheck::quickcheck! {
        fn prop_all_long_years_have_53rd_week(year: ISOYear) -> bool {
            !is_long_year(year)
                || ISOWeekDate::new(year.get(), 53, Weekday::Sunday).is_ok()
        }

        fn prop_prev_day_is_less(wd: ISOWeekDate) -> quickcheck::TestResult {
            use crate::ToSpan;

            if wd == ISOWeekDate::MIN {
                return quickcheck::TestResult::discard();
            }
            let prev_date = wd.date().checked_add(-1.days()).unwrap();
            quickcheck::TestResult::from_bool(prev_date.iso_week_date() < wd)
        }

        fn prop_next_day_is_greater(wd: ISOWeekDate) -> quickcheck::TestResult {
            use crate::ToSpan;

            if wd == ISOWeekDate::MAX {
                return quickcheck::TestResult::discard();
            }
            let next_date = wd.date().checked_add(1.days()).unwrap();
            quickcheck::TestResult::from_bool(wd < next_date.iso_week_date())
        }
    }
}
