//! Generator selection: the name-aware, type-gated catalogue (ADR-0048
//! D7), table-context disambiguation for `name`/`title` (D11), the
//! identifier-family rule (D10), and enum-ish detection (D12).
//!
//! Selection is **token-based**: a column name is split on `_`, `-` and
//! camelCase boundaries, lowercased, and matched against an
//! ordered, most-specific-first list. Each rule is **type-gated** — a
//! name match only fires when the column's type is compatible, so a
//! column called `email` typed `int` falls through to type-based
//! generation rather than producing a string. Documented false-positive
//! guards keep `username`/`filename` away from the bare person-name
//! rule.

use tracing::trace;

use crate::dsl::types::Type;
use crate::seed::{ColumnSpec, Generator};

/// Choose the generator for a column (ADR-0048 D7/D10/D11/D12).
///
/// Precedence: foreign keys and `IN`-CHECK columns are resolved first
/// (the executor / a fixed list), then the ordered name catalogue, then
/// the type-based fallback.
#[must_use]
pub fn choose_generator(table: &str, col: &ColumnSpec) -> Generator {
    let generator = choose_generator_inner(table, col);
    trace!(
        table = table,
        column = %col.name,
        ty = %col.ty,
        chosen = ?generator,
        "seed: chose generator for column"
    );
    generator
}

fn choose_generator_inner(table: &str, col: &ColumnSpec) -> Generator {
    // FK columns are filled by sampling existing parent rows (D14) —
    // the executor owns that; generation here would be wrong.
    if col.is_foreign_key {
        return Generator::ForeignKeySample;
    }
    // A simple `col IN (…)` CHECK becomes the value source (D17), so the
    // common enum-as-CHECK pattern just works.
    if let Some(values) = &col.check_in_values
        && !values.is_empty()
    {
        return Generator::PickFrom(values.clone());
    }

    let toks = tokens(&col.name);
    match_name_generator(table, &toks, col.ty).unwrap_or(Generator::Generic)
}

/// Whether a column name looks like an enum / fixed-value set that has
/// no sensible generic generator (D12). Used by the executor to drive
/// the post-seed advisory; such columns still receive generic text.
#[must_use]
pub fn is_enum_ish(name: &str) -> bool {
    // `priority` is intentionally absent: issue #34 gave it a built-in
    // value set (low/medium/high · 1/2/3), so it is no longer "filled
    // generically" and must not trigger the D13 advisory. `severity` /
    // `rating` / `stars` were never here. `status` stays — it is
    // deliberately left to the advisory (no built-in set).
    const ENUM_TOKENS: &[&str] = &[
        "role", "status", "state", "type", "kind", "category", "level", "tier", "stage", "gender",
    ];
    let toks = tokens(name);
    toks.iter().any(|t| ENUM_TOKENS.contains(&t.as_str()))
}

