/*
SPDX-License-Identifier: MIT OR Apache-2.0
SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
*/

//! Rendering logic for date fields with locale-aware formatting.
//!
//! This module handles date component rendering with support for different date forms,
//! time formatting, and locale-specific date presentation.

use crate::reference::{EdtfString, Reference};
use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
use citum_edtf::{Day, Edtf, MonthOrSeason, Timezone, UnspecifiedYear, Year};
use citum_schema::locale::{GeneralTerm, TermForm};
use citum_schema::options::dates::TimeFormat;
use citum_schema::reference::types::RefDate;
use citum_schema::template::{DateForm, DateVariable as TemplateDateVar, TemplateDate};

fn month_to_string(month: u32, months: &[String]) -> String {
    if month > 0 {
        let index = month - 1;
        if let Some(month_name) = months.get(index as usize) {
            month_name.clone()
        } else {
            String::new()
        }
    } else {
        String::new()
    }
}

fn extract_month(date: &EdtfString, months: &[String]) -> String {
    let parsed_date = date.parse();
    let month: Option<u32> = match parsed_date {
        RefDate::Edtf(edtf) => edtf.month(),
        RefDate::Literal(_) => None,
    };
    match month {
        Some(month) => month_to_string(month, months),
        None => String::new(),
    }
}

/// Compute the delta for unspecified year ranges.
fn unspecified_year_delta(u: &UnspecifiedYear) -> i64 {
    match u {
        UnspecifiedYear::None => 0,
        UnspecifiedYear::One => 9,
        UnspecifiedYear::Two => 99,
        UnspecifiedYear::Three => 999,
        UnspecifiedYear::Four => 9999,
    }
}

/// Format a year with era-aware rendering.
fn format_display_year(
    year: &Year,
    date_terms: &citum_schema::locale::DateTerms,
    era_labels: &citum_schema::options::dates::EraLabels,
    _neg_unspecified: &citum_schema::options::dates::NegativeUnspecifiedYears,
    range_delimiter: &str,
) -> String {
    // Handle positive unspecified years: normalize 'u' to 'X'
    if year.unspecified != UnspecifiedYear::None && year.value > 0 {
        let mut s = year.value.to_string();
        let unspec_count = match year.unspecified {
            UnspecifiedYear::One => 1,
            UnspecifiedYear::Two => 2,
            UnspecifiedYear::Three => 3,
            UnspecifiedYear::Four => 4,
            _ => 0,
        };
        for _ in 0..unspec_count {
            if let Some(last) = s.pop()
                && last != '0'
            {
                s.push('X');
            }
        }
        if s.len() < year.value.to_string().len() {
            let diff = year.value.to_string().len() - s.len();
            for _ in 0..diff {
                s.push('X');
            }
        }
        return s;
    }

    // Handle negative unspecified years: compute historical range
    if year.unspecified != UnspecifiedYear::None && year.value <= 0 {
        let delta = unspecified_year_delta(&year.unspecified);
        let astronomical_min = year.value - delta;
        let astronomical_max = year.value;
        let historical_end = 1 - astronomical_max;
        let historical_start = 1 - astronomical_min;

        let era_term = match era_labels {
            citum_schema::options::dates::EraLabels::Default => {
                date_terms.before_era.as_deref().unwrap_or("")
            }
            citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
            citum_schema::options::dates::EraLabels::BceCe => {
                date_terms.bce.as_deref().unwrap_or("")
            }
        };

        if era_term.is_empty() {
            format!("{historical_start}{range_delimiter}{historical_end}")
        } else {
            format!("{historical_start}{range_delimiter}{historical_end} {era_term}")
        }
    } else if year.value <= 0 {
        // Fully specified negative year
        let historical_year = 1 - year.value;
        let era_term = match era_labels {
            citum_schema::options::dates::EraLabels::Default => {
                date_terms.before_era.as_deref().unwrap_or("")
            }
            citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
            citum_schema::options::dates::EraLabels::BceCe => {
                date_terms.bce.as_deref().unwrap_or("")
            }
        };

        if era_term.is_empty() {
            historical_year.to_string()
        } else {
            format!("{historical_year} {era_term}")
        }
    } else {
        // Positive year
        let era_term = match era_labels {
            citum_schema::options::dates::EraLabels::Default => "",
            citum_schema::options::dates::EraLabels::BcAd => date_terms.ad.as_deref().unwrap_or(""),
            citum_schema::options::dates::EraLabels::BceCe => {
                date_terms.ce.as_deref().unwrap_or("")
            }
        };

        if era_term.is_empty() {
            year.value.to_string()
        } else {
            format!("{} {}", year.value, era_term)
        }
    }
}

