//! Walker entry point (ADR-0024 §architecture).
//!
//! The walker is the single source of truth for the migrated
//! commands. Phase A wires the parse consumer; completion +
//! highlighting still flow through the chumsky path until
//! Phase D / F.
//!
//! Routing rule (ADR-0024 §migration): the input's first
//! identifier-shape token decides whether the walker owns this
//! command. If it matches a registered entry word, the walker
//! takes over end-to-end (success or failure). Otherwise, the
//! router falls through to the chumsky parser, which still
//! carries every non-migrated command's grammar through Phase F.

pub mod context;
pub mod driver;
pub mod highlight;
pub mod lex_helpers;
pub mod outcome;

use crate::dsl::command::{Command, CompareOp, Expr, Operand, Predicate, RowFilter};
use crate::dsl::grammar;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::driver::{FailureKind, NodeWalkResult, walk_node};
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
use crate::dsl::walker::outcome::{Expectation, MatchedPath, WalkBound, WalkOutcome, WalkResult};

pub use context::{ColumnInfo, ConceptSpan};
pub use highlight::{highlight_runs, highlight_runs_in_mode};
pub use outcome::{Diagnostic, Severity};

/// Resolve the hint-panel mode at the end of `source`
/// (ADR-0024 §HintMode-per-node, §Phase D §typed-value-slots).
///
/// Schemaless variant. Surfaces:
/// - `HintMode::ProseOnly("hint.value_literal_slot")` at generic
///   value-literal positions (all five forms in the expected
///   set), and
/// - `HintMode::ForceProse("hint.ambient_typing_name")` at
///   `NewName` ident slots.
///
/// Schema-aware callers should use `hint_mode_at_input_with_schema`
/// instead — that variant narrows the prose to the column's
/// user-facing type at typed value slots (e.g. "Type a date
/// as 'YYYY-MM-DD'" at a date column).
#[must_use]
pub fn hint_mode_at_input(source: &str) -> Option<crate::dsl::grammar::HintMode> {
    hint_mode_at_input_inner(source, None)
}

/// Schema-aware hint-mode resolution (ADR-0024 §Phase D).
///
/// Uses the same schema reference the walker drives parse-time
/// dispatch from. When the walker enters a `Node::TypedValueSlot`
/// at the cursor position, the catalog prose narrows to the
/// column's user-facing type (e.g. `hint.value_slot_int` at an
/// int column).
#[must_use]
pub fn hint_mode_at_input_with_schema(
    source: &str,
    schema: &crate::completion::SchemaCache,
) -> Option<crate::dsl::grammar::HintMode> {
    hint_mode_at_input_inner(source, Some(schema))
}

/// Resolution of the hint-panel mode at the cursor, plus the
/// column name (if known) the cursor's value slot is keyed on.
///
/// Returned by [`hint_resolution_at_input`]. The renderer
/// composes per-column prose ("for `Email`: Type a quoted
/// string …") when `column` is `Some`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HintResolution {
    pub mode: crate::dsl::grammar::HintMode,
    pub column: Option<String>,
    /// Auto-generated columns (serial / shortid) that Form B
    /// `insert into <T> values (…)` silently skips from the
    /// value list (ADR-0018 §3). Populated *only* at the first
    /// value slot of a Form B insert whose table has such
    /// columns — empty everywhere else. The renderer appends a
    /// pedagogical note pointing the user at Form A so the
    /// skipped column is discoverable without reading help
    /// (handoff-12 §2.2).
    pub form_b_autogen_skipped: Vec<String>,
}

/// Single-walk hint resolver (ADR-0024 §Phase D §typed-value-slots).
///
/// Walks `source` against `schema`, then reports both the
/// resolved `HintMode` and the walker's `pending_value_column`
/// (if any). Returns `None` when no HintMode applies.
#[must_use]
pub fn hint_resolution_at_input(
    source: &str,
    schema: Option<&crate::completion::SchemaCache>,
) -> Option<HintResolution> {
    hint_resolution_at_input_in_mode(source, schema, crate::mode::Mode::Simple)
}

/// Mode-aware hint resolver (ADR-0022 Amendment 1).
///
/// Walks `source` in `mode` so advanced-mode SQL resolves slot
/// hints instead of being gated by the simple-mode "this is SQL"
/// path. The no-mode [`hint_resolution_at_input`] defaults to
/// `Mode::Simple`.
#[must_use]
pub fn hint_resolution_at_input_in_mode(
    source: &str,
    schema: Option<&crate::completion::SchemaCache>,
    mode: crate::mode::Mode,
) -> Option<HintResolution> {
    use crate::dsl::grammar::HintMode;

    let snap = expected_for_hint_snapshot(source, schema, mode);
    // Issue #26: an optional positional slot with no candidate text
    // (the `seed <table>` row count) left an `IntroProse` hint that
    // survived the trailing optionals. It is shown even for an
    // otherwise-complete command (empty expected set) — that is exactly
    // the `seed users ▮` case where the count is invisible. Checked
    // first, before the complete-command short-circuit below.
    if let Some(key) = snap.surviving_intro_hint {
        return Some(HintResolution {
            mode: HintMode::IntroProse(key),
            column: None,
            form_b_autogen_skipped: Vec::new(),
        });
    }
    // Empty expected set means the command is already complete
    // (`WalkOutcome::Match`) — no slot to hint at.
    if snap.expected.is_empty() {
        return None;
    }

    // Typed value slot: the walker tagged `pending_value_type`
    // on entry to a `Node::TypedValueSlot`. Per-column-type
    // prose, narrowed by the column's user-facing type, plus
    // the Form B auto-gen pedagogical note.
    if let Some(ty) = snap.pending_value_type {
        return Some(HintResolution {
            mode: HintMode::ProseOnly(catalog_key_for_value_type(ty)),
            form_b_autogen_skipped: form_b_autogen_skipped(
                source,
                snap.user_listed_columns.as_ref(),
                snap.current_table_columns.as_ref(),
                snap.pending_value_column.as_deref(),
            ),
            column: snap.pending_value_column,
        });
    }

    // Node-attached HintMode (ADR-0024 §HintMode-per-node): the
    // grammar declares the mode at the slot via `Node::Hinted`;
    // the walker recorded it in `pending_hint_mode`. The hint
    // resolver reads it directly — no signature-matching on the
    // shape of the expected set. `ProseOnly` covers the
    // value-literal fallback slot; `ForceProse` covers `NewName`
    // ident slots ("Type a name").
    match snap.pending_hint_mode {
        Some(
            mode @ (HintMode::ProseOnly(_) | HintMode::ForceProse(_) | HintMode::IntroProse(_)),
        ) => Some(HintResolution {
            mode,
            column: None,
            form_b_autogen_skipped: Vec::new(),
        }),
        Some(HintMode::SuppressProse | HintMode::Default) | None => None,
    }
}

/// Resolve the clause-concept topic the cursor sits inside, if any
/// (issue #37 / ADR clause-concept-hints, D2).
///
/// Walks the **full** `source` (not a cursor-trimmed prefix — the
/// dormant `WalkBound::Position` path is deliberately not used, so
/// the whole parse and every clause span is available), collects the
/// `Node::Concept` spans recorded in `ctx.concept_spans`, and returns
/// the `topic` of the **innermost** span containing `cursor`. The F1
/// hint surface layers `hint.concept.<topic>` on top of the per-form
/// block when this is `Some`.
///
/// "Innermost" = the containing span with the latest `start`, tie-
/// broken by the earliest `end` (narrowest): when a `references`
/// clause (`foreign_key`) wraps an `on delete` clause
/// (`referential_actions`), a cursor on `on delete` resolves to the
/// more specific inner concept. Containment is inclusive at both ends
/// so the boundary case (cursor exactly at the clause start) also
/// resolves.
#[must_use]
pub fn concept_topic_at_cursor(
    source: &str,
    cursor: usize,
    schema: Option<&crate::completion::SchemaCache>,
    mode: crate::mode::Mode,
) -> Option<&'static str> {
    if source.trim().is_empty() {
        return None;
    }
    let mut ctx = schema.map_or_else(context::WalkContext::new, |s| {
        context::WalkContext::with_schema(s)
    });
    ctx.mode = mode;
    let _ = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
    ctx.concept_spans
        .iter()
        .filter(|s| s.start <= cursor && cursor <= s.end)
        .max_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)))
        .map(|s| s.topic)
}

/// Auto-generated columns a Form B insert skips from its value
/// list — but only when the cursor sits at the *first* value
/// slot, so the pedagogical note fires once per command rather
/// than at every comma.
///
/// Returns empty unless: the command is an `insert`; no explicit
/// column list was given (Form B — `user_listed` is `None`); the
/// table has serial / shortid columns; and `pending_column` is
/// the first non-auto-generated column (the first slot).
fn form_b_autogen_skipped(
    source: &str,
    user_listed: Option<&Vec<String>>,
    table_columns: Option<&Vec<crate::completion::TableColumn>>,
    pending_column: Option<&str>,
) -> Vec<String> {
    use crate::dsl::types::Type;

    // Form A (explicit column list) and non-insert commands
    // (`update T set …` value slots also leave user_listed
    // None) are excluded — the note is insert-Form-B only.
    if user_listed.is_some() {
        return Vec::new();
    }
    if !source
        .trim_start()
        .to_ascii_lowercase()
        .starts_with("insert")
    {
        return Vec::new();
    }
    let Some(cols) = table_columns else {
        return Vec::new();
    };
    let is_auto = |t: Type| matches!(t, Type::Serial | Type::ShortId);
    let skipped: Vec<String> = cols
        .iter()
        .filter(|c| is_auto(c.user_type))
        .map(|c| c.name.clone())
        .collect();
    if skipped.is_empty() {
        return Vec::new();
    }
    // Fire only at the first value slot — i.e. when the slot's
    // column is the first non-auto-generated column.
    let first_non_auto = cols.iter().find(|c| !is_auto(c.user_type));
    match (first_non_auto, pending_column) {
        (Some(first), Some(pending)) if first.name == pending => skipped,
        _ => Vec::new(),
    }
}

fn hint_mode_at_input_inner(
    source: &str,
    schema: Option<&crate::completion::SchemaCache>,
) -> Option<crate::dsl::grammar::HintMode> {
    // Single source of truth: `hint_resolution_at_input` already
    // resolves the slot's HintMode (typed-value-slot per-type
    // prose, or the node-attached `Node::Hinted` annotation).
    // This thin wrapper just drops the resolution's column /
    // skip detail for callers that only need the mode.
    hint_resolution_at_input(source, schema).map(|r| r.mode)
}

const fn catalog_key_for_value_type(ty: crate::dsl::types::Type) -> &'static str {
    use crate::dsl::types::Type;
    match ty {
        Type::Int => "hint.value_slot_int",
        Type::Real => "hint.value_slot_real",
        Type::Decimal => "hint.value_slot_decimal",
        Type::Bool => "hint.value_slot_bool",
        Type::Text => "hint.value_slot_text",
        Type::Date => "hint.value_slot_date",
        Type::DateTime => "hint.value_slot_datetime",
        Type::Serial => "hint.value_slot_serial",
        Type::ShortId => "hint.value_slot_shortid",
    }
}

/// Completion-engine probe (ADR-0024 §Phase D §column-narrowing).
///
/// Runs a single schema-aware walk and returns the structured
/// pieces the completion engine needs: the expected set plus
/// the table-context snapshot the engine reads to narrow
/// column candidates to the active table.
#[derive(Debug, Clone)]
pub struct CompletionProbe {
    pub expected: Vec<outcome::Expectation>,
    /// Mode-class for each entry in `expected` (same length / order).
    /// All `Both` except where a shared entry word's candidate nodes were
    /// merged (ADR-0035 §4i d/e), so the completion engine can block-order
    /// (and the UI colour) mixed simple/advanced continuations.
    pub expected_modes: Vec<crate::completion::ModeClass>,
    /// Columns of `current_table` resolved at the cursor (set
    /// by an `Ident { source: Tables, writes_table: true }`
    /// earlier in the walk). `None` when the walker is
    /// schemaless or the table didn't resolve.
    pub current_table_columns: Option<Vec<crate::completion::TableColumn>>,
    /// The grammar-declared `HintMode` at the cursor's slot
    /// (`Node::Hinted`), if any. A `ProseOnly` slot tells the
    /// completion engine to suppress its keyword candidates —
    /// the node-attached signal that supersedes the
    /// expected-set signature heuristic where the grammar
    /// explicitly marks a slot prose-only (e.g. the
    /// WHERE-expression operand, which also accepts a column
    /// reference — ADR-0026 §8).
    pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
    /// The active `from_scope` at the cursor (top frame on
    /// the walker's scope stack). Empty when no FROM has been
    /// reached or the walker is schemaless. Used by the
    /// completion engine to narrow `cte.|` / `t.|` qualified-
    /// prefix candidates to a single binding's columns
    /// (ADR-0032 §10.5).
    pub from_scope: Vec<context::TableBinding>,
    /// CTE bindings visible at the cursor across all in-scope
    /// frames (innermost to outermost). The same source the
    /// qualified-prefix completion consults for `cte.|` shapes.
    pub cte_bindings: Vec<context::CteBinding>,
}

/// Run a schema-aware walk and report the completion-engine's
/// view (ADR-0024 §Phase D §column-narrowing).
#[must_use]
pub fn completion_probe(source: &str, schema: &crate::completion::SchemaCache) -> CompletionProbe {
    completion_probe_in_mode(source, schema, crate::mode::Mode::Advanced)
}

/// Mode-aware [`completion_probe`] (ADR-0030 §2).
///
/// In `Mode::Simple` the empty-input / fall-through fallback
/// omits advanced-only entry words so Tab does not offer SQL
/// commands in simple mode, and the walker — running with
/// `ctx.mode = mode` — gates SQL-only forms inline.
pub fn completion_probe_in_mode(
    source: &str,
    schema: &crate::completion::SchemaCache,
    mode: crate::mode::Mode,
) -> CompletionProbe {
    use crate::dsl::grammar::{REGISTRY, is_advanced_only};

    let mode_filtered_entries = || -> Vec<outcome::Expectation> {
        // De-duplicate shared entry words (sub-phase 3j): `insert` /
        // `update` / `delete` each appear twice in `REGISTRY` (a
        // `Simple` DSL node and an `Advanced` SQL node), but the
        // entry word should be offered to the user only once.
        let mut seen = std::collections::HashSet::new();
        REGISTRY
            .iter()
            .filter(|(c, _)| {
                mode == crate::mode::Mode::Advanced || !is_advanced_only(c.entry.primary)
            })
            .filter(|(c, _)| seen.insert(c.entry.primary))
            .map(|(c, _)| outcome::Expectation::Word(c.entry.primary))
            .collect()
    };

    if source.trim().is_empty() {
        let entries = mode_filtered_entries();
        return CompletionProbe {
            expected_modes: vec![crate::completion::ModeClass::Both; entries.len()],
            expected: entries,
            current_table_columns: None,
            pending_hint_mode: None,
            from_scope: Vec::new(),
            cte_bindings: Vec::new(),
        };
    }
    let mut ctx = context::WalkContext::with_schema(schema);
    ctx.mode = mode;
    let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
    let Some(result) = result else {
        let entries = mode_filtered_entries();
        return CompletionProbe {
            expected_modes: vec![crate::completion::ModeClass::Both; entries.len()],
            expected: entries,
            current_table_columns: None,
            pending_hint_mode: None,
            from_scope: Vec::new(),
            cte_bindings: Vec::new(),
        };
    };
    let mut expected = match result.outcome {
        outcome::WalkOutcome::Match { .. } => result.tail_expected,
        // A trailing-junk Mismatch (the shape matched, then the
        // user kept typing) still carries the outer shape's
        // skipped trailing optionals in `tail_expected` — e.g.
        // an optional `--create-fk` flag the trailing `--` is
        // starting to type. Merge them so completion still
        // offers the optional continuation. A genuine
        // mid-command mismatch has an empty `tail_expected`.
        outcome::WalkOutcome::Mismatch { expected, .. } => {
            let mut merged = expected;
            merged.extend(result.tail_expected);
            merged
        }
        outcome::WalkOutcome::Incomplete { expected, .. } => expected,
        // Validation failure path: the walker matched the
        // structural shape but the AST builder rejected (e.g.
        // Form C with column-shaped items). The walker still
        // captured the skipped-Optional expectations before the
        // validation fired — surface those so the user gets
        // useful Tab candidates even at a validation-flagged
        // position.
        outcome::WalkOutcome::ValidationFailed { .. } => result.tail_expected,
    };
    // Snapshot the cursor's lexical scope: top frame's
    // from_scope and the union of every frame's cte_bindings
    // (innermost first so a shadowing inner CTE wins on name
    // collision per ADR-0032 §10.3).
    let (from_scope, cte_bindings) = {
        let top_from = ctx
            .from_scope_stack
            .last()
            .map(|f| f.from_scope.clone())
            .unwrap_or_default();
        let mut ctes: Vec<context::CteBinding> = Vec::new();
        for frame in ctx.from_scope_stack.iter().rev() {
            for binding in &frame.cte_bindings {
                if !ctes
                    .iter()
                    .any(|c| c.name.eq_ignore_ascii_case(&binding.name))
                {
                    ctes.push(binding.clone());
                }
            }
        }
        (top_from, ctes)
    };
    // ADR-0035 §4i (d/e) + issue #3 (2026-05-28): shared-entry-word
    // completion merge. In advanced mode an entry word like `create` /
    // `drop` has several candidate nodes (SQL forms + the DSL fallback),
    // but the walk above committed to ONE, so only that node's
    // continuations are in `expected`. Walk every viable
    // (`Incomplete`) candidate from the entry word to the cursor, union
    // their next-token continuations, tag each by the producing category
    // — so completion offers all valid continuations at the current
    // depth and can colour/order them by mode.
    //
    // Originally limited to the entry-word boundary; broadened to every
    // depth so that e.g. `create table T ` in advanced mode surfaces
    // both `with` (DSL, ADR-0009) and `(` (SQL, ADR-0035 §4) as
    // continuations. A candidate whose grammar has already diverged
    // (e.g. SQL `CREATE INDEX` after `create table …`) returns
    // Mismatch and is naturally skipped — the viability check is the
    // gate, not the cursor depth.
    let mut expected_modes = vec![crate::completion::ModeClass::Both; expected.len()];
    {
        let s = skip_whitespace(source, 0);
        if let Some((kw_start, kw_end)) = consume_ident(source, s) {
            let entry = &source[kw_start..kw_end];
            let candidates = grammar::commands_for_entry_word(entry);
            use crate::dsl::grammar::CommandCategory;
            // Advanced mode merges DSL + SQL continuations across all
            // candidate nodes; Simple mode merges only when an entry word
            // has more than one DSL form (e.g. `create table` vs
            // `create m:n relationship`, ADR-0045). With a single DSL form
            // the committed node already carries every continuation, so
            // that case is left untouched (its `Both` mode-class too) —
            // keeping this zero-ripple for every existing command.
            let simple_count = candidates
                .iter()
                .filter(|(_, _, c)| *c == CommandCategory::Simple)
                .count();
            let run_merge = match mode {
                crate::mode::Mode::Advanced => candidates.len() > 1,
                crate::mode::Mode::Simple => simple_count > 1,
            };
            if run_merge {
                // (continuation word, produced-by-simple, produced-by-advanced)
                let mut tally: Vec<(&'static str, bool, bool)> = Vec::new();
                // Continuations that aren't keyword/literal-shaped
                // (notably `Punct`, used for `(` at the SQL column-list
                // boundary — issue #3). Tracked separately because the
                // tally is keyed by `&'static str`; mode classification
                // for punctuation defaults to `Both`.
                let mut punct_tally: Vec<char> = Vec::new();
                for (_, node, category) in candidates {
                    // Simple mode never offers advanced SQL continuations
                    // (ADR-0030 §2); only DSL forms contribute.
                    if mode == crate::mode::Mode::Simple && category == CommandCategory::Advanced {
                        continue;
                    }
                    let mut sctx = context::WalkContext::with_schema(schema);
                    sctx.mode = mode;
                    let (res, _) =
                        walk_one_command(source, source, kw_start, kw_end, 0, node, &mut sctx);
                    let outcome::WalkOutcome::Incomplete { expected: cont, .. } = res.outcome
                    else {
                        continue;
                    };
                    let advanced = category == CommandCategory::Advanced;
                    for e in &cont {
                        match e {
                            outcome::Expectation::Word(w) | outcome::Expectation::Literal(w) => {
                                match tally.iter_mut().find(|(kw, _, _)| kw == w) {
                                    Some(rec) => {
                                        if advanced {
                                            rec.2 = true;
                                        } else {
                                            rec.1 = true;
                                        }
                                    }
                                    None => tally.push((w, !advanced, advanced)),
                                }
                            }
                            outcome::Expectation::Punct(c) if !punct_tally.contains(c) => {
                                punct_tally.push(*c);
                            }
                            _ => {}
                        }
                    }
                }
                if !tally.is_empty() || !punct_tally.is_empty() {
                    // Augment `expected` with merged continuations the
                    // committed node lacked.
                    for &(w, _, _) in &tally {
                        let present = expected.iter().any(|e| {
                            matches!(e,
                                outcome::Expectation::Word(x) | outcome::Expectation::Literal(x)
                                if *x == w)
                        });
                        if !present {
                            expected.push(outcome::Expectation::Word(w));
                        }
                    }
                    for &c in &punct_tally {
                        let present = expected
                            .iter()
                            .any(|e| matches!(e, outcome::Expectation::Punct(x) if *x == c));
                        if !present {
                            expected.push(outcome::Expectation::Punct(c));
                        }
                    }
                    // Classify every expectation by the merged tally.
                    // Word/Literal route through the tally; Punct (and
                    // other expectation shapes) default to `Both`.
                    expected_modes = expected
                        .iter()
                        .map(|e| match e {
                            outcome::Expectation::Word(w) | outcome::Expectation::Literal(w) => {
                                tally.iter().find(|(kw, _, _)| kw == w).map_or(
                                    crate::completion::ModeClass::Both,
                                    |&(_, simple, adv)| match (simple, adv) {
                                        (true, true) | (false, false) => {
                                            crate::completion::ModeClass::Both
                                        }
                                        (false, true) => crate::completion::ModeClass::Advanced,
                                        (true, false) => crate::completion::ModeClass::Simple,
                                    },
                                )
                            }
                            _ => crate::completion::ModeClass::Both,
                        })
                        .collect();
                }
            }
        }
    }
    CompletionProbe {
        expected,
        expected_modes,
        current_table_columns: ctx.current_table_columns,
        pending_hint_mode: ctx.pending_hint_mode,
        from_scope,
        cte_bindings,
    }
}

/// The validity-indicator verdict for `source` (ADR-0027 §3).
///
/// `None` — the input would run clean (the indicator shows
/// nothing); empty / whitespace-only input is also `None`.
/// `Some(Error)` — pressing Enter now fails (a structural
/// parse failure, or a schema-existence diagnostic).
/// `Some(Warning)` — it runs, but is very likely not intended
/// (the ADR-0026 expression flags).
///
/// The verdict is the highest severity across the parse
/// outcome and the `diagnostics` set (ADR-0027 §2).
#[must_use]
pub fn input_verdict(
    source: &str,
    schema: Option<&crate::completion::SchemaCache>,
) -> Option<outcome::Severity> {
    input_verdict_in_mode(source, schema, crate::mode::Mode::Advanced)
}

/// Mode-aware [`input_verdict`] (ADR-0030 §2).
///
/// The `[ERR]` / `[WRN]` indicator reads this through
/// `App::input_verdict_for_indicator` passing the line's
/// effective mode, so a simple-mode `select` lights up ERROR
/// (the SQL-hint validation failure) and an advanced-mode
/// `select` does not.
#[must_use]
pub fn input_verdict_in_mode(
    source: &str,
    schema: Option<&crate::completion::SchemaCache>,
    mode: crate::mode::Mode,
) -> Option<outcome::Severity> {
    use outcome::Severity;
    if source.trim().is_empty() {
        return None;
    }
    let mut ctx = schema.map_or_else(context::WalkContext::new, context::WalkContext::with_schema);
    ctx.mode = mode;
    let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
    let Some(result) = result else {
        // The first token is not a registered command word —
        // typing this and pressing Enter fails.
        return Some(Severity::Error);
    };
    let outcome_severity = match result.outcome {
        outcome::WalkOutcome::Match { .. } => None,
        _ => Some(Severity::Error),
    };
    let diag_severity = result.diagnostics.iter().map(|d| d.severity).max();
    outcome_severity.into_iter().chain(diag_severity).max()
}

/// The schema-aware diagnostics for `source` (ADR-0027 §2).
///
/// Schema-existence ERRORs (unknown table / column) and
/// expression WARNINGs. The highlight overlay and the hint
/// panel both read these for *where* and *why*; the indicator
/// ([`input_verdict`]) is the severity summary over them.
///
/// Empty for empty input, an unrecognised command, or a parse
/// that never reached a structural `Match` — a parse failure
/// carries its own ERROR through the outcome, not through a
/// `Diagnostic`, and is highlighted by the existing
/// definite-error path.
#[must_use]
pub fn input_diagnostics(
    source: &str,
    schema: Option<&crate::completion::SchemaCache>,
) -> Vec<outcome::Diagnostic> {
    input_diagnostics_in_mode(source, schema, crate::mode::Mode::Simple)
}

/// Mode-aware [`input_diagnostics`]. Advanced mode lets the
/// Phase-2 SQL-side diagnostics (ADR-0032 §11) emit alongside
/// the existing DSL diagnostics.
#[must_use]
pub fn input_diagnostics_in_mode(
    source: &str,
    schema: Option<&crate::completion::SchemaCache>,
    mode: crate::mode::Mode,
) -> Vec<outcome::Diagnostic> {
    if source.trim().is_empty() {
        return Vec::new();
    }
    let mut ctx = schema.map_or_else(context::WalkContext::new, context::WalkContext::with_schema);
    ctx.mode = mode;
    let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
    result.map_or_else(Vec::new, |r| r.diagnostics)
}

/// Schema-existence diagnostics (ADR-0027 §2).
///
/// A matched `IdentSource::Tables` token whose name is not in
/// the schema — or a `Columns` token absent from the table in
/// scope — is an ERROR: the command parses but would fail at
/// execution. Runs only on a structural `Match`.
///
/// Column scope is resolved by a single left-to-right pass:
/// every command places its table ident before the columns
/// that belong to it (a qualified `T.c` puts `T` immediately
/// before `c`), so the most recent valid `Tables` ident is the
/// table a subsequent `Columns` ident is checked against. An
/// unknown table clears the scope, so its columns are not
/// cascaded into a second diagnostic.
/// One in-scope FROM-source binding, simulated from the
/// matched-path by `schema_existence_diagnostics`. ADR-0032
/// §10.1 / §11.2 — the multi-binding schema-existence
/// diagnostic resolves bare and qualified column references
/// against this scope.
#[derive(Debug)]
struct PassBinding {
    table: String,
    alias: Option<String>,
}

/// Resolve a qualifier identifier against the active bindings.
/// Aliases shadow base-table names (ADR-0032 §10.5), so alias
/// matches are tried first.
fn resolve_qualifier<'a>(bindings: &'a [PassBinding], qualifier: &str) -> Option<&'a PassBinding> {
    bindings
        .iter()
        .find(|b| {
            b.alias
                .as_deref()
                .is_some_and(|a| a.eq_ignore_ascii_case(qualifier))
        })
        .or_else(|| {
            bindings
                .iter()
                .find(|b| b.table.eq_ignore_ascii_case(qualifier))
        })
}