/// The ordered, most-specific-first name catalogue. Returns `None` when
/// nothing matches (→ type-based fallback) or when a name matches but
/// its type gate fails.
fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Generator> {
    let text = type_is_text(ty);
    let numeric = ty.is_numeric();

    // — Person —
    if text && (has_any(toks, &["fname", "firstname"]) || has_seq(toks, "first", "name")) {
        return Some(Generator::FirstName);
    }
    if text && (has_any(toks, &["lname", "lastname", "surname"]) || has_seq(toks, "last", "name")) {
        return Some(Generator::LastName);
    }
    if text && (has_any(toks, &["username", "login", "handle"]) || has_seq(toks, "user", "name")) {
        return Some(Generator::Username);
    }
    if text && has_any(toks, &["email", "emails"]) {
        return Some(Generator::Email);
    }
    if text && has_any(toks, &["password", "passwd", "pwd"]) {
        return Some(Generator::Password);
    }
    if text && has_any(toks, &["phone", "mobile", "cell", "tel", "telephone"]) {
        return Some(Generator::Phone);
    }

    // — bare `name` / `title` → table-context (D11) —
    // Guarded against the `*_name` false positives handled above (those
    // returned already) plus structural names like `filename`/`table_name`.
    if text && has_any(toks, &["name", "title"]) && !is_name_false_positive(toks) {
        return Some(name_by_table_context(table));
    }

    // — Address —
    if text && has_any(toks, &["city", "town"]) {
        return Some(Generator::City);
    }
    if text && has_token(toks, "country") {
        return Some(Generator::Country);
    }
    // `province` / explicit `state_name`/`state_abbr` → a real state name.
    // Bare `state` is left to enum-ish (it usually means status), so we
    // require `province` or a `state` token paired with name/abbr.
    if text
        && (has_token(toks, "province")
            || (has_token(toks, "state") && has_any(toks, &["name", "abbr"])))
    {
        return Some(Generator::StateName);
    }
    if text && has_any(toks, &["street", "address", "addr"]) {
        return Some(Generator::Street);
    }
    if text && has_any(toks, &["zip", "zipcode", "postcode", "postal"]) {
        return Some(Generator::ZipCode);
    }

    // — Organisation / job —
    if text
        && has_any(
            toks,
            &["company", "employer", "org", "organization", "organisation"],
        )
    {
        return Some(Generator::Company);
    }
    if text && has_any(toks, &["job", "position", "profession", "occupation"]) {
        return Some(Generator::JobTitle);
    }

    // — Free text —
    if text
        && has_any(
            toks,
            &[
                "description",
                "bio",
                "notes",
                "note",
                "summary",
                "comment",
                "comments",
                "about",
            ],
        )
    {
        return Some(Generator::Sentence);
    }
    if text && has_any(toks, &["url", "website", "homepage", "link"]) {
        return Some(Generator::Url);
    }
    if text && has_any(toks, &["color", "colour"]) {
        return Some(Generator::HexColor);
    }

    // — Numeric —
    if numeric
        && has_any(
            toks,
            &[
                "price", "amount", "cost", "salary", "balance", "total", "fee", "revenue",
            ],
        )
    {
        return Some(Generator::CurrencyAmount);
    }
    if numeric && has_token(toks, "age") {
        return Some(Generator::Age);
    }
    if numeric && has_any(toks, &["quantity", "qty", "stock", "count"]) {
        return Some(Generator::SmallInt);
    }
    // — Year-as-int (issue #33) — bounded plausible years so the `int`
    // type fallback (D8) can't emit nonsense like `9419`. `int`-gated
    // (years are whole numbers) and placed *after* the quantity rule so
    // `year_count` (a count of years) stays a `SmallInt`. `birth`/`born`/
    // `dob` + year picks the birth window — the int sibling of the
    // `dob → DateAdult` rule above — otherwise a recent window covers
    // `year` / `*_year` / `published` / `founded`.
    if matches!(ty, Type::Int)
        && (has_token(toks, "year") || has_any(toks, &["published", "founded"]))
    {
        return Some(if has_any(toks, &["birth", "born", "dob"]) {
            Generator::YearBirth
        } else {
            Generator::YearRecent
        });
    }

    // — Conventional choice sets (issue #34) — a few enum-ish names have
    // a near-canonical small value set that reads far better than lorem
    // text. Type-gated; reuses `PickFrom`. Names *without* a canonical
    // set (`status`, `role`, `type`, …) stay unmatched → generic text +
    // the D12/D13 advisory. `status` is deliberately excluded: its real
    // values are too domain-specific (user-confirmed, issue #34). A
    // user-declared `IN`-CHECK still wins — it is resolved before this.
    if has_any(toks, &["priority", "prio"]) {
        if text {
            return Some(pick_from(&["low", "medium", "high"]));
        }
        if matches!(ty, Type::Int) {
            return Some(pick_from(&["1", "2", "3"]));
        }
    }
    if has_token(toks, "severity") {
        if text {
            return Some(pick_from(&["low", "medium", "high", "critical"]));
        }
        if matches!(ty, Type::Int) {
            return Some(pick_from(&["1", "2", "3", "4"]));
        }
    }
    if matches!(ty, Type::Int) && has_any(toks, &["rating", "stars"]) {
        return Some(pick_from(&["1", "2", "3", "4", "5"]));
    }

    // — Temporal (bounded, D8) —
    if matches!(ty, Type::Date) && has_any(toks, &["dob", "birthday", "birthdate"]) {
        return Some(Generator::DateAdult);
    }
    if matches!(ty, Type::Date) && has_token(toks, "date") {
        return Some(Generator::DateRecent);
    }
    if matches!(ty, Type::DateTime) && has_any(toks, &["timestamp", "datetime", "at"]) {
        return Some(Generator::DateTimeRecent);
    }

    // — Boolean —
    if matches!(ty, Type::Bool)
        && (toks.first().map(String::as_str) == Some("is")
            || toks.first().map(String::as_str) == Some("has")
            || has_any(toks, &["active", "enabled", "verified", "deleted"]))
    {
        return Some(Generator::Boolean);
    }

    // — Identifier family (D10) — late so phone/email/etc. win first.
    if matches!(ty, Type::Int | Type::Text) && is_identifier_name(toks) {
        return Some(Generator::IdentitySequential);
    }

    None
}