/// Legacy format_display_year for backwards compatibility.
fn format_display_year_legacy(year: &Year, before_era: Option<&str>) -> String {
    if year.unspecified != UnspecifiedYear::None {
        return year.to_string();
    }

    if year.value <= 0 {
        let historical_year = 1 - year.value;
        if let Some(term) = before_era.filter(|term| !term.is_empty()) {
            format!("{historical_year} {term}")
        } else {
            historical_year.to_string()
        }
    } else {
        year.value.to_string()
    }
}

#[allow(dead_code, reason = "kept for backwards compatibility")]
fn extract_display_year_legacy(date: &EdtfString, before_era: Option<&str>) -> String {
    match date.parse() {
        RefDate::Edtf(edtf) => match edtf {
            Edtf::Date(date) => format_display_year_legacy(&date.year, before_era),
            Edtf::Interval(interval) => {
                format_display_year_legacy(&interval.start.year, before_era)
            }
            Edtf::IntervalFrom(date) | Edtf::IntervalTo(date) => {
                format_display_year_legacy(&date.year, before_era)
            }
        },
        RefDate::Literal(_) => String::new(),
    }
}

fn extract_range_end(
    date: &EdtfString,
    months: &[String],
    date_terms: &citum_schema::locale::DateTerms,
    era_labels: &citum_schema::options::dates::EraLabels,
    neg_unspecified: &citum_schema::options::dates::NegativeUnspecifiedYears,
    range_delimiter: &str,
) -> Option<String> {
    match date.parse() {
        RefDate::Edtf(edtf) => match edtf {
            Edtf::Interval(interval) => {
                let end = &interval.end;
                let year = format_display_year(
                    &end.year,
                    date_terms,
                    era_labels,
                    neg_unspecified,
                    range_delimiter,
                );
                let month = match end.month_or_season {
                    Some(MonthOrSeason::Month(m)) => Some(m),
                    _ => None,
                };
                let day = match end.day {
                    Some(Day::Day(d)) => Some(d),
                    _ => None,
                };

                match (month, day) {
                    (Some(m), Some(d)) if m > 0 && d > 0 => {
                        let month_str = month_to_string(m, months);
                        Some(format!("{} {}, {}", month_str, d, year))
                    }
                    (Some(m), _) if m > 0 => {
                        let month_str = month_to_string(m, months);
                        Some(format!("{} {}", month_str, year))
                    }
                    _ => Some(year),
                }
            }
            Edtf::IntervalFrom(_date) => None, // Open-ended
            Edtf::IntervalTo(date) => {
                let year = format_display_year(
                    &date.year,
                    date_terms,
                    era_labels,
                    neg_unspecified,
                    range_delimiter,
                );
                Some(year)
            }
            _ => None,
        },
        RefDate::Literal(_) => None,
    }
}

/// Formats a time with the specified format, optionally including seconds and timezone.
///
/// Converts 24-hour time to 12-hour format if specified, and appends localized
/// AM/PM or timezone indicators as configured.
fn format_time(
    time: citum_edtf::Time,
    format: &TimeFormat,
    show_seconds: bool,
    show_timezone: bool,
    am_term: Option<&str>,
    pm_term: Option<&str>,
    utc_term: Option<&str>,
) -> String {
    let (display_hour, period) = match format {
        TimeFormat::Hour12 => {
            let (h, p) = if time.hour == 0 {
                (12u32, am_term.unwrap_or("AM"))
            } else if time.hour < 12 {
                (time.hour, am_term.unwrap_or("AM"))
            } else if time.hour == 12 {
                (12u32, pm_term.unwrap_or("PM"))
            } else {
                (time.hour - 12, pm_term.unwrap_or("PM"))
            };
            (h, Some(p))
        }
        TimeFormat::Hour24 => (time.hour, None),
    };

    let time_str = if show_seconds {
        format!("{:02}:{:02}:{:02}", display_hour, time.minute, time.second)
    } else {
        format!("{:02}:{:02}", display_hour, time.minute)
    };

    let with_period = match period {
        Some(p) => format!("{time_str} {p}"),
        None => time_str,
    };

    if show_timezone {
        let tz_str = match time.timezone {
            Some(Timezone::Utc) => utc_term.unwrap_or("UTC").to_string(),
            Some(Timezone::Offset(mins)) => {
                let sign = if mins >= 0 { '+' } else { '-' };
                let abs = mins.unsigned_abs();
                format!("{}{:02}:{:02}", sign, abs / 60, abs % 60)
            }
            None => String::new(),
        };
        if tz_str.is_empty() {
            with_period
        } else {
            format!("{with_period} {tz_str}")
        }
    } else {
        with_period
    }
}

