//! Render-side helpers for the input panel
//! (ADR-0022 stage 2 — token-class colouring of the live
//! input field with cursor injection).
//!
//! The functions here are pure: given an input string, a
//! cursor byte position, and a theme, they return a sequence
//! of `StyledRun`s describing each contiguous span with its
//! ratatui style. `ui::render_input_panel` converts these to
//! `Span<'_>`s at render time.
//!
//! Cursor handling:
//!   - Cursor inside a token splits that token's run into
//!     before/under/after, with `under` carrying the token's
//!     colour plus `Modifier::REVERSED`.
//!   - Cursor on a whitespace gap between tokens splits the
//!     gap the same way.
//!   - Cursor at end-of-input is represented as a trailing
//!     run with empty byte range; the renderer treats that as
//!     "inverted space".
//!
//! Per ADR-0022 §2/§3, this is the silent always-on layer.
//! The error overlay (stage 4) and hint panel (stage 5)
//! compose with these runs without fighting them.

use ratatui::style::{Color, Modifier, Style};

use crate::dsl::parser::{parse_command_with_schema, parse_command_with_schema_in_mode};
use crate::dsl::walker;
use crate::dsl::{ParseError, parse_command};
use crate::mode::Mode;
use crate::theme::Theme;

/// A run of text with its byte range in the source and the
/// ratatui style it should render with. The text itself is
/// not stored — callers slice `source[byte_range.0..byte_range.1]`.
///
/// An empty byte range (`(n, n)`) represents the end-of-input
/// cursor and is rendered as an inverted space.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StyledRun {
    pub byte_range: (usize, usize),
    pub style: Style,
}

impl StyledRun {
    /// The text this run covers in `source`. Empty for the
    /// end-of-input cursor sentinel.
    #[must_use]
    pub fn text<'a>(&self, source: &'a str) -> &'a str {
        &source[self.byte_range.0..self.byte_range.1]
    }
}

/// Build the run sequence for the input panel.
///
/// Lexes `input`, assigns each token its `theme.token_color`,
/// applies the parse-error overlay if the input is in the
/// definite-error state (ADR-0022 §1, §4), applies the
/// invalid-identifier overlay if the cursor is in a known-set
/// slot with no schema match (stage 8e), preserves whitespace
/// gaps as `theme.fg` runs, then injects the cursor at
/// `cursor_byte` (clamped to `input.len()`).
#[must_use]
pub fn render_input_runs(
    input: &str,
    cursor_byte: usize,
    theme: &Theme,
    cache: &crate::completion::SchemaCache,
) -> Vec<StyledRun> {
    render_input_runs_in_mode(input, cursor_byte, theme, cache, Mode::Simple)
}

/// Mode-aware [`render_input_runs`] (ADR-0030 §8).
///
/// Advanced mode runs the highlight walker with `Mode::Advanced`
/// so SQL keywords get matched and coloured, and the
/// definite-error / schema-existence overlays use the
/// advanced-mode parse view.
#[must_use]
pub fn render_input_runs_in_mode(
    input: &str,
    cursor_byte: usize,
    theme: &Theme,
    cache: &crate::completion::SchemaCache,
    mode: Mode,
) -> Vec<StyledRun> {
    // Identity feedback view — highlight/overlay the whole input.
    render_input_runs_feedback(
        input,
        cursor_byte,
        theme,
        cache,
        mode,
        input,
        cursor_byte,
        0,
    )
}

/// [`render_input_runs_in_mode`] with a separate **feedback view** for
/// the walker-driven highlighting and overlays.
///
/// Under the `:` one-shot escape (ADR-0003) the buffer carries a leading
/// `:` that is not advanced SQL; `view` is the stripped SQL (and
/// `view_cursor` the cursor within it) so the walker highlights and
/// diagnoses the SQL itself, while the `:` prefix renders as plain text.
/// `offset` is the byte length stripped from the front — base runs and
/// overlay positions are shifted by it back into `input` coordinates.
/// Callers without a one-shot escape pass `(input, cursor, 0)` (what
/// [`render_input_runs_in_mode`] does).
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn render_input_runs_feedback(
    input: &str,
    cursor_byte: usize,
    theme: &Theme,
    cache: &crate::completion::SchemaCache,
    mode: Mode,
    view: &str,
    view_cursor: usize,
    offset: usize,
) -> Vec<StyledRun> {
    // Base highlighting runs over the SQL view, shifted into buffer
    // coordinates; the stripped prefix (the `:` + space) renders as
    // plain foreground text.
    let mut runs: Vec<StyledRun> = if offset == 0 {
        lex_to_runs_in_mode(input, theme, mode)
    } else {
        let mut r = vec![StyledRun {
            byte_range: (0, offset),
            style: ratatui::style::Style::default().fg(theme.fg),
        }];
        r.extend(
            lex_to_runs_in_mode(view, theme, mode)
                .into_iter()
                .map(|run| StyledRun {
                    byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset),
                    ..run
                }),
        );
        r
    };
    if let InputState::DefiniteErrorAt(pos) =
        classify_parse_result(parse_command_with_schema_in_mode(view, cache, mode))
    {
        overlay_error(&mut runs, pos + offset, theme);
    }
    if let Some(inv) =
        crate::completion::invalid_ident_at_cursor_in_mode(view, view_cursor, cache, mode)
    {
        overlay_error(&mut runs, inv.range.0 + offset, theme);
    }
    // Schema-aware diagnostics (ADR-0027 §2): unknown table /
    // column (ERROR), or a dubious comparison (WARNING), is
    // overlaid wherever it sits — not only under the cursor —
    // so a problem the user has typed past stays visible. The
    // mode-aware walk picks up the SQL-specific diagnostics from
    // ADR-0032 in advanced mode.
    for diag in walker::input_diagnostics_in_mode(view, Some(cache), mode) {
        let colour = match diag.severity {
            walker::Severity::Error => theme.tok_error,
            walker::Severity::Warning => theme.warning,
        };
        overlay_span(
            &mut runs,
            (diag.span.0 + offset, diag.span.1 + offset),
            colour,
        );
    }
    inject_cursor(&mut runs, input, cursor_byte, theme);
    runs
}

/// One of three mid-typing classifications (ADR-0022 §1).
///
/// Distinguishes "the user isn't done yet" from "this token
/// can never fit". Drives error overlay (this stage) and the
/// hint panel ambient mode (stage 5).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputState {
    /// No tokens at all (empty / whitespace-only input).
    Empty,
    /// Parses to a complete `Command`. The user can submit.
    Valid,
    /// Parse failed because more input was expected — every
    /// consumed token fits a known command, just not all of
    /// it is here yet.
    IncompleteAtEof,
    /// Parse failed at a token strictly inside the input —
    /// no continuation can recover. The byte offset is the
    /// failing token's start.
    DefiniteErrorAt(usize),
}

/// Classify `input` into one of the three mid-typing states.
///
/// Schemaless. Wrong-count / wrong-type value-list cases that
/// only the schema-aware parser would catch surface as `Valid`
/// here. For UX-correct classification at typing time prefer
/// [`classify_input_with_schema`] — `render_input_runs` always
/// does. Kept public because handoff-11/12 regression tests use
/// it for schema-independent assertions (cheap, predictable).
///
/// ADR-0022 §13: cheap (lex + parse).
#[must_use]
pub fn classify_input(input: &str) -> InputState {
    if input.trim().is_empty() {
        return InputState::Empty;
    }
    classify_parse_result(parse_command(input))
}

/// Schema-aware variant of [`classify_input`].
///
/// Threads the `SchemaCache` through `parse_command_with_schema`
/// so that typed-slot rejections (Phase D — wrong-count Form B
/// value lists, wrong-type column values, etc.) surface as
/// `DefiniteErrorAt`/`IncompleteAtEof` at typing time, before
/// the user submits.
#[must_use]
pub fn classify_input_with_schema(
    input: &str,
    cache: &crate::completion::SchemaCache,
) -> InputState {
    if input.trim().is_empty() {
        return InputState::Empty;
    }
    classify_parse_result(parse_command_with_schema(input, cache))
}

/// Mode-aware [`classify_input_with_schema`].
///
/// Walks the input in `mode` so the simple-mode DSL surface is
/// classified against the DSL grammar rather than the advanced SQL
/// grammar — relevant for the shared `insert`/`update`/`delete` entry
/// words (ADR-0033 Amendment 3). The mode-less entry point keeps its
/// advanced-mode behaviour.
#[must_use]
pub fn classify_input_with_schema_in_mode(
    input: &str,
    cache: &crate::completion::SchemaCache,
    mode: Mode,
) -> InputState {
    if input.trim().is_empty() {
        return InputState::Empty;
    }
    classify_parse_result(crate::dsl::parser::parse_command_with_schema_in_mode(
        input, cache, mode,
    ))
}

fn classify_parse_result(result: Result<crate::dsl::Command, ParseError>) -> InputState {
    match result {
        Ok(_) => InputState::Valid,
        Err(ParseError::Empty) => InputState::Empty,
        Err(err @ ParseError::Invalid { position, .. }) => {
            // `at_eof` is the parser's own classification: true
            // when more input would (potentially) help, false
            // when a specific token is in the wrong place.
            // Custom-error inputs (try_map failures) currently
            // map to `at_eof = true` — see the field docstring
            // on `ParseError::Invalid::at_eof`.
            if err.at_eof() {
                InputState::IncompleteAtEof
            } else {
                InputState::DefiniteErrorAt(position)
            }
        }
    }
}

/// Ambient hint-panel content for the user's current input
/// (ADR-0022 §6, stage 8b). The renderer dispatches on the
/// returned variant.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AmbientHint {
    /// Single-line prose hint — used for "submit with Enter",
    /// IncompleteAtEof with no keyword candidates (i.e. an
    /// identifier or punctuation slot), and definite-error
    /// states with optional usage template.
    Prose(String),
    /// Multi-candidate (or single-candidate) completion at
    /// the cursor. Each item carries its kind so the
    /// renderer can colour keywords differently from
    /// schema-identifiers (post-stage-8 user feedback).
    /// The selected item — if any — gets bold + brighter
    /// colour; `<` / `>` markers appear at the edges when
    /// items overflow the panel width.
    Candidates {
        items: Vec<crate::completion::Candidate>,
        /// Index into `items` of the currently-inserted Tab
        /// candidate (per the live `LastCompletion` memo), or
        /// `None` if the user hasn't pressed Tab yet.
        selected: Option<usize>,
    },
}

/// Compute the simple-mode ambient hint for the input panel
/// (ADR-0022 §6). Thin wrapper over [`ambient_hint_in_mode`];
/// advanced-mode callers pass the active mode instead.
///
/// Returns `None` for empty input — caller falls back to
/// `panel.hint_empty`.
#[must_use]
pub fn ambient_hint(
    input: &str,
    cursor: usize,
    memo: Option<&crate::completion::LastCompletion>,
    cache: &crate::completion::SchemaCache,
) -> Option<AmbientHint> {
    ambient_hint_in_mode(input, cursor, memo, cache, Mode::Simple)
}

