use std::collections::HashSet;

use chrono::{DateTime, Duration, NaiveDate, NaiveTime, Timelike as _, Utc, Weekday};
use tracing::trace;

use super::TotalsSnapshot;
use crate::{
    currency,
    json::FromJson as _,
    price::{self, Consumed, PeriodNormalized},
    warning::VerdictExt as _,
    Ampere, Kw, Kwh, Price, Verdict, Version, Versioned as _,
};

pub(super) fn parse<'buf>(
    tariff: &crate::tariff::Versioned<'buf>,
) -> Verdict<crate::tariff::v221::Tariff<'buf>, crate::tariff::Warning> {
    match tariff.version() {
        Version::V211 => {
            let tariff = crate::tariff::v211::Tariff::from_json(tariff.as_element());
            tariff.map_caveat(crate::tariff::v221::Tariff::from)
        }
        Version::V221 => crate::tariff::v221::Tariff::from_json(tariff.as_element()),
    }
}

/// A normalized tariff that doesn't correspond to any particular OCPI version.
///
/// The normalized version is based off of the v221 version of a tariff.
///
/// * See: [OCPI spec 2.2.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>).
#[derive(Debug)]
pub(crate) struct Tariff {
    /// Uniquely identifies the tariff within the CPO's platform (and sub-operator platforms).
    id: String,

    /// ISO-4217 code of the currency of this tariff.
    currency: currency::Code,

    /// List of tariff elements.
    elements: Vec<Element>,

    /// The time when this tariff becomes active.
    ///
    /// In UTC, `timezone` field of the `Location` can be used to convert to local time.
    /// Typically used for a new tariff that is already given with the location,
    /// before it becomes active.
    start_date_time: Option<DateTime<Utc>>,

    /// The time after which this tariff is no longer valid.
    ///
    /// In UTC, `time_zone` field of the `Location` can be used to convert to local time.
    /// Typically used when this tariff is going to be replaced with a new tariff in the near future.
    end_date_time: Option<DateTime<Utc>>,

    /// The minimum cost of a session using this tariff.
    min_price: Option<Price>,

    /// The maximum cost of a session using this tariff.
    max_price: Option<Price>,
}

impl Tariff {
    /// Create a new normalized tariff from a v221 Tariff.
    pub(super) fn from_v221(tariff: &crate::tariff::v221::Tariff<'_>) -> Self {
        let crate::tariff::v221::Tariff {
            id,
            elements,
            start_date_time,
            end_date_time,
            party_id: _,
            currency,
            min_price,
            max_price,
        } = tariff;

        let elements = elements.iter().map(Element::new).collect();

        Self {
            id: id.to_string(),
            currency: *currency,
            start_date_time: *start_date_time,
            end_date_time: *end_date_time,
            elements,
            min_price: *min_price,
            max_price: *max_price,
        }
    }

    pub(super) fn id(&self) -> &str {
        &self.id
    }

    pub(super) fn currency(&self) -> currency::Code {
        self.currency
    }

    /// Returns true if any tariff element has a `reservation` restriction.
    pub(super) fn has_reservation_elements(&self) -> bool {
        self.elements.iter().any(|e| {
            e.restrictions
                .iter()
                .any(|r| matches!(r, Restriction::ReservationOnly))
        })
    }

    pub(super) fn min_price(&self) -> Option<Price> {
        self.min_price
    }

    pub(super) fn max_price(&self) -> Option<Price> {
        self.max_price
    }

