//! WORLD-4 Branch B — the fact-checker (P4, fast track). Reads author prose for
//! assertions about the world and verifies them against the simulation + the
//! magic ledger. This is the deterministic *fast* track: per-language pattern
//! matching, no LLM, sub-millisecond per paragraph. The first category is
//! travel time (the most-fired error in fiction); more land alongside it.
//!
//! Findings emit to the Output pane (PANE-1) as `fact_check_warning` messages,
//! so the author sees them without the checker blocking the manuscript. Before
//! emitting, each candidate is run past the magic ledger — a declared exception
//! to physics suppresses the warning with a note (lazy consultation, §8.21).

use crate::world::fact_check_lang::{contains_word, Lang, Msg, Weather};
use crate::world::proposals::PlaceLink;
use crate::world::types::magic::{CheckContext, MagicLedger};

/// A project gazetteer over the world-linked Places: resolves a place name in
/// prose to its `PlaceLink` (climate zone / biome / modelled population), so the
/// climate and demographics checks can compare a claim against the place's
/// actual world data.
pub struct Gazetteer {
    places: Vec<PlaceLink>,
}

impl Gazetteer {
    pub fn new(places: Vec<PlaceLink>) -> Self {
        Self { places }
    }

    /// The world-linked places whose name appears (as a whole word) in `text`,
    /// matching in English (no inflection). Kept for callers without a language.
    pub fn mentioned_in(&self, text: &str) -> Vec<&PlaceLink> {
        self.mentioned_in_lang(text, Lang::En)
    }

    /// As [`mentioned_in`], but matches the name in any of its grammatical forms
    /// for `lang` — so a Russian place resolves when it appears declined (e.g.
    /// `в Москве` matches `Москва`). See [`crate::world::fact_check_lang::name_variants`].
    pub fn mentioned_in_lang(&self, text: &str, lang: Lang) -> Vec<&PlaceLink> {
        let lower = text.to_lowercase();
        self.places
            .iter()
            .filter(|p| {
                crate::world::fact_check_lang::name_variants(&p.name, lang)
                    .iter()
                    .any(|v| contains_word(&lower, v))
            })
            .collect()
    }
}

/// Gazetteer entries for the author-declared landmarks in the world definition:
/// any `geography.landmark` with a name becomes a resolvable place (its
/// `climate_zone` / `population` feed the climate + demographics checks; absent
/// coordinates default to 0). Merged with the stored compiler Places so the
/// fact-checker resolves both.
pub fn declared_places(def: &crate::world::types::WorldDefinition) -> Vec<PlaceLink> {
    let Some(geo) = def.geography.as_ref() else { return Vec::new() };
    geo.landmarks
        .iter()
        .filter(|l| !l.name.trim().is_empty())
        .map(|l| PlaceLink {
            place_id: uuid::Uuid::nil(),
            name: l.name.clone(),
            biome: l.climate_zone.clone(),
            climate_zone: l.climate_zone.clone(),
            hydrology_basis: l.kind.clone(),
            population: l.population,
            x: 0,
            y: 0,
        })
        .collect()
}

/// The world data the fact-checker needs beyond the magic ledger: the gazetteer
/// (place names → world data) and the astronomy facts (moon names). Bundled so
/// new world-data categories don't keep changing the check signature.
pub struct WorldContext {
    pub gazetteer: Gazetteer,
    /// The world's moon names (for the astronomy check).
    pub moons: Vec<String>,
    /// The minerals the world's geology yields (for the economy check).
    pub minerals: Vec<String>,
}

impl WorldContext {
    pub fn new(gazetteer: Gazetteer, moons: Vec<String>, minerals: Vec<String>) -> Self {
        Self { gazetteer, moons, minerals }
    }
}

/// Whole-word containment (so "Or" doesn't match inside "Orenarm").
/// A fact-check finding. `body` is the human message; `suppressed_by` is set
/// when a magic rule covered it (the warning is informational, not a problem).
#[derive(Debug, Clone, PartialEq)]
pub struct Finding {
    pub category: String,
    /// "info" | "warning" | "contradiction".
    pub severity: String,
    /// The message in the paragraph's language.
    pub body: String,
    /// The English message (always present; the ask-AI bridge prefers English).
    pub body_en: String,
    /// The magic rule kind that suppressed this finding, if any.
    pub suppressed_by: Option<String>,
}

/// Run the fast fact-check over a paragraph of prose. `roles` are any actor
/// roles in scope (for magic-ledger consultation); `gaz` resolves place names to
/// their world data (the climate + demographics checks need it; travel time
/// doesn't). Both may be empty / `None`.
pub fn check_paragraph(
    text: &str,
    ledger: &MagicLedger,
    roles: &[String],
    ctx: Option<&WorldContext>,
) -> Vec<Finding> {
    // Degrade rather than mislead: when language detection isn't confident, render
    // warnings in English (the safe baseline) instead of asserting a guessed
    // language. The numeric checks themselves are language-agnostic.
    let (detected, confident) = crate::world::fact_check_lang::detect_with_confidence(text);
    let lang = if confident { detected } else { Lang::En };
    let mut findings = Vec::new();
    findings.extend(check_travel_time(text, ledger, roles, lang));
    if let Some(c) = ctx {
        findings.extend(check_climate(text, &c.gazetteer, ledger, lang));
        findings.extend(check_population(text, &c.gazetteer, ledger, lang));
        findings.extend(check_astronomy(text, &c.moons, ledger, lang));
        findings.extend(check_economy(text, &c.minerals, ledger, lang));
    }
    findings
}

