//! Sub-phase 3b integration tests for the advanced-mode SQL
//! `INSERT` surface (ADR-0033 §1).
//!
//! Covers:
//! - Worker round-trip: a validated `INSERT` runs against the
//!   database, returns the affected-row count, and re-persists the
//!   target table's CSV (ADR-0030 §11).
//! - Single-row, multi-row, explicit-column-list, and full-arity
//!   (no column list) forms.
//! - `history.log` records the literal submitted line.
//! - A failing INSERT (PK conflict) rolls back and does NOT
//!   re-persist the CSV.
//! - Parse path: `parse_command` (advanced mode) lowers the dev
//!   `sqlinsert` scaffold to `Command::SqlInsert`, reconstructing
//!   valid `insert …` SQL and extracting the target table.
//! - `__rdbms_*` target tables are rejected at the grammar layer.
//!
//! The dev `sqlinsert` entry word keeps the SQL INSERT path
//! isolated from the DSL `insert` word until sub-phase 3j; the
//! worker-level tests call `db.run_sql_insert` directly with the
//! real reconstructed SQL.

use rdbms_playground::completion::{SchemaCache, TableColumn};
use rdbms_playground::db::{Database, DbError, InsertResult};
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
use rdbms_playground::event::AppEvent;
use rdbms_playground::input_render::{
    AmbientHint, InputState, ambient_hint_in_mode, classify_input_with_schema_in_mode,
};
use rdbms_playground::mode::Mode;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
use rdbms_playground::runtime::run_replay;

fn rt() -> tokio::runtime::Runtime {
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("tokio rt")
}

fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
    let dir = tempfile::tempdir().expect("create tempdir");
    let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
    let persistence = Persistence::new(project.path().to_path_buf());
    let db = Database::open_with_persistence(project.db_path(), persistence)
        .expect("open db with persistence");
    (project, db, dir)
}

fn read_csv(project: &project::Project, table: &str) -> Option<String> {
    std::fs::read_to_string(project.path().join("data").join(format!("{table}.csv"))).ok()
}

/// Create a two-column table `T(a int pk, b text)` — no
/// auto-generated columns, so 3b's explicit-values requirement is
/// satisfied by every INSERT below.
fn create_t(db: &Database, rt: &tokio::runtime::Runtime) {
    rt.block_on(db.create_table(
        "T".to_string(),
        vec![
            ColumnSpec::new("a", Type::Int),
            ColumnSpec::new("b", Type::Text),
        ],
        vec!["a".to_string()],
        None,
    ))
    .expect("create table T");
}

#[test]
fn single_row_insert_persists_and_counts() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_t(&db, &rt);
    let result = rt
        .block_on(db.run_sql_insert(
            "insert into T (a, b) values (1, 'Ada')".to_string(),
            Some("insert into T (a, b) values (1, 'Ada')".to_string()),
            "T".to_string(),
            Vec::new(),
            String::new(),
            false,
        ))
        .expect("insert runs");
    assert_eq!(result.rows_affected, 1, "one row inserted");
    let csv = read_csv(&project, "T").expect("T.csv written after insert");
    assert!(
        csv.contains("Ada"),
        "CSV reflects the inserted row: {csv:?}"
    );
}

#[test]
fn multi_row_insert_persists_both_rows() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_t(&db, &rt);
    let result = rt
        .block_on(db.run_sql_insert(
            "insert into T (a, b) values (1, 'first'), (2, 'second')".to_string(),
            None,
            "T".to_string(),
            Vec::new(),
            String::new(),
            false,
        ))
        .expect("multi-row insert runs");
    assert_eq!(result.rows_affected, 2, "two rows inserted");
    let csv = read_csv(&project, "T").expect("T.csv written");
    assert!(
        csv.contains("first") && csv.contains("second"),
        "CSV reflects both inserted rows: {csv:?}",
    );
}

#[test]
fn no_column_list_full_arity_insert_persists() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_t(&db, &rt);
    let result = rt
        .block_on(db.run_sql_insert(
            "insert into T values (7, 'full-arity')".to_string(),
            None,
            "T".to_string(),
            Vec::new(),
            String::new(),
            false,
        ))
        .expect("full-arity insert runs");
    assert_eq!(result.rows_affected, 1);
    let csv = read_csv(&project, "T").expect("T.csv written");
    assert!(csv.contains("full-arity"), "CSV reflects the row: {csv:?}");
}

#[test]
fn failed_insert_rolls_back_and_does_not_repersist() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_t(&db, &rt);
    // First insert succeeds and persists.
    rt.block_on(db.run_sql_insert(
        "insert into T (a, b) values (1, 'kept')".to_string(),
        None,
        "T".to_string(),
        Vec::new(),
        String::new(),
        false,
    ))
    .expect("first insert runs");
    // Second insert violates the primary key — it must fail and
    // leave persistence untouched (the transaction rolls back
    // before finalize_persistence).
    let outcome = rt.block_on(db.run_sql_insert(
        "insert into T (a, b) values (1, 'discarded')".to_string(),
        None,
        "T".to_string(),
        Vec::new(),
        String::new(),
        false,
    ));
    assert!(outcome.is_err(), "duplicate PK must fail: {outcome:?}");
    let csv = read_csv(&project, "T").expect("T.csv still present");
    assert!(
        csv.contains("kept") && !csv.contains("discarded"),
        "failed insert must not be persisted: {csv:?}",
    );
}

#[test]
fn failed_multi_row_insert_is_atomic() {
    // ADR-0033 §3b DA gate: a multi-row INSERT whose later tuple
    // violates a constraint fails as one statement — no partial
    // rows land, and the CSV is not rewritten.
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_t(&db, &rt);
    rt.block_on(db.run_sql_insert(
        "insert into T (a, b) values (1, 'existing')".to_string(),
        None,
        "T".to_string(),
        Vec::new(),
        String::new(),
        false,
    ))
    .expect("seed row");
    // Row (2,…) is new but (1,…) collides on the PK — the whole
    // statement must fail with neither tuple applied.
    let outcome = rt.block_on(db.run_sql_insert(
        "insert into T (a, b) values (2, 'fresh'), (1, 'collides')".to_string(),
        None,
        "T".to_string(),
        Vec::new(),
        String::new(),
        false,
    ));
    assert!(
        outcome.is_err(),
        "multi-row PK conflict must fail: {outcome:?}"
    );
    let csv = read_csv(&project, "T").expect("T.csv still present");
    assert!(
        csv.contains("existing") && !csv.contains("fresh") && !csv.contains("collides"),
        "no tuple from the failed multi-row insert may land: {csv:?}",
    );
}

