//! Produce a human-language explanation of a tariff.
//!
//! The explanation is best-effort English, rendered as Markdown, aimed at a person who wants to
//! understand what a tariff charges and when, without having to read the OCPI spec themselves.
//!
//! Rather than describing the tariff field-by-field, the explanation reads the tariff the way a
//! pricing engine does. It groups the price components by dimension (energy, charging
//! time, idle time, flat fee) and narrates how the rate changes as a session progresses.
//! A dimension with more than one tier is rendered as a bulleted list. The interpretation
//! folds in the spec knowledge that:
//!
//! - Tariff elements are matched from the top down, and for each dimension the first element whose
//!   restrictions match supplies the rate. This lets a list of elements express tiers, such as one
//!   rate "for the first 3 hours" and another for "the remaining time".
//! - `TIME` is time spent actively charging, while `PARKING_TIME` is time connected but idle (for
//!   example, after the car has finished charging).
//! - A `min_duration`/`max_duration` restriction bounds a tier by how long the session has lasted;
//!   a `min_kwh`/`max_kwh` restriction bounds it by how much energy has been consumed.
//! - An element with a `reservation` restriction never applies to a regular charging session.
//!
//! * See: [OCPI spec 2.2.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#131-tariff-object>)
//! * See: [OCPI spec 2.1.1: Tariff](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#31-tariff-object>)

use chrono::{DateTime, NaiveTime, TimeDelta, Utc};
use rust_decimal::Decimal;

use crate::{
    currency,
    json::FromJson as _,
    money::VatOrigin,
    tariff::{
        v221::{Element, Restrictions, Tariff},
        v2x::DimensionType,
        Warning,
    },
    warning::VerdictExt as _,
    Ampere, Kw, Kwh, Money, Price, Verdict, Version, Versioned as _, Weekday,
};

/// Build a human-language explanation of the given tariff as Markdown.
///
/// The tariff is parsed into the normalized `v2.2.1` form first, so a `v2.1.1` tariff is explained as
/// its `v2.2.1` equivalent. Any warnings raised while parsing are returned alongside the
/// explanation; a hard parse failure returns an [`ErrorSet`](crate::warning::ErrorSet) instead.
pub(crate) fn explain(tariff: &crate::tariff::Versioned<'_>) -> Verdict<String, Warning> {
    let parsed = match tariff.version() {
        Version::V211 => {
            crate::tariff::v211::Tariff::from_json(tariff.as_element()).map_caveat(Tariff::from)
        }
        Version::V221 => Tariff::from_json(tariff.as_element()),
    };

    parsed.map_caveat(|tariff| render(&tariff))
}

/// Render the parsed tariff as English prose, one paragraph per topic.
fn render(tariff: &Tariff<'_>) -> String {
    let currency = tariff.currency;
    let elements = &tariff.elements;
    let mut paragraphs: Vec<String> = Vec::new();

    // Dimensions are narrated energy first, then the time-based charges (charging then idle), then
    // any flat fee, followed by the session-level price bounds and validity window.
    if let Some(p) = narrate_dimension(elements, currency, DimensionType::Energy) {
        paragraphs.push(p);
    }
    if let Some(p) = narrate_dimension(elements, currency, DimensionType::Time) {
        paragraphs.push(p);
    }
    if let Some(p) = narrate_dimension(elements, currency, DimensionType::ParkingTime) {
        paragraphs.push(p);
    }
    if let Some(p) = narrate_flat(elements, currency) {
        paragraphs.push(p);
    }

    if let Some(p) = narrate_bounds(tariff.min_price, tariff.max_price, currency) {
        paragraphs.push(p);
    }
    if let Some(p) = narrate_validity(tariff.start_date_time, tariff.end_date_time) {
        paragraphs.push(p);
    }

    if paragraphs.is_empty() {
        return fallback_reason(tariff);
    }

    paragraphs.join("\n\n")
}