/// Format the start portion of a date range according to the given form.
fn format_range_start(
    date: &EdtfString,
    form: &DateForm,
    locale: &citum_schema::locale::Locale,
    date_config: Option<&citum_schema::options::dates::DateConfig>,
) -> String {
    let default_era = citum_schema::options::dates::EraLabels::Default;
    let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
    let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
    let neg_unspecified = date_config
        .map(|c| &c.negative_unspecified_years)
        .unwrap_or(&default_neg_unspec);
    let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());

    let extract_year = |d: &EdtfString| -> String {
        match d.parse() {
            RefDate::Edtf(edtf) => match edtf {
                Edtf::Date(date) => format_display_year(
                    &date.year,
                    &locale.dates,
                    era_labels,
                    neg_unspecified,
                    range_delimiter,
                ),
                Edtf::Interval(interval) => format_display_year(
                    &interval.start.year,
                    &locale.dates,
                    era_labels,
                    neg_unspecified,
                    range_delimiter,
                ),
                Edtf::IntervalFrom(date) | Edtf::IntervalTo(date) => format_display_year(
                    &date.year,
                    &locale.dates,
                    era_labels,
                    neg_unspecified,
                    range_delimiter,
                ),
            },
            RefDate::Literal(_) => String::new(),
        }
    };

    match form {
        DateForm::Year => extract_year(date),
        DateForm::YearMonth => {
            let month = extract_month(date, &locale.dates.months.long);
            let year = extract_year(date);
            if month.is_empty() {
                year
            } else {
                format!("{month} {year}")
            }
        }
        DateForm::MonthDay => {
            let month = extract_month(date, &locale.dates.months.long);
            let day = date.day();
            match day {
                Some(d) => format!("{month} {d}"),
                None => month,
            }
        }
        DateForm::Full => {
            let year = extract_year(date);
            let month = extract_month(date, &locale.dates.months.long);
            let day = date.day();
            match (month.is_empty(), day) {
                (true, _) => year,
                (false, None) => format!("{month} {year}"),
                (false, Some(d)) => format!("{month} {d}, {year}"),
            }
        }
        DateForm::YearMonthDay => {
            let year = extract_year(date);
            let month = extract_month(date, &locale.dates.months.long);
            let day = date.day();
            match (month.is_empty(), day) {
                (true, _) => year,
                (false, None) => format!("{year}, {month}"),
                (false, Some(d)) => format!("{year}, {month} {d}"),
            }
        }
        DateForm::DayMonthAbbrYear => {
            let year = extract_year(date);
            let month = extract_month(date, &locale.dates.months.short);
            let day = date.day();
            match (month.is_empty(), day) {
                (true, _) => year,
                (false, None) => format!("{month} {year}"),
                (false, Some(d)) => format!("{d} {month} {year}"),
            }
        }
        DateForm::MonthAbbrDayYear => {
            let year = extract_year(date);
            let month = extract_month(date, &locale.dates.months.short);
            let day = date.day();
            match (month.is_empty(), day) {
                (true, _) => year,
                (false, None) => format!("{month} {year}"),
                (false, Some(d)) => format!("{month} {d}, {year}"),
            }
        }
        _ => extract_year(date),
    }
}

/// Format a date range with start date and delimiter.
fn format_date_range(
    start: String,
    date: &EdtfString,
    locale: &citum_schema::locale::Locale,
    date_config: Option<&citum_schema::options::dates::DateConfig>,
) -> Option<String> {
    let era_labels = date_config
        .map(|c| &c.era_labels)
        .unwrap_or(&citum_schema::options::dates::EraLabels::Default);
    let neg_unspecified = date_config
        .map(|c| &c.negative_unspecified_years)
        .unwrap_or(&citum_schema::options::dates::NegativeUnspecifiedYears::Range);
    let delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());

    if date.is_open_range() {
        // Open-ended range (e.g., "1990/..")
        if let Some(end_marker) = date_config
            .and_then(|c| c.open_range_marker.as_deref())
            .or(locale.dates.open_ended_term.as_deref())
        {
            Some(format!("{start}{delimiter}{end_marker}"))
        } else {
            // No open-ended term available - return start date only
            Some(start)
        }
    } else if let Some(end) = extract_range_end(
        date,
        &locale.dates.months.long,
        &locale.dates,
        era_labels,
        neg_unspecified,
        delimiter,
    ) {
        // Closed range with end date
        Some(format!("{start}{delimiter}{end}"))
    } else {
        Some(start)
    }
}

/// Apply uncertainty and approximation markers to formatted date.
fn apply_date_markers(
    value: String,
    date: &EdtfString,
    date_config: Option<&citum_schema::options::dates::DateConfig>,
) -> String {
    let mut result = value;
    if date.is_approximate()
        && let Some(marker) = date_config.and_then(|c| c.approximation_marker.as_ref())
    {
        result = format!("{marker}{result}");
    }
    if date.is_uncertain()
        && let Some(marker) = date_config.and_then(|c| c.uncertainty_marker.as_ref())
    {
        result = format!("{result}{marker}");
    }
    result
}