/// Resolve a bare `name`/`title` column by the **table** it lives in
/// (D11): product-ish → a product name, company-ish → a company name,
/// person-ish → a person name, otherwise a generic person name.
fn name_by_table_context(table: &str) -> Generator {
    let toks = tokens(table);
    const PRODUCTY: &[&str] = &[
        "product",
        "products",
        "item",
        "items",
        "good",
        "goods",
        "merchandise",
        "catalog",
        "catalogue",
        "inventory",
        "sku",
        "skus",
    ];
    const COMPANYISH: &[&str] = &[
        "company",
        "companies",
        "vendor",
        "vendors",
        "supplier",
        "suppliers",
        "manufacturer",
        "manufacturers",
        "brand",
        "brands",
        "organization",
        "organisation",
    ];
    const PERSONISH: &[&str] = &[
        "user",
        "users",
        "customer",
        "customers",
        "person",
        "people",
        "employee",
        "employees",
        "member",
        "members",
        "contact",
        "contacts",
        "author",
        "authors",
        "student",
        "students",
    ];
    if has_any(&toks, PRODUCTY) {
        Generator::ProductName
    } else if has_any(&toks, COMPANYISH) {
        Generator::Company
    } else if has_any(&toks, PERSONISH) {
        Generator::FullName
    } else {
        // Unknown table: a person name is the most generally useful
        // default for a bare `name` column.
        Generator::FullName
    }
}

/// Names ending in `name`/`title` that are NOT person names. The
/// specific `first`/`last`/`user` cases are matched earlier and return
/// before this guard; this catches structural names.
fn is_name_false_positive(toks: &[String]) -> bool {
    const NON_PERSON: &[&str] = &[
        "file", "table", "host", "domain", "field", "class", "tag", "event", "path", "col",
        "column", "db", "schema", "index", "key", "page", "node", "type",
    ];
    has_any(toks, NON_PERSON) && has_any(toks, &["name", "title"])
}

/// Identifier-family names (D10): treated as unique identifiers. FK
/// columns never reach here (handled in [`choose_generator`]).
fn is_identifier_name(toks: &[String]) -> bool {
    const ID_TOKENS: &[&str] = &["id", "code", "sku", "ref", "reference", "barcode"];
    if has_any(toks, ID_TOKENS) {
        return true;
    }
    // `*_number` / `*_no` as an identifier, but only when qualified
    // (a bare `number`/`no` is too ambiguous, and `phone_number` already
    // matched the phone rule earlier).
    toks.len() >= 2 && has_any(toks, &["number", "no"])
}

// — token utilities —

