//! Value production: turn a [`Generator`] + a seeded RNG into a
//! [`Value`] (ADR-0048 D8/D9). Realistic generators come from the
//! `fake` crate (English locale); `product` is hand-rolled (D9, no
//! commerce module exists); dates are generated against a **fixed
//! reference epoch** so a `--seed` run is fully reproducible without
//! depending on the wall clock (D8 bounded windows).
//!
//! The stateful markers ([`Generator::IdentitySequential`],
//! [`Generator::ForeignKeySample`]) are resolved by the executor with
//! database context; if one reaches here un-intercepted it falls back
//! to type-based generation rather than panicking.

use chrono::{Datelike, NaiveDate};
use fake::Fake;
use rand::RngExt;

use crate::dsl::types::Type;
use crate::dsl::value::Value;
use crate::seed::{Generator, SeedRng};

/// Fixed anchor for bounded date/datetime windows. Using a constant
/// (rather than `now()`) keeps `--seed` output reproducible across days
/// and makes tests deterministic. It advances with releases.
const REF_YEAR: i32 = 2025;
const REF_MONTH: u32 = 6;
const REF_DAY: u32 = 1;

/// `~3 years` window for "recent" dates, in days.
const RECENT_WINDOW_DAYS: i64 = 3 * 365;
/// Adult birth window (≈18–80 years ago), in days.
const ADULT_MIN_DAYS: i64 = 18 * 365;
const ADULT_MAX_DAYS: i64 = 80 * 365;

/// Year windows for the `int`-typed year heuristics (issue #33),
/// expressed relative to [`REF_YEAR`] so they advance with releases —
/// the year siblings of the `DateRecent` / `DateAdult` windows above.
/// `YearRecent` spans ~75 years (1950–2025 at REF_YEAR=2025), wide
/// enough for `published` / `founded` / `release_year`; `YearBirth`
/// mirrors the adult birth window (1945–2007).
const YEAR_RECENT_SPAN: i32 = 75;
const YEAR_BIRTH_MIN_AGE: i32 = 18;
const YEAR_BIRTH_MAX_AGE: i32 = 80;

/// Produce one value for `generator` against destination type `ty`.
#[must_use]
pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Value {
    use fake::faker::address::en as addr;
    use fake::faker::company::en as company;
    use fake::faker::internet::en as net;
    use fake::faker::job::en as job;
    use fake::faker::lorem::en as lorem;
    use fake::faker::name::en as name;
    use fake::faker::phone_number::en as phone;

    match generator {
        Generator::FirstName => Value::Text(name::FirstName().fake_with_rng(rng)),
        Generator::LastName => Value::Text(name::LastName().fake_with_rng(rng)),
        Generator::FullName => Value::Text(name::Name().fake_with_rng(rng)),
        Generator::Email => Value::Text(net::FreeEmail().fake_with_rng(rng)),
        Generator::Username => Value::Text(net::Username().fake_with_rng(rng)),
        Generator::Password => Value::Text(net::Password(8..16).fake_with_rng(rng)),
        Generator::Phone => Value::Text(phone::PhoneNumber().fake_with_rng(rng)),
        Generator::City => Value::Text(addr::CityName().fake_with_rng(rng)),
        Generator::Country => Value::Text(addr::CountryName().fake_with_rng(rng)),
        Generator::StateName => Value::Text(addr::StateName().fake_with_rng(rng)),
        Generator::Street => Value::Text(addr::StreetName().fake_with_rng(rng)),
        Generator::ZipCode => Value::Text(addr::ZipCode().fake_with_rng(rng)),
        Generator::Company => Value::Text(company::CompanyName().fake_with_rng(rng)),
        Generator::JobTitle => Value::Text(job::Title().fake_with_rng(rng)),
        Generator::ProductName => Value::Text(product_name(rng)),
        Generator::Sentence => Value::Text(lorem::Sentence(5..12).fake_with_rng(rng)),
        Generator::Paragraph => Value::Text(lorem::Paragraph(2..4).fake_with_rng(rng)),
        Generator::Url => {
            let word: String = lorem::Word().fake_with_rng(rng);
            let suffix: String = net::DomainSuffix().fake_with_rng(rng);
            Value::Text(format!("https://{word}.{suffix}"))
        }
        // Hand-rolled — `fake`'s color module is feature-gated (it pulls
        // an extra crate); a hex colour is trivial from the RNG.
        Generator::HexColor => Value::Text(format!("#{:06X}", rng.random_range(0..0x0100_0000))),
        Generator::CurrencyAmount => currency_amount(ty, rng),
        Generator::Age => Value::Number(rng.random_range(18..=80).to_string()),
        Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()),
        Generator::YearRecent => Value::Number(
            rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR)
                .to_string(),
        ),
        Generator::YearBirth => Value::Number(
            rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE))
                .to_string(),
        ),
        Generator::DateRecent => {
            Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS)))
        }
        Generator::DateAdult => Value::Text(format_date(random_past_date(
            rng,
            ADULT_MIN_DAYS,
            ADULT_MAX_DAYS,
        ))),
        Generator::DateTimeRecent => Value::Text(random_recent_datetime(rng)),
        Generator::Boolean => Value::Bool(rng.random_range(0..2) == 1),
        Generator::PickFrom(values) if !values.is_empty() => {
            let chosen: &String = pick(rng, values);
            literal_to_value(chosen, ty)
        }
        // The `set <col> between low and high` override (D2). Bounds are
        // interpreted per the destination type; the executor has already
        // validated they parse, so a defensive parse failure here falls
        // back to type-based generation rather than producing junk.
        Generator::Range { low, high } => range_value(low, high, ty, rng),
        // Un-intercepted markers + an empty pick list → type-based.
        Generator::PickFrom(_)
        | Generator::IdentitySequential
        | Generator::ForeignKeySample
        | Generator::Generic => generic_for_type(ty, rng),
    }
}