/// Explain why a tariff produces no charging narrative, in terms of what the tariff contains.
///
/// This is reached only when no dimension, price bound or validity window produced any prose, so
/// the message describes the cause in the tariff rather than any limitation of this tool.
fn fallback_reason(tariff: &Tariff<'_>) -> String {
    let elements = &tariff.elements;

    // Every element is gated on reservations, so none apply to a regular charging session.
    if elements.iter().all(is_reservation_only) {
        return "This tariff never charges a regular charging session: every element applies only \
                to reservation sessions."
            .to_owned();
    }

    // None of the applicable elements define any price component to charge on.
    let has_components = elements
        .iter()
        .filter(|element| !is_reservation_only(element))
        .any(|element| !element.price_components.is_empty());

    if !has_components {
        return "This tariff charges nothing: none of its applicable elements define a price \
                component."
            .to_owned();
    }

    // The only thing left that produces no prose is a flat fee priced at zero.
    "This tariff is free: its only charge is a flat fee of zero.".to_owned()
}

/// Returns true when an element applies only to reservation sessions and so never applies to a
/// regular charging session.
fn is_reservation_only(element: &Element) -> bool {
    element
        .restrictions
        .as_ref()
        .is_some_and(|restrictions| restrictions.reservation.is_some())
}

/// A single priced tier of a dimension: a rate that applies under a set of restrictions.
struct Band<'a> {
    /// The rate for this tier.
    price: Money,

    /// The VAT applied to the rate.
    vat: VatOrigin,

    /// The smallest billable unit; consumption is rounded up to a multiple of this. The unit
    /// depends on the dimension: 1 Wh for energy, 1 second for charging or idle time.
    step_size: u64,

    /// The restrictions that bound this tier.
    restrictions: Option<&'a Restrictions>,
}

/// Collect the priced tiers for a single dimension, in matching order.
fn bands(elements: &[Element], dimension: DimensionType) -> Vec<Band<'_>> {
    let mut bands = Vec::new();

    for element in elements {
        // An element gated on reservations never applies to a regular charging session, so it
        // does not contribute a tier to the explanation.
        if is_reservation_only(element) {
            continue;
        }

        // Only the first price component per dimension in an element is used by the pricing
        // engine, so any later duplicates within the same element are ignored here too.
        if let Some(component) = element
            .price_components
            .iter()
            .find(|component| component.dimension_type == dimension)
        {
            bands.push(Band {
                price: component.price,
                vat: component.vat,
                step_size: component.step_size,
                restrictions: element.restrictions.as_ref(),
            });
        }
    }

    bands
}

/// Narrate the flat (per-session) fee, or fees.
///
/// Like the metered dimensions, a tariff can have several flat tiers (one per matching element)
/// under different conditions, matched top-down. A flat fee can be gated by time of day, date, or a
/// consumption bound (`min/max_duration`, `min/max_kwh`), so the condition pulls in all of those.
fn narrate_flat(elements: &[Element], currency: currency::Code) -> Option<String> {
    let mut bands = bands(elements, DimensionType::Flat);

    // The condition under which a flat tier applies: the qualifiers plus any consumption bounds.
    let condition_of = |restrictions: &Restrictions| -> Vec<String> {
        let mut parts = restriction_parts(restrictions);
        if let Some(bound) = duration_scope(restrictions) {
            parts.push(bound);
        }
        if let Some(bound) = energy_scope(restrictions) {
            parts.push(bound);
        }
        parts
    };

    // A tier with no condition always matches, so any flat tier after it is unreachable.
    let reachable = bands
        .iter()
        .position(|band| {
            band.restrictions
                .is_none_or(|restrictions| condition_of(restrictions).is_empty())
        })
        .map(|index| index.saturating_add(1))
        .unwrap_or(bands.len());
    bands.truncate(reachable);

    // (condition, fee text, is the fee zero?)
    let entries: Vec<(String, String, bool)> = bands
        .iter()
        .enumerate()
        .map(|(index, band)| {
            let condition = band.restrictions.map(&condition_of).unwrap_or_default();
            let condition = if !condition.is_empty() {
                condition.join(", ")
            } else if index > 0 {
                "otherwise".to_owned()
            } else {
                String::new()
            };

            let zero = is_free(band.price);
            let fee = if zero {
                "no fee".to_owned()
            } else {
                format!(
                    "{} per session{}",
                    money(band.price, currency),
                    vat_clause(band.vat)
                )
            };

            (condition, fee, zero)
        })
        .collect();

    match entries.as_slice() {
        // No flat tiers, or a lone zero fee, means there is no flat fee worth a sentence.
        [] | [(_, _, true)] => None,
        [(condition, fee, _)] if condition.is_empty() => Some(format!("**Flat fee:** {fee}.")),
        [(condition, fee, _)] => Some(format!("**Flat fee:** {condition}, {fee}.")),
        _ => {
            let bullets: Vec<String> = entries
                .iter()
                .map(|(condition, fee, _)| format!("- {}: {fee}", capitalize_first(condition)))
                .collect();
            Some(format!("**Flat fee:**\n\n{}", bullets.join("\n")))
        }
    }
}