#[test]
fn parse_path_lowers_sqlinsert_scaffold_to_command() {
    // Advanced-mode parse of the dev scaffold reconstructs valid
    // `insert …` SQL and extracts the target table.
    let command = parse_command("insert into Orders (id, total) values (1, 99.5)")
        .expect("insert parses in advanced mode");
    match command {
        Command::SqlInsert {
            sql, target_table, ..
        } => {
            assert_eq!(sql, "insert into Orders (id, total) values (1, 99.5)");
            assert_eq!(target_table, "Orders");
        }
        other => panic!("expected Command::SqlInsert, got {other:?}"),
    }
}

#[test]
fn parse_path_rejects_internal_target_table() {
    let result = parse_command("insert into __rdbms_playground_columns values (1)");
    assert!(
        result.is_err(),
        "an internal `__rdbms_*` target must be rejected: {result:?}",
    );
}

// =================================================================
// Sub-phase 3c — INSERT … SELECT
// =================================================================

/// Create a two-column table `name(a int pk, b text)`.
fn create_named(db: &Database, rt: &tokio::runtime::Runtime, name: &str) {
    rt.block_on(db.create_table(
        name.to_string(),
        vec![
            ColumnSpec::new("a", Type::Int),
            ColumnSpec::new("b", Type::Text),
        ],
        vec!["a".to_string()],
        None,
    ))
    .unwrap_or_else(|e| panic!("create table {name}: {e:?}"));
}

#[test]
fn parse_path_lowers_insert_select_to_command() {
    let command = parse_command("insert into archive select * from source")
        .expect("INSERT … SELECT parses in advanced mode");
    match command {
        Command::SqlInsert {
            sql, target_table, ..
        } => {
            assert_eq!(sql, "insert into archive select * from source");
            assert_eq!(target_table, "archive");
        }
        other => panic!("expected Command::SqlInsert, got {other:?}"),
    }
}

#[test]
fn parse_path_lowers_with_prefixed_insert_select() {
    // R4: a WITH-prefixed SELECT row source lowers verbatim.
    let command =
        parse_command("insert into archive with t as (select * from orders) select * from t")
            .expect("WITH-prefixed INSERT … SELECT parses");
    match command {
        Command::SqlInsert {
            sql, target_table, ..
        } => {
            assert_eq!(
                sql,
                "insert into archive with t as (select * from orders) select * from t",
            );
            assert_eq!(target_table, "archive");
        }
        other => panic!("expected Command::SqlInsert, got {other:?}"),
    }
}

#[test]
fn insert_select_copies_rows_and_persists() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_named(&db, &rt, "source");
    create_named(&db, &rt, "archive");
    rt.block_on(db.run_sql_insert(
        "insert into source (a, b) values (1, 'one'), (2, 'two')".to_string(),
        None,
        "source".to_string(),
        Vec::new(),
        String::new(),
        false,
    ))
    .expect("seed source");
    let result = rt
        .block_on(db.run_sql_insert(
            "insert into archive select * from source".to_string(),
            Some("insert into archive select * from source".to_string()),
            "archive".to_string(),
            Vec::new(),
            String::new(),
            false,
        ))
        .expect("INSERT … SELECT runs");
    assert_eq!(result.rows_affected, 2, "both source rows copied");
    let csv = read_csv(&project, "archive").expect("archive.csv written");
    assert!(
        csv.contains("one") && csv.contains("two"),
        "archive CSV reflects the copied rows: {csv:?}",
    );
}

#[test]
fn insert_select_with_column_list_and_projection_persists() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_named(&db, &rt, "source");
    create_named(&db, &rt, "target");
    rt.block_on(db.run_sql_insert(
        "insert into source (a, b) values (5, 'five')".to_string(),
        None,
        "source".to_string(),
        Vec::new(),
        String::new(),
        false,
    ))
    .expect("seed source");
    let result = rt
        .block_on(db.run_sql_insert(
            "insert into target (a, b) select a, b from source".to_string(),
            None,
            "target".to_string(),
            Vec::new(),
            String::new(),
            false,
        ))
        .expect("column-list + projection INSERT … SELECT runs");
    assert_eq!(result.rows_affected, 1);
    let csv = read_csv(&project, "target").expect("target.csv written");
    assert!(csv.contains("five"), "target CSV reflects the row: {csv:?}");
}

#[test]
fn with_prefixed_insert_select_runs_and_persists() {
    // R4 end-to-end: the CTE row source executes and lands rows.
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_named(&db, &rt, "orders");
    create_named(&db, &rt, "archive");
    rt.block_on(db.run_sql_insert(
        "insert into orders (a, b) values (1, 'a'), (2, 'b')".to_string(),
        None,
        "orders".to_string(),
        Vec::new(),
        String::new(),
        false,
    ))
    .expect("seed orders");
    let result = rt
        .block_on(db.run_sql_insert(
            "insert into archive with t as (select * from orders) select * from t".to_string(),
            None,
            "archive".to_string(),
            Vec::new(),
            String::new(),
            false,
        ))
        .expect("WITH-prefixed INSERT … SELECT runs");
    assert_eq!(result.rows_affected, 2);
    let csv = read_csv(&project, "archive").expect("archive.csv written");
    assert!(
        csv.contains('a') && csv.contains('b'),
        "archive CSV reflects the CTE-sourced rows: {csv:?}",
    );
}