fn schema_existence_diagnostics(
    path: &MatchedPath,
    schema: Option<&crate::completion::SchemaCache>,
) -> Vec<outcome::Diagnostic> {
    use crate::dsl::grammar::IdentSource;
    use outcome::{Diagnostic, MatchedKind, Severity};

    let Some(schema) = schema else {
        return Vec::new();
    };

    let mut diagnostics = Vec::new();
    // Pre-pass: collect all FROM-source bindings and CTE names
    // by walking the matched-path. ADR-0032 §10.6's projection-
    // before-FROM problem makes a strict left-to-right pass
    // mis-classify projection-side identifiers when the FROM
    // clause comes later. We sidestep it here by gathering the
    // full scope first, then doing the diagnostic check with
    // the complete set of bindings available.
    //
    // For Phase 2 this is a single flat scope (top-level
    // statement). Subquery / CTE-body scopes pop on
    // ScopedSubgrammar exit and their bindings are not
    // distinguished here — full per-frame scope tracking
    // remains a 2e concern. Refs inside subquery / CTE bodies
    // resolve against the union of all matched bindings, which
    // is permissive (a false-positive ambiguity could in
    // principle arise for shadowed names) but conservative
    // (won't false-flag valid refs).
    let mut bindings: Vec<PassBinding> = Vec::new();
    let mut cte_names: Vec<String> = Vec::new();
    // Tables being *created* in this statement (a `CREATE TABLE` target —
    // `IdentSource::NewName`, role `table_name`). A FK that references the
    // table being created (a self-reference) names it via
    // `IdentSource::Tables` before it exists in the schema, so it would
    // otherwise be flagged "no such table" pre-submit (ADR-0035 §4i c).
    let mut created_tables: Vec<String> = Vec::new();
    {
        let mut pending_alias_index: Option<usize> = None;
        for item in &path.items {
            let MatchedKind::Ident { source, role } = item.kind else {
                continue;
            };
            match source {
                IdentSource::Tables
                    if role == "table_name"
                        && (schema_has_table(schema, &item.text)
                            || cte_names_contains(&cte_names, &item.text)) =>
                {
                    bindings.push(PassBinding {
                        table: item.text.clone(),
                        alias: None,
                    });
                    pending_alias_index = Some(bindings.len() - 1);
                }
                IdentSource::Tables if role == "table_name" => {
                    pending_alias_index = None;
                }
                IdentSource::NewName if role == "table_alias" => {
                    if let Some(idx) = pending_alias_index {
                        bindings[idx].alias = Some(item.text.clone());
                    }
                    pending_alias_index = None;
                }
                IdentSource::NewName if role == "cte_name" => {
                    if !cte_names_contains(&cte_names, &item.text) {
                        cte_names.push(item.text.clone());
                    }
                    pending_alias_index = None;
                }
                IdentSource::NewName if role == "table_name" => {
                    // The `CREATE TABLE` target — record it so a
                    // self-referencing FK parent isn't flagged unknown.
                    created_tables.push(item.text.clone());
                    pending_alias_index = None;
                }
                _ => {
                    pending_alias_index = None;
                }
            }
        }
    }

    // ADR-0033 §9: the `excluded` pseudo-table. In an
    // `INSERT … ON CONFLICT … DO UPDATE`, a `excluded.col` reference
    // resolves to the target table's columns — but ONLY within the
    // DO UPDATE action's byte range. Outside it (a VALUES tuple, a
    // trailing RETURNING, or any non-upsert statement) `excluded`
    // has no meaning and must stay unresolved so the existing
    // `unknown_qualifier` diagnostic fires (the leak guard). The
    // range runs from the `update` keyword (the one after
    // `conflict`) to the `RETURNING` boundary (or end).
    // Whether this statement is an INSERT/UPSERT (has an
    // `insert_target_table`). When set, `insert_column` and
    // `update_set_column` idents are validated against that target
    // by `dml_target_column_diagnostics`, NOT against the flat
    // `bindings` below — for an `INSERT … SELECT` those bindings are
    // the SELECT's source tables, the wrong scope (a target-only
    // column would be falsely flagged against the source).
    let has_insert_target = path.items.iter().any(|it| {
        matches!(
            it.kind,
            MatchedKind::Ident {
                source: IdentSource::Tables,
                role: "insert_target_table",
            }
        )
    });

    let upsert_excluded: Option<(String, usize, usize)> = {
        let target = path.items.iter().find_map(|it| match it.kind {
            MatchedKind::Ident {
                source: IdentSource::Tables,
                role: "insert_target_table",
            } => Some(it.text.clone()),
            _ => None,
        });
        let conflict_pos = path
            .items
            .iter()
            .find(|it| matches!(it.kind, MatchedKind::Word("conflict")))
            .map(|it| it.span.0);
        let do_update_pos = conflict_pos.and_then(|cp| {
            path.items
                .iter()
                .find(|it| matches!(it.kind, MatchedKind::Word("update")) && it.span.0 > cp)
                .map(|it| it.span.0)
        });
        let returning_pos = path
            .items
            .iter()
            .find(|it| matches!(it.kind, MatchedKind::Word("returning")))
            .map(|it| it.span.0);
        match (target, do_update_pos) {
            (Some(t), Some(start)) if schema_has_table(schema, &t) => {
                Some((t, start, returning_pos.unwrap_or(usize::MAX)))
            }
            _ => None,
        }
    };

    // The INSERT target + RETURNING-clause start, for re-scoping
    // bare RETURNING refs onto the target (they reference the
    // inserted row, not an `INSERT … SELECT`'s source). `None` for a
    // top-level UPDATE/DELETE (whose target is already a binding).
    let insert_target_name: Option<String> = if has_insert_target {
        path.items
            .iter()
            .find_map(|it| match it.kind {
                MatchedKind::Ident {
                    source: IdentSource::Tables,
                    role: "insert_target_table",
                } => Some(it.text.clone()),
                _ => None,
            })
            .filter(|t| schema_has_table(schema, t))
    } else {
        None
    };
    let returning_pos: Option<usize> = path
        .items
        .iter()
        .find(|it| matches!(it.kind, MatchedKind::Word("returning")))
        .map(|it| it.span.0);

    // Track which CTE names have already been seen, for
    // duplicate detection (a separate single-pass walk; emits
    // the diagnostic on the second occurrence).
    let mut seen_cte_names: Vec<String> = Vec::new();
    // Set on iteration `i` when the current item is the `t`
    // qualifier of a `t.c` reference; consumed on iteration
    // `i + 2` by the `sql_expr_qualified_ref` ident.
    let mut pending_qualifier: Option<(String, (usize, usize))> = None;
    // Projection-alias scope at top-level (ADR-0032 §11.2). Aliases
    // declared in the current SELECT leg's projection list are
    // visible to `ORDER BY` but NOT to `WHERE` / `HAVING` /
    // `GROUP BY`. A flat matched-path single pass suffices: aliases
    // are always written BEFORE these clauses are reached, and
    // subquery levels (depth > 0) keep their own implicit scope.
    let mut paren_depth: i32 = 0;
    let mut current_clause: Option<&'static str> = None;
    let mut leg_aliases: Vec<String> = Vec::new();

    for (i, item) in path.items.iter().enumerate() {
        // Maintain paren-depth, clause kind, and per-leg alias bag
        // BEFORE dispatching on the item — these track context that
        // the ident handlers below read.
        let depth_at_item = paren_depth;
        match &item.kind {
            MatchedKind::Punct('(') => paren_depth += 1,
            MatchedKind::Punct(')') => paren_depth -= 1,
            MatchedKind::Word(w) if depth_at_item == 0 => match *w {
                // A new SELECT leg (top-level or compound-leg
                // start) resets the alias bag and clause kind so a
                // following leg's projection / clauses are scoped
                // to its own aliases only.
                "select" => {
                    leg_aliases.clear();
                    current_clause = None;
                }
                "union" | "intersect" | "except" => {
                    leg_aliases.clear();
                    current_clause = None;
                }
                "where" => current_clause = Some("the WHERE clause"),
                "having" => current_clause = Some("the HAVING clause"),
                "group" => current_clause = Some("the GROUP BY clause"),
                // ORDER BY / LIMIT / OFFSET / FROM are not forbidden
                // contexts for alias references. Clearing here also
                // protects ORDER BY from a sticky earlier clause.
                "order" | "limit" | "offset" | "from" => {
                    current_clause = None;
                }
                _ => {}
            },
            MatchedKind::Ident {
                source: IdentSource::NewName,
                role: "projection_alias",
            } if depth_at_item == 0 => {
                leg_aliases.push(item.text.clone());
            }
            _ => {}
        }

        let MatchedKind::Ident { source, role } = item.kind else {
            continue;
        };
        match source {
            IdentSource::Tables => {
                if role == "qualified_star_qualifier" {
                    // The `t` in `t.*`. Resolve against bindings
                    // (populated by the pre-pass); emit
                    // `unknown_qualifier` if it doesn't resolve. A
                    // target-qualified `<target>.*` inside an INSERT's
                    // RETURNING (or DO UPDATE) range resolves to the
                    // INSERT target, which isn't a binding (same
                    // re-scoping as bare/qualified refs).
                    let resolves_to_target = bare_ref_insert_target(
                        item.span.0,
                        upsert_excluded.as_ref(),
                        insert_target_name.as_deref(),
                        returning_pos,
                    )
                    .is_some_and(|t| t.eq_ignore_ascii_case(&item.text));
                    if !resolves_to_target
                        && resolve_qualifier(&bindings, &item.text).is_none()
                        && !cte_names_contains(&cte_names, &item.text)
                    {
                        diagnostics.push(Diagnostic {
                            severity: Severity::Error,
                            span: item.span,
                            message: crate::friendly::translate(
                                "diagnostic.unknown_qualifier",
                                &[(
                                    "qualifier",
                                    &item.text as &dyn std::fmt::Display,
                                )],
                            ),
                        });
                    }
                } else if !schema_has_table(schema, &item.text)
                    && !cte_names_contains(&cte_names, &item.text)
                    && !created_tables
                        .iter()
                        .any(|t| t.eq_ignore_ascii_case(&item.text))
                {
                    // Unknown table — the pre-pass skipped
                    // pushing this as a binding, so it's not in
                    // the resolution scope. A self-referencing FK
                    // parent (the table being created in this same
                    // statement) is exempt (ADR-0035 §4i c). Flag
                    // it here.
                    diagnostics.push(Diagnostic {
                        severity: Severity::Error,
                        span: item.span,
                        message: crate::friendly::translate(
                            "diagnostic.unknown_table",
                            &[("name", &item.text as &dyn std::fmt::Display)],
                        ),
                    });
                }
            }
            IdentSource::Columns => {
                if role == "sql_expr_qualified_ref" {
                    // The `c` half of `t.c` — the previous pass
                    // iteration set `pending_qualifier` to the
                    // qualifier ident.
                    if let Some((qual, qual_span)) =
                        pending_qualifier.take()
                    {
                        match resolve_qualifier(&bindings, &qual) {
                            Some(binding) => {
                                if !cte_names_contains(
                                    &cte_names,
                                    &binding.table,
                                ) && !schema_has_column(
                                    schema,
                                    &binding.table,
                                    &item.text,
                                ) {
                                    diagnostics.push(Diagnostic {
                                        severity: Severity::Error,
                                        span: item.span,
                                        message: crate::friendly::translate(
                                            "diagnostic.unknown_column",
                                            &[
                                                ("name", &item.text as &dyn std::fmt::Display),
                                                ("table", &binding.table as &dyn std::fmt::Display),
                                            ],
                                        ),
                                    });
                                }
                            }
                            None => {
                                // A qualifier resolves to the INSERT
                                // target's columns when it is, within a
                                // target-scoped region (the DO UPDATE
                                // action — ADR-0033 §9 — or an INSERT's
                                // RETURNING list):
                                //   - `excluded` (the would-be-inserted
                                //     row, mirroring the target), or
                                //   - the target table's own name (a
                                //     target-qualified ref).
                                // Both are byte-range-scoped, so they
                                // stay unresolved (and flagged below) in
                                // a VALUES tuple, an `INSERT … SELECT`'s
                                // own scope, or a non-upsert statement
                                // (the leak guard).
                                let scope_target = bare_ref_insert_target(
                                    qual_span.0,
                                    upsert_excluded.as_ref(),
                                    insert_target_name.as_deref(),
                                    returning_pos,
                                )
                                .filter(|t| {
                                    qual.eq_ignore_ascii_case("excluded")
                                        || qual.eq_ignore_ascii_case(t)
                                });
                                if let Some(target) = scope_target {
                                    // Validate the column against the
                                    // target's columns.
                                    if !schema_has_column(schema, target, &item.text) {
                                        diagnostics.push(Diagnostic {
                                            severity: Severity::Error,
                                            span: item.span,
                                            message: crate::friendly::translate(
                                                "diagnostic.unknown_column",
                                                &[
                                                    ("name", &item.text as &dyn std::fmt::Display),
                                                    ("table", &target as &dyn std::fmt::Display),
                                                ],
                                            ),
                                        });
                                    }
                                } else {
                                    // Qualifier didn't resolve — emit
                                    // unknown_qualifier on the
                                    // qualifier span, not on the
                                    // column, so the learner sees
                                    // the root cause.
                                    diagnostics.push(Diagnostic {
                                        severity: Severity::Error,
                                        span: qual_span,
                                        message: crate::friendly::translate(
                                            "diagnostic.unknown_qualifier",
                                            &[(
                                                "qualifier",
                                                &qual as &dyn std::fmt::Display,
                                            )],
                                        ),
                                    });
                                }
                            }
                        }
                    }
                } else if role == "sql_expr_ident"
                    && is_followed_by_qualified_ref(&path.items, i)
                {
                    // This ident is the `t` qualifier of a
                    // following `t.c`. Defer to the qualified-ref
                    // check on the next iteration.
                    pending_qualifier =
                        Some((item.text.clone(), item.span));
                } else if role == "sql_expr_ident"
                    && is_followed_by_call_args(&path.items, i)
                {
                    // Issue #6: this ident is the *name* of a function
                    // call (immediately followed by `(`), not a column
                    // reference. The grammar admits the call shape
                    // structurally (sql_expr.rs's `CALL_ARGS` comment:
                    // "it does not know which names are aggregates"),
                    // so the validator skips here. The arguments
                    // *inside* the call are ordinary expressions and
                    // their idents flow through the bare-column check
                    // on subsequent iterations.
                } else if let Some(target) = (role == "sql_expr_ident")
                    .then(|| {
                        bare_ref_insert_target(
                            item.span.0,
                            upsert_excluded.as_ref(),
                            insert_target_name.as_deref(),
                            returning_pos,
                        )
                    })
                    .flatten()
                {
                    // A bare column ref inside the UPSERT DO UPDATE
                    // action or an INSERT's RETURNING list references
                    // the target row's columns (and `excluded`, handled
                    // on the qualified path), NOT the row source's
                    // tables. Validate it against the target — for an
                    // `INSERT … SELECT …` the flat `bindings` are the
                    // SELECT's sources, so the bare-column branch below
                    // would check the wrong scope. (Also covers the
                    // VALUES upsert case where `bindings` is empty.)
                    if !schema_has_column(schema, target, &item.text) {
                        diagnostics.push(Diagnostic {
                            severity: Severity::Error,
                            span: item.span,
                            message: crate::friendly::translate(
                                "diagnostic.unknown_column",
                                &[
                                    ("name", &item.text as &dyn std::fmt::Display),
                                    ("table", &target as &dyn std::fmt::Display),
                                ],
                            ),
                        });
                    }
                } else if role == "insert_column"
                    || role == "conflict_target_column"
                    || (role == "update_set_column" && has_insert_target)
                {
                    // The INSERT column list, the ON CONFLICT target,
                    // and the UPSERT DO UPDATE SET columns are all
                    // validated against the INSERT target by
                    // `dml_target_column_diagnostics`. They must NOT be
                    // checked against the flat `bindings` here: for
                    // `INSERT … SELECT` those are the SELECT's source
                    // tables, so a target-only column would be falsely
                    // flagged. (A top-level UPDATE's `update_set_column`
                    // has no insert target and so still flows through
                    // the bare-column check below.)
                } else if !bindings.is_empty() {
                    // Bare column reference. Count which bindings
                    // contain it (case-insensitive). CTE-binding
                    // tables match opportunistically (we don't
                    // know their columns yet — the §10.3 stage-2
                    // harvest is deferred), so CTE refs are
                    // accepted silently.
                    let matched: Vec<&str> = bindings
                        .iter()
                        .filter(|b| {
                            cte_names_contains(&cte_names, &b.table)
                                || schema_has_column(
                                    schema, &b.table, &item.text,
                                )
                        })
                        .map(|b| b.alias.as_deref().unwrap_or(&b.table))
                        .collect();
                    match matched.len() {
                        0 => {
                            // ADR-0032 §11.2 — a top-level bare ref
                            // that doesn't resolve as a column but
                            // DOES match a projection alias in this
                            // leg is either misplaced (forbidden
                            // clause) or a valid alias reference
                            // (ORDER BY / LIMIT). Either way, the
                            // unknown_column diagnostic would
                            // mislead, so suppress it here.
                            let alias_match = depth_at_item == 0
                                && leg_aliases.iter().any(|a| {
                                    a.eq_ignore_ascii_case(&item.text)
                                });
                            if alias_match {
                                if let Some(clause) = current_clause {
                                    diagnostics.push(Diagnostic {
                                        severity: Severity::Error,
                                        span: item.span,
                                        message: crate::friendly::translate(
                                            "diagnostic.projection_alias_misplaced",
                                            &[
                                                ("alias", &item.text as &dyn std::fmt::Display),
                                                ("clause", &clause as &dyn std::fmt::Display),
                                            ],
                                        ),
                                    });
                                }
                                // Allowed-clause alias ref — silent.
                                continue;
                            }
                            // Issue #31: the bare ident is itself an
                            // in-scope FROM source — a table alias
                            // (`o` from `FROM Orders o`) or, when the
                            // source is un-aliased, the table name. The
                            // learner means a *column* of that source
                            // (`o.<column>`); calling it an "unknown
                            // column" misleads. Point at the qualified
                            // form.
                            //
                            // Two guards keep the advice correct:
                            //   - SQL only (`role == "sql_expr_ident"`):
                            //     the DSL `Expr` (role `expr_column`)
                            //     has no `table.column` syntax, so the
                            //     qualified-form advice would be wrong;
                            //     it keeps the generic unknown_column.
                            //   - Match the *effective qualifier*
                            //     (alias if present, else table name),
                            //     not the table name independently. An
                            //     aliased source must be referenced by
                            //     its alias — `FROM Orders o … Orders`
                            //     is invalid SQL, so it must NOT be
                            //     advised as `Orders.<column>`. Mirrors
                            //     the completion side's qualifier rule.
                            let qualifier_binding = (role
                                == "sql_expr_ident")
                                .then(|| {
                                    bindings.iter().find(|b| {
                                        let q = b
                                            .alias
                                            .as_deref()
                                            .unwrap_or(b.table.as_str());
                                        q.eq_ignore_ascii_case(&item.text)
                                    })
                                })
                                .flatten();
                            if let Some(binding) = qualifier_binding {
                                let key = if binding.alias.is_some() {
                                    "diagnostic.alias_used_as_column"
                                } else {
                                    "diagnostic.table_used_as_column"
                                };
                                diagnostics.push(Diagnostic {
                                    severity: Severity::Error,
                                    span: item.span,
                                    message: crate::friendly::translate(
                                        key,
                                        &[(
                                            "name",
                                            &item.text as &dyn std::fmt::Display,
                                        )],
                                    ),
                                });
                                continue;
                            }
                            let table_arg = if bindings.len() == 1 {
                                bindings[0].table.clone()
                            } else {
                                bindings
                                    .iter()
                                    .map(|b| b.table.as_str())
                                    .collect::<Vec<_>>()
                                    .join(", ")
                            };
                            diagnostics.push(Diagnostic {
                                severity: Severity::Error,
                                span: item.span,
                                message: crate::friendly::translate(
                                    "diagnostic.unknown_column",
                                    &[
                                        ("name", &item.text as &dyn std::fmt::Display),
                                        ("table", &table_arg as &dyn std::fmt::Display),
                                    ],
                                ),
                            });
                        }
                        1 => {} // unique match, OK
                        _ => {
                            let qualifiers = matched.join(", ");
                            diagnostics.push(Diagnostic {
                                severity: Severity::Error,
                                span: item.span,
                                message: crate::friendly::translate(
                                    "diagnostic.ambiguous_column",
                                    &[
                                        ("column", &item.text as &dyn std::fmt::Display),
                                        ("qualifiers", &qualifiers as &dyn std::fmt::Display),
                                    ],
                                ),
                            });
                        }
                    }
                }
                // else: no FROM in scope — engine catches the
                // unbound column reference. Skip silently to
                // avoid noise on `SELECT a` style expressions
                // (which the grammar admits per §1).
            }
            IdentSource::NewName => {
                // Pre-flight duplicate CTE detection (ADR-0032
                // §11.5 / Plan §Open-2, user-approved). The
                // pre-pass collected the de-duplicated set; we
                // scan again to find the SECOND occurrence and
                // emit on its span.
                if role == "cte_name" {
                    if seen_cte_names
                        .iter()
                        .any(|n| n.eq_ignore_ascii_case(&item.text))
                    {
                        diagnostics.push(Diagnostic {
                            severity: Severity::Error,
                            span: item.span,
                            message: crate::friendly::translate(
                                "diagnostic.duplicate_cte",
                                &[(
                                    "name",
                                    &item.text as &dyn std::fmt::Display,
                                )],
                            ),
                        });
                    } else {
                        seen_cte_names.push(item.text.clone());
                    }
                }
            }
            IdentSource::Relationships
            | IdentSource::Indexes
            | IdentSource::Types
            // `Generators` (the `set … as <gen>` slot, ADR-0048 D9) is a
            // curated vocabulary; its unknown-name validity is handled by
            // the completion-layer indicator, not this walker diagnostic.
            | IdentSource::Generators
            | IdentSource::Free => {}
        }
    }
    diagnostics
}

fn cte_names_contains(names: &[String], candidate: &str) -> bool {
    names.iter().any(|n| n.eq_ignore_ascii_case(candidate))
}

/// Compound-query column-count mismatch ERROR pass (ADR-0032 §11.2
/// / §11.7). A `UNION` / `INTERSECT` / `EXCEPT` chain whose legs
/// have differing projection arities will be rejected by the
/// engine at execution time; this pass catches it pre-flight so
/// the learner sees the slot highlighted at the offending operator
/// instead of an engine string.
///
/// Counting strategy: the matched-path is flat, so we maintain a
/// per-depth book-keeping of in-progress legs. A leg starts at a
/// `SELECT` keyword and counts projection items as the number of
/// top-level commas (at the leg's own paren-depth) seen before
/// the first leg-end keyword (`FROM` / `WHERE` / `GROUP` / `HAVING`
/// / `ORDER` / `LIMIT` / `OFFSET`) or compound-leg keyword
/// (`UNION` / `INTERSECT` / `EXCEPT`) or matching `)` at the same
/// depth. Commas nested inside function calls or subqueries sit
/// at a deeper paren-depth and are ignored.
///
/// When a compound operator at depth `d` is encountered, the
/// just-completed leg's arity at depth `d` is stashed as a
/// pending comparand; the next leg's arity at depth `d` is
/// compared against it on that leg's close. The op token's span
/// is the diagnostic anchor — that's the join point the learner
/// pointed the chain at.
fn compound_arity_diagnostics(path: &MatchedPath) -> Vec<outcome::Diagnostic> {
    use outcome::{Diagnostic, MatchedKind, Severity};
    use std::collections::HashMap;

    struct LegState {
        arity: usize,
        in_projection: bool,
    }
    struct Pending {
        op_text: &'static str,
        op_span: (usize, usize),
        prev_arity: usize,
    }

    let mut diagnostics = Vec::new();
    let mut depth: i32 = 0;
    let mut legs: HashMap<i32, LegState> = HashMap::new();
    let mut pending: HashMap<i32, Pending> = HashMap::new();

    let close_leg = |depth: i32,
                     legs: &mut HashMap<i32, LegState>,
                     pending: &mut HashMap<i32, Pending>,
                     diagnostics: &mut Vec<Diagnostic>|
     -> Option<usize> {
        let leg = legs.remove(&depth)?;
        if let Some(p) = pending.remove(&depth)
            && p.prev_arity != leg.arity
        {
            diagnostics.push(Diagnostic {
                severity: Severity::Error,
                span: p.op_span,
                message: crate::friendly::translate(
                    "diagnostic.compound_arity_mismatch",
                    &[
                        ("op", &p.op_text as &dyn std::fmt::Display),
                        ("left_n", &p.prev_arity as &dyn std::fmt::Display),
                        ("right_n", &leg.arity as &dyn std::fmt::Display),
                    ],
                ),
            });
        }
        Some(leg.arity)
    };

    for item in &path.items {
        let depth_at_item = depth;
        match &item.kind {
            MatchedKind::Punct('(') => {
                depth += 1;
            }
            MatchedKind::Punct(')') => {
                // Any leg at this depth closes before the depth
                // pops. If a pending set-op was waiting, the leg
                // it expected never arrived — drop the pending
                // (the chain ended without a second leg, which
                // the grammar shouldn't admit but is safer than
                // panicking).
                close_leg(depth, &mut legs, &mut pending, &mut diagnostics);
                pending.remove(&depth);
                depth -= 1;
            }
            MatchedKind::Punct(',') if depth_at_item == depth => {
                if let Some(leg) = legs.get_mut(&depth)
                    && leg.in_projection
                {
                    leg.arity += 1;
                }
            }
            MatchedKind::Word(w) if depth_at_item == depth => {
                match *w {
                    "select" => {
                        // A leg already at this depth shouldn't
                        // happen (a previous compound op or
                        // close-paren would have removed it), but
                        // overwriting is safe — the new leg
                        // supersedes any stale state.
                        legs.insert(
                            depth,
                            LegState {
                                arity: 1,
                                in_projection: true,
                            },
                        );
                    }
                    "from" | "where" | "group" | "having" | "order" | "limit" | "offset" => {
                        if let Some(leg) = legs.get_mut(&depth) {
                            leg.in_projection = false;
                        }
                    }
                    op_word @ ("union" | "intersect" | "except") => {
                        // Close the just-finished leg, comparing
                        // it against any pending set-op state at
                        // this depth.
                        if let Some(arity) =
                            close_leg(depth, &mut legs, &mut pending, &mut diagnostics)
                        {
                            pending.insert(
                                depth,
                                Pending {
                                    op_text: op_word,
                                    op_span: item.span,
                                    prev_arity: arity,
                                },
                            );
                        }
                    }
                    _ => {}
                }
            }
            _ => {}
        }
    }

    // Drain any still-open legs at end-of-path. Same comparison
    // as the close_leg helper does on `)`.
    let depths: Vec<i32> = legs.keys().copied().collect();
    for d in depths {
        close_leg(d, &mut legs, &mut pending, &mut diagnostics);
    }

    diagnostics
}

/// `auto_column_overridden` WARNING (ADR-0033 §8.2, sub-phase 3i).
///
/// A SQL `INSERT` whose explicit column list names a `serial` or
/// `shortid` column is providing a value for a column the playground
/// auto-generates (`serial` via the engine rowid, `shortid` via the
/// worker post-fill). That bypasses the auto-counter / generator and
/// can collide with later auto-generated values — pedagogically worth
/// a nudge, but advisory: the statement still runs (ADR-0027 §1).
///
/// The conflict-target column list of an `ON CONFLICT (…)` uses a
/// distinct role (`conflict_target_column`), so it is not mistaken
/// for an inserted column here.
fn dml_auto_column_diagnostics(
    path: &MatchedPath,
    schema: Option<&crate::completion::SchemaCache>,
) -> Vec<outcome::Diagnostic> {
    use crate::dsl::grammar::IdentSource;
    use crate::dsl::types::Type;
    use outcome::{Diagnostic, MatchedKind, Severity};

    let Some(schema) = schema else {
        return Vec::new();
    };
    let Some(target) = path.items.iter().find_map(|it| match it.kind {
        MatchedKind::Ident {
            source: IdentSource::Tables,
            role: "insert_target_table",
        } => Some(it.text.as_str()),
        _ => None,
    }) else {
        return Vec::new();
    };
    let Some(cols) = schema.columns_for_table(target) else {
        return Vec::new();
    };

    let mut diagnostics = Vec::new();
    for item in &path.items {
        let MatchedKind::Ident {
            source: IdentSource::Columns,
            role: "insert_column",
        } = item.kind
        else {
            continue;
        };
        if let Some(col) = cols
            .iter()
            .find(|c| c.name.eq_ignore_ascii_case(&item.text))
            && matches!(col.user_type, Type::Serial | Type::ShortId)
        {
            diagnostics.push(Diagnostic {
                severity: Severity::Warning,
                span: item.span,
                message: crate::friendly::translate(
                    "diagnostic.auto_column_overridden",
                    &[
                        ("column", &item.text as &dyn std::fmt::Display),
                        ("type", &col.user_type as &dyn std::fmt::Display),
                    ],
                ),
            });
        }
    }
    diagnostics
}