/// Narrate a metered dimension (energy, charging time or idle time) as a sequence of tiers.
///
/// A dimension can have several priced tiers, one per matching element. Each tier leads with the
/// condition under which it applies ("Between 06:00 and 22:00, ...", "after the first 3 hours,
/// ...") followed by its rate, because the first matching element wins. The tiers are listed in
/// matching order and separated with "; ".
///
/// A trailing tier with no condition is the catch-all: it reads "for the remaining ..." when the
/// earlier tiers were purely consumption-bounded, and "otherwise" when an earlier tier was gated by
/// something else (power, current, time of day). Tiers made unreachable by an earlier catch-all are
/// dropped and noted.
fn narrate_dimension(
    elements: &[Element],
    currency: currency::Code,
    dimension: DimensionType,
) -> Option<String> {
    let bands = bands(elements, dimension);
    if bands.is_empty() {
        return None;
    }

    let prose = DimensionProse::new(dimension)?;

    // Compute each band's condition pieces once: the dimension's own bound (primary), the other
    // consumption bound (secondary, which the spec also allows, e.g. an energy threshold on a
    // charging-time tier), and the non-consumption qualifiers (time of day, weekday, ...).
    let pieces: Vec<(Option<String>, Option<String>, Vec<String>)> = bands
        .iter()
        .map(|band| {
            let primary = band
                .restrictions
                .and_then(|restrictions| primary_scope(restrictions, prose.tier));
            let secondary = band
                .restrictions
                .and_then(|restrictions| secondary_scope(restrictions, prose.tier));
            let parts = band.restrictions.map(restriction_parts).unwrap_or_default();
            (primary, secondary, parts)
        })
        .collect();

    // A tier with no consumption bound and no qualifiers always matches, so it is the catch-all:
    // every tier listed after it is unreachable. Keep the catch-all and drop the dead tiers.
    let reachable = pieces
        .iter()
        .position(|(primary, secondary, parts)| {
            primary.is_none() && secondary.is_none() && parts.is_empty()
        })
        .map(|index| index.saturating_add(1))
        .unwrap_or(pieces.len());
    let dropped = pieces.len().saturating_sub(reachable);

    // When every reachable tier shares the same billing step, describe it once at the end;
    // otherwise the step varies per tier and is described inline on each.
    let first_step = bands.first().map(|band| band.step_size);
    let uniform_step = first_step.is_some_and(|step| {
        bands
            .iter()
            .take(reachable)
            .all(|band| band.step_size == step)
    });

    // Track what earlier tiers looked like, to phrase a trailing catch-all correctly: "for the
    // remaining ..." only follows purely consumption-bounded tiers; anything else makes it
    // "otherwise".
    let mut seen_bound = false;
    let mut seen_qualifier = false;
    // Each reachable tier becomes (condition, rate body, per-tier billing-step note).
    let mut tiers: Vec<(String, String, String)> = Vec::with_capacity(reachable);

    for (index, (band, (primary, secondary, mut condition))) in
        bands.iter().zip(pieces).enumerate().take(reachable)
    {
        // A qualifier or a secondary bound is an extra gate (not the dimension's own tiering), so
        // it makes a trailing catch-all read "otherwise" rather than "for the remaining ...".
        let has_qualifier = !condition.is_empty() || secondary.is_some();

        // The consumption bounds ("after the first 3 hours") trail the qualifiers in the condition.
        if let Some(primary) = &primary {
            condition.push(primary.clone());
        }
        if let Some(secondary) = &secondary {
            condition.push(secondary.clone());
        }

        let condition = if !condition.is_empty() {
            condition.join(", ")
        } else if index > 0 && seen_bound && !seen_qualifier {
            format!("for {}", prose.remaining)
        } else if index > 0 {
            "otherwise".to_owned()
        } else {
            String::new()
        };

        // A zero rate reads far better as "free" than "0.00 per hour".
        let free = is_free(band.price);
        let body = if free {
            "free".to_owned()
        } else {
            format!(
                "{} {}{}",
                money(band.price, currency),
                prose.unit,
                vat_clause(band.vat)
            )
        };
        // A step of 1 (the smallest unit) is effectively continuous billing, and a free tier has
        // nothing to bill in steps, so neither earns a note. Otherwise, note the per-tier step.
        let step = if uniform_step || band.step_size == 1 || free {
            String::new()
        } else {
            format!(
                " (billed in {} steps, rounded up)",
                step_phrase(band.step_size, prose.tier)
            )
        };

        seen_bound |= primary.is_some();
        seen_qualifier |= has_qualifier;
        tiers.push((condition, body, step));
    }

    // A single tier reads as one line; multiple tiers become a bulleted list, one per tier, so the
    // sequence of conditions is easy to scan.
    let section = match tiers.as_slice() {
        [(condition, body, _)] if condition.is_empty() => {
            format!("**{}:** {body}.", prose.subject)
        }
        [(condition, body, _)] => {
            format!("**{}:** {condition}, {body}.", prose.subject)
        }
        _ => {
            let bullets: Vec<String> = tiers
                .iter()
                .map(|(condition, body, step)| {
                    format!("- {}: {body}{step}", capitalize_first(condition))
                })
                .collect();
            format!("**{}:**\n\n{}", prose.subject, bullets.join("\n"))
        }
    };

    // The billing step (when shared by every tier) and the unreachable-tier note are secondary
    // detail, set in their own italic line.
    // A shared step of 1 (the smallest unit) is effectively continuous billing and not mentioned.
    let section = match first_step.filter(|&step| uniform_step && step != 1) {
        Some(step) => format!(
            "{section}\n\n_Billed in {} steps, rounded up._",
            step_phrase(step, prose.tier)
        ),
        None => section,
    };

    let section = if dropped > 0 {
        format!(
            "{section}\n\n_Any later tiers never apply, because an earlier rate already matches \
             every session._"
        )
    } else {
        section
    };

    Some(section)
}