#[test]
fn insert_select_from_self_runs_as_plain_insert() {
    // DA gate: INSERT … SELECT where the source is the target
    // executes as a plain insert (InsertResult — no cascade
    // summary; cascade output is a DELETE-only concept, 3f).
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_named(&db, &rt, "T");
    rt.block_on(db.run_sql_insert(
        "insert into T (a, b) values (1, 'x'), (2, 'y')".to_string(),
        None,
        "T".to_string(),
        Vec::new(),
        String::new(),
        false,
    ))
    .expect("seed");
    let result = rt
        .block_on(db.run_sql_insert(
            "insert into T select a + 10, b from T".to_string(),
            None,
            "T".to_string(),
            Vec::new(),
            String::new(),
            false,
        ))
        .expect("self-sourced INSERT … SELECT runs");
    assert_eq!(result.rows_affected, 2, "two rows copied with shifted PK");
    let csv = read_csv(&project, "T").expect("T.csv written");
    assert!(
        csv.contains("11") && csv.contains("12"),
        "the shifted-PK copies landed: {csv:?}",
    );
}

// =================================================================
// Sub-phase 3d — shortid auto-fill (worker)
// =================================================================

/// Full-stack: parse the dev `sqlinsert …` scaffold (so
/// `listed_columns` / `row_source` are extracted exactly as the
/// app does) and run it through the worker.
fn run_sqlinsert(
    db: &Database,
    rt: &tokio::runtime::Runtime,
    input: &str,
) -> Result<InsertResult, DbError> {
    match parse_command(input).expect("parse insert") {
        Command::SqlInsert {
            sql,
            target_table,
            listed_columns,
            row_source,
            returning,
            ..
        } => rt.block_on(db.run_sql_insert(
            sql,
            Some(input.to_string()),
            target_table,
            listed_columns,
            row_source,
            returning,
        )),
        other => panic!("expected Command::SqlInsert, got {other:?}"),
    }
}

fn create_cols(
    db: &Database,
    rt: &tokio::runtime::Runtime,
    name: &str,
    cols: &[(&str, Type)],
    pk: &[&str],
) {
    rt.block_on(db.create_table(
        name.to_string(),
        cols.iter().map(|(n, t)| ColumnSpec::new(*n, *t)).collect(),
        pk.iter().map(|s| (*s).to_string()).collect(),
        None,
    ))
    .unwrap_or_else(|e| panic!("create table {name}: {e:?}"));
}

/// Data rows of a table's CSV (header skipped), each split on `,`.
/// The test data uses comma/quote-free values, so a plain split is
/// sufficient.
fn csv_rows(project: &project::Project, table: &str) -> Vec<Vec<String>> {
    read_csv(project, table)
        .unwrap_or_default()
        .lines()
        .skip(1)
        .filter(|l| !l.is_empty())
        .map(|l| l.split(',').map(str::to_string).collect())
        .collect()
}

#[test]
fn values_autofills_omitted_shortid_pk() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::ShortId), ("label", Type::Text)],
        &["id"],
    );
    let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x')")
        .expect("auto-fill insert runs");
    assert_eq!(result.rows_affected, 1);
    let rows = csv_rows(&project, "t");
    assert_eq!(rows.len(), 1, "one row: {rows:?}");
    assert!(!rows[0][0].is_empty(), "id auto-filled: {rows:?}");
    assert_eq!(rows[0][1], "x", "label preserved: {rows:?}");
}

#[test]
fn values_multirow_autofills_distinct_shortids() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::ShortId), ("label", Type::Text)],
        &["id"],
    );
    let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('a'), ('b'), ('c')")
        .expect("multi-row auto-fill runs");
    assert_eq!(result.rows_affected, 3);
    let rows = csv_rows(&project, "t");
    let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect();
    assert_eq!(ids.len(), 3, "three DISTINCT non-empty shortids: {rows:?}");
    assert!(
        rows.iter().all(|r| !r[0].is_empty()),
        "no empty id: {rows:?}"
    );
}

#[test]
fn explicit_shortid_value_is_respected() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::ShortId), ("label", Type::Text)],
        &["id"],
    );
    // The user provided `id` explicitly — it must be honoured
    // verbatim (the override WARNING is sub-phase 3i).
    run_sqlinsert(
        &db,
        &rt,
        "insert into t (id, label) values ('hardcoded', 'x')",
    )
    .expect("explicit-id insert runs");
    let rows = csv_rows(&project, "t");
    assert_eq!(rows[0][0], "hardcoded", "explicit id preserved: {rows:?}");
}

#[test]
fn insert_select_autofills_distinct_shortids() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "source",
        &[("id", Type::ShortId), ("label", Type::Text)],
        &["id"],
    );
    create_cols(
        &db,
        &rt,
        "target",
        &[("id", Type::ShortId), ("label", Type::Text)],
        &["id"],
    );
    run_sqlinsert(&db, &rt, "insert into source (label) values ('a'), ('b')").expect("seed source");
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into target (label) select label from source",
    )
    .expect("INSERT … SELECT auto-fill runs");
    assert_eq!(result.rows_affected, 2);
    let rows = csv_rows(&project, "target");
    let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect();
    assert_eq!(ids.len(), 2, "two DISTINCT fresh shortids: {rows:?}");
    assert!(
        rows.iter().all(|r| !r[0].is_empty()),
        "no empty id: {rows:?}"
    );
}

#[test]
fn combined_serial_and_shortid_autofill() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    // id: serial PK (engine rowid), code: shortid (worker), name.
    create_cols(
        &db,
        &rt,
        "t",
        &[
            ("id", Type::Serial),
            ("code", Type::ShortId),
            ("name", Type::Text),
        ],
        &["id"],
    );
    run_sqlinsert(&db, &rt, "insert into t (name) values ('x')").expect("combined auto-fill runs");
    let rows = csv_rows(&project, "t");
    assert_eq!(rows.len(), 1, "{rows:?}");
    assert_eq!(rows[0][0], "1", "serial PK engine-filled: {rows:?}");
    assert!(!rows[0][1].is_empty(), "shortid worker-filled: {rows:?}");
    assert_eq!(rows[0][2], "x", "name preserved: {rows:?}");
}

#[test]
fn shortid_autofill_respects_mixed_case_column_name() {
    // ADR-0009 / 3d DA gate: identifiers are case-preserving. The
    // omitted-shortid detection must match the case-preserved
    // schema name `MyId`, not a lowercased form.
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("MyId", Type::ShortId), ("label", Type::Text)],
        &["MyId"],
    );
    run_sqlinsert(&db, &rt, "insert into t (label) values ('x')")
        .expect("mixed-case shortid auto-fill runs");
    let rows = csv_rows(&project, "t");
    assert_eq!(rows.len(), 1, "{rows:?}");
    assert!(!rows[0][0].is_empty(), "MyId auto-filled: {rows:?}");
}

