//! Gazetteer: deterministic terminology normalization filter applied
//! to every AI output (Whisper, oneliner LLM, analysis LLM) before
//! persistence.
//!
//! Invariant: every AI-produced text passes through `replace()` before
//! being written to disk or forwarded downstream. Tokens within
//! edit-distance 2 of a curated vocabulary entry are silently rewritten
//! to the canonical form — *unless* the token itself is a valid word
//! of the target language (dict-veto), in which case it is preserved.
//!
//! Rationale: LLMs have strong training priors that override contextual
//! hints — e.g. they anglicize `Amiodaron` to `Amiodarone` even when
//! the input was correct. A pre-LLM hint channel cannot fix this
//! because the drift happens in the LLM's output. Running the filter
//! post-AI catches both Whisper typos and LLM drift with a single
//! mechanism.
//!
//! Matcher: single-stage linear Damerau-Levenshtein scan over all
//! entries, filtered by distance ≤ MAX_EDIT_DISTANCE. At typical sizes
//! (~200 entries × ~50 tokens), the per-pass cost stays well under a
//! millisecond — negligible next to the AI call it follows.
//!
//! Dict-veto: if a token is within the edit-distance window of a
//! vocab entry but is itself a valid word of the language (checked
//! via a pluggable `DictChecker`, e.g. hunspell/spellbook), we keep
//! it. Prevents false positives like `Kaktus` → `Lantus` (DL=2).
//! Lookup is lazy: only called once we already have a DL-candidate,
//! so common words (99% of a transcript) never touch the dict.

use std::collections::HashSet;
use std::fmt;
use std::io;
use std::path::Path;

use strsim::damerau_levenshtein;

/// Minimum char count for *both* gazetteer entries and transcript tokens.
/// Calibrated against curated vocabulary: 5 keeps prototypical 5-char
/// brand names (Vomex, Tavor, Lasix) while still blocking 4-char
/// collisions between everyday German words and short proper nouns
/// (Bein↔Behn, nach↔Nuck).
const MIN_TOKEN_LEN: usize = 5;

/// Maximum Damerau-Levenshtein distance for a candidate to qualify as a
/// typo of a gazetteer entry. Distance 0 is handled by the `exact`
/// short-circuit; distances > 2 are rejected as too speculative.
const MAX_EDIT_DISTANCE: usize = 2;

/// Veto gate for `replace()`: if `check()` returns true for a token,
/// the token is considered a valid word of the language and is
/// preserved unchanged, even when a DL-close vocab entry exists.
/// Implementations must be `Send + Sync` so a `Gazetteer` can be
/// shared across async tasks via `Arc`.
pub trait DictChecker: Send + Sync {
    fn check(&self, word: &str) -> bool;
}

/// Domain-specific vocabulary, populated once at server start from
/// `*.txt` files under a vocab directory. Read-only thereafter; share
/// via `Arc<Gazetteer>`.
#[derive(Default)]
pub struct Gazetteer {
    /// Canonical entries in original casing — source of truth for both
    /// scanning and emission.
    entries: Vec<Box<str>>,
    /// Lowercased canonicals; short-circuits replacement when the token
    /// already matches a known entry exactly.
    exact: HashSet<Box<str>>,
    /// Optional language-word veto. When set, a DL-candidate token
    /// that checks as a real word is left untouched. `None` = old
    /// behaviour (replace every DL-candidate).
    dict: Option<Box<dyn DictChecker>>,
}

impl fmt::Debug for Gazetteer {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Gazetteer")
            .field("entries", &self.entries.len())
            .field("has_dict", &self.dict.is_some())
            .finish()
    }
}

impl Gazetteer {
    pub fn empty() -> Self {
        Self::default()
    }