/// The English phrasing used for a metered dimension.
#[derive(Clone, Copy)]
struct DimensionProse {
    /// Sentence subject / label, e.g. "Charging time".
    subject: &'static str,

    /// The per-unit rate phrase, e.g. "per hour".
    unit: &'static str,

    /// The noun used for the catch-all tier, e.g. "the remaining charging time".
    remaining: &'static str,

    /// How a tier is bounded: by duration or by consumed energy.
    tier: TierBasis,
}

/// What a tier of a dimension is bounded by.
#[derive(Clone, Copy)]
enum TierBasis {
    /// Bounded by how long the session has lasted (`min_duration`/`max_duration`).
    Duration,

    /// Bounded by how much energy has been consumed (`min_kwh`/`max_kwh`).
    Energy,
}

impl DimensionProse {
    /// The phrasing for a metered dimension, or `None` for the flat fee.
    fn new(dimension: DimensionType) -> Option<Self> {
        let prose = match dimension {
            DimensionType::Energy => DimensionProse {
                subject: "Energy",
                unit: "per kWh",
                remaining: "the remaining energy",
                tier: TierBasis::Energy,
            },
            DimensionType::Time => DimensionProse {
                subject: "Charging time",
                unit: "per hour",
                remaining: "the remaining charging time",
                tier: TierBasis::Duration,
            },
            DimensionType::ParkingTime => DimensionProse {
                subject: "Idle time (connected but not charging)",
                unit: "per hour",
                remaining: "the remaining idle time",
                tier: TierBasis::Duration,
            },
            DimensionType::Flat => return None,
        };

        Some(prose)
    }
}