/// `insert_arity_mismatch` ERROR (ADR-0033 §8.1, sub-phase 3i; Form B
/// branch added 2026-05-28 per issue #1 / ADR-0033 Amendment 5).
///
/// Walker pre-flight when the value-tuple arity disagrees with the
/// expected arity — pre-empting the engine's less-helpful
/// "N values for M columns". Two row-source shapes:
///
/// - **VALUES**: each `value_tuple` is checked independently;
///   **each** offending row emits its own diagnostic on that tuple's
///   span (a five-row list with three wrong tuples emits three).
/// - **INSERT … SELECT** (plain, non-`WITH`): the first SELECT leg's
///   projection arity is compared, anchored on the first projection
///   item. A `WITH`-prefixed row source is skipped here (the outer
///   projection isn't the first `select` token) — the engine still
///   reports it; a false positive would be worse than deferring.
///
/// Expected arity comes from one of two sources:
/// - **Form A** (explicit `(col, …)` list): the list's length.
/// - **Form B** (no column list): the target table's column count —
///   requires the schema and a known table; otherwise this pass is
///   silent and the engine reports the mismatch as before. This
///   covers the user-reported issue #1 case (advanced positional
///   INSERT with wrong value count).
fn dml_insert_arity_diagnostics(
    path: &MatchedPath,
    schema: Option<&crate::completion::SchemaCache>,
    mode: crate::mode::Mode,
) -> Vec<outcome::Diagnostic> {
    use crate::dsl::grammar::IdentSource;
    use crate::dsl::types::Type;
    use outcome::{Diagnostic, MatchedKind, Severity};

    // Form A column count: the explicit `(col, …)` list. The SQL
    // (advanced) grammar tags these `insert_column`; the DSL (simple)
    // grammar tags them `insert_first_item` (issue #17) — count both so
    // a DSL Form A insert isn't mis-classified as Form B.
    let col_arity = path
        .items
        .iter()
        .filter(|it| {
            matches!(
                it.kind,
                MatchedKind::Ident {
                    source: IdentSource::Columns,
                    role: "insert_column" | "insert_first_item",
                }
            )
        })
        .count();

    // Is this an INSERT? `into` is insert-exclusive in both grammars
    // (update uses `set`, delete `from`, show `data`), so it tells the
    // DSL insert apart from other commands that also tag a `table_name`.
    let is_insert = path
        .items
        .iter()
        .any(|it| matches!(it.kind, MatchedKind::Word("into")));
    // Insert target table. The SQL grammar tags it `insert_target_table`
    // (insert-specific); the DSL grammar reuses the generic `table_name`
    // role, so only trust that when `into` confirmed an insert (issue
    // #17).
    let target_table: Option<&str> = path.items.iter().find_map(|it| match it.kind {
        MatchedKind::Ident {
            source: IdentSource::Tables,
            role: "insert_target_table",
        } => Some(it.text.as_str()),
        MatchedKind::Ident {
            source: IdentSource::Tables,
            role: "table_name",
        } if is_insert => Some(it.text.as_str()),
        _ => None,
    });

    // Resolve the expected arity + a message builder. The builder
    // captures the per-case args because the message key (and its
    // placeholders) differ by form and mode.
    //
    // - **Form A** (both modes): the listed-column count, mode-neutral.
    // - **Advanced Form B**: every column needs a value (auto-fills
    //   nothing, ADR-0036) → the full table count.
    // - **Simple Form B** (issue #17): the dispatch auto-fills
    //   serial/shortid (ADR-0018 §3), so only the user-fillable columns
    //   take values. When some columns are skipped the message names
    //   both sets; when none are skipped the count equals the table's
    //   and the plain Form B wording is accurate; when *every* column is
    //   auto-generated no value belongs at all.
    type MsgFn<'a> = Box<dyn Fn(usize) -> String + 'a>;
    let (expected, make_message): (usize, MsgFn) = if col_arity > 0 {
        (
            col_arity,
            Box::new(move |actual| {
                crate::friendly::translate(
                    "diagnostic.insert_arity_mismatch",
                    &[
                        ("expected", &col_arity as &dyn std::fmt::Display),
                        ("actual", &actual as &dyn std::fmt::Display),
                    ],
                )
            }),
        )
    } else {
        let Some(schema) = schema else {
            return Vec::new();
        };
        let Some(target) = target_table else {
            return Vec::new();
        };
        let Some(cols) = schema.table_columns.get(target) else {
            return Vec::new();
        };
        if cols.is_empty() {
            return Vec::new();
        }
        let is_auto = |t: Type| matches!(t, Type::Serial | Type::ShortId);
        let fillable: Vec<&str> = cols
            .iter()
            .filter(|c| !is_auto(c.user_type))
            .map(|c| c.name.as_str())
            .collect();
        if mode == crate::mode::Mode::Advanced || fillable.len() == cols.len() {
            // Advanced Form B (all columns), or simple Form B over a
            // table with no auto-generated columns — "all N" is accurate.
            let expected = if mode == crate::mode::Mode::Advanced {
                cols.len()
            } else {
                fillable.len()
            };
            (
                expected,
                Box::new(move |actual| {
                    crate::friendly::translate(
                        "diagnostic.insert_arity_mismatch_form_b",
                        &[
                            ("expected", &expected as &dyn std::fmt::Display),
                            ("actual", &actual as &dyn std::fmt::Display),
                        ],
                    )
                }),
            )
        } else if fillable.is_empty() {
            // Simple Form B, every column auto-generated → no value
            // belongs at all.
            let table = target.to_string();
            (
                0,
                Box::new(move |actual| {
                    crate::friendly::translate(
                        "diagnostic.insert_arity_mismatch_all_auto",
                        &[
                            ("table", &table as &dyn std::fmt::Display),
                            ("actual", &actual as &dyn std::fmt::Display),
                        ],
                    )
                }),
            )
        } else {
            // Simple Form B with a mix — name the user-fillable and the
            // auto-generated columns so the learner understands the skip.
            let expected = fillable.len();
            let columns = fillable
                .iter()
                .map(|n| format!("`{n}`"))
                .collect::<Vec<_>>()
                .join(", ");
            let skipped = cols
                .iter()
                .filter(|c| is_auto(c.user_type))
                .map(|c| format!("`{}`", c.name))
                .collect::<Vec<_>>()
                .join(", ");
            (
                expected,
                Box::new(move |actual| {
                    crate::friendly::translate(
                        "diagnostic.insert_arity_mismatch_form_b_simple",
                        &[
                            ("expected", &expected as &dyn std::fmt::Display),
                            ("columns", &columns as &dyn std::fmt::Display),
                            ("skipped", &skipped as &dyn std::fmt::Display),
                            ("actual", &actual as &dyn std::fmt::Display),
                        ],
                    )
                }),
            )
        }
    };

    // Locate the row source to scan. Form A/B and INSERT…SELECT carry a
    // `values`/`select`/`with` keyword. The DSL Form C (`insert into T
    // (1, 2)` — bare value list, no `values`) has none: its single value
    // tuple follows the target table directly, with no column-name list
    // (col_arity == 0 here), so scan from just after the table and treat
    // it with the VALUES tuple logic (issue #17).
    let (kw, tail): (&str, &[outcome::MatchedItem]) = if let Some(kw_idx) = path
        .items
        .iter()
        .position(|it| matches!(it.kind, MatchedKind::Word("values" | "select" | "with")))
    {
        let MatchedKind::Word(kw) = path.items[kw_idx].kind else {
            return Vec::new();
        };
        (kw, &path.items[kw_idx + 1..])
    } else if is_insert && mode == crate::mode::Mode::Simple {
        // DSL Form C (`insert into T (1, 2)` — bare value list, no
        // `values` keyword) is simple-mode only; advanced SQL always
        // carries a `values`/`select`/`with` keyword, so this branch
        // stays out of the advanced path (its arity is handled above).
        let Some(tbl_idx) = path.items.iter().position(|it| {
            matches!(
                it.kind,
                MatchedKind::Ident {
                    source: IdentSource::Tables,
                    role: "insert_target_table" | "table_name",
                }
            )
        }) else {
            return Vec::new();
        };
        ("values", &path.items[tbl_idx + 1..])
    } else {
        return Vec::new();
    };

    let mut diagnostics = Vec::new();

    let emit = |span: (usize, usize), actual: usize, diagnostics: &mut Vec<Diagnostic>| {
        diagnostics.push(Diagnostic {
            severity: Severity::Error,
            span,
            message: make_message(actual),
        });
    };

    if kw == "values" {
        // Per-tuple arity: count value exprs (commas at the tuple's
        // interior depth, +1). Nested parens (calls / subqueries)
        // sit deeper and their commas are ignored.
        let mut depth: i32 = 0;
        let mut tuple_arity = 0usize;
        let mut tuple_start = 0usize;
        for it in tail {
            match it.kind {
                MatchedKind::Punct('(') => {
                    if depth == 0 {
                        tuple_arity = 1;
                        tuple_start = it.span.0;
                    }
                    depth += 1;
                }
                MatchedKind::Punct(')') => {
                    depth -= 1;
                    if depth == 0 && tuple_arity != expected {
                        emit((tuple_start, it.span.1), tuple_arity, &mut diagnostics);
                    }
                }
                MatchedKind::Punct(',') if depth == 1 => tuple_arity += 1,
                // A depth-0 keyword ends the VALUES clause — the
                // tuples are over and what follows (`ON CONFLICT (…)`,
                // `RETURNING …`) must NOT be mis-read as a value
                // tuple. Value exprs live inside the tuple parens
                // (depth ≥ 1), so no legitimate value keyword sits at
                // depth 0.
                MatchedKind::Word(_) if depth == 0 => break,
                _ => {}
            }
        }
    } else if kw == "select" {
        // First SELECT leg's projection arity: commas at the leg's
        // depth (0 here — INSERT … SELECT is not parenthesised) up to
        // the first leg-end / compound keyword. Anchor on the first
        // projection item.
        let mut depth: i32 = 0;
        let mut proj_arity = 1usize;
        let mut anchor: Option<(usize, usize)> = None;
        for it in tail {
            match it.kind {
                MatchedKind::Punct('(') => depth += 1,
                MatchedKind::Punct(')') => depth -= 1,
                MatchedKind::Punct(',') if depth == 0 => proj_arity += 1,
                MatchedKind::Word(
                    "from" | "where" | "group" | "having" | "order" | "limit" | "offset" | "union"
                    | "intersect" | "except" | "on" | "returning",
                ) if depth == 0 => break,
                _ => {
                    if depth == 0 && anchor.is_none() {
                        anchor = Some(it.span);
                    }
                }
            }
        }
        if proj_arity != expected {
            let fallback = tail.first().map_or((0, 0), |it| it.span);
            emit(anchor.unwrap_or(fallback), proj_arity, &mut diagnostics);
        }
    }
    diagnostics
}

/// Unknown-column ERROR for the INSERT column list and the UPSERT
/// `DO UPDATE SET` columns (ADR-0033 §8.4 cross-cut + DA finding,
/// sub-phase 3i).
///
/// The flat-scope `schema_existence_diagnostics` validates bare
/// columns against `bindings` built from `table_name`-role idents.
/// An INSERT's target uses the `insert_target_table` role, so it
/// isn't a binding — leaving `insert_column` (the `(a, b)` list) and
/// `update_set_column` (the `DO UPDATE SET` LHS) unvalidated. Adding
/// the target as a binding would falsely make a bare `INSERT … SELECT`
/// projection ambiguous against the target. This targeted pass
/// instead validates those two roles directly against the target's
/// columns — matching the parity a top-level `UPDATE`'s SET column
/// already has (`sql_update_unknown_set_column_is_error`).
///
/// Only applies to statements with an `insert_target_table` (INSERT
/// / UPSERT); a top-level `UPDATE`/`DELETE` has no such ident, so its
/// columns continue through the binding-based pass unchanged.
fn dml_target_column_diagnostics(
    path: &MatchedPath,
    schema: Option<&crate::completion::SchemaCache>,
) -> Vec<outcome::Diagnostic> {
    use crate::dsl::grammar::IdentSource;
    use outcome::{Diagnostic, MatchedKind, Severity};

    let Some(schema) = schema else {
        return Vec::new();
    };
    let Some(target) = path.items.iter().find_map(|it| match it.kind {
        MatchedKind::Ident {
            source: IdentSource::Tables,
            role: "insert_target_table",
        } => Some(it.text.as_str()),
        _ => None,
    }) else {
        return Vec::new();
    };
    // Unknown target table is flagged on its own slot elsewhere; if
    // we can't resolve its columns, defer rather than false-flag.
    let Some(cols) = schema.columns_for_table(target) else {
        return Vec::new();
    };

    let mut diagnostics = Vec::new();
    for item in &path.items {
        let MatchedKind::Ident {
            source: IdentSource::Columns,
            role: "insert_column" | "update_set_column" | "conflict_target_column",
        } = item.kind
        else {
            continue;
        };
        if !cols.iter().any(|c| c.name.eq_ignore_ascii_case(&item.text)) {
            diagnostics.push(Diagnostic {
                severity: Severity::Error,
                span: item.span,
                message: crate::friendly::translate(
                    "diagnostic.unknown_column",
                    &[
                        ("name", &item.text as &dyn std::fmt::Display),
                        ("table", &target as &dyn std::fmt::Display),
                    ],
                ),
            });
        }
    }
    diagnostics
}

/// The INSERT target a bare column ref should resolve against, if
/// the ref sits in a region that references the *target* row rather
/// than the statement's `FROM`/row-source tables (ADR-0033 §5/§9,
/// sub-phase 3i `/runda` round):
///
/// - the UPSERT `DO UPDATE` action (`upsert` = `(target, start,
///   end)`), and
/// - an INSERT's `RETURNING` list (`insert_target` + `returning_pos`).
///
/// For an `INSERT … SELECT …` the flat `bindings` are the SELECT's
/// source tables, so without this re-scoping a target-only column in
/// `DO UPDATE` / `RETURNING` is falsely flagged against the source.
/// Returns `None` for refs that should use the normal binding scope
/// (the row source's own projection / WHERE, a top-level
/// UPDATE/DELETE whose target is already a binding, etc.).
fn bare_ref_insert_target<'a>(
    span_start: usize,
    upsert: Option<&'a (String, usize, usize)>,
    insert_target: Option<&'a str>,
    returning_pos: Option<usize>,
) -> Option<&'a str> {
    if let Some((t, start, end)) = upsert
        && span_start >= *start
        && span_start < *end
    {
        return Some(t);
    }
    if let (Some(t), Some(rs)) = (insert_target, returning_pos)
        && span_start >= rs
    {
        return Some(t);
    }
    None
}

/// `not_null_missing` WARNING (ADR-0033 §8.3, sub-phase 3i).
///
/// A SQL `INSERT` with an explicit `(column_name_list)` that omits a
/// column declared `NOT NULL` with no `DEFAULT` will be rejected by
/// the engine; this pre-flights it as a WARNING (advisory — the
/// statement still parses; the engine enforces it). `serial` /
/// `shortid` columns are excluded: they are auto-filled (engine
/// rowid / worker post-fill), so omitting them is correct, not a
/// missing required value. Only the explicit-column-list form is
/// covered (the no-list form's omission is an arity matter).
///
/// Anchored on the target-table ident — there is no token for the
/// omitted column to point at. One WARNING per missing column.
fn dml_not_null_missing_diagnostics(
    path: &MatchedPath,
    schema: Option<&crate::completion::SchemaCache>,
) -> Vec<outcome::Diagnostic> {
    use crate::dsl::grammar::IdentSource;
    use crate::dsl::types::Type;
    use outcome::{Diagnostic, MatchedKind, Severity};

    let Some(schema) = schema else {
        return Vec::new();
    };
    let Some((target, target_span)) = path.items.iter().find_map(|it| match it.kind {
        MatchedKind::Ident {
            source: IdentSource::Tables,
            role: "insert_target_table",
        } => Some((it.text.as_str(), it.span)),
        _ => None,
    }) else {
        return Vec::new();
    };
    let listed: Vec<String> = path
        .items
        .iter()
        .filter_map(|it| match it.kind {
            MatchedKind::Ident {
                source: IdentSource::Columns,
                role: "insert_column",
            } => Some(it.text.to_ascii_lowercase()),
            _ => None,
        })
        .collect();
    if listed.is_empty() {
        return Vec::new();
    }
    let Some(cols) = schema.columns_for_table(target) else {
        return Vec::new();
    };

    let mut diagnostics = Vec::new();
    for c in cols {
        let auto_generated = matches!(c.user_type, Type::Serial | Type::ShortId);
        let required = c.not_null && !c.has_default && !auto_generated;
        if required && !listed.contains(&c.name.to_ascii_lowercase()) {
            diagnostics.push(Diagnostic {
                severity: Severity::Warning,
                span: target_span,
                message: crate::friendly::translate(
                    "diagnostic.not_null_missing",
                    &[("column", &c.name as &dyn std::fmt::Display)],
                ),
            });
        }
    }
    diagnostics
}

/// SQL-expression predicate-warning pass (ADR-0032 §11.6 — the
/// Phase-1 carry-over gap closure).
///
/// Phase 1's `predicate_warnings` walks the DSL `Expr` AST and
/// emits `diagnostic.eq_null`, `diagnostic.type_mismatch`, and
/// `diagnostic.like_numeric` (ADR-0027 Amendment 1). The SQL
/// expression grammar (`sql_expr.rs`) deliberately builds no
/// AST (ADR-0031 §2), so until Phase 2 the same warnings
/// silently failed to fire on SQL `WHERE` / `HAVING` / `ON` /
/// `CASE` / projection / `ORDER BY` slots.
///
/// This pass walks the matched-path looking for the predicate-
/// tail shapes by node-name labels and emits the same catalog
/// keys. Scope is intentionally narrow: only bare column refs
/// in the form `<column> <cmp> <literal>` are recognised. The
/// qualified-ref form (`<t>.<c> <cmp> <literal>`) and
/// expression-operand cases (`<expr> LIKE <literal>` where the
/// expression isn't a bare column) are not detected here —
/// catching them would require either an AST or a much fuller
/// pattern matcher, and the false-negative posture is safe
/// (the warning is advisory; the engine still runs the query).
fn sql_predicate_warnings(
    path: &MatchedPath,
    schema: Option<&crate::completion::SchemaCache>,
) -> Vec<outcome::Diagnostic> {
    use crate::dsl::grammar::IdentSource;
    use outcome::{Diagnostic, MatchedKind, Severity};

    let Some(schema) = schema else {
        return Vec::new();
    };

    // Pre-pass: same as `schema_existence_diagnostics` — collect
    // the in-scope bindings so a bare column ref can be resolved
    // to its source table.
    let mut bindings: Vec<PassBinding> = Vec::new();
    let mut cte_names: Vec<String> = Vec::new();
    {
        let mut pending_alias_index: Option<usize> = None;
        for item in &path.items {
            let MatchedKind::Ident { source, role } = item.kind else {
                continue;
            };
            match source {
                IdentSource::Tables
                    if role == "table_name"
                        && (schema_has_table(schema, &item.text)
                            || cte_names_contains(&cte_names, &item.text)) =>
                {
                    bindings.push(PassBinding {
                        table: item.text.clone(),
                        alias: None,
                    });
                    pending_alias_index = Some(bindings.len() - 1);
                }
                IdentSource::Tables if role == "table_name" => {
                    pending_alias_index = None;
                }
                IdentSource::NewName if role == "table_alias" => {
                    if let Some(idx) = pending_alias_index {
                        bindings[idx].alias = Some(item.text.clone());
                    }
                    pending_alias_index = None;
                }
                IdentSource::NewName if role == "cte_name" => {
                    if !cte_names_contains(&cte_names, &item.text) {
                        cte_names.push(item.text.clone());
                    }
                    pending_alias_index = None;
                }
                _ => {
                    pending_alias_index = None;
                }
            }
        }
    }

    let mut diagnostics = Vec::new();
    let items = &path.items;

    // Scan for predicate-tail shapes: `<bare-column>` followed
    // by `<cmp-op> <literal-or-null>` or `LIKE <literal>`.
    for i in 0..items.len() {
        let MatchedKind::Ident { source, role } = items[i].kind else {
            continue;
        };
        if source != IdentSource::Columns || role != "sql_expr_ident" {
            continue;
        }
        // Skip qualified-ref qualifiers — they're handled by
        // resolving the t.c chain on the qualifier's binding,
        // which this minimal pass doesn't do.
        if is_followed_by_qualified_ref(items, i) {
            continue;
        }
        // Resolve column → which binding's column → what type.
        let Some(col_type) =
            resolve_bare_column_type(&bindings, &cte_names, schema, &items[i].text)
        else {
            // Unknown column or in a CTE-binding (whose columns
            // are unknown until harvest lands). Either way, skip.
            continue;
        };

        let col_name = items[i].text.clone();
        let Some(next) = items.get(i + 1) else {
            continue;
        };

        // `IS NULL` / `IS NOT NULL` is the right way to test
        // NULL, but `= NULL` / `!= NULL` is the trap — flag.
        if let MatchedKind::Word(kw @ ("=" | "!=" | "<>")) = next.kind
            && let Some(third) = items.get(i + 2)
            && matches!(third.kind, MatchedKind::Word("null"))
        {
            let _ = kw;
            diagnostics.push(Diagnostic {
                severity: Severity::Warning,
                span: third.span,
                message: crate::friendly::translate("diagnostic.eq_null", &[]),
            });
            continue;
        }

        // `<column> LIKE <literal>` — pedagogical: LIKE is a
        // text-pattern match, so a numeric column rarely makes
        // sense as the target.
        if matches!(next.kind, MatchedKind::Word("like")) && col_type.is_numeric() {
            diagnostics.push(Diagnostic {
                severity: Severity::Warning,
                span: items[i].span,
                message: crate::friendly::translate(
                    "diagnostic.like_numeric",
                    &[
                        ("column", &col_name as &dyn std::fmt::Display),
                        ("type", &col_type.keyword() as &dyn std::fmt::Display),
                    ],
                ),
            });
            continue;
        }

        // `<column> <cmp> <literal>` — emit type_mismatch when
        // the literal's type is structurally incompatible with
        // the column's type. Conservative: only flag clear-cut
        // numeric-vs-text mismatches.
        if let MatchedKind::Word(op @ ("=" | "!=" | "<>" | "<" | "<=" | ">" | ">=")) = next.kind
            && let Some(third) = items.get(i + 2)
        {
            let _ = op;
            let mismatch = match (col_type, &third.kind) {
                // Numeric column vs string literal.
                (
                    crate::dsl::types::Type::Int
                    | crate::dsl::types::Type::Real
                    | crate::dsl::types::Type::Decimal
                    | crate::dsl::types::Type::Serial,
                    MatchedKind::StringLit,
                ) => true,
                // Text-shaped column vs raw number literal.
                (
                    crate::dsl::types::Type::Text
                    | crate::dsl::types::Type::Date
                    | crate::dsl::types::Type::DateTime
                    | crate::dsl::types::Type::ShortId,
                    MatchedKind::NumberLit,
                ) => true,
                // Bool vs anything but `true`/`false`/0/1 numbers
                // — too noisy to flag in this conservative pass.
                _ => false,
            };
            if mismatch {
                diagnostics.push(Diagnostic {
                    severity: Severity::Warning,
                    span: third.span,
                    message: crate::friendly::translate(
                        "diagnostic.type_mismatch",
                        &[
                            ("column", &col_name as &dyn std::fmt::Display),
                            ("type", &col_type.keyword() as &dyn std::fmt::Display),
                        ],
                    ),
                });
            }
        }
    }
    diagnostics
}

/// Look up a bare column ref's type by checking each binding.
/// Returns the type if exactly one binding owns the column.
/// Returns `None` for unknown / ambiguous / CTE-routed columns
/// (the latter because the §10.3 stage-2 harvest is deferred,
/// so CTE binding columns are unknown).
fn resolve_bare_column_type(
    bindings: &[PassBinding],
    cte_names: &[String],
    schema: &crate::completion::SchemaCache,
    column: &str,
) -> Option<crate::dsl::types::Type> {
    let mut found: Option<crate::dsl::types::Type> = None;
    for b in bindings {
        if cte_names_contains(cte_names, &b.table) {
            // CTE — columns unknown for now.
            continue;
        }
        if let Some(ty) = schema_column_type(schema, &b.table, column) {
            if found.is_some() {
                // Ambiguous — skip the warning.
                return None;
            }
            found = Some(ty);
        }
    }
    found
}

/// True when the matched-path item at index `i` is immediately
/// followed by `Punct('.')` and a `Columns`-source ident with
/// role `sql_expr_qualified_ref` — i.e. this item is the `t`
/// half of a `t.c` qualified reference. Used by
/// `schema_existence_diagnostics` to skip the bare-column check
/// on qualifiers.
fn is_followed_by_qualified_ref(items: &[outcome::MatchedItem], i: usize) -> bool {
    use outcome::MatchedKind;
    let dot = items.get(i + 1);
    let next_ident = items.get(i + 2);
    matches!(dot.map(|it| &it.kind), Some(MatchedKind::Punct('.')))
        && matches!(
            next_ident.map(|it| &it.kind),
            Some(MatchedKind::Ident {
                role: "sql_expr_qualified_ref",
                ..
            })
        )
}

/// True when the ident at `items[i]` is the *name* of a function call —
/// the immediately following item is `(`. Mirrors the qualified-ref
/// guard and lets the bare-column check in
/// `schema_existence_diagnostics` skip validating a function-call name
/// against the schema (issue #6). The grammar admits the call shape
/// structurally (sql_expr.rs's CALL_ARGS comment: "it does not know
/// which names are aggregates"), so this validator must match: an
/// ident followed by `(` is a function-call name, not a column ref.
fn is_followed_by_call_args(items: &[outcome::MatchedItem], i: usize) -> bool {
    use outcome::MatchedKind;
    matches!(
        items.get(i + 1).map(|it| &it.kind),
        Some(MatchedKind::Punct('('))
    )
}

fn schema_has_table(schema: &crate::completion::SchemaCache, name: &str) -> bool {
    schema.tables.iter().any(|t| t.eq_ignore_ascii_case(name))
}

fn schema_column_type(
    schema: &crate::completion::SchemaCache,
    table: &str,
    column: &str,
) -> Option<crate::dsl::types::Type> {
    schema
        .columns_for_table(table)?
        .iter()
        .find(|c| c.name.eq_ignore_ascii_case(column))
        .map(|c| c.user_type)
}

fn schema_has_column(schema: &crate::completion::SchemaCache, table: &str, column: &str) -> bool {
    schema
        .columns_for_table(table)
        .is_some_and(|cols| cols.iter().any(|c| c.name.eq_ignore_ascii_case(column)))
}

/// The WHERE expression of a filter command, if it has one.
const fn command_where_expr(command: &Command) -> Option<&Expr> {
    match command {
        Command::Update {
            filter: RowFilter::Where(expr),
            ..
        }
        | Command::Delete {
            filter: RowFilter::Where(expr),
            ..
        }
        | Command::ShowData {
            filter: Some(expr), ..
        } => Some(expr),
        _ => None,
    }
}

/// WARNING diagnostics for a WHERE expression (ADR-0026 §7):
/// a type-mismatched comparison, or `= NULL` / `!= NULL`.
/// Both are valid and runnable — the warning is advisory.
///
/// Each diagnostic's span is the offending **literal operand**
/// — precise enough for a per-literal highlight (ADR-0027).
fn expr_warnings(
    expr: &Expr,
    columns: &[crate::completion::TableColumn],
) -> Vec<outcome::Diagnostic> {
    let mut out = Vec::new();
    collect_expr_warnings(expr, columns, &mut out);
    out
}

fn collect_expr_warnings(
    expr: &Expr,
    columns: &[crate::completion::TableColumn],
    out: &mut Vec<outcome::Diagnostic>,
) {
    match expr {
        Expr::Or(terms) | Expr::And(terms) => {
            for term in terms {
                collect_expr_warnings(term, columns, out);
            }
        }
        Expr::Not(inner) => collect_expr_warnings(inner, columns, out),
        Expr::Predicate(predicate) => {
            predicate_warnings(predicate, columns, out);
        }
    }
}

fn predicate_warnings(
    predicate: &Predicate,
    columns: &[crate::completion::TableColumn],
    out: &mut Vec<outcome::Diagnostic>,
) {
    use outcome::{Diagnostic, Severity};
    let warn = |message: String, span: (usize, usize)| Diagnostic {
        severity: Severity::Warning,
        span,
        message,
    };
    match predicate {
        Predicate::Compare { left, op, right } => {
            // `= NULL` / `!= NULL`: valid syntax that is never
            // true — the user almost certainly means IS NULL.
            // The highlight points at the `null` literal itself.
            let null_operand = if matches!(op, CompareOp::Eq | CompareOp::NotEq) {
                [left, right].into_iter().find(|&o| is_null_literal(o))
            } else {
                None
            };
            if let Some(operand) = null_operand {
                out.push(warn(
                    crate::friendly::translate("diagnostic.eq_null", &[]),
                    operand.span(),
                ));
            } else if let Some((message, span)) = pair_type_mismatch(left, right, columns) {
                out.push(warn(message, span));
            }
        }
        Predicate::Between {
            target, low, high, ..
        } => {
            for bound in [low, high] {
                if let Some((message, span)) = pair_type_mismatch(target, bound, columns) {
                    out.push(warn(message, span));
                }
            }
        }
        Predicate::In { target, items, .. } => {
            for item in items {
                if let Some((message, span)) = pair_type_mismatch(target, item, columns) {
                    out.push(warn(message, span));
                }
            }
        }
        // `LIKE` is a text-pattern test; against a numeric
        // column it runs but is almost never intended
        // (ADR-0027, Amendment 1). The negation is irrelevant —
        // `NOT LIKE` on a numeric column is just as dubious.
        Predicate::Like { target, .. } => {
            if let Some((message, span)) = like_numeric_warning(target, columns) {
                out.push(warn(message, span));
            }
        }
        // `IS [NOT] NULL` is the *correct* null test — never
        // flagged.
        Predicate::IsNull { .. } => {}
    }
}

/// A `LIKE` whose target is a numeric column: `LIKE` matches
/// text patterns, so a numeric target is almost certainly a
/// mistake (ADR-0027, Amendment 1). The message is paired with
/// the target column operand's span. `None` when the target is
/// a literal, an unknown column, or a non-numeric column.
fn like_numeric_warning(
    target: &Operand,
    columns: &[crate::completion::TableColumn],
) -> Option<(String, (usize, usize))> {
    let Operand::Column { name, span } = target else {
        return None;
    };
    let ty = columns
        .iter()
        .find(|tc| tc.name.eq_ignore_ascii_case(name))?
        .user_type;
    if !ty.is_numeric() {
        return None;
    }
    Some((
        crate::friendly::translate(
            "diagnostic.like_numeric",
            &[
                ("column", name as &dyn std::fmt::Display),
                ("type", &ty.keyword() as &dyn std::fmt::Display),
            ],
        ),
        *span,
    ))
}

const fn is_null_literal(operand: &Operand) -> bool {
    matches!(
        operand,
        Operand::Literal {
            value: crate::dsl::value::Value::Null,
            ..
        }
    )
}

/// If one operand is a known column and the other a non-null
/// literal whose type the column cannot hold, the type-mismatch
/// WARNING message paired with the **literal operand's span**;
/// otherwise `None` (column-to-column, literal-to-literal, an
/// unknown column — already an ERROR — or a compatible pair).
fn pair_type_mismatch(
    a: &Operand,
    b: &Operand,
    columns: &[crate::completion::TableColumn],
) -> Option<(String, (usize, usize))> {
    let (column, literal, span) = match (a, b) {
        (Operand::Column { name, .. }, Operand::Literal { value, span })
        | (Operand::Literal { value, span }, Operand::Column { name, .. }) => (name, value, *span),
        _ => return None,
    };
    // `null` fits any column; `= NULL` is flagged separately.
    if matches!(literal, crate::dsl::value::Value::Null) {
        return None;
    }
    let ty = columns
        .iter()
        .find(|tc| tc.name.eq_ignore_ascii_case(column))?
        .user_type;
    if literal.bind_for_column(column, ty).is_ok() {
        return None;
    }
    Some((
        crate::friendly::translate(
            "diagnostic.type_mismatch",
            &[
                ("column", column as &dyn std::fmt::Display),
                ("type", &ty.keyword() as &dyn std::fmt::Display),
            ],
        ),
        span,
    ))
}

/// What the grammar would accept at the end of `source`
/// (ADR-0024 §architecture, Phase F walker-driven completion).
///
/// Empty / whitespace-only input yields every command-entry word
/// as `Expectation::Word(primary)`. Otherwise the walker is
/// driven to `EndOfInput`; if the input completes a command,
/// the result is empty; if it fails or is incomplete, the
/// walker's expected-set surfaces verbatim — `Ident { source,
/// role }` carries its `IdentSource` (so the completion engine
/// can schema-look-up without a string round-trip), `Word` /
/// `Literal` carry their primary literal, etc.
///
/// Inputs whose first token is not a registered entry word
/// fall back to listing every entry word — matches the
/// synthetic "unknown command" expectation set the parser
/// produces.
#[must_use]
pub fn expected_at_input(source: &str) -> Vec<outcome::Expectation> {
    expected_at_input_in_mode(source, crate::mode::Mode::Advanced)
}

/// Mode-aware [`expected_at_input`] (ADR-0030 §2). Filters the
/// empty / unknown-entry fallback by mode so simple mode does
/// not surface advanced-only entry words.
#[must_use]
pub fn expected_at_input_in_mode(
    source: &str,
    mode: crate::mode::Mode,
) -> Vec<outcome::Expectation> {
    use crate::dsl::grammar::{REGISTRY, is_advanced_only};

    let mode_filtered = || -> Vec<outcome::Expectation> {
        // De-duplicate shared entry words (sub-phase 3j): `insert` /
        // `update` / `delete` each appear twice in `REGISTRY` (Simple
        // DSL + Advanced SQL nodes); offer the word once.
        let mut seen = std::collections::HashSet::new();
        REGISTRY
            .iter()
            .filter(|(c, _)| {
                mode == crate::mode::Mode::Advanced || !is_advanced_only(c.entry.primary)
            })
            .filter(|(c, _)| seen.insert(c.entry.primary))
            .map(|(c, _)| outcome::Expectation::Word(c.entry.primary))
            .collect()
    };

    if source.trim().is_empty() {
        return mode_filtered();
    }
    let mut ctx = context::WalkContext::new();
    ctx.mode = mode;
    let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
    let Some(result) = result else {
        // Walker didn't engage (unknown entry word): the
        // completion engine should still surface the available
        // entry words so the user can recover.
        return mode_filtered();
    };
    match result.outcome {
        // On Match, surface the outer-shape's skipped-Optional
        // expectations so the completion engine can offer
        // optional-suffix candidates at the end of a valid
        // command (`save` → `as`, etc.).
        outcome::WalkOutcome::Match { .. } => result.tail_expected,
        // A trailing-junk Mismatch (the shape matched, then the
        // user kept typing) still carries the outer shape's
        // skipped trailing optionals in `tail_expected` — e.g.
        // an optional `--create-fk` flag the trailing `--` is
        // starting to type. Surface those alongside the
        // mismatch's own expected set so completion still offers
        // them. A genuine mid-command mismatch has an empty
        // `tail_expected`, so this is a no-op there.
        outcome::WalkOutcome::Mismatch { expected, .. } => {
            let mut merged = expected;
            merged.extend(result.tail_expected);
            merged
        }
        outcome::WalkOutcome::Incomplete { expected, .. } => expected,
        // Validation failure path: the walker matched the
        // structural shape but the AST builder rejected (e.g.
        // Form C with column-shaped items). The walker still
        // captured the skipped-Optional expectations before the
        // validation fired — surface those so the user gets
        // useful Tab candidates even at a validation-flagged
        // position.
        outcome::WalkOutcome::ValidationFailed { .. } => result.tail_expected,
    }
}