/// Economy check: a metal mined or worked in the prose that the world's geology
/// doesn't yield. Only the world's mineral hints are modelled, so this is scoped
/// to ores/metals in a clear extraction context (a mine, a vein, smelting) —
/// mentioning a gold ring doesn't imply local gold.
fn check_economy(text: &str, minerals: &[String], ledger: &MagicLedger, lang: Lang) -> Vec<Finding> {
    if minerals.is_empty() {
        return Vec::new();
    }
    let available: std::collections::HashSet<String> =
        minerals.iter().map(|m| m.to_ascii_lowercase()).collect();
    let mut out = Vec::new();
    for sentence in split_sentences(text) {
        if !has_extraction_context(sentence, lang) {
            continue;
        }
        let lower = sentence.to_lowercase();
        let mut seen = std::collections::HashSet::new();
        // `metals` maps a canonical mineral (the English label the world stores)
        // to its names in the paragraph's language.
        for (canonical, names) in lang.metals() {
            if available.contains(*canonical) || seen.contains(*canonical) {
                continue;
            }
            if names.iter().any(|n| contains_word(&lower, &n.to_lowercase())) {
                seen.insert(*canonical);
                let mineral_list = minerals.join(", ");
                let msg = Msg::Economy { metal: canonical, minerals: &mineral_list };
                let ctx = CheckContext { category: "economy", ..Default::default() };
                let suppressed_by = ledger.find_suppressor(&ctx).map(|r| r.kind.clone());
                let severity = if suppressed_by.is_some() { "info" } else { "warning" };
                out.push(Finding {
                    category: "economy".into(),
                    severity: severity.into(),
                    body: lang.render(&msg),
                    body_en: Lang::En.render(&msg),
                    suppressed_by,
                });
            }
        }
    }
    out
}

/// Whether a sentence describes resource extraction / working (so a metal
/// mention is a production claim, not incidental).
fn has_extraction_context(s: &str, lang: Lang) -> bool {
    let l = s.to_lowercase();
    lang.extraction_words().iter().any(|w| contains_word(&l, &w.to_lowercase()))
}

/// Astronomy check: a stated moon count that disagrees with the world's sky.
fn check_astronomy(text: &str, moons: &[String], ledger: &MagicLedger, lang: Lang) -> Vec<Finding> {
    let world_count = moons.len();
    if world_count == 0 {
        return Vec::new();
    }
    let mut out = Vec::new();
    for sentence in split_sentences(text) {
        let Some(claimed) = find_moon_count(sentence, lang) else {
            continue;
        };
        if claimed == world_count {
            continue;
        }
        let moon_list = moons.join(", ");
        let msg = Msg::Astronomy { claimed, world: world_count, moons: &moon_list };
        let ctx = CheckContext { category: "astronomy", ..Default::default() };
        let suppressed_by = ledger.find_suppressor(&ctx).map(|r| r.kind.clone());
        let severity = if suppressed_by.is_some() { "info" } else { "warning" };
        out.push(Finding {
            category: "astronomy".into(),
            severity: severity.into(),
            body: lang.render(&msg),
            body_en: Lang::En.render(&msg),
            suppressed_by,
        });
    }
    out
}

/// Extract a moon count from a sentence: "the three moons", "both moons".
fn find_moon_count(s: &str, lang: Lang) -> Option<usize> {
    let both = both_words(lang);
    let mut number_words: Vec<&str> =
        lang.numbers().iter().map(|(w, _)| *w).chain(both.iter().copied()).collect();
    number_words.sort_by_key(|w| std::cmp::Reverse(w.len()));
    let num_alt = number_words.iter().map(|w| regex::escape(w)).collect::<Vec<_>>().join("|");
    let moon_alt = alternation(lang.moon_words());
    let re = regex::Regex::new(&format!(r"(?i)(\d+|{num_alt})\s+({moon_alt})")).ok()?;
    let caps = re.captures(s)?;
    let w = caps.get(1)?.as_str().to_lowercase();
    if both.iter().any(|b| b.to_lowercase() == w) {
        return Some(2);
    }
    word_to_number(&w, lang).map(|n| n as usize)
}

/// The single-word "both" in each language (a phrase like French "les deux"
/// can't be a regex token, so those return nothing here).
fn both_words(lang: Lang) -> &'static [&'static str] {
    match lang {
        Lang::En => &["both"],
        Lang::Ru => &["оба", "обе"],
        Lang::Es => &["ambos", "ambas"],
        Lang::De => &["beide"],
        Lang::Fr => &[],
    }
}

/// A case-insensitive regex alternation of the words, longest-first so
/// "kilometres" matches before "km".
fn alternation(words: &[&str]) -> String {
    let mut w: Vec<&str> = words.to_vec();
    w.sort_by_key(|x| std::cmp::Reverse(x.len()));
    w.iter().map(|x| regex::escape(x)).collect::<Vec<_>>().join("|")
}

/// Climate check: weather described at a place that contradicts the place's
/// climate zone (snow in the tropics, jungle heat on the tundra).
fn check_climate(text: &str, gaz: &Gazetteer, ledger: &MagicLedger, lang: Lang) -> Vec<Finding> {
    let mut out = Vec::new();
    for sentence in split_sentences(text) {
        let Some(weather) = detect_weather(sentence, lang) else {
            continue;
        };
        for p in gaz.mentioned_in_lang(sentence, lang) {
            if !climate_conflict(&p.climate_zone, weather) {
                continue;
            }
            let msg = Msg::Climate { weather, place: &p.name, zone: &p.climate_zone };
            let ctx = CheckContext {
                category: "climate_anomaly",
                roles: &[],
                region: Some(&p.name),
                ..Default::default()
            };
            let suppressed_by = ledger.find_suppressor(&ctx).map(|r| r.kind.clone());
            let severity = if suppressed_by.is_some() { "info" } else { "warning" };
            out.push(Finding {
                category: "climate".into(),
                severity: severity.into(),
                body: lang.render(&msg),
                body_en: Lang::En.render(&msg),
                suppressed_by,
            });
        }
    }
    out
}

