//! 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 the
//! per-entry edit-distance threshold (default 2, configurable up to
//! `HARD_MAX_EDIT_DISTANCE` via the `!N` annotation in the vocab file)
//! 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 each entry's `max_distance`. A length-difference
//! pre-filter short-circuits the O(len_a × len_b) DL computation when
//! the absolute length gap already exceeds the threshold. 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;

/// Default Damerau-Levenshtein threshold applied to every entry that
/// does not carry a per-entry override. Distance 0 is handled by the
/// `exact` short-circuit; distances above the entry's threshold are
/// rejected as too speculative.
const DEFAULT_MAX_EDIT_DISTANCE: usize = 2;

/// Hard upper bound for any per-entry `!N` override the parser will
/// accept. Beyond DL=3 on typical 8-char vocabulary entries we are no
/// longer correcting mishearings; we are inventing words. The cap
/// keeps curators honest — if you find yourself needing `!4`, the
/// right next step is a phonetic stage, not a wider edit window.
const HARD_MAX_EDIT_DISTANCE: usize = 3;

/// 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;
}

/// A single vocabulary entry: the canonical surface form plus an optional
/// set of explicit aliases that bypass the dict-veto on exact match.
///
/// Bypass-aliases exist to override Hunspell's overly generous German
/// compound recognition. Example: `Sellink` is the canonical, but
/// Hunspell-de accepts `Seelink` as a valid `See`+`link` compound and
/// would veto the DL=1 replacement. Listing `seelink` in `bypass_aliases`
/// tells the gazetteer "trust me, this specific spelling is a known
/// mishearing of the canonical, skip the dict check".
///
/// The aliases are stored lowercased; matching is case-insensitive.
#[derive(Debug)]
struct Entry {
    canonical: Box<str>,
    /// Lowercased canonical, cached at load time. Used in the DL hot
    /// path so we don't allocate a fresh `String` per token-comparison.
    /// At ~200 entries × 50 tokens that saves 10k allocations per
    /// `replace()` call.
    canonical_lower: Box<str>,
    /// Cached character count of `canonical_lower`. Feeds the
    /// length-difference pre-filter that short-circuits the DL
    /// computation when |len_a - len_b| already exceeds `max_distance`.
    /// `chars().count()` is O(n); caching it once at load time turns
    /// the hot-path check into a single `usize::abs_diff`.
    canonical_chars_len: usize,
    /// Lowercased alias spellings. Empty by default. Looked up only
    /// after an entry has been chosen as the DL-winner — so aliases
    /// listed under entry A never bleed into matches for entry B.
    bypass_aliases: HashSet<Box<str>>,
    /// Per-entry edit-distance threshold. Defaults to
    /// `DEFAULT_MAX_EDIT_DISTANCE`; overridden via the `!N` token in
    /// the vocab file (validated against `HARD_MAX_EDIT_DISTANCE`).
    /// Living on the entry rather than as a global keeps the configuration
    /// local — readers can see *why* a given vocabulary line tolerates
    /// `!3` without hunting a global switch.
    max_distance: usize,
}