/// Compute the disambiguation suffix for year-based citations.
fn compute_disamb_suffix<F: crate::render::format::OutputFormat<Output = String>>(
    date: &EdtfString,
    form: &DateForm,
    hints: &ProcHints,
    options: &RenderOptions<'_>,
    fmt: &F,
) -> Option<String> {
    if hints.disamb_condition && date_form_displays_year(form) && !date.year().is_empty() {
        // Check if year suffix is enabled. Fall back to AuthorDate default
        // (year_suffix: true) when processing is not explicitly set, matching
        // the behavior in disambiguation.rs which uses unwrap_or_default().
        let use_suffix = options
            .config
            .processing
            .as_ref()
            .unwrap_or(&citum_schema::options::Processing::AuthorDate)
            .config()
            .disambiguate
            .as_ref()
            .is_some_and(|d| d.year_suffix);

        if use_suffix {
            int_to_letter(hints.group_index as u32).map(|s| fmt.text(&s))
        } else {
            None
        }
    } else {
        None
    }
}

fn date_form_displays_year(form: &DateForm) -> bool {
    !matches!(form, DateForm::MonthDay)
}

fn inline_disamb_suffix(formatted: &str, form: &DateForm, year: &str, suffix: &str) -> String {
    if year.is_empty() || suffix.is_empty() {
        return formatted.to_string();
    }

    let year_index = match form {
        DateForm::Year | DateForm::YearMonthDay => formatted.find(year),
        DateForm::YearMonth
        | DateForm::Full
        | DateForm::DayMonthAbbrYear
        | DateForm::MonthAbbrDayYear => formatted.rfind(year),
        DateForm::MonthDay => None,
        _ => None,
    };

    let Some(index) = year_index else {
        return format!("{formatted}{suffix}");
    };

    let year_end = index + year.len();
    #[allow(clippy::string_slice, reason = "indices derived from find/rfind")]
    let result = format!(
        "{}{}{}{}",
        &formatted[..index],
        year,
        suffix,
        &formatted[year_end..]
    );
    result
}

/// Format a single date (non-range) according to the given form.
#[allow(
    clippy::too_many_lines,
    reason = "date formatting handles 6 form variants"
)]
fn format_single_date(
    date: &EdtfString,
    form: &DateForm,
    locale: &citum_schema::locale::Locale,
    date_config: Option<&citum_schema::options::dates::DateConfig>,
) -> Option<String> {
    let default_era = citum_schema::options::dates::EraLabels::Default;
    let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
    let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
    let neg_unspecified = date_config
        .map(|c| &c.negative_unspecified_years)
        .unwrap_or(&default_neg_unspec);
    let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());

    let extract_year = |d: &EdtfString| -> String {
        match d.parse() {
            RefDate::Edtf(edtf) => match edtf {
                Edtf::Date(dt) => format_display_year(
                    &dt.year,
                    &locale.dates,
                    era_labels,
                    neg_unspecified,
                    range_delimiter,
                ),
                Edtf::Interval(interval) => format_display_year(
                    &interval.start.year,
                    &locale.dates,
                    era_labels,
                    neg_unspecified,
                    range_delimiter,
                ),
                Edtf::IntervalFrom(dt) | Edtf::IntervalTo(dt) => format_display_year(
                    &dt.year,
                    &locale.dates,
                    era_labels,
                    neg_unspecified,
                    range_delimiter,
                ),
            },
            RefDate::Literal(_) => String::new(),
        }
    };

    match form {
        DateForm::Year => {
            let year = extract_year(date);
            if year.is_empty() { None } else { Some(year) }
        }
        DateForm::YearMonth => {
            let year = extract_year(date);
            if year.is_empty() {
                return None;
            }
            let month = extract_month(date, &locale.dates.months.long);
            let month_opt = (!month.is_empty()).then_some(month.as_str());
            if let Some(rendered) =
                locale.resolve_date_pattern("pattern.date-year-month", Some(&year), month_opt, None)
            {
                return Some(rendered);
            }
            if month.is_empty() {
                Some(year)
            } else {
                Some(format!("{month} {year}"))
            }
        }
        DateForm::MonthDay => {
            let month = extract_month(date, &locale.dates.months.long);
            if month.is_empty() {
                return None;
            }
            let day = date.day();
            if let Some(rendered) =
                locale.resolve_date_pattern("pattern.date-month-day", None, Some(&month), day)
            {
                return Some(rendered);
            }
            match day {
                Some(d) => Some(format!("{month} {d}")),
                None => Some(month),
            }
        }
        DateForm::Full => {
            let year = extract_year(date);
            if year.is_empty() {
                return None;
            }
            let month = extract_month(date, &locale.dates.months.long);
            let day = date.day();
            let base = locale
                .resolve_date_pattern(
                    "pattern.date-full",
                    Some(&year),
                    (!month.is_empty()).then_some(month.as_str()),
                    day,
                )
                .unwrap_or_else(|| match (month.is_empty(), day) {
                    (true, _) => year.clone(),
                    (false, None) => format!("{month} {year}"),
                    (false, Some(d)) => format!("{month} {d}, {year}"),
                });
            // Append time component if configured and present
            if let (Some(time_fmt), Some(time)) = (
                date_config.and_then(|c| c.time_format.as_ref()),
                date.time(),
            ) {
                let show_secs = date_config.is_some_and(|c| c.show_seconds);
                let show_tz = date_config.is_some_and(|c| c.show_timezone);
                let time_str = format_time(
                    time,
                    time_fmt,
                    show_secs,
                    show_tz,
                    locale.dates.am.as_deref(),
                    locale.dates.pm.as_deref(),
                    locale.dates.timezone_utc.as_deref(),
                );
                Some(format!("{base}, {time_str}"))
            } else {
                Some(base)
            }
        }
        DateForm::YearMonthDay => {
            let year = extract_year(date);
            if year.is_empty() {
                return None;
            }
            let month = extract_month(date, &locale.dates.months.long);
            let day = date.day();
            let month_opt = (!month.is_empty()).then_some(month.as_str());
            if let Some(rendered) = locale.resolve_date_pattern(
                "pattern.date-year-month-day",
                Some(&year),
                month_opt,
                day,
            ) {
                return Some(rendered);
            }
            match (month.is_empty(), day) {
                (true, _) => Some(year),
                (false, None) => Some(format!("{year}, {month}")),
                (false, Some(d)) => Some(format!("{year}, {month} {d}")),
            }
        }
        DateForm::DayMonthAbbrYear => {
            let year = extract_year(date);
            if year.is_empty() {
                return None;
            }
            let month = extract_month(date, &locale.dates.months.short);
            let day = date.day();
            let month_opt = (!month.is_empty()).then_some(month.as_str());
            if let Some(rendered) = locale.resolve_date_pattern(
                "pattern.date-day-month-abbr-year",
                Some(&year),
                month_opt,
                day,
            ) {
                return Some(rendered);
            }
            match (month.is_empty(), day) {
                (true, _) => Some(year),
                (false, None) => Some(format!("{month} {year}")),
                (false, Some(d)) => Some(format!("{d} {month} {year}")),
            }
        }
        DateForm::MonthAbbrDayYear => {
            let year = extract_year(date);
            if year.is_empty() {
                return None;
            }
            let month = extract_month(date, &locale.dates.months.short);
            let day = date.day();
            let month_opt = (!month.is_empty()).then_some(month.as_str());
            if let Some(rendered) = locale.resolve_date_pattern(
                "pattern.date-month-abbr-day-year",
                Some(&year),
                month_opt,
                day,
            ) {
                return Some(rendered);
            }
            match (month.is_empty(), day) {
                (true, _) => Some(year),
                (false, None) => Some(format!("{month} {year}")),
                (false, Some(d)) => Some(format!("{month} {d}, {year}")),
            }
        }
        _ => Some(extract_year(date)),
    }
}

