//! Static catalog loader. The TOML is embedded via `include_str!`
//! at compile time; parsed lazily once on first access and cached
//! for the rest of the process lifetime.

use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};

/// Embedded catalog TOML (compile-time). Read by callers wanting
/// the raw source — the parsed form lives in [`catalog`].
pub const CATALOG_TOML: &str = include_str!("../../voices.toml");

/// One entry per `[[voice]]` block in `voices.toml`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VoiceCatalogEntry {
    /// Canonical voice id used by every consumer (plugin code, UI
    /// dropdown, persisted store rows). Stable across catalog
    /// refreshes — if a provider deprecates a voice we keep the id
    /// and flip a `deprecated = true` field (future).
    pub id: String,

    /// Provider id (`"edge"`, `"elevenlabs"`, `"openai_tts"`, …).
    /// Must match a [`crate::providers::TtsProvider::id`].
    pub provider: String,

    /// Human-readable label rendered in the UI dropdown.
    pub label: String,

    /// BCP-47 locale (`"es-CO"`, `"en-US"`, `"pt-BR"`, …). The
    /// wizard groups voices by `locale.split('-').next()`
    /// (language) and offers regional sub-groups.
    pub locale: String,

    /// `"female"`, `"male"`, or `"neutral"`. Optional in the TOML
    /// (defaults to `"neutral"`).
    #[serde(default = "default_gender")]
    pub gender: String,

    /// `"free"`, `"cheap"`, or `"premium"`. Drives the operator's
    /// `cost_class` filter on `voice_list_voices`.
    pub cost_class: String,

    /// `true` when the provider charges per-character (ElevenLabs
    /// premium voices, future custom-trained voices). UI shows a
    /// padlock badge for these.
    #[serde(default)]
    pub premium: bool,

    /// Provider-native id when it differs from [`id`]. ElevenLabs
    /// uses opaque UUIDs internally; the canonical id stays stable
    /// for consumers while `native_id` carries the real string we
    /// hand to the API.
    #[serde(default)]
    pub native_id: Option<String>,

    /// Optional sample audio URL for UI preview. Plugin doesn't
    /// fetch this — UI does.
    #[serde(default)]
    pub sample_url: Option<String>,
}

fn default_gender() -> String {
    "neutral".to_string()
}

/// File-level TOML shape; `[[voice]]` items deserialise into the
/// `voices` vec.
#[derive(Debug, Deserialize)]
struct CatalogFile {
    #[serde(rename = "voice")]
    voices: Vec<VoiceCatalogEntry>,
}

/// Materialised catalog. Indexable, cloneable. Most callers want
/// [`catalog()`] which hands back a static reference instead.
#[derive(Debug, Clone)]
pub struct VoiceCatalog {
    voices: Vec<VoiceCatalogEntry>,
}

impl VoiceCatalog {
    /// All voices, in TOML order. UI may re-sort.
    pub fn voices(&self) -> &[VoiceCatalogEntry] {
        &self.voices
    }

    /// Total count — sanity check for boot-time logging.
    pub fn len(&self) -> usize {
        self.voices.len()
    }

    /// `true` when the catalog has no entries. Should never happen
    /// in production — every release ships ≥ 1 Edge voice.
    pub fn is_empty(&self) -> bool {
        self.voices.is_empty()
    }
}

static CATALOG: Lazy<VoiceCatalog> = Lazy::new(|| {
    let file: CatalogFile =
        toml::from_str(CATALOG_TOML).expect("voices.toml must parse — broken release");
    VoiceCatalog {
        voices: file.voices,
    }
});

/// Shared catalog. First call parses; every subsequent call is a
/// no-cost dereference.
pub fn catalog() -> &'static VoiceCatalog {
    &CATALOG
}

/// Look up an entry by canonical id.
pub fn find_by_id(id: &str) -> Option<&'static VoiceCatalogEntry> {
    catalog().voices.iter().find(|v| v.id == id)
}