/// Strict-required expected set at the end of `source`, plus
/// the walker's `pending_value_type` at the cursor.
///
/// Like `expected_at_input` but returns empty on
/// `WalkOutcome::Match` — optional-suffix continuations are not
/// surfaced. Used by the hint resolver to distinguish "must
/// type more" from "could continue", and to dispatch per-type
/// prose when the cursor is inside a typed value slot.
/// Post-walk snapshot the hint resolver needs: the strict
/// expected set plus the `WalkContext` fields that survive the
/// walk and feed per-column / pedagogical prose.
struct HintWalkSnapshot {
    expected: Vec<outcome::Expectation>,
    pending_value_type: Option<crate::dsl::types::Type>,
    pending_value_column: Option<String>,
    /// The grammar-declared `HintMode` at the cursor's slot
    /// (`Node::Hinted` annotation, ADR-0024 §HintMode-per-node).
    pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
    /// An `IntroProse` catalog key for an *optional* positional slot at
    /// the cursor that produced no candidate (issue #26 — `seed <table>`
    /// row count). Survives the trailing optional siblings that clear
    /// `pending_hint_mode`; already filtered to the cursor position.
    surviving_intro_hint: Option<&'static str>,
    current_table_columns: Option<Vec<crate::completion::TableColumn>>,
    /// `Some` when the input used Form A's explicit column list.
    /// `None` for Form B (`insert into T values …`) and for
    /// every non-insert command.
    user_listed_columns: Option<Vec<String>>,
}

fn expected_for_hint_snapshot(
    source: &str,
    schema: Option<&crate::completion::SchemaCache>,
    mode: crate::mode::Mode,
) -> HintWalkSnapshot {
    use crate::dsl::grammar::REGISTRY;

    let entry_words = || -> Vec<outcome::Expectation> {
        REGISTRY
            .iter()
            .map(|(c, _)| outcome::Expectation::Word(c.entry.primary))
            .collect()
    };

    let empty_snapshot = || HintWalkSnapshot {
        expected: entry_words(),
        pending_value_type: None,
        pending_value_column: None,
        pending_hint_mode: None,
        surviving_intro_hint: None,
        current_table_columns: None,
        user_listed_columns: None,
    };

    if source.trim().is_empty() {
        return empty_snapshot();
    }
    let mut ctx = schema.map_or_else(context::WalkContext::new, |s| {
        context::WalkContext::with_schema(s)
    });
    ctx.mode = mode;
    let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
    let Some(result) = result else {
        return empty_snapshot();
    };
    let expected = match result.outcome {
        outcome::WalkOutcome::Match { .. } | outcome::WalkOutcome::ValidationFailed { .. } => {
            Vec::new()
        }
        outcome::WalkOutcome::Incomplete { expected, .. }
        | outcome::WalkOutcome::Mismatch { expected, .. } => expected,
    };
    HintWalkSnapshot {
        expected,
        pending_value_type: ctx.pending_value_type,
        pending_value_column: ctx.pending_value_column,
        pending_hint_mode: ctx.pending_hint_mode,
        // Issue #26: only surface the skipped-optional hint when the
        // optional was skipped *at the cursor* (the end of the walked
        // slice). Captured earlier (before a later clause consumed past
        // it) → stale, so drop it.
        surviving_intro_hint: ctx
            .surviving_intro_hint
            .filter(|(_, pos)| *pos == source.len())
            .map(|(key, _)| key),
        current_table_columns: ctx.current_table_columns,
        user_listed_columns: ctx.user_listed_columns,
    }
}

/// Public walk entry. `bound` is `EndOfInput` for parse;
/// `Position(cursor)` for completion / hint (Phase A: not yet
/// wired).
///
/// Returns:
/// - `(Some(WalkResult), Some(Command))` on full match — the
///   AST builder produced a typed Command.
/// - `(Some(WalkResult), None)` on failure where the walker
///   committed (matched the entry word). Caller surfaces the
///   walker's error.
/// - `(None, None)` when the entry word doesn't match any
///   registered command — the router falls through to chumsky.
pub fn walk<'a>(
    source: &str,
    bound: WalkBound,
    ctx: &mut WalkContext<'a>,
) -> (Option<WalkResult>, Option<Command>) {
    // Phase A only consumes EndOfInput; Position would slice
    // the source, which is the same operation.
    let effective_source: &str = match bound {
        WalkBound::EndOfInput => source,
        WalkBound::Position(end) => &source[..end.min(source.len())],
    };

    let start = skip_whitespace(effective_source, 0);
    if start >= effective_source.len() {
        return (None, None);
    }

    // Identify the command by its entry word. If the first
    // identifier-shape token isn't a registered entry, the
    // walker yields to chumsky.
    let Some((kw_start, kw_end)) = consume_ident(effective_source, start) else {
        return (None, None);
    };
    let entry_text = &effective_source[kw_start..kw_end];
    let candidates = grammar::commands_for_entry_word(entry_text);
    if candidates.is_empty() {
        // First token isn't a registered entry word — yield to
        // the chumsky path.
        return (None, None);
    }

    // ADR-0033 Amendment 1 — category-grouped, mode-aware
    // dispatch. `decide` chooses which registered candidate to
    // commit (or emits the "this is SQL" hint), running any
    // speculative match-testing on scratch contexts so the
    // caller's `ctx` is only ever touched by the committed walk.
    match decide(
        effective_source,
        kw_start,
        kw_end,
        &candidates,
        ctx.mode,
        ctx.schema,
    ) {
        Decision::Commit { idx, node } => {
            let (result, cmd) =
                walk_one_command(effective_source, source, kw_start, kw_end, idx, node, ctx);
            (Some(result), cmd)
        }
        Decision::ThisIsSql { primary } => (
            Some(this_is_sql_result(entry_text, primary, kw_start, kw_end)),
            None,
        ),
    }
}