/// WORLD-5 — the timeline-aware entry point. Given a paragraph's
/// [`TimelineContext`](crate::world::timeline_context::TimelineContext), check the
/// prose's weather against the **timeline-dated season**: snow in a paragraph the
/// timeline places in summer is a hard contradiction (dated ground truth, not
/// prose inference). Returns nothing when the paragraph has no effective season.
pub fn check_timeline(
    text: &str,
    timeline: &crate::world::timeline_context::TimelineContext,
    ledger: &MagicLedger,
) -> Vec<Finding> {
    let Some(season) = timeline.effective_season.as_deref() else {
        return Vec::new();
    };
    let lang = crate::world::fact_check_lang::detect(text);
    check_date_season(text, season, ledger, lang)
}

/// A canonical season, for comparing a prose date-hint against a (possibly
/// custom-named) calendar season.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CanonSeason {
    Spring,
    Summer,
    Autumn,
    Winter,
}

/// Map a calendar season name to its canonical season, if it names a recognizable
/// one (so a conlang/custom season degrades to no comparison).
fn canon_season(name: &str) -> Option<CanonSeason> {
    let s = name.to_lowercase();
    if s.contains("summer") {
        Some(CanonSeason::Summer)
    } else if s.contains("winter") {
        Some(CanonSeason::Winter)
    } else if s.contains("spring") {
        Some(CanonSeason::Spring)
    } else if s.contains("autumn") || s.contains("fall") {
        Some(CanonSeason::Autumn)
    } else {
        None
    }
}

/// Seasonal date-hints (festivals, agricultural / solstitial references) per
/// language. Multilingual from WORLD-4's baseline; lowercase (the text is
/// lowercased before matching).
use CanonSeason::{Autumn, Spring, Summer, Winter};

const DATE_HINTS_EN: &[(&str, CanonSeason)] = &[
    ("midsummer", Summer), ("high summer", Summer), ("summer solstice", Summer),
    ("midwinter", Winter), ("deep winter", Winter), ("winter solstice", Winter), ("yule", Winter),
    ("spring festival", Spring), ("vernal equinox", Spring), ("planting season", Spring),
    ("harvest", Autumn), ("harvest festival", Autumn), ("autumn equinox", Autumn),
];

const DATE_HINTS_RU: &[(&str, CanonSeason)] = &[
    ("разгар лета", Summer), ("середина лета", Summer), ("летнее солнцестояние", Summer), ("купала", Summer),
    ("разгар зимы", Winter), ("середина зимы", Winter), ("зимнее солнцестояние", Winter), ("святки", Winter),
    ("весенний праздник", Spring), ("весеннее равноденствие", Spring), ("посевная", Spring), ("масленица", Spring),
    ("жатва", Autumn), ("сбор урожая", Autumn), ("осеннее равноденствие", Autumn),
];

const DATE_HINTS_ES: &[(&str, CanonSeason)] = &[
    ("pleno verano", Summer), ("solsticio de verano", Summer), ("san juan", Summer),
    ("pleno invierno", Winter), ("solsticio de invierno", Winter),
    ("fiesta de primavera", Spring), ("equinoccio de primavera", Spring), ("la siembra", Spring),
    ("la cosecha", Autumn), ("vendimia", Autumn), ("equinoccio de otoño", Autumn),
];

const DATE_HINTS_FR: &[(&str, CanonSeason)] = &[
    ("plein été", Summer), ("solstice d'été", Summer), ("la saint-jean", Summer),
    ("plein hiver", Winter), ("solstice d'hiver", Winter),
    ("fête du printemps", Spring), ("équinoxe de printemps", Spring), ("les semailles", Spring),
    ("la moisson", Autumn), ("les vendanges", Autumn), ("équinoxe d'automne", Autumn),
];

const DATE_HINTS_DE: &[(&str, CanonSeason)] = &[
    ("hochsommer", Summer), ("mittsommer", Summer), ("sommersonnenwende", Summer), ("johannistag", Summer),
    ("hochwinter", Winter), ("mittwinter", Winter), ("wintersonnenwende", Winter), ("julfest", Winter),
    ("frühlingsfest", Spring), ("frühlingsäquinoktium", Spring), ("die aussaat", Spring),
    ("die ernte", Autumn), ("erntedankfest", Autumn), ("herbstäquinoktium", Autumn),
];

/// The date-hint table for a language.
fn date_hints(lang: Lang) -> &'static [(&'static str, CanonSeason)] {
    match lang {
        Lang::En => DATE_HINTS_EN,
        Lang::Ru => DATE_HINTS_RU,
        Lang::Es => DATE_HINTS_ES,
        Lang::Fr => DATE_HINTS_FR,
        Lang::De => DATE_HINTS_DE,
    }
}

/// WORLD-5 — the `date_coherence` check: a seasonal date-hint in the prose (a
/// festival, a harvest, a solstice) that contradicts the timeline-dated season.
/// Softer than the weather check (a festival may be metaphorical) → `warning`.
pub fn check_date_coherence(
    text: &str,
    timeline: &crate::world::timeline_context::TimelineContext,
    ledger: &MagicLedger,
) -> Vec<Finding> {
    let Some(season) = timeline.effective_season.as_deref() else {
        return Vec::new();
    };
    let Some(dated) = canon_season(season) else {
        return Vec::new();
    };
    let lang = crate::world::fact_check_lang::detect(text);
    let lower = text.to_lowercase();
    for (hint, implied) in date_hints(lang) {
        if *implied != dated && contains_word(&lower, hint) {
            let msg = Msg::DateCoherence { hint, season };
            let ctx = CheckContext { category: "date_coherence", ..Default::default() };
            let suppressed_by = ledger.find_suppressor(&ctx).map(|r| r.kind.clone());
            let severity = if suppressed_by.is_some() { "info" } else { "warning" };
            return vec![Finding {
                category: "date_coherence".into(),
                severity: severity.into(),
                body: lang.render(&msg),
                body_en: Lang::En.render(&msg),
                suppressed_by,
            }];
        }
    }
    Vec::new()
}