#[test]
fn two_shortids_pk_and_nonpk_both_autofill_distinctly() {
    // Two shortid columns (one PK, one not), both omitted: each
    // gets its own distinct-per-row batch.
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[
            ("id", Type::ShortId),
            ("code", Type::ShortId),
            ("label", Type::Text),
        ],
        &["id"],
    );
    let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x'), ('y')")
        .expect("two-shortid auto-fill runs");
    assert_eq!(result.rows_affected, 2);
    let rows = csv_rows(&project, "t");
    let ids: std::collections::HashSet<&String> = rows.iter().map(|x| &x[0]).collect();
    let codes: std::collections::HashSet<&String> = rows.iter().map(|x| &x[1]).collect();
    assert_eq!(ids.len(), 2, "distinct ids: {rows:?}");
    assert_eq!(codes.len(), 2, "distinct codes: {rows:?}");
    assert!(
        rows.iter().all(|x| !x[0].is_empty() && !x[1].is_empty()),
        "both shortid columns filled on every row: {rows:?}",
    );
}

#[test]
fn two_shortids_one_provided_one_autofilled() {
    // The user provides one shortid and omits the other; the
    // provided value is honoured and only the omitted one fills.
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[
            ("id", Type::ShortId),
            ("code", Type::ShortId),
            ("label", Type::Text),
        ],
        &["id"],
    );
    run_sqlinsert(&db, &rt, "insert into t (id, label) values ('myid', 'x')")
        .expect("partial-shortid insert runs");
    let rows = csv_rows(&project, "t");
    assert_eq!(rows[0][0], "myid", "provided id preserved: {rows:?}");
    assert!(!rows[0][1].is_empty(), "omitted code auto-filled: {rows:?}");
}

#[test]
fn compound_pk_with_shortid_member_autofills() {
    // A shortid that is part of a compound PK still auto-fills when
    // omitted (membership in the PK is irrelevant to the fill).
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[
            ("id", Type::ShortId),
            ("region", Type::Int),
            ("label", Type::Text),
        ],
        &["id", "region"],
    );
    run_sqlinsert(&db, &rt, "insert into t (region, label) values (1, 'x')")
        .expect("compound-pk insert runs");
    let rows = csv_rows(&project, "t");
    assert!(
        !rows[0][0].is_empty(),
        "shortid PK member auto-filled: {rows:?}",
    );
    assert_eq!(rows[0][1], "1", "{rows:?}");
}

#[test]
fn autofill_does_not_mask_arity_mismatch() {
    // A column-list / value-count mismatch must still be rejected
    // even when a shortid auto-fill would otherwise kick in — the
    // auto-fill path must not silently drop the extra value. (3i
    // adds a friendly pre-flight; until then we defer to the
    // engine rather than mask the error.)
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::ShortId), ("label", Type::Text)],
        &["id"],
    );
    let outcome = run_sqlinsert(&db, &rt, "insert into t (label) values ('a', 'b')");
    assert!(
        outcome.is_err(),
        "arity mismatch must be rejected, not masked: {outcome:?}",
    );
    let rows = csv_rows(&project, "t");
    assert!(
        rows.is_empty(),
        "no row should land on a rejected insert: {rows:?}"
    );
}

#[test]
fn sql_insert_autofills_omitted_nonpk_serial() {
    // ADR-0018 §1/§5 + X4: advanced-mode SQL INSERT auto-fills an omitted
    // non-PK `serial` column with MAX+1 (per row), exactly as simple-mode
    // `do_insert` does — honouring the "auto-generated on every path"
    // contract. (Was: silently inserting NULL.) Mirrors the existing
    // shortid auto-fill, which already runs on this path.
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::Int), ("seq", Type::Serial)],
        &["id"],
    );

    // Single row, omitting the non-PK serial `seq`.
    run_sqlinsert(&db, &rt, "insert into t (id) values (10)").expect("single-row insert runs");
    // Multi-row, omitting `seq` — each row gets a distinct, increasing
    // serial continuing from the current MAX.
    run_sqlinsert(&db, &rt, "insert into t (id) values (20), (30)").expect("multi-row insert runs");

    let rows = csv_rows(&project, "t");
    // No NULL serials, and the sequence is 1, 2, 3 across the three rows.
    assert_eq!(
        rows,
        vec![
            vec!["10".to_string(), "1".to_string()],
            vec!["20".to_string(), "2".to_string()],
            vec!["30".to_string(), "3".to_string()],
        ],
        "omitted non-PK serial auto-filled MAX+1 per row (no NULLs): {rows:?}",
    );
}

#[test]
fn autofill_insert_select_wider_projection_is_rejected() {
    // SELECT projects more columns than the list: the guard defers
    // to the engine instead of dropping the extra projection.
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "src",
        &[("a", Type::Text), ("b", Type::Text)],
        &["a"],
    );
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::ShortId), ("label", Type::Text)],
        &["id"],
    );
    run_sqlinsert(&db, &rt, "insert into src (a, b) values ('p', 'q')").expect("seed");
    let outcome = run_sqlinsert(&db, &rt, "insert into t (label) select a, b from src");
    assert!(
        outcome.is_err(),
        "wider projection must be rejected: {outcome:?}"
    );
    assert!(csv_rows(&project, "t").is_empty(), "nothing should land");
}

#[test]
fn autofill_insert_select_narrower_projection_is_rejected() {
    // SELECT projects fewer columns than the list: the guard
    // prevents an out-of-range read and defers to the engine.
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(&db, &rt, "src", &[("a", Type::Text)], &["a"]);
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::ShortId), ("x", Type::Text), ("y", Type::Text)],
        &["id"],
    );
    run_sqlinsert(&db, &rt, "insert into src (a) values ('p')").expect("seed");
    let outcome = run_sqlinsert(&db, &rt, "insert into t (x, y) select a from src");
    assert!(
        outcome.is_err(),
        "narrower projection must be rejected: {outcome:?}"
    );
    assert!(csv_rows(&project, "t").is_empty(), "nothing should land");
}