    /// Returns the set of Price Components that are active during the given `ChargePeriod`.
    ///
    /// When the list of Tariff Elements contains more than one Element that has a Price Component
    /// for a certain dimension. Then the first Tariff Element with a Price Component for that
    /// dimension in the list with matching Tariff Restrictions will be used.
    ///
    /// Only one Price Component per dimension can be active at any point in time, but multiple
    /// Price Components for different dimensions can be active at once. That is you can have an
    /// ENERGY component and a TIME component active at the same time. But only those ones that
    /// are in the first Tariff Element that has a Price Component for that dimension and that has
    /// restrictions that match at that time.
    ///
    /// When no Tariff Element with a specific Dimension is found for which the Restrictions match,
    /// and there is no Tariff Element in the list with the given Dimension without Restrictions,
    /// there will be no costs for that Tariff Dimension.
    ///
    /// It is advised to always add a "default" Price Component per dimension. This can be achieved
    /// by adding a Tariff Element without restrictions after all other occurrences of the same
    /// dimension in the list of Tariff Elements.
    ///
    /// Such a Tariff Element will act as fallback when there is no other Tariff Element that has
    /// matching restrictions and that contains a Price Component for that dimension.
    ///
    /// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>.
    pub(super) fn active_components(&self, period: &PeriodNormalized) -> price::ComponentSet {
        let mut component_set = price::ComponentSet {
            energy: None,
            flat: None,
            duration_charging: None,
            duration_idle: None,
        };

        for tariff_element in &self.elements {
            trace!("{period:#?}");

            if !tariff_element.is_active(period) {
                continue;
            }

            if component_set.duration_charging.is_none() {
                component_set
                    .duration_charging
                    .clone_from(&tariff_element.components.duration_charging);
            }

            if component_set.duration_idle.is_none() {
                component_set
                    .duration_idle
                    .clone_from(&tariff_element.components.duration_idle);
            }

            if component_set.energy.is_none() {
                component_set
                    .energy
                    .clone_from(&tariff_element.components.energy);
            }

            if component_set.flat.is_none() {
                component_set
                    .flat
                    .clone_from(&tariff_element.components.flat);
            }

            if component_set.has_all_components() {
                break;
            }
        }

        component_set
    }

    fn is_active(&self, start_time: DateTime<Utc>) -> bool {
        let is_after_start = self
            .start_date_time
            .map(|s| start_time >= s)
            .unwrap_or(true);
        let is_before_end = self.end_date_time.map(|s| start_time < s).unwrap_or(true);

        is_after_start && is_before_end
    }
}

/// A Tariff Element is a group of Price Components that share a set of restrictions under which they apply.
///
/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#144-tariffelement-class>.
#[derive(Debug)]
pub(super) struct Element {
    /// List of Price Components that each describe how a certain dimension is priced.
    components: price::ComponentSet,

    /// Restrictions that describe under which circumstances the Price Components of this Tariff Element apply.
    restrictions: Vec<Restriction>,
}

impl Element {
    fn new(element: &crate::tariff::v221::Element) -> Self {
        let restrictions = if let Some(restrictions) = &element.restrictions {
            collect_restrictions(restrictions)
        } else {
            Vec::new()
        };

        let mut components = price::ComponentSet {
            energy: None,
            flat: None,
            duration_charging: None,
            duration_idle: None,
        };

        for component in &element.price_components {
            let price_component = price::Component::new(component);

            match component.dimension_type {
                crate::tariff::v2x::DimensionType::Flat => {
                    components.flat.get_or_insert(price_component)
                }
                crate::tariff::v2x::DimensionType::Time => {
                    components.duration_charging.get_or_insert(price_component)
                }
                crate::tariff::v2x::DimensionType::ParkingTime => {
                    components.duration_idle.get_or_insert(price_component)
                }
                crate::tariff::v2x::DimensionType::Energy => {
                    components.energy.get_or_insert(price_component)
                }
            };
        }

        Self {
            components,
            restrictions,
        }
    }

    fn is_active(&self, period: &PeriodNormalized) -> bool {
        for restriction in &self.restrictions {
            if !restriction.snapshot_validity_exclusive(&period.start_snapshot) {
                return false;
            }

            if !restriction.period_validity(&period.consumed) {
                return false;
            }
        }

        true
    }

    #[expect(dead_code, reason = "pending use in linter")]
    fn is_active_at_end(&self, period: &PeriodNormalized) -> bool {
        for restriction in &self.restrictions {
            if !restriction.snapshot_validity_inclusive(&period.end_snapshot) {
                return false;
            }
        }

        true
    }
}

