//! Shared helpers + canonical schema shapes for the
//! typing-surface matrix.
//!
//! See `tests/typing_surface.rs` for the entry point and
//! `docs/handoff/20260515-handoff-12.md` §1 for the design
//! rationale.

// Helpers below are `pub` so submodules can use them. The
// `unreachable_pub` lint trips because Cargo sees the test
// binary's crate root as the only consumer, but submodule
// access requires `pub`. `dead_code` similarly trips during
// incremental work when not every helper has callers yet.
#![allow(dead_code, unreachable_pub)]

use rdbms_playground::completion::{
    Completion, SchemaCache, TableColumn, candidates_at_cursor_in_mode,
};
use rdbms_playground::dsl::parser::parse_command_with_schema_in_mode;
use rdbms_playground::dsl::types::Type;
use rdbms_playground::input_render::{
    AmbientHint, InputState, ambient_hint_in_mode, classify_input_with_schema_in_mode,
};
use rdbms_playground::mode::Mode;

pub mod add_relationship;
pub mod app_commands;
pub mod candidate_ordering;
pub mod constraints;
pub mod create_m2n;
pub mod create_table;
pub mod delete_all_rows;
pub mod delete_with_where;
pub mod drop_column;
pub mod drop_relationship;
pub mod explain;
pub mod index_ops;
pub mod insert_form_a;
pub mod insert_form_b;
pub mod insert_form_c;
pub mod rename_change_column;
pub mod update_all_rows;
pub mod update_with_where;
pub mod where_expression;

// =========================================================
// Canonical schema shapes (handoff §1 — CANONICAL_SCHEMA_SHAPES)
// =========================================================
//
// Used in snapshot names so failing cases identify the schema.

pub const SHAPE_EMPTY: &str = "empty";
pub const SHAPE_SERIAL_PK: &str = "serial_pk";
pub const SHAPE_TEXT_PK: &str = "text_pk";
pub const SHAPE_MULTI_TABLE: &str = "multi_table";
pub const SHAPE_EVERY_TYPE: &str = "every_type";

/// Empty schema — no tables at all.
pub fn schema_empty() -> SchemaCache {
    SchemaCache::default()
}

/// Single table with a `serial` PK + two text columns.
/// `Customers(id:serial, Name:text, Email:text)`.
///
/// Exercises auto-gen-skip behaviour: in Form B `id` is omitted
/// from the value list; in Form A the user can list it explicitly.
pub fn schema_serial_pk() -> SchemaCache {
    build_schema(&[(
        "Customers",
        &[
            ("id", Type::Serial),
            ("Name", Type::Text),
            ("Email", Type::Text),
        ],
    )])
}

/// Single table with a text PK and no auto-gen columns.
/// `Items(Code:text, Title:text)`.
///
/// Exercises the no-auto-gen path: Form A and Form B both
/// require values for every column.
pub fn schema_text_pk() -> SchemaCache {
    build_schema(&[("Items", &[("Code", Type::Text), ("Title", Type::Text)])])
}

/// Two tables sharing no column names.
/// `Customers(id:serial, Name:text)` +
/// `Orders(OrderId:serial, CustId:int, Total:real)`.
///
/// Exercises column-name-leakage detection: column candidates at
/// a position scoped to one table must not include columns from
/// the other table.
pub fn schema_multi_table() -> SchemaCache {
    build_schema(&[
        ("Customers", &[("id", Type::Serial), ("Name", Type::Text)]),
        (
            "Orders",
            &[
                ("OrderId", Type::Serial),
                ("CustId", Type::Int),
                ("Total", Type::Real),
            ],
        ),
    ])
}

/// One table with one column of every `Type` variant.
/// Exercises per-type slot prose at every value position.
pub fn schema_every_type() -> SchemaCache {
    build_schema(&[(
        "Things",
        &[
            ("k", Type::Int),
            ("r", Type::Real),
            ("d", Type::Decimal),
            ("b", Type::Bool),
            ("dt", Type::Date),
            ("ts", Type::DateTime),
            ("sid", Type::ShortId),
            ("auto", Type::Serial),
            ("note", Type::Text),
        ],
    )])
}

fn build_schema(tables: &[(&str, &[(&str, Type)])]) -> SchemaCache {
    let mut cache = SchemaCache::default();
    for (table, cols) in tables {
        let table_cols: Vec<TableColumn> = cols
            .iter()
            .map(|(n, t)| TableColumn {
                name: (*n).to_string(),
                user_type: *t,
                not_null: false,
                has_default: false,
            })
            .collect();
        cache.tables.push((*table).to_string());
        for c in &table_cols {
            if !cache.columns.contains(&c.name) {
                cache.columns.push(c.name.clone());
            }
        }
        cache.table_columns.insert((*table).to_string(), table_cols);
    }
    cache
}