// =================================================================
// Sub-phase 3g — RETURNING (ADR-0033 §5)
// =================================================================

#[test]
fn insert_returning_star_returns_inserted_row() {
    let (_project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::Int), ("b", Type::Text)],
        &["id"],
    );
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into t (id, b) values (1, 'Ada') returning *",
    )
    .expect("INSERT … RETURNING * runs");
    assert_eq!(result.rows_affected, 1, "one row inserted");
    assert_eq!(
        result.data.rows.len(),
        1,
        "RETURNING yielded the inserted row"
    );
    assert_eq!(result.data.columns, vec!["id".to_string(), "b".to_string()]);
    assert_eq!(result.data.rows[0][1], Some("Ada".to_string()));
}

#[test]
fn insert_multirow_returning_id_yields_distinct_rows() {
    let (_project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::Int), ("b", Type::Text)],
        &["id"],
    );
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into t (id, b) values (1, 'a'), (2, 'b'), (3, 'c') returning id",
    )
    .expect("multi-row INSERT … RETURNING id runs");
    assert_eq!(result.rows_affected, 3, "three rows inserted");
    assert_eq!(result.data.columns, vec!["id".to_string()]);
    let ids: std::collections::BTreeSet<_> =
        result.data.rows.iter().map(|r| r[0].clone()).collect();
    assert_eq!(
        ids.len(),
        3,
        "three distinct ids returned: {:?}",
        result.data.rows
    );
}

#[test]
fn insert_returning_autofills_shortid_and_returns_it() {
    // The auto-fill × RETURNING interaction (3d × 3g): the worker
    // rewrites the INSERT to add the generated shortid, and the
    // rewrite must PRESERVE the RETURNING tail so the generated id
    // surfaces in the returned row.
    let (_project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::ShortId), ("label", Type::Text)],
        &["id"],
    );
    let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x') returning *")
        .expect("auto-fill INSERT … RETURNING * runs");
    assert_eq!(
        result.rows_affected, 1,
        "one row inserted (RETURNING-counted)"
    );
    assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the row");
    // `id` is the auto-filled shortid column; it must be non-empty in
    // the returned row (proving the rewrite kept RETURNING).
    let id_idx = result
        .data
        .columns
        .iter()
        .position(|c| c == "id")
        .expect("id column");
    let id_val = result.data.rows[0][id_idx].clone();
    assert!(
        id_val.is_some_and(|s| !s.is_empty()),
        "generated shortid surfaced via RETURNING"
    );
}

#[test]
fn insert_returning_recovers_bare_column_type() {
    // 3g type recovery: a bare-column RETURNING ref recovers its
    // playground type via the column-origin path (a `bool` column
    // renders as the word, not 0/1).
    let (_project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::Int), ("active", Type::Bool)],
        &["id"],
    );
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into t (id, active) values (1, true) returning active",
    )
    .expect("INSERT … RETURNING active runs");
    assert_eq!(
        result.data.column_types,
        vec![Some(Type::Bool)],
        "bool type recovered"
    );
    assert_eq!(
        result.data.rows[0][0],
        Some("true".to_string()),
        "rendered as the bool word"
    );
}

#[test]
fn insert_returning_computed_expression_is_typeless() {
    // 3g: a computed RETURNING projection has no base-table origin,
    // so its recovered type is None (renders with neutral alignment).
    let (_project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::Int), ("n", Type::Int)],
        &["id"],
    );
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into t (id, n) values (1, 5) returning n + 1",
    )
    .expect("INSERT … RETURNING <expr> runs");
    assert_eq!(
        result.data.column_types,
        vec![None],
        "computed projection is typeless"
    );
    assert_eq!(
        result.data.rows[0][0],
        Some("6".to_string()),
        "engine evaluated n + 1"
    );
}

#[test]
fn insert_returning_recovers_multiple_bare_column_types() {
    // 3g type recovery spans the playground vocabulary. RETURNING
    // reuses the SELECT column-origin path (`resolve_select_column_
    // types`), exhaustively type-tested on the SELECT side; this
    // pins a representative spread reached via the RETURNING tail.
    let (_project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[
            ("id", Type::Int),
            ("txt", Type::Text),
            ("amount", Type::Decimal),
            ("ratio", Type::Real),
            ("flag", Type::Bool),
        ],
        &["id"],
    );
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into t (id, txt, amount, ratio, flag) values (1, 'a', 9.50, 1.5, true) returning id, txt, amount, ratio, flag",
    )
    .expect("INSERT … RETURNING <cols> runs");
    assert_eq!(
        result.data.column_types,
        vec![
            Some(Type::Int),
            Some(Type::Text),
            Some(Type::Decimal),
            Some(Type::Real),
            Some(Type::Bool),
        ],
        "each bare-column RETURNING ref recovered its playground type",
    );
}

#[test]
fn multirow_autofill_returning_yields_distinct_generated_ids() {
    // DA gate (3d × 3g): multi-row INSERT with an omitted shortid PK
    // AND RETURNING — the auto-fill rewrite produces N tuples with N
    // distinct generated ids, and RETURNING * must surface all N
    // rows each carrying its own generated id.
    let (_project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::ShortId), ("label", Type::Text)],
        &["id"],
    );
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into t (label) values ('a'), ('b'), ('c') returning *",
    )
    .expect("multi-row auto-fill INSERT … RETURNING * runs");
    assert_eq!(result.rows_affected, 3, "three rows inserted");
    assert_eq!(result.data.rows.len(), 3, "three rows returned");
    let id_idx = result
        .data
        .columns
        .iter()
        .position(|c| c == "id")
        .expect("id column");
    let ids: std::collections::BTreeSet<_> =
        result.data.rows.iter().map(|r| r[id_idx].clone()).collect();
    assert_eq!(
        ids.len(),
        3,
        "three DISTINCT generated ids via RETURNING: {:?}",
        result.data.rows
    );
    assert!(
        ids.iter()
            .all(|v| v.as_ref().is_some_and(|s| !s.is_empty())),
        "all ids non-empty"
    );
}