/// Convenience for providers: resolve the native id (or fall back
/// to the canonical id when none is declared).
pub fn find_native_id(id: &str) -> Option<&'static str> {
    find_by_id(id).map(|e| e.native_id.as_deref().unwrap_or(&e.id))
}

/// Filter voices by optional criteria. `None` → no filter on that
/// dimension. The `locale_prefix` filter matches any voice whose
/// `locale` STARTS WITH the prefix, so `"es"` returns every
/// Spanish family voice.
pub fn filter(
    locale_prefix: Option<&str>,
    provider: Option<&str>,
    cost_class: Option<&str>,
) -> Vec<&'static VoiceCatalogEntry> {
    catalog()
        .voices
        .iter()
        .filter(|v| {
            locale_prefix
                .map(|p| v.locale.starts_with(p))
                .unwrap_or(true)
                && provider.map(|p| v.provider == p).unwrap_or(true)
                && cost_class.map(|c| v.cost_class == c).unwrap_or(true)
        })
        .collect()
}

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

    #[test]
    fn catalog_parses_without_panic_at_boot() {
        let cat = catalog();
        assert!(!cat.is_empty(), "catalog must have at least one voice");
        assert!(
            cat.len() >= 30,
            "expected ≥ 30 curated voices, got {}",
            cat.len()
        );
    }

    #[test]
    fn every_entry_has_canonical_id() {
        for v in catalog().voices() {
            assert!(!v.id.is_empty(), "empty id in catalog");
            assert!(!v.provider.is_empty(), "empty provider for `{}`", v.id);
            assert!(!v.locale.is_empty(), "empty locale for `{}`", v.id);
            assert!(
                ["free", "cheap", "premium"].contains(&v.cost_class.as_str()),
                "bad cost_class `{}` for `{}`",
                v.cost_class,
                v.id
            );
        }
    }

    #[test]
    fn canonical_ids_unique() {
        let mut seen = std::collections::HashSet::new();
        for v in catalog().voices() {
            assert!(
                seen.insert(v.id.clone()),
                "duplicate canonical id `{}` in catalog",
                v.id
            );
        }
    }

    #[test]
    fn find_by_id_returns_known_voice() {
        let v = find_by_id("es-CO-SalomeNeural").expect("Salomé must be present");
        assert_eq!(v.provider, "edge");
        assert_eq!(v.locale, "es-CO");
    }

    #[test]
    fn find_by_id_returns_none_for_unknown() {
        assert!(find_by_id("xx-YY-Bogus").is_none());
    }

    #[test]
    fn find_native_id_falls_back_to_canonical() {
        // Edge voices: native_id absent → canonical id returned.
        assert_eq!(
            find_native_id("es-CO-SalomeNeural"),
            Some("es-CO-SalomeNeural")
        );
        // ElevenLabs: distinct native_id.
        let nat = find_native_id("el-Rachel-en-US").unwrap();
        assert_ne!(nat, "el-Rachel-en-US");
        assert!(!nat.is_empty());
    }

    #[test]
    fn filter_by_locale_prefix_matches_family() {
        let es = filter(Some("es"), None, None);
        assert!(es.iter().all(|v| v.locale.starts_with("es")));
        assert!(es.len() >= 10);
    }

    #[test]
    fn filter_by_provider_isolates() {
        let elevenlabs = filter(None, Some("elevenlabs"), None);
        assert!(elevenlabs.iter().all(|v| v.provider == "elevenlabs"));
        assert!(!elevenlabs.is_empty());
    }

    #[test]
    fn filter_by_cost_class_keeps_consistent_class() {
        let free = filter(None, None, Some("free"));
        assert!(free.iter().all(|v| v.cost_class == "free"));
        assert!(!free.is_empty());
    }

    #[test]
    fn filter_combinations_intersect() {
        // Spanish + free + edge → multiple matches.
        let combo = filter(Some("es"), Some("edge"), Some("free"));
        assert!(!combo.is_empty());
        for v in combo {
            assert!(v.locale.starts_with("es"));
            assert_eq!(v.provider, "edge");
            assert_eq!(v.cost_class, "free");
        }
    }
}