/// Describe a billing step in the unit appropriate to the dimension, e.g. "0.001 kWh" or "1-second".
///
/// The energy `step_size` is given in Wh by the spec, but it is shown in kWh because readers expect
/// energy in kWh and tend to read "Wh" as a typo.
fn step_phrase(step_size: u64, tier: TierBasis) -> String {
    match tier {
        TierBasis::Energy => {
            let kwh = Kwh::from_watt_hours(Decimal::from(step_size));
            format!("{} kWh", Decimal::from(kwh).normalize())
        }
        TierBasis::Duration => format!("{step_size}-second"),
    }
}

/// Narrate the overall `min_price`/`max_price` bounds.
fn narrate_bounds(
    min_price: Option<Price>,
    max_price: Option<Price>,
    currency: currency::Code,
) -> Option<String> {
    let mut sentences = Vec::new();

    if let Some(min) = min_price {
        sentences.push(format!(
            "A session always costs at least {}.",
            price(min, currency)
        ));
    }
    if let Some(max) = max_price {
        sentences.push(format!(
            "A session never costs more than {}.",
            price(max, currency)
        ));
    }

    if sentences.is_empty() {
        None
    } else {
        Some(sentences.join(" "))
    }
}

/// Narrate the validity window of the tariff itself.
fn narrate_validity(
    start_date_time: Option<DateTime<Utc>>,
    end_date_time: Option<DateTime<Utc>>,
) -> Option<String> {
    match (start_date_time, end_date_time) {
        (Some(start), Some(end)) => Some(format!(
            "This tariff is only valid from {} until {} (UTC).",
            start.format("%Y-%m-%d %H:%M"),
            end.format("%Y-%m-%d %H:%M"),
        )),
        (Some(start), None) => Some(format!(
            "This tariff only becomes active on {} (UTC).",
            start.format("%Y-%m-%d %H:%M"),
        )),
        (None, Some(end)) => Some(format!(
            "This tariff is no longer valid from {} (UTC).",
            end.format("%Y-%m-%d %H:%M"),
        )),
        (None, None) => None,
    }
}

/// The consumption bound that matches the dimension: duration for charging/idle time, energy for
/// the energy dimension. This is the tier's natural "for the first N" phrase.
fn primary_scope(restrictions: &Restrictions, tier: TierBasis) -> Option<String> {
    match tier {
        TierBasis::Duration => duration_scope(restrictions),
        TierBasis::Energy => energy_scope(restrictions),
    }
}

/// The consumption bound of the other kind than the dimension's own. The spec allows, for example,
/// an energy threshold on a charging-time tier; this surfaces it as an extra condition.
fn secondary_scope(restrictions: &Restrictions, tier: TierBasis) -> Option<String> {
    match tier {
        TierBasis::Duration => energy_scope(restrictions),
        TierBasis::Energy => duration_scope(restrictions),
    }
}