#[test]
fn insert_select_returning_executes_and_returns_rows() {
    // DA gate: the grammar accepts INSERT … SELECT … RETURNING; this
    // pins that it also EXECUTES through run_returning (the SELECT row
    // source feeds the insert, and RETURNING yields the inserted rows).
    let (_project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "src",
        &[("id", Type::Int), ("b", Type::Text)],
        &["id"],
    );
    create_cols(
        &db,
        &rt,
        "dst",
        &[("id", Type::Int), ("b", Type::Text)],
        &["id"],
    );
    run_sqlinsert(
        &db,
        &rt,
        "insert into src (id, b) values (1, 'x'), (2, 'y')",
    )
    .expect("seed src");
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into dst select * from src returning id, b",
    )
    .expect("INSERT … SELECT … RETURNING runs");
    assert_eq!(result.rows_affected, 2, "two rows copied");
    assert_eq!(
        result.data.rows.len(),
        2,
        "RETURNING yielded both inserted rows"
    );
    let bs: std::collections::BTreeSet<_> = result.data.rows.iter().map(|r| r[1].clone()).collect();
    assert!(bs.contains(&Some("x".to_string())) && bs.contains(&Some("y".to_string())));
}

// =================================================================
// Sub-phase 3h — UPSERT (ON CONFLICT … DO NOTHING / DO UPDATE)
// =================================================================

#[test]
fn conflict_target_columns_excluded_from_listed_columns() {
    // DA gate: the ON CONFLICT (col, …) target uses a DISTINCT role
    // from the inserted column list, so build_sql_insert's
    // listed_columns (which drives shortid auto-fill) must NOT pick
    // up the conflict-target columns. If it did, an omitted shortid
    // would look "listed" and auto-fill would wrongly skip.
    match parse_command("insert into t (name) values ('x') on conflict (id) do nothing")
        .expect("parse upsert")
    {
        Command::SqlInsert { listed_columns, .. } => {
            assert_eq!(
                listed_columns,
                vec!["name".to_string()],
                "only the inserted column list, not the conflict target",
            );
        }
        other => panic!("expected SqlInsert, got {other:?}"),
    }
}

#[test]
fn autofill_upsert_real_conflict_preserves_clause_and_excluded() {
    // DA gate (stronger than autofill_preserves_on_conflict_clause,
    // which can't tell a preserved clause from a dropped one because
    // the generated id never conflicts). Here the table has a shortid
    // PK (auto-filled) AND a UNIQUE `code`. The second insert reuses
    // code 'A', so it hits a REAL conflict: with the ON CONFLICT
    // clause preserved through the auto-fill rewrite it DO-UPDATEs via
    // excluded; if the rewrite had dropped the clause it would raise a
    // UNIQUE violation instead (the `.expect` would panic).
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    rt.block_on(db.create_table(
        "t".to_string(),
        vec![
            ColumnSpec::new("id", Type::ShortId),
            ColumnSpec {
                unique: true,
                ..ColumnSpec::new("code", Type::Text)
            },
            ColumnSpec::new("label", Type::Text),
        ],
        vec!["id".to_string()],
        None,
    ))
    .expect("create table with shortid pk + unique code");
    run_sqlinsert(
        &db,
        &rt,
        "insert into t (code, label) values ('A', 'first')",
    )
    .expect("seed");
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into t (code, label) values ('A', 'second') on conflict (code) do update set label = excluded.label",
    )
    .expect("auto-filled UPSERT with a real conflict (clause preserved)");
    assert_eq!(
        result.rows_affected, 1,
        "the conflicting row was updated, not inserted"
    );
    let rows = csv_rows(&project, "t");
    assert_eq!(
        rows.len(),
        1,
        "still one row (DO UPDATE, not a second insert)"
    );
    assert!(
        rows[0].iter().any(|c| c == "second"),
        "label updated via excluded: {rows:?}"
    );
    assert!(
        !rows[0].iter().any(|c| c == "first"),
        "old label replaced: {rows:?}"
    );
}

#[test]
fn on_conflict_do_nothing_keeps_existing_row() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::Int), ("name", Type::Text)],
        &["id"],
    );
    run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into t (id, name) values (1, 'new') on conflict (id) do nothing",
    )
    .expect("ON CONFLICT DO NOTHING runs");
    assert_eq!(result.rows_affected, 0, "conflicting row left untouched");
    let rows = csv_rows(&project, "t");
    assert_eq!(rows.len(), 1, "still one row");
    assert!(
        rows[0].iter().any(|c| c == "orig"),
        "original value kept: {rows:?}"
    );
}

#[test]
fn on_conflict_do_update_applies_excluded() {
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::Int), ("name", Type::Text)],
        &["id"],
    );
    run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into t (id, name) values (1, 'new') on conflict (id) do update set name = excluded.name",
    )
    .expect("ON CONFLICT DO UPDATE runs");
    assert_eq!(result.rows_affected, 1, "the conflicting row was updated");
    let rows = csv_rows(&project, "t");
    assert_eq!(rows.len(), 1, "still one row (updated, not inserted)");
    assert!(
        rows[0].iter().any(|c| c == "new"),
        "row updated to excluded.name: {rows:?}"
    );
}

#[test]
fn on_conflict_do_nothing_without_target() {
    let (_project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::Int), ("name", Type::Text)],
        &["id"],
    );
    run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into t (id, name) values (1, 'x') on conflict do nothing",
    )
    .expect("ON CONFLICT (no target) DO NOTHING runs");
    assert_eq!(
        result.rows_affected, 0,
        "any-conflict do-nothing absorbed the duplicate"
    );
}

#[test]
fn autofill_preserves_on_conflict_clause() {
    // DA gate / landmine: an INSERT with an omitted shortid PK AND an
    // ON CONFLICT tail. The auto-fill rewrite reconstructs INSERT …
    // VALUES …; row_source must stop before ON CONFLICT (so the
    // materialisation prepare doesn't choke) and the rewrite must
    // re-append the clause (so it isn't silently dropped). The fresh
    // generated id won't conflict, so the row inserts — the point is
    // the rewrite doesn't prepare-fail and the clause survives.
    let (project, db, _dir) = open_project_db();
    let rt = rt();
    create_cols(
        &db,
        &rt,
        "t",
        &[("id", Type::ShortId), ("label", Type::Text)],
        &["id"],
    );
    let result = run_sqlinsert(
        &db,
        &rt,
        "insert into t (label) values ('x') on conflict (id) do nothing",
    )
    .expect("auto-fill INSERT with ON CONFLICT runs (clause preserved)");
    assert_eq!(result.rows_affected, 1, "row inserted with a generated id");
    let rows = csv_rows(&project, "t");
    assert_eq!(rows.len(), 1, "one row landed");
}

