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

//! Locale definitions for Citum.
//!
//! Locales provide language-specific terms, date formats, and punctuation rules
//! for citation formatting.

mod embedded;
/// Locator text normalization.
pub mod locator;
/// Message evaluation for parameterized locale strings.
pub mod message;
mod message_ids;
/// Raw locale types used during locale file parsing.
pub mod raw;
mod raw_conversion;
/// Structured locale types used by the processor.
pub mod types;

use crate::citation::LocatorType;
use crate::template::ContributorRole;
pub use message::{MessageArgs, MessageEvaluator, Mf2MessageEvaluator};
pub use raw::{RawLocale, RawTermValue};
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
pub use types::*;

/// A list of month names (12 elements for Jan-Dec).
pub type MonthList = Vec<String>;

/// Identifies a field in the archive hierarchy for locale term lookup.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArchiveHierarchyField {
    /// Named collection or record group.
    Collection,
    /// Named series or sub-collection.
    Series,
    /// Box or container designation.
    Box,
    /// Folder designation.
    Folder,
    /// Item, file, or reference-code designation.
    Item,
}

impl ArchiveHierarchyField {
    /// Returns the MF2 message ID for this field's locale label.
    fn message_id(self) -> &'static str {
        match self {
            Self::Collection => "term.archive-collection-label",
            Self::Series => "term.archive-series-label",
            Self::Box => "term.archive-box-label",
            Self::Folder => "term.archive-folder-label",
            Self::Item => "term.archive-item-label",
        }
    }
}

/// A locale definition containing language-specific terms and formatting rules.
///
/// The `evaluator` field holds the message evaluation engine, selected based on
/// `evaluation.message_syntax`. This allows for trait-based swapping to ICU4X
/// implementations in the future without changing call sites.
#[derive(Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub struct Locale {
    /// The locale identifier (e.g., "en-US", "de-DE").
    #[cfg_attr(feature = "schema", schemars(skip))]
    pub locale: String,
    /// Date-related terms (months, seasons).
    #[serde(default)]
    pub dates: DateTerms,
    /// Contributor role terms (editor, translator, etc.).
    #[serde(default)]
    #[cfg_attr(feature = "schema", schemars(skip))]
    pub roles: HashMap<ContributorRole, ContributorTerm>,
    /// Locator terms (page, chapter, etc.).
    #[serde(default)]
    #[cfg_attr(feature = "schema", schemars(skip))]
    pub locators: HashMap<LocatorType, LocatorTerm>,
    /// General terms (and, et al., etc.).
    #[serde(default)]
    pub terms: Terms,
    /// Whether to place periods/commas inside quotation marks.
    /// true = American style ("text."), false = British style ("text".)
    #[serde(default)]
    pub punctuation_in_quote: bool,
    /// Articles to strip from titles when sorting (e.g., "the", "a", "an" for English).
    /// These should be lowercase and will be matched case-insensitively.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub sort_articles: Vec<String>,
    /// Schema version from the source locale file (None = legacy v1).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub locale_schema_version: Option<String>,
    /// Runtime evaluation configuration.
    #[serde(default)]
    pub evaluation: EvaluationConfig,
    /// ICU MF1 messages keyed by message ID (populated for v2 locales).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub messages: HashMap<String, String>,
    /// Named date format presets: symbolic name → CLDR pattern.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub date_formats: HashMap<String, String>,
    /// Number formatting options.
    #[serde(default)]
    pub number_formats: NumberFormats,
    /// Grammar options.
    #[serde(default)]
    pub grammar_options: GrammarOptions,
    /// Backwards-compatibility aliases: old term key → new message ID.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub legacy_term_aliases: HashMap<String, String>,
    /// Vocabulary maps for genre and medium display text.
    #[serde(default, skip_serializing_if = "VocabMap::is_empty")]
    pub vocab: VocabMap,
    /// Message evaluator implementation (not serialized; set during load).
    #[serde(skip, default = "default_evaluator")]
    #[cfg_attr(feature = "schema", schemars(skip))]
    pub evaluator: Arc<dyn MessageEvaluator>,
}

/// Default message evaluator (MF2).
fn default_evaluator() -> Arc<dyn MessageEvaluator> {
    Arc::new(Mf2MessageEvaluator)
}

impl Default for Locale {
    fn default() -> Self {
        Self {
            locale: String::default(),
            dates: DateTerms::default(),
            roles: HashMap::default(),
            locators: HashMap::default(),
            terms: Terms::default(),
            punctuation_in_quote: false,
            sort_articles: Vec::default(),
            locale_schema_version: None,
            evaluation: EvaluationConfig::default(),
            messages: HashMap::default(),
            date_formats: HashMap::default(),
            number_formats: NumberFormats::default(),
            grammar_options: GrammarOptions::default(),
            legacy_term_aliases: HashMap::default(),
            vocab: VocabMap::default(),
            evaluator: default_evaluator(),
        }
    }
}