/// Turn the duration restrictions of a tier into a scope phrase like "for the first 3 hours".
///
/// These bounds are on how long the session has lasted, not a time of day. The two-sided case is
/// phrased "between X and Y into the session" so it is not mistaken for a wall-clock window.
fn duration_scope(restrictions: &Restrictions) -> Option<String> {
    match (restrictions.min_duration, restrictions.max_duration) {
        (None, Some(max)) => Some(format!("for the first {}", humanize_duration(max))),
        (Some(min), None) => Some(format!("after the first {}", humanize_duration(min))),
        (Some(min), Some(max)) => Some(format!(
            "between {} and {} into the session",
            humanize_duration(min),
            humanize_duration(max)
        )),
        (None, None) => None,
    }
}

/// Turn the energy restrictions of a tier into a scope phrase like "for the first 20 kWh".
fn energy_scope(restrictions: &Restrictions) -> Option<String> {
    match (restrictions.min_kwh, restrictions.max_kwh) {
        (None, Some(max)) => Some(format!("for the first {}", kwh(max))),
        (Some(min), None) => Some(format!("after the first {}", kwh(min))),
        (Some(min), Some(max)) => Some(format!("from {} to {}", kwh(min), kwh(max))),
        (None, None) => None,
    }
}

/// Collect the non-tiering restrictions (time of day, weekday, date, power, current) into a list of
/// English phrases, e.g. `["between 06:00 and 22:00", "while charging at 50 kW or more"]`.
///
/// The phrases are lowercase and carry no "only"/"between" lead-in beyond their own, so callers can
/// place them at the front of a sentence ("Between 06:00 and 22:00, ...") or in a trailing clause.
fn restriction_parts(restrictions: &Restrictions) -> Vec<String> {
    let mut parts = Vec::new();

    match (restrictions.start_time, restrictions.end_time) {
        // Equal start and end times describe an empty window, so the element never applies. The
        // spec uses 00:00 for "end of day", which is the only case where equal times would not be
        // degenerate, but that is the wrapping branch below, not this one.
        (Some(start), Some(end)) if start == end => {
            parts.push(format!(
                "never (its window {} to {} is empty)",
                hm(start),
                hm(end)
            ));
        }
        // An end time earlier than the start time means the window wraps past midnight.
        (Some(start), Some(end)) if end < start => {
            parts.push(format!(
                "between {} and {} the next day",
                hm(start),
                hm(end)
            ));
        }
        (Some(start), Some(end)) => parts.push(format!("between {} and {}", hm(start), hm(end))),
        (Some(start), None) => parts.push(format!("from {} onwards", hm(start))),
        (None, Some(end)) => parts.push(format!("before {}", hm(end))),
        (None, None) => {}
    }

    if let Some(days) = restrictions.day_of_week.as_ref().filter(|d| !d.is_empty()) {
        let names: Vec<&str> = days.iter().copied().map(weekday_name).collect();
        parts.push(format!("on {}", names.join(", ")));
    }

    match (restrictions.start_date, restrictions.end_date) {
        (Some(start), Some(end)) => parts.push(format!("from {start} until {end}")),
        (Some(start), None) => parts.push(format!("from {start} onwards")),
        (None, Some(end)) => parts.push(format!("until {end}")),
        (None, None) => {}
    }

    if let Some(min) = restrictions.min_power {
        parts.push(format!("while charging at {} or more", kw(min)));
    }
    if let Some(max) = restrictions.max_power {
        parts.push(format!("while charging below {}", kw(max)));
    }
    if let Some(min) = restrictions.min_current {
        parts.push(format!("at {} or more", ampere(min)));
    }
    if let Some(max) = restrictions.max_current {
        parts.push(format!("below {}", ampere(max)));
    }

    parts
}