/// Uniform value in `[low, high]` for the `between` override (D2).
///
/// Bounds are interpreted by destination type. Returns the type-based
/// fallback for a bound that does not parse or a type that has no range
/// meaning — the executor pre-validates, so this is defensive only.
fn range_value(low: &str, high: &str, ty: Type, rng: &mut SeedRng) -> Value {
    match ty {
        Type::Int | Type::Serial => parse_int_range(low, high)
            .map(|(lo, hi)| Value::Number(rng.random_range(lo..=hi).to_string()))
            .unwrap_or_else(|| generic_for_type(ty, rng)),
        Type::Real | Type::Decimal => parse_real_range(low, high)
            .map(|(lo, hi)| {
                let v = rng.random::<f64>().mul_add(hi - lo, lo);
                Value::Number(format!("{v:.2}"))
            })
            .unwrap_or_else(|| generic_for_type(ty, rng)),
        Type::Date => parse_date_range(low, high)
            .map(|(lo, hi)| Value::Text(format_date(random_date_between(rng, lo, hi))))
            .unwrap_or_else(|| generic_for_type(ty, rng)),
        Type::DateTime => parse_datetime_range(low, high)
            .map(|(lo, hi)| Value::Text(random_datetime_between(rng, lo, hi)))
            .unwrap_or_else(|| generic_for_type(ty, rng)),
        // text / bool / shortid have no range meaning.
        _ => generic_for_type(ty, rng),
    }
}