impl fmt::Debug for Locale {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Locale")
            .field("locale", &self.locale)
            .field("dates", &self.dates)
            .field("roles", &self.roles)
            .field("locators", &self.locators)
            .field("terms", &self.terms)
            .field("punctuation_in_quote", &self.punctuation_in_quote)
            .field("sort_articles", &self.sort_articles)
            .field("locale_schema_version", &self.locale_schema_version)
            .field("evaluation", &self.evaluation)
            .field("messages", &self.messages)
            .field("date_formats", &self.date_formats)
            .field("number_formats", &self.number_formats)
            .field("grammar_options", &self.grammar_options)
            .field("legacy_term_aliases", &self.legacy_term_aliases)
            .field("vocab", &self.vocab)
            .field("evaluator", &"<MessageEvaluator>")
            .finish()
    }
}

/// Convert a kebab-case key to a human-readable display string.
///
/// Splits on `-`, capitalizes the first character of the first word, and joins with spaces.
fn kebab_to_display(key: &str) -> String {
    let mut words = key.split('-');
    let mut result = String::new();
    if let Some(first) = words.next() {
        let mut chars = first.chars();
        if let Some(c) = chars.next() {
            result.extend(c.to_uppercase());
            result.push_str(chars.as_str());
        }
        for word in words {
            result.push(' ');
            result.push_str(word);
        }
    }
    result
}

impl Locale {
    /// Create a new English (US) locale with default terms.
    pub fn en_us() -> Self {
        Self {
            locale: "en-US".into(),
            dates: DateTerms::en_us(),
            roles: embedded::en_us_role_terms(),
            locators: embedded::en_us_locator_terms(),
            terms: Terms::en_us(),
            punctuation_in_quote: true,
            sort_articles: vec!["the".into(), "a".into(), "an".into()],
            locale_schema_version: None,
            evaluation: EvaluationConfig {
                message_syntax: MessageSyntax::Mf2,
            },
            messages: embedded::en_us_archive_messages(),
            date_formats: HashMap::new(),
            number_formats: NumberFormats {
                decimal_separator: ".".into(),
                thousands_separator: ",".into(),
                minimum_digits: 1,
            },
            grammar_options: GrammarOptions {
                punctuation_in_quote: true,
                nbsp_before_colon: false,
                open_quote: "\u{201C}".into(),
                close_quote: "\u{201D}".into(),
                open_inner_quote: "\u{2018}".into(),
                close_inner_quote: "\u{2019}".into(),
                serial_comma: true,
                page_range_delimiter: "\u{2013}".into(),
            },
            legacy_term_aliases: HashMap::new(),
            vocab: embedded::embedded_en_us_vocab().clone(),
            evaluator: Arc::new(Mf2MessageEvaluator),
        }
    }