#[test]
fn sql_dml_validates_literal_values_like_the_dsl() {
    // ADR-0036 Phase 1: advanced-mode SQL `INSERT` now validates each
    // literal value against its column type before the (still verbatim)
    // insert runs, sharing the DSL's per-type validators. `2025/01/15` is
    // a malformed date (slashes, not dashes) — the DSL rejects it at bind
    // time, and advanced-mode SQL now refuses it too (it used to splice the
    // literal into text and let a STRICT TEXT column accept anything).
    let (project, db, _d) = open_project_db();
    let r = rt();
    r.block_on(db.create_table(
        "T".to_string(),
        vec![
            ColumnSpec::new("id", Type::Int),
            ColumnSpec::new("d", Type::Date),
        ],
        vec!["id".to_string()],
        Some("create table T with pk id(int)".to_string()),
    ))
    .expect("create T(id int pk, d date)");

    // DSL path — validates the `date` and rejects the malformed value.
    let dsl = r.block_on(db.insert(
        "T".to_string(),
        Some(vec!["id".to_string(), "d".to_string()]),
        vec![
            Value::Number("1".to_string()),
            Value::Text("2025/01/15".to_string()),
        ],
        Some("insert".to_string()),
    ));
    assert!(
        dsl.is_err(),
        "the DSL insert path validates `date` and rejects 2025/01/15; got {dsl:?}"
    );

    // SQL path (advanced mode, full pipeline) — now REJECTS it too.
    std::fs::write(
        project.path().join("ins.commands"),
        "insert into T (id, d) values (2, '2025/01/15')\n",
    )
    .expect("write script");
    let events = r.block_on(run_replay(&db, project.path(), "ins.commands"));
    assert!(
        matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
        "advanced-mode SQL validates the `date` literal and refuses \
         2025/01/15 (ADR-0036 Phase 1); events: {events:?}"
    );
    // A valid date still inserts (the bound/verbatim path is unaffected).
    std::fs::write(
        project.path().join("ok.commands"),
        "insert into T (id, d) values (3, '2025-01-15')\n",
    )
    .expect("write script");
    let ok = r.block_on(run_replay(&db, project.path(), "ok.commands"));
    assert!(
        matches!(ok.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 1),
        "a well-formed date still inserts; events: {ok:?}"
    );
}

#[test]
fn sql_insert_expression_value_is_not_validated_and_runs() {
    // An expression position (not a bare literal) is left to the engine —
    // ADR-0036 Phase 1 has nothing static to validate, so `1 + 2` into an
    // int column computes 3 and inserts; it must not be mis-classified as
    // a literal or rejected.
    let (project, db, _d) = open_project_db();
    let r = rt();
    r.block_on(db.create_table(
        "T".to_string(),
        vec![
            ColumnSpec::new("id", Type::Int),
            ColumnSpec::new("n", Type::Int),
        ],
        vec!["id".to_string()],
        Some("create table T with pk id(int)".to_string()),
    ))
    .expect("create T");
    std::fs::write(
        project.path().join("e.commands"),
        "insert into T (id, n) values (1, 1 + 2)\n",
    )
    .expect("write script");
    let events = r.block_on(run_replay(&db, project.path(), "e.commands"));
    assert!(
        matches!(events.last(), Some(AppEvent::ReplayCompleted { count, .. }) if *count == 1),
        "the expression value executes (engine computes it); events: {events:?}"
    );
}

#[test]
fn sql_insert_multi_row_validates_each_literal() {
    // Validation applies to every literal row; a malformed `date` in the
    // second tuple is caught (ADR-0036 Phase 1 — execution is verbatim, so
    // multi-row comes for free).
    let (project, db, _d) = open_project_db();
    let r = rt();
    r.block_on(db.create_table(
        "T".to_string(),
        vec![
            ColumnSpec::new("id", Type::Int),
            ColumnSpec::new("d", Type::Date),
        ],
        vec!["id".to_string()],
        Some("create table T with pk id(int)".to_string()),
    ))
    .expect("create T");
    std::fs::write(
        project.path().join("m.commands"),
        "insert into T (id, d) values (1, '2025-01-15'), (2, '2025/02/20')\n",
    )
    .expect("write script");
    let events = r.block_on(run_replay(&db, project.path(), "m.commands"));
    assert!(
        matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
        "the malformed date in the second row is caught; events: {events:?}"
    );
}

#[test]
fn sql_insert_natural_order_validates_against_schema_columns() {
    // With no explicit column list, positions map to the schema's columns
    // in definition order (engine semantics) — validation must use that
    // mapping, not an explicit list (ADR-0036 Phase 1).
    let (project, db, _d) = open_project_db();
    let r = rt();
    r.block_on(db.create_table(
        "T".to_string(),
        vec![
            ColumnSpec::new("id", Type::Int),
            ColumnSpec::new("d", Type::Date),
        ],
        vec!["id".to_string()],
        Some("create table T with pk id(int)".to_string()),
    ))
    .expect("create T");
    std::fs::write(
        project.path().join("nat.commands"),
        "insert into T values (1, '2025/02/20')\n",
    )
    .expect("write script");
    let events = r.block_on(run_replay(&db, project.path(), "nat.commands"));
    assert!(
        matches!(events.last(), Some(AppEvent::ReplayFailed { .. })),
        "natural-order insert validates the date against column `d`; events: {events:?}"
    );
}

// =================================================================
// ADR-0036 Phase 3a — live typed-slot hint for the UPSERT
// `ON CONFLICT … DO UPDATE SET col = <rhs>` value position.
// =================================================================