/// Split a column/table name into lowercase tokens on `_`, `-`, spaces,
/// and camelCase boundaries. `created_at` → [`created`, `at`];
/// `firstName` → [`first`, `name`]; `DOB` → [`dob`].
fn tokens(name: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut cur = String::new();
    let mut prev_was_lower_or_digit = false;
    for ch in name.chars() {
        if ch == '_' || ch == '-' || ch == ' ' {
            if !cur.is_empty() {
                out.push(std::mem::take(&mut cur));
            }
            prev_was_lower_or_digit = false;
            continue;
        }
        // camelCase boundary: an uppercase letter following a lowercase
        // letter or digit starts a new token.
        if ch.is_ascii_uppercase() && prev_was_lower_or_digit && !cur.is_empty() {
            out.push(std::mem::take(&mut cur));
        }
        cur.push(ch.to_ascii_lowercase());
        prev_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
    }
    if !cur.is_empty() {
        out.push(cur);
    }
    out
}

/// A `PickFrom` generator from string-literal values (issue #34's
/// conventional choice sets). `literal_to_value` interprets each entry
/// by the destination type at generation time (an `int` column turns
/// `"1"` into a number).
fn pick_from(values: &[&str]) -> Generator {
    Generator::PickFrom(values.iter().map(|s| (*s).to_string()).collect())
}

fn has_token(toks: &[String], t: &str) -> bool {
    toks.iter().any(|x| x == t)
}

fn has_any(toks: &[String], candidates: &[&str]) -> bool {
    candidates.iter().any(|c| has_token(toks, c))
}

/// Whether `a` is immediately followed by `b` in the token list — for
/// matching split compound names like `first name` / `user name`.
fn has_seq(toks: &[String], a: &str, b: &str) -> bool {
    toks.windows(2).any(|w| w[0] == a && w[1] == b)
}