    /// Load every `*.txt` file (non-recursive) under `dir`. Blank lines
    /// and lines beginning with `#` are skipped. Case-insensitive
    /// duplicates across files are silently collapsed.
    pub async fn load_dir(dir: &Path) -> io::Result<Self> {
        let mut g = Self::default();
        let mut entries = tokio::fs::read_dir(dir).await?;
        while let Some(entry) = entries.next_entry().await? {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("txt") {
                continue;
            }
            let content = tokio::fs::read_to_string(&path).await?;
            for line in content.lines() {
                let trimmed = line.trim();
                if trimmed.is_empty() || trimmed.starts_with('#') {
                    continue;
                }
                g.insert(trimmed);
            }
        }
        Ok(g)
    }

    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Attach a language-word checker. The returned `Gazetteer` vetoes
    /// replacements whose input token is a recognized word of the
    /// language (e.g. `Kaktus` stays `Kaktus` even though it's DL=2
    /// from `Lantus`). Builder-style so `load_dir(...).await?.with_dict(d)`
    /// is a single expression.
    pub fn with_dict(mut self, dict: Box<dyn DictChecker>) -> Self {
        self.dict = Some(dict);
        self
    }

    fn insert(&mut self, entry: &str) {
        if entry.chars().count() < MIN_TOKEN_LEN {
            return;
        }
        let lower = entry.to_lowercase();
        if !self.exact.insert(lower.into_boxed_str()) {
            // Duplicate (case-insensitive) — already indexed.
            return;
        }
        self.entries.push(entry.into());
    }

    /// Rewrite every word-token within edit-distance 2 of a gazetteer
    /// entry to the canonical form (original casing from the vocab
    /// file). Exact matches and non-word segments pass through
    /// unchanged. Each replacement is logged at info level as an
    /// audit trail.
    pub fn replace(&self, text: &str) -> String {
        if self.is_empty() {
            return text.to_string();
        }
        let mut out = String::with_capacity(text.len());
        for seg in tokenize(text) {
            match seg {
                Segment::Word(w) => match self.find_candidate(w) {
                    Some((c, d)) => {
                        tracing::info!(
                            token = w,
                            canonical = c,
                            distance = d,
                            "gazetteer replaced"
                        );
                        out.push_str(c);
                    }
                    None => out.push_str(w),
                },
                Segment::Other(s) => out.push_str(s),
            }
        }
        out
    }

    fn find_candidate(&self, token: &str) -> Option<(&str, usize)> {
        if token.chars().count() < MIN_TOKEN_LEN {
            return None;
        }
        let lower = token.to_lowercase();
        if self.exact.contains(lower.as_str()) {
            return None;
        }

        let best = self
            .entries
            .iter()
            .map(|c| (damerau_levenshtein(&lower, &c.to_lowercase()), c.as_ref()))
            .filter(|(d, _)| *d >= 1 && *d <= MAX_EDIT_DISTANCE)
            .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)))?;

        // Dict-veto: only consult the checker *after* we already have
        // a DL-candidate. Common words (99% of a transcript) never
        // get here, so the dict cost is paid only on the ~0.1% of
        // tokens that are actually at risk of mis-replacement.
        //
        // Case handling: German nouns are capitalized and hunspell
        // rejects them when lowercased. We try the token as-is, then
        // with a capitalized first char (in case Whisper lowercased
        // a noun at sentence start). Either match is a veto.
        if let Some(dict) = self.dict.as_ref() {
            let first_upper = first_char_uppercased(token);
            if dict.check(token) || dict.check(&first_upper) {
                tracing::info!(
                    token = %token,
                    candidate = best.1,
                    distance = best.0,
                    "gazetteer blocked by dict veto",
                );
                return None;
            }
        }

        Some((best.1, best.0))
    }
}

/// Return `token` with its first character uppercased; rest untouched.
/// Cheap: allocates only when the first char actually changes case.
/// Example: `"kaktus"` → `"Kaktus"`, `"Kaktus"` → `"Kaktus"`.
fn first_char_uppercased(token: &str) -> String {
    let mut chars = token.chars();
    match chars.next() {
        Some(first) => {
            let mut out: String = first.to_uppercase().collect();
            out.push_str(chars.as_str());
            out
        }
        None => String::new(),
    }
}