#[test]
fn advanced_upsert_do_update_set_offers_typed_slot_hint() {
    // ADR-0036 Phase 3a: the `DO UPDATE SET col = ` value position
    // shares the SQL UPDATE `SET` treatment, so it drives the same
    // column-typed slot hint (boundary-aware lookahead → typed slot).
    let mut cache = SchemaCache::default();
    let cols = vec![
        TableColumn {
            name: "id".to_string(),
            user_type: Type::Int,
            not_null: false,
            has_default: false,
        },
        TableColumn {
            name: "Name".to_string(),
            user_type: Type::Text,
            not_null: false,
            has_default: false,
        },
    ];
    cache.tables.push("Customers".to_string());
    cache.columns.push("id".to_string());
    cache.columns.push("Name".to_string());
    cache.table_columns.insert("Customers".to_string(), cols);

    let input =
        "insert into Customers (id, Name) values (1, 'x') on conflict (id) do update set Name=";
    let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced);
    let Some(AmbientHint::Prose(prose)) = hint else {
        panic!("expected a Prose hint at the UPSERT SET value slot, got {hint:?}");
    };
    assert!(
        prose.contains("Name"),
        "hint names the column `Name`: {prose:?}"
    );
    assert!(
        prose.contains("quoted string"),
        "text-column hint says `quoted string`: {prose:?}"
    );
}

// =================================================================
// ADR-0036 Phase 3b — live typed-slot hints + highlighting for the
// INSERT `VALUES (…)` positions (per-position column mapping via the
// `Node::SetColumn` primitive; boundary-aware lookahead per position).
// =================================================================

/// Build a `SchemaCache` for the advanced-mode typing-surface tests.
fn vschema(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
}

fn prose_at(input: &str, schema: &SchemaCache) -> String {
    let hint = ambient_hint_in_mode(input, input.len(), None, schema, Mode::Advanced);
    match hint {
        Some(AmbientHint::Prose(p)) => p,
        other => panic!("expected a Prose hint for {input:?}, got {other:?}"),
    }
}

#[test]
fn advanced_insert_form_a_value_offers_typed_slot_hint() {
    // Form A (explicit column list): the value position maps to the
    // user-listed column, so the hint is that column's typed prose.
    let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
    let prose = prose_at("insert into Things (note) values (", &schema);
    assert!(
        prose.contains("note"),
        "names listed column `note`: {prose:?}"
    );
    assert!(
        prose.contains("quoted string"),
        "text-column prose: {prose:?}"
    );
}

#[test]
fn advanced_insert_form_b_value_maps_first_column() {
    // Form B (no column list): positions map to ALL columns in
    // declaration order, so the first position is the first column.
    let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
    let prose = prose_at("insert into Things values (", &schema);
    assert!(prose.contains("k"), "names first column `k`: {prose:?}");
    assert!(prose.contains("integer"), "int-column prose: {prose:?}");
}

#[test]
fn advanced_insert_second_position_hints_second_column() {
    // Per-position mapping advances: after the first value + comma, the
    // hint is the SECOND column's typed prose.
    let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
    let prose = prose_at("insert into Things (k, note) values (5, ", &schema);
    assert!(
        prose.contains("note"),
        "second position names `note`: {prose:?}"
    );
    assert!(
        prose.contains("quoted string"),
        "text-column prose: {prose:?}"
    );
}

#[test]
fn advanced_insert_value_int_mismatch_is_caught_live() {
    let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
    let bad = classify_input_with_schema_in_mode(
        "insert into Things (k) values (3.14)",
        &schema,
        Mode::Advanced,
    );
    assert!(
        !matches!(bad, InputState::Valid),
        "decimal into int rejected live: {bad:?}"
    );
    let ok = classify_input_with_schema_in_mode(
        "insert into Things (k) values (5)",
        &schema,
        Mode::Advanced,
    );
    assert!(
        matches!(ok, InputState::Valid),
        "valid int literal parses: {ok:?}"
    );
}

#[test]
fn advanced_insert_string_into_int_is_caught_live() {
    // The Option-A win over the structural fallback: a wrong-KIND lone
    // literal (a string into an int column) is rejected WHILE TYPING,
    // not only at execution.
    let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
    let bad = classify_input_with_schema_in_mode(
        "insert into Things (k) values ('text')",
        &schema,
        Mode::Advanced,
    );
    assert!(
        !matches!(bad, InputState::Valid),
        "string into int rejected live: {bad:?}"
    );
}

#[test]
fn advanced_insert_multi_row_typed_and_mismatch_caught() {
    let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
    let ok = classify_input_with_schema_in_mode(
        "insert into Things (k, note) values (1, 'a'), (2, 'b')",
        &schema,
        Mode::Advanced,
    );
    assert!(
        matches!(ok, InputState::Valid),
        "well-formed multi-row parses: {ok:?}"
    );
    let bad = classify_input_with_schema_in_mode(
        "insert into Things (k, note) values (1, 'a'), (3.14, 'b')",
        &schema,
        Mode::Advanced,
    );
    assert!(
        !matches!(bad, InputState::Valid),
        "a mismatch in the second row is caught: {bad:?}"
    );
}

#[test]
fn advanced_insert_form_b_maps_all_columns_including_serial() {
    // SQL Form B supplies a value for EVERY column (no auto-fill), so
    // the position count = all columns, and a serial column's position
    // takes an int literal (unlike the DSL, which omits auto-gen cols).
    let schema = vschema(&[(
        "Customers",
        &[
            ("id", Type::Serial),
            ("Name", Type::Text),
            ("Email", Type::Text),
        ],
    )]);
    let state = classify_input_with_schema_in_mode(
        "insert into Customers values (1, 'Bob', 'b@c')",
        &schema,
        Mode::Advanced,
    );
    assert!(
        matches!(state, InputState::Valid),
        "Form B maps all 3 columns: {state:?}"
    );
}

#[test]
fn advanced_insert_value_expressions_still_parse_via_sql_expr() {
    // Regression guard: a non-lone-literal value position (arithmetic,
    // literal-prefixed, function call, signed number) falls through to
    // sql_expr unchanged — the typed slot must not steal it.
    let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
    for input in [
        "insert into Things (k) values (1 + 2)",
        "insert into Things (k, note) values (5, upper(note))",
        "insert into Things (k) values (-5)",
        "insert into Things (k) values ((select 1))",
    ] {
        let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced);
        assert!(
            matches!(state, InputState::Valid),
            "{input:?} must parse: {state:?}"
        );
    }
}