/// Text-typed for heuristic purposes — `text`, `shortid`, plus the
/// text-backed `decimal`/`date`/`datetime` are excluded here because
/// those have their own dedicated gates; only `text`/`shortid` accept
/// free-text generators.
const fn type_is_text(ty: Type) -> bool {
    matches!(ty, Type::Text | Type::ShortId)
}

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

    fn choose(table: &str, name: &str, ty: Type) -> Generator {
        choose_generator(table, &ColumnSpec::plain(name, ty))
    }

    #[test]
    fn person_name_fields_map_to_name_generators() {
        assert_eq!(
            choose("users", "first_name", Type::Text),
            Generator::FirstName
        );
        assert_eq!(
            choose("users", "firstName", Type::Text),
            Generator::FirstName
        );
        assert_eq!(
            choose("users", "last_name", Type::Text),
            Generator::LastName
        );
        assert_eq!(choose("users", "surname", Type::Text), Generator::LastName);
    }

    #[test]
    fn contact_fields_map_correctly() {
        assert_eq!(choose("users", "email", Type::Text), Generator::Email);
        assert_eq!(choose("users", "work_email", Type::Text), Generator::Email);
        assert_eq!(choose("users", "username", Type::Text), Generator::Username);
        assert_eq!(
            choose("users", "user_name", Type::Text),
            Generator::Username
        );
        assert_eq!(choose("users", "phone", Type::Text), Generator::Phone);
        assert_eq!(
            choose("accounts", "password", Type::Text),
            Generator::Password
        );
    }

    #[test]
    fn address_fields_map_correctly() {
        assert_eq!(choose("a", "city", Type::Text), Generator::City);
        assert_eq!(choose("a", "country", Type::Text), Generator::Country);
        assert_eq!(choose("a", "street", Type::Text), Generator::Street);
        assert_eq!(choose("a", "zip", Type::Text), Generator::ZipCode);
        assert_eq!(choose("a", "postcode", Type::Text), Generator::ZipCode);
        assert_eq!(choose("a", "province", Type::Text), Generator::StateName);
    }

    #[test]
    fn bare_name_uses_table_context() {
        // D11 — the same column name resolves differently by table.
        assert_eq!(
            choose("products", "name", Type::Text),
            Generator::ProductName
        );
        assert_eq!(choose("items", "title", Type::Text), Generator::ProductName);
        assert_eq!(choose("users", "name", Type::Text), Generator::FullName);
        assert_eq!(choose("customers", "name", Type::Text), Generator::FullName);
        assert_eq!(choose("vendors", "name", Type::Text), Generator::Company);
        // Unknown table → person name default.
        assert_eq!(choose("widgets", "name", Type::Text), Generator::FullName);
    }

    #[test]
    fn name_false_positives_do_not_become_person_names() {
        // These must NOT resolve to a person/product name.
        assert_ne!(choose("files", "filename", Type::Text), Generator::FullName);
        assert_ne!(
            choose("meta", "table_name", Type::Text),
            Generator::FullName
        );
        // They fall through to a generic / non-person generator.
        assert_eq!(choose("files", "filename", Type::Text), Generator::Generic);
    }

    #[test]
    fn numeric_name_heuristics_are_type_gated() {
        // `price` on a numeric column → currency; on text → falls through.
        assert_eq!(choose("p", "price", Type::Int), Generator::CurrencyAmount);
        assert_eq!(
            choose("p", "price", Type::Decimal),
            Generator::CurrencyAmount
        );
        assert_eq!(choose("p", "price", Type::Text), Generator::Generic);
        assert_eq!(choose("u", "age", Type::Int), Generator::Age);
        assert_eq!(choose("o", "quantity", Type::Int), Generator::SmallInt);
    }

    #[test]
    fn email_on_wrong_type_falls_through() {
        // The type gate: an `email` int column does NOT get a string —
        // it falls through to type-based generation.
        assert_eq!(choose("u", "email", Type::Int), Generator::Generic);
    }

    #[test]
    fn temporal_fields_are_bounded_and_type_gated() {
        assert_eq!(choose("u", "dob", Type::Date), Generator::DateAdult);
        assert_eq!(choose("o", "order_date", Type::Date), Generator::DateRecent);
        assert_eq!(
            choose("o", "created_at", Type::DateTime),
            Generator::DateTimeRecent
        );
        assert_eq!(
            choose("o", "timestamp", Type::DateTime),
            Generator::DateTimeRecent
        );
        // Wrong type → not a date generator.
        assert_eq!(choose("o", "order_date", Type::Int), Generator::Generic);
    }

    #[test]
    fn boolean_fields_map_to_boolean() {
        assert_eq!(choose("u", "is_active", Type::Bool), Generator::Boolean);
        assert_eq!(choose("u", "has_paid", Type::Bool), Generator::Boolean);
        assert_eq!(choose("u", "enabled", Type::Bool), Generator::Boolean);
    }

    #[test]
    fn identifier_family_is_unique_sequential() {
        assert_eq!(
            choose("t", "code", Type::Text),
            Generator::IdentitySequential
        );
        assert_eq!(
            choose("t", "sku", Type::Text),
            Generator::IdentitySequential
        );
        assert_eq!(
            choose("t", "order_number", Type::Int),
            Generator::IdentitySequential
        );
        assert_eq!(
            choose("t", "external_id", Type::Int),
            Generator::IdentitySequential
        );
    }

    #[test]
    fn foreign_key_columns_defer_to_executor() {
        let mut spec = ColumnSpec::plain("user_id", Type::Int);
        spec.is_foreign_key = true;
        assert_eq!(
            choose_generator("orders", &spec),
            Generator::ForeignKeySample
        );
    }

    #[test]
    fn check_in_values_become_pick_from() {
        let mut spec = ColumnSpec::plain("status", Type::Text);
        spec.check_in_values = Some(vec!["active".into(), "closed".into()]);
        assert_eq!(
            choose_generator("orders", &spec),
            Generator::PickFrom(vec!["active".into(), "closed".into()])
        );
    }

    #[test]
    fn enum_ish_names_are_detected_for_the_advisory() {
        assert!(is_enum_ish("status"));
        assert!(is_enum_ish("role"));
        assert!(is_enum_ish("order_state"));
        // Issue #34: `priority` gained a built-in value set, so it is no
        // longer advised (it is no longer "filled generically").
        assert!(!is_enum_ish("priority"));
        assert!(!is_enum_ish("severity"));
        assert!(!is_enum_ish("rating"));
        assert!(!is_enum_ish("email"));
        assert!(!is_enum_ish("first_name"));
    }

    #[test]
    fn year_like_int_columns_map_to_bounded_years() {
        // Issue #33: `int`-gated year heuristics. `birth`/`born`/`dob`
        // years pick the birth window; the rest a recent window.
        assert_eq!(
            choose("authors", "birth_year", Type::Int),
            Generator::YearBirth
        );
        assert_eq!(
            choose("authors", "birthYear", Type::Int),
            Generator::YearBirth
        );
        assert_eq!(choose("u", "year_born", Type::Int), Generator::YearBirth);
        assert_eq!(choose("books", "year", Type::Int), Generator::YearRecent);
        assert_eq!(
            choose("films", "release_year", Type::Int),
            Generator::YearRecent
        );
        assert_eq!(
            choose("books", "published", Type::Int),
            Generator::YearRecent
        );
        assert_eq!(
            choose("companies", "founded", Type::Int),
            Generator::YearRecent
        );
        // Type-gated: a text `year` is not a bounded-year int.
        assert_eq!(choose("books", "year", Type::Text), Generator::Generic);
        // `year_count` is a count, not a year — the quantity rule wins.
        assert_eq!(choose("t", "year_count", Type::Int), Generator::SmallInt);
    }

    #[test]
    fn conventional_choice_sets_map_to_pick_from() {
        // Issue #34: type-gated built-in value sets.
        assert_eq!(
            choose("tickets", "priority", Type::Text),
            Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into()]),
        );
        assert_eq!(
            choose("tickets", "prio", Type::Int),
            Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]),
        );
        assert_eq!(
            choose("bugs", "severity", Type::Text),
            Generator::PickFrom(vec![
                "low".into(),
                "medium".into(),
                "high".into(),
                "critical".into()
            ]),
        );
        assert_eq!(
            choose("bugs", "severity", Type::Int),
            Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into()]),
        );
        assert_eq!(
            choose("reviews", "rating", Type::Int),
            Generator::PickFrom(vec![
                "1".into(),
                "2".into(),
                "3".into(),
                "4".into(),
                "5".into()
            ]),
        );
        assert_eq!(
            choose("reviews", "stars", Type::Int),
            Generator::PickFrom(vec![
                "1".into(),
                "2".into(),
                "3".into(),
                "4".into(),
                "5".into()
            ]),
        );
    }

    #[test]
    fn status_is_left_to_the_advisory_not_given_a_set() {
        // User-confirmed (issue #34): `status` keeps the D12 "don't
        // guess" stance — generic text + the advisory, no built-in set.
        assert_eq!(choose("orders", "status", Type::Text), Generator::Generic);
        assert!(is_enum_ish("status"));
    }

    #[test]
    fn a_declared_in_check_still_wins_over_a_built_in_set() {
        // The CHECK is the user's explicit intent; it precedes the
        // issue-#34 default set for the same name.
        let mut spec = ColumnSpec::plain("priority", Type::Text);
        spec.check_in_values = Some(vec!["p1".into(), "p2".into()]);
        assert_eq!(
            choose_generator("tickets", &spec),
            Generator::PickFrom(vec!["p1".into(), "p2".into()]),
        );
    }

    #[test]
    fn enum_ish_columns_fall_through_to_generic() {
        // No special generator — generic text + the advisory flags them.
        assert_eq!(choose("orders", "status", Type::Text), Generator::Generic);
        assert_eq!(choose("users", "role", Type::Text), Generator::Generic);
    }

    #[test]
    fn unmatched_columns_use_type_based_fallback() {
        assert_eq!(
            choose("t", "some_freeform_field", Type::Text),
            Generator::Generic
        );
    }

    #[test]
    fn tokenizer_splits_on_all_boundaries() {
        assert_eq!(tokens("created_at"), vec!["created", "at"]);
        assert_eq!(tokens("firstName"), vec!["first", "name"]);
        assert_eq!(tokens("DOB"), vec!["dob"]);
        assert_eq!(tokens("user-email"), vec!["user", "email"]);
        assert_eq!(tokens("HTTPStatus"), vec!["httpstatus"]);
    }
}