/// WORLD-5 — a prose-stated travel duration that contradicts the timeline gap
/// between the paragraph's linked event and the traveller's prior different-place
/// event. The RFC's flagship example: prose says "three days", the timeline shows
/// a 35-day gap. `ticks_per_day` converts the gap to days.
pub fn check_travel_timeline(
    text: &str,
    timeline: &crate::world::timeline_context::TimelineContext,
    events: &[crate::world::timeline_context::TlEvent],
    ticks_per_day: i64,
    ledger: &MagicLedger,
) -> Vec<Finding> {
    if timeline.linked_events.is_empty() || ticks_per_day <= 0 {
        return Vec::new();
    }
    let lang = crate::world::fact_check_lang::detect(text);
    let Some(prose_days) = split_sentences(text).filter_map(|s| find_duration_days(s, lang)).next()
    else {
        return Vec::new();
    };
    let linked: Vec<&crate::world::timeline_context::TlEvent> =
        events.iter().filter(|e| timeline.linked_events.contains(&e.id)).collect();
    for anchor in &linked {
        if anchor.places.is_empty() {
            continue;
        }
        for ch in &anchor.characters {
            // The traveller's most-recent prior event at a different place.
            let prior = events
                .iter()
                .filter(|e| {
                    e.id != anchor.id
                        && e.characters.contains(ch)
                        && e.start_ticks < anchor.start_ticks
                        && !e.places.is_empty()
                        && !e.places.iter().any(|p| anchor.places.contains(p))
                })
                .max_by_key(|e| e.start_ticks);
            let Some(prior) = prior else { continue };
            let timeline_days =
                (anchor.start_ticks - prior.start_ticks) as f32 / ticks_per_day as f32;
            if timeline_days <= 0.0 {
                continue;
            }
            let ratio = prose_days / timeline_days;
            if (0.33..=3.0).contains(&ratio) {
                continue; // close enough — narrative compression is fine within ~3×
            }
            let msg = Msg::TravelTimeline {
                prose_days,
                timeline_days,
                from: &prior.title,
                to: &anchor.title,
            };
            let ctx = CheckContext { category: "travel_time", ..Default::default() };
            let suppressed_by = ledger.find_suppressor(&ctx).map(|r| r.kind.clone());
            let severity = if suppressed_by.is_some() { "info" } else { "warning" };
            return vec![Finding {
                category: "travel_time".into(),
                severity: severity.into(),
                body: lang.render(&msg),
                body_en: Lang::En.render(&msg),
                suppressed_by,
            }];
        }
    }
    Vec::new()
}

/// Weather contradicting a dated season. Conservative: only the common
/// English-named seasons (`summer` / `winter`) are temperature-mapped; a
/// custom- or conlang-named season degrades to no finding rather than guessing.
fn check_date_season(text: &str, season: &str, ledger: &MagicLedger, lang: Lang) -> Vec<Finding> {
    let s = season.to_lowercase();
    let cold_season = s.contains("winter");
    let hot_season = s.contains("summer");
    if !cold_season && !hot_season {
        return Vec::new();
    }
    let mut out = Vec::new();
    for sentence in split_sentences(text) {
        let Some(weather) = detect_weather(sentence, lang) else {
            continue;
        };
        let conflict = matches!(
            (weather, cold_season, hot_season),
            (Weather::Cold, _, true) | (Weather::Hot, true, _)
        );
        if !conflict {
            continue;
        }
        let msg = Msg::DateSeason { weather, season };
        let ctx = CheckContext { category: "climate_anomaly", ..Default::default() };
        let suppressed_by = ledger.find_suppressor(&ctx).map(|r| r.kind.clone());
        // A dated season is ground truth — a clear mismatch is a contradiction.
        let severity = if suppressed_by.is_some() { "info" } else { "contradiction" };
        out.push(Finding {
            category: "climate".into(),
            severity: severity.into(),
            body: lang.render(&msg),
            body_en: Lang::En.render(&msg),
            suppressed_by,
        });
        break; // one per paragraph is enough
    }
    out
}

/// Demographics check: a population stated for a place that diverges sharply
/// from the modelled population.
fn check_population(text: &str, gaz: &Gazetteer, ledger: &MagicLedger, lang: Lang) -> Vec<Finding> {
    let mut out = Vec::new();
    for sentence in split_sentences(text) {
        let Some(claimed) = find_population(sentence, lang) else {
            continue;
        };
        let places: Vec<_> =
            gaz.mentioned_in_lang(sentence, lang).into_iter().filter(|p| p.population > 0).collect();
        // Only compare when exactly one place is in the sentence (otherwise the
        // number's owner is ambiguous — better to stay quiet than false-positive).
        if places.len() != 1 {
            continue;
        }
        let p = places[0];
        let modeled = p.population as f32;
        let ratio = claimed / modeled;
        if ratio <= 3.0 && ratio >= 0.33 {
            continue; // close enough
        }
        let msg = Msg::Population { place: &p.name, claimed: claimed as u64, modeled: p.population };
        let ctx = CheckContext { category: "demographics", region: Some(&p.name), ..Default::default() };
        let suppressed_by = ledger.find_suppressor(&ctx).map(|r| r.kind.clone());
        let severity = if suppressed_by.is_some() { "info" } else { "warning" };
        out.push(Finding {
            category: "demographics".into(),
            severity: severity.into(),
            body: lang.render(&msg),
            body_en: Lang::En.render(&msg),
            suppressed_by,
        });
    }
    out
}

fn split_sentences(text: &str) -> impl Iterator<Item = &str> {
    text.split(|c| c == '.' || c == '!' || c == '?' || c == '\n')
}

fn detect_weather(s: &str, lang: Lang) -> Option<Weather> {
    let l = s.to_lowercase();
    if lang.cold_weather().iter().any(|w| l.contains(&w.to_lowercase())) {
        Some(Weather::Cold)
    } else if lang.hot_weather().iter().any(|w| l.contains(&w.to_lowercase())) {
        Some(Weather::Hot)
    } else {
        None
    }
}