/// Mode-aware ambient hint for the input panel (ADR-0022
/// Amendment 1).
///
/// Walks the input in `mode` so advanced-mode SQL surfaces slot
/// hints + completion candidates instead of the simple-mode
/// "this is SQL" gate. The simple-mode entry point [`ambient_hint`]
/// forwards here with `Mode::Simple`.
///
/// In simple mode, when the line is a *definite* DSL error but the
/// same line would parse in advanced mode, the DSL error prose is
/// suffixed with the `advanced_mode.also_valid_sql` pointer — so the
/// user keeps the actionable DSL fix *and* learns it would run as SQL
/// in advanced mode (ADR-0033 Amendment 3). Mid-typing (incomplete)
/// input is not suffixed, to avoid noise during normal DSL entry.
///
/// Returns `None` for empty input — caller falls back to
/// `panel.hint_empty`.
#[must_use]
pub fn ambient_hint_in_mode(
    input: &str,
    cursor: usize,
    memo: Option<&crate::completion::LastCompletion>,
    cache: &crate::completion::SchemaCache,
    mode: Mode,
) -> Option<AmbientHint> {
    let core = ambient_hint_core_in_mode(input, cursor, memo, cache, mode);
    // Combine: a simple-mode *definite* DSL error that would run in
    // advanced mode keeps its DSL prose and gains the mode pointer.
    // Skip the "command complete" prose — appending the pointer to a
    // "submit this" hint would be contradictory (and that prose can
    // come from the hint's schemaless fallback even when the
    // schema-aware classify is a definite error — a pre-existing
    // quirk this combine step deliberately does not amplify).
    if mode == Mode::Simple
        && let Some(AmbientHint::Prose(message)) = &core
        && *message != crate::t!("hint.ambient_complete")
        && let Some(suffix) = advanced_alternative_note(input, cache)
    {
        return Some(AmbientHint::Prose(format!("{message} {suffix}")));
    }
    core
}

/// The `advanced_mode.also_valid_sql` pointer string, or `None`.
///
/// Returns the pointer when a simple-mode line is a *definite* DSL
/// error (not merely incomplete) yet would be **valid** in advanced
/// mode (ADR-0033 Amendment 5).
///
/// "Valid" is the ADR-0027 sense: the advanced-mode input-validity
/// verdict comes back as `None` (no Warning, no Error) after every
/// static check — parse, schema existence, type slots, INSERT arity,
/// predicate warnings, and any future addition. The pointer fires only
/// when switching modes is **actually** the next step the user can
/// take to make the line valid: a `Severity::Warning` or
/// `Severity::Error` from the advanced-mode pipeline means switching
/// wouldn't resolve the line, so the pointer would mislead.
///
/// Issue #1 origin: previously the gate was a syntactic "would parse"
/// check, which fired for the user's reported case (4-col table,
/// 3 positional values) where the line parses but fails at the engine.
/// Now the gate reads `input_verdict_in_mode` so any static
/// rejection — including the Form B arity diagnostic added in this
/// same change (issue #1) — suppresses the pointer automatically.
#[must_use]
pub fn advanced_alternative_note(
    input: &str,
    cache: &crate::completion::SchemaCache,
) -> Option<String> {
    // The line must be *definitely* invalid in simple mode — a definite
    // parse error, or (issue #17) a parse that succeeds structurally but
    // carries a blocking ERROR diagnostic such as a value-count
    // mismatch. Incomplete input (still being typed) and empty input are
    // excluded so the pointer doesn't flicker mid-keystroke.
    let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple) {
        InputState::DefiniteErrorAt(_) => true,
        InputState::Valid => {
            crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Simple)
                == Some(crate::dsl::walker::outcome::Severity::Error)
        }
        InputState::Empty | InputState::IncompleteAtEof => false,
    };
    if !definite_dsl_error {
        return None;
    }
    // The validity-verdict-driven gate (ADR-0033 Amendment 5): the
    // line must be fully valid (verdict `None`) in advanced mode.
    if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some() {
        return None;
    }
    Some(crate::t!("advanced_mode.also_valid_sql"))
}

/// Education note for the simple-mode INSERT Form B count-mismatch
/// case (issue #1 sub-task 2).
///
/// When a simple-mode `insert into <T> values (…)` (Form B) supplies
/// more values than the non-auto-generated columns of `<T>` — but no
/// more than every column — the bare "expected `)`" parse error
/// doesn't explain the contract that excluded the serial / shortid
/// columns. This helper returns a note that names the columns Form B
/// expects values for, names the auto-generated ones it skips, and
/// shows the column-list (Form A) override.
///
/// Returns `None` when the input doesn't match the pattern (e.g. the
/// advanced parse fails, the schema isn't known, the table has no
/// auto-generated columns, the value count is at or below the non-auto
/// count, or the value count exceeds the total column count — which is
/// a different error class the engine surfaces directly).
///
/// Also `None` when the cross-mode `advanced_alternative_note` pointer
/// would fire for the same input: those two pieces of advice overlap
/// (both are escape hatches from the Form-B mismatch — one to advanced
/// mode, one to Form A) and showing both would clutter the error
/// without adding pedagogy. The cross-mode pointer wins because it
/// only fires when switching modes actually works (issue #1 sub-task
/// 1's gate); when it doesn't fire, this note steps in.
/// Submit-time pre-flight for a simple-mode (DSL) `Command::Insert`
/// whose positional value count doesn't match the expected count
/// (issue #17). Returns the advice line(s) to display when there is a
/// mismatch — the caller (`dispatch_dsl`) blocks dispatch whenever this
/// is `Some`, so a wrong-count insert never reaches the worker. `None`
/// when the command isn't an insert, the table is unknown, or the count
/// already matches.
///
/// This is the simple-mode counterpart of the advanced Ok-arm pre-flight
/// (`form_b_positional_count_mismatch_note`). Both modes now parse a
/// wrong-count insert as `Ok` (so the typing-time arity diagnostic can
/// fire — issue #17), so dispatch is gated here, uniformly, rather than
/// by a parse error.
///
/// Expected count: Form A (explicit `(col, …)`) → the listed count;
/// Form B/C (no list) → the user-fillable (non-auto-generated) count,
/// since the dispatch auto-fills serial/shortid (ADR-0018 §3).
///
/// Advice selection mirrors the previous Err-arm logic: the cross-mode
/// pointer wins when the same text is valid in advanced mode; otherwise
/// Form B/C shows the rich teaching note (names the fillable + auto
/// columns and the Form-A override) and Form A shows the column-list
/// arity message.
#[must_use]
pub fn dsl_insert_count_mismatch_notes(
    input: &str,
    cmd: &crate::dsl::command::Command,
    cache: &crate::completion::SchemaCache,
) -> Option<Vec<String>> {
    use crate::dsl::command::Command;
    use crate::dsl::types::Type;
    let Command::Insert {
        table,
        columns,
        values,
    } = cmd
    else {
        return None;
    };
    let table_cols = cache.table_columns.get(table)?;
    let is_auto = |t: Type| matches!(t, Type::Serial | Type::ShortId);
    let expected = columns.as_ref().map_or_else(
        || table_cols.iter().filter(|c| !is_auto(c.user_type)).count(),
        Vec::len,
    );
    if values.len() == expected {
        return None; // counts match — nothing to flag, dispatch proceeds
    }
    // Count mismatch → the caller blocks dispatch. Build the advice.
    // The cross-mode pointer is the single most useful line when the
    // same text is valid in advanced mode, so it suppresses the rest.
    if let Some(pointer) = advanced_alternative_note(input, cache) {
        return Some(vec![pointer]);
    }
    let note = if columns.is_some() {
        // Form A: the column-list arity message.
        crate::t!(
            "diagnostic.insert_arity_mismatch",
            expected = expected,
            actual = values.len()
        )
    } else {
        // Form B/C: the rich teaching note. Falls back to the all-auto
        // explanation for a table whose columns are all auto-generated
        // (the override note doesn't apply there).
        form_b_extra_values_note(input, cache).unwrap_or_else(|| {
            crate::t!(
                "diagnostic.insert_arity_mismatch_all_auto",
                table = table,
                actual = values.len()
            )
        })
    };
    Some(vec![note])
}

#[must_use]
pub fn form_b_extra_values_note(
    input: &str,
    cache: &crate::completion::SchemaCache,
) -> Option<String> {
    use crate::dsl::command::Command;
    use crate::dsl::types::Type;
    if advanced_alternative_note(input, cache).is_some() {
        return None;
    }
    let parsed = parse_command_with_schema_in_mode(input, cache, Mode::Advanced).ok()?;
    let Command::SqlInsert {
        target_table,
        listed_columns,
        literal_rows,
        ..
    } = parsed
    else {
        return None;
    };
    if !listed_columns.is_empty() || literal_rows.is_empty() {
        return None;
    }
    let table_cols = cache.table_columns.get(&target_table)?;
    let is_auto = |t: Type| matches!(t, Type::Serial | Type::ShortId);
    let auto: Vec<&str> = table_cols
        .iter()
        .filter(|c| is_auto(c.user_type))
        .map(|c| c.name.as_str())
        .collect();
    if auto.is_empty() {
        return None;
    }
    let non_auto: Vec<&str> = table_cols
        .iter()
        .filter(|c| !is_auto(c.user_type))
        .map(|c| c.name.as_str())
        .collect();
    // Defence in depth: a table that is all-auto has no non-auto
    // column to talk about. The cross-mode-pointer gate above would
    // normally shield us (the line works in advanced — the pointer
    // fires and we return early), but the helper is `pub` and should
    // not produce "expects 0 values for " on its own.
    if non_auto.is_empty() {
        return None;
    }
    // Fire whenever the supplied count differs from Form B's expected
    // count — the teaching message is forward-looking (it states what
    // Form B expects + the override path) so it works for under-supply,
    // over-supply within the column range, and over-supply past the
    // total column count alike (issue #1 siblings task — over- /
    // under-supply previously fell through to the bare parse error).
    let value_count = literal_rows[0].len();
    if value_count == non_auto.len() {
        return None;
    }
    let expected_phrase = if non_auto.len() == 1 {
        format!("1 value for {}", quote_join_and(&non_auto))
    } else {
        format!(
            "{count} values for {names}",
            count = non_auto.len(),
            names = quote_join_and(&non_auto),
        )
    };
    let auto_phrase = if auto.len() == 1 {
        format!(
            "{name} is auto-generated and filled automatically",
            name = quote_join_and(&auto),
        )
    } else {
        format!(
            "{names} are auto-generated and filled automatically",
            names = quote_join_and(&auto),
        )
    };
    let all_cols = table_cols
        .iter()
        .map(|c| c.name.as_str())
        .collect::<Vec<_>>()
        .join(", ");
    Some(crate::t!(
        "insert.form_b_extra_values_note",
        table = target_table,
        expected_phrase = expected_phrase,
        auto_phrase = auto_phrase,
        all_cols = all_cols,
    ))
}

/// Pre-flight note for advanced-mode `insert into <T> values (…)`
/// (positional, no column list) when the value count doesn't match
/// the table's column count (issue #1 sub-task 3).
///
/// The advanced-mode SQL grammar accepts any positional value count;
/// the engine then rejects the line with a NOT-NULL / type-mismatch
/// error that doesn't tell the user about the column-list override.
/// This helper detects the case at dispatch time and returns a note
/// that names the rule, lists the target table's columns, and shows
/// the column-list (Form A) override.
///
/// Returns `None` when the input doesn't match the pattern (the parse
/// isn't a Form B `SqlInsert`, the schema isn't known, the table has
/// no auto-generated columns or no non-auto columns, the literal-row
/// shape is empty, or every row's value count already matches the
/// column count).
///
/// Conservative on multi-row VALUES: fires only when **every** row's
/// value count is the same wrong number. Mixed-length rows fall
/// through to the engine error — they're a different shape of mistake.
#[must_use]
pub fn form_b_positional_count_mismatch_note(
    parsed: &crate::dsl::command::Command,
    cache: &crate::completion::SchemaCache,
) -> Option<String> {
    use crate::dsl::command::Command;
    use crate::dsl::types::Type;
    let Command::SqlInsert {
        target_table,
        listed_columns,
        literal_rows,
        ..
    } = parsed
    else {
        return None;
    };
    if !listed_columns.is_empty() || literal_rows.is_empty() {
        return None;
    }
    let table_cols = cache.table_columns.get(target_table)?;
    let col_count = table_cols.len();
    let row_lens: Vec<usize> = literal_rows.iter().map(Vec::len).collect();
    // Only fire when every row is the same (wrong) length.
    let first_len = row_lens[0];
    if !row_lens.iter().all(|n| *n == first_len) {
        return None;
    }
    if first_len == col_count {
        return None;
    }
    let is_auto = |t: Type| matches!(t, Type::Serial | Type::ShortId);
    // The override only makes sense when (a) there's something to
    // skip and (b) something to list. A table that is all-auto can't
    // omit anything via the column list; a table with no autos
    // doesn't benefit from the column-list form for this purpose.
    let has_auto = table_cols.iter().any(|c| is_auto(c.user_type));
    if !has_auto {
        return None;
    }
    let non_auto: Vec<&str> = table_cols
        .iter()
        .filter(|c| !is_auto(c.user_type))
        .map(|c| c.name.as_str())
        .collect();
    if non_auto.is_empty() {
        return None;
    }
    let all_cols_list = quote_join_and(
        &table_cols
            .iter()
            .map(|c| c.name.as_str())
            .collect::<Vec<_>>(),
    );
    let non_auto_csv = non_auto.join(", ");
    Some(crate::t!(
        "insert.form_b_positional_count_mismatch_note",
        table = target_table,
        col_count = col_count,
        col_list = all_cols_list,
        supplied = first_len,
        non_auto_csv = non_auto_csv,
    ))
}