// =========================================================
// Assessment helper — runs the four typing-surface APIs at a
// (input, cursor, schema) cell and packages the results for
// snapshot + property assertions.
// =========================================================

/// Compact, Debug-printable assessment of the typing surface
/// at one cell.
#[derive(Debug)]
pub struct Assessment {
    pub input: String,
    pub cursor: usize,
    pub state: InputState,
    pub hint: Option<AmbientHint>,
    pub completion: Option<Completion>,
    /// Parse result rendered as `Ok(<short label>)` or
    /// `Err(<short error label>)` to keep snapshots stable as
    /// the `Command` AST evolves.
    pub parse_result: Result<String, String>,
}

/// Assess the typing surface at the given cell.
///
/// The whole typing-surface matrix exercises the **DSL** surface
/// (insert Forms A/B/C, `update`/`delete` with `where` / `--all-rows`,
/// the DSL expression grammar, DDL, app commands) — which is the
/// **Simple-mode** surface (ADR-0003). So every facet here is computed
/// in Simple mode, and consistently so: `ambient_hint` already
/// defaults to Simple, and the others are pinned to Simple via their
/// `*_in_mode` variants. This matters since sub-phase 3j made
/// `insert`/`update`/`delete` shared entry words (ADR-0033
/// Amendment 3): in Advanced mode those route to the SQL grammar
/// (different completion / hints / parse), whereas the DSL forms this
/// matrix documents live in Simple mode. The SQL surface is covered
/// by `tests/sql_*.rs` and the advanced-mode walker diagnostics.
pub fn assess(input: &str, cursor: usize, schema: &SchemaCache) -> Assessment {
    let state = classify_input_with_schema_in_mode(input, schema, Mode::Simple);
    let hint = ambient_hint_in_mode(input, cursor, None, schema, Mode::Simple);
    let completion = candidates_at_cursor_in_mode(input, cursor, schema, Mode::Simple);
    let parse_result = match parse_command_with_schema_in_mode(input, schema, Mode::Simple) {
        Ok(cmd) => Ok(command_kind_label(&cmd)),
        Err(e) => Err(parse_error_label(&e)),
    };
    Assessment {
        input: input.into(),
        cursor,
        state,
        hint,
        completion,
        parse_result,
    }
}

/// Convenience: assess at the end of input.
pub fn assess_at_end(input: &str, schema: &SchemaCache) -> Assessment {
    assess(input, input.len(), schema)
}

fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
    use rdbms_playground::dsl::AppCommand;
    use rdbms_playground::dsl::Command::*;
    match cmd {
        CreateTable { .. } => "CreateTable".into(),
        SqlCreateTable { .. } => "SqlCreateTable".into(),
        DropTable { .. } => "DropTable".into(),
        SqlDropTable { .. } => "SqlDropTable".into(),
        AddColumn { .. } => "AddColumn".into(),
        DropColumn { .. } => "DropColumn".into(),
        RenameColumn { .. } => "RenameColumn".into(),
        ChangeColumnType { .. } => "ChangeColumnType".into(),
        AddRelationship { .. } => "AddRelationship".into(),
        CreateM2nRelationship { .. } => "CreateM2nRelationship".into(),
        DropRelationship { .. } => "DropRelationship".into(),
        AddIndex { .. } => "AddIndex".into(),
        DropIndex { .. } => "DropIndex".into(),
        SqlDropIndex { .. } => "SqlDropIndex".into(),
        SqlCreateIndex { .. } => "SqlCreateIndex".into(),
        SqlAlterTable { .. } => "SqlAlterTable".into(),
        AddConstraint { .. } => "AddConstraint".into(),
        DropConstraint { .. } => "DropConstraint".into(),
        ShowTable { .. } => "ShowTable".into(),
        ShowList { kind, name } => format!("ShowList({kind:?}, {})", name.is_some()),
        Insert { .. } => "Insert".into(),
        Seed { .. } => "Seed".into(),
        Update { .. } => "Update".into(),
        Delete { .. } => "Delete".into(),
        ShowData { .. } => "ShowData".into(),
        Replay { .. } => "Replay".into(),
        Explain { .. } => "Explain".into(),
        Select { .. } => "Select".into(),
        SqlInsert { .. } => "SqlInsert".into(),
        SqlUpdate { .. } => "SqlUpdate".into(),
        SqlDelete { .. } => "SqlDelete".into(),
        App(app) => match app {
            AppCommand::Quit => "App(Quit)".into(),
            AppCommand::Help { .. } => "App(Help)".into(),
            AppCommand::Hint => "App(Hint)".into(),
            AppCommand::Version => "App(Version)".into(),
            AppCommand::Rebuild => "App(Rebuild)".into(),
            AppCommand::Save => "App(Save)".into(),
            AppCommand::SaveAs => "App(SaveAs)".into(),
            AppCommand::New => "App(New)".into(),
            AppCommand::Load => "App(Load)".into(),
            AppCommand::Export { .. } => "App(Export)".into(),
            AppCommand::Import { .. } => "App(Import)".into(),
            AppCommand::Mode { .. } => "App(Mode)".into(),
            AppCommand::Messages { .. } => "App(Messages)".into(),
            AppCommand::Undo => "App(Undo)".into(),
            AppCommand::Redo => "App(Redo)".into(),
            AppCommand::Copy { .. } => "App(Copy)".into(),
        },
    }
}