    /// Strip leading articles from a string for sorting.
    ///
    /// Uses locale-specific articles (e.g., "the", "a", "an" for English;
    /// "der", "die", "das" for German). Falls back to English articles
    /// if no locale-specific articles are defined.
    pub fn strip_sort_articles<'a>(&self, s: &'a str) -> &'a str {
        let s = s.trim();

        // Default English articles
        const DEFAULT_ARTICLES: &[&str] = &["the", "a", "an"];

        if self.sort_articles.is_empty() {
            // Use default English articles
            for article in DEFAULT_ARTICLES {
                let prefix = format!("{} ", article);
                if s.to_lowercase().starts_with(&prefix) {
                    #[allow(
                        clippy::string_slice,
                        reason = "prefix is derived from ASCII article"
                    )]
                    return &s[prefix.len()..];
                }
            }
        } else {
            // Use locale-specific articles
            for article in &self.sort_articles {
                let prefix = format!("{} ", article);
                if s.to_lowercase().starts_with(&prefix) {
                    #[allow(
                        clippy::string_slice,
                        reason = "prefix is derived from a defined article"
                    )]
                    return &s[prefix.len()..];
                }
            }
        }
        s
    }

    /// Look up display text for a genre canonical key.
    ///
    /// Falls back to a readable form of the key if no translation found.
    pub fn lookup_genre(&self, key: &str) -> String {
        self.vocab
            .genre
            .get(key)
            .cloned()
            .unwrap_or_else(|| kebab_to_display(key))
    }

    /// Look up display text for a medium canonical key.
    ///
    /// Falls back to a readable form of the key if no translation found.
    pub fn lookup_medium(&self, key: &str) -> String {
        self.vocab
            .medium
            .get(key)
            .cloned()
            .unwrap_or_else(|| kebab_to_display(key))
    }

    /// Get a contributor role term.
    fn resolve_gendered_value(
        value: &MaybeGendered<String>,
        requested_gender: Option<GrammaticalGender>,
    ) -> Option<&str> {
        value
            .resolve_with_fallback(requested_gender)
            .map(String::as_str)
    }

    fn resolve_gendered_value_neutral(value: &MaybeGendered<String>) -> Option<&str> {
        value.resolve_neutral().map(String::as_str)
    }

    fn resolve_no_date_value<'a>(
        value: &'a SimpleTerm,
        form: &TermForm,
        requested_gender: Option<GrammaticalGender>,
    ) -> Option<&'a str> {
        match requested_gender {
            Some(GrammaticalGender::Common) => match *form {
                TermForm::Long => value
                    .long
                    .resolve_strict(Some(GrammaticalGender::Common))
                    .map(String::as_str),
                TermForm::Short => value
                    .short
                    .resolve_strict(Some(GrammaticalGender::Common))
                    .map(String::as_str)
                    .filter(|value| !value.is_empty())
                    .or_else(|| {
                        value
                            .long
                            .resolve_strict(Some(GrammaticalGender::Common))
                            .map(String::as_str)
                    }),
                _ => value
                    .long
                    .resolve_strict(Some(GrammaticalGender::Common))
                    .map(String::as_str),
            },
            _ => match *form {
                TermForm::Long => Self::resolve_gendered_value(&value.long, requested_gender),
                TermForm::Short => {
                    Self::resolve_gendered_value(&value.short, requested_gender.clone())
                        .filter(|value| !value.is_empty())
                        .or_else(|| Self::resolve_gendered_value(&value.long, requested_gender))
                }
                _ => Self::resolve_gendered_value(&value.long, requested_gender),
            },
        }
    }

    /// Get a contributor role term.
    pub fn role_term(
        &self,
        role: &ContributorRole,
        plural: bool,
        form: &TermForm,
        requested_gender: Option<GrammaticalGender>,
    ) -> Option<&str> {
        let term = self.roles.get(role)?;
        let simple = if plural { &term.plural } else { &term.singular };
        let term_text = match *form {
            TermForm::Long => Self::resolve_gendered_value(&simple.long, requested_gender),
            TermForm::Short => {
                Self::resolve_gendered_value(&simple.short, requested_gender.clone())
                    .filter(|value| !value.is_empty())
                    .or_else(|| Self::resolve_gendered_value(&simple.long, requested_gender))
            }
            TermForm::Verb => Self::resolve_gendered_value(&term.verb.long, None),
            TermForm::VerbShort => Self::resolve_gendered_value(&term.verb.short, None)
                .filter(|value| !value.is_empty())
                .or_else(|| Self::resolve_gendered_value(&term.verb.long, None)),
            _ => Self::resolve_gendered_value(&simple.long, requested_gender),
        };

        match term_text {
            Some(value) if !value.is_empty() => Some(value),
            _ => None,
        }
    }

    /// Resolve a contributor role term using only neutral/common values.
    pub fn role_term_neutral(
        &self,
        role: &ContributorRole,
        plural: bool,
        form: &TermForm,
    ) -> Option<&str> {
        let term = self.roles.get(role)?;
        let simple = if plural { &term.plural } else { &term.singular };
        let term_text = match *form {
            TermForm::Long => Self::resolve_gendered_value_neutral(&simple.long),
            TermForm::Short => Self::resolve_gendered_value_neutral(&simple.short)
                .filter(|value| !value.is_empty())
                .or_else(|| Self::resolve_gendered_value_neutral(&simple.long)),
            TermForm::Verb => Self::resolve_gendered_value(&term.verb.long, None),
            TermForm::VerbShort => Self::resolve_gendered_value(&term.verb.short, None)
                .filter(|value| !value.is_empty())
                .or_else(|| Self::resolve_gendered_value(&term.verb.long, None)),
            _ => Self::resolve_gendered_value_neutral(&simple.long),
        };

        match term_text {
            Some(value) if !value.is_empty() => Some(value),
            _ => None,
        }
    }

    /// Resolve a contributor role term, evaluating MF2 messages when configured.
    pub fn resolved_role_term(
        &self,
        role: &ContributorRole,
        plural: bool,
        form: &TermForm,
        requested_gender: Option<GrammaticalGender>,
    ) -> Option<String> {
        if let Some(message_id) = Self::role_message_id(role, form)
            && let Some(resolved) = self.resolve_message_text(
                message_id,
                Some(u64::from(plural) + 1),
                requested_gender.clone(),
            )
        {
            return Some(resolved);
        }

        self.role_term(role, plural, form, requested_gender)
            .map(ToOwned::to_owned)
    }

    /// Resolve a contributor role term using only neutral/common values.
    pub fn resolved_role_term_neutral(
        &self,
        role: &ContributorRole,
        plural: bool,
        form: &TermForm,
    ) -> Option<String> {
        if let Some(message_id) = Self::role_message_id(role, form)
            && let Some(resolved) = self.resolve_message_text(
                message_id,
                Some(u64::from(plural) + 1),
                Some(GrammaticalGender::Common),
            )
        {
            return Some(resolved);
        }

        self.role_term_neutral(role, plural, form)
            .map(ToOwned::to_owned)
    }

    /// Get a locator term.
    pub fn locator_term(
        &self,
        locator: &LocatorType,
        plural: bool,
        form: &TermForm,
        requested_gender: Option<GrammaticalGender>,
    ) -> Option<&str> {
        let term = self.locators.get(locator)?;
        let form_term = match *form {
            TermForm::Long => &term.long,
            TermForm::Short => &term.short,
            TermForm::Symbol => &term.symbol,
            _ => &term.short, // Fallback
        };

        if let Some(ft) = form_term {
            let value = if plural { &ft.plural } else { &ft.singular };
            Self::resolve_gendered_value(value, requested_gender)
        } else {
            None
        }
    }

    /// Resolve a locator term, evaluating MF2 messages when configured.
    pub fn resolved_locator_term(
        &self,
        locator: &LocatorType,
        plural: bool,
        form: &TermForm,
        requested_gender: Option<GrammaticalGender>,
    ) -> Option<String> {
        if let Some(message_id) = Self::locator_message_id(locator, form)
            && let Some(resolved) = self.resolve_message_text(
                message_id,
                Some(u64::from(plural) + 1),
                requested_gender.clone(),
            )
        {
            return Some(resolved);
        }

        self.locator_term(locator, plural, form, requested_gender.clone())
            .map(ToOwned::to_owned)
            .or_else(|| {
                if let LocatorType::Custom(key) = locator {
                    self.locator_term_any_form(locator, plural, requested_gender)
                        .map(ToOwned::to_owned)
                        .or_else(|| Some(key.clone()))
                } else {
                    None
                }
            })
    }

    fn locator_term_any_form(
        &self,
        locator: &LocatorType,
        plural: bool,
        requested_gender: Option<GrammaticalGender>,
    ) -> Option<&str> {
        let term = self.locators.get(locator)?;
        [&term.long, &term.short, &term.symbol]
            .into_iter()
            .flatten()
            .next()
            .map(|forms| {
                if plural {
                    Self::resolve_gendered_value(&forms.plural, requested_gender).unwrap_or("")
                } else {
                    Self::resolve_gendered_value(&forms.singular, requested_gender).unwrap_or("")
                }
            })
            .filter(|value| !value.is_empty())
    }

    /// Resolve a general term to a borrowed string.
    pub fn general_term(
        &self,
        term: &GeneralTerm,
        form: &TermForm,
        requested_gender: Option<GrammaticalGender>,
    ) -> Option<&str> {
        // Legacy borrowed lookup path: prefer plain v2 messages first, then
        // alias-backed messages, and finally the v1 term tables.
        let candidate_id = format!("term.{}", Self::general_term_to_message_id(term));
        if let Some(msg) = self.messages.get(&candidate_id) {
            // Only use plain messages here (no ICU variable syntax)
            if !msg.contains('{') {
                return Some(msg.as_str());
            }
        }
        // Check legacy_term_aliases
        let legacy_key = Self::general_term_to_legacy_key(term);
        if let Some(msg_id) = self.legacy_term_aliases.get(legacy_key)
            && let Some(msg) = self.messages.get(msg_id)
            && !msg.contains('{')
        {
            return Some(msg.as_str());
        }

        // First try the flattened map
        if *term != GeneralTerm::NoDate
            && let Some(simple) = self.terms.general.get(term)
        {
            return match *form {
                TermForm::Long => Self::resolve_gendered_value(&simple.long, requested_gender),
                TermForm::Short => {
                    Self::resolve_gendered_value(&simple.short, requested_gender.clone())
                        .filter(|value| !value.is_empty())
                        .or_else(|| Self::resolve_gendered_value(&simple.long, requested_gender))
                }

                _ => Self::resolve_gendered_value(&simple.long, requested_gender),
            };
        }

        // Fallback to specific fields for common terms
        match term {
            GeneralTerm::And => self.terms.and.as_deref(),
            GeneralTerm::EtAl => self.terms.et_al.as_deref(),
            GeneralTerm::AndOthers => self.terms.and_others.as_deref(),
            GeneralTerm::Accessed => self.terms.accessed.as_deref(),
            GeneralTerm::Ibid => self.terms.ibid.as_deref(),
            GeneralTerm::In => self.terms.in_.as_deref(),
            GeneralTerm::NoDate => self
                .terms
                .general
                .get(term)
                .and_then(|value| Self::resolve_no_date_value(value, form, requested_gender))
                .or(self.terms.no_date.as_deref()),
            GeneralTerm::Retrieved => self.terms.retrieved.as_deref(),
            GeneralTerm::At => self.terms.at.as_deref(),
            GeneralTerm::By => self.terms.by.as_deref(),
            GeneralTerm::From => self.terms.from.as_deref(),
            GeneralTerm::Of => self
                .terms
                .general
                .get(term)
                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
            GeneralTerm::To => self
                .terms
                .general
                .get(term)
                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
            GeneralTerm::Anonymous => {
                Self::resolve_gendered_value(&self.terms.anonymous.long, requested_gender)
            }
            GeneralTerm::Circa => {
                Self::resolve_gendered_value(&self.terms.circa.long, requested_gender)
            }
            // Fallback to locators for shared terms
            GeneralTerm::Volume => {
                self.locator_term(&LocatorType::Volume, false, form, requested_gender)
            }
            GeneralTerm::Issue => {
                self.locator_term(&LocatorType::Issue, false, form, requested_gender)
            }
            GeneralTerm::Page => {
                self.locator_term(&LocatorType::Page, false, form, requested_gender)
            }
            GeneralTerm::Chapter => {
                self.locator_term(&LocatorType::Chapter, false, form, requested_gender)
            }
            GeneralTerm::Section => {
                self.locator_term(&LocatorType::Section, false, form, requested_gender)
            }
            GeneralTerm::Here => self
                .terms
                .general
                .get(term)
                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
            GeneralTerm::Deposited => self
                .terms
                .general
                .get(term)
                .and_then(|value| Self::resolve_gendered_value(&value.long, requested_gender)),
            _ => None,
        }
    }

    /// Resolve a general term, evaluating MF2 messages when configured.
    pub fn resolved_general_term(
        &self,
        term: &GeneralTerm,
        form: &TermForm,
        requested_gender: Option<GrammaticalGender>,
    ) -> Option<String> {
        if let Some(message_id) = Self::general_message_id(term, form)
            && let Some(resolved) =
                self.resolve_message_text(message_id, None, requested_gender.clone())
        {
            return Some(resolved);
        }

        self.general_term(term, form, requested_gender)
            .map(ToOwned::to_owned)
    }

    /// Resolve an archive hierarchy label, using MF2 messages.
    /// Returns singular form (count=1) by default.
    pub fn resolved_archive_term(&self, field: ArchiveHierarchyField) -> Option<String> {
        self.resolve_message_text(field.message_id(), Some(1), None)
    }

    /// Get the "and" term based on style preference.
    pub fn and_term(&self, use_symbol: bool) -> &str {
        if use_symbol {
            self.terms.and_symbol.as_deref().unwrap_or("&")
        } else {
            self.terms.and.as_deref().unwrap_or("and")
        }
    }

    /// Get the "et al." term.
    pub fn et_al(&self) -> &str {
        self.terms.et_al.as_deref().unwrap_or("et al.")
    }

    /// Get a month name.
    pub fn month_name(&self, month: u8, short: bool) -> &str {
        let idx = (month.saturating_sub(1)) as usize;
        if short {
            self.dates
                .months
                .short
                .get(idx)
                .map(|s| s.as_str())
                .unwrap_or("")
        } else {
            self.dates
                .months
                .long
                .get(idx)
                .map(|s| s.as_str())
                .unwrap_or("")
        }
    }

    /// Resolve a `pattern.date-*` message with locale-specific year/month/day
    /// components.
    ///
    /// Returns `Some(rendered)` only when the locale carries an MF2 message
    /// at `message_id` and the evaluator produces output. Callers fall back
    /// to the engine's hardcoded English assembly on `None`.
    ///
    /// A component is forwarded to the evaluator only when non-empty; an
    /// authored pattern that references `{$day}` therefore yields `None` if
    /// the input date carries no day, letting the caller pick a shorter form.
    ///
    /// The day argument is taken as `Option<u32>` rather than a pre-formatted
    /// string so the digit-to-string allocation is deferred until after the
    /// message lookup succeeds — the common case for legacy locales (`en-US`,
    /// every v1 file) is the lookup miss, which now incurs zero allocation.
    pub fn resolve_date_pattern(
        &self,
        message_id: &str,
        year: Option<&str>,
        month: Option<&str>,
        day: Option<u32>,
    ) -> Option<String> {
        let message = self.messages.get(message_id)?;
        if self.evaluation.message_syntax == MessageSyntax::Static {
            return None;
        }

        let day_str = day.map(|d| d.to_string());
        let args = MessageArgs {
            year: year.filter(|s| !s.is_empty()),
            month: month.filter(|s| !s.is_empty()),
            day: day_str.as_deref(),
            ..MessageArgs::default()
        };
        self.evaluator.evaluate(message, &args)
    }
}