/// Whether `weather` contradicts a climate zone.
fn climate_conflict(zone: &str, weather: Weather) -> bool {
    let warm_zones = ["hot_desert", "savanna", "tropical_rainforest", "tropical_seasonal"];
    let cold_zones = ["tundra", "ice_cap", "taiga"];
    match weather {
        Weather::Cold => warm_zones.contains(&zone),
        Weather::Hot => cold_zones.contains(&zone),
    }
}

/// Extract a population figure ("100,000", "2 thousand", "1 million", and the
/// per-language multiplier words).
fn find_population(s: &str, lang: Lang) -> Option<f32> {
    let thousand = alternation(lang.thousand_words());
    let million = alternation(lang.million_words());
    let re = regex::Regex::new(&format!(
        r"(?i)(\d[\d,. ]*\d|\d)\s*({thousand}|{million})?"
    ))
    .ok()?;
    let mut best: Option<f32> = None;
    for caps in re.captures_iter(s) {
        let raw = caps.get(1)?.as_str().replace([',', ' '], "");
        let Ok(mut n) = raw.parse::<f32>() else { continue };
        if let Some(unit) = caps.get(2).map(|m| m.as_str().to_lowercase()) {
            if lang.thousand_words().iter().any(|w| w.to_lowercase() == unit) {
                n *= 1_000.0;
            } else if lang.million_words().iter().any(|w| w.to_lowercase() == unit) {
                n *= 1_000_000.0;
            }
        }
        // Only plausibly-population numbers (avoid catching years / small counts).
        if n >= 500.0 && best.map_or(true, |b| n > b) {
            best = Some(n);
        }
    }
    best
}

fn fmt_pop(n: u64) -> String {
    if n >= 1_000_000 {
        format!("{:.1}M", n as f64 / 1_000_000.0)
    } else if n >= 10_000 {
        format!("{:.0}k", n as f64 / 1_000.0)
    } else {
        n.to_string()
    }
}

/// Travel-time check: a sentence that states both a distance and a duration
/// implies a pace; flag paces that exceed pre-industrial overland travel.
fn check_travel_time(text: &str, ledger: &MagicLedger, roles: &[String], lang: Lang) -> Vec<Finding> {
    let mut out = Vec::new();
    for sentence in split_sentences(text) {
        let (Some(km), Some(days)) =
            (find_distance_km(sentence, lang), find_duration_days(sentence, lang))
        else {
            continue;
        };
        if days <= 0.0 || km <= 0.0 {
            continue;
        }
        let pace = km / days;
        // Baseline pre-industrial overland pace: ~25–40 km/day on foot, 50–80
        // mounted. Use a generous mounted median; flag clear outliers.
        let baseline = 65.0_f32;
        let ratio = pace / baseline;
        let (severity, severe) = if ratio > 2.5 {
            ("contradiction", true)
        } else if ratio > 1.5 {
            ("warning", false)
        } else {
            continue; // plausible
        };
        let msg = Msg::Travel { km, days, pace, severe };
        // Lazy magic consultation.
        let ctx = CheckContext { category: "travel_time", roles, ..Default::default() };
        let suppressed_by = ledger.find_suppressor(&ctx).map(|r| r.kind.clone());
        let severity = if suppressed_by.is_some() { "info" } else { severity };
        out.push(Finding {
            category: "travel_time".into(),
            severity: severity.into(),
            body: lang.render(&msg),
            body_en: Lang::En.render(&msg),
            suppressed_by,
        });
    }
    out
}

/// Emit a finding to the Output pane as a `fact_check_warning` (a no-op outside
/// the TUI). `source` is the paragraph the finding is about, so a re-check can
/// clear the paragraph's prior findings first. Suppressed findings carry the
/// rule note in their metadata.
pub fn emit_finding(f: &Finding, source: Option<uuid::Uuid>) {
    emit_finding_impl(f, source, false);
}

/// WORLD-5 — emit a timeline-derived finding (carries `timeline: true` so the
/// Output pane shows the 📅 marker).
pub fn emit_finding_timeline(f: &Finding, source: Option<uuid::Uuid>) {
    emit_finding_impl(f, source, true);
}

fn emit_finding_impl(f: &Finding, source: Option<uuid::Uuid>, timeline: bool) {
    use crate::pane::output::{kinds, Lifetime, Message, Severity};
    let severity = match f.severity.as_str() {
        "contradiction" => Severity::Contradiction,
        "warning" => Severity::Warning,
        _ => Severity::Info,
    };
    let text = match &f.suppressed_by {
        Some(rule) => format!("{} (consistent with magic rule `{rule}`)", f.body),
        None => f.body.clone(),
    };
    let mut msg = Message::new(
        kinds::FACT_CHECK_WARNING,
        severity,
        Lifetime::UntilActedOn,
        serde_json::json!({
            "text": text,
            "body_en": f.body_en,
            "category": f.category,
            "track": "fast",
            "timeline": timeline,
            "suppressed_by": f.suppressed_by,
        }),
    );
    if let Some(id) = source {
        msg = msg.with_source_paragraph(id);
    }
    crate::pane::output::emit(&msg);
}

/// Extract a distance from a sentence, normalised to km, using the language's
/// units (km / miles / leagues + their translations).
fn find_distance_km(s: &str, lang: Lang) -> Option<f32> {
    let groups = lang.distance_units();
    let all: Vec<&str> = groups.iter().flat_map(|(us, _)| us.iter().copied()).collect();
    let alt = alternation(&all);
    let re = regex::Regex::new(&format!(r"(?i)(\d+(?:[.,]\d+)?)\s*({alt})")).ok()?;
    let caps = re.captures(s)?;
    let n: f32 = caps.get(1)?.as_str().replace(',', ".").parse().ok()?;
    let unit = caps.get(2)?.as_str().to_lowercase();
    let factor = groups
        .iter()
        .find(|(us, _)| us.iter().any(|u| u.to_lowercase() == unit))
        .map(|(_, f)| *f)
        .unwrap_or(1.0);
    Some(n * factor)
}