fn parse_error_label(err: &rdbms_playground::dsl::ParseError) -> String {
    use rdbms_playground::dsl::ParseError::*;
    match err {
        Empty => "Empty".into(),
        Invalid { at_eof, .. } => {
            if *at_eof {
                "Invalid(at_eof)".into()
            } else {
                "Invalid(definite)".into()
            }
        }
    }
}

// =========================================================
// Property-assertion helpers.
//
// Inline tests in submodules combine these with insta
// snapshots — the snapshot detects drift; the assertions
// detect bug-class regressions (no column leakage, hint
// surfaces the right column, etc.).
// =========================================================

/// Returns the list of candidate texts from the ambient hint,
/// or an empty Vec if the hint isn't in Candidates mode.
pub fn hint_candidate_texts(a: &Assessment) -> Vec<String> {
    match &a.hint {
        Some(AmbientHint::Candidates { items, .. }) => {
            items.iter().map(|c| c.text.clone()).collect()
        }
        _ => Vec::new(),
    }
}

/// Returns the list of completion candidate texts (from the
/// `candidates_at_cursor` surface), or an empty Vec if there
/// is no completion.
pub fn completion_candidate_texts(a: &Assessment) -> Vec<String> {
    a.completion.as_ref().map_or_else(Vec::new, |c| {
        c.candidates.iter().map(|c| c.text.clone()).collect()
    })
}

/// True if the hint is Prose containing the given substring.
pub fn hint_prose_contains(a: &Assessment, needle: &str) -> bool {
    matches!(&a.hint, Some(AmbientHint::Prose(p)) if p.contains(needle))
}

/// True if the hint is Prose; returns the prose string.
pub const fn hint_prose(a: &Assessment) -> Option<&str> {
    match &a.hint {
        Some(AmbientHint::Prose(p)) => Some(p.as_str()),
        _ => None,
    }
}

/// Assert state matches the given variant (by Debug name).
pub fn assert_state(a: &Assessment, expected: &str) {
    let actual = format!("{:?}", a.state);
    // DefiniteErrorAt carries a position — compare prefix.
    assert!(
        actual.starts_with(expected),
        "state: expected {expected}, got {actual} (input: {:?}, cursor: {})",
        a.input,
        a.cursor,
    );
}

/// Assert that no candidate (either hint Candidates or
/// completion) contains any of the given names. Used to detect
/// column-name leakage from foreign tables.
pub fn assert_no_candidate_named(a: &Assessment, forbidden: &[&str]) {
    let hint_cands = hint_candidate_texts(a);
    let comp_cands = completion_candidate_texts(a);
    for name in forbidden {
        assert!(
            !hint_cands.iter().any(|c| c == name),
            "hint candidates contain forbidden {name:?}: {hint_cands:?} (input: {:?})",
            a.input,
        );
        assert!(
            !comp_cands.iter().any(|c| c == name),
            "completion candidates contain forbidden {name:?}: {comp_cands:?} (input: {:?})",
            a.input,
        );
    }
}

/// Assert that at least one candidate matches each expected name.
pub fn assert_candidate_present(a: &Assessment, expected: &[&str]) {
    let hint_cands = hint_candidate_texts(a);
    let comp_cands = completion_candidate_texts(a);
    let all: Vec<&String> = hint_cands.iter().chain(comp_cands.iter()).collect();
    for name in expected {
        assert!(
            all.iter().any(|c| *c == name),
            "expected candidate {name:?} not present. hint: {hint_cands:?}, completion: {comp_cands:?} (input: {:?})",
            a.input,
        );
    }
}

/// Snapshot helper — takes a stable name + the Assessment.
/// Wraps `insta::assert_debug_snapshot!` with a deterministic
/// snapshot suffix so each cell has its own file.
#[macro_export]
macro_rules! snap {
    ($name:expr, $assessment:expr) => {
        ::insta::with_settings!({
            snapshot_suffix => $name,
            description => format!("input={:?} cursor={}", $assessment.input, $assessment.cursor),
        }, {
            ::insta::assert_debug_snapshot!(&$assessment);
        });
    };
}

// =========================================================
// Infrastructure smoke tests — verify the helper itself
// behaves before submodules rely on it.
// =========================================================