/// The dispatcher's choice for a given input (ADR-0033
/// Amendment 1): commit a specific registered candidate, or emit
/// the simple-mode "this is SQL" hint.
enum Decision {
    /// Walk this candidate into the caller's `WalkContext`.
    Commit {
        idx: usize,
        node: &'static crate::dsl::grammar::CommandNode,
    },
    /// Simple mode with SQL-shaped input: emit
    /// `advanced_mode.sql_in_simple`, carrying this entry literal.
    ThisIsSql { primary: &'static str },
}

/// Category-grouped, mode-aware dispatch decision (ADR-0033
/// Amendment 1).
///
/// Pure with respect to the caller's context: any speculative
/// match-testing runs on a fresh scratch `WalkContext` (see
/// `scratch_outcome`), so `decide` never mutates the caller's
/// accumulators.
///
/// - **Simple mode** commits the DSL (`Simple`) candidate. With
///   no DSL candidate (a SQL-only entry word) it emits the
///   "this is SQL" hint. For a shared entry word whose DSL shape
///   does not match but whose SQL shape does, it also emits the
///   hint — so `delete … returning *` in simple mode points the
///   user at advanced mode rather than at a bare DSL parse error.
/// - **Advanced mode** tries `Advanced` candidates first, then
///   the `Simple` candidate as a fallback; the first full match
///   wins. When none fully match it commits the candidate that
///   progressed furthest (advanced-first on ties) so the surfaced
///   error is the most informative.
fn decide(
    effective_source: &str,
    kw_start: usize,
    kw_end: usize,
    candidates: &[(
        usize,
        &'static crate::dsl::grammar::CommandNode,
        crate::dsl::grammar::CommandCategory,
    )],
    mode: crate::mode::Mode,
    schema: Option<&crate::completion::SchemaCache>,
) -> Decision {
    use crate::dsl::grammar::CommandCategory;

    let advanced: Vec<(usize, &'static crate::dsl::grammar::CommandNode)> = candidates
        .iter()
        .filter(|(_, _, cat)| *cat == CommandCategory::Advanced)
        .map(|(i, n, _)| (*i, *n))
        .collect();
    let simple: Vec<(usize, &'static crate::dsl::grammar::CommandNode)> = candidates
        .iter()
        .filter(|(_, _, cat)| *cat == CommandCategory::Simple)
        .map(|(i, n, _)| (*i, *n))
        .collect();

    match mode {
        crate::mode::Mode::Simple => {
            // Simple mode is the DSL surface (ADR-0003). A shared
            // entry word (`insert`/`update`/`delete`) commits its DSL
            // candidate so the user sees DSL completion and the *real*
            // DSL error — with its position — rather than a bare
            // "this is SQL" that discards the actionable detail
            // (ADR-0033 Amendment 3). "This is SQL" is reserved for
            // entry words with no DSL form (`select` / `with`): there
            // the DSL surface has nothing to offer. For a DSL line
            // that fails here but would be *valid* in advanced mode (the
            // ADR-0027 sense, per ADR-0033 Amendment 5: verdict `None`,
            // i.e. parse succeeds + no Warning/Error from any
            // diagnostic pass), a "Trying to write SQL?" pointer is
            // appended at the rendering layer (see
            // `advanced_alternative_note`), combining the DSL fix with
            // the mode hint.
            if simple.is_empty() {
                let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary);
                return Decision::ThisIsSql { primary };
            }
            // An entry word may register more than one DSL form
            // (e.g. `create table` and `create m:n relationship`,
            // ADR-0045). Commit the first that fully matches or is
            // content-rejected (a `ValidationFailed` means the shape
            // fits but the content is invalid — that error must
            // surface), mirroring the advanced branch below. With a
            // single DSL form this reduces to "commit it": a lone
            // non-matching candidate falls through to the
            // furthest-progress step and is committed anyway, so its
            // positioned DSL error still surfaces (unchanged behaviour).
            for &(idx, node) in &simple {
                if matches!(
                    scratch_outcome(effective_source, kw_start, kw_end, node, mode, schema),
                    WalkOutcome::Match { .. } | WalkOutcome::ValidationFailed { .. }
                ) {
                    return Decision::Commit { idx, node };
                }
            }
            // None matched — commit the furthest-progress candidate
            // (first on ties) so the surfaced DSL error is the most
            // informative.
            let mut best = simple[0];
            let mut best_progress =
                scratch_progress(effective_source, kw_start, kw_end, best.1, mode, schema);
            for &(idx, node) in &simple[1..] {
                let progress =
                    scratch_progress(effective_source, kw_start, kw_end, node, mode, schema);
                if progress > best_progress {
                    best = (idx, node);
                    best_progress = progress;
                }
            }
            Decision::Commit {
                idx: best.0,
                node: best.1,
            }
        }
        crate::mode::Mode::Advanced => {
            // Advanced candidates first, DSL as the fallback.
            let ordered: Vec<(usize, &'static crate::dsl::grammar::CommandNode)> =
                advanced.iter().chain(simple.iter()).copied().collect();
            // `candidates` is non-empty (the caller checked), so
            // `ordered` is non-empty too.
            if ordered.len() == 1 {
                let (idx, node) = ordered[0];
                return Decision::Commit { idx, node };
            }
            // Commit the first candidate that fully matches OR is
            // rejected by a content validator. A `ValidationFailed`
            // means the shape *fits* but the content is invalid (e.g.
            // an internal `__rdbms_*` target rejected by
            // `reject_internal_table`); that error must surface rather
            // than being masked by falling back to a candidate that
            // lacks the validator (sub-phase 3j — the DSL `insert`
            // node has no internal-table rail). A structural
            // `Mismatch` (e.g. the DSL-only `--all-rows` the SQL shape
            // can't consume) is *not* committed here, so the fallback
            // to the DSL node still works.
            for &(idx, node) in &ordered {
                if matches!(
                    scratch_outcome(effective_source, kw_start, kw_end, node, mode, schema),
                    WalkOutcome::Match { .. } | WalkOutcome::ValidationFailed { .. }
                ) {
                    return Decision::Commit { idx, node };
                }
            }
            // None matched or content-rejected — commit the
            // furthest-progress candidate, keeping the first
            // (advanced) on ties.
            let mut best = ordered[0];
            let mut best_progress =
                scratch_progress(effective_source, kw_start, kw_end, best.1, mode, schema);
            for &(idx, node) in &ordered[1..] {
                let progress =
                    scratch_progress(effective_source, kw_start, kw_end, node, mode, schema);
                if progress > best_progress {
                    best = (idx, node);
                    best_progress = progress;
                }
            }
            Decision::Commit {
                idx: best.0,
                node: best.1,
            }
        }
    }
}

/// Build the `advanced_mode.sql_in_simple` result for a SQL entry
/// word typed in simple mode (ADR-0030 §2, ADR-0033 Amendment 1).
/// The entry word stays highlighted as a keyword; the input
/// carries an ERROR verdict (it will not run here).
fn this_is_sql_result(
    entry_text: &str,
    primary: &'static str,
    kw_start: usize,
    kw_end: usize,
) -> WalkResult {
    let mut path = MatchedPath::new();
    let mut per_byte = Vec::new();
    path.push(crate::dsl::walker::outcome::MatchedItem {
        kind: crate::dsl::walker::outcome::MatchedKind::Word(primary),
        text: entry_text.to_string(),
        span: (kw_start, kw_end),
    });
    per_byte.push(crate::dsl::walker::outcome::ByteClass {
        start: kw_start,
        end: kw_end,
        class: grammar::HighlightClass::Keyword,
    });
    WalkResult {
        outcome: WalkOutcome::ValidationFailed {
            position: kw_start,
            error: crate::dsl::grammar::ValidationError {
                message_key: "advanced_mode.sql_in_simple",
                args: vec![("command", primary.to_string())],
            },
        },
        matched_path: path,
        per_byte_class: per_byte,
        diagnostics: Vec::new(),
        tail_expected: Vec::new(),
    }
}

/// Run `walk_one_command` on a fresh scratch `WalkContext` so the
/// dispatcher can test a candidate without disturbing the
/// caller's accumulators (ADR-0033 Amendment 1).
fn scratch_outcome(
    effective_source: &str,
    kw_start: usize,
    kw_end: usize,
    node: &'static crate::dsl::grammar::CommandNode,
    mode: crate::mode::Mode,
    schema: Option<&crate::completion::SchemaCache>,
) -> WalkOutcome {
    let mut sctx = schema.map_or_else(context::WalkContext::new, context::WalkContext::with_schema);
    sctx.mode = mode;
    let (result, _cmd) = walk_one_command(
        effective_source,
        effective_source,
        kw_start,
        kw_end,
        0,
        node,
        &mut sctx,
    );
    result.outcome
}

/// How far (byte position) a candidate's walk progressed. A full
/// match scores the whole input; a failure scores its failure
/// position. Used only to tie-break when no candidate fully
/// matches.
fn scratch_progress(
    effective_source: &str,
    kw_start: usize,
    kw_end: usize,
    node: &'static crate::dsl::grammar::CommandNode,
    mode: crate::mode::Mode,
    schema: Option<&crate::completion::SchemaCache>,
) -> usize {
    match scratch_outcome(effective_source, kw_start, kw_end, node, mode, schema) {
        WalkOutcome::Match { .. } => effective_source.len(),
        WalkOutcome::Incomplete { position, .. }
        | WalkOutcome::Mismatch { position, .. }
        | WalkOutcome::ValidationFailed { position, .. } => position,
    }
}

/// Walk a *single* committed command's shape and produce its
/// `WalkResult` + optional `Command` (ADR-0033 Amendment 1).
///
/// Factored out of `walk` so the dispatcher's speculative
/// match-testing (`scratch_outcome`) reuses the exact same walk +
/// outcome-mapping + AST-builder + diagnostic path on a scratch
/// context, while the committed walk runs into the caller's
/// context. `source` is the full (unbounded) input the AST
/// builder reads for SQL command text; `effective_source` is the
/// bound-trimmed slice the walker matches against.
fn walk_one_command<'a>(
    effective_source: &str,
    source: &str,
    kw_start: usize,
    kw_end: usize,
    command_idx: usize,
    command_node: &'static crate::dsl::grammar::CommandNode,
    ctx: &mut WalkContext<'a>,
) -> (WalkResult, Option<Command>) {
    let entry_text = &effective_source[kw_start..kw_end];
    let mut path = MatchedPath::new();
    let mut per_byte = Vec::new();

    // Record the entry-word match.
    path.push(crate::dsl::walker::outcome::MatchedItem {
        kind: crate::dsl::walker::outcome::MatchedKind::Word(command_node.entry.primary),
        text: entry_text.to_string(),
        span: (kw_start, kw_end),
    });
    per_byte.push(crate::dsl::walker::outcome::ByteClass {
        start: kw_start,
        end: kw_end,
        class: grammar::HighlightClass::Keyword,
    });

    let mut tail_expected: Vec<Expectation> = Vec::new();
    let outcome = match walk_node(
        effective_source,
        kw_end,
        &command_node.shape,
        ctx,
        &mut path,
        &mut per_byte,
    ) {
        NodeWalkResult::Matched { end, skipped } => {
            // Carry the outer shape's skipped-Optional
            // expectations into WalkResult so completion can
            // surface optional-suffix candidates (`save` →
            // `as`). Empty for shapes with no trailing
            // optionals.
            tail_expected = skipped;
            NodeWalkResult::Matched {
                end,
                skipped: Vec::new(),
            }
        }
        other => other,
    };
    let outcome = match outcome {
        NodeWalkResult::Matched { end, .. } => {
            let trailing = skip_whitespace(effective_source, end);
            if trailing < effective_source.len() {
                // The shape matched but the user kept typing.
                // Don't merge skipped-Optional expectations
                // into the trailing-input error: the completion
                // engine reads `expected` to decide what to
                // suggest, and adding "what could have come
                // before this trailing token" would suggest
                // candidates the user has already passed.
                WalkOutcome::Mismatch {
                    position: trailing,
                    expected: vec![Expectation::EndOfInput],
                }
            } else {
                WalkOutcome::Match { command_idx }
            }
        }
        NodeWalkResult::NoMatch { position, expected } => {
            // The shape required content the user hasn't typed.
            // (Optional/empty-Seq shapes always return Matched
            // even when skipped, so reaching NoMatch here means
            // the command really wanted something more.)
            let post = skip_whitespace(effective_source, position);
            if post >= effective_source.len() {
                WalkOutcome::Incomplete {
                    position: post,
                    expected,
                }
            } else {
                WalkOutcome::Mismatch {
                    position: post,
                    expected,
                }
            }
        }
        NodeWalkResult::Incomplete { position, expected } => {
            WalkOutcome::Incomplete { position, expected }
        }
        NodeWalkResult::Failed { position, kind } => match kind {
            FailureKind::Mismatch { expected } => WalkOutcome::Mismatch { position, expected },
            FailureKind::Validation(error) => WalkOutcome::ValidationFailed { position, error },
        },
    };

    // Apply the AST builder. A validation error here surfaces
    // as a `ValidationFailed` outcome (so the bridge can render
    // the catalog wording correctly) rather than as a generic
    // "AST builder failed" fallback.
    let (final_outcome, cmd) = match outcome {
        WalkOutcome::Match { .. } => match (command_node.ast_builder)(&path, source) {
            Ok(c) => (outcome, Some(c)),
            Err(error) => (
                WalkOutcome::ValidationFailed {
                    position: path.items.last().map_or(kw_start, |i| i.span.0),
                    error,
                },
                None,
            ),
        },
        other => (other, None),
    };

    // Schema-existence diagnostics (ADR-0027 §2) layer on top
    // of a structurally-valid parse; a parse that already
    // failed gets its ERROR verdict from `outcome`.
    let mut diagnostics = if matches!(final_outcome, WalkOutcome::Match { .. }) {
        let mut d = schema_existence_diagnostics(&path, ctx.schema);
        // ADR-0032 §11.6 — Phase-1 carry-over gap closure.
        // The SQL-expression predicate-warning pass runs on
        // every successful parse, covering SQL `WHERE` /
        // `HAVING` / `ON` / `CASE` / projection / `ORDER BY`
        // slots uniformly (a flat matched-path walk doesn't
        // distinguish slot kind). The existing DSL `Expr`
        // AST variant below remains the source of truth for
        // DSL `WHERE` expressions; a DSL command produces no
        // sql_expr_ident roles so the two passes don't
        // collide.
        d.extend(sql_predicate_warnings(&path, ctx.schema));
        // ADR-0032 §11.2 / §11.7 — compound-arity ERROR pass.
        // Catches `SELECT 1, 2 UNION SELECT 1` pre-flight so the
        // operator slot is highlighted rather than the engine
        // wording shown at execution time.
        d.extend(compound_arity_diagnostics(&path));
        // ADR-0033 §8.2 — WARNING when a SQL INSERT lists a
        // serial/shortid (auto-generated) column explicitly.
        d.extend(dml_auto_column_diagnostics(&path, ctx.schema));
        // ADR-0033 §8.1 (+ Amendment 5, issue #1) — ERROR when the
        // arity disagrees with a VALUES tuple (per row) or the
        // INSERT…SELECT projection. Form A uses the column list's
        // length; Form B uses the schema's column count for the
        // target table.
        d.extend(dml_insert_arity_diagnostics(&path, ctx.schema, ctx.mode));
        // ADR-0033 §8.3 — WARNING when an INSERT's column list omits
        // a NOT-NULL-no-default (non-auto-gen) column.
        d.extend(dml_not_null_missing_diagnostics(&path, ctx.schema));
        // ADR-0033 §8.4 cross-cut — ERROR for an unknown column in
        // the INSERT column list or the UPSERT DO UPDATE SET (the
        // INSERT target isn't a binding, so the flat-scope pass
        // doesn't cover these).
        d.extend(dml_target_column_diagnostics(&path, ctx.schema));
        // ADR-0032 §10.3 / §11.2 — diagnostics emitted during
        // the walk by node handlers with direct context the
        // post-walk passes can't reconstruct (primarily the
        // CTE harvest's arity-check at body-frame exit). Drain
        // unconditionally so accumulated entries don't leak
        // into a subsequent walk via a re-used WalkContext.
        d.extend(std::mem::take(&mut ctx.pending_diagnostics));
        d
    } else {
        ctx.pending_diagnostics.clear();
        Vec::new()
    };
    // Expression WARNING diagnostics — type-mismatched
    // comparisons and `= NULL` (ADR-0026 §7, surfaced through
    // ADR-0027's model). Only a successfully-built command has
    // a `where` expression to inspect.
    if let Some(command) = &cmd
        && let Some(expr) = command_where_expr(command)
    {
        let columns = ctx.current_table_columns.as_deref().unwrap_or(&[]);
        diagnostics.extend(expr_warnings(expr, columns));
    }
    let result = WalkResult {
        outcome: final_outcome,
        matched_path: path,
        per_byte_class: per_byte,
        tail_expected,
        diagnostics,
    };
    (result, cmd)
}

#[cfg(test)]
mod tests {
    //! Walker behaviour tests — Phase A (ADR-0024 §migration).
    //!
    //! These cover every app-lifecycle command the walker now
    //! owns. Each input is paired with its expected `Command`
    //! output (the differential-against-chumsky check
    //! materialised as hand-curated expectations — same role
    //! the differential test scaffolding plays per ADR-0024
    //! §test-discipline).
    //!
    //! The handoff document lists these tests as "walker-
    //! specific tests for trie-only features" — they pin down
    //! the walker's contract for the migrated commands so
    //! Phase B-F migrations can refactor without regression.
    use crate::dsl::command::{AppCommand, Command, MessagesValue, ModeValue};
    use crate::dsl::parser::parse_command_in_mode;
    use crate::mode::Mode;

    /// Parse in **Simple mode** — the DSL surface (ADR-0003). The
    /// tests in this module exercise the DSL grammar (app commands,
    /// DDL, and the `insert`/`update`/`delete` DSL forms), all
    /// canonical in Simple mode. Sub-phase 3j made
    /// `insert`/`update`/`delete` shared entry words (ADR-0033 §2,
    /// Amendment 3), so parsing them in Advanced mode would route the
    /// overlap to the SQL command variants; the SQL surface is
    /// validated through the advanced-mode diagnostic helpers
    /// (`diag_keys` / `diagnostics_advanced`) and `tests/sql_*.rs`.
    fn parse(input: &str) -> Result<Command, crate::dsl::ParseError> {
        parse_command_in_mode(input, Mode::Simple)
    }

    // ---- Bare no-arg commands ---------------------------------

    #[test]
    fn walker_parses_quit() {
        assert_eq!(parse("quit").unwrap(), Command::App(AppCommand::Quit));
    }

    #[test]
    fn walker_parses_help() {
        assert_eq!(
            parse("help").unwrap(),
            Command::App(AppCommand::Help { topic: None })
        );
    }

    #[test]
    fn walker_parses_rebuild() {
        assert_eq!(parse("rebuild").unwrap(), Command::App(AppCommand::Rebuild));
    }

    #[test]
    fn walker_parses_undo() {
        assert_eq!(parse("undo").unwrap(), Command::App(AppCommand::Undo));
    }

    #[test]
    fn walker_parses_redo() {
        assert_eq!(parse("redo").unwrap(), Command::App(AppCommand::Redo));
    }

    #[test]
    fn walker_parses_undo_case_insensitive() {
        // Keywords are case-insensitive (ADR-0009).
        assert_eq!(parse("UNDO").unwrap(), Command::App(AppCommand::Undo));
    }

    #[test]
    fn walker_parses_new() {
        assert_eq!(parse("new").unwrap(), Command::App(AppCommand::New));
    }

    #[test]
    fn walker_parses_load() {
        assert_eq!(parse("load").unwrap(), Command::App(AppCommand::Load));
    }

    // ---- Save / save as ---------------------------------------

    #[test]
    fn walker_parses_save() {
        assert_eq!(parse("save").unwrap(), Command::App(AppCommand::Save));
    }

    #[test]
    fn walker_parses_save_as() {
        assert_eq!(parse("save as").unwrap(), Command::App(AppCommand::SaveAs));
    }

    #[test]
    fn walker_save_keywords_case_insensitive() {
        assert_eq!(parse("SAVE").unwrap(), Command::App(AppCommand::Save));
        assert_eq!(parse("Save AS").unwrap(), Command::App(AppCommand::SaveAs));
    }

    // ---- Mode -------------------------------------------------

    #[test]
    fn walker_parses_mode_simple() {
        assert_eq!(
            parse("mode simple").unwrap(),
            Command::App(AppCommand::Mode {
                value: ModeValue::Simple,
            })
        );
    }

    #[test]
    fn walker_parses_mode_advanced() {
        assert_eq!(
            parse("mode advanced").unwrap(),
            Command::App(AppCommand::Mode {
                value: ModeValue::Advanced,
            })
        );
    }

    #[test]
    fn walker_mode_unknown_value_emits_friendly_error() {
        let err = parse("mode foo").unwrap_err();
        match err {
            crate::dsl::ParseError::Invalid { message, .. } => {
                // The catalog wording for `mode.unknown` carries
                // the user's value verbatim.
                assert!(message.contains("foo"), "got: {message}");
            }
            other => panic!("expected Invalid, got {other:?}"),
        }
    }

    // ---- Messages ---------------------------------------------

    #[test]
    fn walker_parses_messages_bare() {
        assert_eq!(
            parse("messages").unwrap(),
            Command::App(AppCommand::Messages { value: None })
        );
    }

    #[test]
    fn walker_parses_messages_short() {
        assert_eq!(
            parse("messages short").unwrap(),
            Command::App(AppCommand::Messages {
                value: Some(MessagesValue::Short),
            })
        );
    }

    #[test]
    fn walker_parses_messages_verbose() {
        assert_eq!(
            parse("messages verbose").unwrap(),
            Command::App(AppCommand::Messages {
                value: Some(MessagesValue::Verbose),
            })
        );
    }

    #[test]
    fn walker_messages_unknown_value_emits_friendly_error() {
        let err = parse("messages bogus").unwrap_err();
        match err {
            crate::dsl::ParseError::Invalid { message, .. } => {
                assert!(message.contains("bogus"), "got: {message}");
            }
            other => panic!("expected Invalid, got {other:?}"),
        }
    }

    // ---- Export -----------------------------------------------

    #[test]
    fn walker_parses_export_bare() {
        assert_eq!(
            parse("export").unwrap(),
            Command::App(AppCommand::Export { path: None })
        );
    }

    #[test]
    fn walker_parses_export_with_path() {
        assert_eq!(
            parse("export backups/MyExport.zip").unwrap(),
            Command::App(AppCommand::Export {
                path: Some("backups/MyExport.zip".to_string()),
            })
        );
    }

    #[test]
    fn walker_export_trims_trailing_whitespace() {
        // Pre-migration the source-slice helper trimmed; the
        // walker treats "    " after `export` as zero BarePath
        // matches and produces the bare form.
        assert_eq!(
            parse("export    ").unwrap(),
            Command::App(AppCommand::Export { path: None })
        );
    }

    // ---- Import -----------------------------------------------

    #[test]
    fn walker_parses_import_bare() {
        assert_eq!(
            parse("import").unwrap(),
            Command::App(AppCommand::Import {
                path: String::new(),
                target: None,
            })
        );
    }

    #[test]
    fn walker_parses_import_with_path() {
        assert_eq!(
            parse("import some/file.zip").unwrap(),
            Command::App(AppCommand::Import {
                path: "some/file.zip".to_string(),
                target: None,
            })
        );
    }

    #[test]
    fn walker_parses_import_with_path_and_target() {
        assert_eq!(
            parse("import some/file.zip as MyImported").unwrap(),
            Command::App(AppCommand::Import {
                path: "some/file.zip".to_string(),
                target: Some("MyImported".to_string()),
            })
        );
    }

    #[test]
    fn walker_import_keeps_as_inside_path() {
        // The lexer-free walker terminates `BarePath` at the
        // first whitespace byte. `path/asfile.zip` is one
        // token; the `as` *inside* it stays part of the path.
        assert_eq!(
            parse("import path/asfile.zip").unwrap(),
            Command::App(AppCommand::Import {
                path: "path/asfile.zip".to_string(),
                target: None,
            })
        );
    }

    #[test]
    fn walker_import_trailing_as_without_target_errors() {
        // Phase B Optional-backtracking: when the user types
        // `import foo.zip as ` and stops, the inner Optional
        // `(as <target>)` partial-matches `as` then runs out
        // of input → backtracks (matches chumsky's `or_not`
        // semantics). The walker reports a successful parse of
        // `import foo.zip` followed by trailing `as ` → a
        // structural Mismatch with expected=`end of input`.
        // The friendly "import: empty target after `as`"
        // wording is no longer produced by the walker, but the
        // integration test
        // (`import_with_empty_target_after_as_errors`) still
        // passes because the rendered `import_usage` template
        // line in the dispatch output contains both "import"
        // and "target".
        let err = parse("import foo.zip as ").unwrap_err();
        match err {
            crate::dsl::ParseError::Invalid { message, .. } => {
                assert!(
                    message.contains("import"),
                    "expected `import` in 'after `<prefix>`' framing; got: {message}"
                );
            }
            other => panic!("expected Invalid, got {other:?}"),
        }
    }

    // ---- Routing fall-through ---------------------------------

    #[test]
    fn walker_does_not_engage_for_non_app_keywords() {
        // The router falls through to the chumsky path. The
        // existing chumsky parser produces this Command.
        assert!(matches!(
            parse("drop table Customers").unwrap(),
            Command::DropTable { .. }
        ));
    }

    #[test]
    fn walker_does_not_engage_for_unknown_first_token() {
        // Not an entry word — chumsky yields its usual
        // unknown-command error.
        assert!(parse("frobulate").is_err());
    }

    // ---- Trailing-garbage detection ---------------------------

    #[test]
    fn walker_quit_with_trailing_garbage_errors() {
        assert!(parse("quit nonsense").is_err());
    }

    #[test]
    fn walker_save_with_trailing_garbage_errors() {
        assert!(parse("save Customers").is_err());
    }

    // ---- Whitespace tolerance ---------------------------------

    #[test]
    fn walker_tolerates_leading_and_internal_whitespace() {
        assert_eq!(parse("  quit  ").unwrap(), Command::App(AppCommand::Quit));
        assert_eq!(
            parse("save   as").unwrap(),
            Command::App(AppCommand::SaveAs)
        );
        assert_eq!(
            parse("mode\tadvanced").unwrap(),
            Command::App(AppCommand::Mode {
                value: ModeValue::Advanced,
            })
        );
    }

    // =========================================================
    // Phase B — DDL commands.
    // =========================================================

    use crate::dsl::action::ReferentialAction;
    use crate::dsl::command::{ChangeColumnMode, RelationshipSelector};
    use crate::dsl::types::Type;

    #[test]
    fn walker_parses_drop_table() {
        assert_eq!(
            parse("drop table Customers").unwrap(),
            Command::DropTable {
                name: "Customers".to_string(),
            }
        );
    }

    #[test]
    fn walker_parses_drop_column_with_optional_connectives() {
        let want = Command::DropColumn {
            table: "Customers".to_string(),
            column: "Email".to_string(),
            cascade: false,
        };
        assert_eq!(parse("drop column Customers: Email").unwrap(), want);
        assert_eq!(parse("drop column from Customers: Email").unwrap(), want);
        assert_eq!(
            parse("drop column from table Customers: Email").unwrap(),
            want
        );
        assert_eq!(parse("drop column table Customers: Email").unwrap(), want);
    }

    #[test]
    fn walker_parses_drop_relationship_named() {
        assert_eq!(
            parse("drop relationship Orders_to_Customers").unwrap(),
            Command::DropRelationship {
                selector: RelationshipSelector::Named {
                    name: "Orders_to_Customers".to_string(),
                },
            }
        );
    }

    #[test]
    fn walker_parses_drop_relationship_endpoints() {
        assert_eq!(
            parse("drop relationship from Customers.id to Orders.customer_id").unwrap(),
            Command::DropRelationship {
                selector: RelationshipSelector::Endpoints {
                    parent_table: "Customers".to_string(),
                    parent_column: "id".to_string(),
                    child_table: "Orders".to_string(),
                    child_column: "customer_id".to_string(),
                },
            }
        );
    }

    #[test]
    fn walker_parses_add_column() {
        assert_eq!(
            parse("add column Customers: Email (text)").unwrap(),
            Command::AddColumn {
                table: "Customers".to_string(),
                column: "Email".to_string(),
                ty: Type::Text,
                not_null: false,
                unique: false,
                default: None,
                check: None,
            }
        );
    }

    #[test]
    fn walker_add_column_unknown_type_errors_with_friendly_wording() {
        let err = parse("add column Customers: Email (varchar)").unwrap_err();
        match err {
            crate::dsl::ParseError::Invalid { message, .. } => {
                assert!(message.contains("varchar"), "got: {message}");
            }
            other => panic!("expected Invalid, got {other:?}"),
        }
    }

    #[test]
    fn walker_parses_rename_column() {
        assert_eq!(
            parse("rename column Customers: Email to ContactEmail").unwrap(),
            Command::RenameColumn {
                table: "Customers".to_string(),
                old: "Email".to_string(),
                new: "ContactEmail".to_string(),
            }
        );
    }

    #[test]
    fn walker_parses_change_column() {
        assert_eq!(
            parse("change column Customers: Email (text)").unwrap(),
            Command::ChangeColumnType {
                table: "Customers".to_string(),
                column: "Email".to_string(),
                ty: Type::Text,
                mode: ChangeColumnMode::Default,
            }
        );
    }

    #[test]
    fn walker_parses_change_column_with_force_conversion_flag() {
        assert_eq!(
            parse("change column Customers: Email (int) --force-conversion").unwrap(),
            Command::ChangeColumnType {
                table: "Customers".to_string(),
                column: "Email".to_string(),
                ty: Type::Int,
                mode: ChangeColumnMode::ForceConversion,
            }
        );
    }

    #[test]
    fn walker_change_column_rejects_both_flags() {
        let err = parse("change column Customers: Email (int) --force-conversion --dont-convert")
            .unwrap_err();
        match err {
            crate::dsl::ParseError::Invalid { message, .. } => {
                assert!(message.contains("mutually exclusive"), "got: {message}");
            }
            other => panic!("expected Invalid, got {other:?}"),
        }
    }

    #[test]
    fn walker_parses_add_relationship_minimal() {
        assert_eq!(
            parse("add 1:n relationship from Customers.id to Orders.customer_id").unwrap(),
            Command::AddRelationship {
                name: None,
                parent_table: "Customers".to_string(),
                parent_columns: vec!["id".to_string()],
                child_table: "Orders".to_string(),
                child_columns: vec!["customer_id".to_string()],
                on_delete: ReferentialAction::default_action(),
                on_update: ReferentialAction::default_action(),
                create_fk: false,
            }
        );
    }

    #[test]
    fn walker_parses_add_relationship_with_name_and_actions_and_flag() {
        assert_eq!(
            parse(
                "add 1:n relationship as cust_orders from Customers.id to Orders.customer_id \
                 on delete cascade on update set null --create-fk"
            )
            .unwrap(),
            Command::AddRelationship {
                name: Some("cust_orders".to_string()),
                parent_table: "Customers".to_string(),
                parent_columns: vec!["id".to_string()],
                child_table: "Orders".to_string(),
                child_columns: vec!["customer_id".to_string()],
                on_delete: ReferentialAction::Cascade,
                on_update: ReferentialAction::SetNull,
                create_fk: true,
            }
        );
    }

    #[test]
    fn walker_add_relationship_repeated_clause_errors() {
        let err = parse(
            "add 1:n relationship from Customers.id to Orders.customer_id \
             on delete cascade on delete restrict",
        )
        .unwrap_err();
        match err {
            crate::dsl::ParseError::Invalid { message, .. } => {
                assert!(
                    message.contains("delete") && message.contains("twice"),
                    "got: {message}"
                );
            }
            other => panic!("expected Invalid, got {other:?}"),
        }
    }

    // =========================================================
    // Phase C — create table.
    // =========================================================

    use crate::dsl::command::ColumnSpec;

    fn col(name: &str, ty: Type) -> ColumnSpec {
        ColumnSpec::new(name, ty)
    }

    #[test]
    fn walker_parses_create_table_with_pk_default_id_serial() {
        assert_eq!(
            parse("create table Customers with pk").unwrap(),
            Command::CreateTable {
                name: "Customers".to_string(),
                columns: vec![col("id", Type::Serial)],
                primary_key: vec!["id".to_string()],
            }
        );
    }

    #[test]
    fn walker_parses_create_table_named_typed_pk() {
        assert_eq!(
            parse("create table Customers with pk email(text)").unwrap(),
            Command::CreateTable {
                name: "Customers".to_string(),
                columns: vec![col("email", Type::Text)],
                primary_key: vec!["email".to_string()],
            }
        );
    }

    #[test]
    fn walker_parses_create_table_compound_pk() {
        assert_eq!(
            parse("create table OrderLines with pk order_id(int),product_id(int)").unwrap(),
            Command::CreateTable {
                name: "OrderLines".to_string(),
                columns: vec![col("order_id", Type::Int), col("product_id", Type::Int)],
                primary_key: vec!["order_id".to_string(), "product_id".to_string()],
            }
        );
    }

    #[test]
    fn walker_create_table_pk_tolerates_whitespace_around_punct() {
        assert_eq!(
            parse("create table T  with  pk   id ( serial )").unwrap(),
            Command::CreateTable {
                name: "T".to_string(),
                columns: vec![col("id", Type::Serial)],
                primary_key: vec!["id".to_string()],
            }
        );
        assert_eq!(
            parse("create table T with pk a ( int ) , b ( int )").unwrap(),
            Command::CreateTable {
                name: "T".to_string(),
                columns: vec![col("a", Type::Int), col("b", Type::Int)],
                primary_key: vec!["a".to_string(), "b".to_string()],
            }
        );
    }

    #[test]
    fn walker_bare_create_table_errors_with_with_pk_hint() {
        let err = parse("create table Customers").unwrap_err();
        match err {
            crate::dsl::ParseError::Invalid { message, .. } => {
                assert!(
                    message.contains("with pk"),
                    "error should mention `with pk`:\n{message}"
                );
            }
            other => panic!("expected Invalid, got {other:?}"),
        }
    }

    #[test]
    fn walker_create_table_keywords_are_case_insensitive() {
        assert_eq!(
            parse("CREATE TABLE Customers WITH PK email(TEXT)").unwrap(),
            Command::CreateTable {
                name: "Customers".to_string(),
                columns: vec![col("email", Type::Text)],
                primary_key: vec!["email".to_string()],
            }
        );
    }

    // =========================================================
    // Phase D — data commands (show, insert, update, delete).
    // =========================================================

    use crate::dsl::command::RowFilter;
    use crate::dsl::value::Value;

    #[test]
    fn walker_parses_show_data() {
        assert_eq!(
            parse("show data Customers").unwrap(),
            Command::ShowData {
                name: "Customers".to_string(),
                filter: None,
                limit: None,
            }
        );
    }

    #[test]
    fn walker_parses_show_table() {
        assert_eq!(
            parse("show table Customers").unwrap(),
            Command::ShowTable {
                name: "Customers".to_string()
            }
        );
    }

    #[test]
    fn walker_parses_show_data_with_where_and_limit() {
        // ADR-0026 §5: `show data` gains an optional `where`
        // and an optional `limit <n>`.
        match parse("show data Customers where id=1 limit 10").unwrap() {
            Command::ShowData {
                name,
                filter: Some(_),
                limit: Some(10),
            } => assert_eq!(name, "Customers"),
            other => panic!("expected ShowData with filter + limit, got {other:?}"),
        }
    }

    #[test]
    fn walker_parses_show_data_with_limit_only() {
        assert!(matches!(
            parse("show data Customers limit 5").unwrap(),
            Command::ShowData {
                filter: None,
                limit: Some(5),
                ..
            }
        ));
    }

    #[test]
    fn walker_parses_update_with_complex_where() {
        // The WHERE is a full boolean expression, not a single
        // equality (ADR-0026).
        match parse("update T set Active=true where Age>30 and Name like 'A%'").unwrap() {
            Command::Update {
                filter: RowFilter::Where(crate::dsl::Expr::And(terms)),
                ..
            } => assert_eq!(terms.len(), 2, "two AND-ed predicates"),
            other => panic!("expected Update with And-expression filter, got {other:?}"),
        }
    }

    #[test]
    fn walker_parses_delete_with_or_where() {
        assert!(matches!(
            parse("delete from T where id=1 or id=2").unwrap(),
            Command::Delete {
                filter: RowFilter::Where(crate::dsl::Expr::Or(_)),
                ..
            }
        ));
    }

    // ---- input_verdict (ADR-0027 §3) --------------------------

    #[test]
    fn input_verdict_clean_command_is_none() {
        assert_eq!(super::input_verdict("quit", None), None);
        assert_eq!(super::input_verdict("show table Customers", None), None);
    }

    #[test]
    fn input_verdict_empty_input_is_none() {
        assert_eq!(super::input_verdict("", None), None);
        assert_eq!(super::input_verdict("   ", None), None);
    }

    #[test]
    fn input_verdict_incomplete_command_is_error() {
        assert_eq!(
            super::input_verdict("create table", None),
            Some(super::Severity::Error),
        );
    }

    #[test]
    fn input_verdict_unknown_command_is_error() {
        assert_eq!(
            super::input_verdict("frobnicate the gizmo", None),
            Some(super::Severity::Error),
        );
    }

    #[test]
    fn input_verdict_mismatched_token_is_error() {
        // `quit` takes no argument — trailing junk fails.
        assert_eq!(
            super::input_verdict("quit now", None),
            Some(super::Severity::Error),
        );
    }

    #[test]
    fn input_verdict_unknown_table_is_error() {
        // The command parses, but the table does not exist —
        // an ERROR diagnostic (ADR-0027 §2).
        let schema = schema_with("Customers", &[("id", Type::Int)]);
        assert_eq!(
            super::input_verdict("show data NoSuchTable", Some(&schema)),
            Some(super::Severity::Error),
        );
    }

    #[test]
    fn input_verdict_unknown_column_is_error() {
        let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        assert_eq!(
            super::input_verdict("show data Customers where NoSuchCol = 1", Some(&schema),),
            Some(super::Severity::Error),
        );
    }

    #[test]
    fn input_verdict_known_table_and_column_is_clean() {
        let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        assert_eq!(
            super::input_verdict("show data Customers where id = 1", Some(&schema),),
            None,
        );
    }

    #[test]
    fn input_verdict_type_mismatch_is_warning() {
        // `Age` is int; comparing it with a text literal runs,
        // but is flagged (ADR-0026 §7).
        let schema = schema_with("Customers", &[("id", Type::Int), ("Age", Type::Int)]);
        assert_eq!(
            super::input_verdict("delete from Customers where Age = 'hello'", Some(&schema),),
            Some(super::Severity::Warning),
        );
    }

    #[test]
    fn input_verdict_eq_null_is_warning() {
        let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        assert_eq!(
            super::input_verdict("delete from Customers where Name = null", Some(&schema),),
            Some(super::Severity::Warning),
        );
    }

    #[test]
    fn input_verdict_compatible_comparison_is_clean() {
        let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        assert_eq!(
            super::input_verdict("delete from Customers where id = 5", Some(&schema),),
            None,
        );
    }

    #[test]
    fn input_verdict_error_outranks_warning() {
        // An unknown column (ERROR) alongside `= NULL`
        // (WARNING) — the indicator shows the higher severity.
        let schema = schema_with("Customers", &[("id", Type::Int)]);
        assert_eq!(
            super::input_verdict(
                "delete from Customers where NoSuchCol = null",
                Some(&schema),
            ),
            Some(super::Severity::Error),
        );
    }

    // ---- Existing-cases sweep (ADR-0027 §6) -------------------

    #[test]
    fn input_verdict_sweep_unknown_table_across_commands() {
        let schema = schema_with("Customers", &[("id", Type::Int)]);
        for input in [
            "drop table NoSuchTable",
            "show table NoSuchTable",
            "show data NoSuchTable",
            "add column to NoSuchTable: x (int)",
        ] {
            assert_eq!(
                super::input_verdict(input, Some(&schema)),
                Some(super::Severity::Error),
                "unknown table in {input:?} should be flagged",
            );
        }
    }

    #[test]
    fn input_verdict_sweep_unknown_column_across_commands() {
        let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        for input in [
            "drop column from table Customers: NoSuchCol",
            "update Customers set NoSuchCol = 1 where id = 1",
        ] {
            assert_eq!(
                super::input_verdict(input, Some(&schema)),
                Some(super::Severity::Error),
                "unknown column in {input:?} should be flagged",
            );
        }
    }

    #[test]
    fn input_verdict_known_entities_across_commands_are_clean() {
        let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        for input in [
            "show table Customers",
            "drop table Customers",
            "add column to Customers: Email (text)",
            "drop column from table Customers: Name",
        ] {
            assert_eq!(
                super::input_verdict(input, Some(&schema)),
                None,
                "{input:?} references only known entities — clean",
            );
        }
    }

    // ---- precise diagnostic spans (ADR-0027 highlight wiring) -

    /// Walk `input` with `schema` and return the diagnostics the
    /// walk produced.
    fn diagnostics(input: &str, schema: &SchemaCache) -> Vec<super::outcome::Diagnostic> {
        let mut ctx = super::context::WalkContext::with_schema(schema);
        let (result, _cmd) = super::walk(input, super::outcome::WalkBound::EndOfInput, &mut ctx);
        result.map_or_else(Vec::new, |r| r.diagnostics)
    }

    #[test]
    fn type_mismatch_warning_span_covers_only_the_literal() {
        // The coarse pre-ADR-0027 span was the whole WHERE
        // clause; it is now exactly the offending literal.
        let schema = schema_with("Customers", &[("Age", Type::Int)]);
        let input = "delete from Customers where Age = 'hello'";
        let diags = diagnostics(input, &schema);
        assert_eq!(diags.len(), 1);
        assert_eq!(diags[0].severity, super::Severity::Warning);
        let (s, e) = diags[0].span;
        assert_eq!(
            &input[s..e],
            "'hello'",
            "the WARNING span should cover exactly the literal",
        );
    }

    #[test]
    fn eq_null_warning_span_covers_the_null_literal() {
        let schema = schema_with("Customers", &[("Name", Type::Text)]);
        let input = "delete from Customers where Name = null";
        let diags = diagnostics(input, &schema);
        assert_eq!(diags.len(), 1);
        let (s, e) = diags[0].span;
        assert_eq!(&input[s..e], "null");
    }

    #[test]
    fn between_warning_spans_each_offending_bound() {
        // `Age` is int; both text bounds mismatch — two
        // distinct WARNINGs, each spanning its own bound.
        let schema = schema_with("Customers", &[("Age", Type::Int)]);
        let input = "delete from Customers where Age between 'a' and 'z'";
        let diags = diagnostics(input, &schema);
        assert_eq!(diags.len(), 2);
        let spans: Vec<&str> = diags.iter().map(|d| &input[d.span.0..d.span.1]).collect();
        assert_eq!(spans, vec!["'a'", "'z'"]);
    }

    #[test]
    fn in_warning_spans_only_the_mismatched_item() {
        // `Age` is int; of `(1, 'two', 3)` only `'two'` is wrong.
        let schema = schema_with("Customers", &[("Age", Type::Int)]);
        let input = "delete from Customers where Age in (1, 'two', 3)";
        let diags = diagnostics(input, &schema);
        assert_eq!(diags.len(), 1);
        let (s, e) = diags[0].span;
        assert_eq!(&input[s..e], "'two'");
    }

    #[test]
    fn unknown_column_error_span_covers_the_identifier() {
        let schema = schema_with("Customers", &[("id", Type::Int)]);
        let input = "delete from Customers where NoSuchCol = 1";
        let diags = diagnostics(input, &schema);
        assert_eq!(diags.len(), 1);
        assert_eq!(diags[0].severity, super::Severity::Error);
        let (s, e) = diags[0].span;
        assert_eq!(&input[s..e], "NoSuchCol");
    }

    // ---- LIKE on a numeric column (ADR-0027, Amendment 1) -----

    #[test]
    fn like_on_a_numeric_column_is_a_warning() {
        // `LIKE` is a text-pattern match — against an int
        // column it runs but is almost never intended.
        let schema = schema_with("Customers", &[("Age", Type::Int)]);
        let input = "delete from Customers where Age like '1%'";
        let diags = diagnostics(input, &schema);
        assert_eq!(diags.len(), 1);
        assert_eq!(diags[0].severity, super::Severity::Warning);
        let (s, e) = diags[0].span;
        assert_eq!(&input[s..e], "Age", "the span is the numeric column");
    }

    #[test]
    fn not_like_on_a_numeric_column_is_also_a_warning() {
        // The `LIKE`-on-numeric predicate warning is a DSL diagnostic
        // (its sibling `like_on_a_numeric_column_is_a_warning` uses
        // the Simple-mode `diagnostics` helper). Since sub-phase 3j
        // made `delete` a shared entry word (ADR-0033 Amendment 3),
        // this verdict is taken in Simple mode so the input routes to
        // the DSL `delete` and its predicate diagnostics.
        let schema = schema_with("Orders", &[("Total", Type::Decimal)]);
        assert_eq!(
            super::input_verdict_in_mode(
                "delete from Orders where Total not like '9%'",
                Some(&schema),
                crate::mode::Mode::Simple,
            ),
            Some(super::Severity::Warning),
        );
    }

    #[test]
    fn like_on_a_text_column_is_clean() {
        // `LIKE 'A%'` on a text column is its intended use.
        let schema = schema_with("Customers", &[("Name", Type::Text)]);
        assert_eq!(
            super::input_verdict("delete from Customers where Name like 'A%'", Some(&schema),),
            None,
        );
    }

    #[test]
    fn walker_parses_insert_with_explicit_column_list() {
        assert_eq!(
            parse("insert into Customers (Email, Name) values ('a@b.c', 'Alice')").unwrap(),
            Command::Insert {
                table: "Customers".to_string(),
                columns: Some(vec!["Email".to_string(), "Name".to_string()]),
                values: vec![
                    Value::Text("a@b.c".to_string()),
                    Value::Text("Alice".to_string())
                ],
            }
        );
    }

    #[test]
    fn walker_parses_insert_with_values_keyword_only() {
        assert_eq!(
            parse("insert into Customers values (1, 'Alice', null)").unwrap(),
            Command::Insert {
                table: "Customers".to_string(),
                columns: None,
                values: vec![
                    Value::Number("1".to_string()),
                    Value::Text("Alice".to_string()),
                    Value::Null,
                ],
            }
        );
    }

    #[test]
    fn walker_parses_insert_short_form_without_column_list() {
        assert_eq!(
            parse("insert into Customers (1, 'Alice', true)").unwrap(),
            Command::Insert {
                table: "Customers".to_string(),
                columns: None,
                values: vec![
                    Value::Number("1".to_string()),
                    Value::Text("Alice".to_string()),
                    Value::Bool(true),
                ],
            }
        );
    }

    #[test]
    fn walker_parses_insert_supports_negative_numbers() {
        assert_eq!(
            parse("insert into T values (-5)").unwrap(),
            Command::Insert {
                table: "T".to_string(),
                columns: None,
                values: vec![Value::Number("-5".to_string())],
            }
        );
    }

    #[test]
    fn walker_parses_update_with_where() {
        assert_eq!(
            parse("update Customers set Email='new@b.c' where id=1").unwrap(),
            Command::Update {
                table: "Customers".to_string(),
                assignments: vec![("Email".to_string(), Value::Text("new@b.c".to_string()))],
                filter: RowFilter::eq("id", Value::Number("1".to_string())),
            }
        );
    }

    #[test]
    fn walker_parses_update_with_multiple_assignments() {
        assert_eq!(
            parse("update Customers set Email='a@b.c', Name='Alice' where id=1").unwrap(),
            Command::Update {
                table: "Customers".to_string(),
                assignments: vec![
                    ("Email".to_string(), Value::Text("a@b.c".to_string())),
                    ("Name".to_string(), Value::Text("Alice".to_string())),
                ],
                filter: RowFilter::eq("id", Value::Number("1".to_string())),
            }
        );
    }

    #[test]
    fn walker_parses_update_with_all_rows_flag() {
        assert_eq!(
            parse("update Customers set Active=true --all-rows").unwrap(),
            Command::Update {
                table: "Customers".to_string(),
                assignments: vec![("Active".to_string(), Value::Bool(true))],
                filter: RowFilter::AllRows,
            }
        );
    }

    #[test]
    fn walker_parses_delete_with_where() {
        assert_eq!(
            parse("delete from Customers where id=42").unwrap(),
            Command::Delete {
                table: "Customers".to_string(),
                filter: RowFilter::eq("id", Value::Number("42".to_string())),
            }
        );
    }

    #[test]
    fn walker_parses_delete_with_all_rows() {
        assert_eq!(
            parse("delete from Customers --all-rows").unwrap(),
            Command::Delete {
                table: "Customers".to_string(),
                filter: RowFilter::AllRows,
            }
        );
    }

    #[test]
    fn walker_delete_without_where_or_flag_errors() {
        assert!(parse("delete from Customers").is_err());
    }

    #[test]
    fn walker_update_without_where_or_flag_errors() {
        assert!(parse("update Customers set Email='x'").is_err());
    }

    // =========================================================
    // Phase E — replay.
    // =========================================================

    #[test]
    fn walker_parses_replay_with_bare_relative_path() {
        assert_eq!(
            parse("replay history.log").unwrap(),
            Command::Replay {
                path: "history.log".to_string(),
            }
        );
    }

    #[test]
    fn walker_parses_replay_with_bare_absolute_path() {
        assert_eq!(
            parse("replay /tmp/seed.commands").unwrap(),
            Command::Replay {
                path: "/tmp/seed.commands".to_string(),
            }
        );
    }

    #[test]
    fn walker_parses_replay_with_quoted_path_supports_whitespace() {
        // Phase A's path-bearing UX change: paths with spaces use
        // the quoted form.
        assert_eq!(
            parse("replay 'my project/seed.commands'").unwrap(),
            Command::Replay {
                path: "my project/seed.commands".to_string(),
            }
        );
    }

    #[test]
    fn walker_parses_replay_with_quoted_path_supports_escaped_quote() {
        assert_eq!(
            parse("replay 'O''Brien.commands'").unwrap(),
            Command::Replay {
                path: "O'Brien.commands".to_string(),
            }
        );
    }

    #[test]
    fn walker_replay_keyword_case_insensitive() {
        assert_eq!(
            parse("REPLAY foo.txt").unwrap(),
            Command::Replay {
                path: "foo.txt".to_string(),
            }
        );
    }

    #[test]
    fn walker_replay_without_path_errors() {
        assert!(parse("replay").is_err());
    }

    #[test]
    fn walker_replay_with_empty_quoted_path_parses_as_empty() {
        // Parser layer accepts; runtime rejects empty paths
        // before any I/O. Mirrors the chumsky-side contract
        // (parser.rs `replay_with_empty_quoted_path_errors`).
        assert_eq!(
            parse("replay ''").unwrap(),
            Command::Replay {
                path: String::new(),
            }
        );
    }

    // =========================================================
    // hint_mode_at_input (ADR-0024 §HintMode-per-node)
    // =========================================================

    use super::hint_mode_at_input;
    use crate::dsl::grammar::HintMode;

    #[test]
    fn hint_mode_value_literal_slot_after_insert_open_paren() {
        // `insert into T (` expects a value-literal or column
        // ident at the inner position. After `values (` it's
        // strictly value-literals — the signature triggers
        // ProseOnly.
        match hint_mode_at_input("insert into T values (") {
            Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
            other => panic!("expected ProseOnly value_literal_slot, got {other:?}"),
        }
    }

    #[test]
    fn hint_mode_value_literal_slot_after_update_set_assign() {
        match hint_mode_at_input("update T set col=") {
            Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
            other => panic!("expected ProseOnly value_literal_slot, got {other:?}"),
        }
    }

    #[test]
    fn hint_mode_value_literal_slot_in_where_clause() {
        match hint_mode_at_input("delete from T where col=") {
            Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
            other => panic!("expected ProseOnly value_literal_slot, got {other:?}"),
        }
    }

    #[test]
    fn hint_mode_new_name_slot_for_create_table() {
        // `create table ` expects a NewName ident.
        match hint_mode_at_input("create table ") {
            Some(HintMode::ForceProse("hint.ambient_typing_name")) => {}
            other => panic!("expected ForceProse typing_name, got {other:?}"),
        }
    }

    #[test]
    fn hint_mode_new_name_slot_for_add_column_name() {
        // `add column T: ` expects a NewName ident.
        match hint_mode_at_input("add column to table T: ") {
            Some(HintMode::ForceProse("hint.ambient_typing_name")) => {}
            other => panic!("expected ForceProse typing_name, got {other:?}"),
        }
    }

    #[test]
    fn hint_mode_none_for_keyword_position() {
        // Entry-keyword position: no HintMode override applies.
        assert!(hint_mode_at_input("").is_none());
        assert!(hint_mode_at_input("cr").is_none());
    }

    #[test]
    fn hint_mode_none_for_complete_command() {
        // Valid complete command: no expected, no override.
        assert!(hint_mode_at_input("create table T with pk").is_none());
    }

    #[test]
    fn hint_mode_none_at_schema_ident_slot() {
        // `show data ` expects a table-name ident from the
        // schema — schema-listable slot, not a HintMode case.
        assert!(hint_mode_at_input("show data ").is_none());
    }

    // =========================================================
    // Phase D full — schema-aware value typing.
    // =========================================================

    use crate::completion::{SchemaCache, TableColumn};
    use crate::dsl::parser::parse_command_with_schema_in_mode;

    /// Phase-D typed value slots are the **DSL** surface (Simple
    /// mode). Sub-phase 3j made `insert`/`update`/`delete` shared
    /// entry words (ADR-0033 Amendment 3), so these typed-slot tests
    /// parse in Simple mode to reach the DSL grammar rather than the
    /// SQL one. Thin wrapper keeps the existing call sites unchanged.
    fn parse_command_with_schema(
        input: &str,
        schema: &SchemaCache,
    ) -> Result<crate::dsl::command::Command, crate::dsl::ParseError> {
        parse_command_with_schema_in_mode(input, schema, crate::mode::Mode::Simple)
    }

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

    #[test]
    fn phase_d_insert_with_schema_accepts_typed_values_per_column() {
        // Form B: the grammar dispatches one slot per
        // non-auto-generated column — the serial `id` is
        // skipped because the dispatch path (`db::do_insert`)
        // auto-fills it (ADR-0018 §3).
        let schema = schema_with(
            "Customers",
            &[
                ("id", Type::Serial),
                ("Name", Type::Text),
                ("Active", Type::Bool),
            ],
        );
        // 2 user-typed values: Name (text), Active (bool).
        let cmd =
            parse_command_with_schema("insert into Customers values ('Alice', true)", &schema)
                .expect("parse");
        match cmd {
            Command::Insert { table, values, .. } => {
                assert_eq!(table, "Customers");
                assert_eq!(values.len(), 2);
            }
            other => panic!("expected Insert, got {other:?}"),
        }
    }

    #[test]
    fn phase_d_insert_form_b_skips_serial_column() {
        // Form B: `insert into <T> values (…)` excludes auto-generated
        // columns from the value list. Supplying a value for the serial
        // column is a count mismatch. As of issue #17 such a wrong-count
        // tuple parses **structurally** (routed to the type-blind
        // fallback) so the friendly arity diagnostic can fire — the
        // mismatch is now reported as an ERROR diagnostic rather than a
        // bare parse error, matching advanced mode. Dispatch is gated by
        // the submit pre-flight, not by a parse failure.
        let schema = schema_with("Customers", &[("id", Type::Serial), ("Name", Type::Text)]);
        // Two values where Form B expects one (Name only): structurally
        // parses, but the simple-mode arity diagnostic flags it (Form B
        // expects 1 value for `Name`; `id` is auto-generated).
        let diags = diag_keys_simple("insert into Customers values (1, 'Alice')", &schema);
        assert!(
            diags
                .iter()
                .any(|d| d.contains("1 value(s)") && d.contains("2 given")),
            "Form B serial-skip count mismatch must fire the arity \
             diagnostic (expected 1, got 2); got {diags:?}",
        );
    }

    #[test]
    fn phase_d_insert_form_a_accepts_serial_when_listed() {
        // Form A: user explicitly lists `id`. The dispatch path
        // accepts user-supplied serial values when they're in
        // the explicit column list; the grammar mirrors that.
        let schema = schema_with("Customers", &[("id", Type::Serial), ("Name", Type::Text)]);
        let cmd = parse_command_with_schema(
            "insert into Customers (id, Name) values (1, 'Alice')",
            &schema,
        )
        .expect("parse");
        match cmd {
            Command::Insert {
                columns, values, ..
            } => {
                assert_eq!(
                    columns.as_deref(),
                    Some(&["id".to_string(), "Name".to_string()][..])
                );
                assert_eq!(values.len(), 2);
            }
            other => panic!("expected Insert, got {other:?}"),
        }
    }

    #[test]
    fn phase_d_insert_form_a_filters_to_user_listed_columns() {
        // Form A: listing only Name should accept exactly one
        // value (for Name), even though the table has more
        // columns.
        let schema = schema_with(
            "Customers",
            &[
                ("id", Type::Serial),
                ("Name", Type::Text),
                ("Active", Type::Bool),
            ],
        );
        let cmd =
            parse_command_with_schema("insert into Customers (Name) values ('Alice')", &schema)
                .expect("parse");
        match cmd {
            Command::Insert {
                columns, values, ..
            } => {
                assert_eq!(columns.as_deref(), Some(&["Name".to_string()][..]));
                assert_eq!(values.len(), 1);
            }
            other => panic!("expected Insert, got {other:?}"),
        }
    }

    #[test]
    fn phase_d_insert_rejects_decimal_in_int_column() {
        // The schema has `id` as Int. `3.14` is a Number with a
        // decimal — the typed `int_slot` validator rejects.
        let schema = schema_with("T", &[("id", Type::Int)]);
        let err = parse_command_with_schema("insert into T values (3.14)", &schema)
            .expect_err("should reject");
        match err {
            crate::dsl::ParseError::Invalid { message, .. } => {
                assert!(
                    message.contains("integer") || message.contains("3.14"),
                    "got: {message}"
                );
            }
            other => panic!("expected Invalid, got {other:?}"),
        }
    }

    #[test]
    fn phase_d_insert_accepts_null_at_any_column() {
        // null is the absence sentinel; every typed slot
        // accepts it.
        let schema = schema_with(
            "T",
            &[("a", Type::Int), ("b", Type::Text), ("c", Type::Bool)],
        );
        let cmd = parse_command_with_schema("insert into T values (null, null, null)", &schema)
            .expect("parse");
        match cmd {
            Command::Insert { values, .. } => {
                assert!(values.iter().all(|v| matches!(v, Value::Null)));
            }
            other => panic!("expected Insert, got {other:?}"),
        }
    }

    #[test]
    fn phase_d_insert_falls_back_when_table_not_in_schema() {
        // The schema is empty; the walker can't resolve column
        // info for `Customers`. The DynamicSubgrammar falls
        // back to the schemaless generic value-literal list and
        // accepts mixed-shape values as it did pre-Phase-D.
        let schema = SchemaCache::default();
        let cmd = parse_command_with_schema("insert into Customers values (1, 'Alice')", &schema)
            .expect("parse — fallback path");
        match cmd {
            Command::Insert { values, .. } => assert_eq!(values.len(), 2),
            other => panic!("expected Insert, got {other:?}"),
        }
    }

    #[test]
    fn phase_d_schemaless_parse_command_still_works() {
        // The pre-Phase-D `parse_command(input)` signature
        // passes no schema; the DynamicSubgrammar falls back to
        // the schemaless value-literal list.
        let cmd = parse("insert into T values (1, 'Alice', null)").expect("parse");
        match cmd {
            Command::Insert { values, .. } => assert_eq!(values.len(), 3),
            other => panic!("expected Insert, got {other:?}"),
        }
    }

    #[test]
    fn phase_d_insert_accepts_bool_value_for_bool_column() {
        let schema = schema_with("T", &[("flag", Type::Bool)]);
        let cmd =
            parse_command_with_schema("insert into T values (false)", &schema).expect("parse");
        match cmd {
            Command::Insert { values, .. } => {
                assert_eq!(values, vec![Value::Bool(false)]);
            }
            other => panic!("expected Insert, got {other:?}"),
        }
    }

    #[test]
    fn phase_d_update_accepts_text_value_for_text_column() {
        let schema = schema_with("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
        let cmd =
            parse_command_with_schema("update Customers set Email='new@b.c' where id=1", &schema)
                .expect("parse");
        match cmd {
            Command::Update { assignments, .. } => {
                assert_eq!(assignments.len(), 1);
                assert_eq!(assignments[0].0, "Email");
            }
            other => panic!("expected Update, got {other:?}"),
        }
    }

    #[test]
    fn phase_d_update_rejects_decimal_in_int_set_column() {
        // Email is text; Score is int. Assigning `3.14` to Score
        // hits the int_slot validator.
        let schema = schema_with("T", &[("id", Type::Int), ("Score", Type::Int)]);
        let err = parse_command_with_schema("update T set Score=3.14 where id=1", &schema)
            .expect_err("should reject");
        match err {
            crate::dsl::ParseError::Invalid { message, .. } => {
                assert!(
                    message.contains("integer") || message.contains("3.14"),
                    "got: {message}"
                );
            }
            other => panic!("expected Invalid, got {other:?}"),
        }
    }

    #[test]
    fn phase_d_delete_where_uses_typed_column_value() {
        // `where id=1` — id is Int; `1` matches the int_slot.
        let schema = schema_with("T", &[("id", Type::Int), ("Name", Type::Text)]);
        let cmd = parse_command_with_schema("delete from T where id=1", &schema).expect("parse");
        match cmd {
            Command::Delete { .. } => {}
            other => panic!("expected Delete, got {other:?}"),
        }
    }

    #[test]
    fn phase_d_delete_where_permits_decimal_at_int_column() {
        // ADR-0026 §7: a type-mismatched WHERE comparison is
        // flagged in the editor but never blocks. `id` is Int
        // and `3.14` is not — yet the command still parses and
        // would run (this relaxes the pre-ADR-0026 rejection).
        let schema = schema_with("T", &[("id", Type::Int)]);
        let cmd = parse_command_with_schema("delete from T where id=3.14", &schema)
            .expect("type-mismatched WHERE comparisons are permissive");
        assert!(
            matches!(cmd, crate::dsl::Command::Delete { .. }),
            "got {cmd:?}"
        );
    }

    // ---- Typed-slot HintMode (Phase D + HintMode dispatch) ----

    use crate::dsl::walker::hint_mode_at_input_with_schema;

    #[test]
    fn typed_hint_at_insert_first_value_position_for_int_column() {
        let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        match hint_mode_at_input_with_schema("insert into Customers values (", &schema) {
            Some(HintMode::ProseOnly("hint.value_slot_int")) => {}
            other => panic!("expected ProseOnly value_slot_int, got {other:?}"),
        }
    }

    #[test]
    fn typed_hint_at_insert_second_value_position_for_text_column() {
        let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        match hint_mode_at_input_with_schema("insert into Customers values (1, ", &schema) {
            Some(HintMode::ProseOnly("hint.value_slot_text")) => {}
            other => panic!("expected ProseOnly value_slot_text, got {other:?}"),
        }
    }

    #[test]
    fn typed_hint_at_update_set_value_uses_column_type() {
        let schema = schema_with("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
        match hint_mode_at_input_with_schema("update Customers set Email=", &schema) {
            Some(HintMode::ProseOnly("hint.value_slot_text")) => {}
            other => panic!("expected ProseOnly value_slot_text, got {other:?}"),
        }
    }

    #[test]
    fn typed_hint_at_update_set_value_for_int_column() {
        let schema = schema_with("Customers", &[("id", Type::Int), ("Score", Type::Int)]);
        match hint_mode_at_input_with_schema("update Customers set Score=", &schema) {
            Some(HintMode::ProseOnly("hint.value_slot_int")) => {}
            other => panic!("expected ProseOnly value_slot_int, got {other:?}"),
        }
    }

    #[test]
    fn typed_hint_at_where_value_uses_column_type() {
        let schema = schema_with("Events", &[("ts", Type::DateTime)]);
        match hint_mode_at_input_with_schema("delete from Events where ts=", &schema) {
            Some(HintMode::ProseOnly("hint.value_slot_datetime")) => {}
            other => panic!("expected ProseOnly value_slot_datetime, got {other:?}"),
        }
    }

    #[test]
    fn typed_hint_falls_back_to_generic_when_schema_missing() {
        // Empty schema: walker can't resolve column types.
        let schema = SchemaCache::default();
        match hint_mode_at_input_with_schema("insert into T values (", &schema) {
            Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
            other => panic!("expected generic ProseOnly, got {other:?}"),
        }
    }

    #[test]
    fn typed_hint_not_emitted_after_complete_value() {
        // `insert into T values (1` — the int slot just MATCHED
        // (`1` is a valid int). Pending_value_type was cleared on
        // the successful match. No hint at this position
        // (between values).
        let schema = schema_with("T", &[("id", Type::Int)]);
        // Walker is now waiting for `,` or `)`. No HintMode.
        let mode = hint_mode_at_input_with_schema("insert into T values (1", &schema);
        // The current position isn't a typed slot; expected is
        // `,` / `)`. No HintMode fires.
        assert!(mode.is_none(), "got {mode:?}");
    }

    #[test]
    fn typed_hint_for_each_user_settable_type_routes_via_form_b() {
        // Form B (`insert into T values (…)`) excludes auto-
        // generated columns from the value list — so only the
        // user-settable types appear at this position.
        for (ty, key) in [
            (Type::Int, "hint.value_slot_int"),
            (Type::Real, "hint.value_slot_real"),
            (Type::Decimal, "hint.value_slot_decimal"),
            (Type::Bool, "hint.value_slot_bool"),
            (Type::Text, "hint.value_slot_text"),
            (Type::Date, "hint.value_slot_date"),
            (Type::DateTime, "hint.value_slot_datetime"),
        ] {
            let schema = schema_with("T", &[("c", ty)]);
            let mode = hint_mode_at_input_with_schema("insert into T values (", &schema);
            assert!(
                matches!(mode, Some(HintMode::ProseOnly(k)) if k == key),
                "expected ProseOnly({key}) for type {ty:?}, got {mode:?}",
            );
        }
    }

    #[test]
    fn typed_hint_for_auto_generated_types_routes_via_form_a() {
        // Serial / shortid columns can be set by the user only
        // in Form A (`insert into T (col) values (…)`) — Form B
        // skips them because the dispatch path auto-fills.
        for (ty, key) in [
            (Type::Serial, "hint.value_slot_serial"),
            (Type::ShortId, "hint.value_slot_shortid"),
        ] {
            let schema = schema_with("T", &[("c", ty)]);
            let mode = hint_mode_at_input_with_schema("insert into T (c) values (", &schema);
            assert!(
                matches!(mode, Some(HintMode::ProseOnly(k)) if k == key),
                "expected ProseOnly({key}) for type {ty:?}, got {mode:?}",
            );
        }
    }

    #[test]
    fn typed_hint_form_b_skips_serial_column_to_generic_or_text_neighbor() {
        // A serial-only table in Form B has nothing for the user
        // to type — column_value_list returns the schemaless
        // fallback, so the hint at the first value position is
        // the generic value-literal prose.
        let schema = schema_with("T", &[("id", Type::Serial)]);
        let mode = hint_mode_at_input_with_schema("insert into T values (", &schema);
        assert!(
            matches!(mode, Some(HintMode::ProseOnly("hint.value_literal_slot"))),
            "got {mode:?}",
        );
    }

    #[test]
    fn phase_d_update_multi_assignment_uses_per_column_types() {
        let schema = schema_with(
            "Customers",
            &[
                ("id", Type::Int),
                ("Name", Type::Text),
                ("Score", Type::Int),
            ],
        );
        // `Score=42` (int slot) and `Name='Alice'` (text slot)
        // — each value slot dispatches on the column whose
        // ident matched immediately before.
        let cmd = parse_command_with_schema(
            "update Customers set Score=42, Name='Alice' where id=1",
            &schema,
        )
        .expect("parse");
        match cmd {
            Command::Update { assignments, .. } => {
                assert_eq!(assignments.len(), 2);
                assert_eq!(assignments[0].0, "Score");
                assert_eq!(assignments[1].0, "Name");
            }
            other => panic!("expected Update, got {other:?}"),
        }
    }

    // ---- ADR-0032 §11.5 Phase-2 diagnostics ---------------------

    /// Build a two-table schema for join/qualified-ref tests.
    fn two_table_schema() -> SchemaCache {
        let mut cache = SchemaCache::default();
        cache.tables.push("a".to_string());
        cache.tables.push("b".to_string());
        cache.columns.push("id".to_string());
        cache.columns.push("name".to_string());
        cache.columns.push("total".to_string());
        cache.table_columns.insert(
            "a".to_string(),
            vec![
                TableColumn {
                    name: "id".to_string(),
                    user_type: Type::Int,
                    not_null: false,
                    has_default: false,
                },
                TableColumn {
                    name: "name".to_string(),
                    user_type: Type::Text,
                    not_null: false,
                    has_default: false,
                },
            ],
        );
        cache.table_columns.insert(
            "b".to_string(),
            vec![
                TableColumn {
                    name: "id".to_string(),
                    user_type: Type::Int,
                    not_null: false,
                    has_default: false,
                },
                TableColumn {
                    name: "total".to_string(),
                    user_type: Type::Real,
                    not_null: false,
                    has_default: false,
                },
            ],
        );
        cache
    }

    fn diag_keys(source: &str, schema: &SchemaCache) -> Vec<&'static str> {
        // SQL SELECT lives in Advanced mode (ADR-0030 §2). The
        // default `input_diagnostics` uses Simple, which gates
        // the command out and yields no diagnostics. Build the
        // walk manually so we can set the right mode.
        let mut ctx = super::context::WalkContext::with_schema(schema);
        ctx.mode = crate::mode::Mode::Advanced;
        let (result, _cmd) = super::walk(source, super::outcome::WalkBound::EndOfInput, &mut ctx);
        let diagnostics = result.map_or_else(Vec::new, |r| r.diagnostics);
        diagnostics
            .into_iter()
            .map(|d| Box::leak(d.message.into_boxed_str()) as &str)
            .collect()
    }

    /// Simple-mode counterpart of [`diag_keys`] — the DSL surface
    /// (ADR-0003). Issue #17: the arity diagnostic must fire in simple
    /// mode too, with the user-fillable (serial-skipped) Form B count.
    fn diag_keys_simple(source: &str, schema: &SchemaCache) -> Vec<String> {
        let mut ctx = super::context::WalkContext::with_schema(schema);
        ctx.mode = crate::mode::Mode::Simple;
        let (result, _cmd) = super::walk(source, super::outcome::WalkBound::EndOfInput, &mut ctx);
        result.map_or_else(Vec::new, |r| {
            r.diagnostics.into_iter().map(|d| d.message).collect()
        })
    }

    #[test]
    fn insert_arity_mismatch_simple_form_b_uses_user_fillable_count() {
        // Issue #17: simple-mode Form B skips serial/shortid (auto-
        // filled), so `Customers(id serial, Name, Age, SerNo)` expects
        // 2 values (Name, Age). Three values is a mismatch — and simple
        // mode must surface the friendly arity diagnostic (as advanced
        // mode already does), counted against the user-fillable columns,
        // not the full table.
        let schema = schema_with(
            "Customers",
            &[
                ("id", Type::Serial),
                ("Name", Type::Text),
                ("Age", Type::Int),
                ("SerNo", Type::Serial),
            ],
        );
        let diags = diag_keys_simple("insert into Customers values ('Oli', 52, 3)", &schema);
        assert!(
            diags
                .iter()
                .any(|d| d.contains("2 value(s)") && d.contains("3 given")),
            "simple Form B must fire arity diagnostic with user-fillable \
             count (2) and actual (3); got {diags:?}",
        );
        // Pedagogical: names the user-fillable and the auto-generated
        // columns so the learner understands the skip.
        assert!(
            diags.iter().any(|d| d.contains("Name")
                && d.contains("Age")
                && d.contains("id")
                && d.contains("SerNo")),
            "message should name fillable (Name, Age) and auto-gen (id, \
             SerNo) columns; got {diags:?}",
        );
    }

    #[test]
    fn insert_arity_mismatch_simple_form_a_uses_listed_count() {
        // Simple Form A: the explicit column list sets the expected
        // count (mode-neutral). `(Name, Age)` lists 2 columns; one value
        // is a mismatch. The DSL Form A column role is `insert_first_item`
        // (issue #17 — the diagnostic counts it as well as the SQL
        // `insert_column`).
        let schema = schema_with(
            "Customers",
            &[
                ("id", Type::Serial),
                ("Name", Type::Text),
                ("Age", Type::Int),
            ],
        );
        let diags = diag_keys_simple("insert into Customers (Name, Age) values ('Oli')", &schema);
        assert!(
            diags
                .iter()
                .any(|d| d.contains("names 2 column(s)") && d.contains("1 value(s)")),
            "simple Form A must fire the column-list arity diagnostic; got {diags:?}",
        );
    }

    #[test]
    fn insert_arity_mismatch_simple_form_c_uses_user_fillable_count() {
        // Simple Form C (`insert into T (vals)`, no `values` keyword)
        // shares Form B's count semantics — the diagnostic must fire even
        // though there is no `values` keyword to anchor on (issue #17).
        let schema = schema_with(
            "Customers",
            &[
                ("id", Type::Serial),
                ("Name", Type::Text),
                ("Age", Type::Int),
            ],
        );
        let diags = diag_keys_simple("insert into Customers ('Oli', 52, 3)", &schema);
        assert!(
            diags
                .iter()
                .any(|d| d.contains("2 value(s)") && d.contains("3 given")),
            "simple Form C must fire the arity diagnostic (expected 2, got 3); got {diags:?}",
        );
    }

    #[test]
    fn insert_arity_mismatch_simple_all_auto_table() {
        // Edge: every column auto-generated (all serial/shortid). The
        // user-fillable count is 0, so any supplied value is a mismatch —
        // the tailored "all auto-generated" message fires (issue #17).
        let schema = schema_with("Counters", &[("id", Type::Serial), ("seq", Type::Serial)]);
        let diags = diag_keys_simple("insert into Counters values (1)", &schema);
        assert!(
            diags
                .iter()
                .any(|d| d.contains("all auto-generated")
                    || d.contains("auto-generated, so no values")),
            "all-auto table must fire the tailored message; got {diags:?}",
        );
    }

    #[test]
    fn insert_arity_advanced_form_b_still_uses_full_column_count() {
        // Guard: issue #17 made the diagnostic mode-aware, but advanced
        // Form B must keep the full-column-count semantics (auto-fills
        // nothing, ADR-0036) — `('Oli', 52, 3)` for a 4-column table
        // reports "all 4 column(s)", not the simple-mode user-fillable 2.
        let schema = schema_with(
            "Customers",
            &[
                ("id", Type::Serial),
                ("Name", Type::Text),
                ("Age", Type::Int),
                ("SerNo", Type::Serial),
            ],
        );
        let diags = diag_keys("insert into Customers values ('Oli', 52, 3)", &schema);
        assert!(
            diags
                .iter()
                .any(|d| d.contains("all 4 column(s)") && d.contains("3 value(s)")),
            "advanced Form B must keep the full-column count (4); got {diags:?}",
        );
    }

    #[test]
    fn unknown_qualifier_in_qualified_ref_is_error() {
        let schema = two_table_schema();
        // `t` is not in scope (only `a` and `b` are).
        let diags = diag_keys("select t.id from a join b on a.id = b.id", &schema);
        assert!(
            diags.iter().any(|d| d.contains("no such table or alias")),
            "expected unknown_qualifier; got {diags:?}",
        );
    }

    #[test]
    fn ambiguous_bare_column_is_error() {
        let schema = two_table_schema();
        // `id` exists in both `a` and `b`.
        let diags = diag_keys("select id from a join b on a.id = b.id", &schema);
        assert!(
            diags.iter().any(|d| d.contains("ambiguous")),
            "expected ambiguous_column; got {diags:?}",
        );
    }

    #[test]
    fn unambiguous_bare_column_no_error() {
        let schema = two_table_schema();
        // `name` is only in `a`; `total` is only in `b` — no ambiguity.
        let diags = diag_keys("select name, total from a join b on a.id = b.id", &schema);
        assert!(diags.is_empty(), "expected no diagnostics; got {diags:?}",);
    }

    #[test]
    fn qualified_refs_in_join_on_resolve_cleanly() {
        let schema = two_table_schema();
        let diags = diag_keys(
            "select a.name, b.total from a join b on a.id = b.id",
            &schema,
        );
        assert!(diags.is_empty(), "expected no diagnostics; got {diags:?}",);
    }

    #[test]
    fn unknown_column_via_qualified_ref() {
        let schema = two_table_schema();
        let diags = diag_keys("select a.nosuch from a", &schema);
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "expected unknown_column; got {diags:?}",
        );
    }

    /// Like `schema_with`, but each column carries explicit
    /// `(name, type, not_null, has_default)` so the not_null_missing
    /// pass can be exercised.
    fn schema_required(table: &str, cols: &[(&str, Type, bool, bool)]) -> SchemaCache {
        let cols: Vec<TableColumn> = cols
            .iter()
            .map(|(n, t, nn, hd)| TableColumn {
                name: (*n).to_string(),
                user_type: *t,
                not_null: *nn,
                has_default: *hd,
            })
            .collect();
        let mut cache = SchemaCache::default();
        cache.tables.push(table.to_string());
        for c in &cols {
            cache.columns.push(c.name.clone());
        }
        cache.table_columns.insert(table.to_string(), cols);
        cache
    }

    #[test]
    fn insert_column_list_unknown_column_is_flagged() {
        // 3i cross-cut: schema-existence fires on the INSERT column
        // list (the target isn't a binding, so a targeted pass covers
        // it).
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
        let diags = diag_keys("insert into t (nonexistent) values (1)", &schema);
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "unknown INSERT column should be flagged; got {diags:?}",
        );
    }

    #[test]
    fn insert_select_target_only_column_not_misflagged() {
        // Regression (/runda DA round): `name` is a column of the
        // INSERT target `a` but NOT of the SELECT source `b`. The
        // insert_column must be validated against the target, not the
        // SELECT's source tables — otherwise the flat-scope
        // bare-column branch falsely flags it against `b`.
        let schema = two_table_schema(); // a(id,name), b(id,total)
        let diags = diag_keys("insert into a (name) select total from b", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("no such column")),
            "target column `name` must not be flagged against the SELECT source; got {diags:?}",
        );
    }