/// spellbook-backed `DictChecker`. Loads hunspell-format `.aff` +
/// `.dic` once at construction; applies affix expansion on every
/// `check()` call, so flected forms (`Kakteen`, `Kaktusse`) are
/// recognized even though only `Kaktus` lives in the `.dic`.
pub struct SpellbookDict {
    inner: spellbook::Dictionary,
}

impl SpellbookDict {
    /// `stem` is the path prefix: e.g. `/usr/share/hunspell/de_DE`.
    /// Both `<stem>.aff` and `<stem>.dic` must exist and be readable.
    pub fn load(stem: &Path) -> io::Result<Self> {
        let aff_path = stem.with_extension("aff");
        let dic_path = stem.with_extension("dic");
        let aff = std::fs::read_to_string(&aff_path)?;
        let dic = std::fs::read_to_string(&dic_path)?;
        let inner = spellbook::Dictionary::new(&aff, &dic)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
        Ok(Self { inner })
    }
}

impl DictChecker for SpellbookDict {
    fn check(&self, word: &str) -> bool {
        self.inner.check(word)
    }
}

/// A slice of the input text — either a word or a non-word run.
enum Segment<'a> {
    Word(&'a str),
    Other(&'a str),
}

/// Split `text` into alternating runs of alphabetic and non-alphabetic
/// chars. Round-trip: concatenating every segment yields the original
/// string byte-for-byte.
fn tokenize(text: &str) -> Vec<Segment<'_>> {
    let mut out = Vec::new();
    let mut chars = text.char_indices().peekable();
    while let Some((start, c)) = chars.next() {
        let is_alpha = c.is_alphabetic();
        let mut end = start + c.len_utf8();
        while let Some(&(next_start, next_c)) = chars.peek() {
            if next_c.is_alphabetic() != is_alpha {
                break;
            }
            end = next_start + next_c.len_utf8();
            chars.next();
        }
        let slice = &text[start..end];
        out.push(if is_alpha {
            Segment::Word(slice)
        } else {
            Segment::Other(slice)
        });
    }
    out
}

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

    fn from_entries(entries: &[&str]) -> Gazetteer {
        let mut g = Gazetteer::empty();
        for e in entries {
            g.insert(e);
        }
        g
    }

    /// In-memory `DictChecker` for unit tests. **Case-sensitive** on
    /// purpose — mirrors how hunspell-de treats nouns (`Kaktus` in the
    /// dict, `kaktus` rejected). This ensures our tests exercise the
    /// same path the production code takes with real hunspell.
    struct HashSetDict(HashSet<String>);

    impl HashSetDict {
        fn new(words: &[&str]) -> Self {
            Self(words.iter().map(|w| w.to_string()).collect())
        }
    }

    impl DictChecker for HashSetDict {
        fn check(&self, word: &str) -> bool {
            self.0.contains(word)
        }
    }

    #[test]
    fn empty_gazetteer_is_pass_through() {
        let g = Gazetteer::empty();
        let input = "Patient nahm Zerebrum-Medikation.";
        assert_eq!(g.replace(input), input);
    }

    #[test]
    fn replaces_typo_within_distance() {
        let g = from_entries(&["Cerebrum"]);
        assert_eq!(
            g.replace("Patient mit Blutung im Zerebrum."),
            "Patient mit Blutung im Cerebrum."
        );
    }

    #[test]
    fn noop_on_exact_match() {
        let g = from_entries(&["Cerebrum"]);
        assert_eq!(
            g.replace("Patient mit Blutung im Cerebrum."),
            "Patient mit Blutung im Cerebrum."
        );
    }

    /// Lowercase variant of an entry is still "exact" — we don't
    /// rewrite a token that differs only in case.
    #[test]
    fn noop_on_case_only_difference() {
        let g = from_entries(&["Cerebrum"]);
        assert_eq!(
            g.replace("Patient mit Blutung im cerebrum."),
            "Patient mit Blutung im cerebrum."
        );
    }

    /// A lowercase token that's also a spelling variant (not exact) is
    /// replaced with the vocab's canonical casing.
    #[test]
    fn replace_preserves_original_casing() {
        let g = from_entries(&["Cerebrum"]);
        let out = g.replace("Blutung im zerebrum heute.");
        assert_eq!(out, "Blutung im Cerebrum heute.");
    }

    #[test]
    fn noop_on_unrelated_token() {
        let g = from_entries(&["Cerebrum"]);
        let input = "Patient hat Husten und Fieber.";
        assert_eq!(g.replace(input), input);
    }

    /// Entries shorter than MIN_TOKEN_LEN are rejected at load time —
    /// prevents short-proper-noun collisions with everyday German.
    #[test]
    fn rejects_entries_below_min_length() {
        let g = from_entries(&["Nuck", "Bein", "Behn"]);
        assert_eq!(g.len(), 0, "short entries must be dropped");
        let input = "Patient klagt nach schwerem Heben.";
        assert_eq!(g.replace(input), input);
    }

    /// Classic ASR-substitution case (N↔D) — distance 1 match.
    #[test]
    fn replaces_consonant_substitution() {
        let g = from_entries(&["Carvedilol"]);
        assert_eq!(
            g.replace("Patient nimmt Carvenilol."),
            "Patient nimmt Carvedilol."
        );
    }

    /// Dropped leading consonant — edit-distance 1 from Berodual.
    #[test]
    fn replaces_dropped_consonant() {
        let g = from_entries(&["Berodual"]);
        assert_eq!(
            g.replace("Patient bekommt Erodual."),
            "Patient bekommt Berodual."
        );
    }

    /// With multiple in-range candidates, the closest by
    /// Damerau-Levenshtein wins; ties break alphabetically.
    #[test]
    fn picks_closest_candidate() {
        let g = from_entries(&["Cerebrum", "Cerebrom"]);
        assert_eq!(
            g.replace("Patient mit Blutung im Zerebrum."),
            "Patient mit Blutung im Cerebrum."
        );
    }

    #[test]
    fn preserves_whitespace_and_punctuation() {
        let g = from_entries(&["Cerebrum"]);
        let input = "Line 1.\n\nLine 2: foo-bar; baz!";
        assert_eq!(g.replace(input), input);
    }

    #[tokio::test]
    async fn load_dir_reads_all_txt_files() {
        let dir = tempdir().unwrap();
        tokio::fs::write(dir.path().join("meds.txt"), "Aspirin\nIbuprofen\n")
            .await
            .unwrap();
        tokio::fs::write(dir.path().join("more.txt"), "Coracoideus\n")
            .await
            .unwrap();
        let g = Gazetteer::load_dir(dir.path()).await.unwrap();
        assert_eq!(g.len(), 3);
    }

    #[tokio::test]
    async fn load_dir_skips_blank_lines_and_comments() {
        let dir = tempdir().unwrap();
        tokio::fs::write(
            dir.path().join("v.txt"),
            "# meds\nAspirin\n\n   \n# another\nIbuprofen\n",
        )
        .await
        .unwrap();
        let g = Gazetteer::load_dir(dir.path()).await.unwrap();
        assert_eq!(g.len(), 2);
    }

    #[tokio::test]
    async fn load_dir_ignores_non_txt_files() {
        let dir = tempdir().unwrap();
        tokio::fs::write(dir.path().join("v.txt"), "Aspirin\n")
            .await
            .unwrap();
        tokio::fs::write(dir.path().join("readme.md"), "Ibuprofen\n")
            .await
            .unwrap();
        let g = Gazetteer::load_dir(dir.path()).await.unwrap();
        assert_eq!(g.len(), 1);
    }

    #[tokio::test]
    async fn load_dir_missing_dir_is_err() {
        let dir = tempdir().unwrap();
        let missing = dir.path().join("nonexistent");
        assert!(Gazetteer::load_dir(&missing).await.is_err());
    }

    /// Core false-positive fix: `Kaktus` is an everyday German word
    /// that sits at DL=2 from `Lantus`. Without the dict veto, the
    /// naive DL rule would rewrite it. With the veto, it stays.
    /// Mock dict stores `Kaktus` case-sensitive — so the original
    /// token matches directly without any case-folding.
    #[test]
    fn dict_veto_blocks_alltagswort_rewrite() {
        let g = from_entries(&["Lantus"]).with_dict(Box::new(HashSetDict::new(&["Kaktus"])));
        assert_eq!(
            g.replace("Patient hat einen Kaktus."),
            "Patient hat einen Kaktus."
        );
    }

    /// Case-robustness regression: Whisper sometimes emits a noun
    /// lowercase (sentence start missing, ASR artefacts). Even then,
    /// our first-char-uppercase fallback hits the dict and the veto
    /// holds. Mock contains the canonical `Kaktus`, input is the
    /// lowercased variant — replacement must still be blocked.
    #[test]
    fn dict_veto_handles_lowercased_noun_via_first_char_upper() {
        let g = from_entries(&["Lantus"]).with_dict(Box::new(HashSetDict::new(&["Kaktus"])));
        assert_eq!(g.replace("ein kaktus steht da."), "ein kaktus steht da.");
    }

    /// Negative control: a token that is *not* a recognized word
    /// still gets rewritten. `Zerebrum` is a typo of `Cerebrum`
    /// (it's the German spelling people reach for but the canonical
    /// vocab form uses `C`). Dict does not contain `zerebrum`.
    #[test]
    fn dict_veto_allows_nonword_rewrite() {
        let g = from_entries(&["Cerebrum"]).with_dict(Box::new(HashSetDict::new(&["Kaktus"])));
        assert_eq!(g.replace("Blutung im Zerebrum."), "Blutung im Cerebrum.");
    }

    /// The exact-match short-circuit runs *before* any dict lookup.
    /// Even if the dict also contained `Lantus`, we'd never get to
    /// the veto — but more importantly, an exact hit should just
    /// pass through cheaply.
    #[test]
    fn dict_veto_preserves_exact_match_fast_path() {
        let g = from_entries(&["Lantus"]).with_dict(Box::new(HashSetDict::new(&["Lantus"])));
        assert_eq!(g.replace("Patient nimmt Lantus."), "Patient nimmt Lantus.");
    }

    /// Regression guard: without a dict attached, the gazetteer keeps
    /// its historical behaviour — the dict field defaults to None.
    #[test]
    fn gazetteer_without_dict_behaves_as_before() {
        let g = from_entries(&["Lantus"]);
        assert_eq!(
            g.replace("Patient hat einen Kaktus."),
            "Patient hat einen Lantus."
        );
    }

    /// Live check against the system-installed hunspell-de.
    /// Marked `#[ignore]` because it depends on
    /// `/usr/share/hunspell/de_DE.{aff,dic}` being present — the
    /// regular test suite must pass on any build host. Run via
    /// `cargo test -- --ignored` on a machine with `pacman -S
    /// hunspell-de` (or the equivalent).
    ///
    /// Proves two things at once:
    ///   1. spellbook can actually parse the German .aff/.dic pair
    ///      (the README warns about complex compound directives).
    ///   2. Affix expansion is applied — the canonical plural
    ///      `Kakteen` of the base form `Kaktus` is recognized even
    ///      though `Kakteen` is not literally in the .dic.
    #[test]
    #[ignore]
    fn spellbook_dict_recognizes_german_base_and_inflection() {
        let dict = SpellbookDict::load(Path::new("/usr/share/hunspell/de_DE"))
            .expect("hunspell-de installed");
        assert!(dict.check("Kaktus"), "base form should be in dict");
        assert!(dict.check("Patient"), "common word should be in dict");
        assert!(!dict.check("Lantus"), "medical brand should NOT be in dict");
        assert!(!dict.check("Zerebrum"), "misspelling should NOT be in dict");
        // The real payoff: canonical plural of Kaktus is Kakteen.
        // If spellbook doesn't apply affix expansion for German, this
        // will fail and we need to switch to a hunspell FFI crate.
        assert!(
            dict.check("Kakteen"),
            "inflected form must be recognized via affix expansion"
        );
    }
}