#[test]
fn smoke_each_schema_shape_builds() {
    // Each canonical schema must produce a SchemaCache the
    // walker can probe without panicking. Run a trivial
    // assess() against each.
    for (label, schema) in [
        (SHAPE_EMPTY, schema_empty()),
        (SHAPE_SERIAL_PK, schema_serial_pk()),
        (SHAPE_TEXT_PK, schema_text_pk()),
        (SHAPE_MULTI_TABLE, schema_multi_table()),
        (SHAPE_EVERY_TYPE, schema_every_type()),
    ] {
        let a = assess("insert", 6, &schema);
        // `insert` alone is incomplete regardless of schema —
        // a useful invariant to pin so any regression in the
        // entry-word path surfaces immediately.
        assert!(
            matches!(a.state, InputState::IncompleteAtEof),
            "{label}: expected IncompleteAtEof for 'insert', got {:?}",
            a.state,
        );
    }
}

#[test]
fn smoke_assess_at_end_returns_each_field() {
    let schema = schema_serial_pk();
    let a = assess_at_end("insert into Customers values (", &schema);
    // The four surfaces are populated:
    assert_eq!(a.input, "insert into Customers values (");
    assert_eq!(a.cursor, "insert into Customers values (".len());
    // The walker recognises this as incomplete (more tokens
    // wanted), not a definite error.
    assert!(matches!(a.state, InputState::IncompleteAtEof));
    // The hint surfaces per-column prose for the first
    // non-auto-gen column (Name).
    assert!(
        hint_prose_contains(&a, "Name"),
        "expected hint prose to mention `Name`, got {:?}",
        a.hint,
    );
}

#[test]
fn smoke_assess_parse_label_round_trips() {
    let schema = schema_serial_pk();
    let a = assess_at_end("insert into Customers values ('Alice', 'a@b.c')", &schema);
    assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
    assert!(matches!(a.state, InputState::Valid));
}

/// `seed` (ADR-0048) gets the standard ambient surface for free from
/// grammar registration: table-name completion, the validity indicator
/// flagging an unknown table, and the `--seed` flag offered as a
/// candidate.
#[test]
fn seed_completion_and_validity() {
    let schema = schema_serial_pk(); // Customers(id serial, Name, Email)

    // Completion: `seed ` offers existing table names.
    let cands = completion_candidate_texts(&assess_at_end("seed ", &schema));
    assert!(
        cands.iter().any(|c| c == "Customers"),
        "`seed ` should complete table names, got {cands:?}"
    );

    // Validity (ADR-0027): a known table seeds clean; an unknown one is
    // flagged (same table slot as update/delete/show data).
    let ok = assess_at_end("seed Customers 5", &schema);
    assert!(
        matches!(ok.state, InputState::Valid),
        "known table: {:?}",
        ok.state
    );
    // seed's unknown-table behaviour must match its closest sibling
    // `show data` (same table-only slot), whatever that is.
    let seed_ghost = assess_at_end("seed Ghost 5", &schema).state;
    let show_ghost = assess_at_end("show data Ghost", &schema).state;
    assert_eq!(
        std::mem::discriminant(&seed_ghost),
        std::mem::discriminant(&show_ghost),
        "seed should treat an unknown table like `show data`: seed={seed_ghost:?}, show={show_ghost:?}"
    );

    // The `--seed` reproducibility flag is offered after the count.
    let flag_cands = completion_candidate_texts(&assess_at_end("seed Customers 5 ", &schema));
    assert!(
        flag_cands.iter().any(|c| c.contains("seed")),
        "`--seed` should be offered as a candidate, got {flag_cands:?}"
    );

    // Phase 2 (ADR-0048 D2): the `set` clause is offered after the count.
    assert!(
        flag_cands.iter().any(|c| c == "set"),
        "`set` should be offered after the count, got {flag_cands:?}"
    );

    // `set ` offers the active table's columns (narrowed to Customers).
    let set_cands = completion_candidate_texts(&assess_at_end("seed Customers set ", &schema));
    assert!(
        set_cands.iter().any(|c| c == "Name") && set_cands.iter().any(|c| c == "Email"),
        "`set ` should complete this table's columns, got {set_cands:?}"
    );

    // `set <col> as ` offers the curated generator vocabulary (D9).
    let gen_cands =
        completion_candidate_texts(&assess_at_end("seed Customers set Email as ", &schema));
    assert!(
        gen_cands.iter().any(|c| c == "email") && gen_cands.iter().any(|c| c == "product"),
        "`as ` should complete generator names, got {gen_cands:?}"
    );

    // Column-fill (D1 form 2): `seed Customers.` offers the columns.
    let fill_cands = completion_candidate_texts(&assess_at_end("seed Customers.", &schema));
    assert!(
        fill_cands.iter().any(|c| c == "Name"),
        "`seed Customers.` should complete column names, got {fill_cands:?}"
    );
}