fn collect_restrictions(restriction: &crate::tariff::v221::Restrictions) -> Vec<Restriction> {
    let mut collected = Vec::new();

    match (restriction.start_time, restriction.end_time) {
        (Some(start_time), Some(end_time)) if end_time < start_time => {
            collected.push(Restriction::WrappingTime {
                start_time,
                end_time,
            });
        }
        (start_time, end_time) => {
            if let Some(start_time) = start_time {
                collected.push(Restriction::StartTime(start_time));
            }

            if let Some(end_time) = end_time {
                collected.push(Restriction::EndTime(end_time));
            }
        }
    }

    if let Some(start_date) = restriction.start_date {
        collected.push(Restriction::StartDate(start_date));
    }

    if let Some(end_date) = restriction.end_date {
        collected.push(Restriction::EndDate(end_date));
    }

    if let Some(min_kwh) = restriction.min_kwh {
        collected.push(Restriction::MinKwh(min_kwh));
    }

    if let Some(max_kwh) = restriction.max_kwh {
        collected.push(Restriction::MaxKwh(max_kwh));
    }

    if let Some(min_current) = restriction.min_current {
        collected.push(Restriction::MinCurrent(min_current));
    }

    if let Some(max_current) = restriction.max_current {
        collected.push(Restriction::MaxCurrent(max_current));
    }

    if let Some(min_power) = restriction.min_power {
        collected.push(Restriction::MinPower(min_power));
    }

    if let Some(max_power) = restriction.max_power {
        collected.push(Restriction::MaxPower(max_power));
    }

    if let Some(min_duration) = restriction.min_duration {
        collected.push(Restriction::MinDuration(min_duration));
    }

    if let Some(max_duration) = restriction.max_duration {
        collected.push(Restriction::MaxDuration(max_duration));
    }

    if let Some(day_of_week) = restriction.day_of_week.as_deref() {
        if !day_of_week.is_empty() {
            collected.push(Restriction::Weekday(
                day_of_week.iter().copied().map(Into::into).collect(),
            ));
        }
    }

    // Both RESERVATION and RESERVATION_EXPIRES make the element inapplicable to regular charging
    // CDRs; the distinction only matters for reservation-session billing, which is not supported.
    if restriction.reservation.is_some() {
        collected.push(Restriction::ReservationOnly);
    }

    collected
}

/// A `TariffRestrictions` object describes if and when a Tariff Element becomes active or inactive during a Charging Session.
///
/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#146-tariffrestrictions-class>.
#[derive(Debug, Clone)]
enum Restriction {
    StartTime(NaiveTime),
    EndTime(NaiveTime),
    WrappingTime {
        start_time: NaiveTime,
        end_time: NaiveTime,
    },
    StartDate(NaiveDate),
    EndDate(NaiveDate),
    MinKwh(Kwh),
    MaxKwh(Kwh),
    MinCurrent(Ampere),
    MaxCurrent(Ampere),
    MinPower(Kw),
    MaxPower(Kw),
    MinDuration(Duration),
    MaxDuration(Duration),
    Weekday(HashSet<Weekday>),
    /// The element is restricted to reservation sessions only and never applies during CDR pricing.
    ReservationOnly,
}

impl Restriction {
    /// Returns true if this restriction is valid at time of the given [`TotalsSnapshot`].
    ///
    /// The time based restrictions are treated as exclusive comparisons.
    fn snapshot_validity_exclusive(&self, snapshot: &TotalsSnapshot) -> bool {
        match self {
            &Self::WrappingTime {
                start_time,
                end_time,
            } => snapshot.local_time() >= start_time || snapshot.local_time() < end_time,
            &Self::StartTime(start_time) => snapshot.local_time() >= start_time,
            &Self::EndTime(end_time) => snapshot.local_time() < end_time,
            &Self::StartDate(start_date) => snapshot.local_date() >= start_date,
            &Self::EndDate(end_date) => snapshot.local_date() < end_date,
            &Self::MinKwh(min_energy) => snapshot.energy >= min_energy,
            &Self::MaxKwh(max_energy) => snapshot.energy < max_energy,
            &Self::MinDuration(min_duration) => snapshot.duration_total >= min_duration,
            &Self::MaxDuration(max_duration) => snapshot.duration_total < max_duration,
            Self::Weekday(days) => days.contains(&snapshot.local_weekday()),
            Self::MinCurrent(_) | Self::MaxCurrent(_) | Self::MinPower(_) | Self::MaxPower(_) => {
                true
            }
            Self::ReservationOnly => false,
        }
    }