/// 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 with their per-entry bypass-alias sets.
    entries: Vec<Entry>,
    /// 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. A malformed
    /// line (e.g. a secondary token without `-` prefix) aborts the load
    /// with `io::ErrorKind::InvalidData` — server start fails loud
    /// rather than silently misindexing.
    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 parsed = parse_line(line).map_err(|e| {
                    io::Error::new(
                        io::ErrorKind::InvalidData,
                        format!("{}: {e}", path.display()),
                    )
                })?;
                if let Some(entry) = parsed {
                    g.insert_entry(entry);
                }
            }
        }
        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
    }

    /// Insert a bare canonical token with no bypass-aliases. Used by
    /// tests and by `insert_entry` as the underlying primitive.
    /// Returns false if the token is too short or already indexed.
    fn insert_canonical(&mut self, canonical: &str) -> bool {
        if canonical.chars().count() < MIN_TOKEN_LEN {
            return false;
        }
        let canonical_lower: Box<str> = canonical.to_lowercase().into_boxed_str();
        let canonical_chars_len = canonical_lower.chars().count();
        if !self.exact.insert(canonical_lower.clone()) {
            // Duplicate (case-insensitive) — already indexed.
            return false;
        }
        self.entries.push(Entry {
            canonical: canonical.into(),
            canonical_lower,
            canonical_chars_len,
            bypass_aliases: HashSet::new(),
            max_distance: DEFAULT_MAX_EDIT_DISTANCE,
        });
        true
    }

    /// Insert a fully parsed entry. If the canonical is below
    /// `MIN_TOKEN_LEN` or duplicates an earlier canonical (case-insensitive),
    /// the entry is silently dropped — same dedup behaviour as the
    /// original `insert`. Otherwise the bypass-alias set is attached
    /// to the new entry.
    fn insert_entry(&mut self, entry: Entry) {
        if !self.insert_canonical(&entry.canonical) {
            return;
        }
        // Just inserted as the last element; attach its per-entry
        // configuration (aliases + distance override).
        if let Some(last) = self.entries.last_mut() {
            last.bypass_aliases = entry.bypass_aliases;
            last.max_distance = entry.max_distance;
        }
    }

    /// 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)> {
        let token_chars_len = token.chars().count();
        if token_chars_len < MIN_TOKEN_LEN {
            return None;
        }
        let lower = token.to_lowercase();
        if self.exact.contains(lower.as_str()) {
            return None;
        }

        // Length pre-filter: DL is a metric, so DL(a, b) >= ||a| - |b||.
        // If the length gap already exceeds the entry's threshold, the
        // O(len_a × len_b) DL computation cannot possibly succeed —
        // skip it. At ~200 entries this pruning is small; once the
        // vocabulary grows it becomes the dominant savings.
        let best = self
            .entries
            .iter()
            .filter_map(|e| {
                if token_chars_len.abs_diff(e.canonical_chars_len) > e.max_distance {
                    return None;
                }
                let d = damerau_levenshtein(&lower, &e.canonical_lower);
                (d >= 1 && d <= e.max_distance).then_some((d, e))
            })
            .min_by(|a, b| {
                a.0.cmp(&b.0)
                    .then_with(|| a.1.canonical.cmp(&b.1.canonical))
            })?;

        let (distance, entry) = best;

        // Per-entry bypass: if the user has explicitly listed `lower`
        // as a known mishearing of this canonical, skip the dict-veto.
        // Aliases are scoped to the entry that won the DL contest, so
        // identical aliases under unrelated entries do not interfere.
        if entry.bypass_aliases.contains(lower.as_str()) {
            tracing::info!(
                token = %token,
                canonical = %entry.canonical,
                distance,
                "gazetteer bypass alias matched",
            );
            return Some((entry.canonical.as_ref(), distance));
        }

        // 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 = %entry.canonical,
                    distance,
                    "gazetteer blocked by dict veto",
                );
                return None;
            }
        }

        Some((entry.canonical.as_ref(), distance))
    }
}

/// 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
}

/// Errors that can arise while parsing a single vocab-file line.
#[derive(Debug)]
pub enum ParseError {
    /// A secondary token on a multi-token line did not start with `-`
    /// (alias) or `!` (distance override). Example: `"Sellink Seelink"` —
    /// the user almost certainly meant `"-Seelink"`. Failing loud at
    /// startup beats silent ambiguity.
    UnknownSecondaryToken { line: String, token: String },
    /// A `!N` token whose `N` part did not parse as a non-negative
    /// integer. Example: `"Foo !abc"`.
    InvalidDistance { line: String, token: String },
    /// A `!N` token whose value lies outside `1..=HARD_MAX_EDIT_DISTANCE`.
    /// `!0` would be useless (DL=0 is the exact-match short-circuit);
    /// values above the hard cap risk widespread false-positives across
    /// the vocabulary and are refused by design.
    DistanceOutOfRange { line: String, value: usize },
    /// More than one `!N` token on the same line. Ambiguous intent;
    /// reject rather than guess which override "wins".
    DuplicateDistanceOverride { line: String },
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::UnknownSecondaryToken { line, token } => write!(
                f,
                "vocab line {line:?}: secondary token {token:?} must start with '-' \
                 (bypass alias) or '!' (distance override)",
            ),
            Self::InvalidDistance { line, token } => write!(
                f,
                "vocab line {line:?}: distance override {token:?} must be of the form \
                 !N where N is a non-negative integer",
            ),
            Self::DistanceOutOfRange { line, value } => write!(
                f,
                "vocab line {line:?}: distance override !{value} is out of range \
                 (allowed: 1..={HARD_MAX_EDIT_DISTANCE})",
            ),
            Self::DuplicateDistanceOverride { line } => write!(
                f,
                "vocab line {line:?}: more than one distance override (!N) on the same line",
            ),
        }
    }
}