/// Extract a duration from a sentence, normalised to days, using the language's
/// number words + day/week words.
fn find_duration_days(s: &str, lang: Lang) -> Option<f32> {
    let nums: Vec<&str> = lang.numbers().iter().map(|(w, _)| *w).collect();
    let num_alt = alternation(&nums);
    let day_week: Vec<&str> =
        lang.day_words().iter().chain(lang.week_words()).copied().collect();
    let unit_alt = alternation(&day_week);
    let re = regex::Regex::new(&format!(r"(?i)(\d+|{num_alt})\s+({unit_alt})")).ok()?;
    let caps = re.captures(s)?;
    let n = word_to_number(caps.get(1)?.as_str(), lang)?;
    let unit = caps.get(2)?.as_str().to_lowercase();
    let is_week = lang.week_words().iter().any(|w| w.to_lowercase() == unit);
    Some(if is_week { n * 7.0 } else { n })
}

/// Parse a digit string or a spelled-out number in the given language.
fn word_to_number(w: &str, lang: Lang) -> Option<f32> {
    if let Ok(n) = w.parse::<f32>() {
        return Some(n);
    }
    let lw = w.to_lowercase();
    lang.numbers().iter().find(|(word, _)| word.to_lowercase() == lw).map(|(_, n)| *n)
}

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

    fn empty_ledger() -> MagicLedger {
        MagicLedger::default()
    }

    #[test]
    fn flags_an_impossible_pace() {
        // 612 km in 3 days = 204 km/day → far exceeds → contradiction.
        let f = check_paragraph(
            "The messenger rode 612 km in three days to reach the capital.",
            &empty_ledger(),
            &[],
            None,
        );
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].category, "travel_time");
        assert_eq!(f[0].severity, "contradiction");
        assert!(f[0].suppressed_by.is_none());
    }

    #[test]
    fn passes_a_plausible_pace() {
        // 120 km in 3 days = 40 km/day → plausible → no finding.
        let f = check_paragraph("They walked 120 km in three days.", &empty_ledger(), &[], None);
        assert!(f.is_empty(), "got {f:?}");
    }

    #[test]
    fn miles_are_converted() {
        // 300 miles ≈ 483 km in 2 days ≈ 241 km/day → contradiction.
        let f = check_paragraph("She flew 300 miles in two days.", &empty_ledger(), &[], None);
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].severity, "contradiction");
    }

    #[test]
    fn magic_rule_suppresses_with_a_note() {
        let ledger: MagicLedger = serde_hjson::from_str(
            r#"{ enabled: true, rules: [ { kind: "messenger_birds", covers: ["travel_time"], applicable_to: { roles: ["any"] } } ] }"#,
        )
        .unwrap();
        let f = check_paragraph("The messenger rode 612 km in three days.", &ledger, &[], None);
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].severity, "info"); // downgraded
        assert_eq!(f[0].suppressed_by.as_deref(), Some("messenger_birds"));
    }

    fn gaz() -> Gazetteer {
        Gazetteer::new(vec![
            PlaceLink {
                place_id: uuid::Uuid::nil(),
                name: "Velmaril".into(),
                biome: "tropical_seasonal".into(),
                climate_zone: "tropical_seasonal".into(),
                hydrology_basis: "river_mouth".into(),
                population: 40_000,
                x: 60,
                y: 69,
            },
            PlaceLink {
                place_id: uuid::Uuid::nil(),
                name: "Korthun".into(),
                biome: "tundra".into(),
                climate_zone: "tundra".into(),
                hydrology_basis: "confluence".into(),
                population: 8_000,
                x: 42,
                y: 12,
            },
        ])
    }

    #[test]
    fn flags_snow_in_the_tropics() {
        let g = WorldContext::new(gaz(), vec![], vec![]);
        let f = check_paragraph("A blizzard buried Velmaril overnight.", &empty_ledger(), &[], Some(&g));
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].category, "climate");
        assert_eq!(f[0].severity, "warning");
        // No false positive for cold weather at the tundra town.
        let f2 = check_paragraph("A blizzard buried Korthun overnight.", &empty_ledger(), &[], Some(&g));
        assert!(f2.is_empty(), "got {f2:?}");
    }

    #[test]
    fn flags_a_population_mismatch() {
        let g = WorldContext::new(gaz(), vec![], vec![]);
        // Velmaril models ~40k; prose claims 2 million → off by 50× → warning.
        let f = check_paragraph("Velmaril, a teeming city of 2 million souls.", &empty_ledger(), &[], Some(&g));
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].category, "demographics");
        // A close figure passes.
        let f2 = check_paragraph("Velmaril, a city of 45,000.", &empty_ledger(), &[], Some(&g));
        assert!(f2.is_empty(), "got {f2:?}");
    }

    #[test]
    fn flags_a_resource_the_geology_lacks() {
        // World yields copper/gold/iron/coal; prose mines silver → warning.
        let ctx = WorldContext::new(
            Gazetteer::new(vec![]),
            vec![],
            vec!["copper".into(), "gold".into(), "iron".into(), "coal".into()],
        );
        let f = check_paragraph("The silver mines of the north ran deep.", &empty_ledger(), &[], Some(&ctx));
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].category, "economy");
        // A metal the world has, or no extraction context, passes.
        let f2 = check_paragraph("The copper mines of the north ran deep.", &empty_ledger(), &[], Some(&ctx));
        assert!(f2.is_empty(), "got {f2:?}");
        let f3 = check_paragraph("She wore a silver ring.", &empty_ledger(), &[], Some(&ctx));
        assert!(f3.is_empty(), "got {f3:?}");
    }

    #[test]
    fn gazetteer_matches_whole_words_only() {
        let g = gaz();
        // "Korthun" must not match inside another word.
        assert_eq!(g.mentioned_in("the Korthuns").len(), 0);
        assert_eq!(g.mentioned_in("near Korthun, north").len(), 1);
    }

    #[test]
    fn declared_geography_and_economy_feed_the_checker() {
        let body = r#"{
            name: "T"
            seed: 1
            astronomy: {
                star: { luminosity_solar: 1.0 }
                planet: { mass_earth: 1.0, radius_earth: 1.0, axial_tilt_deg: 23.4, day_length_hours: 24.0 }
                orbit: { semi_major_axis_au: 1.0 }
                calendar: { months: 12, month_length_days: 30 }
            }
            geology: { generated: { notable_minerals: ["iron", "Tin"] } }
            economy: { resources: ["petroleum", "iron"] }
            geography: {
                landmarks: [
                    { name: "Cairo", kind: "city", climate_zone: "hot_desert", population: 9000000 }
                ]
            }
        }"#;
        let def = crate::world::types::WorldDefinition::from_hjson(body).unwrap();
        // declared_minerals: deduped + lowercased union of geology + economy.
        let m = def.declared_minerals();
        assert!(m.contains(&"iron".to_string()));
        assert!(m.contains(&"tin".to_string()));
        assert!(m.contains(&"petroleum".to_string()));
        assert_eq!(m.iter().filter(|x| *x == "iron").count(), 1, "deduped");
        // declared_places: the landmark becomes a gazetteer entry.
        let places = declared_places(&def);
        assert_eq!(places.len(), 1);
        assert_eq!(places[0].name, "Cairo");
        assert_eq!(places[0].climate_zone, "hot_desert");
        // …and resolves a climate anomaly by name.
        let ctx = WorldContext::new(Gazetteer::new(places), vec![], def.declared_minerals());
        let f = check_paragraph("Snow fell on Cairo for three days.", &empty_ledger(), &[], Some(&ctx));
        assert!(f.iter().any(|f| f.category == "climate"), "got {f:?}");
    }

    #[test]
    fn timeline_dated_season_flags_anachronistic_weather() {
        use crate::world::timeline_context::{DateSource, TimelineContext};
        let summer = TimelineContext {
            paragraph_id: uuid::Uuid::nil(),
            linked_events: vec![uuid::Uuid::nil()],
            nearby_events: vec![],
            effective_date: Some(1000),
            date_source: DateSource::ExplicitLink(uuid::Uuid::nil()),
            effective_season: Some("summer".into()),
        };
        // Snow, but the timeline dates this in summer → contradiction.
        let f = check_timeline("Snow fell thick across the city.", &summer, &empty_ledger());
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].category, "climate");
        assert_eq!(f[0].severity, "contradiction");
        assert!(f[0].body.contains("summer"));

        // Heat in summer is consistent → nothing.
        let f2 = check_timeline("The sweltering heat pressed down.", &summer, &empty_ledger());
        assert!(f2.is_empty(), "got {f2:?}");

        // No effective season → no timeline finding.
        let none = TimelineContext::empty(uuid::Uuid::nil());
        assert!(check_timeline("Snow fell.", &none, &empty_ledger()).is_empty());
    }

    #[test]
    fn date_coherence_flags_festival_vs_dated_season() {
        use crate::world::timeline_context::{DateSource, TimelineContext};
        let winter = TimelineContext {
            paragraph_id: uuid::Uuid::nil(),
            linked_events: vec![uuid::Uuid::nil()],
            nearby_events: vec![],
            effective_date: Some(1),
            date_source: DateSource::ExplicitLink(uuid::Uuid::nil()),
            effective_season: Some("winter".into()),
        };
        // A midsummer feast, but the timeline dates this in winter → warning.
        let f = check_date_coherence("The midsummer feast filled the hall.", &winter, &empty_ledger());
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].category, "date_coherence");
        assert_eq!(f[0].severity, "warning");
        assert!(f[0].body.contains("winter"));

        // A harvest in autumn-dated prose is consistent → nothing.
        let autumn = TimelineContext { effective_season: Some("autumn".into()), ..winter.clone() };
        assert!(check_date_coherence("After the harvest, they rested.", &autumn, &empty_ledger()).is_empty());

        // A custom-named season can't be compared → nothing.
        let custom = TimelineContext { effective_season: Some("Frostmoon".into()), ..winter.clone() };
        assert!(check_date_coherence("The midsummer feast.", &custom, &empty_ledger()).is_empty());
    }

    #[test]
    fn date_coherence_detects_hints_in_five_languages() {
        use crate::world::timeline_context::{DateSource, TimelineContext};
        let winter = TimelineContext {
            paragraph_id: uuid::Uuid::nil(),
            linked_events: vec![uuid::Uuid::nil()],
            nearby_events: vec![],
            effective_date: Some(1),
            date_source: DateSource::ExplicitLink(uuid::Uuid::nil()),
            effective_season: Some("winter".into()),
        };
        // A high-summer marker in winter-dated prose, in each language.
        let cases = [
            "In high summer they feasted, and the wine flowed with song through the night.",
            "В разгар лета они праздновали, и пели песни в большом зале города.",
            "En pleno verano celebraban la fiesta, con vino y con canciones para todos.",
            "En plein été, ils célébraient la fête, avec du vin et sans fin dans la nuit.",
            "Im Hochsommer feierten sie das Fest, und durch die Nacht ohne Ende mit Wein.",
        ];
        for c in cases {
            let f = check_date_coherence(c, &winter, &empty_ledger());
            assert_eq!(f.len(), 1, "expected a summer-in-winter finding for: {c} (got {f:?})");
            assert_eq!(f[0].category, "date_coherence");
        }
    }

    #[test]
    fn travel_timeline_flags_prose_vs_event_gap() {
        use crate::world::timeline_context::{build_context, TlEvent};
        use crate::world::timeline_context::TimelineContext;
        let mara = uuid::Uuid::new_v4();
        let velmaril = uuid::Uuid::new_v4();
        let korthun = uuid::Uuid::new_v4();
        let para = uuid::Uuid::new_v4();
        // Departure at day 0 (Velmaril), arrival at day 35 (Korthun, linked to para).
        let depart = TlEvent {
            id: uuid::Uuid::new_v4(),
            title: "Departure from Velmaril".into(),
            start_ticks: 0,
            end_ticks: None,
            linked_paragraphs: vec![],
            characters: vec![mara],
            places: vec![velmaril],
        };
        let arrive = TlEvent {
            id: uuid::Uuid::new_v4(),
            title: "Arrival in Korthun".into(),
            start_ticks: 35,
            end_ticks: None,
            linked_paragraphs: vec![para],
            characters: vec![mara],
            places: vec![korthun],
        };
        let events = vec![depart, arrive];
        // ticks_per_day = 1 here, so the gap is 35 days.
        let ctx: TimelineContext = {
            // build_context needs a calendar; reuse one with day=1.
            use crate::timeline::calendar::{Calendar, CalendarConfig};
            let cal = Calendar::from_config(CalendarConfig { preset: "gregorian".into(), ..Default::default() });
            // gregorian day ticks: build_context only uses season; the travel check
            // is given ticks_per_day=1 below regardless.
            build_context(para, &events, &cal, 1000)
        };
        // Prose says three days, timeline says 35 → flag.
        let f = check_travel_timeline("Mara rode three days to Korthun.", &ctx, &events, 1, &empty_ledger());
        assert_eq!(f.len(), 1, "got {f:?}");
        assert_eq!(f[0].category, "travel_time");
        assert_eq!(f[0].severity, "warning");
        assert!(f[0].body.contains("Korthun"));

        // Prose "thirty days" is within 3× of 35 → no finding.
        let f2 = check_travel_timeline("Mara rode thirty days to Korthun.", &ctx, &events, 1, &empty_ledger());
        assert!(f2.is_empty(), "got {f2:?}");
    }

    #[test]
    fn timeline_season_respects_magic_ledger() {
        use crate::world::timeline_context::{DateSource, TimelineContext};
        use crate::world::types::magic::{Applicability, MagicLedger, MagicRule};
        let winter = TimelineContext {
            paragraph_id: uuid::Uuid::nil(),
            linked_events: vec![uuid::Uuid::nil()],
            nearby_events: vec![],
            effective_date: Some(1),
            date_source: DateSource::ExplicitLink(uuid::Uuid::nil()),
            effective_season: Some("winter".into()),
        };
        let ledger = MagicLedger {
            enabled: true,
            rules: vec![MagicRule {
                kind: "weather_control".into(),
                covers: vec!["climate_anomaly".into()],
                description: "The conclave bends the seasons".into(),
                applicable_to: Applicability::default(),
                parameters: Default::default(),
            }],
        };
        // Tropical heat in winter, but a covering magic rule → suppressed (info).
        let f = check_timeline("Tropical heat lay over the valley.", &winter, &ledger);
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].severity, "info");
        assert_eq!(f[0].suppressed_by.as_deref(), Some("weather_control"));
    }

    #[test]
    fn gazetteer_resolves_declined_russian_name() {
        let g = Gazetteer::new(vec![PlaceLink {
            place_id: uuid::Uuid::nil(),
            name: "Москва".into(),
            biome: "temperate_forest".into(),
            climate_zone: "temperate_forest".into(),
            hydrology_basis: "river".into(),
            population: 50_000,
            x: 10,
            y: 10,
        }]);
        // Prepositional / genitive forms resolve under Russian; English does not.
        assert_eq!(g.mentioned_in_lang("дорога вела в Москве", Lang::Ru).len(), 1);
        assert_eq!(g.mentioned_in_lang("к северу от Москвы", Lang::Ru).len(), 1);
        assert_eq!(g.mentioned_in_lang("дорога вела в Москве", Lang::En).len(), 0);
    }

    #[test]
    fn per_language_extractors() {
        // Distance + duration + weather + population in the four non-English
        // baselines, exercising the vocab directly (not via detection).
        assert_eq!(find_distance_km("600 км", Lang::Ru), Some(600.0));
        assert_eq!(find_duration_days("три дня", Lang::Ru), Some(3.0));
        assert!(detect_weather("на город опустился снег", Lang::Ru).is_some());

        assert_eq!(find_duration_days("tres días", Lang::Es), Some(3.0));
        assert_eq!(find_duration_days("trois jours", Lang::Fr), Some(3.0));

        // 300 Meilen ≈ 483 km; "drei Tage" = 3; "2 Millionen" = 2,000,000.
        assert!((find_distance_km("300 Meilen", Lang::De).unwrap() - 482.7).abs() < 1.0);
        assert_eq!(find_duration_days("drei Tage", Lang::De), Some(3.0));
        assert!((find_population("2 Millionen", Lang::De).unwrap() - 2_000_000.0).abs() < 1.0);
    }

    #[test]
    fn russian_travel_time_flags() {
        // A full Russian paragraph: 600 km in three days → flagged.
        let f = check_paragraph(
            "Гонец проскакал 600 км за три дня без отдыха, чтобы доставить королевский приказ.",
            &empty_ledger(),
            &[],
            None,
        );
        assert_eq!(f.len(), 1, "got {f:?}");
        assert_eq!(f[0].category, "travel_time");
    }

    #[test]
    fn flags_wrong_moon_count() {
        let ctx = WorldContext::new(gaz(), vec!["Korthana".into(), "Eldra".into()], vec![]);
        // World has 2 moons; prose says three.
        let f = check_paragraph("All three moons hung over the bay.", &empty_ledger(), &[], Some(&ctx));
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].category, "astronomy");
        assert_eq!(f[0].severity, "warning");
        // "both moons" agrees with two → no finding.
        let f2 = check_paragraph("Both moons were full.", &empty_ledger(), &[], Some(&ctx));
        assert!(f2.is_empty(), "got {f2:?}");
    }
}