#[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_en_us_locale() {
        let locale = Locale::en_us();
        assert_eq!(locale.locale, "en-US");
        assert_eq!(locale.and_term(false), "and");
        assert_eq!(locale.and_term(true), "&");
        assert_eq!(locale.et_al(), "et al.");
    }

    #[test]
    fn test_month_names() {
        let locale = Locale::en_us();
        assert_eq!(locale.month_name(1, false), "January");
        assert_eq!(locale.month_name(1, true), "Jan.");
        assert_eq!(locale.month_name(12, false), "December");
    }

    #[test]
    fn test_role_terms() {
        let locale = Locale::en_us();

        assert_eq!(
            locale.role_term(&ContributorRole::Editor, false, &TermForm::Short, None),
            Some("ed.")
        );
        assert_eq!(
            locale.role_term(&ContributorRole::Editor, true, &TermForm::Short, None),
            Some("eds.")
        );
        assert_eq!(
            locale.role_term(&ContributorRole::Translator, false, &TermForm::Verb, None),
            Some("translated by")
        );
    }

    #[test]
    fn test_no_date_term_resolves_long_and_short_forms() {
        let locale = Locale::en_us();

        assert_eq!(
            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
            Some("no date")
        );
        assert_eq!(
            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
            Some("n.d.")
        );
    }

    #[test]
    fn test_no_date_term_falls_back_to_legacy_short_form() {
        let mut locale = Locale::default();
        locale.terms.no_date = Some("n.d.".to_string());

        assert_eq!(
            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
            Some("n.d.")
        );
        assert_eq!(
            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
            Some("n.d.")
        );
    }

    #[test]
    fn test_locale_deserialization() {
        let json = r#"{
            "locale": "en-US",
            "dates": {
                "months": {
                    "long": ["January", "February", "March", "April", "May", "June",
                             "July", "August", "September", "October", "November", "December"],
                    "short": ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
                              "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
                },
                "seasons": ["Spring", "Summer", "Autumn", "Winter"]
            },
            "roles": {},
            "terms": {
                "and": "and",
                "et-al": "et al."
            }
        }"#;

        let locale: Locale = serde_json::from_str(json).unwrap();
        assert_eq!(locale.locale, "en-US");
        assert_eq!(locale.dates.months.long[0], "January");
        assert_eq!(locale.terms.and.as_ref().unwrap(), "and");
    }

    #[test]
    fn test_yaml_locale_loading() {
        let yaml = r#"
locale: de-DE
dates:
  months:
    long:
      - Januar
      - Februar
      - März
      - April
      - Mai
      - Juni
      - Juli
      - August
      - September
      - Oktober
      - November
      - Dezember
    short:
      - Jan.
      - Feb.
      - März
      - Apr.
      - Mai
      - Juni
      - Juli
      - Aug.
      - Sep.
      - Okt.
      - Nov.
      - Dez.
  seasons:
    - Frühling
    - Sommer
    - Herbst
    - Winter
terms:
  and:
    long: und
    symbol: "&"
  et_al:
    long: "u. a."
"#;

        let locale = Locale::from_yaml_str(yaml).unwrap();
        assert_eq!(locale.locale, "de-DE");
        assert_eq!(locale.and_term(false), "und");
        assert_eq!(locale.et_al(), "u. a.");
        assert_eq!(locale.month_name(1, false), "Januar");
        assert_eq!(locale.month_name(3, false), "März");
    }

    #[test]
    fn test_yaml_no_date_term_preserves_long_and_short_forms() {
        let yaml = r#"
locale: en-US
dates:
  months:
    long: [January, February, March, April, May, June, July, August, September, October, November, December]
    short: [Jan., Feb., Mar., Apr., May, June, July, Aug., Sept., Oct., Nov., Dec.]
  seasons: [Spring, Summer, Autumn, Winter]
roles: {}
terms:
  no date:
    long: no date
    short: n.d.
"#;

        let locale = Locale::from_yaml_str(yaml).unwrap();
        assert_eq!(
            locale.general_term(&GeneralTerm::NoDate, &TermForm::Long, None),
            Some("no date")
        );
        assert_eq!(
            locale.general_term(&GeneralTerm::NoDate, &TermForm::Short, None),
            Some("n.d.")
        );
        assert_eq!(locale.terms.no_date.as_deref(), Some("n.d."));
    }

    /// v2 locale with grammar-options overrides punctuation_in_quote correctly.
    #[test]
    fn test_v2_grammar_options_sync_punctuation_in_quote() {
        let yaml = r#"
locale-schema-version: "2"
locale: en-GB
grammar-options:
  punctuation-in-quote: false
"#;
        let locale = Locale::from_yaml_str(yaml).unwrap();
        // grammar_options is the authoritative source for v2 locales
        assert!(!locale.grammar_options.punctuation_in_quote);
        // legacy field is synced from grammar_options
        assert!(!locale.punctuation_in_quote);
    }

    /// v1 locale (no grammar-options) derives punctuation_in_quote from locale ID.
    #[test]
    fn test_v1_locale_derives_punctuation_from_locale_id() {
        let yaml = r#"
locale: en-US
"#;
        let locale = Locale::from_yaml_str(yaml).unwrap();
        // en-US uses American style (inside)
        assert!(locale.punctuation_in_quote);
        assert!(locale.grammar_options.punctuation_in_quote);
    }

    /// apply_override merges messages key-by-key into the base locale.
    #[test]
    fn test_apply_override_merges_messages() {
        let mut locale = Locale::en_us();
        locale
            .messages
            .insert("term.page-label".into(), "p.".into());
        let ov = LocaleOverride {
            messages: [("term.page-label".into(), "pg.".into())].into(),
            ..Default::default()
        };
        locale.apply_override(&ov);
        assert_eq!(
            locale.messages.get("term.page-label").map(|s| s.as_str()),
            Some("pg.")
        );
    }

    /// apply_override with grammar_options replaces block and syncs punctuation_in_quote.
    #[test]
    fn test_apply_override_grammar_options_syncs_punctuation() {
        let mut locale = Locale::en_us();
        locale.punctuation_in_quote = false;
        let ov = LocaleOverride {
            grammar_options: Some(GrammarOptions {
                punctuation_in_quote: true,
                ..Default::default()
            }),
            ..Default::default()
        };
        locale.apply_override(&ov);
        assert!(locale.punctuation_in_quote);
        assert!(locale.grammar_options.punctuation_in_quote);
    }

    #[test]
    fn test_resolved_locator_term_evaluates_plural_message() {
        let locale = Locale::en_us();

        assert_eq!(
            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
            Some("p.".to_string())
        );
        assert_eq!(
            locale.resolved_locator_term(&LocatorType::Page, true, &TermForm::Short, None),
            Some("pp.".to_string())
        );
    }

    #[test]
    fn test_resolved_locator_term_falls_back_to_custom_locale_form_then_raw_key() {
        let locale = Locale::from_yaml_str(
            r#"
locale: en-US
locators:
  reel:
    long:
      singular: "reel"
      plural: "reels"
"#,
        )
        .expect("custom locale should parse");

        assert_eq!(
            locale.resolved_locator_term(
                &LocatorType::Custom("reel".to_string()),
                false,
                &TermForm::Short,
                None,
            ),
            Some("reel".to_string())
        );
        assert_eq!(
            locale.resolved_locator_term(
                &LocatorType::Custom("movement".to_string()),
                false,
                &TermForm::Short,
                None,
            ),
            Some("movement".to_string())
        );
    }

    #[test]
    fn test_legacy_locator_terms_under_terms_still_populate_locators() {
        let locale = Locale::from_yaml_str(
            r#"
locale: en-US
terms:
  page:
    short:
      singular: "pg."
      plural: "pgs."
"#,
        )
        .expect("legacy locator terms should parse");

        assert_eq!(
            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
            Some("pg.".to_string())
        );
    }

    #[test]
    fn test_explicit_locators_override_legacy_terms_for_builtin_keys() {
        let locale = Locale::from_yaml_str(
            r#"
locale: en-US
terms:
  page:
    short:
      singular: "pg."
      plural: "pgs."
locators:
  page:
    short:
      singular: "p."
      plural: "pp."
"#,
        )
        .expect("mixed locator forms should parse");

        assert_eq!(
            locale.resolved_locator_term(&LocatorType::Page, false, &TermForm::Short, None),
            Some("p.".to_string())
        );
    }

    #[test]
    fn test_non_locator_terms_are_not_reclassified_as_custom_locators() {
        let locale = Locale::from_yaml_str(
            r#"
locale: en-US
terms:
  and:
    long: "und"
"#,
        )
        .expect("general terms should parse");

        assert_eq!(locale.terms.and.as_deref(), Some("und"));
        assert!(
            !locale
                .locators
                .contains_key(&LocatorType::Custom("and".to_string()))
        );
    }

    #[test]
    fn test_resolved_role_term_evaluates_plural_message() {
        let locale = Locale::en_us();

        assert_eq!(
            locale.resolved_role_term(&ContributorRole::Editor, false, &TermForm::Long, None),
            Some("editor".to_string())
        );
        assert_eq!(
            locale.resolved_role_term(&ContributorRole::Editor, true, &TermForm::Long, None),
            Some("editors".to_string())
        );
    }

    #[test]
    fn test_role_term_prefers_common_form_for_mixed_gender_requests() {
        let locale = Locale::from_yaml_str(
            r#"
locale: es-ES
roles:
  editor:
    long:
      singular:
        masculine: editor
        feminine: editora
        common: persona editora
      plural:
        masculine: editores
        feminine: editoras
        common: equipo editorial
    short:
      singular: ed.
      plural: eds.
    verb: editado por
"#,
        )
        .expect("gendered locale should parse");

        assert_eq!(
            locale.role_term(
                &ContributorRole::Editor,
                false,
                &TermForm::Long,
                Some(GrammaticalGender::Feminine),
            ),
            Some("editora")
        );
        assert_eq!(
            locale.role_term(
                &ContributorRole::Editor,
                true,
                &TermForm::Long,
                Some(GrammaticalGender::Common),
            ),
            Some("equipo editorial")
        );
    }

    #[test]
    fn test_no_date_term_falls_back_when_requested_gender_has_no_matching_slot() {
        let locale = Locale::from_yaml_str(
            r#"
locale: es-ES
terms:
  no date:
    long:
      masculine: sin fecha
  no_date: s. f.
"#,
        )
        .expect("locale should parse");

        assert_eq!(
            locale.general_term(
                &GeneralTerm::NoDate,
                &TermForm::Long,
                Some(GrammaticalGender::Common),
            ),
            Some("s. f.")
        );
    }

    #[test]
    fn test_es_es_locale_is_embedded() {
        let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
        let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
        let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");

        assert_eq!(locale.locale, "es-ES");
        assert_eq!(
            locale.resolved_role_term(
                &ContributorRole::Editor,
                false,
                &TermForm::Long,
                Some(GrammaticalGender::Feminine),
            ),
            Some("editora".to_string())
        );
    }

    #[test]
    fn embedded_locale_ids_include_all_bundled_locale_files() {
        for id in [
            "en-US", "ar-AR", "de-DE", "es-ES", "eu-ES", "fr-FR", "tr-TR",
        ] {
            assert!(
                crate::embedded::EMBEDDED_LOCALE_IDS.contains(&id),
                "{id} should be listed as an embedded locale"
            );
        }
    }

    #[test]
    fn bundled_ar_ar_and_eu_es_locales_are_embedded_and_parseable() {
        for id in ["ar-AR", "eu-ES"] {
            let bytes = crate::embedded::get_locale_bytes(id).expect("locale should be embedded");
            let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
            let locale = Locale::from_yaml_str(yaml).expect("embedded locale should parse");

            assert_eq!(locale.locale, id);
        }
    }

    #[test]
    fn test_es_es_role_term_resolves_gendered_mf2_message() {
        let bytes = crate::embedded::get_locale_bytes("es-ES").expect("es-ES should be embedded");
        let yaml = std::str::from_utf8(bytes).expect("embedded locale should be utf-8");
        let locale = Locale::from_yaml_str(yaml).expect("embedded es-ES should parse");

        assert_eq!(
            locale.resolved_role_term(
                &ContributorRole::Editor,
                true,
                &TermForm::Long,
                Some(GrammaticalGender::Masculine),
            ),
            Some("editores".to_string())
        );
        assert_eq!(
            locale.resolved_role_term(
                &ContributorRole::Translator,
                true,
                &TermForm::Long,
                Some(GrammaticalGender::Feminine),
            ),
            Some("traductoras".to_string())
        );
        assert_eq!(
            locale.resolved_role_term_neutral(&ContributorRole::Editor, true, &TermForm::Long),
            Some("equipo editorial".to_string())
        );
    }

    #[test]
    fn test_role_term_falls_back_when_mf2_message_cannot_evaluate() {
        let locale = Locale::from_yaml_str(
            r#"
locale: es-ES
evaluation:
  message-syntax: mf2
messages:
  role.editor.label-long: |
    .match {$gender :unknown} {$count :plural}
    when feminine one {editora}
roles:
  editor:
    long:
      singular:
        feminine: editora heredada
      plural:
        feminine: editoras heredadas
"#,
        )
        .expect("locale should parse");

        assert_eq!(
            locale.resolved_role_term(
                &ContributorRole::Editor,
                false,
                &TermForm::Long,
                Some(GrammaticalGender::Feminine),
            ),
            Some("editora heredada".to_string())
        );
    }

    #[test]
    fn test_lookup_genre_known_key() {
        let locale = Locale::from_yaml_str(
            r#"
locale: en-US
vocab:
  genre:
    phd-thesis: "PhD thesis"
"#,
        )
        .unwrap();
        assert_eq!(locale.lookup_genre("phd-thesis"), "PhD thesis");
    }

    #[test]
    fn test_lookup_medium_known_key() {
        let locale = Locale::from_yaml_str(
            r#"
locale: en-US
vocab:
  medium:
    television: "Television"
"#,
        )
        .unwrap();
        assert_eq!(locale.lookup_medium("television"), "Television");
    }

    #[test]
    fn test_lookup_genre_fallback() {
        let locale = Locale::en_us();
        // Unknown key → title-case first word + spaces
        assert_eq!(locale.lookup_genre("unknown-key"), "Unknown key");
    }

    #[test]
    fn test_en_us_locale_uses_embedded_vocab() {
        let locale = Locale::en_us();

        assert_eq!(locale.lookup_genre("phd-thesis"), "PhD thesis");
        assert_eq!(locale.lookup_medium("audio-cd"), "Audio CD");
    }

    #[test]
    fn test_from_yaml_str_inherits_embedded_vocab_defaults() {
        let locale = Locale::from_yaml_str("locale: en-US\n").unwrap();

        assert_eq!(locale.lookup_genre("phd-thesis"), "PhD thesis");
    }

    #[test]
    fn test_partial_genre_vocab_override_preserves_medium_defaults() {
        let locale = Locale::from_yaml_str(
            r#"
locale: en-US
vocab:
  genre:
    phd-thesis: "Doctoral dissertation"
"#,
        )
        .unwrap();

        assert_eq!(locale.lookup_genre("phd-thesis"), "Doctoral dissertation");
        assert_eq!(locale.lookup_medium("audio-cd"), "Audio CD");
    }

    #[test]
    fn test_partial_medium_vocab_override_preserves_genre_defaults() {
        let locale = Locale::from_yaml_str(
            r#"
locale: en-US
vocab:
  medium:
    television: "Broadcast television"
"#,
        )
        .unwrap();

        assert_eq!(locale.lookup_medium("television"), "Broadcast television");
        assert_eq!(locale.lookup_genre("phd-thesis"), "PhD thesis");
    }

    #[test]
    fn test_kebab_to_display_single_word() {
        assert_eq!(kebab_to_display("video"), "Video");
    }

    #[test]
    fn test_kebab_to_display_multiple_words() {
        assert_eq!(kebab_to_display("phd-thesis"), "Phd thesis");
        assert_eq!(kebab_to_display("audio-cd"), "Audio cd");
    }
}