    #[test]
    fn conflict_target_in_insert_select_validated_against_target() {
        // Regression (/runda DA round): ON CONFLICT (name) where
        // `name` is the target `a`'s column but not the SELECT source
        // `b`'s — must validate against the target, not be flagged
        // against `b`.
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id) select id from b on conflict (name) do nothing",
            &schema,
        );
        assert!(
            !diags.iter().any(|d| d.contains("no such column")),
            "conflict target `name` (a's column) must not flag against `b`; got {diags:?}",
        );
    }

    #[test]
    fn do_update_bare_rhs_in_insert_select_validated_against_target() {
        // Regression (/runda DA round): a DO UPDATE SET RHS bare ref
        // (`name`, a target column) in an INSERT … SELECT … ON CONFLICT
        // resolves to the target `a`, not the SELECT source `b`.
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id) select id from b on conflict (id) do update set name = name",
            &schema,
        );
        assert!(
            !diags.iter().any(|d| d.contains("no such column")),
            "DO UPDATE bare ref `name` must not flag against `b`; got {diags:?}",
        );
    }

    #[test]
    fn do_update_bare_ref_unknown_column_is_flagged() {
        // Closes the #12 residual: a DO UPDATE bare ref to a column
        // that doesn't exist on the target IS flagged (validated
        // against the target). Covers the SET RHS and the WHERE.
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
        let set_rhs = diag_keys(
            "insert into t (a, b) values (1, 'x') on conflict (a) do update set b = nope",
            &schema,
        );
        assert!(
            set_rhs.iter().any(|d| d.contains("no such column")),
            "unknown DO UPDATE SET RHS ref should be flagged; got {set_rhs:?}",
        );
        let where_ref = diag_keys(
            "insert into t (a, b) values (1, 'x') on conflict (a) do update set b = 'y' where nope = 1",
            &schema,
        );
        assert!(
            where_ref.iter().any(|d| d.contains("no such column")),
            "unknown DO UPDATE WHERE ref should be flagged; got {where_ref:?}",
        );
    }

    #[test]
    fn returning_ref_in_insert_select_validated_against_target() {
        // Regression (/runda DA round): RETURNING references the
        // INSERTED (target) row. In an INSERT … SELECT … RETURNING, a
        // target-only column must resolve to the target `a`, not be
        // flagged against the SELECT source `b`.
        let schema = two_table_schema(); // a(id,name), b(id,total)
        let diags = diag_keys(
            "insert into a (id) select id from b returning name",
            &schema,
        );
        assert!(
            !diags.iter().any(|d| d.contains("no such column")),
            "RETURNING `name` (a's column) must not flag against `b`; got {diags:?}",
        );
    }

    #[test]
    fn target_qualified_star_in_returning_resolves_against_target() {
        // Regression (/runda DA round): `returning a.*` (target-
        // qualified star) in an INSERT … SELECT resolves to the
        // target `a`, not flagged against the SELECT source `b`.
        let schema = two_table_schema();
        let diags = diag_keys("insert into a (id) select id from b returning a.*", &schema);
        assert!(
            diags.is_empty(),
            "target-qualified `a.*` in RETURNING must resolve cleanly; got {diags:?}",
        );
    }

    #[test]
    fn unrelated_qualified_star_in_returning_still_flagged() {
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id) select id from b returning zzz.*",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("no such table or alias")),
            "unrelated `zzz.*` qualifier should still flag; got {diags:?}",
        );
    }

    #[test]
    fn target_qualified_ref_in_returning_resolves_against_target() {
        // Regression (/runda DA round): a target-qualified ref
        // `a.name` in an INSERT … SELECT … RETURNING resolves to the
        // target `a`, not flagged as an unknown qualifier (a is the
        // target, b is the SELECT source).
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id) select id from b returning a.name",
            &schema,
        );
        assert!(
            diags.is_empty(),
            "target-qualified RETURNING ref must resolve cleanly; got {diags:?}",
        );
    }

    #[test]
    fn target_qualified_ref_unknown_column_still_flagged() {
        // `a.nope` — a is the target but nope isn't its column.
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id) select id from b returning a.nope",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "unknown column under the target qualifier should flag; got {diags:?}",
        );
    }

    #[test]
    fn unrelated_qualifier_in_returning_still_flagged() {
        // A qualifier that's neither `excluded` nor the target is
        // still an unknown qualifier (the leak guard holds).
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id) select id from b returning zzz.name",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("no such table or alias")),
            "unrelated qualifier should still flag; got {diags:?}",
        );
    }

    #[test]
    fn returning_ref_unknown_column_is_flagged() {
        // Flip side: a RETURNING ref to a column on neither table is
        // flagged (against the INSERT target).
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id) select id from b returning nope",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "unknown RETURNING column should be flagged; got {diags:?}",
        );
    }

    #[test]
    fn returning_ref_in_plain_insert_validated_against_target() {
        // The VALUES INSERT … RETURNING case (no bindings at all):
        // a valid target column is silent, an unknown one flags.
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
        let ok = diag_keys("insert into t (a) values (1) returning b", &schema);
        assert!(
            !ok.iter().any(|d| d.contains("no such column")),
            "got {ok:?}"
        );
        let bad = diag_keys("insert into t (a) values (1) returning nope", &schema);
        assert!(
            bad.iter().any(|d| d.contains("no such column")),
            "got {bad:?}"
        );
    }

    #[test]
    fn do_update_bare_ref_known_column_silent() {
        // And a valid bare ref to a target column is silent.
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
        let diags = diag_keys(
            "insert into t (a, b) values (1, 'x') on conflict (a) do update set b = 'y' where a > 0",
            &schema,
        );
        assert!(
            !diags.iter().any(|d| d.contains("no such column")),
            "valid DO UPDATE refs must be silent; got {diags:?}",
        );
    }

    #[test]
    fn conflict_target_unknown_column_is_flagged() {
        // The flip side: a conflict target naming a column that is in
        // neither table is flagged (against the INSERT target).
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id) values (1) on conflict (nope) do nothing",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "unknown conflict-target column should be flagged; got {diags:?}",
        );
    }

    #[test]
    fn insert_select_genuinely_unknown_insert_column_still_flagged() {
        // The flip side: a column in neither the target nor the source
        // is still flagged (against the target, by the dedicated pass).
        let schema = two_table_schema();
        let diags = diag_keys("insert into a (nope) select total from b", &schema);
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "a genuinely unknown INSERT column should still be flagged; got {diags:?}",
        );
    }

    #[test]
    fn insert_column_list_known_columns_silent() {
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
        let diags = diag_keys("insert into t (a, b) values (1, 'x')", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("no such column")),
            "known columns must not flag; got {diags:?}",
        );
    }

    #[test]
    fn upsert_do_update_unknown_set_column_is_flagged() {
        // DA finding (#12) closed: parity with a top-level UPDATE's
        // SET column — an unknown DO UPDATE SET column is flagged.
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
        let diags = diag_keys(
            "insert into t (a, b) values (1, 'x') on conflict (a) do update set nosuch = 1",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "unknown DO UPDATE SET column should be flagged; got {diags:?}",
        );
    }

    #[test]
    fn upsert_do_update_known_set_column_silent() {
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]);
        let diags = diag_keys(
            "insert into t (a, b) values (1, 'x') on conflict (a) do update set b = excluded.b",
            &schema,
        );
        assert!(
            !diags.iter().any(|d| d.contains("no such column")),
            "known SET column + excluded ref must be clean; got {diags:?}",
        );
    }

    #[test]
    fn predicate_warning_fires_on_sql_update_values_context() {
        // 3i cross-cut: the Phase-2 predicate-warning pass fires on a
        // DML SET/WHERE sql_expr slot (here `= NULL` in an UPDATE).
        let schema = schema_with("t", &[("id", Type::Int), ("v", Type::Int)]);
        let diags = diag_keys("update t set v = 1 where v = NULL", &schema);
        assert!(
            diags.iter().any(|d| d.contains("IS NULL")),
            "eq_null should fire on the UPDATE WHERE; got {diags:?}",
        );
    }

    #[test]
    fn not_null_missing_fires_when_required_column_omitted() {
        // `b` is NOT NULL with no default and is omitted → WARNING.
        let schema = schema_required(
            "t",
            &[
                ("a", Type::Int, false, false),
                ("b", Type::Text, true, false),
            ],
        );
        let diags = diag_keys("insert into t (a) values (1)", &schema);
        assert!(
            diags.iter().any(|d| d.contains("is required")),
            "omitting NOT NULL `b` should warn; got {diags:?}",
        );
    }

    #[test]
    fn not_null_missing_silent_when_included() {
        let schema = schema_required(
            "t",
            &[
                ("a", Type::Int, false, false),
                ("b", Type::Text, true, false),
            ],
        );
        let diags = diag_keys("insert into t (a, b) values (1, 'x')", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("is required")),
            "including `b` must not warn; got {diags:?}",
        );
    }

    #[test]
    fn not_null_missing_silent_when_column_has_default() {
        // NOT NULL but DEFAULT present → omitting is fine.
        let schema = schema_required(
            "t",
            &[
                ("a", Type::Int, false, false),
                ("b", Type::Text, true, true),
            ],
        );
        let diags = diag_keys("insert into t (a) values (1)", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("is required")),
            "a defaulted column must not warn; got {diags:?}",
        );
    }

    #[test]
    fn not_null_missing_excludes_auto_generated() {
        // A serial PK is NOT NULL no-default but auto-filled, so
        // omitting it is correct — not a missing required value.
        let schema = schema_required(
            "t",
            &[
                ("id", Type::Serial, true, false),
                ("b", Type::Text, false, false),
            ],
        );
        let diags = diag_keys("insert into t (b) values ('x')", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("is required")),
            "auto-gen serial must not warn; got {diags:?}",
        );
    }

    #[test]
    fn insert_arity_mismatch_single_row_fires() {
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys("insert into t (a, b) values (1, 2, 3)", &schema);
        assert!(
            diags.iter().any(|d| d.contains("value(s) are given")),
            "3 values for 2 columns should fire; got {diags:?}",
        );
    }

    #[test]
    fn insert_form_b_arity_mismatch_under_supply_fires() {
        // Issue #1: advanced-mode positional INSERT without a column
        // list requires a value for every column. Three values for a
        // 4-column table (the user's reported bug case) is a
        // mismatch — the diagnostic powers the validity verdict that
        // drives the [ERR] indicator and ADR-0033 Amendment 5's
        // `also_valid_sql` gate.
        let schema = schema_with(
            "Customers",
            &[
                ("id", Type::Serial),
                ("Name", Type::Text),
                ("Age", Type::Int),
                ("SerNo", Type::Serial),
            ],
        );
        let diags = diag_keys("insert into Customers values ('Oli', 52, 3)", &schema);
        assert!(
            diags.iter().any(|d| d.contains("value(s) are given")),
            "Form B under-supply must fire arity diagnostic; got {diags:?}",
        );
    }

    #[test]
    fn insert_form_b_arity_mismatch_over_supply_fires() {
        // Symmetric case: more values than columns — engine would
        // reject; the diagnostic catches it pre-flight.
        let schema = schema_with(
            "Customers",
            &[
                ("id", Type::Serial),
                ("Name", Type::Text),
                ("Age", Type::Int),
                ("SerNo", Type::Serial),
            ],
        );
        let diags = diag_keys(
            "insert into Customers values ('Oli', 52, 3, 13, 99)",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("value(s) are given")),
            "Form B over-supply must fire arity diagnostic; got {diags:?}",
        );
    }

    #[test]
    fn insert_form_b_arity_match_is_silent() {
        // The user's confirmed happy path: every column supplied
        // positionally (autos included) — no arity diagnostic.
        let schema = schema_with(
            "Customers",
            &[
                ("id", Type::Serial),
                ("Name", Type::Text),
                ("Age", Type::Int),
                ("SerNo", Type::Serial),
            ],
        );
        let diags = diag_keys("insert into Customers values (13, 'Oli', 42, 13)", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("value(s) are given")),
            "matched Form B arity must not fire; got {diags:?}",
        );
    }

    #[test]
    fn insert_form_b_arity_unknown_table_is_silent() {
        // Defensive: when the target table isn't in the schema cache,
        // we can't know the expected count — defer to the engine
        // (the schema-existence pass flags the unknown table, not the
        // arity pass).
        let schema = schema_with("Customers", &[("Name", Type::Text)]);
        let diags = diag_keys("insert into Strangers values ('Oli', 52, 3)", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("value(s) are given")),
            "unknown table must not trigger an arity diagnostic; got {diags:?}",
        );
    }

    #[test]
    fn insert_arity_match_is_silent() {
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys("insert into t (a, b) values (1, 2)", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("value(s) are given")),
            "matched arity must not fire; got {diags:?}",
        );
    }

    #[test]
    fn insert_arity_mismatch_emits_per_row() {
        // ADR-0033 §8.1: each offending tuple emits its own
        // diagnostic; the matched rows stay silent.
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys(
            "insert into t (a, b) values (1, 2), (3, 4, 5), (6), (7, 8)",
            &schema,
        );
        let n = diags
            .iter()
            .filter(|d| d.contains("value(s) are given"))
            .count();
        assert_eq!(
            n, 2,
            "rows 2 and 3 mismatch, rows 1 and 4 match; got {diags:?}"
        );
    }

    #[test]
    fn insert_arity_with_on_conflict_flags_only_the_tuple() {
        // Regression guard: the VALUES walk must stop at ON CONFLICT —
        // its `(col)` conflict target is not a value tuple. A real
        // tuple mismatch still fires; the conflict target never does.
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys(
            "insert into t (a, b) values (1, 2, 3) on conflict (a) do nothing",
            &schema,
        );
        let n = diags
            .iter()
            .filter(|d| d.contains("value(s) are given"))
            .count();
        assert_eq!(
            n, 1,
            "only the 3-value tuple is flagged, not the conflict target; got {diags:?}"
        );
    }

    #[test]
    fn insert_arity_clean_with_on_conflict_is_silent() {
        // Matched tuple arity + an ON CONFLICT target must be fully
        // silent (the conflict target isn't counted as a tuple).
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys(
            "insert into t (a, b) values (1, 2) on conflict (a) do update set b = excluded.b",
            &schema,
        );
        assert!(
            !diags.iter().any(|d| d.contains("value(s) are given")),
            "no arity diagnostic expected; got {diags:?}",
        );
    }

    #[test]
    fn insert_arity_with_returning_flags_only_tuple() {
        // RETURNING is a depth-0 keyword that ends the VALUES clause;
        // the walk must stop there (same guard as ON CONFLICT) so the
        // RETURNING projection isn't mis-counted as a value tuple.
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys("insert into t (a, b) values (1, 2, 3) returning *", &schema);
        let n = diags
            .iter()
            .filter(|d| d.contains("value(s) are given"))
            .count();
        assert_eq!(n, 1, "only the 3-value tuple is flagged; got {diags:?}");
    }

    #[test]
    fn insert_arity_ignores_commas_nested_in_a_value() {
        // A comma inside a function call (depth ≥ 2) is not a tuple
        // separator and must not inflate the value count.
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys("insert into t (a, b) values (1, coalesce(1, 2))", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("value(s) are given")),
            "two values (one a 2-arg call) match the 2-col list; got {diags:?}",
        );
    }

    #[test]
    fn insert_select_arity_mismatch_fires() {
        // INSERT … SELECT: 1 listed column vs a 2-item projection.
        let schema = two_table_schema(); // a(id,name), b(id,total)
        let diags = diag_keys("insert into a (id) select id, total from b", &schema);
        assert!(
            diags.iter().any(|d| d.contains("value(s) are given")),
            "2-col projection into 1-col list should fire; got {diags:?}",
        );
    }

    #[test]
    fn insert_select_arity_match_is_silent() {
        let schema = two_table_schema();
        let diags = diag_keys("insert into a (id, name) select id, total from b", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("value(s) are given")),
            "matched projection arity must not fire; got {diags:?}",
        );
    }

    #[test]
    fn auto_column_overridden_fires_on_explicit_serial() {
        // ADR-0033 §8.2: listing a serial column explicitly warns.
        let schema = schema_with("t", &[("id", Type::Serial), ("name", Type::Text)]);
        let diags = diag_keys("insert into t (id, name) values (5, 'x')", &schema);
        assert!(
            diags.iter().any(|d| d.contains("auto-generated")),
            "explicit serial value should warn; got {diags:?}",
        );
    }

    #[test]
    fn auto_column_overridden_fires_on_explicit_shortid() {
        let schema = schema_with("t", &[("id", Type::ShortId), ("name", Type::Text)]);
        let diags = diag_keys("insert into t (id, name) values ('abc', 'x')", &schema);
        assert!(
            diags.iter().any(|d| d.contains("auto-generated")),
            "explicit shortid value should warn; got {diags:?}",
        );
    }

    #[test]
    fn auto_column_overridden_silent_when_omitted() {
        // Negative: omitting the auto-gen column does not warn.
        let schema = schema_with("t", &[("id", Type::Serial), ("name", Type::Text)]);
        let diags = diag_keys("insert into t (name) values ('x')", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("auto-generated")),
            "omitting the serial column must not warn; got {diags:?}",
        );
    }

    #[test]
    fn auto_column_overridden_conflict_target_not_flagged() {
        // The ON CONFLICT (id) target names `id` but isn't an
        // *inserted* value, so it must not trigger the warning;
        // only the inserted `id` in the column list would.
        let schema = schema_with("t", &[("id", Type::Serial), ("name", Type::Text)]);
        let diags = diag_keys(
            "insert into t (name) values ('x') on conflict (id) do nothing",
            &schema,
        );
        assert!(
            !diags.iter().any(|d| d.contains("auto-generated")),
            "conflict-target serial column must not warn; got {diags:?}",
        );
    }

    #[test]
    fn upsert_excluded_resolves_in_do_update() {
        // ADR-0033 §9: inside a DO UPDATE action, `excluded.col`
        // resolves to the target table's columns — no diagnostic.
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name",
            &schema,
        );
        assert!(
            diags.is_empty(),
            "excluded.name should resolve in DO UPDATE; got {diags:?}"
        );
    }

    #[test]
    fn upsert_excluded_resolves_in_do_update_where() {
        // The DO UPDATE WHERE is part of the action's byte range, so
        // `excluded` resolves there too (not just in the SET RHS).
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id = excluded.id",
            &schema,
        );
        assert!(
            diags.is_empty(),
            "excluded in DO UPDATE WHERE should resolve; got {diags:?}"
        );
    }

    #[test]
    fn upsert_excluded_unknown_column_is_flagged() {
        // `excluded` mirrors the target's shape, so an unknown column
        // under it is still an unknown_column error.
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id, name) values (1, 'x') on conflict (id) do update set name = excluded.nosuch",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "excluded.nosuch should be unknown_column; got {diags:?}",
        );
    }

    #[test]
    fn excluded_outside_do_update_is_unknown_qualifier() {
        // The leak guard: `excluded` in a VALUES tuple (no DO UPDATE
        // in scope) has no meaning and must be flagged.
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id, name) values (excluded.name, 'x')",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("no such table or alias")),
            "excluded in VALUES should be unknown_qualifier; got {diags:?}",
        );
    }

    #[test]
    fn excluded_in_values_flagged_even_when_do_update_present() {
        // Strongest leak case: same statement uses `excluded` BOTH in
        // VALUES (out of scope → flagged) and in DO UPDATE (in scope →
        // resolves). The byte-range scoping must distinguish them.
        let schema = two_table_schema();
        let diags = diag_keys(
            "insert into a (id, name) values (excluded.id, 'x') on conflict (id) do update set name = excluded.name",
            &schema,
        );
        // Exactly the VALUES-side `excluded.id` is flagged; the
        // DO UPDATE-side `excluded.name` resolves cleanly.
        assert!(
            diags.iter().any(|d| d.contains("no such table or alias")),
            "VALUES-side excluded must still leak-flag; got {diags:?}",
        );
        assert!(
            !diags.iter().any(|d| d.contains("no such column")),
            "DO UPDATE-side excluded.name must resolve (no unknown_column); got {diags:?}",
        );
    }

    #[test]
    fn select_aggregate_function_name_not_flagged_as_unknown_column() {
        // Issue #6: `select sum(Age) from Customers` runs cleanly at
        // the engine but the schema-existence pass was treating `sum`
        // as a bare column reference and flagging "no such column
        // `sum` on table `Customers`". The grammar admits the
        // function-call shape structurally (sql_expr.rs comment at
        // CALL_ARGS, ADR-0031 §1: "it does not know which names are
        // aggregates"), so the validator must match: an ident
        // followed by `(` is a function-call name, not a column
        // reference.
        let schema = schema_with("Customers", &[("Age", Type::Int)]);
        for input in [
            // The five standard SQL aggregates …
            "select sum(Age) from Customers",
            "select avg(Age) from Customers",
            "select count(*) from Customers",
            "select count(Age) from Customers",
            "select min(Age) from Customers",
            "select max(Age) from Customers",
            // … `count(distinct …)` (ADR-0031 §1 — the one place
            // DISTINCT leads the argument list) …
            "select count(distinct Age) from Customers",
            // … and non-aggregate functions, which get the same
            // treatment by the same fix because the grammar already
            // accepts them and the engine evaluates them.
            "select length(Age) from Customers",
            "select coalesce(Age, 0) from Customers",
            // Nested calls — the outer name is followed by `(` (skip),
            // the inner name is followed by `(` (skip), the innermost
            // bare ident is followed by `)` and gets validated.
            "select upper(lower(Age)) from Customers",
            // Functions in WHERE: same `sql_expr_ident` role, same
            // skip guard applies.
            "select Age from Customers where length(Age) > 0",
        ] {
            let diags = diag_keys(input, &schema);
            assert!(
                !diags.iter().any(|d| d.contains("no such column")),
                "function-call name must not trip `no such column` for {input:?}; got {diags:?}",
            );
        }
    }

    #[test]
    fn select_unknown_column_inside_distinct_arg_still_flagged() {
        // ADR-0031 §1 calls out `count(distinct col)` as a special
        // argument shape. The argument is still an ordinary column
        // reference and a genuinely unknown one must still flag —
        // the DISTINCT keyword doesn't shield the column-ref
        // validation downstream.
        let schema = schema_with("Customers", &[("Age", Type::Int)]);
        let diags = diag_keys("select count(distinct nonexistent) from Customers", &schema);
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "unknown column inside `count(distinct …)` must still fire; got {diags:?}",
        );
    }

    #[test]
    fn select_unknown_argument_inside_function_call_still_flagged() {
        // Symmetric guard: the fix only skips validation of the
        // function-call *name*; the argument expressions are still
        // ordinary expressions and a genuinely unknown column inside
        // a call must still fire `no such column`.
        let schema = schema_with("Customers", &[("Age", Type::Int)]);
        let diags = diag_keys("select sum(nonexistent) from Customers", &schema);
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "unknown column inside the call args must still fire; got {diags:?}",
        );
    }

    #[test]
    fn insert_select_unknown_projection_column_is_error() {
        // ADR-0033 sub-phase 3c cross-cut: the Phase-2
        // schema-existence pass fires on a SELECT row source
        // embedded in an INSERT (no re-implementation needed).
        // `nonexistent_col` is not a column of `a`.
        let schema = two_table_schema();
        let diags = diag_keys("insert into b select nonexistent_col from a", &schema);
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "expected unknown_column on the INSERT…SELECT projection; \
             got {diags:?}",
        );
    }

    #[test]
    fn sql_update_unknown_set_column_is_error() {
        // ADR-0033 sub-phase 3e cross-cut: the schema-existence
        // pass fires on the SET assignment column.
        let schema = schema_with("t", &[("id", Type::Int), ("v", Type::Text)]);
        let diags = diag_keys("update t set nonexistent = 1 where id = 1", &schema);
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "expected unknown_column on the SET column; got {diags:?}",
        );
    }

    #[test]
    fn sql_update_eq_null_in_where_warns() {
        // ADR-0033 sub-phase 3e cross-cut: the predicate-warning
        // pass fires on `= NULL` in an UPDATE's WHERE.
        let schema = schema_with("t", &[("id", Type::Int), ("v", Type::Int)]);
        let diags = diag_keys("update t set v = 1 where v = NULL", &schema);
        assert!(
            diags.iter().any(|d| d.contains("IS NULL")),
            "expected eq_null warning on the WHERE; got {diags:?}",
        );
    }

    #[test]
    fn sql_delete_where_unknown_column_is_error() {
        // 3k cross-cut (matrix I3): schema-existence fires on a
        // top-level DELETE's WHERE, not only on INSERT/UPDATE slots.
        let schema = schema_with("t", &[("id", Type::Int), ("v", Type::Int)]);
        let diags = diag_keys("delete from t where nonexistent = 1", &schema);
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "expected unknown_column on the DELETE WHERE; got {diags:?}",
        );
    }

    #[test]
    fn sql_update_where_unknown_column_is_error() {
        // 3k cross-cut (matrix I3): schema-existence fires on a
        // top-level UPDATE's WHERE (the SET case is covered by
        // `sql_update_unknown_set_column_is_error`).
        let schema = schema_with("t", &[("id", Type::Int), ("v", Type::Int)]);
        let diags = diag_keys("update t set v = 1 where nonexistent = 1", &schema);
        assert!(
            diags.iter().any(|d| d.contains("no such column")),
            "expected unknown_column on the UPDATE WHERE; got {diags:?}",
        );
    }

    #[test]
    fn sql_delete_where_like_numeric_warns() {
        // 3k cross-cut (matrix I6): the like_numeric predicate
        // warning fires on a DELETE's WHERE (the prior coverage was
        // SELECT / HAVING / CASE only).
        let schema = schema_with("t", &[("id", Type::Int), ("count", Type::Int)]);
        let diags = diag_keys("delete from t where count like 5", &schema);
        assert!(
            diags.iter().any(|d| d.contains("LIKE")),
            "expected like_numeric warning on the DELETE WHERE; got {diags:?}",
        );
    }

    #[test]
    fn sql_update_set_case_predicate_warns() {
        // 3k cross-cut (matrix I7, corrected): a predicate warning
        // fires inside a CASE in an UPDATE SET RHS — a row-scoped
        // DML sql_expr slot (ADR-0033 §8.4 — "WHERE and CASE").
        let schema = schema_with("t", &[("id", Type::Int), ("v", Type::Int)]);
        let diags = diag_keys(
            "update t set v = case when v = NULL then 1 else 0 end where id = 1",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("IS NULL")),
            "expected eq_null warning inside the UPDATE SET CASE; got {diags:?}",
        );
    }

    #[test]
    fn insert_select_where_predicate_warns() {
        // 3k cross-cut (matrix I7, corrected): the predicate-warning
        // pass fires on the WHERE of an INSERT … SELECT row source
        // (`b.total` is real, so `like 5` is a numeric LIKE). Plain
        // INSERT VALUES carries no row scope, so the realizable
        // claim is the INSERT … SELECT slot, not VALUES.
        let schema = two_table_schema(); // a(id,name), b(id,total real)
        let diags = diag_keys(
            "insert into a (id) select id from b where total like 5",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("LIKE")),
            "expected like_numeric on the INSERT…SELECT WHERE; got {diags:?}",
        );
    }

    #[test]
    fn cte_name_is_valid_table_source() {
        let schema = schema_with("base", &[("id", Type::Int)]);
        // `cte_x` doesn't exist as a table; it's declared by
        // WITH and the post-walk pass should treat it as valid.
        let diags = diag_keys(
            "with cte_x as (select * from base) select * from cte_x",
            &schema,
        );
        assert!(diags.is_empty(), "expected no diagnostics; got {diags:?}",);
    }

    #[test]
    fn duplicate_cte_in_same_with_block_is_error() {
        // `WITH …` doesn't dispatch through the registry yet
        // (a `data::WITH` `CommandNode` is a future sub-phase).
        // Walk the fragment directly via SQL_SELECT_STATEMENT
        // so the diagnostic pass sees the cte_name idents, then
        // assert duplicate_cte fires on the second occurrence.
        let schema = schema_with("base", &[("id", Type::Int)]);
        let mut ctx = super::context::WalkContext::with_schema(&schema);
        let mut path = super::outcome::MatchedPath::new();
        let mut per_byte: Vec<super::outcome::ByteClass> = Vec::new();
        let input = "with x as (select 1), x as (select 2) select * from x";
        let result = crate::dsl::walker::driver::walk_node(
            input,
            0,
            &crate::dsl::grammar::sql_select::SQL_SELECT_STATEMENT,
            &mut ctx,
            &mut path,
            &mut per_byte,
        );
        assert!(
            matches!(
                result,
                crate::dsl::walker::driver::NodeWalkResult::Matched { .. }
            ),
            "fragment should walk: {result:?}"
        );
        let diags = super::schema_existence_diagnostics(&path, Some(&schema));
        let messages: Vec<&str> = diags.iter().map(|d| d.message.as_str()).collect();
        assert!(
            messages.iter().any(|m| m.contains("duplicate")),
            "expected duplicate_cte; got {messages:?}",
        );
    }

    #[test]
    fn unknown_table_in_from_still_flags() {
        // Regression — the multi-binding extension must not
        // break the single-table unknown-table case.
        let schema = schema_with("base", &[("id", Type::Int)]);
        let diags = diag_keys("select * from nonexistent", &schema);
        assert!(
            diags.iter().any(|d| d.contains("no such table")),
            "expected unknown_table; got {diags:?}",
        );
    }

    #[test]
    fn self_referencing_fk_in_create_table_is_not_flagged_unknown() {
        // ADR-0035 §4i (c): a CREATE TABLE whose FK references the table
        // being created (a self-reference) must not pre-flag the
        // not-yet-created table as unknown — the FK parent equals the
        // CREATE target. Empty schema: `T` does not exist yet.
        let schema = SchemaCache::default();
        let diags = diag_keys(
            "create table T (id int primary key, parent_id int references T(id))",
            &schema,
        );
        assert!(
            !diags.iter().any(|d| d.contains("no such table")),
            "self-ref FK parent must not be flagged unknown; got {diags:?}",
        );
    }

    #[test]
    fn create_table_fk_to_genuinely_unknown_table_still_flags() {
        // The exemption is only for the self-reference: a FK to some
        // *other* non-existent table is still flagged pre-submit.
        let schema = SchemaCache::default();
        let diags = diag_keys(
            "create table T (id int primary key, ghost_id int references Ghost(id))",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("no such table")),
            "FK to a genuinely-unknown table must still flag; got {diags:?}",
        );
    }

    #[test]
    fn alias_resolves_qualifier() {
        let schema = two_table_schema();
        // The alias `x` resolves to `a` — `x.name` finds `a.name`.
        let diags = diag_keys("select x.name from a x", &schema);
        assert!(diags.is_empty(), "expected no diagnostics; got {diags:?}",);
    }

    // ---- ADR-0032 §11.6 — Phase-1 carry-over gap closure ----

    /// A schema with a single table whose columns span a few
    /// types — enough to exercise like_numeric and
    /// type_mismatch on SQL expressions.
    fn typed_schema() -> SchemaCache {
        schema_with(
            "products",
            &[
                ("id", Type::Serial),
                ("name", Type::Text),
                ("price", Type::Real),
                ("created", Type::Date),
                ("is_active", Type::Bool),
            ],
        )
    }

    #[test]
    fn sql_where_like_numeric_warns() {
        // ADR-0032 §11.6 — THE Phase-1 gap that motivated this
        // section. `LIKE` on a numeric column made no sense, but
        // Phase 1's predicate-warning pass walked the DSL Expr
        // AST and never saw SQL WHERE.
        let schema = typed_schema();
        let diags = diag_keys("select * from products where price like 5", &schema);
        assert!(
            diags.iter().any(|d| d.contains("LIKE")),
            "expected like_numeric warning on SQL WHERE; got {diags:?}",
        );
    }

    #[test]
    fn sql_where_eq_null_warns() {
        let schema = typed_schema();
        let diags = diag_keys("select * from products where name = null", &schema);
        assert!(
            diags.iter().any(|d| d.contains("= NULL")),
            "expected eq_null warning on SQL WHERE; got {diags:?}",
        );
    }

    #[test]
    fn sql_where_type_mismatch_text_vs_number_warns() {
        let schema = typed_schema();
        let diags = diag_keys("select * from products where name = 5", &schema);
        assert!(
            diags.iter().any(|d| d.contains("different type")),
            "expected type_mismatch warning on SQL WHERE; got {diags:?}",
        );
    }

    #[test]
    fn sql_where_type_mismatch_number_vs_text_warns() {
        let schema = typed_schema();
        let diags = diag_keys("select * from products where price = 'high'", &schema);
        assert!(
            diags.iter().any(|d| d.contains("different type")),
            "expected type_mismatch warning on SQL WHERE; got {diags:?}",
        );
    }

    #[test]
    fn sql_where_type_compatible_does_not_warn() {
        let schema = typed_schema();
        let diags = diag_keys("select * from products where price = 5", &schema);
        // `price` is real; `5` is numeric — compatible (any
        // numeric-real comparison is fine). No warning.
        assert!(
            !diags
                .iter()
                .any(|d| d.contains("different type") || d.contains("LIKE")),
            "expected no warnings for compatible types; got {diags:?}",
        );
    }

    #[test]
    fn sql_having_predicate_warning_fires() {
        // Phase-1 gap also affects HAVING.
        let schema = typed_schema();
        let diags = diag_keys(
            "select count(*) from products group by name having price like 5",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("LIKE")),
            "expected like_numeric warning on HAVING; got {diags:?}",
        );
    }

    #[test]
    fn sql_case_predicate_warning_fires() {
        // ADR-0032 §11.6 — predicate warning fires inside
        // `CASE WHEN <bare-col> <op> <literal>` shapes too.
        let schema = typed_schema();
        let diags = diag_keys(
            "select case when price like 5 then 1 else 0 end from products",
            &schema,
        );
        assert!(
            diags.iter().any(|d| d.contains("LIKE")),
            "expected like_numeric warning inside CASE; got {diags:?}",
        );
    }

    #[test]
    fn sql_order_by_predicate_warning_fires() {
        // Predicate-shape inside ORDER BY's sql_expr — same
        // pass, same warning.
        let schema = typed_schema();
        let diags = diag_keys("select * from products order by price like 5", &schema);
        assert!(
            diags.iter().any(|d| d.contains("LIKE")),
            "expected like_numeric warning inside ORDER BY; got {diags:?}",
        );
    }

    #[test]
    fn sql_projection_predicate_warning_fires() {
        // Predicate shape used as a projection item (returns
        // 0/1). Same warning surface.
        let schema = typed_schema();
        let diags = diag_keys("select price like 5 from products", &schema);
        assert!(
            diags.iter().any(|d| d.contains("LIKE")),
            "expected like_numeric warning inside projection; got {diags:?}",
        );
    }

    #[test]
    fn sql_join_on_predicate_warning_fires() {
        // Phase-1 gap also affects JOIN ON.
        let mut cache = SchemaCache::default();
        cache.tables.push("a".to_string());
        cache.tables.push("b".to_string());
        cache.columns.push("id".to_string());
        cache.columns.push("price".to_string());
        cache.table_columns.insert(
            "a".to_string(),
            vec![TableColumn {
                name: "id".to_string(),
                user_type: Type::Int,
                not_null: false,
                has_default: false,
            }],
        );
        cache.table_columns.insert(
            "b".to_string(),
            vec![TableColumn {
                name: "price".to_string(),
                user_type: Type::Real,
                not_null: false,
                has_default: false,
            }],
        );
        let diags = diag_keys("select * from a join b on price like 5", &cache);
        assert!(
            diags.iter().any(|d| d.contains("LIKE")),
            "expected like_numeric warning on JOIN ON; got {diags:?}",
        );
    }

    // ---- ADR-0032 §11.2 — projection_alias_misplaced ----

    #[test]
    fn projection_alias_in_where_is_misplaced() {
        // ADR-0032 §11.2 plan test: `SELECT a + b AS x FROM t
        // WHERE x > 0` fires `projection_alias_misplaced`.
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys("select a + b as x from t where x > 0", &schema);
        assert!(
            diags
                .iter()
                .any(|d| { d.contains("alias `x`") && d.contains("WHERE") }),
            "expected projection_alias_misplaced on WHERE; got {diags:?}",
        );
        // The unknown_column diagnostic must NOT also fire on
        // the same span — the alias check pre-empts it.
        assert!(
            !diags.iter().any(|d| d.contains("no such column")),
            "unknown_column must be suppressed when alias matches; got {diags:?}",
        );
    }

    #[test]
    fn projection_alias_in_having_is_misplaced() {
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys("select a + b as x from t group by a having x > 0", &schema);
        assert!(
            diags
                .iter()
                .any(|d| { d.contains("alias `x`") && d.contains("HAVING") }),
            "expected projection_alias_misplaced on HAVING; got {diags:?}",
        );
    }

    #[test]
    fn projection_alias_in_group_by_is_misplaced() {
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys("select a + b as x from t group by x", &schema);
        assert!(
            diags
                .iter()
                .any(|d| { d.contains("alias `x`") && d.contains("GROUP BY") }),
            "expected projection_alias_misplaced on GROUP BY; got {diags:?}",
        );
    }

    #[test]
    fn projection_alias_in_order_by_is_allowed() {
        // ADR-0032 §11.2 negative case: `… ORDER BY x` doesn't
        // fire — aliases are bound by ORDER BY evaluation time.
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys("select a + b as x from t order by x", &schema);
        assert!(diags.is_empty(), "ORDER BY alias is allowed; got {diags:?}",);
    }

    #[test]
    fn real_column_shadowed_by_alias_is_not_misplaced() {
        // `SELECT name AS id FROM t WHERE id > 0` — the alias
        // `id` shadows nothing in the table, but a real column
        // `id` exists. WHERE id refers to the table column (per
        // SQL spec); the diagnostic must NOT fire.
        let schema = schema_with("t", &[("id", Type::Int), ("name", Type::Text)]);
        let diags = diag_keys("select name as id from t where id > 0", &schema);
        assert!(
            diags.is_empty(),
            "real-column WHERE ref must not be flagged as misplaced; got {diags:?}",
        );
    }

    // ---- Issue #31 — bare table alias / table used as a column ----

    #[test]
    fn bare_table_alias_in_group_by_is_alias_hint_not_unknown_column() {
        // Issue #31: `… GROUP BY o` where `o` aliases a FROM source.
        // The learner means `o.<column>`; the diagnostic must point
        // at the qualified form, NOT call `o` an unknown column.
        let schema = two_table_schema();
        let diags = diag_keys(
            "select a.id from a o join b on a.id = b.id group by o",
            &schema,
        );
        assert!(
            diags
                .iter()
                .any(|d| d.contains("`o` is a table alias") && d.contains("o.<column>")),
            "expected alias_used_as_column hint; got {diags:?}",
        );
        assert!(
            !diags.iter().any(|d| d.contains("no such column")),
            "unknown_column must not fire for an in-scope alias; got {diags:?}",
        );
    }

    #[test]
    fn bare_table_alias_in_projection_is_alias_hint() {
        // The same applies outside GROUP BY — a bare alias in the
        // projection (`SELECT o …`) is equally not a column.
        let schema = two_table_schema();
        let diags = diag_keys("select o from a o join b on a.id = b.id", &schema);
        assert!(
            diags.iter().any(|d| d.contains("`o` is a table alias")),
            "expected alias_used_as_column hint in projection; got {diags:?}",
        );
    }

    #[test]
    fn bare_unaliased_table_used_as_column_is_table_hint() {
        // An un-aliased FROM source referenced bare gets the
        // table-form hint (qualify with the table name).
        let schema = two_table_schema();
        let diags = diag_keys("select id from a group by a", &schema);
        assert!(
            diags
                .iter()
                .any(|d| d.contains("`a` is a table") && d.contains("a.<column>")),
            "expected table_used_as_column hint; got {diags:?}",
        );
    }

    #[test]
    fn genuine_unknown_column_still_reports_no_such_column() {
        // Regression guard: the alias branch must not swallow a
        // genuine typo. `nope` matches no alias, no table, no column.
        let schema = two_table_schema();
        let diags = diag_keys(
            "select a.id from a o join b on a.id = b.id group by nope",
            &schema,
        );
        assert!(
            diags
                .iter()
                .any(|d| d.contains("no such column") && d.contains("nope")),
            "a genuine unknown column must still report no such column; got {diags:?}",
        );
    }

    #[test]
    fn aliased_table_referenced_by_real_name_is_not_table_hint() {
        // DA guard (issue #31): a source aliased as `x` must be
        // referenced by the alias — `FROM a x … GROUP BY a` is invalid
        // SQL, so we must NOT advise `a.<column>`. The branch matches
        // the *effective qualifier* (the alias when present), so `a`
        // (the now-shadowed table name) falls through to the generic
        // unknown_column rather than wrong qualified-form advice.
        let schema = two_table_schema();
        let diags = diag_keys(
            "select x.id from a x join b on x.id = b.id group by a",
            &schema,
        );
        assert!(
            diags
                .iter()
                .any(|d| d.contains("no such column") && d.contains("`a`")),
            "an aliased table referenced by its real name must fall through to \
             unknown_column; got {diags:?}",
        );
        assert!(
            !diags.iter().any(|d| d.contains("is a table")),
            "must not advise `a.<column>` when `a` is aliased as `x`; got {diags:?}",
        );
    }

    #[test]
    fn dsl_bare_table_name_in_where_keeps_unknown_column() {
        // DA guard (issue #31): the alias/table hint is SQL-only
        // (role `sql_expr_ident`). The DSL `Expr` (role `expr_column`)
        // has no `table.column` syntax, so advising the qualified form
        // would be wrong. A DSL bare table-name ref stays the generic
        // unknown_column it was before issue #31.
        let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
        for input in [
            "show data Customers where Customers = 5",
            "update Customers set Name = 'x' where Customers = 5",
        ] {
            let diags = diag_keys_simple(input, &schema);
            assert!(
                diags.iter().any(|d| d.contains("no such column")),
                "DSL bare table ref must stay unknown_column for {input:?}; got {diags:?}",
            );
            assert!(
                !diags.iter().any(|d| d.contains("is a table")),
                "DSL must not get SQL qualified-form advice for {input:?}; got {diags:?}",
            );
        }
    }

    // ---- ADR-0032 §11.2 — compound_arity_mismatch ----

    #[test]
    fn compound_union_arity_mismatch_fires() {
        // ADR-0032 §11.2 plan test: `SELECT 1, 2 UNION SELECT 1`
        // fires `compound_arity_mismatch`.
        let schema = schema_with("t", &[("a", Type::Int)]);
        let diags = diag_keys("select 1, 2 union select 1", &schema);
        assert!(
            diags
                .iter()
                .any(|d| { d.contains("union") && d.contains("number of columns") }),
            "expected compound_arity_mismatch on UNION; got {diags:?}",
        );
    }

    #[test]
    fn compound_union_arity_match_no_diagnostic() {
        // Matched-arity legs don't fire.
        let schema = schema_with("t", &[("a", Type::Int)]);
        let diags = diag_keys("select 1, 2 union select 3, 4", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("number of columns")),
            "matched arity should not fire; got {diags:?}",
        );
    }

    #[test]
    fn compound_intersect_arity_mismatch_fires() {
        let schema = schema_with("t", &[("a", Type::Int)]);
        let diags = diag_keys("select 1 intersect select 1, 2", &schema);
        assert!(
            diags
                .iter()
                .any(|d| { d.contains("intersect") && d.contains("number of columns") }),
            "expected compound_arity_mismatch on INTERSECT; got {diags:?}",
        );
    }

    #[test]
    fn compound_except_arity_mismatch_fires() {
        let schema = schema_with("t", &[("a", Type::Int)]);
        let diags = diag_keys("select 1, 2, 3 except select 1, 2", &schema);
        assert!(
            diags
                .iter()
                .any(|d| { d.contains("except") && d.contains("number of columns") }),
            "expected compound_arity_mismatch on EXCEPT; got {diags:?}",
        );
    }

    #[test]
    fn compound_arity_with_function_call_args_not_confused() {
        // Function-call commas are at deeper depth — they must
        // not be counted as projection items.
        // `count(a, b)` is ONE projection item.
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys("select count(a, b) from t union select 1", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("number of columns")),
            "function-call commas must not inflate arity; got {diags:?}",
        );
    }

    #[test]
    fn compound_union_all_arity_mismatch_fires() {
        // `UNION ALL` keyword sequence is handled identically.
        let schema = schema_with("t", &[("a", Type::Int)]);
        let diags = diag_keys("select 1 union all select 1, 2", &schema);
        assert!(
            diags
                .iter()
                .any(|d| { d.contains("union") && d.contains("number of columns") }),
            "expected compound_arity_mismatch on UNION ALL; got {diags:?}",
        );
    }

    #[test]
    fn compound_three_leg_chain_emits_per_mismatch() {
        // Chained legs at the same depth — each set-op compares
        // its preceding leg against its following leg.
        let schema = schema_with("t", &[("a", Type::Int)]);
        let diags = diag_keys("select 1 union select 1, 2 union select 1", &schema);
        let mismatch_count = diags
            .iter()
            .filter(|d| d.contains("number of columns"))
            .count();
        assert_eq!(
            mismatch_count, 2,
            "expected two mismatch diagnostics; got {diags:?}",
        );
    }

    #[test]
    fn compound_arity_inside_cte_body_detected() {
        // CTE body at depth 1 — the arity-mismatch is detected
        // inside the parens, at the inner UNION.
        let schema = schema_with("t", &[("a", Type::Int)]);
        let diags = diag_keys(
            "with x as (select 1, 2 union select 1) select * from x",
            &schema,
        );
        assert!(
            diags
                .iter()
                .any(|d| { d.contains("union") && d.contains("number of columns") }),
            "expected compound_arity_mismatch inside CTE body; got {diags:?}",
        );
    }

    // ---- ADR-0032 §11.2 — cte_arity_mismatch ----

    #[test]
    fn cte_arity_mismatch_when_col_list_shorter() {
        // `WITH x(a, b) AS (SELECT 1, 2, 3)` — declared 2,
        // derived 3 → fires.
        let schema = schema_with("base", &[("id", Type::Int)]);
        let diags = diag_keys("with x (a, b) as (select 1, 2, 3) select * from x", &schema);
        assert!(
            diags.iter().any(|d| {
                d.contains("CTE `x`")
                    && d.contains("declares 2 columns")
                    && d.contains("body has 3")
            }),
            "expected cte_arity_mismatch (declared 2, actual 3); got {diags:?}",
        );
    }

    #[test]
    fn cte_arity_mismatch_when_col_list_longer() {
        // `WITH x(a, b, c) AS (SELECT 1)` — declared 3,
        // derived 1 → fires.
        let schema = schema_with("base", &[("id", Type::Int)]);
        let diags = diag_keys("with x (a, b, c) as (select 1) select * from x", &schema);
        assert!(
            diags.iter().any(|d| {
                d.contains("CTE `x`")
                    && d.contains("declares 3 columns")
                    && d.contains("body has 1")
            }),
            "expected cte_arity_mismatch (declared 3, actual 1); got {diags:?}",
        );
    }

    #[test]
    fn cte_arity_match_no_diagnostic() {
        // `WITH x(a, b) AS (SELECT 1, 2)` — matched arity, no
        // diagnostic.
        let schema = schema_with("base", &[("id", Type::Int)]);
        let diags = diag_keys("with x (a, b) as (select 1, 2) select * from x", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("declares")),
            "matched arity should not fire; got {diags:?}",
        );
    }

    #[test]
    fn cte_arity_no_col_list_no_diagnostic() {
        // No explicit col-list → no arity check (derived
        // columns are the canonical view).
        let schema = schema_with("base", &[("id", Type::Int)]);
        let diags = diag_keys("with x as (select 1, 2, 3) select * from x", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("declares")),
            "no col-list should suppress arity check; got {diags:?}",
        );
    }

    #[test]
    fn alias_in_inner_subquery_does_not_affect_outer_aliases() {
        // The inner `AS y` is inside parens (depth > 0) and
        // must not be collected into the outer leg's alias bag.
        // Outer `WHERE x` would otherwise (wrongly) match `y`
        // — here we test that the outer `WHERE y` is flagged
        // as unknown_column (not misplaced) because there is no
        // alias `y` in the OUTER leg's projection.
        let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]);
        let diags = diag_keys("select (select a as y from t) from t where y > 0", &schema);
        assert!(
            !diags.iter().any(|d| d.contains("misplaced")),
            "inner-subquery alias must not affect outer scope; got {diags:?}",
        );
    }
}