impl ComponentValues for TemplateDate {
    fn values<F: crate::render::format::OutputFormat<Output = String>>(
        &self,
        reference: &Reference,
        hints: &ProcHints,
        options: &RenderOptions<'_>,
    ) -> Option<ProcValues<F::Output>> {
        let fmt = F::default();
        let date_opt: Option<EdtfString> = match self.date {
            TemplateDateVar::Issued => reference.csl_issued_date(),
            TemplateDateVar::Accessed => reference.accessed(),
            TemplateDateVar::OriginalPublished => reference.original_date(),
            _ => None,
        };

        let Some(date) = date_opt.filter(|d| !d.0.is_empty()) else {
            // Handle fallback if date is missing
            if let Some(fallbacks) = &self.fallback {
                for component in fallbacks {
                    if let Some(values) = component.values::<F>(reference, hints, options) {
                        return Some(values);
                    }
                }
            }
            // For issued dates, substitute the locale's "no-date" term (e.g. "n.d.")
            if matches!(self.date, TemplateDateVar::Issued)
                && let Some(nd) = options.locale.resolved_general_term(
                    &GeneralTerm::NoDate,
                    &TermForm::Short,
                    None,
                )
            {
                return Some(ProcValues {
                    value: nd,
                    prefix: None,
                    suffix: None,
                    url: None,
                    substituted_key: None,
                    pre_formatted: false,
                });
            }
            return None;
        };

        let locale = options.locale;
        let date_config = options.config.dates.as_ref();
        let effective_form = self.form.clone();

        let formatted = if date.is_range() {
            // Handle date ranges
            let start = format_range_start(&date, &effective_form, locale, date_config);
            format_date_range(start, &date, locale, date_config)
        } else {
            // Single date (not a range)
            format_single_date(&date, &effective_form, locale, date_config)
        };

        // Apply uncertainty and approximation markers
        let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));

        // Handle disambiguation suffix (a, b, c...).
        // Year-suffix is keyed off the issued year only; suppress it for other date
        // components (e.g. original-published) so a reprint template renders
        // `(1926/1967a)` rather than `(1926a/1967a)`.
        let disamb_suffix = matches!(self.date, TemplateDateVar::Issued)
            .then(|| compute_disamb_suffix(&date, &effective_form, hints, options, &fmt))
            .flatten();

        formatted.map(|value| {
            let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
                (
                    inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
                    None,
                )
            } else {
                (value, None)
            };

            ProcValues {
                value,
                prefix: None,
                suffix,
                url: crate::values::resolve_effective_url(
                    self.links.as_ref(),
                    options.config.links.as_ref(),
                    reference,
                    citum_schema::options::LinkAnchor::Component,
                ),
                substituted_key: None,
                pre_formatted: false,
            }
        })
    }
}