impl std::error::Error for ParseError {}

/// Parse a single vocab-file line into an `Entry`.
///
/// Format:
/// - Whitespace-separated tokens.
/// - Blank lines and lines starting with `#` → `Ok(None)`.
/// - First token = canonical. Below `MIN_TOKEN_LEN` → `Ok(None)`
///   (preserves the historical "short entries are silently dropped"
///   behaviour, so vocab files written for the old single-token format
///   keep working unchanged).
/// - Secondary tokens are prefixed:
///   - `-alias` → bypass-alias (lowercased, exact-match veto-override).
///   - `!N` → per-entry max edit distance (N in `1..=HARD_MAX_EDIT_DISTANCE`).
///
///   Any other secondary form is a hard parse error.
/// - At most one `!N` per line; a second one is rejected as ambiguous.
/// - Aliases shorter than `MIN_TOKEN_LEN` are accepted: that floor only
///   exists to protect the DL-heuristic from short-word collisions, but
///   bypass-aliases are exact-match only and explicit, so the floor
///   does not apply.
fn parse_line(line: &str) -> Result<Option<Entry>, ParseError> {
    let trimmed = line.trim();
    if trimmed.is_empty() || trimmed.starts_with('#') {
        return Ok(None);
    }
    let mut tokens = trimmed.split_whitespace();
    let canonical = match tokens.next() {
        Some(c) => c,
        None => return Ok(None),
    };
    if canonical.chars().count() < MIN_TOKEN_LEN {
        return Ok(None);
    }
    let mut bypass_aliases = HashSet::new();
    let mut max_distance: Option<usize> = None;
    for tok in tokens {
        if let Some(rest) = tok.strip_prefix('-') {
            if rest.is_empty() {
                return Err(ParseError::UnknownSecondaryToken {
                    line: trimmed.to_string(),
                    token: tok.to_string(),
                });
            }
            bypass_aliases.insert(rest.to_lowercase().into_boxed_str());
        } else if let Some(rest) = tok.strip_prefix('!') {
            let value: usize = rest.parse().map_err(|_| ParseError::InvalidDistance {
                line: trimmed.to_string(),
                token: tok.to_string(),
            })?;
            if !(1..=HARD_MAX_EDIT_DISTANCE).contains(&value) {
                return Err(ParseError::DistanceOutOfRange {
                    line: trimmed.to_string(),
                    value,
                });
            }
            if max_distance.is_some() {
                return Err(ParseError::DuplicateDistanceOverride {
                    line: trimmed.to_string(),
                });
            }
            max_distance = Some(value);
        } else {
            return Err(ParseError::UnknownSecondaryToken {
                line: trimmed.to_string(),
                token: tok.to_string(),
            });
        }
    }
    let canonical_lower: Box<str> = canonical.to_lowercase().into_boxed_str();
    let canonical_chars_len = canonical_lower.chars().count();
    Ok(Some(Entry {
        canonical: canonical.into(),
        canonical_lower,
        canonical_chars_len,
        bypass_aliases,
        max_distance: max_distance.unwrap_or(DEFAULT_MAX_EDIT_DISTANCE),
    }))
}