#[cfg(test)]
mod projection_before_from_tests {
    //! ADR-0032 §10.6 — projection-before-FROM correctness
    //! after the full walk. The 2d schema-existence pass's
    //! two-pass binding collection (gather all FROM bindings
    //! first, then resolve column refs) means the diagnostic
    //! verdict is already correct at end-of-walk:
    //!
    //! - A projection ident that resolves under the eventual
    //!   FROM scope produces no diagnostic.
    //! - A projection ident that does NOT resolve produces an
    //!   `unknown_column` diagnostic on its span — the renderer
    //!   then overlays this as an Error visual via the
    //!   `input_render.rs` diagnostic-overlay path, achieving
    //!   the user-visible effect §10.6 prescribes ("the
    //!   highlight snaps to the column class … or to the
    //!   unknown-identifier diagnostic").
    //!
    //! These tests pin the behavior so a future refactor can't
    //! silently regress it.

    use super::*;
    use crate::completion::{SchemaCache, TableColumn};
    use crate::dsl::types::Type;

    fn schema_with_table_and_columns() -> SchemaCache {
        let mut s = SchemaCache::default();
        s.tables.push("mytable".to_string());
        s.columns.push("real_col".to_string());
        s.columns.push("another_col".to_string());
        s.table_columns.insert(
            "mytable".to_string(),
            vec![
                TableColumn {
                    name: "real_col".to_string(),
                    user_type: Type::Text,
                    not_null: false,
                    has_default: false,
                },
                TableColumn {
                    name: "another_col".to_string(),
                    user_type: Type::Int,
                    not_null: false,
                    has_default: false,
                },
            ],
        );
        s
    }