/// Convert a 1-based index into an alphabetic suffix (`1 -> "a"`, `27 -> "aa"`).
#[must_use]
pub fn int_to_letter(n: u32) -> Option<String> {
    if n == 0 {
        return None;
    }

    let mut result = String::new();
    let mut num = n - 1;

    loop {
        result.push((b'a' + (num % 26) as u8) as char);
        if num < 26 {
            break;
        }
        num = num / 26 - 1;
    }

    Some(result.chars().rev().collect())
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing,
    clippy::todo,
    clippy::unimplemented,
    clippy::unreachable,
    clippy::get_unwrap,
    reason = "Panicking is acceptable and often desired in tests."
)]
mod tests {
    use super::*;

    #[test]
    fn test_int_to_letter() {
        // Test basic single-letter conversions (1-26)
        assert_eq!(int_to_letter(1), Some("a".to_string()));
        assert_eq!(int_to_letter(2), Some("b".to_string()));
        assert_eq!(int_to_letter(26), Some("z".to_string()));

        // Test double-letter conversions (27+)
        assert_eq!(int_to_letter(27), Some("aa".to_string()));
        assert_eq!(int_to_letter(52), Some("az".to_string()));
        assert_eq!(int_to_letter(53), Some("ba".to_string()));

        // Test zero returns None
        assert_eq!(int_to_letter(0), None);
    }
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing,
    clippy::todo,
    clippy::unimplemented,
    clippy::unreachable,
    clippy::get_unwrap,
    reason = "Panicking is acceptable and often desired in tests."
)]
mod time_tests {
    use super::*;
    use citum_edtf::{Time, Timezone};

    #[test]
    fn test_format_time_12h_utc() {
        let time = Time {
            hour: 23,
            minute: 20,
            second: 30,
            timezone: Some(Timezone::Utc),
        };
        let result = format_time(
            time,
            &TimeFormat::Hour12,
            false,
            true,
            Some("AM"),
            Some("PM"),
            Some("UTC"),
        );
        assert_eq!(result, "11:20 PM UTC");
    }

    #[test]
    fn test_format_time_24h_utc() {
        let time = Time {
            hour: 23,
            minute: 20,
            second: 30,
            timezone: Some(Timezone::Utc),
        };
        let result = format_time(
            time,
            &TimeFormat::Hour24,
            false,
            true,
            None,
            None,
            Some("UTC"),
        );
        assert_eq!(result, "23:20 UTC");
    }

    #[test]
    fn test_format_time_with_offset() {
        let time = Time {
            hour: 10,
            minute: 10,
            second: 10,
            timezone: Some(Timezone::Offset(330)),
        };
        let result = format_time(
            time,
            &TimeFormat::Hour24,
            false,
            true,
            None,
            None,
            Some("UTC"),
        );
        assert_eq!(result, "10:10 +05:30");
    }