#[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_canonical(e);
        }
        g
    }

    /// Build a gazetteer from full vocab-file lines (canonical plus
    /// optional `-alias` tokens). Panics on parse error — tests should
    /// pass well-formed lines or use `parse_line` directly to assert
    /// errors.
    fn from_lines(lines: &[&str]) -> Gazetteer {
        let mut g = Gazetteer::empty();
        for line in lines {
            if let Some(entry) = parse_line(line).expect("parse_line") {
                g.insert_entry(entry);
            }
        }
        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."
        );
    }

    // --- Bypass-alias parser tests ---

    /// Backward-compatibility: a single-token line still produces a
    /// canonical entry with no aliases. All historical vocab files
    /// stay valid under the new parser.
    #[test]
    fn parse_line_single_token_unchanged() {
        let entry = parse_line("Sellink").unwrap().unwrap();
        assert_eq!(&*entry.canonical, "Sellink");
        assert!(entry.bypass_aliases.is_empty());
    }

    /// Canonical plus one bypass-alias. The alias is stored lowercased
    /// (matching is case-insensitive); the canonical preserves its
    /// original casing for emission.
    #[test]
    fn parse_line_with_bypass_alias() {
        let entry = parse_line("Sellink -Seelink").unwrap().unwrap();
        assert_eq!(&*entry.canonical, "Sellink");
        assert!(entry.bypass_aliases.contains("seelink"));
        assert_eq!(entry.bypass_aliases.len(), 1);
    }

    /// Multiple aliases on the same line, all dash-prefixed.
    #[test]
    fn parse_line_multiple_aliases() {
        let entry = parse_line("Lantus -Latus -Lattus").unwrap().unwrap();
        assert_eq!(&*entry.canonical, "Lantus");
        assert!(entry.bypass_aliases.contains("latus"));
        assert!(entry.bypass_aliases.contains("lattus"));
        assert_eq!(entry.bypass_aliases.len(), 2);
    }

    /// A secondary token without `-` or `!` is a hard parse error. The
    /// server-start path converts this to `io::ErrorKind::InvalidData`.
    #[test]
    fn parse_line_rejects_secondary_without_prefix() {
        let err = parse_line("Sellink Seelink").unwrap_err();
        match err {
            ParseError::UnknownSecondaryToken { line, token } => {
                assert_eq!(line, "Sellink Seelink");
                assert_eq!(token, "Seelink");
            }
            other => panic!("expected UnknownSecondaryToken, got {other:?}"),
        }
    }

    /// Blank lines and `#`-comments produce `Ok(None)`, just like the
    /// old loop's `continue`.
    #[test]
    fn parse_line_skips_blank_and_comment() {
        assert!(parse_line("").unwrap().is_none());
        assert!(parse_line("   ").unwrap().is_none());
        assert!(parse_line("# a comment").unwrap().is_none());
        assert!(parse_line("   # indented comment").unwrap().is_none());
    }

    /// A bare `-` (no alias body) is treated as a malformed secondary
    /// token, not as an empty alias.
    #[test]
    fn parse_line_rejects_bare_dash() {
        let err = parse_line("Sellink -").unwrap_err();
        assert!(matches!(err, ParseError::UnknownSecondaryToken { .. }));
    }

    // --- Bypass-alias behavioural tests ---

    /// Core bug-fix test for the 2026-04-30 log finding. Whisper
    /// produced "Seelink", which DL=1 matches "Sellink". Hunspell-de
    /// (here mocked) accepts "Seelink" as a valid German compound and
    /// would veto the replacement. The bypass-alias forces the rewrite
    /// regardless.
    #[test]
    fn bypass_alias_overrides_dict_veto() {
        let g =
            from_lines(&["Sellink -Seelink"]).with_dict(Box::new(HashSetDict::new(&["Seelink"])));
        assert_eq!(g.replace("MRT-Seelink heute."), "MRT-Sellink heute.");
    }

    /// Bypass-aliases are exact-match only (case-insensitive). A
    /// declension like "Seelinks" is not in the alias set, so it falls
    /// through to the normal DL+veto path. Here the DL=2 candidate
    /// would be vetoed by the dict (which "knows" the plural), so the
    /// token stays untouched — proving the bypass does not bleed.
    #[test]
    fn bypass_alias_is_exact_match_only() {
        let g =
            from_lines(&["Sellink -Seelink"]).with_dict(Box::new(HashSetDict::new(&["Seelinks"])));
        assert_eq!(g.replace("Sieht Seelinks aus."), "Sieht Seelinks aus.");
    }

    /// An alias listed under entry A must not enable a bypass for
    /// entry B's matches. Here only `Sellink` has the bypass, but the
    /// token under test (`Kaktus`) is DL=2 from `Lantus`. The dict
    /// veto must still block the Kaktus → Lantus rewrite.
    #[test]
    fn bypass_alias_does_not_affect_other_entries() {
        let g = from_lines(&["Sellink -Seelink", "Lantus"])
            .with_dict(Box::new(HashSetDict::new(&["Kaktus"])));
        assert_eq!(
            g.replace("Patient hat einen Kaktus."),
            "Patient hat einen Kaktus."
        );
    }

    /// Case-insensitive alias matching: the alias is stored lowercased,
    /// so any casing of the input token that lowercases to the alias
    /// triggers the bypass.
    #[test]
    fn bypass_alias_is_case_insensitive() {
        let g = from_lines(&["Sellink -Seelink"]).with_dict(Box::new(HashSetDict::new(&[
            "Seelink", "seelink", "SEELINK",
        ])));
        assert_eq!(g.replace("Mit SEELINK heute."), "Mit Sellink heute.");
        assert_eq!(g.replace("mit seelink heute."), "mit Sellink heute.");
    }

    // --- Per-entry distance override (!N) parser tests ---

    /// Default behaviour preserved: a single-token line yields
    /// `max_distance = DEFAULT_MAX_EDIT_DISTANCE`.
    #[test]
    fn parse_line_default_distance_is_two() {
        let entry = parse_line("Aspirin").unwrap().unwrap();
        assert_eq!(entry.max_distance, DEFAULT_MAX_EDIT_DISTANCE);
        assert_eq!(entry.max_distance, 2);
    }

    /// Loosen: `!3` opens an entry to DL=3 — the use case that
    /// motivated this whole feature (Thorazemit ↔ Torasemid).
    #[test]
    fn parse_line_with_distance_override_three() {
        let entry = parse_line("Torasemid !3").unwrap().unwrap();
        assert_eq!(&*entry.canonical, "Torasemid");
        assert_eq!(entry.max_distance, 3);
        assert!(entry.bypass_aliases.is_empty());
    }

    /// Tighten: `!1` shrinks an entry to DL=1 — useful for short
    /// canonicals where DL=2 admits too many neighbours.
    #[test]
    fn parse_line_accepts_distance_one() {
        let entry = parse_line("Aspirin !1").unwrap().unwrap();
        assert_eq!(entry.max_distance, 1);
    }

    /// `!N` and `-alias` are independent and may coexist on the same
    /// line. Order does not matter to the parser.
    #[test]
    fn parse_line_combines_distance_and_alias() {
        let entry = parse_line("Torasemid !3 -Thorasemid").unwrap().unwrap();
        assert_eq!(&*entry.canonical, "Torasemid");
        assert_eq!(entry.max_distance, 3);
        assert!(entry.bypass_aliases.contains("thorasemid"));
    }

    /// `!N` may appear before or after `-alias` tokens — whitespace
    /// order is irrelevant. Regression guard against accidentally
    /// coupling the parser to token order.
    #[test]
    fn parse_line_alias_then_distance_works() {
        let entry = parse_line("Torasemid -Thorasemid !3").unwrap().unwrap();
        assert_eq!(entry.max_distance, 3);
        assert!(entry.bypass_aliases.contains("thorasemid"));
    }

    /// Above the hard cap (`HARD_MAX_EDIT_DISTANCE = 3`) is rejected.
    /// The cap is intentional: edit-distance 4 on typical 8-char
    /// vocabulary entries flips half the word and is no longer a
    /// mishearing — extending here is the wrong fix.
    #[test]
    fn parse_line_rejects_distance_above_cap() {
        let err = parse_line("Aspirin !4").unwrap_err();
        match err {
            ParseError::DistanceOutOfRange { value, .. } => assert_eq!(value, 4),
            other => panic!("expected DistanceOutOfRange, got {other:?}"),
        }
    }

    /// `!0` is rejected — DL=0 is the exact-match short-circuit, so
    /// an entry tolerating "no edits" would silently never match.
    /// Better to fail loud than to ship a useless line.
    #[test]
    fn parse_line_rejects_distance_zero() {
        let err = parse_line("Aspirin !0").unwrap_err();
        assert!(matches!(
            err,
            ParseError::DistanceOutOfRange { value: 0, .. }
        ));
    }

    /// Non-numeric `!N` fails with `InvalidDistance`, not silently.
    #[test]
    fn parse_line_rejects_invalid_distance_token() {
        let err = parse_line("Aspirin !abc").unwrap_err();
        match err {
            ParseError::InvalidDistance { token, .. } => assert_eq!(token, "!abc"),
            other => panic!("expected InvalidDistance, got {other:?}"),
        }
    }

    /// More than one `!N` on the same line is ambiguous — reject
    /// rather than guess which override "wins".
    #[test]
    fn parse_line_rejects_duplicate_distance() {
        let err = parse_line("Aspirin !2 !3").unwrap_err();
        assert!(matches!(err, ParseError::DuplicateDistanceOverride { .. }));
    }

    // --- Per-entry distance override (!N) behavioural tests ---

    /// Without the override, a DL=3 token does NOT match — that is
    /// the original conservative behaviour and must be preserved.
    #[test]
    fn find_candidate_dl3_no_match_without_override() {
        let g = from_lines(&["Torasemid"]);
        // "Thorazemit" is DL=3 from "Torasemid":
        //   T-orasemid → Thorasemid (insert h)
        //          s   →        z   (substitution)
        //          d   →        t   (substitution)
        assert_eq!(
            g.replace("Patient nimmt Thorazemit heute."),
            "Patient nimmt Thorazemit heute."
        );
    }

    /// With `!3`, the same DL=3 token now resolves to the canonical.
    /// This is the headline behaviour the feature exists for.
    #[test]
    fn find_candidate_dl3_match_with_per_entry_override() {
        let g = from_lines(&["Torasemid !3"]);
        assert_eq!(
            g.replace("Patient nimmt Thorazemit heute."),
            "Patient nimmt Torasemid heute."
        );
    }

    /// DL=4 is never matched, even with `!3` — the hard cap holds.
    /// Picking a token genuinely 4 edits away from "Torasemid":
    /// "Thorazemett" — insert h, s→z, d→t, append t.
    #[test]
    fn find_candidate_dl4_never_matches_with_override_three() {
        let g = from_lines(&["Torasemid !3"]);
        assert_eq!(
            g.replace("Wert von Thorazemett heute."),
            "Wert von Thorazemett heute."
        );
    }

    /// `!1` tightens the threshold: a DL=2 token that *would* have
    /// matched at the default no longer does. Verifies that the
    /// per-entry value really overrides the default in both directions.
    #[test]
    fn find_candidate_distance_one_tightens_filter() {
        // Default would catch DL=2 typo of "Cerebrum"; !1 must not.
        let g = from_lines(&["Cerebrum !1"]);
        // "Zerabrum": z→c (1) + a→e (1) = DL=2 from "Cerebrum".
        assert_eq!(g.replace("Blutung im Zerabrum."), "Blutung im Zerabrum.");
        // DL=1 still matches under !1.
        assert_eq!(g.replace("Blutung im Zerebrum."), "Blutung im Cerebrum.");
    }

    /// `!3` does NOT bypass the dict-veto. If a DL=3 candidate is
    /// itself a real word of the language, Hunspell's veto must still
    /// hold — the per-entry threshold widens the DL filter, nothing else.
    #[test]
    fn find_candidate_dl3_blocked_by_dict_veto() {
        // Mock dict claims the DL=3 token is a real word. Even with
        // !3 admitting it past the DL filter, the veto must keep it.
        let g =
            from_lines(&["Torasemid !3"]).with_dict(Box::new(HashSetDict::new(&["Thorazemit"])));
        assert_eq!(
            g.replace("Patient nimmt Thorazemit heute."),
            "Patient nimmt Thorazemit heute."
        );
    }

    /// Bypass-aliases compose with `!3`: an explicit alias still
    /// overrides the dict-veto on a !3-entry, just as it does on a
    /// default-distance entry.
    #[test]
    fn bypass_alias_works_alongside_distance_override() {
        let g = from_lines(&["Torasemid !3 -Thorazemit"])
            .with_dict(Box::new(HashSetDict::new(&["Thorazemit"])));
        assert_eq!(
            g.replace("Patient nimmt Thorazemit heute."),
            "Patient nimmt Torasemid heute."
        );
    }

    /// `load_dir` propagates a parser error as `InvalidData`, with the
    /// offending file path in the message — server-start should fail
    /// loud rather than silently misindex.
    #[tokio::test]
    async fn load_dir_propagates_parse_error_as_invalid_data() {
        let dir = tempdir().unwrap();
        tokio::fs::write(dir.path().join("v.txt"), "Sellink Seelink\n")
            .await
            .unwrap();
        let err = Gazetteer::load_dir(dir.path()).await.unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
        let msg = err.to_string();
        assert!(
            msg.contains("Seelink"),
            "error message must name the bad token: {msg}"
        );
        assert!(
            msg.contains("v.txt"),
            "error message must name the file: {msg}"
        );
    }

    /// 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"
        );
    }
}