/// Validate that `low`/`high` parse as bounds for `ty`.
///
/// The `between` override (D2) is checked by the executor *before*
/// generation. Returns a short human reason on failure (the executor
/// wraps it in a friendly error naming the column), `None` when valid.
#[must_use]
pub fn range_bounds_reason(ty: Type, low: &str, high: &str) -> Option<String> {
    let ok = match ty {
        Type::Int | Type::Serial => parse_int_range(low, high).is_some(),
        Type::Real | Type::Decimal => parse_real_range(low, high).is_some(),
        Type::Date => parse_date_range(low, high).is_some(),
        Type::DateTime => parse_datetime_range(low, high).is_some(),
        // text / bool / shortid have no range meaning.
        Type::Text | Type::Bool | Type::ShortId => false,
    };
    if ok {
        return None;
    }
    Some(match ty {
        Type::Int | Type::Serial => "expected two whole numbers, e.g. `between 1 and 100`".to_string(),
        Type::Real | Type::Decimal => "expected two numbers, e.g. `between 1.0 and 9.99`".to_string(),
        Type::Date => "expected two quoted dates, e.g. `between '2023-01-01' and '2024-12-31'`".to_string(),
        Type::DateTime => {
            "expected two quoted datetimes, e.g. `between '2023-01-01T00:00:00' and '2024-12-31T23:59:59'`"
                .to_string()
        }
        Type::Text | Type::Bool | Type::ShortId => {
            "a `between` range only applies to numeric and date/datetime columns".to_string()
        }
    })
}

/// Parse and order an integer range; `None` if either bound is not an
/// integer.
fn parse_int_range(low: &str, high: &str) -> Option<(i64, i64)> {
    let lo: i64 = low.trim().parse().ok()?;
    let hi: i64 = high.trim().parse().ok()?;
    Some(if lo <= hi { (lo, hi) } else { (hi, lo) })
}

fn parse_real_range(low: &str, high: &str) -> Option<(f64, f64)> {
    let lo: f64 = low.trim().parse().ok()?;
    let hi: f64 = high.trim().parse().ok()?;
    if !lo.is_finite() || !hi.is_finite() {
        return None;
    }
    Some(if lo <= hi { (lo, hi) } else { (hi, lo) })
}

fn parse_date_range(low: &str, high: &str) -> Option<(NaiveDate, NaiveDate)> {
    let lo = NaiveDate::parse_from_str(low.trim(), "%Y-%m-%d").ok()?;
    let hi = NaiveDate::parse_from_str(high.trim(), "%Y-%m-%d").ok()?;
    Some(if lo <= hi { (lo, hi) } else { (hi, lo) })
}