/// Capitalize the first character of a string, leaving the rest untouched.
fn capitalize_first(text: &str) -> String {
    let mut chars = text.chars();
    match chars.next() {
        Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
        None => String::new(),
    }
}

/// Return true when a rate is exactly zero, so it can be narrated as "free".
fn is_free(money: Money) -> bool {
    Decimal::from(money) == Decimal::ZERO
}

/// Format a `Money` amount with its currency symbol, fixed to two decimals for readability.
///
/// Two decimals reads best for ordinary prices, but a small nonzero rate must never collapse to a
/// "0.00" string and read as free; in that case the value's own precision is used instead.
fn money(money: Money, currency: currency::Code) -> String {
    let amount = Decimal::from(money);
    let symbol = currency.into_symbol();

    if amount != Decimal::ZERO && amount.round_dp(2) == Decimal::ZERO {
        format!("{symbol}{}", amount.normalize())
    } else {
        format!("{symbol}{amount:.2}")
    }
}

/// Format a `Price` (which may carry a VAT-inclusive value) with its currency symbol.
fn price(price: Price, currency: currency::Code) -> String {
    match price.incl_vat {
        Some(incl) => {
            format!(
                "{} ({} incl. VAT)",
                money(price.excl_vat, currency),
                money(incl, currency)
            )
        }
        None => money(price.excl_vat, currency),
    }
}

/// Format a `Kwh` value without trailing zeros, e.g. "20 kWh".
fn kwh(value: Kwh) -> String {
    format!("{} kWh", Decimal::from(value).normalize())
}

/// Format a `Kw` value without trailing zeros, e.g. "11 kW".
fn kw(value: Kw) -> String {
    format!("{} kW", Decimal::from(value).normalize())
}

/// Format an `Ampere` value without trailing zeros, e.g. "16 A".
fn ampere(value: Ampere) -> String {
    format!("{} A", Decimal::from(value).normalize())
}

/// Format a `NaiveTime` as `HH:MM`.
fn hm(time: NaiveTime) -> String {
    time.format("%H:%M").to_string()
}

/// A trailing clause describing the VAT applied to a rate, or an empty string when no VAT applies.
fn vat_clause(vat: VatOrigin) -> String {
    match vat {
        // `v2.1.1` tariffs carry no VAT information, so saying nothing is the honest choice.
        VatOrigin::Unknown | VatOrigin::NotProvided => String::new(),
        VatOrigin::Provided(vat) => format!(" (excl. {} VAT)", Decimal::from(vat).normalize()),
    }
}

/// Render a duration as a friendly phrase such as "3 hours" or "1 hour 30 minutes".
fn humanize_duration(duration: TimeDelta) -> String {
    let total_seconds = duration.num_seconds().max(0);
    let hours = total_seconds / 3600;
    let minutes = (total_seconds % 3600) / 60;
    let seconds = total_seconds % 60;

    let mut parts = Vec::new();

    if hours > 0 {
        parts.push(unit(hours, "hour"));
    }
    if minutes > 0 {
        parts.push(unit(minutes, "minute"));
    }
    if seconds > 0 {
        parts.push(unit(seconds, "second"));
    }

    if parts.is_empty() {
        "0 seconds".to_owned()
    } else {
        parts.join(" ")
    }
}

/// Format a count with a singular/plural unit, e.g. "1 hour" or "3 hours".
fn unit(count: i64, noun: &str) -> String {
    if count == 1 {
        format!("1 {noun}")
    } else {
        format!("{count} {noun}s")
    }
}

/// The English name of a weekday.
fn weekday_name(day: Weekday) -> &'static str {
    match day {
        Weekday::Monday => "Monday",
        Weekday::Tuesday => "Tuesday",
        Weekday::Wednesday => "Wednesday",
        Weekday::Thursday => "Thursday",
        Weekday::Friday => "Friday",
        Weekday::Saturday => "Saturday",
        Weekday::Sunday => "Sunday",
    }
}