    #[test]
    fn test_format_time_no_timezone() {
        let time = Time {
            hour: 14,
            minute: 30,
            second: 0,
            timezone: None,
        };
        let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
        assert_eq!(result, "14:30");
    }
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing,
    clippy::todo,
    clippy::unimplemented,
    clippy::unreachable,
    clippy::get_unwrap,
    reason = "Panicking is acceptable and often desired in tests."
)]
mod era_tests {
    use super::*;
    use citum_edtf::{UnspecifiedYear, Year};
    use citum_schema::locale::DateTerms;
    use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};

    fn en_terms() -> DateTerms {
        DateTerms::en_us()
    }

    #[test]
    fn positive_year_default_no_suffix() {
        let year = Year {
            value: 54,
            unspecified: UnspecifiedYear::None,
        };
        let result = format_display_year(
            &year,
            &en_terms(),
            &EraLabels::Default,
            &NegativeUnspecifiedYears::Range,
            "–",
        );
        assert_eq!(result, "54");
    }

    #[test]
    fn positive_year_bc_ad() {
        let year = Year {
            value: 54,
            unspecified: UnspecifiedYear::None,
        };
        let result = format_display_year(
            &year,
            &en_terms(),
            &EraLabels::BcAd,
            &NegativeUnspecifiedYears::Range,
            "–",
        );
        assert_eq!(result, "54 AD");
    }

    #[test]
    fn positive_year_bce_ce() {
        let year = Year {
            value: 54,
            unspecified: UnspecifiedYear::None,
        };
        let result = format_display_year(
            &year,
            &en_terms(),
            &EraLabels::BceCe,
            &NegativeUnspecifiedYears::Range,
            "–",
        );
        assert_eq!(result, "54 CE");
    }

    #[test]
    fn negative_year_default() {
        let year = Year {
            value: -43,
            unspecified: UnspecifiedYear::None,
        };
        let result = format_display_year(
            &year,
            &en_terms(),
            &EraLabels::Default,
            &NegativeUnspecifiedYears::Range,
            "–",
        );
        assert_eq!(result, "44 BC");
    }

    #[test]
    fn negative_year_bc_ad() {
        let year = Year {
            value: -43,
            unspecified: UnspecifiedYear::None,
        };
        let result = format_display_year(
            &year,
            &en_terms(),
            &EraLabels::BcAd,
            &NegativeUnspecifiedYears::Range,
            "–",
        );
        assert_eq!(result, "44 BC");
    }

    #[test]
    fn negative_year_bce_ce() {
        let year = Year {
            value: -43,
            unspecified: UnspecifiedYear::None,
        };
        let result = format_display_year(
            &year,
            &en_terms(),
            &EraLabels::BceCe,
            &NegativeUnspecifiedYears::Range,
            "–",
        );
        assert_eq!(result, "44 BCE");
    }

    #[test]
    fn positive_unspecified_ones() {
        let year = Year {
            value: 1990,
            unspecified: UnspecifiedYear::One,
        };
        let result = format_display_year(
            &year,
            &en_terms(),
            &EraLabels::Default,
            &NegativeUnspecifiedYears::Range,
            "–",
        );
        assert_eq!(result, "199X");
    }

    #[test]
    fn positive_unspecified_two() {
        let year = Year {
            value: 1900,
            unspecified: UnspecifiedYear::Two,
        };
        let result = format_display_year(
            &year,
            &en_terms(),
            &EraLabels::Default,
            &NegativeUnspecifiedYears::Range,
            "–",
        );
        assert_eq!(result, "19XX");
    }

    #[test]
    fn negative_unspecified_range() {
        let year = Year {
            value: -90,
            unspecified: UnspecifiedYear::One,
        };
        let result = format_display_year(
            &year,
            &en_terms(),
            &EraLabels::Default,
            &NegativeUnspecifiedYears::Range,
            "–",
        );
        assert_eq!(result, "100–91 BC");
    }

    #[test]
    fn negative_unspecified_century() {
        let year = Year {
            value: 0,
            unspecified: UnspecifiedYear::Two,
        };
        let result = format_display_year(
            &year,
            &en_terms(),
            &EraLabels::Default,
            &NegativeUnspecifiedYears::Range,
            "–",
        );
        assert_eq!(result, "100–1 BC");
    }

    #[test]
    fn backwards_compat_negative_year() {
        let year = Year {
            value: -99,
            unspecified: UnspecifiedYear::None,
        };
        let result = format_display_year(
            &year,
            &en_terms(),
            &EraLabels::Default,
            &NegativeUnspecifiedYears::Range,
            "–",
        );
        assert_eq!(result, "100 BC");
    }
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    reason = "Panicking is acceptable in tests."
)]
mod locale_pattern_tests {
    use super::*;
    use citum_schema::locale::Locale;

    fn en_us() -> Locale {
        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
            .expect("en-US locale should parse")
    }

    fn es_es() -> Locale {
        Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
            .expect("es-ES locale should parse")
    }

    fn eu_es() -> Locale {
        Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
            .expect("eu-ES locale should parse")
    }

    fn full(locale: &Locale, edtf: &str) -> String {
        format_single_date(&EdtfString(edtf.to_string()), &DateForm::Full, locale, None)
            .expect("date should render")
    }

    fn month_day(locale: &Locale, edtf: &str) -> String {
        format_single_date(
            &EdtfString(edtf.to_string()),
            &DateForm::MonthDay,
            locale,
            None,
        )
        .expect("date should render")
    }

    #[test]
    fn en_us_full_unchanged_by_pattern_machinery() {
        // Regression: en-US declares no pattern.date-*, so the engine's
        // hardcoded English assembly must still produce the original output.
        assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
    }

    #[test]
    fn en_us_month_day_unchanged_by_pattern_machinery() {
        assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
    }

    #[test]
    fn es_es_full_uses_locale_pattern() {
        // Spanish day-first assembly via pattern.date-full.
        assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
    }

    #[test]
    fn es_es_month_day_uses_locale_pattern() {
        assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
    }