    fn diagnostics_advanced(source: &str, schema: &SchemaCache) -> Vec<outcome::Diagnostic> {
        let mut ctx = context::WalkContext::with_schema(schema);
        ctx.mode = crate::mode::Mode::Advanced;
        let (result, _) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
        result.map_or_else(Vec::new, |r| r.diagnostics)
    }

    #[test]
    fn projection_before_from_resolves_via_eventual_from() {
        // `select real_col from mytable` — the projection
        // ident appears in the path BEFORE the FROM binding,
        // but the two-pass diagnostic resolves correctly
        // against the eventual scope. No diagnostic.
        let schema = schema_with_table_and_columns();
        let diags = diagnostics_advanced("select real_col from mytable", &schema);
        assert!(
            diags.is_empty(),
            "projection-before-FROM legit column must not be flagged; got {diags:?}",
        );
    }

    #[test]
    fn projection_before_from_flags_unknown_column() {
        // `select bogus_col from mytable` — bogus_col doesn't
        // belong to mytable. The diagnostic fires on the
        // projection ident's span; the renderer overlays this
        // as Error in `input_render.rs`.
        let schema = schema_with_table_and_columns();
        let diags = diagnostics_advanced("select bogus_col from mytable", &schema);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].severity, outcome::Severity::Error);
        // Span should cover `bogus_col` (offset 7..16).
        assert_eq!(diags[0].span, (7, 16));
        assert!(
            diags[0].message.contains("no such column"),
            "expected unknown_column wording; got {:?}",
            diags[0].message,
        );
    }

    #[test]
    fn multi_projection_before_from_flags_only_unknowns() {
        // `select real_col, bogus_col, another_col from mytable`
        // — only bogus_col flags; the two real ones resolve.
        let schema = schema_with_table_and_columns();
        let diags = diagnostics_advanced(
            "select real_col, bogus_col, another_col from mytable",
            &schema,
        );
        assert_eq!(
            diags.len(),
            1,
            "expected exactly one diagnostic; got {diags:?}",
        );
        assert!(diags[0].message.contains("bogus_col"));
    }

    #[test]
    fn projection_without_from_is_silent() {
        // `select c1, c2` — no FROM in scope at all. The
        // current behavior is to skip the bare-column check
        // entirely (avoid noise on `SELECT 1` style
        // expressions). This is documented in the
        // schema_existence pass.
        let schema = schema_with_table_and_columns();
        let diags = diagnostics_advanced("select c1, c2", &schema);
        assert!(diags.is_empty(), "no FROM → silent; got {diags:?}",);
    }
}

/// Sub-phase 3a — category-grouped, mode-aware dispatch
/// (ADR-0033 Amendment 1).
///
/// These exercise the dispatch mechanism end-to-end on a *smoke*
/// registry: a single shared entry word (`smk`) carrying a
/// `Simple` DSL node and an `Advanced` SQL node with
/// distinguishable tails (`dsltail` / `sqltail`). The dispatch
/// functions (`decide`, `walk_one_command`, `this_is_sql_result`)
/// are module-private; this child module reaches them via
/// `super::*`. The smoke nodes never enter the real `REGISTRY`,
/// so production dispatch is unaffected.
#[cfg(test)]
mod dispatch_3a_tests {
    use super::*;
    use crate::dsl::command::{AppCommand, Command};
    use crate::dsl::grammar::{CommandCategory, CommandNode, Node, ValidationError, Word};
    use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
    use crate::dsl::walker::outcome::MatchedPath;
    use crate::mode::Mode;

    // Distinct dummy commands so a test can tell which node a walk
    // committed to (the outcome alone doesn't distinguish them).
    fn dsl_builder(_: &MatchedPath, _: &str) -> Result<Command, ValidationError> {
        Ok(Command::App(AppCommand::Help { topic: None }))
    }
    fn sql_builder(_: &MatchedPath, _: &str) -> Result<Command, ValidationError> {
        Ok(Command::App(AppCommand::Quit))
    }

    static SMOKE_DSL: CommandNode = CommandNode {
        entry: Word::keyword("smk"),
        shape: Node::Word(Word::keyword("dsltail")),
        ast_builder: dsl_builder,
        help_id: None,
        hint_ids: &[],
        usage_ids: &[],
    };
    static SMOKE_SQL: CommandNode = CommandNode {
        entry: Word::keyword("smk"),
        shape: Node::Word(Word::keyword("sqltail")),
        ast_builder: sql_builder,
        help_id: None,
        hint_ids: &[],
        usage_ids: &[],
    };

    type Candidates = Vec<(usize, &'static CommandNode, CommandCategory)>;

    /// A shared entry word: both a DSL and a SQL node under `smk`.
    /// Listed SQL-first to prove `decide` partitions by category
    /// rather than relying on registry order.
    fn shared() -> Candidates {
        vec![
            (0, &SMOKE_SQL, CommandCategory::Advanced),
            (1, &SMOKE_DSL, CommandCategory::Simple),
        ]
    }

    /// A SQL-only entry word (no DSL fallback) — models `select`.
    fn sql_only() -> Candidates {
        vec![(0, &SMOKE_SQL, CommandCategory::Advanced)]
    }

    fn kw(input: &str) -> (usize, usize) {
        let start = skip_whitespace(input, 0);
        consume_ident(input, start).expect("entry word")
    }

    fn run_decide(input: &str, mode: Mode, cands: &Candidates) -> Decision {
        let (ks, ke) = kw(input);
        decide(input, ks, ke, cands, mode, None)
    }

    /// Mirror `walk`'s dispatch: decide, then either walk the
    /// committed node or build the "this is SQL" result. Returns
    /// the resulting outcome plus the committed command (if any).
    fn dispatch(input: &str, mode: Mode, cands: &Candidates) -> (WalkOutcome, Option<Command>) {
        let (ks, ke) = kw(input);
        let entry_text = &input[ks..ke];
        match decide(input, ks, ke, cands, mode, None) {
            Decision::Commit { idx, node } => {
                let mut ctx = context::WalkContext::new();
                ctx.mode = mode;
                let (res, cmd) = walk_one_command(input, input, ks, ke, idx, node, &mut ctx);
                (res.outcome, cmd)
            }
            Decision::ThisIsSql { primary } => (
                this_is_sql_result(entry_text, primary, ks, ke).outcome,
                None,
            ),
        }
    }

    fn committed_node(input: &str, mode: Mode, cands: &Candidates) -> &'static CommandNode {
        match run_decide(input, mode, cands) {
            Decision::Commit { node, .. } => node,
            Decision::ThisIsSql { .. } => panic!("expected Commit, got ThisIsSql for {input:?}"),
        }
    }

    // ---- Exit-gate case 1: Simple + DSL input → DSL match ------

    #[test]
    fn simple_mode_dsl_input_matches_dsl() {
        let cands = shared();
        assert!(
            std::ptr::eq(
                committed_node("smk dsltail", Mode::Simple, &cands),
                &SMOKE_DSL
            ),
            "simple mode must commit the DSL node for DSL input",
        );
        let (outcome, cmd) = dispatch("smk dsltail", Mode::Simple, &cands);
        assert!(
            matches!(outcome, WalkOutcome::Match { .. }),
            "got {outcome:?}"
        );
        assert_eq!(cmd, Some(Command::App(AppCommand::Help { topic: None })));
    }

    // ---- Exit-gate case 2: Advanced + SQL input → SQL match ----

    #[test]
    fn advanced_mode_sql_input_matches_sql() {
        let cands = shared();
        assert!(
            std::ptr::eq(
                committed_node("smk sqltail", Mode::Advanced, &cands),
                &SMOKE_SQL
            ),
            "advanced mode must commit the SQL node for SQL input",
        );
        let (outcome, cmd) = dispatch("smk sqltail", Mode::Advanced, &cands);
        assert!(
            matches!(outcome, WalkOutcome::Match { .. }),
            "got {outcome:?}"
        );
        assert_eq!(cmd, Some(Command::App(AppCommand::Quit)));
    }

    // ---- Exit-gate case 3: Simple + SQL-shaped input on a SHARED
    //      word commits the DSL node (ADR-0033 Amendment 3) --------

    #[test]
    fn simple_mode_shared_word_commits_dsl_even_for_sql_input() {
        // A shared word (DSL + SQL) in simple mode always commits its
        // DSL candidate, even when the input matches only the SQL
        // tail. The DSL grammar then rejects the SQL-only tail with a
        // normal parse error (a `Mismatch`), surfacing the actionable
        // DSL detail; the "Trying to write SQL?" pointer (ADR-0033
        // Amendment 3, gated per Amendment 5 on the line being *valid*
        // in advanced mode — verdict `None`) is added at the rendering
        // layer, not here. "This is SQL" as a
        // dispatch outcome is reserved for entry words with no DSL
        // form (see `simple_mode_sql_only_entry_word_is_this_is_sql`).
        let cands = shared();
        assert!(
            std::ptr::eq(
                committed_node("smk sqltail", Mode::Simple, &cands),
                &SMOKE_DSL
            ),
            "simple mode must commit the DSL node for a shared word",
        );
        let (outcome, cmd) = dispatch("smk sqltail", Mode::Simple, &cands);
        assert!(
            matches!(outcome, WalkOutcome::Mismatch { .. }),
            "the DSL grammar rejects the SQL-only tail; got {outcome:?}",
        );
        assert_eq!(cmd, None);
    }

    /// A pure SQL-only entry word (no DSL node, like `select`) in
    /// simple mode also yields the "this is SQL" hint — the
    /// behaviour the old whole-command `is_advanced_only` gate
    /// produced, now via `decide`.
    #[test]
    fn simple_mode_sql_only_entry_word_is_this_is_sql() {
        let cands = sql_only();
        let (outcome, _) = dispatch("smk sqltail", Mode::Simple, &cands);
        match outcome {
            WalkOutcome::ValidationFailed { error, .. } => {
                assert_eq!(error.message_key, "advanced_mode.sql_in_simple");
            }
            other => panic!("expected ValidationFailed, got {other:?}"),
        }
    }

    // ---- Exit-gate case 4 / 5: Advanced + DSL-only input →
    //      DSL match via fallback (the R1-equivalent invariant) --

    #[test]
    fn advanced_mode_dsl_input_falls_back_to_dsl() {
        // `dsltail` matches the DSL node but NOT the SQL node.
        // Advanced mode tries SQL first; it must fall back to the
        // DSL node rather than surfacing the SQL node's failure.
        let cands = shared();
        assert!(
            std::ptr::eq(
                committed_node("smk dsltail", Mode::Advanced, &cands),
                &SMOKE_DSL
            ),
            "advanced mode must fall back to DSL when SQL doesn't match",
        );
        let (outcome, cmd) = dispatch("smk dsltail", Mode::Advanced, &cands);
        assert!(
            matches!(outcome, WalkOutcome::Match { .. }),
            "got {outcome:?}"
        );
        assert_eq!(cmd, Some(Command::App(AppCommand::Help { topic: None })));
    }

    /// In advanced mode a non-shared DSL entry word (no Advanced
    /// candidate) still commits the single DSL node.
    #[test]
    fn advanced_mode_dsl_only_entry_word_commits_dsl() {
        let cands: Candidates = vec![(0, &SMOKE_DSL, CommandCategory::Simple)];
        assert!(std::ptr::eq(
            committed_node("smk dsltail", Mode::Advanced, &cands),
            &SMOKE_DSL,
        ));
        let (outcome, _) = dispatch("smk dsltail", Mode::Advanced, &cands);
        assert!(
            matches!(outcome, WalkOutcome::Match { .. }),
            "got {outcome:?}"
        );
    }
}

#[cfg(test)]
mod order_by_expected_set_tests {
    //! F5 (handoff 30 §3.3) — when ORDER BY has consumed `order
    //! by` and is awaiting a sort item, the expected set must not
    //! be padded with clause keywords belonging to clauses that
    //! sit *before* ORDER BY (the FROM's JOIN options, WHERE /
    //! GROUP BY / HAVING, set-ops). Those optionals were skipped
    //! earlier in the seq; once ORDER BY commits past them they
    //! are no longer valid continuations at the cursor.
    use super::*;
    use crate::dsl::walker::outcome::Expectation;
    use crate::mode::Mode;

    fn expected_words(source: &str) -> Vec<&'static str> {
        expected_at_input_in_mode(source, Mode::Advanced)
            .iter()
            .filter_map(|e| match e {
                Expectation::Word(w) => Some(*w),
                _ => None,
            })
            .collect()
    }

    #[test]
    fn order_by_excludes_preceding_clause_keywords() {
        let words = expected_words("select Name from T order by ");
        let preceding_clause_kw = [
            "where",
            "group",
            "having",
            "join",
            "union",
            "intersect",
            "except",
            "left",
            "right",
            "full",
            "cross",
            "inner",
            "as",
        ];
        let leaked: Vec<&str> = preceding_clause_kw
            .iter()
            .copied()
            .filter(|k| words.contains(k))
            .collect();
        assert!(
            leaked.is_empty(),
            "ORDER BY expected set leaked preceding-clause keywords \
             {leaked:?}; full word set: {words:?}",
        );
    }

    #[test]
    fn order_by_after_sort_item_offers_direction() {
        // After a complete sort item (`order by Name`) the
        // sort-direction keywords are valid continuations.
        // walk_repeated used to discard the item's trailing
        // optionals, so completion offered neither.
        let words = expected_words("select Name from T order by Name ");
        assert!(words.contains(&"asc"), "expected `asc`; got {words:?}");
        assert!(words.contains(&"desc"), "expected `desc`; got {words:?}");
        // The separator is deliberately not surfaced (user choice).
        let full = expected_at_input_in_mode("select Name from T order by Name ", Mode::Advanced);
        assert!(
            !full.iter().any(|e| matches!(e, Expectation::Punct(','))),
            "`,` separator should not be surfaced; got {full:?}",
        );
    }

    #[test]
    fn order_by_still_offers_a_sort_item() {
        // Guard against over-correction: the legitimate sort-item
        // continuation (a column identifier) must survive the
        // pending-skipped suppression.
        let expected = expected_at_input_in_mode("select Name from T order by ", Mode::Advanced);
        assert!(
            expected
                .iter()
                .any(|e| matches!(e, Expectation::Ident { .. } | Expectation::NumberLit)),
            "ORDER BY must still offer a sort item; got {expected:?}",
        );
    }
}

#[cfg(test)]
mod concept_hint_tests {
    //! Issue #37 / ADR clause-concept-hints — `concept_topic_at_cursor`.
    //!
    //! The cursor is positioned by `find`-ing a substring so the
    //! tests don't hard-code byte offsets. "inside" means the cursor
    //! sits within already-typed clause text (the cursor-inside
    //! semantics, not merely the slot boundary).
    use super::concept_topic_at_cursor;
    use crate::mode::Mode;

    /// Cursor at the START of `needle` within `src`.
    fn at(src: &str, needle: &str) -> usize {
        src.find(needle).expect("needle present")
    }
    /// Cursor in the MIDDLE of `needle` within `src` (inside typed text).
    fn inside(src: &str, needle: &str) -> usize {
        at(src, needle) + needle.len() / 2
    }

    fn topic(src: &str, cursor: usize, mode: Mode) -> Option<&'static str> {
        concept_topic_at_cursor(src, cursor, None, mode)
    }

    #[test]
    fn referential_actions_at_on_delete_boundary() {
        let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
        assert_eq!(
            topic(s, at(s, "on delete"), Mode::Simple),
            Some("referential_actions"),
        );
    }

    #[test]
    fn referential_actions_inside_already_typed_action() {
        // Cursor parked inside the finished `cascade` — the
        // cursor-inside case the slot-boundary mechanism can't serve.
        let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
        assert_eq!(
            topic(s, inside(s, "cascade"), Mode::Simple),
            Some("referential_actions"),
        );
    }

    #[test]
    fn cardinality_one_to_many_on_marker() {
        let s = "add 1:n relationship from Customers.id to Orders.cid";
        assert_eq!(
            topic(s, inside(s, "1:n"), Mode::Simple),
            Some("cardinality_one_to_many"),
        );
    }

    #[test]
    fn cardinality_many_to_many_on_marker() {
        let s = "create m:n relationship from Students to Courses";
        assert_eq!(
            topic(s, inside(s, "m:n"), Mode::Simple),
            Some("cardinality_many_to_many"),
        );
    }

    #[test]
    fn primary_key_in_with_pk_simple() {
        let s = "create table Orders with pk Code(text)";
        assert_eq!(
            topic(s, inside(s, "with pk"), Mode::Simple),
            Some("primary_key"),
        );
    }

    #[test]
    fn primary_key_in_create_table_constraint_advanced() {
        let s = "create table Orders (id int primary key)";
        assert_eq!(
            topic(s, inside(s, "primary key"), Mode::Advanced),
            Some("primary_key"),
        );
    }

    #[test]
    fn unique_constraint_simple() {
        let s = "create table Ages with pk age(int) unique";
        assert_eq!(topic(s, inside(s, "unique"), Mode::Simple), Some("unique"));
    }

    #[test]
    fn unique_constraint_advanced() {
        let s = "create table Orders (Code text unique)";
        assert_eq!(
            topic(s, inside(s, "unique"), Mode::Advanced),
            Some("unique")
        );
    }

    #[test]
    fn check_constraint_simple() {
        let s = "create table Ages with pk age(int) check (age > 0)";
        assert_eq!(topic(s, inside(s, "check"), Mode::Simple), Some("check"));
    }

    #[test]
    fn check_constraint_advanced() {
        let s = "create table Orders (Qty int check (Qty > 0))";
        assert_eq!(topic(s, inside(s, "check"), Mode::Advanced), Some("check"));
    }

    #[test]
    fn foreign_key_references_advanced() {
        let s = "create table Orders (cid int references Customers(id))";
        assert_eq!(
            topic(s, inside(s, "references"), Mode::Advanced),
            Some("foreign_key"),
        );
    }

    #[test]
    fn nested_on_delete_inside_references_resolves_innermost() {
        // `references … on delete cascade` nests referential_actions
        // inside foreign_key; cursor on `on delete` → inner concept.
        let s = "create table Orders (cid int references Customers(id) on delete cascade)";
        assert_eq!(
            topic(s, at(s, "on delete"), Mode::Advanced),
            Some("referential_actions"),
        );
        // …but a cursor on `references` → the outer foreign_key.
        assert_eq!(
            topic(s, inside(s, "references"), Mode::Advanced),
            Some("foreign_key"),
        );
    }

    #[test]
    fn cursor_outside_any_clause_is_none() {
        let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
        // On the entry word `add` — no concept clause there.
        assert_eq!(topic(s, at(s, "add"), Mode::Simple), None);
    }

    #[test]
    fn empty_input_is_none() {
        assert_eq!(topic("", 0, Mode::Simple), None);
    }
}