/// Format `items` as an English-prose list with backtick-quoted
/// identifiers: `["a"]` → `` `a` ``, `["a","b"]` → `` `a` and `b` ``,
/// `["a","b","c"]` → `` `a`, `b`, and `c` `` (Oxford comma).
fn quote_join_and(items: &[&str]) -> String {
    match items {
        [] => String::new(),
        [only] => format!("`{only}`"),
        [a, b] => format!("`{a}` and `{b}`"),
        rest => {
            let head: Vec<String> = rest[..rest.len() - 1]
                .iter()
                .map(|s| format!("`{s}`"))
                .collect();
            format!("{}, and `{}`", head.join(", "), rest[rest.len() - 1])
        }
    }
}

fn ambient_hint_core_in_mode(
    input: &str,
    cursor: usize,
    memo: Option<&crate::completion::LastCompletion>,
    cache: &crate::completion::SchemaCache,
    mode: Mode,
) -> Option<AmbientHint> {
    if input.trim().is_empty() {
        return None;
    }
    // Mid-cycle through Tab candidates: the memo carries the
    // candidate list captured when Tab was first pressed, plus
    // the current selection_idx. While the memo is alive the
    // hint shows that exact list — recomputing at the
    // post-insert cursor would whiplash the panel through "what
    // comes next at the new cursor" between cycles. Closes
    // the user-reported #4 in stage-8 testing.
    if let Some(m) = memo {
        return Some(AmbientHint::Candidates {
            items: m.candidates.clone(),
            selected: Some(m.selection_idx),
        });
    }
    // Completion candidates at the cursor, computed once: both the
    // diagnostic-shadow check below and the candidate ladder
    // further down consume them. `candidates_at_cursor_in_mode`
    // narrows column candidates to the active table and runs the
    // §10.6 look-ahead, so it is the authoritative "what can go
    // here" set.
    let completion = crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);

    // Schema-aware diagnostics (ADR-0027 §2). `input_diagnostics`
    // is non-empty only for a command that *structurally parses*
    // — so a non-empty result means "this command is complete
    // and submittable, but wrong or dubious". That is the single
    // most important thing to tell the user, ahead of slot hints
    // and completions, so it is checked early — right after the
    // Tab-cycle memo. An unknown name is an ERROR, a dubious
    // comparison a WARNING; the diagnostic under the cursor wins
    // (the panel explains where the user is looking), else the
    // most severe one. The error overlay still marks every
    // flagged token; this panel carries the *why*.
    //
    // F1 (ADR-0022): the hint panel *is* the completion UI, so a
    // premature "unknown table/column" ERROR on the very token the
    // user is still typing must not shadow its completion. When an
    // under-cursor ERROR overlaps the (non-empty) partial a
    // candidate would replace, prefer the candidates.
    let diagnostics = crate::dsl::walker::input_diagnostics_in_mode(input, Some(cache), mode);
    if let Some(diag) = pick_hint_diagnostic(&diagnostics, cursor.min(input.len())) {
        let typing_over_diag = diag.severity == crate::dsl::walker::Severity::Error
            && completion.as_ref().is_some_and(|c| {
                let (replace_start, replace_end) = c.replaced_range;
                let (diag_start, diag_end) = diag.span;
                replace_end > replace_start
                    && !c.candidates.is_empty()
                    && diag_start < replace_end
                    && replace_start < diag_end
            });
        if !typing_over_diag {
            return Some(AmbientHint::Prose(diag.message.clone()));
        }
    }
    // Resolve the walker-side `HintMode` at the cursor. This
    // detects value-literal-slot and NewName-slot positions
    // declaratively (ADR-0024 §HintMode-per-node) — the
    // ambient-hint ladder dispatches on the returned variant
    // before falling through to the generic candidates / prose
    // framings.
    //
    // We pass the `leading` slice (input up to the start of any
    // partial identifier the user is mid-typing) so the hint
    // mode reflects the slot expected at the token boundary,
    // not whatever the partial would resolve to.
    let leading = hint_leading_slice(input, cursor);
    // ADR-0024 §Phase D §typed-value-slots: pass the schema so
    // the resolver can narrow value-slot prose per column type
    // (Date → "Type a date as 'YYYY-MM-DD'", etc.) and surface
    // the column name when the walker has it bound.
    let resolution =
        crate::dsl::walker::hint_resolution_at_input_in_mode(leading, Some(cache), mode);
    match resolution.as_ref().map(|r| r.mode) {
        Some(crate::dsl::grammar::HintMode::ProseOnly(key)) => {
            // The cursor sits at a slot where Tab candidates
            // would be actively misleading. Surface the catalog
            // prose for the slot. Only fires at empty prefix —
            // once the user starts typing a partial, normal
            // candidate completion (e.g. `n` → `null`) applies.
            if cursor_partial_is_empty(input, cursor) {
                let detail = crate::friendly::translate(key, &[]);
                let resolution = resolution.expect("matched on resolution.mode");
                let mut composed = match resolution.column {
                    Some(column) => crate::t!(
                        "hint.value_slot_for_column",
                        column = column,
                        detail = detail
                    ),
                    None => detail,
                };
                // Form B pedagogical note: when the first value
                // slot of `insert into T values (…)` is reached
                // and T has auto-generated columns the value list
                // skips, point the user at the explicit-column
                // form so the skipped column is discoverable
                // (handoff-12 §2.2).
                if !resolution.form_b_autogen_skipped.is_empty() {
                    let columns = resolution
                        .form_b_autogen_skipped
                        .iter()
                        .map(|c| format!("`{c}`"))
                        .collect::<Vec<_>>()
                        .join(", ");
                    composed.push(' ');
                    composed.push_str(&crate::t!(
                        "hint.value_slot_autogen_skipped",
                        columns = columns
                    ));
                }
                return Some(AmbientHint::Prose(composed));
            }
        }
        Some(crate::dsl::grammar::HintMode::ForceProse(_key)) => {
            // NewName slot: show "Type a name [then <next>]".
            // The probe in `typing_name_at_cursor` reads what
            // would come *after* the name, so we still consult
            // it to populate the optional `then` clause. The
            // walker-side `ForceProse` annotation tells us
            // *that* we're in this mode; the probe tells us
            // *what comes next*.
            if let Some(t) = crate::completion::typing_name_at_cursor(input, cursor) {
                let text = t.next_after_name.map_or_else(
                    || crate::t!("hint.ambient_typing_name"),
                    |next| crate::t!("hint.ambient_typing_name_then", next = next),
                );
                return Some(AmbientHint::Prose(text));
            }
        }
        Some(crate::dsl::grammar::HintMode::IntroProse(key)) => {
            // Slot entry: surface the catalog prose so an
            // invisible-by-default ident slot (the column-name
            // `NewName` at the CREATE TABLE element position,
            // issue #4) reads as the dominant first move with
            // the keyword alternatives folded into the prose.
            // Tab candidates remain available via the parallel
            // completion surface; the user still cycles the
            // keyword set.
            return Some(AmbientHint::Prose(crate::friendly::translate(key, &[])));
        }
        Some(
            crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default,
        )
        | None => {}
    }

    // No HintMode override: candidate-or-prose ladder applies.
    // Candidates win when any exist — the panel surfaces them
    // directly because they're more actionable than prose
    // framings.
    // Candidate completion (computed once above) runs through the
    // `mode`-aware walker view (ADR-0022 Amendment 1): in advanced
    // mode SQL keywords and schema candidates surface; in simple
    // mode `select` is gated as "this is SQL" (ADR-0030 §2).
    if let Some(comp) = completion {
        return Some(AmbientHint::Candidates {
            items: comp.candidates,
            selected: None,
        });
    }
    // Invalid identifier: cursor sits in a known-set slot but
    // the typed prefix matches nothing in the schema. (Stage
    // 8e / the user's #5.)
    if let Some(inv) =
        crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
    {
        let kind = match inv.source {
            crate::dsl::grammar::IdentSource::Tables => "table",
            crate::dsl::grammar::IdentSource::Columns => "column",
            crate::dsl::grammar::IdentSource::Relationships => "relationship",
            // The `seed … set <col> as <gen>` curated vocabulary
            // (ADR-0048 D9) flags an unknown name here.
            crate::dsl::grammar::IdentSource::Generators => "generator",
            // `NewName`, `Types`, `Free` are filtered out by
            // `invalid_ident_at_cursor` (it only fires for
            // known-set sources via `completes_from_schema`), so
            // these arms are unreachable in practice — render a
            // neutral fallback rather than panic.
            _ => "identifier",
        };
        return Some(AmbientHint::Prose(crate::t!(
            "hint.ambient_invalid_ident",
            kind = kind,
            found = inv.found,
        )));
    }
    // Otherwise fall back to the prose framings from stage 5,
    // parsed in the active `mode` (ADR-0022 Amendment 1). In
    // simple mode a SQL form still surfaces the "this is SQL"
    // hint (ADR-0030 §2); in advanced mode it parses as SQL.
    //
    // Issue #2: parse *with the schema* so the expected-token prose
    // reflects the schema-aware grammar. Between two values of an
    // `insert … values (…)` tuple, the type-blind (schemaless) grammar
    // closes the tuple after one value and points at `)`; the
    // schema-aware walk knows the remaining columns and correctly
    // points at `,`. The other hint paths above already use the cache,
    // so this keeps the whole ladder schema-consistent.
    match parse_command_with_schema_in_mode(input, cache, mode) {
        Ok(_) => Some(AmbientHint::Prose(crate::t!("hint.ambient_complete"))),
        Err(ParseError::Empty) => None,
        Err(ParseError::Invalid {
            message,
            position,
            at_eof,
            expected,
        }) => {
            if at_eof {
                if expected.is_empty() {
                    Some(AmbientHint::Prose(message))
                } else {
                    let joined = oxford_or(&expected);
                    Some(AmbientHint::Prose(crate::t!(
                        "hint.ambient_expected",
                        expected = joined
                    )))
                }
            } else {
                let _ = position;
                // The form the user has committed to drives the
                // usage template — `add index …` shows the
                // `add index` usage, not the first `add` form.
                // Mode-aware (ADR-0042 G3): advanced-mode shared
                // entry words show their SQL form, not the DSL one.
                let usage = crate::dsl::grammar::usage_key_for_input_in_mode(input, mode)
                    .map(|key| crate::friendly::translate(key, &[]));
                Some(AmbientHint::Prose(match usage {
                    Some(u) => crate::t!(
                        "hint.ambient_error_with_usage",
                        message = message,
                        usage = u,
                    ),
                    None => message,
                }))
            }
        }
    }
}