    /// Returns true if this restriction is valid at the time of the given [`TotalsSnapshot`].
    ///
    /// The time based restrictions are treated as inclusive comparisons. For example, a snapshot
    /// at 00:00 on a Tuesday is regarded as valid for a restriction that has a `Weekday` which
    /// includes Monday.
    fn snapshot_validity_inclusive(&self, snapshot: &TotalsSnapshot) -> bool {
        match self {
            &Self::WrappingTime {
                start_time,
                end_time,
            } => snapshot.local_time() >= start_time || snapshot.local_time() < end_time,
            &Self::EndTime(end_time) => snapshot.local_time() <= end_time,
            &Self::EndDate(end_date) => {
                // Since the end date of this period is derived from the start date of the next period
                // we can't do an exclusive comparison. But we should still check that this period doesn't
                // end in the middle of the day on `end_date`.

                let is_before_end_date = snapshot.local_date() < end_date;
                let is_on_end_date = snapshot.local_date() == end_date;
                let is_at_midnight = snapshot.local_time().num_seconds_from_midnight() == 0;

                is_before_end_date || (is_on_end_date && is_at_midnight)
            }
            &Self::MinKwh(min_energy) => snapshot.energy >= min_energy,
            &Self::MaxKwh(max_energy) => snapshot.energy < max_energy,
            &Self::MinDuration(min_duration) => snapshot.duration_total >= min_duration,
            &Self::MaxDuration(max_duration) => snapshot.duration_total < max_duration,
            Self::Weekday(days) => {
                let includes_weekday = days.contains(&snapshot.local_weekday());
                let includes_day_before = days.contains(&snapshot.local_weekday().pred());
                let is_at_midnight = snapshot.local_time().num_seconds_from_midnight() == 0;
                includes_weekday || (includes_day_before && is_at_midnight)
            }
            Restriction::StartTime(_)
            | Restriction::StartDate(_)
            | Restriction::MinCurrent(_)
            | Restriction::MaxCurrent(_)
            | Restriction::MinPower(_)
            | Restriction::MaxPower(_) => true,
            Restriction::ReservationOnly => false,
        }
    }

    /// Checks if this restriction is valid for `state`.
    fn period_validity(&self, consumed: &Consumed) -> bool {
        match *self {
            Self::MinCurrent(min_current) => consumed
                .current_max
                .map(|current| current >= min_current)
                .unwrap_or(true),
            Self::MaxCurrent(max_current) => consumed
                .current_min
                .map(|current| current < max_current)
                .unwrap_or(true),
            Self::MinPower(min_power) => consumed
                .power_max
                .map(|power| power >= min_power)
                .unwrap_or(true),
            Self::MaxPower(max_power) => consumed
                .power_min
                .map(|power| power < max_power)
                .unwrap_or(true),
            Restriction::StartTime(_)
            | Restriction::EndTime(_)
            | Restriction::WrappingTime {
                start_time: _,
                end_time: _,
            }
            | Restriction::StartDate(_)
            | Restriction::EndDate(_)
            | Restriction::MinKwh(_)
            | Restriction::MaxKwh(_)
            | Restriction::MinDuration(_)
            | Restriction::MaxDuration(_)
            | Restriction::Weekday(_) => true,
            Restriction::ReservationOnly => false,
        }
    }
}

/// Find the first active tariff and return it and it's index.
pub(super) fn find_first_active(
    tariffs: Vec<Tariff>,
    start_date_time: DateTime<Utc>,
) -> Option<(usize, Tariff)> {
    tariffs
        .into_iter()
        .enumerate()
        .find(|(_, t)| t.is_active(start_date_time))
}

/// Return a normalized `Tariff` for each `v221::Tariff` given.
///
/// The parsing/deserialization of JSON to a `v221::Tariff` may result in some `UnexpectedFields`.
/// And the creation of the normalized `Tariff` object may result in `Warning`s.
pub(super) fn normalize_all(tariffs: &[crate::tariff::v221::Tariff<'_>]) -> Vec<Tariff> {
    tariffs.iter().map(Tariff::from_v221).collect()
}
