//! Catalog loader and `{name}` substitution (ADR-0019 §8.4).
//!
//! The catalog is an `include_str!`-embedded YAML file parsed
//! once on first access. Hierarchical keys flatten to
//! dot-separated paths so lookups are a single `HashMap` hit.
//!
//! Substitution rejects format specifiers (`{name:…}`),
//! unknown / missing names, unterminated `{`, and stray `}`.
//! `{{` and `}}` escape to literal `{` / `}`. Everything that
//! ends in a `panic!` should have been caught by the validator
//! unit test (Step 7) at build time.

use std::collections::HashMap;
use std::fmt::{Display, Write};
use std::sync::OnceLock;

const EN_US: &str = include_str!("strings/en-US.yaml");

/// Loaded catalog: a flat map from dot-separated key to template.
pub struct Catalog {
    entries: HashMap<String, String>,
}

impl Catalog {
    fn load() -> Self {
        let value: serde_norway::Value = serde_norway::from_str(EN_US)
            .expect("embedded en-US.yaml must parse (ADR-0019 §8.6 startup check)");
        let mut entries = HashMap::new();
        flatten(&value, String::new(), &mut entries);
        Self { entries }
    }

    /// Look up a flat dot-separated key. Returns the template
    /// string with placeholders un-substituted.
    #[must_use]
    pub fn get(&self, key: &str) -> Option<&str> {
        self.entries.get(key).map(String::as_str)
    }

    /// Iterate every catalog key; used by the validator test.
    pub fn keys(&self) -> impl Iterator<Item = &str> {
        self.entries.keys().map(String::as_str)
    }
}

fn flatten(value: &serde_norway::Value, prefix: String, out: &mut HashMap<String, String>) {
    match value {
        serde_norway::Value::Mapping(map) => {
            for (k, v) in map {
                let k_str = k.as_str().expect("catalog keys must be strings");
                let next = if prefix.is_empty() {
                    k_str.to_string()
                } else {
                    format!("{prefix}.{k_str}")
                };
                flatten(v, next, out);
            }
        }
        serde_norway::Value::String(s) => {
            out.insert(prefix, s.clone());
        }
        // Empty top-level (Null) is fine — an empty catalog
        // loads as no entries. Anything else is a structure
        // error worth panicking over since the catalog is
        // shipped with the binary.
        serde_norway::Value::Null if prefix.is_empty() => {}
        other => panic!("catalog value at `{prefix}` is not a string: {other:?}"),
    }
}

/// Singleton catalog access. Loads on first call; subsequent
/// calls are a `HashMap` lookup away.
pub fn catalog() -> &'static Catalog {
    static C: OnceLock<Catalog> = OnceLock::new();
    C.get_or_init(Catalog::load)
}

/// Look up `key` and substitute the supplied named arguments.
/// See module docs for failure modes.
pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String {
    let template = catalog().get(key).unwrap_or_else(|| {
        panic!("missing catalog key: `{key}` (the validator should have caught this)");
    });
    substitute(template, args, key)
}

fn substitute(template: &str, args: &[(&str, &dyn Display)], key: &str) -> String {
    let mut out = String::with_capacity(template.len());
    let mut chars = template.chars().peekable();
    while let Some(c) = chars.next() {
        match c {
            '{' => {
                if chars.peek() == Some(&'{') {
                    chars.next();
                    out.push('{');
                    continue;
                }
                let mut name = String::new();
                let mut closed = false;
                while let Some(&nc) = chars.peek() {
                    match nc {
                        '}' => {
                            chars.next();
                            closed = true;
                            break;
                        }
                        ':' => {
                            panic!(
                                "catalog key `{key}` uses a format specifier in \
                                 `{{{name}:...}}` — specifiers are not allowed \
                                 (ADR-0019 §8.4)"
                            );
                        }
                        _ => {
                            chars.next();
                            name.push(nc);
                        }
                    }
                }
                if !closed {
                    panic!("catalog key `{key}` has unterminated `{{`");
                }
                if name.is_empty() {
                    panic!("catalog key `{key}` has empty `{{}}` placeholder");
                }
                let value = args
                    .iter()
                    .find(|(n, _)| *n == name)
                    .map(|(_, v)| v)
                    .unwrap_or_else(|| {
                        panic!(
                            "catalog key `{key}` references `{{{name}}}` but that \
                             argument was not supplied"
                        );
                    });
                write!(out, "{value}").expect("writing to String never fails");
            }
            '}' => {
                if chars.peek() == Some(&'}') {
                    chars.next();
                    out.push('}');
                } else {
                    panic!("catalog key `{key}` has stray `}}`");
                }
            }
            _ => out.push(c),
        }
    }
    out
}

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

    #[test]
    fn catalog_loads_at_least_the_test_entry() {
        // Step 1 ships a single sanity entry under `_test.*`;
        // its presence proves the loader walks the YAML and
        // flattens hierarchical groups correctly.
        assert!(
            catalog().get("_test.hello").is_some(),
            "expected `_test.hello` in catalog; entries: {:?}",
            catalog().keys().collect::<Vec<_>>()
        );
    }

    #[test]
    fn substitute_replaces_named_placeholder() {
        let s = substitute("Hello, {name}!", &[("name", &"World")], "_test.hello");
        assert_eq!(s, "Hello, World!");
    }

    #[test]
    fn substitute_handles_multiple_placeholders() {
        let s = substitute(
            "{a} + {b} = {sum}",
            &[("a", &1), ("b", &2), ("sum", &3)],
            "_test.math",
        );
        assert_eq!(s, "1 + 2 = 3");
    }

    #[test]
    fn substitute_supports_doubled_braces_as_literals() {
        let s = substitute(
            "set with {{count}} = {count}",
            &[("count", &5)],
            "_test.literals",
        );
        assert_eq!(s, "set with {count} = 5");
    }

    #[test]
    fn substitute_passes_display_values_unchanged() {
        // Display for an i64 — no padding, no thousands separator.
        // ADR-0019 §8.7: value formats stay invariant.
        let s = substitute("n = {n}", &[("n", &1234567_i64)], "_test.display");
        assert_eq!(s, "n = 1234567");
    }

    #[test]
    #[should_panic(expected = "format specifier")]
    #[allow(clippy::literal_string_with_formatting_args)]
    fn substitute_rejects_format_specifier() {
        // The literal `{n:08}` is intentional — that's the
        // shape we want to refuse.
        let _ = substitute("padded: {n:08}", &[("n", &5)], "_test.specifier");
    }

    #[test]
    #[should_panic(expected = "not supplied")]
    fn substitute_rejects_unknown_placeholder() {
        let _ = substitute("Hello, {who}!", &[("name", &"World")], "_test.unknown");
    }

    #[test]
    #[should_panic(expected = "unterminated")]
    fn substitute_rejects_unterminated_brace() {
        let _ = substitute("oh no {", &[], "_test.unterm");
    }

    #[test]
    #[should_panic(expected = "stray")]
    fn substitute_rejects_stray_close_brace() {
        let _ = substitute("oh no }", &[], "_test.stray");
    }

    #[test]
    #[should_panic(expected = "empty")]
    fn substitute_rejects_empty_placeholder() {
        let _ = substitute("oh no {}", &[], "_test.empty");
    }

    #[test]
    fn t_macro_dispatches_to_translate() {
        // Sanity check that the macro produces the same output
        // as a direct call. Uses the `_test.hello` entry from
        // the catalog.
        let m = crate::t!("_test.hello", name = "World");
        let d = translate("_test.hello", &[("name", &"World")]);
        assert_eq!(m, d);
    }
}