/// Accept both the `T`-separated and space-separated datetime spellings
/// the app validates (`bind_datetime` / `validate_datetime`).
fn parse_one_datetime(s: &str) -> Option<chrono::NaiveDateTime> {
    let t = s.trim();
    chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S")
        .or_else(|_| chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%d %H:%M:%S"))
        .ok()
}

fn parse_datetime_range(
    low: &str,
    high: &str,
) -> Option<(chrono::NaiveDateTime, chrono::NaiveDateTime)> {
    let lo = parse_one_datetime(low)?;
    let hi = parse_one_datetime(high)?;
    Some(if lo <= hi { (lo, hi) } else { (hi, lo) })
}

/// Uniform date in `[lo, hi]` (inclusive).
fn random_date_between(rng: &mut SeedRng, lo: NaiveDate, hi: NaiveDate) -> NaiveDate {
    let lo_ce = lo.num_days_from_ce();
    let hi_ce = hi.num_days_from_ce();
    let day = rng.random_range(lo_ce..=hi_ce);
    NaiveDate::from_num_days_from_ce_opt(day).unwrap_or(lo)
}

/// Uniform datetime in `[lo, hi]`, rendered `YYYY-MM-DDTHH:MM:SS`.
fn random_datetime_between(
    rng: &mut SeedRng,
    lo: chrono::NaiveDateTime,
    hi: chrono::NaiveDateTime,
) -> String {
    let lo_s = lo.and_utc().timestamp();
    let hi_s = hi.and_utc().timestamp();
    let secs = if lo_s <= hi_s {
        rng.random_range(lo_s..=hi_s)
    } else {
        rng.random_range(hi_s..=lo_s)
    };
    let dt = chrono::DateTime::from_timestamp(secs, 0).map_or(lo, |d| d.naive_utc());
    dt.format("%Y-%m-%dT%H:%M:%S").to_string()
}

/// Type-based fallback generation (D8). Never produces NULL for a
/// generatable type; `serial`/`shortid` are handled by the executor
/// (autogen) and yield NULL here only as a last resort.
fn generic_for_type(ty: Type, rng: &mut SeedRng) -> Value {
    use fake::faker::lorem::en as lorem;
    match ty {
        Type::Text => {
            let words: Vec<String> = lorem::Words(2..4).fake_with_rng(rng);
            Value::Text(words.join(" "))
        }
        Type::ShortId => Value::Text(crate::dsl::shortid::generate_with_rng(rng)),
        Type::Int => Value::Number(rng.random_range(1..=10_000).to_string()),
        Type::Serial => Value::Number(rng.random_range(1..=10_000).to_string()),
        Type::Real => {
            let n: f64 = rng.random_range(0..100_000) as f64 / 100.0;
            Value::Number(format!("{n:.2}"))
        }
        Type::Decimal => {
            let dollars = rng.random_range(0..10_000);
            let cents = rng.random_range(0..100);
            Value::Number(format!("{dollars}.{cents:02}"))
        }
        Type::Bool => Value::Bool(rng.random_range(0..2) == 1),
        Type::Date => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
        Type::DateTime => Value::Text(random_recent_datetime(rng)),
    }
}

/// Wrap a fixed-list literal as the right `Value` shape for `ty` (used
/// by `PickFrom` — enum / `IN`-CHECK values).
fn literal_to_value(s: &str, ty: Type) -> Value {
    match ty {
        Type::Int | Type::Serial | Type::Real | Type::Decimal => Value::Number(s.to_string()),
        Type::Bool => Value::Bool(matches!(s.to_ascii_lowercase().as_str(), "true" | "1")),
        _ => Value::Text(s.to_string()),
    }
}

/// A money-shaped amount: whole for `int`/`serial`, two-decimal for the
/// fractional numeric types.
fn currency_amount(ty: Type, rng: &mut SeedRng) -> Value {
    match ty {
        Type::Real | Type::Decimal => {
            let dollars = rng.random_range(1..=1_000);
            let cents = rng.random_range(0..100);
            Value::Number(format!("{dollars}.{cents:02}"))
        }
        // int / serial / anything else numeric → whole amount.
        _ => Value::Number(rng.random_range(1..=1_000).to_string()),
    }
}

// — the hand-rolled `product` generator (D9) —

const PRODUCT_ADJECTIVES: &[&str] = &[
    "Sleek",
    "Rustic",
    "Ergonomic",
    "Handcrafted",
    "Refined",
    "Modern",
    "Vintage",
    "Compact",
    "Premium",
    "Lightweight",
    "Durable",
    "Elegant",
    "Sturdy",
    "Smooth",
    "Gorgeous",
    "Intelligent",
    "Practical",
    "Awesome",
    "Incredible",
    "Recycled",
];
const PRODUCT_MATERIALS: &[&str] = &[
    "Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo", "Plastic", "Ceramic",
    "Glass", "Concrete", "Rubber", "Bronze", "Marble", "Linen", "Silk", "Aluminum", "Wool", "Gold",
    "Carbon",
];
const PRODUCT_NOUNS: &[&str] = &[
    "Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug", "Shoes", "Jacket", "Watch",
    "Wallet", "Bench", "Hat", "Gloves", "Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket",
];

fn product_name(rng: &mut SeedRng) -> String {
    format!(
        "{} {} {}",
        pick(rng, PRODUCT_ADJECTIVES),
        pick(rng, PRODUCT_MATERIALS),
        pick(rng, PRODUCT_NOUNS),
    )
}

// — bounded dates (D8) —

const fn reference_date() -> NaiveDate {
    match NaiveDate::from_ymd_opt(REF_YEAR, REF_MONTH, REF_DAY) {
        Some(d) => d,
        None => panic!("reference date constants must be valid"),
    }
}

/// A date between `min_days_ago` and `max_days_ago` before the
/// reference epoch (inclusive).
fn random_past_date(rng: &mut SeedRng, min_days_ago: i64, max_days_ago: i64) -> NaiveDate {
    let days_ago = rng.random_range(min_days_ago..=max_days_ago);
    let ce = reference_date().num_days_from_ce();
    let target = ce - i32::try_from(days_ago).unwrap_or(0);
    NaiveDate::from_num_days_from_ce_opt(target).unwrap_or_else(reference_date)
}

fn format_date(date: NaiveDate) -> String {
    date.format("%Y-%m-%d").to_string()
}

/// A recent datetime: a recent date plus a random time-of-day, rendered
/// as `YYYY-MM-DDTHH:MM:SS`.
fn random_recent_datetime(rng: &mut SeedRng) -> String {
    let date = random_past_date(rng, 0, RECENT_WINDOW_DAYS);
    let h = rng.random_range(0..24);
    let m = rng.random_range(0..60);
    let s = rng.random_range(0..60);
    format!("{}T{h:02}:{m:02}:{s:02}", format_date(date))
}

/// Pick a uniformly random element from a non-empty slice.
fn pick<'a, T>(rng: &mut SeedRng, items: &'a [T]) -> &'a T {
    &items[rng.random_range(0..items.len())]
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::seed::make_rng;
    use pretty_assertions::assert_eq;

    fn gen_once(generator: &Generator, ty: Type, seed: u64) -> Value {
        let mut rng = make_rng(Some(seed));
        generate_value(generator, ty, &mut rng)
    }

    #[test]
    fn generation_is_deterministic_for_a_fixed_seed() {
        for generator in [
            Generator::FullName,
            Generator::Email,
            Generator::ProductName,
            Generator::DateRecent,
            Generator::CurrencyAmount,
        ] {
            let a = gen_once(&generator, Type::Text, 7);
            let b = gen_once(&generator, Type::Text, 7);
            assert_eq!(a, b, "{generator:?} must reproduce for a fixed seed");
        }
    }

    #[test]
    fn text_generators_produce_nonempty_text() {
        for generator in [
            Generator::FirstName,
            Generator::LastName,
            Generator::FullName,
            Generator::Email,
            Generator::Username,
            Generator::Company,
            Generator::City,
            Generator::ProductName,
        ] {
            let v = gen_once(&generator, Type::Text, 3);
            match v {
                Value::Text(s) => {
                    assert!(!s.trim().is_empty(), "{generator:?} produced empty text")
                }
                other => panic!("{generator:?} produced non-text {other:?}"),
            }
        }
    }

    #[test]
    fn email_looks_like_an_email() {
        let v = gen_once(&Generator::Email, Type::Text, 11);
        let Value::Text(s) = v else {
            panic!("not text")
        };
        assert!(s.contains('@'), "email should contain @: {s}");
    }

    #[test]
    fn product_name_is_three_capitalised_words() {
        let v = gen_once(&Generator::ProductName, Type::Text, 99);
        let Value::Text(s) = v else {
            panic!("not text")
        };
        let words: Vec<&str> = s.split(' ').collect();
        assert_eq!(words.len(), 3, "product name should be 3 words: {s}");
        for w in words {
            assert!(
                w.chars().next().unwrap().is_ascii_uppercase(),
                "word `{w}` not capitalised"
            );
        }
    }

    #[test]
    fn recent_dates_fall_within_the_bounded_window() {
        let mut rng = make_rng(Some(1));
        let earliest = reference_date()
            .checked_sub_days(chrono::Days::new(RECENT_WINDOW_DAYS as u64))
            .unwrap();
        let latest = reference_date();
        for _ in 0..200 {
            let v = generate_value(&Generator::DateRecent, Type::Date, &mut rng);
            let Value::Text(s) = v else {
                panic!("date not text")
            };
            let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
            assert!(
                d >= earliest && d <= latest,
                "date {d} outside recent window"
            );
        }
    }

    #[test]
    fn dob_dates_fall_within_the_adult_window() {
        let mut rng = make_rng(Some(2));
        let earliest = reference_date()
            .checked_sub_days(chrono::Days::new(ADULT_MAX_DAYS as u64))
            .unwrap();
        let latest = reference_date()
            .checked_sub_days(chrono::Days::new(ADULT_MIN_DAYS as u64))
            .unwrap();
        for _ in 0..200 {
            let v = generate_value(&Generator::DateAdult, Type::Date, &mut rng);
            let Value::Text(s) = v else {
                panic!("date not text")
            };
            let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
            assert!(d >= earliest && d <= latest, "dob {d} outside adult window");
        }
    }

    #[test]
    fn datetime_is_iso_shaped() {
        let v = gen_once(&Generator::DateTimeRecent, Type::DateTime, 5);
        let Value::Text(s) = v else {
            panic!("not text")
        };
        assert!(s.contains('T'), "datetime needs a T separator: {s}");
        // Parses as a naive datetime.
        chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S")
            .unwrap_or_else(|e| panic!("invalid datetime {s}: {e}"));
    }

    #[test]
    fn currency_is_whole_for_int_and_fractional_for_decimal() {
        let Value::Number(int_amt) = gen_once(&Generator::CurrencyAmount, Type::Int, 4) else {
            panic!("not a number")
        };
        assert!(
            !int_amt.contains('.'),
            "int currency should be whole: {int_amt}"
        );
        let Value::Number(dec_amt) = gen_once(&Generator::CurrencyAmount, Type::Decimal, 4) else {
            panic!("not a number")
        };
        assert!(
            dec_amt.contains('.'),
            "decimal currency should have cents: {dec_amt}"
        );
    }

    #[test]
    fn age_is_in_human_range() {
        let mut rng = make_rng(Some(8));
        for _ in 0..100 {
            let Value::Number(a) = generate_value(&Generator::Age, Type::Int, &mut rng) else {
                panic!("age not a number")
            };
            let n: i64 = a.parse().unwrap();
            assert!((18..=80).contains(&n), "age {n} out of range");
        }
    }

    #[test]
    fn pick_from_chooses_a_listed_value() {
        let generator = Generator::PickFrom(vec!["active".into(), "closed".into()]);
        let mut rng = make_rng(Some(6));
        for _ in 0..50 {
            let Value::Text(s) = generate_value(&generator, Type::Text, &mut rng) else {
                panic!("not text")
            };
            assert!(
                matches!(s.as_str(), "active" | "closed"),
                "unexpected pick {s}"
            );
        }
    }

    #[test]
    fn pick_from_wraps_numeric_values_as_numbers() {
        let generator = Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]);
        let mut rng = make_rng(Some(6));
        let v = generate_value(&generator, Type::Int, &mut rng);
        assert!(
            matches!(v, Value::Number(_)),
            "numeric pick should be a Number: {v:?}"
        );
    }

    #[test]
    fn year_generators_stay_within_their_bounded_windows() {
        // Issue #33: both year generators emit a plain `int` inside a
        // bounded, plausible window — never the unbounded-int nonsense.
        let mut rng = make_rng(Some(7));
        for _ in 0..300 {
            let Value::Number(s) = generate_value(&Generator::YearRecent, Type::Int, &mut rng)
            else {
                panic!("YearRecent must be a Number")
            };
            let n: i32 = s.parse().unwrap();
            assert!(
                (1950..=2025).contains(&n),
                "YearRecent {n} out of [1950,2025]"
            );
        }
        for _ in 0..300 {
            let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng)
            else {
                panic!("YearBirth must be a Number")
            };
            let n: i32 = s.parse().unwrap();
            assert!(
                (1945..=2007).contains(&n),
                "YearBirth {n} out of [1945,2007]"
            );
        }
    }

    #[test]
    fn year_generators_are_deterministic_for_a_fixed_seed() {
        assert_eq!(
            gen_once(&Generator::YearRecent, Type::Int, 42),
            gen_once(&Generator::YearRecent, Type::Int, 42),
        );
        assert_eq!(
            gen_once(&Generator::YearBirth, Type::Int, 42),
            gen_once(&Generator::YearBirth, Type::Int, 42),
        );
    }

    #[test]
    fn int_range_stays_within_inclusive_bounds() {
        let g = Generator::Range {
            low: "10".into(),
            high: "20".into(),
        };
        let mut rng = make_rng(Some(5));
        for _ in 0..200 {
            let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
                panic!("int range should be a number")
            };
            let n: i64 = s.parse().unwrap();
            assert!((10..=20).contains(&n), "int {n} out of [10,20]");
        }
    }

    #[test]
    fn real_range_stays_within_bounds_and_has_cents() {
        let g = Generator::Range {
            low: "1.0".into(),
            high: "9.0".into(),
        };
        let mut rng = make_rng(Some(5));
        for _ in 0..200 {
            let Value::Number(s) = generate_value(&g, Type::Real, &mut rng) else {
                panic!("real range should be a number")
            };
            let n: f64 = s.parse().unwrap();
            assert!((1.0..=9.0).contains(&n), "real {n} out of [1,9]");
            assert!(s.contains('.'), "real should be formatted with cents: {s}");
        }
    }

    #[test]
    fn date_range_stays_within_quoted_bounds() {
        let g = Generator::Range {
            low: "2023-01-01".into(),
            high: "2023-12-31".into(),
        };
        let lo = NaiveDate::parse_from_str("2023-01-01", "%Y-%m-%d").unwrap();
        let hi = NaiveDate::parse_from_str("2023-12-31", "%Y-%m-%d").unwrap();
        let mut rng = make_rng(Some(9));
        for _ in 0..200 {
            let Value::Text(s) = generate_value(&g, Type::Date, &mut rng) else {
                panic!("date range should be text")
            };
            let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid date");
            assert!(d >= lo && d <= hi, "date {d} out of range");
        }
    }

    #[test]
    fn reversed_bounds_are_tolerated() {
        let g = Generator::Range {
            low: "20".into(),
            high: "10".into(),
        };
        let mut rng = make_rng(Some(1));
        let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
            panic!("number")
        };
        let n: i64 = s.parse().unwrap();
        assert!(
            (10..=20).contains(&n),
            "reversed bounds still produce in-range: {n}"
        );
    }

    #[test]
    fn range_bounds_reason_accepts_compatible_and_rejects_incompatible() {
        // Numeric / date / datetime accept; text / bool reject.
        assert!(range_bounds_reason(Type::Int, "1", "10").is_none());
        assert!(range_bounds_reason(Type::Real, "1.5", "9.9").is_none());
        assert!(range_bounds_reason(Type::Date, "2023-01-01", "2024-01-01").is_none());
        assert!(
            range_bounds_reason(Type::DateTime, "2023-01-01T00:00:00", "2024-01-01T00:00:00")
                .is_none()
        );
        // Non-numeric bound on a numeric column.
        assert!(range_bounds_reason(Type::Int, "abc", "10").is_some());
        // A range on a text column is meaningless.
        assert!(range_bounds_reason(Type::Text, "a", "z").is_some());
        assert!(range_bounds_reason(Type::Bool, "0", "1").is_some());
    }

    #[test]
    fn markers_fall_back_to_type_based_generation() {
        // An un-intercepted marker must not panic; it generates by type.
        let v = gen_once(&Generator::IdentitySequential, Type::Text, 1);
        assert!(matches!(v, Value::Text(_)));
        let v = gen_once(&Generator::ForeignKeySample, Type::Int, 1);
        assert!(matches!(v, Value::Number(_)));
    }

    #[test]
    fn generic_fallback_matches_each_type() {
        let mut rng = make_rng(Some(0));
        assert!(matches!(
            generate_value(&Generator::Generic, Type::Text, &mut rng),
            Value::Text(_)
        ));
        assert!(matches!(
            generate_value(&Generator::Generic, Type::Int, &mut rng),
            Value::Number(_)
        ));
        assert!(matches!(
            generate_value(&Generator::Generic, Type::Bool, &mut rng),
            Value::Bool(_)
        ));
        // shortid fallback is a valid base58 id.
        let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else {
            panic!("shortid not text")
        };
        assert!(
            crate::dsl::shortid::validate(&sid).is_ok(),
            "invalid shortid {sid}"
        );
    }
}