    #[test]
    fn eu_es_full_uses_locale_pattern() {
        // Basque genitive-absolutive shape via pattern.date-full.
        // Content is PROVISIONAL — see locales/eu-ES.yaml header comment.
        assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
    }

    #[test]
    fn eu_es_month_day_uses_locale_pattern() {
        assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
    }

    fn year_month(locale: &Locale, edtf: &str) -> String {
        format_single_date(
            &EdtfString(edtf.to_string()),
            &DateForm::YearMonth,
            locale,
            None,
        )
        .expect("date should render")
    }

    fn year_month_day(locale: &Locale, edtf: &str) -> String {
        format_single_date(
            &EdtfString(edtf.to_string()),
            &DateForm::YearMonthDay,
            locale,
            None,
        )
        .expect("date should render")
    }

    fn day_month_abbr_year(locale: &Locale, edtf: &str) -> String {
        format_single_date(
            &EdtfString(edtf.to_string()),
            &DateForm::DayMonthAbbrYear,
            locale,
            None,
        )
        .expect("date should render")
    }

    fn month_abbr_day_year(locale: &Locale, edtf: &str) -> String {
        format_single_date(
            &EdtfString(edtf.to_string()),
            &DateForm::MonthAbbrDayYear,
            locale,
            None,
        )
        .expect("date should render")
    }

    #[test]
    fn en_us_year_month_unchanged_by_pattern_machinery() {
        // en-US has no pattern.date-year-month, so hardcoded assembly must hold.
        assert_eq!(year_month(&en_us(), "2023-01"), "January 2023");
    }

    #[test]
    fn en_us_year_month_day_unchanged_by_pattern_machinery() {
        assert_eq!(year_month_day(&en_us(), "2023-01-12"), "2023, January 12");
    }

    #[test]
    fn en_us_day_month_abbr_year_unchanged_by_pattern_machinery() {
        assert_eq!(day_month_abbr_year(&en_us(), "2023-01-12"), "12 Jan. 2023");
    }

    #[test]
    fn en_us_month_abbr_day_year_unchanged_by_pattern_machinery() {
        assert_eq!(month_abbr_day_year(&en_us(), "2023-01-12"), "Jan. 12, 2023");
    }

    #[test]
    fn es_es_year_month_uses_locale_pattern() {
        // Spanish: month before year connected with "de".
        assert_eq!(year_month(&es_es(), "2023-01"), "enero de 2023");
    }

    #[test]
    fn eu_es_year_month_uses_locale_pattern() {
        // Basque: year-first genitive shape. PROVISIONAL — see locales/eu-ES.yaml.
        assert_eq!(year_month(&eu_es(), "2023-01"), "2023ko urtarrila");
    }

    #[test]
    fn year_month_missing_month_falls_back_to_year() {
        // Year-only EDTF: no month to pattern-assemble, returns year alone.
        assert_eq!(year_month(&es_es(), "2023"), "2023");
    }

    #[test]
    fn es_es_year_month_day_uses_locale_pattern() {
        // Spanish: year first, then day/month connected with "de".
        assert_eq!(year_month_day(&es_es(), "2023-01-12"), "2023, 12 de enero");
    }

    #[test]
    fn es_es_year_month_day_missing_day_falls_back() {
        // Pattern requires $day; evaluator returns None, falls back to
        // hardcoded "{year}, {month}".
        assert_eq!(year_month_day(&es_es(), "2023-01"), "2023, enero");
    }

    #[test]
    fn es_es_day_month_abbr_year_uses_locale_pattern() {
        // Spanish abbreviated form: "12 ene. de 2023" via pattern.
        assert_eq!(
            day_month_abbr_year(&es_es(), "2023-01-12"),
            "12 ene. de 2023"
        );
    }

    #[test]
    fn es_es_day_month_abbr_year_missing_day_falls_back() {
        // Pattern requires $day; falls back to hardcoded "{month} {year}".
        assert_eq!(day_month_abbr_year(&es_es(), "2023-01"), "ene. 2023");
    }

    #[test]
    fn es_es_month_abbr_day_year_uses_locale_pattern() {
        // Spanish abbreviated form: "ene. 12 de 2023" via pattern.
        assert_eq!(
            month_abbr_day_year(&es_es(), "2023-01-12"),
            "ene. 12 de 2023"
        );
    }

    #[test]
    fn es_es_month_abbr_day_year_missing_day_falls_back() {
        // Pattern requires $day; falls back to hardcoded "{month} {year}".
        assert_eq!(month_abbr_day_year(&es_es(), "2023-01"), "ene. 2023");
    }

    #[test]
    fn pattern_missing_day_falls_back_to_english_assembly() {
        // Year-month only input: pattern.date-full requires {$day} so the
        // evaluator returns None, and the engine falls through to its
        // hardcoded `{month} {year}` assembly. (A future pattern.date-year-month
        // can fix this for inflected locales — out of scope for this bean.)
        assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
    }
}