/// Slice of `input` ending at the start of the partial
/// identifier-shape token at `cursor` (if any). Mirrors the
/// look-back used by `completion::candidates_at_cursor` so the
/// hint resolver sees the same "what was expected at the token
/// boundary" view of the world.
fn hint_leading_slice(input: &str, cursor: usize) -> &str {
    let cursor = cursor.min(input.len());
    let bytes = input.as_bytes();
    let mut start = cursor;
    while start > 0 {
        let prev = bytes[start - 1];
        if prev.is_ascii_alphanumeric() || prev == b'_' {
            start -= 1;
        } else {
            break;
        }
    }
    &input[..start]
}

/// True when the cursor is at a token boundary — no partial
/// identifier-shape token in progress.
fn cursor_partial_is_empty(input: &str, cursor: usize) -> bool {
    let cursor = cursor.min(input.len());
    let bytes = input.as_bytes();
    if cursor == 0 {
        return true;
    }
    let prev = bytes[cursor - 1];
    !(prev.is_ascii_alphanumeric() || prev == b'_')
}

/// "A, B, or C" / "A or B" / "A". Local copy because the
/// parser's identical helper is private.
fn oxford_or(items: &[String]) -> String {
    match items {
        [] => String::new(),
        [a] => a.clone(),
        [a, b] => format!("{a} or {b}"),
        rest => {
            let (last, head) = rest.split_last().expect("len >= 3");
            format!("{}, or {last}", head.join(", "))
        }
    }
}

/// Choose which diagnostic the hint panel surfaces: the one
/// whose span contains the cursor (the panel explains where the
/// user is looking), else the most severe — ERROR over WARNING,
/// ties broken leftmost.
fn pick_hint_diagnostic(
    diagnostics: &[crate::dsl::walker::Diagnostic],
    cursor: usize,
) -> Option<&crate::dsl::walker::Diagnostic> {
    diagnostics
        .iter()
        .find(|d| d.span.0 <= cursor && cursor <= d.span.1)
        .or_else(|| {
            diagnostics.iter().max_by(|a, b| {
                a.severity
                    .cmp(&b.severity)
                    .then_with(|| b.span.0.cmp(&a.span.0))
            })
        })
}

fn overlay_error(runs: &mut [StyledRun], error_byte: usize, theme: &Theme) {
    // Failing tokens have their byte_range starting exactly at
    // `error_byte`. Override the fg colour while preserving any
    // other style bits the base run carried.
    if let Some(run) = runs.iter_mut().find(|r| r.byte_range.0 == error_byte) {
        run.style = run.style.fg(theme.tok_error);
    }
    // If no run starts at error_byte, the failure is past the
    // last token (an EOF failure misclassified as definite —
    // shouldn't happen given classify_input's contract). No-op.
}

/// Recolour every run wholly inside the byte range `span` to
/// `colour`, preserving other style bits. Unlike `overlay_error`
/// — which targets the single run *starting* at a byte — this
/// covers a whole span: a diagnostic (ADR-0027) is anchored to
/// a token's exact byte range (an identifier, a literal), and
/// the matching base run sits exactly inside it.
fn overlay_span(runs: &mut [StyledRun], span: (usize, usize), colour: Color) {
    let (start, end) = span;
    for run in runs.iter_mut() {
        if run.byte_range.0 >= start && run.byte_range.1 <= end {
            run.style = run.style.fg(colour);
        }
    }
}

/// Cursor-less variant: tokenises `input` into styled runs
/// covering the full byte range, with no inverted cursor.
/// Used by the echo-line renderer (ADR-0022 §5) where there's
/// no cursor to show.
#[must_use]
pub fn lex_to_runs(input: &str, theme: &Theme) -> Vec<StyledRun> {
    lex_to_runs_in_mode(input, theme, Mode::Simple)
}

/// Mode-aware [`lex_to_runs`]. Advanced mode runs the walker
/// with `Mode::Advanced` so SQL keywords past the entry word
/// match and get highlighted (ADR-0030 §8).
#[must_use]
pub fn lex_to_runs_in_mode(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
    base_runs(input, theme, mode)
}

fn base_runs(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
    // Walker-driven highlighting (ADR-0024 §architecture, Phase F).
    // `walker::highlight_runs_in_mode` returns per-byte classes for
    // every token shape in the source; whitespace gaps are not
    // represented and we fill them with the default foreground
    // colour below.
    let classes = walker::highlight_runs_in_mode(input, mode);
    let mut runs = Vec::with_capacity(classes.len() * 2);
    let mut pos = 0;
    for class in classes {
        let (start, end) = (class.start, class.end);
        if pos < start {
            runs.push(StyledRun {
                byte_range: (pos, start),
                style: Style::default().fg(theme.fg),
            });
        }
        runs.push(StyledRun {
            byte_range: (start, end),
            style: Style::default().fg(theme.highlight_class_color(class.class)),
        });
        pos = end;
    }
    if pos < input.len() {
        runs.push(StyledRun {
            byte_range: (pos, input.len()),
            style: Style::default().fg(theme.fg),
        });
    }
    runs
}

fn inject_cursor(runs: &mut Vec<StyledRun>, input: &str, cursor_byte: usize, theme: &Theme) {
    let cursor_byte = cursor_byte.min(input.len());

    // End-of-input cursor: append the empty-range sentinel.
    if cursor_byte == input.len() {
        runs.push(StyledRun {
            byte_range: (input.len(), input.len()),
            style: Style::default()
                .fg(theme.fg)
                .add_modifier(Modifier::REVERSED),
        });
        return;
    }

    let idx = runs
        .iter()
        .position(|r| r.byte_range.0 <= cursor_byte && cursor_byte < r.byte_range.1)
        .expect("cursor_byte < input.len() ⇒ some run contains it");
    let target = runs[idx].clone();
    let (start, end) = target.byte_range;

    // Walk to the next char boundary so a multi-byte UTF-8
    // codepoint is treated as a single visual unit at the
    // cursor.
    let mut char_end = cursor_byte + 1;
    while char_end < input.len() && !input.is_char_boundary(char_end) {
        char_end += 1;
    }

    let mut replacement: Vec<StyledRun> = Vec::with_capacity(3);
    if start < cursor_byte {
        replacement.push(StyledRun {
            byte_range: (start, cursor_byte),
            style: target.style,
        });
    }
    replacement.push(StyledRun {
        byte_range: (cursor_byte, char_end),
        style: target.style.add_modifier(Modifier::REVERSED),
    });
    if char_end < end {
        replacement.push(StyledRun {
            byte_range: (char_end, end),
            style: target.style,
        });
    }

    runs.splice(idx..=idx, replacement);
}

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

    fn dark() -> Theme {
        Theme::dark()
    }

    fn reversed(r: &StyledRun) -> bool {
        r.style.add_modifier.contains(Modifier::REVERSED)
    }

    #[test]
    fn empty_input_renders_only_the_end_of_input_cursor() {
        let runs = render_input_runs("", 0, &dark(), &empty_cache());
        assert_eq!(runs.len(), 1);
        assert_eq!(runs[0].byte_range, (0, 0));
        assert!(reversed(&runs[0]));
    }

    #[test]
    fn one_shot_colon_highlights_the_sql_and_overlays_no_error() {
        // ADR-0003 `:` one-shot: the SQL after the `:` must highlight and
        // diagnose like real advanced mode — the `:` prefix renders as
        // plain text and a valid query carries no error overlay (the old
        // path let the walker choke on the `:` and mark it red).
        use crate::completion::{SchemaCache, TableColumn};
        use crate::dsl::types::Type;
        let theme = dark();
        let mut cache = SchemaCache::default();
        cache.tables.push("Customers".into());
        cache.columns.push("name".into());
        cache.table_columns.insert(
            "Customers".into(),
            vec![TableColumn::new("name", Type::Text)],
        );
        let input = ": select name from Customers";
        let view = "select name from Customers";
        let offset = 2; // ": "
        let runs = render_input_runs_feedback(
            input,
            input.len(),
            &theme,
            &cache,
            Mode::Advanced,
            view,
            view.len(),
            offset,
        );
        assert!(
            runs.iter().all(|r| r.style.fg != Some(theme.tok_error)),
            "a valid one-shot query must carry no error overlay: {runs:?}",
        );
        assert!(
            runs.iter()
                .any(|r| r.byte_range.0 == offset && r.style.fg == Some(theme.tok_keyword)),
            "the `select` keyword (past the `: ` prefix) is keyword-coloured: {runs:?}",
        );
        assert_eq!(
            runs.first().unwrap().byte_range.0,
            0,
            "the `:` prefix is rendered from byte 0",
        );
    }

    #[test]
    fn keyword_token_takes_keyword_colour() {
        let theme = dark();
        let runs = render_input_runs("create", 6, &theme, &empty_cache());
        // Token + end-of-input cursor.
        assert_eq!(runs.len(), 2);
        assert_eq!(runs[0].byte_range, (0, 6));
        assert_eq!(runs[0].style.fg, Some(theme.tok_keyword));
        assert!(!reversed(&runs[0]));
        assert!(reversed(&runs[1]));
    }

    #[test]
    fn cursor_inside_token_splits_into_three_runs_keeping_colour() {
        let theme = dark();
        let runs = render_input_runs("create", 3, &theme, &empty_cache());
        assert_eq!(runs.len(), 3);
        assert_eq!(runs[0].byte_range, (0, 3));
        assert_eq!(runs[1].byte_range, (3, 4));
        assert_eq!(runs[2].byte_range, (4, 6));
        // All three keep the keyword colour.
        for r in &runs {
            assert_eq!(r.style.fg, Some(theme.tok_keyword));
        }
        assert!(!reversed(&runs[0]));
        assert!(reversed(&runs[1]));
        assert!(!reversed(&runs[2]));
    }

    #[test]
    fn cursor_on_whitespace_inverts_a_single_space() {
        let theme = dark();
        // "create table" has whitespace at byte 6.
        let runs = render_input_runs("create table", 6, &theme, &empty_cache());
        // base: keyword, ws(6,7), keyword. After cursor injection
        // at the start of ws: under=(6,7) REVERSED. The
        // before/after slices are empty so we get 3 runs total.
        assert_eq!(runs.len(), 3);
        let r_under: Vec<_> = runs.iter().filter(|r| reversed(r)).collect();
        assert_eq!(r_under.len(), 1);
        assert_eq!(r_under[0].byte_range, (6, 7));
        assert_eq!(r_under[0].style.fg, Some(theme.fg));
    }

    #[test]
    fn lex_error_token_renders_in_error_colour() {
        let theme = dark();
        let runs = render_input_runs("$", 1, &theme, &empty_cache());
        // Error token (0,1), then end-of-input cursor (1,1).
        assert_eq!(runs.len(), 2);
        assert_eq!(runs[0].style.fg, Some(theme.tok_error));
    }

    #[test]
    fn whitespace_between_tokens_takes_default_fg() {
        let theme = dark();
        let runs = render_input_runs("create table", 12, &theme, &empty_cache());
        // base: keyword(0,6), ws(6,7), keyword(7,12). Plus
        // end-of-input cursor (12,12) = 4 runs.
        assert_eq!(runs.len(), 4);
        assert_eq!(runs[1].byte_range, (6, 7));
        assert_eq!(runs[1].style.fg, Some(theme.fg));
        assert_eq!(runs[3].byte_range, (12, 12));
        assert!(reversed(&runs[3]));
    }

    #[test]
    fn cursor_inside_multi_byte_string_literal_advances_to_char_boundary() {
        let theme = dark();
        // 'café' = ['(0)', c(1), a(2), f(3), é(4-5), '(6)] — é is 2 bytes.
        // Cursor at byte 4: inside é. char_end advances to 6.
        let runs = render_input_runs("'café'", 4, &theme, &empty_cache());
        let r_under: Vec<_> = runs.iter().filter(|r| reversed(r)).collect();
        assert_eq!(r_under.len(), 1);
        assert_eq!(r_under[0].byte_range, (4, 6));
    }

    #[test]
    fn end_of_input_cursor_is_an_empty_range() {
        let runs = render_input_runs("create", 6, &dark(), &empty_cache());
        let last = runs.last().expect("non-empty");
        assert_eq!(last.byte_range, (6, 6));
        assert!(reversed(last));
    }

    // ---- ambient_hint (stage 5 + stage 8b) ----

    fn empty_cache() -> crate::completion::SchemaCache {
        crate::completion::SchemaCache::default()
    }

    fn prose(input: &str, cursor: usize) -> Option<String> {
        match ambient_hint(input, cursor, None, &empty_cache()) {
            Some(AmbientHint::Prose(s)) => Some(s),
            _ => None,
        }
    }

    fn cands_hint(input: &str, cursor: usize) -> Option<Vec<String>> {
        match ambient_hint(input, cursor, None, &empty_cache()) {
            Some(AmbientHint::Candidates { items, .. }) => {
                Some(items.into_iter().map(|c| c.text).collect())
            }
            _ => None,
        }
    }

    #[test]
    fn ambient_hint_is_none_for_empty_input() {
        assert!(ambient_hint("", 0, None, &empty_cache()).is_none());
        assert!(ambient_hint("   ", 3, None, &empty_cache()).is_none());
    }

    #[test]
    fn advanced_create_table_element_position_introduces_column_name() {
        // Issue #4: at `create table T (`, the user is at the
        // ELEMENT slot of the column-def list. The current candidate
        // list shows only table-level constraint keywords (`primary`,
        // `unique`, `check`, `constraint`, `foreign`); a new column
        // is the dominant first move and is currently invisible
        // because the COLUMN_DEF branch starts with an `Ident::NewName`
        // slot which produces no concrete candidate.
        //
        // The fix wraps the ELEMENT choice in a `Hinted::IntroProse`
        // that surfaces a prose hint mentioning the column name first,
        // with the constraint keywords as the alternative. Tab
        // candidates remain available.
        let cache = crate::completion::SchemaCache::default();
        let input = "create table Orders (";
        match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
            Some(AmbientHint::Prose(p)) => {
                assert!(
                    p.to_lowercase().contains("column name"),
                    "prose must mention `column name`; got: {p:?}",
                );
                // Constraint alternatives should still be mentioned.
                assert!(
                    p.contains("primary") && p.contains("unique"),
                    "prose should mention constraint alternatives; got: {p:?}",
                );
            }
            other => panic!("expected Prose hint at ELEMENT slot; got: {other:?}"),
        }
        // Tab candidates should remain available (the keywords still cycle).
        let comp = crate::completion::candidates_at_cursor_in_mode(
            input,
            input.len(),
            &cache,
            Mode::Advanced,
        )
        .expect("completion must remain available");
        let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
        for kw in &["primary", "unique"] {
            assert!(
                texts.contains(kw),
                "Tab candidate `{kw}` must remain; got {texts:?}",
            );
        }
    }

    fn seed_cache() -> crate::completion::SchemaCache {
        use crate::completion::TableColumn;
        use crate::dsl::types::Type;
        let mut cache = crate::completion::SchemaCache::default();
        cache.tables.push("users".to_string());
        cache.columns.push("email".to_string());
        cache.table_columns.insert(
            "users".to_string(),
            vec![TableColumn::new("email", Type::Text)],
        );
        cache
    }

    #[test]
    fn seed_count_is_advertised_at_the_optional_position() {
        // Issue #26: `seed users ▮` is a complete command, so the hint
        // ladder shows only the `set` / `--seed` continuation chips —
        // the optional row count (a bare number with no candidate) was
        // invisible. An IntroProse hint that survives the trailing
        // optionals now advertises it; Tab still cycles the keywords.
        let cache = seed_cache();
        let input = "seed users ";
        match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) {
            Some(AmbientHint::Prose(p)) => {
                assert!(
                    p.contains("row count") && p.contains("20"),
                    "prose must mention the row count and the default; got: {p:?}",
                );
                assert!(
                    p.contains("set") && p.contains("--seed") && p.contains(".column"),
                    "prose should fold in the keyword + column-fill options; got: {p:?}",
                );
            }
            other => panic!("expected a Prose count hint; got: {other:?}"),
        }
        // Tab candidates remain available (completion is independent).
        let comp = crate::completion::candidates_at_cursor_in_mode(
            input,
            input.len(),
            &cache,
            Mode::Simple,
        )
        .expect("completion remains available");
        let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
        assert!(
            texts.contains(&"set") && texts.contains(&"--seed"),
            "Tab must still cycle `set` / `--seed`; got {texts:?}",
        );

        // `seed` runs in both modes (ADR-0048), so the hint must fire in
        // advanced mode too — not only simple.
        match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
            Some(AmbientHint::Prose(p)) => assert!(
                p.contains("row count"),
                "count hint must also fire in advanced mode; got: {p:?}",
            ),
            other => panic!("expected the count hint in advanced mode; got: {other:?}"),
        }
    }

    #[test]
    fn seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given() {
        // Position guard: the hint shows only while the cursor sits at
        // the count slot. Once the count is supplied — or a later clause
        // consumes input past it — it must not reappear.
        let cache = seed_cache();
        for input in ["seed users 50 ", "seed users set email = 'x' "] {
            let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple);
            let is_count_prose = matches!(
                &hint,
                Some(AmbientHint::Prose(p)) if p.contains("row count")
            );
            assert!(
                !is_count_prose,
                "count hint must not show for {input:?}; got {hint:?}"
            );
        }
    }

    #[test]
    fn seed_count_hint_also_fires_after_a_column_fill_target() {
        // The count is valid after `seed users.email` too, so the hint
        // fires there — `.email` is a real column (no diagnostic).
        let cache = seed_cache();
        let input = "seed users.email ";
        match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) {
            Some(AmbientHint::Prose(p)) => assert!(
                p.contains("row count"),
                "count hint expected after a column-fill target; got: {p:?}",
            ),
            other => panic!("expected a Prose count hint; got: {other:?}"),
        }
    }

    #[test]
    fn genuine_column_typo_in_complete_select_still_hints_via_diagnostic() {
        // Issue #6 trade-off lockdown: dropping the typing-time
        // `invalid_ident_at_cursor` flag at `sql_expr_ident` positions
        // (to avoid the false positive on function names like `sum`)
        // must not silently kill the typo signal for *genuine* column
        // typos. Once the SELECT is structurally complete (FROM is in
        // scope), the schema-existence pass fires `unknown_column`
        // and the ambient hint surfaces that diagnostic via
        // `pick_hint_diagnostic`. The user still gets the typing-time
        // warning, just through a different path.
        use crate::completion::{SchemaCache, TableColumn};
        use crate::dsl::types::Type;
        let mut cache = SchemaCache::default();
        cache.tables.push("Customers".to_string());
        let tc = vec![TableColumn {
            name: "Age".to_string(),
            user_type: Type::Int,
            not_null: false,
            has_default: false,
        }];
        for c in &tc {
            cache.columns.push(c.name.clone());
        }
        cache.table_columns.insert("Customers".to_string(), tc);
        for input in [
            "select Agx from Customers",
            "select * from Customers where Agx = 5",
            "select * from Customers where Agx",
        ] {
            match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
                Some(AmbientHint::Prose(p)) => assert!(
                    p.contains("no such column") && p.contains("Agx"),
                    "complete SELECT with column typo must surface the diagnostic hint for {input:?}; got: {p:?}",
                ),
                other => panic!(
                    "complete SELECT with column typo must produce a Prose hint for {input:?}; got: {other:?}",
                ),
            }
        }
    }

    #[test]
    fn advanced_select_partial_function_name_not_flagged_as_invalid_column() {
        // Issue #6 follow-on: while the user is typing
        // `select sum` (no `(` yet), the ambient hint must not
        // pre-emptively show "No such column: `sum`". At a SQL
        // expression position the partial could resolve to either a
        // column reference *or* a function-call name; the typing-time
        // `invalid_ident_at_cursor` check would otherwise mislead
        // because it only knows about schema columns. Submit-time
        // validation still flags a genuine column typo (the
        // `unknown_column` diagnostic only skips function-call names,
        // which require a trailing `(` — so a bare unknown ident
        // still trips at submit).
        use crate::completion::{SchemaCache, TableColumn};
        use crate::dsl::types::Type;
        let mut cache = SchemaCache::default();
        cache.tables.push("Customers".to_string());
        let tc = vec![TableColumn {
            name: "Age".to_string(),
            user_type: Type::Int,
            not_null: false,
            has_default: false,
        }];
        for c in &tc {
            cache.columns.push(c.name.clone());
        }
        cache.table_columns.insert("Customers".to_string(), tc);
        let input = "select sum";
        let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced);
        if let Some(AmbientHint::Prose(p)) = &hint {
            assert!(
                !p.contains("No such column"),
                "`select sum` mid-typing must not pre-emptively flag `sum` as an invalid column; got: {p:?}",
            );
        }
    }

    #[test]
    fn advanced_select_genuine_column_typo_before_from_warns_at_typing_time() {
        // Issue #16: the gap the issue-#6 trade-off opened. While the
        // user types `select Agx` (no FROM yet, so the schema-existence
        // diagnostic stays silent), a genuine column typo must warn at
        // typing time via the restored `invalid_ident` path — `Agx`
        // matches neither a schema column nor a known function name.
        use crate::completion::{SchemaCache, TableColumn};
        use crate::dsl::types::Type;
        let mut cache = SchemaCache::default();
        cache.tables.push("Customers".to_string());
        let tc = vec![TableColumn {
            name: "Age".to_string(),
            user_type: Type::Int,
            not_null: false,
            has_default: false,
        }];
        for c in &tc {
            cache.columns.push(c.name.clone());
        }
        cache.table_columns.insert("Customers".to_string(), tc);
        let input = "select Agx";
        match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
            Some(AmbientHint::Prose(p)) => assert!(
                p.contains("No such") && p.contains("Agx"),
                "a genuine column typo before FROM must warn at typing time; got: {p:?}",
            ),
            other => panic!("`select Agx` must surface a typing-time typo hint; got: {other:?}",),
        }
    }

    #[test]
    fn advanced_partial_typing_does_not_leak_bare_double_in_prose() {
        // Issue #5 (prose half): at `create table Orders (count` (no
        // trailing space), the user is mid-typing what's
        // grammatically a column name (`count` could be the start of
        // `counterparty`). The bare `Word("double")` from the
        // DOUBLE_PRECISION_NODES branch must not appear in the
        // ambient hint at this position — the new IntroProse hint
        // from issue #4 already covers this position by introducing
        // the element slot ("Type a column name, or a table-level
        // constraint: …"), and the user discovers the type list
        // (with `double precision` as a single composite, not bare
        // `double`) when they advance to the SQL_TYPE slot.
        let cache = crate::completion::SchemaCache::default();
        let input = "create table Orders (count";
        match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
            Some(AmbientHint::Prose(p)) => {
                assert!(
                    !p.contains("`double`"),
                    "bare `double` must not appear in the prose; got: {p:?}",
                );
            }
            other => panic!("expected Prose hint at partial column name; got: {other:?}"),
        }
    }

    #[test]
    fn advanced_type_position_offers_double_precision_not_bare_double() {
        // Issue #5: at the SQL_TYPE position (`create table Orders
        // (count `), the candidate list previously surfaced `double`
        // as a peer of the playground's regular types — the user
        // sees a leading "double" alongside int/text/etc. and has
        // to know it's the start of the two-word `double precision`
        // alias. The fix surfaces `double precision` as a single
        // composite candidate and suppresses the bare `double`.
        let cache = crate::completion::SchemaCache::default();
        let input = "create table Orders (count ";
        let comp = crate::completion::candidates_at_cursor_in_mode(
            input,
            input.len(),
            &cache,
            Mode::Advanced,
        )
        .expect("completion expected at the SQL_TYPE position");
        let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
        assert!(
            !texts.contains(&"double"),
            "bare `double` must NOT appear as a type candidate; got {texts:?}",
        );
        assert!(
            texts.contains(&"double precision"),
            "`double precision` should appear as a composite type candidate; got {texts:?}",
        );
        // The regular type vocabulary still appears.
        for t in &["int", "text", "real", "serial"] {
            assert!(
                texts.iter().any(|x| x == t),
                "regular type `{t}` must remain a candidate; got {texts:?}",
            );
        }
    }

    #[test]
    fn advanced_create_table_offers_open_paren_after_name() {
        // Issue #3: typing `create table Orders ` in advanced mode
        // should offer both `with` (DSL form, ADR-0009) and `(` (SQL
        // form, ADR-0035 §4) as the next-step continuation. Today
        // only `with` surfaces — the shared-entry-word completion
        // merge only fires at the entry-word boundary, so deeper
        // positions show only the committed node's continuations.
        let cache = crate::completion::SchemaCache::default();
        let input = "create table Orders ";
        let comp = crate::completion::candidates_at_cursor_in_mode(
            input,
            input.len(),
            &cache,
            Mode::Advanced,
        )
        .expect("completion expected for advanced create-table after name");
        let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
        assert!(
            texts.contains(&"("),
            "advanced mode must offer `(` for the SQL column-def list; got {texts:?}",
        );
        assert!(
            texts.contains(&"with"),
            "advanced mode must keep `with` for the DSL form; got {texts:?}",
        );
    }

    #[test]
    fn advanced_mode_ambient_offers_sql_from_slot_candidate() {
        // ADR-0022 Amendment 1: advanced-mode ambient assistance
        // surfaces SQL completion candidates (here the FROM-slot
        // table) instead of the simple-mode "this is SQL" gate.
        let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
        let input = "select * from ";
        match ambient_hint_in_mode(
            input,
            input.len(),
            None,
            &cache,
            crate::mode::Mode::Advanced,
        ) {
            Some(AmbientHint::Candidates { items, .. }) => assert!(
                items.iter().any(|c| c.text == "Customers"),
                "FROM slot should offer table `Customers`; got {items:?}",
            ),
            other => panic!("expected candidates in advanced mode, got {other:?}"),
        }
    }

    #[test]
    fn advanced_mode_ambient_offers_dml_slot_candidates() {
        // 3k cross-cut (matrix A6, advanced surface): the ambient hint
        // panel surfaces SQL DML slot assistance in Advanced mode —
        // column candidates at an `UPDATE … SET` LHS slot and inside an
        // `INSERT … (` column list. (The simple-mode DSL value-slot
        // prose is a separate surface; this pins the §8 advanced claim.)
        use crate::dsl::types::Type;
        let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);

        let set_slot = "update Customers set ";
        match ambient_hint_in_mode(set_slot, set_slot.len(), None, &cache, Mode::Advanced) {
            Some(AmbientHint::Candidates { items, .. }) => assert!(
                items.iter().any(|c| c.text == "Name" || c.text == "id"),
                "UPDATE SET slot should offer column candidates; got {items:?}",
            ),
            other => panic!("expected candidates at the UPDATE SET slot, got {other:?}"),
        }

        let col_list = "insert into Customers (";
        match ambient_hint_in_mode(col_list, col_list.len(), None, &cache, Mode::Advanced) {
            Some(AmbientHint::Candidates { items, .. }) => assert!(
                items.iter().any(|c| c.text == "Name" || c.text == "id"),
                "INSERT column-list slot should offer column candidates; got {items:?}",
            ),
            other => panic!("expected candidates in the INSERT column list, got {other:?}"),
        }
    }

    #[test]
    fn simple_mode_ambient_does_not_surface_sql_candidates() {
        // The simple-mode entry point keeps gating SQL — advanced
        // assistance is opt-in via mode, never leaked into simple.
        let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
        let input = "select * from ";
        let hint =
            ambient_hint_in_mode(input, input.len(), None, &cache, crate::mode::Mode::Simple);
        let offers_table = matches!(
            &hint,
            Some(AmbientHint::Candidates { items, .. })
                if items.iter().any(|c| c.text == "Customers"),
        );
        assert!(
            !offers_table,
            "simple mode must not surface SQL FROM candidates: {hint:?}",
        );
    }

    // ---- F1: mid-typed token completes, not flagged (both modes) ----

    #[test]
    fn f1_mid_typed_table_prefix_shows_completion_not_error() {
        // "select * from c" — `c` prefix-matches `Customers`. The
        // hint must offer the completion, not "no such table c".
        let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
        match ambient_hint_in_mode(
            "select * from c",
            "select * from c".len(),
            None,
            &cache,
            crate::mode::Mode::Advanced,
        ) {
            Some(AmbientHint::Candidates { items, .. }) => assert!(
                items.iter().any(|c| c.text == "Customers"),
                "expected Customers completion, got {items:?}",
            ),
            other => panic!("F1: expected completion candidates, got {other:?}"),
        }
    }

    #[test]
    fn f1_genuinely_unknown_table_still_shows_error() {
        // "zzz" matches no table prefix — the error must still show.
        let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
        match ambient_hint_in_mode(
            "select * from zzz",
            "select * from zzz".len(),
            None,
            &cache,
            crate::mode::Mode::Advanced,
        ) {
            Some(AmbientHint::Prose(s)) => {
                assert!(s.contains("zzz"), "expected unknown-table error, got {s:?}");
            }
            other => panic!("F1: expected unknown-table error prose, got {other:?}"),
        }
    }

    #[test]
    fn f1_simple_mode_dsl_mid_typed_table_completes() {
        // The same shadowing affects DSL commands in simple mode:
        // "show data c" must offer Customers, not "no such table c".
        let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
        match ambient_hint_in_mode(
            "show data c",
            "show data c".len(),
            None,
            &cache,
            crate::mode::Mode::Simple,
        ) {
            Some(AmbientHint::Candidates { items, .. }) => assert!(
                items.iter().any(|c| c.text == "Customers"),
                "expected Customers completion, got {items:?}",
            ),
            other => panic!("F1 (simple): expected completion candidates, got {other:?}"),
        }
    }

    // ---- F2: projection-before-FROM narrows to the FROM table ----

    #[test]
    fn f2_empty_projection_narrows_to_from_table() {
        use crate::completion::TableColumn;
        use crate::dsl::types::Type;
        // Two tables; cursor in the EMPTY projection of
        // "select  from Orders" must offer Orders' column, NOT
        // Customers' column.
        let mut cache = schema_with_columns("Customers", &[("cust_col", Type::Text)]);
        cache.tables.push("Orders".to_string());
        cache.columns.push("order_col".to_string());
        cache.table_columns.insert(
            "Orders".to_string(),
            vec![TableColumn {
                name: "order_col".to_string(),
                user_type: Type::Int,
                not_null: false,
                has_default: false,
            }],
        );

        let comp = crate::completion::candidates_at_cursor_in_mode(
            "select  from Orders",
            7,
            &cache,
            crate::mode::Mode::Advanced,
        )
        .expect("candidates at projection cursor");
        let texts: Vec<String> = comp.candidates.iter().map(|c| c.text.clone()).collect();
        assert!(
            texts.iter().any(|t| t == "order_col"),
            "F2: should offer the FROM table's column; got {texts:?}",
        );
        assert!(
            !texts.iter().any(|t| t == "cust_col"),
            "F2: must NOT offer the other table's column; got {texts:?}",
        );
    }

    // ---- Phase D typed-slot hints (end-to-end) -------

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

    fn issue31_join_cache() -> crate::completion::SchemaCache {
        use crate::completion::{SchemaCache, TableColumn};
        use crate::dsl::types::Type;
        let mut cache = SchemaCache::default();
        let tables: &[(&str, &[(&str, Type)])] = &[
            ("Customers", &[("id", Type::Serial), ("name", Type::Text)]),
            (
                "Products",
                &[
                    ("id", Type::Serial),
                    ("name", Type::Text),
                    ("price", Type::Decimal),
                ],
            ),
            (
                "OrderLines",
                &[
                    ("id", Type::Serial),
                    ("order_id", Type::Int),
                    ("product_id", Type::Int),
                    ("count", Type::Int),
                ],
            ),
            (
                "Orders",
                &[
                    ("id", Type::Serial),
                    ("customer_id", Type::Int),
                    ("date", Type::Date),
                ],
            ),
        ];
        for (t, cols) in tables {
            cache.tables.push((*t).to_string());
            let tc: Vec<TableColumn> = cols
                .iter()
                .map(|(n, ty)| TableColumn::new(*n, *ty))
                .collect();
            for c in &tc {
                cache.columns.push(c.name.clone());
            }
            cache.table_columns.insert((*t).to_string(), tc);
        }
        cache
    }

    #[test]
    fn issue31_group_by_partial_alias_shows_alias_hint() {
        // Issue #31 end-to-end: the manual-testing query ended in
        // `… group by o`, where `o` aliases `Orders`. The ambient
        // hint must guide the learner to `o.<column>`, not claim
        // `o` is an unknown column.
        let cache = issue31_join_cache();
        let input = "select c.name as customer_name, o.id as order_id, o.date, sum(ol.count*p.price) as total from Orders o join OrderLines ol on o.id=ol.order_id join Products p on p.id=ol.product_id join Customers c on c.id=o.customer_id group by o";
        match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
            Some(AmbientHint::Prose(p)) => {
                assert!(
                    p.contains("`o` is a table alias") && p.contains("o.<column>"),
                    "expected the alias hint; got: {p:?}",
                );
                assert!(
                    !p.contains("no such column"),
                    "must not show the misleading unknown-column message; got: {p:?}",
                );
            }
            other => panic!("expected a Prose alias hint; got: {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_at_insert_first_value_shows_int_prose() {
        use crate::dsl::types::Type;
        let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        let input = "insert into Customers values (";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(p.contains("integer"), "expected int-slot prose, got: {p:?}",);
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_combines_dsl_error_with_advanced_sql_pointer() {
        // ADR-0033 Amendment 3: in simple mode, a *definite* DSL
        // error whose line would run as SQL in advanced mode keeps
        // its actionable DSL prose AND gains the
        // `advanced_mode.also_valid_sql` pointer. `Customers(id
        // serial, Name, Email)`: DSL Form B auto-skips the serial
        // `id`, so three values is a definite DSL error — but the same
        // line is a valid SQL insert (all three columns).
        use crate::dsl::types::Type;
        let cache = schema_with_columns(
            "Customers",
            &[
                ("id", Type::Serial),
                ("Name", Type::Text),
                ("Email", Type::Text),
            ],
        );
        let input = "insert into Customers values (1, 'Alice', 'a@b.c')";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                // The DSL detail survives …
                assert!(p.contains("Name"), "expected DSL slot detail, got: {p:?}");
                // … and the advanced-mode pointer is appended.
                // Substring "mode advanced" is the actionable fragment
                // (the switch command) that survives wording revisions.
                assert!(
                    p.contains("mode advanced"),
                    "expected the advanced-mode pointer, got: {p:?}",
                );
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_does_not_add_pointer_for_a_valid_dsl_command() {
        // A valid simple-mode DSL command gets no advanced pointer —
        // it isn't an error, and there is nothing to switch modes for.
        use crate::dsl::types::Type;
        let cache = schema_with_columns("Customers", &[("id", Type::Serial), ("Name", Type::Text)]);
        let input = "insert into Customers values ('Alice')";
        if let Some(AmbientHint::Prose(p)) = ambient_hint(input, input.len(), None, &cache) {
            assert!(
                !p.contains("mode advanced"),
                "a valid DSL command must not carry the advanced pointer, got: {p:?}",
            );
        }
    }

    #[test]
    fn ambient_hint_omits_advanced_pointer_when_form_b_value_count_wouldnt_match() {
        // Issue #1: simple-mode rejects `insert into Customers values
        // ('Oli', 52, 3)` because Form B skips both serials and expects
        // 2 values for `Name`, `Age`. The same line ALSO fails in
        // advanced mode: positional VALUES requires every column (4
        // total) but only 3 were supplied. The cross-mode pointer would
        // be misleading — switching modes wouldn't help — so it must
        // not be appended.
        use crate::dsl::types::Type;
        let cache = schema_with_columns(
            "Customers",
            &[
                ("id", Type::Serial),
                ("Name", Type::Text),
                ("Age", Type::Int),
                ("SerNo", Type::Serial),
            ],
        );
        let input = "insert into Customers values ('Oli', 52, 3)";
        let note = advanced_alternative_note(input, &cache);
        assert!(
            note.is_none(),
            "Form B mismatch where advanced mode would also reject — \
             the cross-mode pointer must be suppressed; got: {note:?}",
        );
    }

    #[test]
    fn ambient_hint_at_insert_second_value_shows_text_prose() {
        use crate::dsl::types::Type;
        let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        let input = "insert into Customers values (1, ";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(
                    p.contains("quoted string"),
                    "expected text-slot prose, got: {p:?}",
                );
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_at_update_set_shows_per_column_prose() {
        use crate::dsl::types::Type;
        let cache =
            schema_with_columns("Customers", &[("id", Type::Int), ("Birthday", Type::Date)]);
        let input = "update Customers set Birthday=";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(
                    p.contains("YYYY-MM-DD"),
                    "expected date-slot prose, got: {p:?}",
                );
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn in_progress_form_a_values_list_classifies_as_incomplete_not_definite_error() {
        // Regression: typing `insert into T (a, b, c) values
        // (1, 2, 3` (no closing paren yet) used to classify as
        // DefiniteErrorAt(<values position>) because the
        // walker's Optional rolled back the partial values
        // list, leaving the rest of the input as trailing junk
        // — the renderer then overlaid `values` in red. After
        // the walk_optional fix (only roll back when the inner
        // hasn't committed), the Optional propagates Incomplete
        // and the user sees no error overlay until they submit.
        assert_eq!(
            classify_input("insert into Orders (id, CustId, Total) values (42, 89, 17.59"),
            InputState::IncompleteAtEof,
        );
        assert_eq!(
            classify_input("insert into Orders (id, CustId, Total) values (42, 89"),
            InputState::IncompleteAtEof,
        );
    }

    #[test]
    fn ambient_hint_at_insert_first_value_mentions_column_name() {
        use crate::dsl::types::Type;
        let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        let input = "insert into Customers values (";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(p.contains("id"), "expected column name `id`, got {p:?}");
                assert!(p.contains("integer"), "expected int prose, got {p:?}",);
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_at_update_set_mentions_column_name() {
        use crate::dsl::types::Type;
        let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
        let input = "update Customers set Email=";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(
                    p.contains("Email"),
                    "expected column name `Email`, got {p:?}",
                );
                assert!(
                    p.contains("quoted string"),
                    "expected text prose, got {p:?}",
                );
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_at_where_mentions_column_name() {
        use crate::dsl::types::Type;
        let cache = schema_with_columns("Events", &[("ts", Type::DateTime)]);
        let input = "delete from Events where ts=";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(p.contains("ts"), "expected column name `ts`, got {p:?}");
                assert!(
                    p.contains("YYYY-MM-DD"),
                    "expected datetime prose, got {p:?}",
                );
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_at_second_insert_value_mentions_second_column() {
        use crate::dsl::types::Type;
        let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        let input = "insert into Customers values (1, ";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(
                    p.contains("Name"),
                    "expected second column `Name`, got {p:?}",
                );
                assert!(
                    p.contains("quoted string"),
                    "expected text prose, got {p:?}",
                );
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_between_values_points_to_comma_not_close_paren() {
        // Issue #2: at the cursor just after a completed first value
        // (no comma yet) the between-values fallback hint must reflect
        // the SCHEMA-AWARE grammar — there is a second column still to
        // fill, so the meaningful next token is `,`, not `)`. The bug
        // was that this fallback parsed schemalessly, so the type-blind
        // grammar closed the tuple after one value and pointed at `)`.
        // Not literal-kind-specific: each case below uses a value that
        // is type-correct for the first column, so the only difference
        // from the report's string case is the literal shape.
        use crate::dsl::types::Type;
        let cases: &[(&[(&str, Type)], &str)] = &[
            // string first value (the report's case): first col text.
            (
                &[("Name", Type::Text), ("Age", Type::Int)],
                "insert into Customers values ('Oli'",
            ),
            // integer first value: first col int.
            (
                &[("Age", Type::Int), ("Name", Type::Text)],
                "insert into Customers values (42",
            ),
            // real first value: first col real.
            (
                &[("Score", Type::Real), ("Name", Type::Text)],
                "insert into Customers values (3.5",
            ),
        ];
        for (cols, input) in cases {
            let cache = schema_with_columns("Customers", cols);
            match ambient_hint(input, input.len(), None, &cache) {
                Some(AmbientHint::Prose(p)) => {
                    assert!(
                        p.contains(','),
                        "expected a comma next-token hint for {input:?}, got: {p:?}",
                    );
                    assert!(
                        !p.contains(')'),
                        "must not point at the closing paren mid-tuple for \
                         {input:?}, got: {p:?}",
                    );
                }
                other => panic!("expected Prose for {input:?}, got {other:?}"),
            }
        }
    }

    #[test]
    fn advanced_mode_wrong_arity_insert_keeps_friendly_diagnostic_over_fallback() {
        // Issue #2 no-masking guard. In advanced mode a wrong-arity
        // insert tuple structurally matches via the type-blind path and
        // the walker emits the friendly `insert_arity_mismatch_form_b`
        // diagnostic (ADR-0033 §8.1 / Amendment 5). That diagnostic is
        // checked EARLY in the hint ladder, so the issue-#2 schema-aware
        // fallback (which would otherwise say "expected `)`") must NOT
        // shadow it. Locks ladder ordering so the fix can't regress the
        // richer message.
        use crate::dsl::types::Type;
        let cache = schema_with_columns(
            "Customers",
            &[
                ("id", Type::Serial),
                ("Name", Type::Text),
                ("Age", Type::Int),
                ("SerNo", Type::Serial),
            ],
        );
        let input = "insert into Customers values ('Oli', 52, 3)";
        match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
            Some(AmbientHint::Prose(p)) => {
                assert!(
                    p.contains("value(s) are given"),
                    "expected the friendly arity diagnostic, got: {p:?}",
                );
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_after_last_value_points_to_close_paren() {
        // Counterpart to the issue #2 fix: once every column has a
        // value, the schema-aware fallback SHOULD point at `)` — there
        // is nothing left to fill. Guards against over-correcting the
        // fix into never suggesting the close paren.
        use crate::dsl::types::Type;
        let cache = schema_with_columns("Customers", &[("Name", Type::Text), ("Age", Type::Int)]);
        let input = "insert into Customers values ('Oli', 52";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(
                    p.contains(')'),
                    "expected a close-paren next-token hint, got: {p:?}",
                );
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_at_value_slot_falls_back_to_generic_without_schema() {
        // Empty cache: the walker can't resolve the column type
        // → falls back to the generic value-literal prose.
        let cache = empty_cache();
        let input = "insert into T values (";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                // Generic prose lists all forms.
                assert!(p.contains("number"), "got: {p:?}");
                assert!(p.contains("true/false") || p.contains("true"), "got: {p:?}");
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_for_valid_input_invites_submit() {
        let h = prose("create table T with pk", 22).expect("prose hint");
        assert!(h.contains("Enter"), "got {h:?}");
    }

    #[test]
    fn ambient_hint_at_partial_keyword_position_returns_candidates() {
        // `show` mid-keyword: candidates_at_cursor returns
        // {data, table} filtered by prefix "show" — but
        // "show" doesn't match any keyword's prefix. The
        // partial prefix walk finds `show`; expected set at
        // start-of-input is the entry keywords; none start
        // with "show" except `show` itself. Hmm — let me
        // check the actual semantics: at "show" cursor 4,
        // start = 0, partial = "show", expected = entry
        // keywords. Filter by "show" → just `show`. Single
        // candidate.
        let cs = cands_hint("show", 4).expect("candidate hint");
        assert_eq!(cs, vec!["show".to_string()]);
    }

    #[test]
    fn ambient_hint_at_word_boundary_after_show_returns_all_subcommands() {
        // data / table plus the V5 list-all forms, grammar order.
        let cs = cands_hint("show ", 5).expect("candidate hint");
        assert_eq!(
            cs,
            vec![
                "data".to_string(),
                "table".to_string(),
                "tables".to_string(),
                "relationships".to_string(),
                "indexes".to_string(),
                "relationship".to_string(),
                "index".to_string(),
            ],
        );
    }

    #[test]
    fn ambient_hint_usage_matches_the_add_form_typed() {
        // A trailing-junk error after `add index …` must show
        // the `add index` usage — `add` is a multi-form
        // command and the hint used to always show the first
        // form (`add column`).
        let input = "add index on Customers (barg):";
        let h = prose(input, input.len()).expect("prose hint");
        assert!(h.contains("usage:"), "got {h:?}");
        assert!(
            h.contains("add index"),
            "should show the `add index` usage, got {h:?}",
        );
        assert!(
            !h.contains("add column"),
            "should not show the `add column` usage, got {h:?}",
        );
    }

    #[test]
    fn ambient_hint_for_definite_error_includes_usage_template() {
        let h = prose("insert into T extra", 19).expect("prose hint");
        assert!(
            h.contains("usage:"),
            "definite-error hint should include usage template, got {h:?}",
        );
        assert!(
            h.contains("insert into <Table>"),
            "should reference the insert usage template, got {h:?}",
        );
    }

    #[test]
    fn ambient_hint_for_unknown_command_falls_back_to_message() {
        // `frobulate widgets` cursor at start: candidates are
        // computed first; "frobulate" doesn't match any
        // keyword, so candidates = empty → falls back to
        // prose error message.
        let h = prose("frobulate widgets", 17).expect("prose hint");
        assert!(
            !h.contains("usage:"),
            "no entry keyword consumed → no usage template; got {h:?}",
        );
        assert!(
            h.contains("frobulate"),
            "message should mention the unknown word; got {h:?}",
        );
    }

    #[test]
    fn ambient_hint_for_invalid_identifier_says_no_such() {
        use crate::completion::SchemaCache;
        // `show data Custp` is a complete command naming a table
        // that does not exist — surfaced by the ADR-0027
        // diagnostic branch (the schema-existence ERROR).
        let cache = SchemaCache {
            tables: vec!["Customers".to_string()],
            ..SchemaCache::default()
        };
        match ambient_hint("show data Custp", 15, None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(
                    p.to_lowercase().contains("no such table"),
                    "expected 'no such table' wording, got {p:?}",
                );
                assert!(p.contains("Custp"), "should name the bad ident, got {p:?}");
            }
            other => panic!("expected Prose for invalid-ident, got {other:?}"),
        }
    }

    // ---- diagnostic hints (ADR-0027 hint wiring) ----

    #[test]
    fn ambient_hint_surfaces_unknown_table_diagnostic() {
        use crate::dsl::types::Type;
        let cache = schema_with_columns("Customers", &[("id", Type::Int)]);
        match ambient_hint("show data Missing", 17, None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(p.contains("Missing"), "got {p:?}");
                assert!(p.to_lowercase().contains("no such table"), "got {p:?}",);
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_surfaces_type_mismatch_over_submit_prose() {
        use crate::dsl::types::Type;
        // The command parses cleanly — without the diagnostic
        // branch this shows the misleading "press Enter" prose.
        let cache = schema_with_columns("Events", &[("Count", Type::Int)]);
        let input = "delete from Events where Count = 'oops'";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(!p.contains("Enter"), "should not invite submit: {p:?}");
                assert!(p.contains("Count"), "should name the column: {p:?}");
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_surfaces_like_numeric_warning() {
        use crate::dsl::types::Type;
        let cache = schema_with_columns("Events", &[("Count", Type::Int)]);
        let input = "delete from Events where Count like '9%'";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(p.contains("LIKE"), "should mention LIKE: {p:?}");
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_clean_command_still_invites_submit() {
        use crate::dsl::types::Type;
        let cache = schema_with_columns("Events", &[("Count", Type::Int)]);
        let input = "delete from Events where Count = 7";
        match ambient_hint(input, input.len(), None, &cache) {
            Some(AmbientHint::Prose(p)) => {
                assert!(p.contains("Enter"), "clean command invites submit: {p:?}");
            }
            other => panic!("expected Prose, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_diagnostic_follows_the_cursor() {
        use crate::dsl::types::Type;
        // Two type-mismatch WARNINGs; the hint names the column
        // whose offending literal the cursor sits in.
        let cache = schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
        let input = "delete from Events where a = 'x' or b = 'y'";
        let on_x = input.find("'x'").expect("'x' literal") + 1;
        let on_y = input.find("'y'").expect("'y' literal") + 1;
        let prose_at = |cursor| match ambient_hint(input, cursor, None, &cache) {
            Some(AmbientHint::Prose(p)) => p,
            other => panic!("expected Prose, got {other:?}"),
        };
        assert!(prose_at(on_x).contains("`a`"), "cursor on 'x' → column a");
        assert!(prose_at(on_y).contains("`b`"), "cursor on 'y' → column b");
    }

    #[test]
    fn ambient_hint_with_memo_carries_selected_index() {
        use crate::completion::{Candidate, CandidateKind, LastCompletion};
        let memo = LastCompletion {
            inserted_range: (5, 5),
            original_text: String::new(),
            candidates: vec![
                Candidate {
                    text: "data".to_string(),
                    kind: CandidateKind::Keyword,
                    mode: crate::completion::ModeClass::Both,
                },
                Candidate {
                    text: "table".to_string(),
                    kind: CandidateKind::Keyword,
                    mode: crate::completion::ModeClass::Both,
                },
            ],
            selection_idx: 1,
        };
        match ambient_hint("show ", 5, Some(&memo), &empty_cache()) {
            Some(AmbientHint::Candidates { items, selected }) => {
                assert_eq!(items.len(), 2);
                assert_eq!(items[0].text, "data");
                assert_eq!(items[1].text, "table");
                assert_eq!(selected, Some(1));
            }
            other => panic!("expected Candidates, got {other:?}"),
        }
    }

    #[test]
    fn ambient_hint_during_cycling_shows_memo_list_not_recomputed() {
        // Stage-8 user-reported #4: cycling through candidates
        // moves the cursor; the panel should NOT shift to "what
        // comes next at the new cursor position" — it should
        // keep showing the memo's candidate list with the
        // updated selection. Without the memo short-circuit,
        // ambient_hint would recompute candidates_at_cursor
        // post-Tab and produce a different list.
        use crate::completion::{Candidate, CandidateKind, LastCompletion};
        let memo = LastCompletion {
            inserted_range: (5, 11),
            original_text: String::new(),
            // Include candidates whose order would NOT match
            // what candidates_at_cursor("show table ", 11) would
            // produce — proves the memo's list is being used,
            // not a recomputed one.
            candidates: vec![
                Candidate {
                    text: "data".to_string(),
                    kind: CandidateKind::Keyword,
                    mode: crate::completion::ModeClass::Both,
                },
                Candidate {
                    text: "table".to_string(),
                    kind: CandidateKind::Keyword,
                    mode: crate::completion::ModeClass::Both,
                },
            ],
            selection_idx: 1,
        };
        match ambient_hint("show table ", 11, Some(&memo), &empty_cache()) {
            Some(AmbientHint::Candidates { items, selected }) => {
                assert_eq!(items.len(), 2);
                assert_eq!(items[0].text, "data");
                assert_eq!(items[1].text, "table");
                assert_eq!(selected, Some(1));
            }
            other => panic!("expected Candidates from memo, got {other:?}"),
        }
    }

    // ---- classify_input + error overlay (stage 4) ----

    #[test]
    fn classify_empty_input() {
        assert_eq!(classify_input(""), InputState::Empty);
        assert_eq!(classify_input("   "), InputState::Empty);
    }

    #[test]
    fn classify_complete_command_is_valid() {
        assert_eq!(
            classify_input("create table Customers with pk"),
            InputState::Valid,
        );
    }

    #[test]
    fn classify_partial_keyword_only_is_incomplete() {
        // `create` alone — parser fails at EOF expecting `table`.
        assert_eq!(classify_input("create"), InputState::IncompleteAtEof);
    }

    #[test]
    fn classify_partial_command_mid_clause_is_incomplete() {
        assert_eq!(
            classify_input("create table Customers"),
            InputState::IncompleteAtEof,
        );
    }

    #[test]
    fn classify_unknown_command_is_definite_error_at_zero() {
        assert_eq!(
            classify_input("frobulate widgets"),
            InputState::DefiniteErrorAt(0),
        );
    }

    #[test]
    fn classify_wrong_token_mid_command_is_definite_error_at_token_position() {
        // `create table` consumed (12 bytes inc. trailing space
        // skipped by lexer); `1Bad` lexes as Number(13)+Identifier(14).
        // Parser expects ident at position 13, finds Number — fails.
        let state = classify_input("create table 1Bad");
        match state {
            InputState::DefiniteErrorAt(pos) => assert_eq!(pos, 13),
            other => panic!("expected DefiniteErrorAt(13), got {other:?}"),
        }
    }

    #[test]
    fn classify_trailing_whitespace_does_not_create_definite_error() {
        // Trailing whitespace alone shouldn't promote an
        // incomplete-at-EOF state into a definite error.
        assert_eq!(classify_input("create   "), InputState::IncompleteAtEof,);
    }

    #[test]
    fn render_input_runs_overlays_error_on_failing_token() {
        let theme = dark();
        let runs = render_input_runs("frobulate widgets", 17, &theme, &empty_cache());
        // First run is `frobulate` at (0,9). Should be tok_error
        // colour (definite error overlay).
        assert_eq!(runs[0].byte_range, (0, 9));
        assert_eq!(runs[0].style.fg, Some(theme.tok_error));
        // Second run is whitespace, third is `widgets` — these
        // don't get the overlay (only the failing token).
        let widgets = runs.iter().find(|r| r.byte_range == (10, 17));
        assert!(widgets.is_some());
        assert_eq!(
            widgets.unwrap().style.fg,
            Some(theme.tok_identifier),
            "tokens after the error stay in their lex-class colour",
        );
    }

    #[test]
    fn render_input_runs_does_not_overlay_for_incomplete_input() {
        let theme = dark();
        let runs = render_input_runs("create", 6, &theme, &empty_cache());
        // No error overlay — `create` keeps tok_keyword.
        assert_eq!(runs[0].byte_range, (0, 6));
        assert_eq!(runs[0].style.fg, Some(theme.tok_keyword));
    }

    #[test]
    fn render_input_runs_does_not_overlay_for_valid_input() {
        let theme = dark();
        let runs = render_input_runs("create table T with pk", 22, &theme, &empty_cache());
        // None of the tokens should be tok_error.
        for r in &runs {
            assert_ne!(
                r.style.fg,
                Some(theme.tok_error),
                "no error overlay for valid input: {r:?}",
            );
        }
    }

    #[test]
    fn full_valid_command_lexes_to_each_token_class() {
        // Use a valid command — `update ... --all-rows` — with
        // a schema that actually has the table and column, so
        // no overlay (parse-error or ADR-0027 diagnostic)
        // replaces a class colour with tok_error. Tokens:
        // keyword(s), identifier(s), string literal, punct (=),
        // flag.
        use crate::dsl::types::Type;
        let theme = dark();
        let cache = schema_with_columns("T", &[("Name", Type::Text)]);
        let input = "update T set Name='hi' --all-rows";
        let runs = render_input_runs(input, input.len(), &theme, &cache);
        let fgs: Vec<_> = runs.iter().filter_map(|r| r.style.fg).collect();
        assert!(fgs.contains(&theme.tok_keyword)); // update / set
        assert!(fgs.contains(&theme.tok_identifier)); // T / Name
        assert!(fgs.contains(&theme.tok_string)); // 'hi'
        assert!(fgs.contains(&theme.tok_punct)); // =
        assert!(fgs.contains(&theme.tok_flag)); // --all-rows
        // The valid command must not have any error overlay.
        for r in &runs {
            assert_ne!(r.style.fg, Some(theme.tok_error));
        }
    }

    // ---- diagnostic overlays (ADR-0027 highlight wiring) ----

    #[test]
    fn unknown_table_is_overlaid_in_error_colour() {
        use crate::dsl::types::Type;
        let theme = dark();
        let cache = schema_with_columns("Customers", &[("id", Type::Int)]);
        let input = "show data NoSuchTable";
        // Cursor at the start — the overlay must fire even
        // though the cursor is nowhere near the bad name.
        let runs = render_input_runs(input, 0, &theme, &cache);
        let bad = runs
            .iter()
            .find(|r| r.text(input) == "NoSuchTable")
            .expect("a run for the table name");
        assert_eq!(
            bad.style.fg,
            Some(theme.tok_error),
            "diagnostic highlight is global, not cursor-local",
        );
    }

    #[test]
    fn unknown_column_is_overlaid_in_error_colour() {
        use crate::dsl::types::Type;
        let theme = dark();
        let cache = schema_with_columns("Customers", &[("id", Type::Int)]);
        let input = "delete from Customers where Nope = 1";
        let runs = render_input_runs(input, 0, &theme, &cache);
        let bad = runs
            .iter()
            .find(|r| r.text(input) == "Nope")
            .expect("a run for the column name");
        assert_eq!(bad.style.fg, Some(theme.tok_error));
    }

    #[test]
    fn type_mismatch_literal_is_overlaid_in_warning_colour() {
        use crate::dsl::types::Type;
        let theme = dark();
        let cache = schema_with_columns("Events", &[("Count", Type::Int)]);
        let input = "delete from Events where Count = 'oops'";
        let runs = render_input_runs(input, 0, &theme, &cache);
        let lit = runs
            .iter()
            .find(|r| r.text(input) == "'oops'")
            .expect("a run for the literal");
        assert_eq!(lit.style.fg, Some(theme.warning));
        // Precise span: the column name is not warning-coloured.
        let col = runs
            .iter()
            .find(|r| r.text(input) == "Count")
            .expect("a run for the column");
        assert_ne!(col.style.fg, Some(theme.warning));
    }

    #[test]
    fn like_on_numeric_column_is_overlaid_in_warning_colour() {
        use crate::dsl::types::Type;
        let theme = dark();
        let cache = schema_with_columns("Events", &[("Count", Type::Int)]);
        let input = "delete from Events where Count like '9%'";
        let runs = render_input_runs(input, 0, &theme, &cache);
        let col = runs
            .iter()
            .find(|r| r.text(input) == "Count")
            .expect("a run for the column");
        assert_eq!(col.style.fg, Some(theme.warning));
    }

    #[test]
    fn clean_schema_aware_command_has_no_diagnostic_overlay() {
        use crate::dsl::types::Type;
        let theme = dark();
        let cache = schema_with_columns("Events", &[("Count", Type::Int)]);
        let input = "delete from Events where Count = 7";
        let runs = render_input_runs(input, input.len(), &theme, &cache);
        for r in &runs {
            assert_ne!(r.style.fg, Some(theme.tok_error));
            assert_ne!(r.style.fg, Some(theme.warning));
        }
    }
}
