//! Keyword + identifier completion for ambient typing
//! assistance (ADR-0022 stage 8).
//!
//! This stage 1 cut covers keyword completion only. Identifier
//! completion (schema-aware) lands in a follow-on substage
//! that builds on this scaffolding.
//!
//! Concept: `candidates_at_cursor(input, cursor)` returns a
//! `Completion` describing what would happen if the user
//! pressed Tab right now — the byte range to replace, the
//! partial prefix already typed, and the list of fitting
//! candidates. Empty / no-candidate cases return `None`.
//!
//! The cycling memo (`LastCompletion` on `App`) lives in
//! `app.rs`; this module owns the candidate computation.

use crate::dsl::grammar::IdentSource;
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::dsl::types::Type;
use crate::dsl::walker::outcome::Expectation;
use crate::dsl::{ParseError, parse_command};
use crate::mode::Mode;

/// Composite literal candidates whose lexed shape is more than
/// one token but which the user types as a single fluent piece.
/// Pairs of (walker-expected-literal, full-composite-text).
///
/// When the walker reports `Expectation::Literal(opener)` or
/// `Expectation::Word(opener)` at the cursor, the engine surfaces the
/// full composite text as a Tab candidate. Used for multi-token
/// fragments the user thinks of as a single phrase:
///
/// - `1:n` — the opener for `add 1:n relationship`.
/// - `m:n` — the opener for `create m:n relationship` (ADR-0045).
/// - `double precision` — the lone two-word SQL type alias
///   (ADR-0035 §6.3; the grammar has a dedicated branch so the per-word
///   `Ident` validator never has to make sense of `double` alone).
///   Surfacing it as a composite stops bare `double` from appearing in
///   the type candidate list alongside `int`/`text`/etc. (issue #5).
///   Source 1's keyword filter drops openers that appear here so the
///   composite replaces the bare opener rather than appearing
///   alongside it.
const COMPOSITE_CANDIDATES: &[(&str, &str)] =
    &[("1", "1:n"), ("m", "m:n"), ("double", "double precision")];

/// Per-project schema lookup cache (ADR-0022 §9, ADR-0024 §Phase D).
///
/// Held by `App::schema_cache` and consulted by the completion
/// engine for identifier slots and by the walker for schema-aware
/// value-slot dispatch (Phase D full). Empty by default; the
/// runtime refreshes on project load and after successful DDL.
#[derive(Debug, Clone, Default)]
pub struct SchemaCache {
    pub tables: Vec<String>,
    pub columns: Vec<String>,
    pub relationships: Vec<String>,
    pub indexes: Vec<String>,
    /// Per-table column metadata with user-facing types
    /// (ADR-0024 §Phase D). Keyed by table name; lookup is
    /// case-insensitive in `columns_for_table` so the walker
    /// can resolve `Customers` regardless of how it was typed.
    pub table_columns: std::collections::HashMap<String, Vec<TableColumn>>,
    /// Per-table user indexes (ADR-0025). Keyed by table name; drives
    /// the nested tables/indexes items panel (S2). Each entry carries
    /// the index's uniqueness so the panel can mark a UNIQUE index
    /// (ADR-0035 §4d).
    pub table_indexes: std::collections::HashMap<String, Vec<IndexEntry>>,
}

/// One per-table index for the items panel (ADR-0025 / ADR-0035 §4d):
/// its name and whether it is a UNIQUE index.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexEntry {
    pub name: String,
    pub unique: bool,
}

/// One column's user-facing type info, scoped to a table
/// (ADR-0024 §Phase D, §WalkContext).
///
/// `not_null` / `has_default` (ADR-0033 §8.3, sub-phase 3i) let the
/// walker pre-flight a `not_null_missing` WARNING — an `INSERT`
/// whose column list omits a required column. They default to
/// `false`, so callers/tests that don't care construct via
/// [`TableColumn::new`].
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TableColumn {
    pub name: String,
    pub user_type: crate::dsl::types::Type,
    /// The column is declared `NOT NULL` (a PK column is also
    /// effectively not-null; the cache builder records that).
    pub not_null: bool,
    /// The column has a `DEFAULT` — so omitting it on `INSERT` is
    /// fine even when `not_null`.
    pub has_default: bool,
}

impl TableColumn {
    /// A column with no NOT-NULL / default info — the common case
    /// for callers and tests that don't exercise ADR-0033 §8.3.
    #[must_use]
    pub fn new(name: impl Into<String>, user_type: crate::dsl::types::Type) -> Self {
        Self {
            name: name.into(),
            user_type,
            not_null: false,
            has_default: false,
        }
    }
}

impl SchemaCache {
    /// Lookup the candidate list for an identifier slot.
    /// Sources that don't read from the schema (`NewName`,
    /// `Types`, `Free`) return `&[]`.
    #[must_use]
    pub fn for_source(&self, source: IdentSource) -> &[String] {
        match source {
            IdentSource::Tables => &self.tables,
            IdentSource::Columns => &self.columns,
            IdentSource::Relationships => &self.relationships,
            IdentSource::Indexes => &self.indexes,
            // Curated / invented sources never come from the schema
            // cache — `Generators` candidates are supplied separately
            // from the `seed` vocabulary (ADR-0048 D9).
            IdentSource::NewName
            | IdentSource::Types
            | IdentSource::Generators
            | IdentSource::Free => &[],
        }
    }

    /// Per-table column metadata lookup. Case-insensitive on
    /// the table name so the walker can resolve identifiers
    /// the user typed in either case (ADR-0009 — keywords are
    /// case-insensitive, identifiers preserve case; this helper
    /// matches the walker's case-insensitive entry-word lookup
    /// rather than the strict-case `tables` Vec).
    ///
    /// Returns `None` when no table matches; an empty `Vec`
    /// when the table exists but has no columns (rare —
    /// CSV-empty tables still carry PK columns in metadata).
    #[must_use]
    pub fn columns_for_table(&self, table: &str) -> Option<&[TableColumn]> {
        self.table_columns
            .iter()
            .find(|(name, _)| name.eq_ignore_ascii_case(table))
            .map(|(_, cols)| cols.as_slice())
    }
}

/// What the grammar would accept at the end of `leading`,
/// expressed as structured `Expectation`s direct from the
/// walker (ADR-0024 §architecture, Phase F walker-driven
/// completion). Replaces the `ParseError`-string round-trip.
fn expected_at(leading: &str, mode: Mode) -> Vec<Expectation> {
    crate::dsl::walker::expected_at_input_in_mode(leading, mode)
}

/// A single Tab-insertable item with its source (so the
/// renderer can colour keywords differently from schema
/// identifiers, and so the ordering can group keywords first).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
    pub text: String,
    pub kind: CandidateKind,
    /// Source-mode classification (ADR-0035 §4i e). `Both` (neutral)
    /// except for the merged continuations of a shared entry word, where
    /// the hint UI colours `Advanced`/`Simple` differently — but only
    /// when the candidate list actually mixes modes.
    pub mode: ModeClass,
}

/// Re-ranker for a freshly-computed candidate list (ADR-0024
/// §ranker-layer).
///
/// The grammar tree declares *what's valid*; the ranker decides
/// *what's likely useful first*. Lives outside the trie so
/// frequency-based ranking, content-aware priors (e.g. `Email`
/// → text first), and recency hooks can plug in without
/// touching grammar declarations.
///
/// Default is `identity_ranker` — declaration order from the
/// grammar tree is preserved.
pub type Ranker = fn(Vec<Candidate>) -> Vec<Candidate>;

/// Identity ranker: returns its input unchanged.
#[must_use]
pub const fn identity_ranker(candidates: Vec<Candidate>) -> Vec<Candidate> {
    candidates
}

/// Which input mode(s) a shared-entry-word continuation belongs to.
///
/// (ADR-0035 §4i d/e.) Computed only where a shared entry word's
/// candidate nodes are merged; everywhere else a continuation is `Both`
/// (neutral). Drives the contiguous colour-block ordering (and, in the
/// UI, the colour) when a candidate list mixes modes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModeClass {
    /// Valid in both simple (DSL) and advanced (SQL) mode — neutral.
    Both,
    /// Advanced/SQL-only continuation.
    Advanced,
    /// Simple/DSL-only continuation.
    Simple,
}

impl ModeClass {
    /// Block-ordering key: `Both` (0) → `Advanced` (1) → `Simple` (2), so
    /// each colour reads as one contiguous block (user-confirmed order).
    #[must_use]
    pub const fn block_order(self) -> u8 {
        match self {
            Self::Both => 0,
            Self::Advanced => 1,
            Self::Simple => 2,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateKind {
    /// One of the parser's expected keywords.
    Keyword,
    /// A schema entity (table, column, relationship).
    Identifier,
    /// A `--name`-style flag. Coloured with `tok_flag` so the
    /// hint matches the way it'll render in the input pane.
    Flag,
    /// A single-char punctuation token the walker expects next
    /// (e.g. `(` at the start of `insert into T (cols)`). Used
    /// to surface branching alternatives the user might not
    /// otherwise discover — at `insert into Orders ` the walker
    /// expects either `values` or `(`, and surfacing both makes
    /// the Form A path discoverable.
    Punct,
    /// A curated SQL function name (ADR-0022 Amendment 6, issue
    /// #15) offered at a `sql_expr_ident` slot. Coloured with
    /// `tok_function` so a learner can tell `sum` / `upper` apart
    /// from a column reference or a clause keyword. The set is
    /// `crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS`.
    Function,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Completion {
    /// Byte range in `input` to be replaced when a candidate
    /// is accepted. Equal `(cursor, cursor)` when the user is
    /// at a token boundary (no partial prefix).
    pub replaced_range: (usize, usize),
    /// Partial prefix the user has typed at the cursor. Empty
    /// when the cursor is at a token boundary.
    pub partial_prefix: String,
    /// Fitting candidates, ordered schema-identifiers-first then
    /// keywords, alphabetised within each group, deduplicated.
    pub candidates: Vec<Candidate>,
}

/// Compute what would happen if the user pressed Tab right
/// now (ADR-0022 stage 8). `None` means there is nothing to
/// complete (no candidates fit / cursor at end of complete
/// input).
///
/// Candidates are built from two sources:
/// - **Keywords**: the parser's expected-set entries that are
///   bare keywords (excluding punctuation and descriptive
///   labels per ADR-0022 §10).
/// - **Schema identifiers**: when the parser's expected-set
///   includes an `IdentSource::expected_label()`, the matching
///   schema list from `cache` is added (skipping the `NewName`
///   slot — the user invents those).
///
/// Both sources are filtered by the typed partial prefix
/// (case-insensitive starts-with), combined, sorted, and
/// deduplicated.
#[must_use]
pub fn candidates_at_cursor(input: &str, cursor: usize, cache: &SchemaCache) -> Option<Completion> {
    candidates_at_cursor_in_mode(input, cursor, cache, Mode::Advanced)
}

/// Mode-aware [`candidates_at_cursor`] (ADR-0030 §2). Tab in
/// simple mode no longer offers advanced-mode-only commands;
/// the walker's mode gate flows through the probe inside.
#[must_use]
pub fn candidates_at_cursor_in_mode(
    input: &str,
    cursor: usize,
    cache: &SchemaCache,
    mode: Mode,
) -> Option<Completion> {
    candidates_at_cursor_with_in_mode(input, cursor, cache, identity_ranker, mode)
}

/// Variant of [`candidates_at_cursor`] that applies a custom
/// `Ranker` to the final candidate list (ADR-0024 §ranker-layer).
/// The default `candidates_at_cursor` calls this with
/// `identity_ranker`.
#[must_use]
pub fn candidates_at_cursor_with(
    input: &str,
    cursor: usize,
    cache: &SchemaCache,
    ranker: Ranker,
) -> Option<Completion> {
    candidates_at_cursor_with_in_mode(input, cursor, cache, ranker, Mode::Advanced)
}

/// Mode-aware [`candidates_at_cursor_with`].
#[must_use]
pub fn candidates_at_cursor_with_in_mode(
    input: &str,
    cursor: usize,
    cache: &SchemaCache,
    ranker: Ranker,
    mode: Mode,
) -> Option<Completion> {
    let cursor = cursor.min(input.len());

    // Walk backward from the cursor over identifier-shaped
    // characters to find the partial prefix the user is mid-typing.
    let bytes = input.as_bytes();
    let mut start = cursor;
    while start > 0 {
        let prev = bytes[start - 1];
        if prev.is_ascii_alphanumeric() || prev == b'_' {
            start -= 1;
        } else {
            break;
        }
    }

    // Flag-aware extension. The plain walk above stops at `-`, so a
    // flag the user is mid-typing (`-`, `--`, `--all`, `--create-fk`)
    // leaves an *empty* partial sitting just after the dash(es) — which
    // made the engine offer every keyword (a `-` prefix-matches nothing,
    // so the empty-prefix path let `on` through) and, worse, replace an
    // empty range so accepting produced `-on` / `---create-fk`. When a
    // dash-prefixed token sits at a word boundary AND a flag is actually
    // expected here, treat the whole dash-run-plus-body as the partial so
    // it is matched and replaced wholesale. The "flag is expected" gate
    // (one cheap probe on the pre-dash prefix) keeps a signed number /
    // minus (`where x = -5`) from being mis-read as a flag.
    {
        let mut run = cursor;
        while run > 0 {
            let p = bytes[run - 1];
            if p.is_ascii_alphanumeric() || p == b'_' || p == b'-' {
                run -= 1;
            } else {
                break;
            }
        }
        let word_boundary = run == 0 || bytes[run - 1].is_ascii_whitespace();
        if run < cursor && bytes[run] == b'-' && word_boundary && run < start {
            let pre = crate::dsl::walker::completion_probe_in_mode(&input[..run], cache, mode);
            if pre
                .expected
                .iter()
                .any(|e| matches!(e, Expectation::Flag(_)))
            {
                start = run;
            }
        }
    }

    let partial_prefix = input[start..cursor].to_string();
    let leading = &input[..start];

    // When the full input already parses, the cursor is at
    // the end of a "complete enough" command — but the leading
    // slice still reports an expected set (the words the user
    // just finished typing, or optional-suffix continuations).
    // We let the completion engine run normally, then below
    // filter out candidates that exactly equal the partial
    // prefix the user already typed: it's not useful to suggest
    // `pk` at the end of `create table T with pk`. The
    // optional-suffix case (`save ` → `as`) is preserved
    // because there `partial_prefix` is empty.
    //
    // Parse *with the schema* (not the schemaless `parse_command_in_mode`)
    // so "complete" means the schema-aware grammar accepts it — the same
    // consistency fix as issue #2's ambient-hint fallback. A schemaless
    // parse can report a type-/arity-wrong tuple as complete and wrongly
    // gate the drop on. Today the two agree at every position this drop
    // can fire (the drop only removes a re-offered keyword == partial,
    // and schema divergence is confined to value type/arity, which never
    // coincides — see issue #18); schema-aware is the principled, latent-
    // bug-proof choice and can only ever *retain* a candidate the
    // schemaless gate would have dropped, never the reverse.
    let input_parses_complete = parse_command_with_schema_in_mode(input, cache, mode).is_ok();

    // Schema-aware probe: one walk yields both the expected set
    // and the table-context snapshot (ADR-0024 §Phase D
    // §column-narrowing). The engine reads
    // `current_table_columns` to narrow column candidates to the
    // active table rather than the flat `cache.columns` (which
    // unions every table's columns). ADR-0030 §2: the probe runs
    // with the active mode so simple-mode users don't see SQL
    // commands offered.
    let probe = crate::dsl::walker::completion_probe_in_mode(leading, cache, mode);

    // ADR-0032 §10.6 follow-up — the "edit an existing query"
    // workflow: the user goes back inside a projection list to
    // type a column, but the FROM clause is already typed
    // *after* the cursor. The leading-only walk sees an empty
    // from_scope and the column source falls back to the global
    // column list — noisy. A second walk on the FULL input
    // populates from_scope_stack[0] with the trailing FROM's
    // bindings, which we can narrow against. The cost is one
    // extra walk on each Tab press; gated on the leading walk
    // having produced no from_scope (so the common case where
    // the cursor is past FROM pays nothing).
    let lookahead_probe = if probe.from_scope.is_empty()
        && probe.cte_bindings.is_empty()
        && input.len() > leading.len()
    {
        let direct = crate::dsl::walker::completion_probe_in_mode(input, cache, mode);
        if direct.from_scope.is_empty() && direct.cte_bindings.is_empty() {
            // The slot at the cursor is empty/incomplete — e.g. the
            // projection list of `select <cursor> from T` after the
            // user deleted `*` — so the full-input walk never
            // reached FROM and recovered no scope. Repair by
            // inserting a neutral expression placeholder at the
            // cursor and re-walking, so the trailing FROM/CTE scope
            // is recovered for column narrowing (ADR-0032 §10.6).
            // Only the repaired walk's `from_scope` / `cte_bindings`
            // are consumed (table + columns), so the inserted token
            // doesn't perturb the expected set, which comes from the
            // leading probe.
            let mut repaired = String::with_capacity(input.len() + 2);
            repaired.push_str(&input[..start]);
            repaired.push_str("1 ");
            repaired.push_str(&input[start..]);
            let repaired_probe =
                crate::dsl::walker::completion_probe_in_mode(&repaired, cache, mode);
            if repaired_probe.from_scope.is_empty() && repaired_probe.cte_bindings.is_empty() {
                Some(direct)
            } else {
                Some(repaired_probe)
            }
        } else {
            Some(direct)
        }
    } else {
        None
    };

    // Resolution scope = leading probe's scope first; fall back
    // to the look-ahead probe when the leading walk produced
    // nothing. The leading walk has precedence so a cursor
    // inside a subquery's projection (where the inner FROM is
    // local to that subquery's frame) doesn't get mis-narrowed
    // by the outer SELECT's bindings.
    let resolution_from_scope: &[crate::dsl::walker::context::TableBinding] =
        if !probe.from_scope.is_empty() {
            &probe.from_scope
        } else {
            lookahead_probe
                .as_ref()
                .map_or(&[][..], |la| &la.from_scope[..])
        };
    let resolution_cte_bindings: &[crate::dsl::walker::context::CteBinding] =
        if !probe.cte_bindings.is_empty() {
            &probe.cte_bindings
        } else {
            lookahead_probe
                .as_ref()
                .map_or(&[][..], |la| &la.cte_bindings[..])
        };

    // For unqualified column completion, prefer the leading
    // walk's `current_table_columns`; fall back to "the union of
    // the look-ahead from_scope's bindings' columns" when leading
    // produced no in-scope columns. Phase-1 DSL paths unaffected.
    let lookahead_union_columns: Vec<TableColumn> = if probe.current_table_columns.is_none() {
        let mut out: Vec<TableColumn> = Vec::new();
        for binding in resolution_from_scope {
            for col in &binding.columns {
                if !out.iter().any(|c| c.name.eq_ignore_ascii_case(&col.name)) {
                    out.push(col.clone());
                }
            }
        }
        out
    } else {
        Vec::new()
    };
    let lookahead_slice: Option<&[TableColumn]> = if lookahead_union_columns.is_empty() {
        None
    } else {
        Some(lookahead_union_columns.as_slice())
    };
    let current_table_columns: Option<&[TableColumn]> =
        probe.current_table_columns.as_deref().or(lookahead_slice);

    // ADR-0032 §10.5 — qualified-prefix completion. When the
    // cursor sits immediately after `<ident> .` (ignoring
    // whitespace), narrow `IdentSource::Columns` candidates to
    // that qualifier's binding columns alone. The qualifier
    // resolves against the active `from_scope` (alias matches
    // first, then table names), falling back to `cte_bindings`
    // for `cte_alias.|` shapes. Unresolved qualifier → empty
    // column list (the structural error path surfaces the
    // unresolved-prefix message).
    let prefix_qualifier = peek_back_qualifier(input, start);
    let qualified_columns: Option<Vec<String>> = prefix_qualifier.as_ref().map(|q| {
        // ADR-0033 §9: `excluded.|` inside an `INSERT … ON
        // CONFLICT … DO UPDATE` completes to the target table's
        // columns — `excluded` mirrors the would-be-inserted row.
        // The target's columns are the INSERT's
        // `current_table_columns` (set by the target-table slot).
        // The diagnostic pass enforces the strict DO-UPDATE
        // byte-range; completion is the softer surface and offers
        // the columns whenever the INSERT target is in hand.
        if q.eq_ignore_ascii_case("excluded")
            && let Some(cols) = current_table_columns
        {
            cols.iter().map(|c| c.name.clone()).collect()
        } else {
            resolve_qualifier_columns_in(q, resolution_from_scope, resolution_cte_bindings, cache)
        }
    });

    let expected = if probe.expected.is_empty() {
        expected_at(leading, mode)
    } else {
        probe.expected.clone()
    };
    if expected.is_empty() {
        return None;
    }

    // Value-literal slot: at an empty-prefix position the only
    // candidates we'd surface are `null`/`true`/`false` (the
    // keyword literals — number / string-literal slots are
    // descriptive labels, not Tab candidates). Surfacing those
    // three is actively misleading — the user usually wants a
    // number, a quoted string, or a date. Suppress so the
    // ambient_hint ladder falls through to a prose hint with
    // format examples instead.
    //
    // EXCEPT when the same expected set also contains a
    // schema-listable Ident expectation — that's the ambiguous
    // position at `insert into T (` where Form A (column list)
    // and Form C (bare value list) both apply. There the user
    // has actionable column candidates that shouldn't be
    // hidden by the value-literal suppression.
    let has_schema_ident = expected.iter().any(|e| {
        matches!(
            e,
            Expectation::Ident { source, .. } if source.completes_from_schema()
        )
    });
    // A slot the grammar explicitly marked `ProseOnly`
    // (`Node::Hinted`) suppresses its keyword candidates
    // regardless of `has_schema_ident`. The WHERE-expression
    // operand is exactly this case: it accepts a column
    // reference *and* a value literal, so the signature
    // heuristic alone would surface the misleading
    // `null`/`true`/`false` trio (ADR-0026 §8).
    let prose_only_slot = matches!(
        probe.pending_hint_mode,
        Some(crate::dsl::grammar::HintMode::ProseOnly(_))
    );
    if partial_prefix.is_empty()
        && (prose_only_slot || (is_value_literal_signature(&expected) && !has_schema_ident))
    {
        return None;
    }

    let lowered_prefix = partial_prefix.to_lowercase();
    let matches_prefix = |s: &str| s.to_lowercase().starts_with(&lowered_prefix);

    // Source 1: keyword candidates direct from the walker's
    // expected set. `Word(primary)` and `Literal(s)` both
    // surface here; we keep only the alphabetic ones —
    // single-digit literals like `1` go through the composite
    // pipeline below, and punct never surfaces as a candidate.
    // Declaration order is preserved (matches the canonical
    // command shape, e.g. `to` before `table` for
    // `add column [to] [table] …`).
    //
    // Composite openers (e.g. `double` for `double precision`) are
    // filtered out here so Source 1.6 (the composite pipeline) can
    // surface the full multi-word candidate without the bare opener
    // also appearing alongside (issue #5).
    let mut keywords: Vec<String> = expected
        .iter()
        .filter_map(|e| match e {
            Expectation::Word(w) | Expectation::Literal(w) => Some(*w),
            _ => None,
        })
        .filter(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_alphabetic()))
        .filter(|w| !COMPOSITE_CANDIDATES.iter().any(|(opener, _)| opener == w))
        .map(str::to_string)
        .filter(|name| matches_prefix(name))
        .collect();
    let mut seen_kw = std::collections::HashSet::new();
    keywords.retain(|k| seen_kw.insert(k.clone()));

    // (ADR-0035 §4i e) Block-order the keyword continuations by
    // mode-class when a shared entry word merged simple + advanced forms,
    // so the colours read as contiguous blocks Both → Advanced → Simple
    // (user-confirmed order). A single-mode list (all `Both`) is left in
    // its declaration order. `sort_by_key` is stable, preserving the
    // intra-block order.
    let kw_mode = |kw: &str| -> ModeClass {
        probe
            .expected
            .iter()
            .zip(&probe.expected_modes)
            .find_map(|(e, m)| match e {
                Expectation::Word(w) | Expectation::Literal(w) if w.eq_ignore_ascii_case(kw) => {
                    Some(*m)
                }
                _ => None,
            })
            .unwrap_or(ModeClass::Both)
    };
    let mixed = keywords
        .iter()
        .map(|k| kw_mode(k.as_str()).block_order())
        .collect::<std::collections::HashSet<u8>>()
        .len()
        > 1;
    if mixed {
        keywords.sort_by_key(|k| kw_mode(k.as_str()).block_order());
    }

    // Source 1.5: type-name candidates when the walker expects
    // a column-type slot. Type names are a closed set sourced
    // from `Type::all()` (ADR-0005 declaration order:
    // text/int/real/decimal/bool/date/datetime/serial/
    // shortid). The walker surfaces this as
    // `Expectation::Ident { source: Types }`.
    let type_names: Vec<String> = if expected.iter().any(|e| {
        matches!(
            e,
            Expectation::Ident {
                source: IdentSource::Types,
                ..
            }
        )
    }) {
        Type::all()
            .iter()
            .map(|t| t.keyword().to_string())
            .filter(|s| matches_prefix(s))
            .collect()
    } else {
        Vec::new()
    };

    // Source 1.55: flag candidates (`--name`). Surfaced as a
    // distinct CandidateKind so the hint panel can colour them
    // with `tok_flag` (matching how they'll appear after
    // insertion). The flag-aware partial detection above captures any
    // leading dash-run, so the partial is one of: empty, all-dashes
    // (`-` / `--`), or `[-]+body`. Stripping the leading dashes and
    // matching the remainder against the flag *body* handles all of
    // them uniformly (empty / all-dashes → match every flag).
    let flag_needle = partial_prefix.trim_start_matches('-').to_lowercase();
    let flags: Vec<String> = expected
        .iter()
        .filter_map(|e| match e {
            Expectation::Flag(name) => Some(*name),
            _ => None,
        })
        .filter(|body| body.to_lowercase().starts_with(&flag_needle))
        .map(|body| format!("--{body}"))
        .collect();

    // Source 1.6: composite-literal candidates. Some commands
    // start with a multi-token literal sequence that the user
    // types as a single fluent piece (e.g. `1:n` for
    // `add 1:n relationship`). The walker's expected-set
    // surfaces the first token only (`Expectation::Literal("1")`);
    // the engine surfaces the full composite text so the user
    // can Tab through without knowing the surface syntax.
    let composites: Vec<String> = COMPOSITE_CANDIDATES
        .iter()
        .filter(|(opener, _)| {
            expected.iter().any(|e| match e {
                Expectation::Literal(l) | Expectation::Word(l) => *l == *opener,
                _ => false,
            })
        })
        .map(|(_, text)| (*text).to_string())
        .filter(|s| matches_prefix(s))
        .collect();

    // Source 1.7: branching-punct candidates. At positions
    // where the walker expects a punct character that opens a
    // sub-shape (notably `(` opening Form A or C of insert),
    // surface the punct as a Tab candidate so the user
    // discovers the option. Closing punct (`)`, `,`, etc.) and
    // expected-after-content punct (`:`, `=`, `.`) are not
    // surfaced — they're trailing terminals the user types
    // naturally, not "shape branches" worth advertising in the
    // hint panel.
    let punct_candidates: Vec<String> = if partial_prefix.is_empty() {
        expected
            .iter()
            .filter_map(|e| match e {
                Expectation::Punct('(') => Some("(".to_string()),
                _ => None,
            })
            .collect()
    } else {
        Vec::new()
    };

    // Source 1.8: SQL function-name candidates (ADR-0022 Amendment 6,
    // issue #15). At a `sql_expr_ident` slot the grammar accepts a
    // column reference *or* the leading name of a function call
    // (ADR-0031 §1); the column candidates come from the schema
    // (Source 2), and these surface the curated known-function set so
    // a learner can discover `sum` / `upper` / … by Tab. Prefix-
    // filtered like every other source; empty prefix offers the whole
    // set. Tagged `CandidateKind::Function` for its own colour.
    let has_sql_expr_slot = expected.iter().any(|e| {
        matches!(
            e,
            Expectation::Ident {
                role: "sql_expr_ident",
                ..
            }
        )
    });
    let mut functions: Vec<String> = if has_sql_expr_slot {
        crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS
            .iter()
            .filter(|f| matches_prefix(f))
            .map(|f| (*f).to_string())
            .collect()
    } else {
        Vec::new()
    };
    // Source 1.9: fake-data generator names (ADR-0048 D9). At the
    // `seed … set <col> as ⟨here⟩` slot (`IdentSource::Generators`) the
    // curated vocabulary is offered so a learner can discover `email` /
    // `product` / … by Tab. Same `Function` kind / `tok_function` colour
    // as SQL functions (no new theme colour — ADR-0048 §Grammar).
    let has_generator_slot = expected.iter().any(|e| {
        matches!(
            e,
            Expectation::Ident {
                source: IdentSource::Generators,
                ..
            }
        )
    });
    if has_generator_slot {
        functions.extend(
            crate::seed::KNOWN_GENERATORS
                .iter()
                .filter(|g| matches_prefix(g))
                .map(|g| (*g).to_string()),
        );
    }

    // Source 1.95: in-scope table aliases (issue #31). At a bare
    // `sql_expr_ident` slot — one *not* already past a `qualifier .`
    // (handled by §10.5 column narrowing) — the partial may be a
    // FROM-source the learner is mid-typing as a qualifier
    // (`group by o` → `o.<column>`). Offer each binding's *qualifier*:
    // its alias if it has one, else the table name (an aliased source
    // must be referenced by its alias, not the raw table name). This
    // makes aliases Tab-discoverable and — since a non-empty candidate
    // set overlapping the partial suppresses the under-cursor error
    // (the `typing_over_diag` path) — keeps the alias from flashing as
    // a bogus "unknown column" while typing. Mixed into `identifiers`
    // so it sorts/dedups/colours uniformly with column candidates.
    let alias_candidates: Vec<String> = if has_sql_expr_slot && prefix_qualifier.is_none() {
        // Once the partial *exactly* matches an in-scope qualifier,
        // discoverability is served — the learner has a whole alias
        // in hand and now needs the "add `.column`" hint
        // (`diagnostic.alias_used_as_column`), not sibling aliases
        // that merely share the prefix. Offering them would also let
        // the `typing_over_diag` path suppress that very hint. So in
        // the exact-match case we emit no alias candidates and let
        // the targeted diagnostic surface.
        let partial_is_exact_alias = resolution_from_scope.iter().any(|b| {
            let q = b.alias.as_deref().unwrap_or(b.table.as_str());
            q.eq_ignore_ascii_case(&partial_prefix)
        });
        if partial_is_exact_alias {
            Vec::new()
        } else {
            let mut out: Vec<String> = Vec::new();
            for binding in resolution_from_scope {
                let qualifier = binding.alias.as_deref().unwrap_or(binding.table.as_str());
                if matches_prefix(qualifier)
                    && !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier))
                {
                    out.push(qualifier.to_string());
                }
            }
            out
        }
    } else {
        Vec::new()
    };

    // Source 2: schema identifiers — accumulated across every
    // matching schema-listable `Ident { source }` expectation.
    // `NewName` / `Types` / `Free` sources don't query the
    // schema cache and contribute nothing here.
    //
    // Column candidates narrow to `current_table_columns` when
    // the walker resolved the active table — `update T set ` at
    // table T offers T's columns, not every table's columns
    // (ADR-0024 §Phase D §column-narrowing). Schemaless / table
    // not in schema falls back to the global `cache.columns`.
    let mut identifiers: Vec<String> = expected
        .iter()
        .filter_map(|e| match e {
            Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
            _ => None,
        })
        .flat_map(|source| {
            if source == IdentSource::Columns {
                qualified_columns.as_deref().map_or_else(
                    || {
                        current_table_columns.map_or_else(
                            || cache.for_source(source).to_vec(),
                            |cols| cols.iter().map(|c| c.name.clone()).collect(),
                        )
                    },
                    <[_]>::to_vec,
                )
            } else {
                cache.for_source(source).to_vec()
            }
        })
        .filter(|name| matches_prefix(name))
        .collect();
    // Fold in the in-scope alias qualifiers (Source 1.95). They are
    // already prefix-filtered; dedup against any column of the same
    // spelling happens via the shared sort/dedup below.
    identifiers.extend(alias_candidates);
    identifiers.sort();
    identifiers.dedup();
    // If an identifier shares its name with a keyword candidate
    // (rare in practice), the keyword wins — keywords are
    // grammar; the user can name a table the same thing but
    // resolving collisions in the user's favour would create
    // ambiguity in the live render.
    identifiers.retain(|name| !keywords.contains(name));
    // If a known function name collides with one of the user's own
    // columns (a column literally named `count` / `date` / …), the
    // column wins and the function candidate is dropped — otherwise the
    // same text would appear twice in the candidate line, distinguished
    // only by colour. Same spirit as the keyword-wins rule above: a
    // schema name is the user's content and the more relevant
    // completion; a bare `count` accepted as the column still becomes a
    // call the moment the user types `(`. Case-insensitive so `Count`
    // (column) also suppresses `count` (function). (DA review, #15.)
    functions.retain(|f| !identifiers.iter().any(|i| i.eq_ignore_ascii_case(f)));

    // Schema identifiers first: a column / table name the user
    // would otherwise have to look up is the highest-value
    // completion (valuable to experts, not just learners, who
    // come to know the keywords over time). Keywords and the
    // other closed-set grammar parts follow: keywords, then type
    // names (closed-set grammar — coloured as keywords), then
    // composite literals (`1:n`, …), then branching punct (`(`
    // opening a sub-shape), then flags (own colour).
    let mut candidates: Vec<Candidate> = Vec::with_capacity(
        identifiers.len()
            + keywords.len()
            + type_names.len()
            + functions.len()
            + composites.len()
            + punct_candidates.len()
            + flags.len(),
    );
    candidates.extend(identifiers.into_iter().map(|text| Candidate {
        text,
        kind: CandidateKind::Identifier,
        mode: ModeClass::Both,
    }));
    // Keywords carry their merged mode-class (Both unless a shared entry
    // word mixed simple + advanced continuations — ADR-0035 §4i e).
    candidates.extend(keywords.into_iter().map(|text| {
        let mode = kw_mode(text.as_str());
        Candidate {
            text,
            kind: CandidateKind::Keyword,
            mode,
        }
    }));
    candidates.extend(type_names.into_iter().map(|text| Candidate {
        text,
        kind: CandidateKind::Keyword,
        mode: ModeClass::Both,
    }));
    // Function names sit after keywords/types: a learner reads the
    // clause keywords first, then discovers callables. Their own
    // `Function` kind drives the `tok_function` colour (issue #15).
    candidates.extend(functions.into_iter().map(|text| Candidate {
        text,
        kind: CandidateKind::Function,
        mode: ModeClass::Both,
    }));
    candidates.extend(composites.into_iter().map(|text| Candidate {
        text,
        kind: CandidateKind::Keyword,
        mode: ModeClass::Both,
    }));
    candidates.extend(punct_candidates.into_iter().map(|text| Candidate {
        text,
        kind: CandidateKind::Punct,
        mode: ModeClass::Both,
    }));
    candidates.extend(flags.into_iter().map(|text| Candidate {
        text,
        kind: CandidateKind::Flag,
        mode: ModeClass::Both,
    }));

    if candidates.is_empty() {
        return None;
    }

    // When the input is already a valid complete command, drop
    // candidates that exactly match the partial prefix — those
    // are the words the user just finished typing (e.g. `pk` in
    // `create table T with pk`), not useful suggestions. Keeps
    // schema-narrowing intact (`show data Cu` → `Customers` is
    // not an exact match; preserved).
    if input_parses_complete && !partial_prefix.is_empty() {
        let lowered_partial = partial_prefix.to_lowercase();
        candidates.retain(|c| c.text.to_lowercase() != lowered_partial);
    }

    let candidates = ranker(candidates);
    if candidates.is_empty() {
        return None;
    }

    Some(Completion {
        replaced_range: (start, cursor),
        partial_prefix,
        candidates,
    })
}

/// Peek backward from `start` over whitespace and look for a
/// `<ident> .` shape — the qualifier of a qualified reference
/// the cursor is mid-typing past. Returns the qualifier text in
/// case-preserved form, or `None` when no qualifier is present.
///
/// Identifier characters are ASCII alphanumeric + underscore,
/// matching the partial-prefix recogniser used in
/// `candidates_at_cursor_with_in_mode`.
fn peek_back_qualifier(input: &str, start: usize) -> Option<String> {
    let bytes = input.as_bytes();
    // Skip whitespace immediately before the partial.
    let mut i = start;
    while i > 0 && bytes[i - 1].is_ascii_whitespace() {
        i -= 1;
    }
    // Expect a `.` next.
    if i == 0 || bytes[i - 1] != b'.' {
        return None;
    }
    i -= 1;
    // Skip whitespace between the `.` and the qualifier ident.
    while i > 0 && bytes[i - 1].is_ascii_whitespace() {
        i -= 1;
    }
    // Walk back over identifier characters.
    let ident_end = i;
    while i > 0 {
        let c = bytes[i - 1];
        if c.is_ascii_alphanumeric() || c == b'_' {
            i -= 1;
        } else {
            break;
        }
    }
    if i == ident_end {
        return None;
    }
    Some(input[i..ident_end].to_string())
}

/// Resolve a qualifier to a column-name list against the
/// supplied from_scope (alias-then-table match) and cte_bindings
/// (for `cte_alias.|`). An unresolved qualifier returns an empty
/// list per ADR-0032 §10.5.
fn resolve_qualifier_columns_in(
    qualifier: &str,
    from_scope: &[crate::dsl::walker::context::TableBinding],
    cte_bindings: &[crate::dsl::walker::context::CteBinding],
    cache: &SchemaCache,
) -> Vec<String> {
    // First: alias match in the active from_scope.
    if let Some(binding) = from_scope.iter().find(|b| {
        b.alias
            .as_deref()
            .is_some_and(|a| a.eq_ignore_ascii_case(qualifier))
    }) {
        if !binding.columns.is_empty() {
            return binding.columns.iter().map(|c| c.name.clone()).collect();
        }
        if let Some(cte) = cte_bindings
            .iter()
            .find(|c| c.name.eq_ignore_ascii_case(&binding.table))
        {
            return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
        }
    }
    // Second: table-name match in the active from_scope.
    if let Some(binding) = from_scope
        .iter()
        .find(|b| b.table.eq_ignore_ascii_case(qualifier))
    {
        if !binding.columns.is_empty() {
            return binding.columns.iter().map(|c| c.name.clone()).collect();
        }
        if let Some(cte) = cte_bindings
            .iter()
            .find(|c| c.name.eq_ignore_ascii_case(&binding.table))
        {
            return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
        }
    }
    // Third: direct cte_bindings match (cte_alias.|).
    if let Some(cte) = cte_bindings
        .iter()
        .find(|c| c.name.eq_ignore_ascii_case(qualifier))
    {
        return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
    }
    // Fourth: a bare table name from the schema cache — DSL
    // paths reach this for `from <Table>.<col>` shapes where
    // the probe didn't push a from_scope binding (no SQL FROM
    // matched). Preserves Phase-1 behaviour for the DSL.
    if let Some(cols) = cache.columns_for_table(qualifier) {
        return cols.iter().map(|c| c.name.clone()).collect();
    }
    Vec::new()
}

/// Detect a value-literal expected-set signature. A value-literal
/// slot is the only position where the walker's expected-set
/// simultaneously contains all five forms `null` / `true` /
/// `false` / number / string literal. See the suppression
/// rationale at the call site in `candidates_at_cursor`.
fn is_value_literal_signature(expected: &[Expectation]) -> bool {
    let has_word = |needle: &str| {
        expected
            .iter()
            .any(|e| matches!(e, Expectation::Word(w) if *w == needle))
    };
    has_word("null")
        && has_word("true")
        && has_word("false")
        && expected.iter().any(|e| matches!(e, Expectation::NumberLit))
        && expected.iter().any(|e| matches!(e, Expectation::StringLit))
}

/// `Some(prose)` when the cursor sits at an empty-prefix value-literal slot.
///
/// The hint panel surfaces format guidance (number, quoted text,
/// date, datetime, bool, null) instead of the misleading "null
/// true false" keyword-only candidate list.
///
/// Note: this is a stopgap until ADR-0023 lands schema-aware
/// completion (which would surface format examples specific to
/// the column's type — e.g. just the datetime format at a
/// datetime column). Today the hint lists all valid literal
/// shapes regardless of context.
#[must_use]
pub fn value_literal_hint_at_cursor(input: &str, cursor: usize) -> Option<String> {
    let cursor = cursor.min(input.len());
    let bytes = input.as_bytes();
    let mut start = cursor;
    while start > 0 {
        let prev = bytes[start - 1];
        if prev.is_ascii_alphanumeric() || prev == b'_' {
            start -= 1;
        } else {
            break;
        }
    }
    if start != cursor {
        // Partial prefix is non-empty — keyword completion
        // handles it (e.g. `n` → `null`).
        return None;
    }
    let leading = &input[..start];
    let expected = expected_at(leading, Mode::Advanced);
    if !is_value_literal_signature(&expected) {
        return None;
    }
    Some(crate::t!("hint.value_literal_slot"))
}

/// What the user has typed in an identifier slot whose schema
/// list contains nothing matching the prefix (ADR-0022 stage 8e
/// + the user's #5).
///
/// The renderer overlays the partial token with `tok_error`;
/// the hint panel renders an "invalid …" message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidIdent {
    /// Byte range of the typed-but-not-found identifier.
    pub range: (usize, usize),
    /// The text the user typed in the slot.
    pub found: String,
    /// Which known-set slot this position expected.
    pub source: IdentSource,
}

/// "User is typing a name" cursor state (round-3 follow-up).
///
/// Fires at `NewName` slots — positions where the user is
/// expected to invent a name (new table, new column, new
/// relationship). Used by the hint panel to surface a friendly
/// "Type a name" hint instead of the technical "next: `(`"
/// that would otherwise appear once the partial identifier
/// gets consumed by the parser.
///
/// `next_after_name` is what the parser would expect once the
/// user finishes typing the name — derived by re-parsing with
/// a single-letter placeholder identifier substituted at the
/// cursor. `None` when the post-name parse succeeds (the rest
/// of the command is already in place) or has no meaningful
/// next-token information (custom errors with empty expected
/// set).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypingName {
    pub next_after_name: Option<String>,
}

/// `Some(_)` when the cursor is at or inside a `NewName`-slot
/// position. Otherwise `None`.
#[must_use]
pub fn typing_name_at_cursor(input: &str, cursor: usize) -> Option<TypingName> {
    let cursor = cursor.min(input.len());
    let bytes = input.as_bytes();
    let mut start = cursor;
    while start > 0 {
        let prev = bytes[start - 1];
        if prev.is_ascii_alphanumeric() || prev == b'_' {
            start -= 1;
        } else {
            break;
        }
    }
    let leading = &input[..start];
    let expected = expected_at(leading, Mode::Advanced);
    let is_new_name_slot = expected.iter().any(|e| {
        matches!(
            e,
            Expectation::Ident {
                source: IdentSource::NewName,
                ..
            }
        )
    });
    if !is_new_name_slot {
        return None;
    }
    // Probe what comes after the name by substituting a
    // single-letter identifier placeholder. Walk forward over
    // any partial text past the cursor first so the probe
    // replaces the user's in-progress name as a whole.
    let mut end = cursor;
    while end < bytes.len() {
        let c = bytes[end];
        if c.is_ascii_alphanumeric() || c == b'_' {
            end += 1;
        } else {
            break;
        }
    }
    let probe = format!("{}X{}", &input[..start], &input[end..]);
    let next_after_name = match parse_command(&probe) {
        Ok(_) => None,
        Err(ParseError::Empty) => None,
        Err(ParseError::Invalid { expected, .. }) if expected.is_empty() => None,
        Err(ParseError::Invalid { expected, .. }) => Some(oxford_or(&expected)),
    };
    Some(TypingName { next_after_name })
}

/// English-style "A, B, or C" join used by the hint panel
/// prose. Lifted out of `input_render` so the completion
/// module can produce ready-to-render strings.
fn oxford_or(items: &[String]) -> String {
    match items {
        [] => String::new(),
        [a] => a.clone(),
        [a, b] => format!("{a} or {b}"),
        rest => {
            let (last, head) = rest.split_last().expect("len >= 3");
            format!("{}, or {last}", head.join(", "))
        }
    }
}

/// Detect "the user has typed an identifier here that the
/// schema doesn't have." Returns `None` for any of:
/// - cursor at empty / whitespace partial;
/// - cursor at a position that doesn't expect a known-set
///   identifier (keyword slot, NewName slot, complete input);
/// - cursor partial matches at least one schema name.
#[must_use]
pub fn invalid_ident_at_cursor(
    input: &str,
    cursor: usize,
    cache: &SchemaCache,
) -> Option<InvalidIdent> {
    invalid_ident_at_cursor_in_mode(input, cursor, cache, Mode::Advanced)
}

/// Mode-aware [`invalid_ident_at_cursor`].
///
/// The slot's expected set is computed in `mode`, so a simple-mode
/// caller doesn't get the advanced (SQL) grammar's view of a shared
/// `insert`/`update`/`delete` entry word — e.g. it won't flag `rows`
/// as an "unknown column" in `update T … --all-rows`, which is a DSL
/// flag in simple mode rather than the SQL expression `- -all - rows`
/// (ADR-0033 Amendment 3).
#[must_use]
pub fn invalid_ident_at_cursor_in_mode(
    input: &str,
    cursor: usize,
    cache: &SchemaCache,
    mode: Mode,
) -> Option<InvalidIdent> {
    let cursor = cursor.min(input.len());
    let bytes = input.as_bytes();
    let mut start = cursor;
    while start > 0 {
        let prev = bytes[start - 1];
        if prev.is_ascii_alphanumeric() || prev == b'_' {
            start -= 1;
        } else {
            break;
        }
    }
    if start == cursor {
        // No partial token at the cursor — nothing to flag.
        return None;
    }
    let partial = &input[start..cursor];
    // A token that starts with a digit cannot be an identifier
    // (the identifier shape requires a letter or `_` first) —
    // it is a numeric literal, never an "invalid column".
    // Without this guard a literal like `1` at a slot that
    // *also* accepts a column reference — the WHERE-expression
    // operand (ADR-0026) — is mis-flagged as an unknown column.
    if partial.starts_with(|c: char| c.is_ascii_digit()) {
        return None;
    }
    let leading = &input[..start];
    let expected = expected_at(leading, mode);
    if expected.is_empty() {
        return None;
    }
    // Issue #6 / #16: at a SQL expression position the partial could
    // resolve to either a column reference *or* a function-call name
    // (the grammar admits the call shape structurally — see
    // sql_expr.rs's `CALL_ARGS` comment, "it does not know which
    // names are aggregates"). Issue #6 dropped the flag here wholesale
    // to stop the false positive on names like `sum`; issue #16
    // restores it for genuine typos using the curated known-function
    // list. When the partial prefix-matches a known function name we
    // still bail (it may yet become a function call); otherwise we
    // fall through to the schema-column check below, which flags the
    // partial as "no such column" unless it prefix-matches a real
    // column. So `select Agx` warns at typing time again, while
    // `select sum` does not.
    let has_sql_expr_slot = expected.iter().any(|e| {
        matches!(
            e,
            Expectation::Ident {
                role: "sql_expr_ident",
                ..
            }
        )
    });
    if has_sql_expr_slot && crate::dsl::sql_functions::is_known_function_prefix(partial) {
        return None;
    }
    // A bare ident at a SQL expression slot may be a **table alias / name**
    // the user is mid-typing as a qualifier (`ol` in `sum(ol.count)`). The
    // defining FROM clause can sit *after* the cursor — the projection
    // references it — so the leading-only walk has an empty from-scope and
    // would wrongly flag the alias as an unknown column. Recover the scope
    // from the FULL input (mirrors the §10.6 edit-an-existing-query
    // lookahead the candidate engine uses for column narrowing) and bail
    // when the partial prefix-matches a binding's alias or table name.
    if has_sql_expr_slot {
        let full = crate::dsl::walker::completion_probe_in_mode(input, cache, mode);
        let lowered = partial.to_lowercase();
        let matches_qualifier = full.from_scope.iter().any(|b| {
            b.alias
                .as_deref()
                .is_some_and(|a| a.to_lowercase().starts_with(&lowered))
                || b.table.to_lowercase().starts_with(&lowered)
        });
        if matches_qualifier {
            return None;
        }
    }
    // ADR-0048 D9: the `seed … set <col> as <gen>` slot is a curated
    // vocabulary (`IdentSource::Generators`), not a schema source, so the
    // schema-column check below would never see it. A partial that
    // prefix-matches a known generator is an in-progress name; anything
    // else is an unknown generator → flag it `[ERR]` while typing.
    let has_generator_slot = expected.iter().any(|e| {
        matches!(
            e,
            Expectation::Ident {
                source: IdentSource::Generators,
                ..
            }
        )
    });
    if has_generator_slot {
        if crate::seed::is_known_generator_prefix(partial) {
            return None;
        }
        return Some(InvalidIdent {
            range: (start, cursor),
            found: partial.to_string(),
            source: IdentSource::Generators,
        });
    }
    // Find every schema-listable source in the expected list.
    let sources: Vec<IdentSource> = expected
        .iter()
        .filter_map(|e| match e {
            Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
            _ => None,
        })
        .collect();
    if sources.is_empty() {
        return None;
    }
    let lowered = partial.to_lowercase();
    // If any schema entry across the matching slots matches
    // the prefix, the partial is not "invalid" — it's an
    // in-progress lookup.
    let any_match = sources
        .iter()
        .flat_map(|s| cache.for_source(*s))
        .any(|name| name.to_lowercase().starts_with(&lowered));
    if any_match {
        return None;
    }
    // Pick the first source kind for the diagnostic — when
    // multiple are expected (e.g. `drop relationship …`
    // expects Relationships *or* the `from` keyword;
    // here only the schema source survives the filter) we
    // surface the first.
    Some(InvalidIdent {
        range: (start, cursor),
        found: partial.to_string(),
        source: sources[0],
    })
}

// `expected_set` is gone: the walker-driven `expected_at` above
// returns structured `Expectation`s with full `IdentSource`
// information, avoiding the lossy string round-trip the
// chumsky-era completion engine relied on.

/// Snapshot of a freshly-inserted completion. The memo lives
/// on `App::last_completion` until any non-Tab/non-Shift-Tab
/// keystroke clears it (or Esc/Backspace consumes it).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LastCompletion {
    /// Byte range in the input currently occupied by the
    /// inserted candidate (including the trailing space).
    pub inserted_range: (usize, usize),
    /// The text that was at `inserted_range` *before* any
    /// completion was applied — restored by Esc / Backspace.
    pub original_text: String,
    /// Cycle list, fixed at memo-creation time. Each candidate
    /// carries its kind so the hint panel keeps the
    /// keyword-vs-identifier colour coding stable across cycle
    /// transitions.
    pub candidates: Vec<Candidate>,
    /// Which `candidates[i]` is currently visible.
    pub selection_idx: usize,
}

impl LastCompletion {
    /// Wrap-around forward step.
    #[must_use]
    pub const fn next_idx(&self) -> usize {
        (self.selection_idx + 1) % self.candidates.len()
    }

    /// Wrap-around backward step.
    #[must_use]
    pub const fn prev_idx(&self) -> usize {
        (self.selection_idx + self.candidates.len() - 1) % self.candidates.len()
    }
}

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

    fn cands(input: &str, cursor: usize) -> Vec<String> {
        candidates_at_cursor(input, cursor, &SchemaCache::default()).map_or_else(Vec::new, |c| {
            c.candidates.into_iter().map(|c| c.text).collect()
        })
    }

    fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> {
        candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
            c.candidates.into_iter().map(|c| c.text).collect()
        })
    }

    /// Simple-mode completion candidates — the DSL surface
    /// (ADR-0003). Used by tests of DSL-only completion (the
    /// `--all-rows` flag, the DSL value-literal slots), which since
    /// sub-phase 3j must run in Simple mode: `insert`/`update`/
    /// `delete` are shared entry words (ADR-0033 Amendment 3) and
    /// Advanced mode surfaces the SQL grammar's completions instead.
    fn cands_simple(input: &str, cursor: usize) -> Vec<String> {
        candidates_at_cursor_in_mode(input, cursor, &SchemaCache::default(), Mode::Simple)
            .map_or_else(Vec::new, |c| {
                c.candidates.into_iter().map(|c| c.text).collect()
            })
    }

    fn cand_kinds_with(
        input: &str,
        cursor: usize,
        cache: &SchemaCache,
    ) -> Vec<(String, CandidateKind)> {
        candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
            c.candidates.into_iter().map(|c| (c.text, c.kind)).collect()
        })
    }

    #[test]
    fn empty_input_offers_all_command_entry_keywords() {
        let cs = cands("", 0);
        // Ten command-entry keywords.
        assert!(cs.contains(&"create".to_string()));
        assert!(cs.contains(&"drop".to_string()));
        assert!(cs.contains(&"add".to_string()));
        assert!(cs.contains(&"insert".to_string()));
        assert!(cs.contains(&"update".to_string()));
        assert!(cs.contains(&"delete".to_string()));
        assert!(cs.contains(&"show".to_string()));
        assert!(cs.contains(&"rename".to_string()));
        assert!(cs.contains(&"change".to_string()));
        assert!(cs.contains(&"replay".to_string()));
    }

    #[test]
    fn partial_keyword_narrows_to_matching_entries() {
        // Typed `cr` — only `create` starts with that.
        let cs = cands("cr", 2);
        assert_eq!(cs, vec!["create".to_string()]);
    }

    #[test]
    fn partial_keyword_is_case_insensitive() {
        let cs = cands("CR", 2);
        assert_eq!(cs, vec!["create".to_string()]);
    }

    #[test]
    fn at_token_boundary_offers_next_expected_keyword() {
        // After `create ` advanced mode offers `table` (valid in both
        // modes) plus the SQL-only `unique` (`create unique index`) and
        // `index` — the shared-entry-word merge (ADR-0035 §4i d) — and
        // `m:n` (`create m:n relationship`, ADR-0045), surfaced as the
        // composite (the bare `m` opener is filtered).
        // `table` (Both) blocks before the Advanced-only `unique`/`index`.
        let cs = cands("create ", 7);
        assert_eq!(
            cs,
            vec![
                "table".to_string(),
                "unique".to_string(),
                "index".to_string(),
                "m:n".to_string()
            ]
        );
    }

    #[test]
    fn shared_entry_word_drop_merges_all_continuations() {
        // ADR-0035 §4i (d): in advanced mode `drop` is a shared entry
        // word (SQL `drop table`/`drop index` + the DSL drops). The
        // completion must offer EVERY valid continuation, not just the
        // first-decided node's. Block order: Both (table, index) then
        // Simple-only (column, relationship, constraint).
        let cs = cands("drop ", 5);
        for kw in ["table", "index", "column", "relationship", "constraint"] {
            assert!(
                cs.contains(&kw.to_string()),
                "`drop ` should offer `{kw}`; got {cs:?}"
            );
        }
        // Both-mode continuations block before the simple-only ones.
        let pos = |k: &str| cs.iter().position(|c| c == k).unwrap();
        assert!(
            pos("table") < pos("column"),
            "Both block precedes Simple block: {cs:?}"
        );
        assert!(
            pos("index") < pos("relationship"),
            "Both block precedes Simple block: {cs:?}"
        );
    }

    #[test]
    fn shared_entry_word_drop_partial_keeps_matching_continuation() {
        // A partial second keyword (`drop rel`) used to dead-end at an
        // empty list (only the SQL node walked); the merge keeps the
        // DSL `relationship` continuation.
        let cs = cands("drop rel", 8);
        assert_eq!(cs, vec!["relationship".to_string()]);
    }

    #[test]
    fn multi_candidate_position_offers_add_subcommands() {
        // After `add ` the parser expects `column` (for
        // `add column ...`), `index` (for `add index ...`,
        // ADR-0025), `constraint` (for `add constraint ...`,
        // ADR-0029 §2.2), and `1` (the opener for
        // `add 1:n relationship ...`). The completion engine
        // sections keyword candidates ahead of the `1:n`
        // composite literal, so the literal sorts last even
        // though `add 1:n` is declared second.
        let cs = cands("add ", 4);
        assert_eq!(
            cs,
            vec![
                "column".to_string(),
                "index".to_string(),
                "constraint".to_string(),
                "1:n".to_string(),
            ]
        );
    }

    #[test]
    fn one_to_n_filters_to_prefix_match() {
        // Typed `1` after `add ` — only `1:n` matches.
        let cs = cands("add 1", 5);
        assert_eq!(cs, vec!["1:n".to_string()]);
    }

    #[test]
    fn update_filter_position_offers_where_and_all_rows() {
        // After `update T set Name='hi' ` the parser expects
        // a `,` (more assignments), `where` (where clause),
        // or `--all-rows` (flag). Punctuation isn't surfaced;
        // `where` and `--all-rows` should appear.
        // `--all-rows` is a DSL-only rail (Simple mode); in Advanced
        // mode `update`/`delete` route to the SQL grammar, which has
        // no such flag (ADR-0033 Amendment 3).
        let cs = cands_simple("update T set Name='hi' ", 23);
        assert!(cs.contains(&"where".to_string()), "got {cs:?}");
        assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
    }

    #[test]
    fn delete_filter_position_offers_where_and_all_rows() {
        let cs = cands_simple("delete from T ", 14);
        assert!(cs.contains(&"where".to_string()), "got {cs:?}");
        assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
    }

    #[test]
    fn flag_candidates_are_classified_as_flag_kind() {
        // Hint-panel colouring distinguishes flags from
        // keywords (amber vs purple) — flags get their own
        // CandidateKind so the renderer can apply tok_flag.
        // Simple mode: `--all-rows` is the DSL rail.
        let kinds = candidates_at_cursor_in_mode(
            "delete from T ",
            14,
            &SchemaCache::default(),
            Mode::Simple,
        )
        .expect("some completion")
        .candidates
        .into_iter()
        .map(|c| (c.text, c.kind))
        .collect::<Vec<_>>();
        let flag = kinds
            .iter()
            .find(|(t, _)| t == "--all-rows")
            .expect("--all-rows present");
        assert_eq!(flag.1, CandidateKind::Flag);
    }

    #[test]
    fn flag_candidates_filter_by_partial_prefix() {
        let cs = cands_simple("delete from T --", 16);
        assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
    }

    #[test]
    fn typed_dashes_still_offer_an_optional_trailing_flag() {
        // Regression: `add 1:n relationship … [--create-fk]` —
        // at a trailing space the flag is offered, but once the
        // user typed `--` the trailing-junk Mismatch dropped the
        // skipped optional's expectation and completion went
        // empty. Both positions must offer `--create-fk`.
        let at_space = cands("add 1:n relationship from X.a to Y.b ", 37);
        assert!(
            at_space.contains(&"--create-fk".to_string()),
            "trailing space should offer --create-fk, got {at_space:?}",
        );
        let at_dashes = cands("add 1:n relationship from X.a to Y.b --", 39);
        assert!(
            at_dashes.contains(&"--create-fk".to_string()),
            "typed `--` should still offer --create-fk, got {at_dashes:?}",
        );
    }

    #[test]
    fn single_dash_offers_flags_not_keywords_and_replaces_the_dash() {
        // Bug (manual testing): `add 1:n relationship … -` (one dash)
        // offered the `on` keyword *and* `--create-fk`, and accepting
        // produced `-on` / `---create-fk` because the lone `-` was not
        // part of the replaced range. A dash at a flag position is a
        // flag-in-progress: offer flags, exclude keywords, replace the
        // dash on accept.
        let input = "add 1:n relationship from X.a to Y.b -";
        let c = candidates_at_cursor(input, input.len(), &SchemaCache::default())
            .expect("a `-` at a flag position offers candidates");
        let texts: Vec<&str> = c.candidates.iter().map(|x| x.text.as_str()).collect();
        assert!(
            texts.contains(&"--create-fk"),
            "should offer --create-fk: {texts:?}"
        );
        assert!(
            !texts.contains(&"on"),
            "must NOT offer `on` after a dash: {texts:?}"
        );
        assert_eq!(
            c.replaced_range,
            (input.len() - 1, input.len()),
            "the `-` must be inside the replaced range so accept yields `--create-fk`",
        );
    }

    #[test]
    fn double_dash_replaces_both_dashes_on_accept() {
        let input = "delete from T --";
        let c =
            candidates_at_cursor_in_mode(input, input.len(), &SchemaCache::default(), Mode::Simple)
                .expect("`--` offers the flag");
        assert!(c.candidates.iter().any(|x| x.text == "--all-rows"));
        assert_eq!(
            c.replaced_range,
            (input.len() - 2, input.len()),
            "both dashes are replaced so accept yields `--all-rows`, not `----all-rows`",
        );
    }

    #[test]
    fn dash_at_a_value_position_is_not_treated_as_a_flag() {
        // `show data T where x = -5` — the `-` is a sign, not a flag.
        // No flag is expected here, so the dash must not be swallowed
        // into a flag partial: the partial stays `5` (the original
        // value-operand behaviour), and no `--…` candidate appears.
        let mut s = SchemaCache::default();
        s.tables.push("T".into());
        s.columns.push("x".into());
        let input = "show data T where x = -5";
        if let Some(c) = candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple) {
            assert!(
                !c.candidates.iter().any(|x| x.text.starts_with("--")),
                "no flags at a value position: {:?}",
                c.candidates,
            );
            assert_eq!(
                c.replaced_range,
                (input.len() - 1, input.len()),
                "only the `5` is the partial; the `-` (sign) is not captured",
            );
        }
    }

    #[test]
    fn typed_dashes_offer_the_optional_cascade_flag_on_drop_column() {
        // The same optional-flag class: `drop column … [--cascade]`.
        let at_dashes = cands("drop column from table T: c --", 30);
        assert!(
            at_dashes.contains(&"--cascade".to_string()),
            "typed `--` should offer --cascade, got {at_dashes:?}",
        );
    }

    #[test]
    fn typed_dashes_offer_the_change_column_conversion_flags() {
        // `change column … [--force-conversion | --dont-convert]`
        // — the flags sit in a `Repeated { min: 0 }`; the same
        // trailing-junk-Mismatch fix must surface them.
        let at_dashes = cands("change column T: c (int) --", 27);
        assert!(
            at_dashes.contains(&"--force-conversion".to_string())
                && at_dashes.contains(&"--dont-convert".to_string()),
            "typed `--` should offer both conversion flags, got {at_dashes:?}",
        );
    }

    // ---- App-lifecycle command completion (round-5 fold-in) ----

    #[test]
    fn empty_input_offers_app_command_entry_keywords() {
        let cs = cands("", 0);
        // App-lifecycle commands now appear alongside DSL
        // commands in the entry-keyword set.
        for expected in &[
            "quit", "help", "rebuild", "save", "new", "load", "export", "import", "mode",
            "messages", "undo", "redo", "copy",
        ] {
            assert!(
                cs.contains(&expected.to_string()),
                "missing {expected:?} in entry-keyword candidates: {cs:?}",
            );
        }
    }

    #[test]
    fn load_prefix_offers_load_only() {
        let cs = cands("l", 1);
        assert_eq!(cs, vec!["load".to_string()]);
    }

    #[test]
    fn save_prefix_offers_save() {
        let cs = cands("sa", 2);
        assert_eq!(cs, vec!["save".to_string()]);
    }

    #[test]
    fn mode_then_space_offers_simple_and_advanced() {
        // `mode ` requires a value; the parser fails at EOF and
        // the expected-set contains the two known keywords.
        let cs = cands("mode ", 5);
        assert!(cs.contains(&"simple".to_string()), "got {cs:?}");
        assert!(cs.contains(&"advanced".to_string()), "got {cs:?}");
    }

    // ---- Optional-suffix completion (round-5 gap, closed in Phase D) ----
    //
    // Pre-Phase-D: `save ` parsed as a valid `save` command, so
    // the completion engine had no expected-set to mine and the
    // `as` suffix never surfaced as a Tab candidate. Phase D's
    // `WalkResult::tail_expected` carries the outer shape's
    // skipped-Optional expectations even on `Match`, so these
    // surface without a separate probe mechanism.

    #[test]
    fn save_space_offers_as_via_tail_expected() {
        let cs = cands("save ", 5);
        assert_eq!(cs, vec!["as".to_string()]);
    }

    #[test]
    fn messages_space_offers_short_and_verbose_via_tail_expected() {
        let cs = cands("messages ", 9);
        assert!(cs.contains(&"short".to_string()), "got {cs:?}");
        assert!(cs.contains(&"verbose".to_string()), "got {cs:?}");
    }

    // ---- Value-literal slot suppression (round-6) -----------

    #[test]
    fn value_literal_slot_suppresses_keyword_candidates_at_empty_prefix() {
        // After `insert into T values (` the parser's expected
        // set contains null/true/false/number/string literal.
        // The keyword pipeline would otherwise surface `null`,
        // `true`, `false` as Tab candidates — actively
        // misleading at a slot where the user is more likely
        // entering a number / text / date. Suppress.
        // DSL value-literal slot (Simple mode); in Advanced mode
        // `insert` routes to the SQL grammar (ADR-0033 Amendment 3).
        let cs = cands_simple("insert into T values (", 22);
        assert!(cs.is_empty(), "got misleading candidates {cs:?}");
    }

    #[test]
    fn value_literal_slot_with_partial_prefix_still_completes() {
        // Once the user types a prefix, normal keyword
        // completion applies — `n` → `null`, `tr` → `true`,
        // `fa` → `false`.
        assert_eq!(
            cands_simple("insert into T values (n", 23),
            vec!["null".to_string()],
        );
        assert_eq!(
            cands_simple("insert into T values (tr", 24),
            vec!["true".to_string()],
        );
        assert_eq!(
            cands_simple("insert into T values (fa", 24),
            vec!["false".to_string()],
        );
    }

    #[test]
    fn value_literal_slot_after_first_value_also_suppresses() {
        // Comma-separated value positions all hit the same slot
        // signature. `insert into T values (1, ` → expected:
        // null/true/false/number/string. Suppress.
        let cs = cands_simple("insert into T values (1, ", 25);
        assert!(cs.is_empty(), "got {cs:?}");
    }

    #[test]
    fn update_set_value_slot_suppresses() {
        // `update T set col=` is also a value-literal slot.
        let cs = cands_simple("update T set col=", 17);
        assert!(cs.is_empty(), "got {cs:?}");
    }

    #[test]
    fn where_value_slot_suppresses() {
        // `where col=` is also a value-literal slot.
        let cs = cands_simple("delete from T where col=", 24);
        assert!(cs.is_empty(), "got {cs:?}");
    }

    #[test]
    fn value_literal_hint_fires_at_empty_value_slot() {
        let hint = value_literal_hint_at_cursor("insert into T values (", 22);
        let s = hint.expect("hint should fire at value-literal slot");
        // Lists each literal form so the user sees the full set
        // of valid inputs rather than just three keywords.
        assert!(s.contains("number"), "got {s:?}");
        assert!(s.contains("text") || s.contains("'"), "got {s:?}");
        assert!(s.contains("true"), "got {s:?}");
        assert!(s.contains("false") || s.contains("/false"), "got {s:?}");
        assert!(s.contains("null"), "got {s:?}");
        // Format examples for the cases users typically can't
        // guess (date, datetime).
        assert!(
            s.contains("YYYY-MM-DD"),
            "should include date format, got {s:?}",
        );
        assert!(
            s.contains("HH:MM:SS"),
            "should include datetime format, got {s:?}",
        );
    }

    #[test]
    fn value_literal_hint_does_not_fire_at_partial_prefix() {
        // With a partial prefix the keyword-completion path
        // handles it; the prose hint short-circuit only
        // applies to empty-prefix positions.
        assert!(value_literal_hint_at_cursor("insert into T values (n", 23).is_none());
    }

    #[test]
    fn value_literal_hint_does_not_fire_at_keyword_slot() {
        // Entry keyword position is not a value-literal slot.
        assert!(value_literal_hint_at_cursor("", 0).is_none());
        assert!(value_literal_hint_at_cursor("insert ", 7).is_none());
    }

    #[test]
    fn show_offers_all_subcommands() {
        // `show` branches: data / table (singular) plus the V5
        // list-all forms tables / relationships / indexes, in
        // grammar-declaration order.
        let cs = cands("show ", 5);
        assert_eq!(
            cs,
            vec![
                "data".to_string(),
                "table".to_string(),
                "tables".to_string(),
                "relationships".to_string(),
                "indexes".to_string(),
                "relationship".to_string(),
                "index".to_string(),
            ],
        );
    }

    #[test]
    fn drop_offers_all_five_subcommands_in_simple_mode() {
        // The DSL `drop` branches: column / relationship / table / index
        // (ADR-0025) / constraint (ADR-0029 §2.2). Candidates follow
        // grammar declaration order, so `constraint` — added last —
        // appears last. Simple mode, because `drop` is a shared entry
        // word: advanced mode surfaces the SQL `DROP TABLE` completion
        // instead (ADR-0033 Amendment 3 / ADR-0035 §4c — see below).
        let cs = cands_simple("drop ", 5);
        assert_eq!(
            cs,
            vec![
                "column".to_string(),
                "relationship".to_string(),
                "table".to_string(),
                "index".to_string(),
                "constraint".to_string(),
            ],
        );
    }

    #[test]
    fn drop_in_advanced_mode_surfaces_all_merged_continuations() {
        // ADR-0035 §4c: `drop` gained an advanced SQL node
        // (`DROP TABLE [IF EXISTS]`). With the 4i (d) shared-entry-word
        // merge, advanced-mode `drop ` now offers EVERY valid
        // continuation across the SQL and DSL nodes, block-ordered Both →
        // Advanced → Simple: `table`/`index` (valid in both modes) before
        // the DSL-only `column`/`relationship`/`constraint`.
        assert_eq!(
            cands("drop ", 5),
            vec![
                "table".to_string(),
                "index".to_string(),
                "column".to_string(),
                "relationship".to_string(),
                "constraint".to_string(),
            ]
        );
    }

    #[test]
    fn complete_command_offers_no_candidates() {
        // `create table T with pk` is a complete command —
        // no candidates to offer.
        let input = "create table T with pk";
        let cs = cands(input, input.len());
        assert!(cs.is_empty(), "got {cs:?}");
    }

    #[test]
    fn non_branching_punctuation_is_not_surfaced_as_candidate() {
        // After `add column to table T` the walker expects `:`.
        // `:` is a "trailing-content" punct — the user types it
        // naturally as they continue the command, so the hint
        // panel doesn't surface it. Only branching punct (`(`
        // opening a sub-shape) becomes a Tab candidate.
        let input = "add column to table T";
        let cs = cands(input, input.len());
        assert!(
            cs.is_empty(),
            "trailing-content punct should not surface: {cs:?}"
        );
    }

    #[test]
    fn open_paren_branching_punct_surfaces_after_insert_into_table() {
        // After `insert into Orders ` the walker expects either
        // `values` (Form B) or `(` (Forms A / C). Both surface
        // as Tab candidates so the user discovers the column-
        // list form.
        let cs = cands("insert into Orders ", 19);
        assert!(cs.contains(&"values".to_string()), "got {cs:?}");
        assert!(cs.contains(&"(".to_string()), "got {cs:?}");
    }

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

    fn two_table_alias_cache() -> SchemaCache {
        use crate::dsl::types::Type;
        let mut cache = schema_with_table("a", &[("id", Type::Int), ("name", Type::Text)]);
        cache.tables.push("b".to_string());
        cache.columns.push("total".to_string());
        cache.table_columns.insert(
            "b".to_string(),
            vec![
                TableColumn::new("id", Type::Int),
                TableColumn::new("total", Type::Real),
            ],
        );
        cache
    }

    #[test]
    fn bare_expr_slot_offers_in_scope_aliases() {
        // Issue #31: at a bare SQL-expression slot (here GROUP BY) the
        // in-scope FROM aliases are Tab-discoverable, so a learner can
        // reach `o.<column>` without guessing the alias.
        let cache = two_table_alias_cache();
        let input = "select a.id from a o join b z on o.id = z.id group by ";
        let cs = cands_with(input, input.len(), &cache);
        assert!(
            cs.contains(&"o".to_string()),
            "alias `o` must be offered; got {cs:?}"
        );
        assert!(
            cs.contains(&"z".to_string()),
            "alias `z` must be offered; got {cs:?}"
        );
    }

    #[test]
    fn bare_expr_slot_narrows_aliases_by_partial_prefix() {
        // A partial that prefix-matches several aliases offers each;
        // an exact match (`o`) is the learner's whole alias — no
        // sibling-alias noise, so the `alias_used_as_column` hint can
        // surface instead (issue #31).
        let cache = two_table_alias_cache();
        let input = "select a.id from a aa join b ab on aa.id = ab.id group by a";
        let cs = cands_with(input, input.len(), &cache);
        assert!(
            cs.contains(&"aa".to_string()),
            "alias `aa` must be offered; got {cs:?}"
        );
        assert!(
            cs.contains(&"ab".to_string()),
            "alias `ab` must be offered; got {cs:?}"
        );

        // Exact-alias partial: the alias source steps aside.
        let exact = "select aa.id from a aa join b ab on aa.id = ab.id group by aa";
        let cs2 = cands_with(exact, exact.len(), &cache);
        assert!(
            !cs2.iter().any(|c| c == "ab"),
            "an exact-alias partial must not surface sibling aliases; got {cs2:?}",
        );
    }

    #[test]
    fn alias_not_offered_after_a_qualifier_dot() {
        // Past `o.` the §10.5 column-narrowing owns the slot; aliases
        // are not candidates there.
        let cache = two_table_alias_cache();
        let input = "select a.id from a o join b z on o.id = z.id group by o.";
        let cs = cands_with(input, input.len(), &cache);
        assert!(
            !cs.iter().any(|c| c == "o" || c == "z"),
            "aliases must not be offered after a qualifier dot; got {cs:?}",
        );
    }

    #[test]
    fn update_set_offers_only_current_table_columns() {
        use crate::dsl::types::Type;
        // SchemaCache.columns has columns from many tables, but
        // at `update Customers set ` only Customers' columns
        // should appear.
        let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
        // Pretend the global flat list has columns from a second
        // table that aren't in Customers.
        cache.columns.push("OrderTotal".to_string());
        cache.columns.push("Stock".to_string());
        cache.table_columns.insert(
            "Orders".to_string(),
            vec![TableColumn {
                name: "OrderTotal".to_string(),
                user_type: Type::Real,
                not_null: false,
                has_default: false,
            }],
        );
        cache.tables.push("Orders".to_string());
        let cs = cands_with("update Customers set ", 21, &cache);
        // Customers's columns should appear:
        assert!(cs.contains(&"id".to_string()), "got {cs:?}");
        assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
        // Other tables' columns should NOT:
        assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
        assert!(!cs.contains(&"Stock".to_string()), "got {cs:?}");
    }

    #[test]
    fn order_by_completion_omits_preceding_clause_keywords() {
        use crate::dsl::types::Type;
        // F5 (handoff 30 §3.3): at `… order by ` the candidate
        // list is the start of a sort item — the table's columns
        // plus expression-start keywords. It must NOT be padded
        // with clause keywords belonging to clauses positioned
        // *before* ORDER BY (the FROM's JOIN options, WHERE /
        // GROUP BY / HAVING, set-ops). Those used to shove the
        // columns off-screen.
        let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
        let input = "select Name from Things order by ";
        let cs = cands_with(input, input.len(), &cache);
        // The columns the user wants are offered:
        assert!(cs.contains(&"Name".to_string()), "got {cs:?}");
        assert!(cs.contains(&"Qty".to_string()), "got {cs:?}");
        // Preceding-clause keywords must not leak in:
        for kw in [
            "where",
            "group",
            "having",
            "join",
            "union",
            "intersect",
            "except",
            "left",
            "right",
            "full",
            "cross",
            "inner",
            "as",
        ] {
            assert!(
                !cs.contains(&kw.to_string()),
                "preceding-clause keyword `{kw}` leaked into ORDER BY \
                 completion; got {cs:?}",
            );
        }
    }

    #[test]
    fn order_by_after_sort_item_offers_direction() {
        use crate::dsl::types::Type;
        // walk_repeated trailing-optional fix: after a complete
        // sort item the direction keywords surface as
        // continuations (previously discarded at the Repeated
        // boundary, so completion offered neither).
        let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
        let input = "select Name from Things order by Name ";
        let cs = cands_with(input, input.len(), &cache);
        assert!(cs.contains(&"asc".to_string()), "got {cs:?}");
        assert!(cs.contains(&"desc".to_string()), "got {cs:?}");
    }

    #[test]
    fn projection_after_item_offers_alias_keyword() {
        use crate::dsl::types::Type;
        // walk_repeated trailing-optional fix: after a complete
        // projection item the `as` alias keyword surfaces.
        let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
        let input = "select Name ";
        let cs = cands_with(input, input.len(), &cache);
        assert!(cs.contains(&"as".to_string()), "got {cs:?}");
    }

    #[test]
    fn create_table_after_column_spec_offers_constraints() {
        // walk_repeated trailing-optional fix: after a complete
        // column spec the optional column constraints surface as
        // continuations (was a bare "Submit with Enter" prose).
        let input = "create table Customers with pk Code(text) ";
        let cs = cands_with(input, input.len(), &SchemaCache::default());
        for kw in ["not", "unique", "default", "check"] {
            assert!(
                cs.contains(&kw.to_string()),
                "expected column-constraint `{kw}`; got {cs:?}",
            );
        }
    }

    #[test]
    fn identifiers_precede_keywords_at_expression_position() {
        use crate::dsl::types::Type;
        // ADR-0022 Amendment 2: at an expression position offering
        // both column names and keywords, every column precedes
        // every keyword so the names stay visible by default.
        let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
        let input = "select * from Things where ";
        let cs = cands_with(input, input.len(), &cache);
        let pos = |needle: &str| {
            cs.iter()
                .position(|c| c == needle)
                .unwrap_or_else(|| panic!("{needle:?} not in candidates: {cs:?}"))
        };
        // Both columns come before any expression-start keyword.
        let last_ident = pos("Name").max(pos("Qty"));
        let first_kw = pos("not").min(pos("exists"));
        assert!(
            last_ident < first_kw,
            "identifiers must precede keywords; got {cs:?}",
        );
    }

    #[test]
    fn update_where_offers_only_current_table_columns() {
        use crate::dsl::types::Type;
        let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
        cache.columns.push("OrderTotal".to_string());
        let cs = cands_with("update Customers set Email='x' where ", 37, &cache);
        assert!(cs.contains(&"id".to_string()), "got {cs:?}");
        assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
        assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
    }

    #[test]
    fn numeric_literal_in_where_is_not_flagged_as_invalid_column() {
        use crate::dsl::types::Type;
        // ADR-0026: the WHERE-expression operand accepts a
        // column reference *or* a literal. A number literal
        // (`1`) sits at a slot that also expects a column —
        // it must not be mis-flagged as an unknown column.
        let cache = schema_with_table("Customers", &[("id", Type::Int)]);
        let input = "delete from Customers where id=1";
        assert!(
            invalid_ident_at_cursor(input, input.len(), &cache).is_none(),
            "a numeric literal must not be reported as an invalid column",
        );
    }

    #[test]
    fn insert_into_open_paren_offers_current_table_columns() {
        use crate::dsl::types::Type;
        let cache = schema_with_table(
            "Customers",
            &[
                ("id", Type::Int),
                ("Email", Type::Text),
                ("Name", Type::Text),
            ],
        );
        let cs = cands_with("insert into Customers (", 23, &cache);
        // The user is at Form A's column-list position. All
        // three columns of Customers should appear so they can
        // pick.
        assert!(cs.contains(&"id".to_string()), "got {cs:?}");
        assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
        assert!(cs.contains(&"Name".to_string()), "got {cs:?}");
    }

    #[test]
    fn insert_into_open_paren_does_not_offer_unrelated_columns() {
        use crate::dsl::types::Type;
        let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
        cache.columns.push("OrderTotal".to_string());
        let cs = cands_with("insert into Customers (", 23, &cache);
        assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
    }

    #[test]
    fn drop_column_from_offers_only_current_table_columns() {
        // The DDL `TABLE_NAME_EXISTING` slot now sets
        // `writes_table` (handoff-13 §2.2 fix), so the
        // column-name slot after the table name narrows to that
        // table's columns. `OrderTotal` belongs to no table in
        // this cache's `table_columns`, so it must not leak.
        use crate::dsl::types::Type;
        let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
        cache.columns.push("OrderTotal".to_string());
        let cs = cands_with("drop column from Customers: ", 28, &cache);
        assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
        assert!(cs.contains(&"id".to_string()), "got {cs:?}");
        assert!(
            !cs.contains(&"OrderTotal".to_string()),
            "OrderTotal (not a Customers column) must not leak: got {cs:?}",
        );
    }

    #[test]
    fn open_paren_candidate_is_classified_as_punct_kind() {
        // The `(` candidate gets its own kind so the hint
        // renderer can colour it as punctuation rather than
        // mis-classifying it as a keyword.
        let comp = candidates_at_cursor("insert into Orders ", 19, &SchemaCache::default())
            .expect("some completion");
        let paren = comp
            .candidates
            .iter()
            .find(|c| c.text == "(")
            .expect("( present");
        assert_eq!(paren.kind, CandidateKind::Punct);
    }

    #[test]
    fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
        let comp =
            candidates_at_cursor("cre", 3, &SchemaCache::default()).expect("some completion");
        assert_eq!(comp.replaced_range, (0, 3));
        assert_eq!(comp.partial_prefix, "cre");
        assert_eq!(comp.candidates.len(), 1);
        assert_eq!(comp.candidates[0].text, "create");
        assert_eq!(comp.candidates[0].kind, CandidateKind::Keyword);
    }

    #[test]
    fn cursor_at_word_boundary_has_empty_partial_prefix() {
        let comp =
            candidates_at_cursor("create ", 7, &SchemaCache::default()).expect("some completion");
        assert_eq!(comp.replaced_range, (7, 7));
        assert_eq!(comp.partial_prefix, "");
    }

    // ---- type-name completion (round-3 follow-up #2) ----

    #[test]
    fn type_slot_offers_full_type_vocabulary_when_partial_empty() {
        // After `add column to T: Name (` the parser expects
        // a column type. With no partial typed, all nine types
        // from `Type::all()` are offered in declaration order.
        let cs = cands("add column to T: Name (", 23);
        assert_eq!(
            cs,
            vec![
                "text".to_string(),
                "int".to_string(),
                "real".to_string(),
                "decimal".to_string(),
                "bool".to_string(),
                "date".to_string(),
                "datetime".to_string(),
                "serial".to_string(),
                "shortid".to_string(),
            ],
        );
    }

    #[test]
    fn type_slot_narrows_to_prefix_matches() {
        // `de` matches only `decimal` (despite the surface
        // resemblance to `date`/`datetime`, those start with
        // `da`). The user-reported case from real testing
        // round 4.
        let cs = cands("add column to T: Name (de", 25);
        assert_eq!(cs, vec!["decimal".to_string()]);
    }

    #[test]
    fn type_slot_narrows_to_da_for_date_family() {
        // `da` correctly returns date and datetime — in
        // Type::all() declaration order (date before datetime,
        // matching ADR-0005's grouping).
        let cs = cands("add column to T: Name (da", 25);
        assert_eq!(cs, vec!["date".to_string(), "datetime".to_string()]);
    }

    #[test]
    fn type_slot_single_match_for_unique_prefix() {
        // `sh` uniquely identifies `shortid`.
        let cs = cands("add column to T: Name (sh", 25);
        assert_eq!(cs, vec!["shortid".to_string()]);
    }

    #[test]
    fn type_slot_no_match_for_invalid_prefix() {
        // `var` matches nothing — Tab is a no-op; the parser's
        // unknown-type custom error still fires on submit.
        let cs = cands("add column to T: Name (var", 26);
        assert!(cs.is_empty(), "got {cs:?}");
    }

    #[test]
    fn identifiers_come_before_keywords_in_grammar_order() {
        // "add column " has both schema-identifier candidates and
        // keyword candidates. Per ADR-0022 Amendment 2: schema
        // identifiers first (alphabetised) so the names the user
        // would have to look up stay visible, then keywords in
        // *grammar order* (`to` before `table` because the
        // canonical shape is `add column [to] [table] <Table>:…`).
        // The grammar order falls out of the walker's source-order
        // expected-set traversal — we preserve that order through
        // `describe_expected`.
        let cache = SchemaCache {
            tables: vec!["Customers".to_string(), "Orders".to_string()],
            ..SchemaCache::default()
        };
        let kinds = cand_kinds_with("add column ", 11, &cache);
        assert_eq!(
            kinds,
            vec![
                ("Customers".to_string(), CandidateKind::Identifier),
                ("Orders".to_string(), CandidateKind::Identifier),
                ("to".to_string(), CandidateKind::Keyword),
                ("table".to_string(), CandidateKind::Keyword),
            ],
        );
    }

    #[test]
    fn keyword_wins_when_keyword_text_collides_with_schema_name() {
        // Pathological: a table named "table". Keywords
        // dominate the slot — the user can still reference
        // their table via different syntax.
        let cache = SchemaCache {
            tables: vec!["table".to_string(), "Customers".to_string()],
            ..SchemaCache::default()
        };
        let kinds = cand_kinds_with("add column ", 11, &cache);
        // `table` appears once, as a keyword (not duplicated
        // as identifier).
        let table_entries: Vec<_> = kinds.iter().filter(|(t, _)| t == "table").collect();
        assert_eq!(table_entries.len(), 1);
        assert_eq!(table_entries[0].1, CandidateKind::Keyword);
    }

    // ---- SchemaCache + identifier completion (stage 8c) ----

    #[test]
    fn schema_cache_offers_table_names_at_table_slot() {
        let cache = SchemaCache {
            tables: vec!["Customers".to_string(), "Orders".to_string()],
            columns: vec![],
            relationships: vec![],
            ..SchemaCache::default()
        };
        // After `show data ` the parser expects a table name.
        let cs = cands_with("show data ", 10, &cache);
        assert_eq!(cs, vec!["Customers".to_string(), "Orders".to_string()]);
    }

    #[test]
    fn insert_into_offers_table_names_at_target_slot() {
        // 3k cross-cut (matrix A3): after `insert into ` the target
        // table slot completes to the schema's table names.
        let cache = SchemaCache {
            tables: vec!["Customers".to_string(), "Orders".to_string()],
            columns: vec![],
            relationships: vec![],
            ..SchemaCache::default()
        };
        let cs = cands_with("insert into ", 12, &cache);
        assert_eq!(cs, vec!["Customers".to_string(), "Orders".to_string()]);
    }

    #[test]
    fn schema_cache_offers_column_names_at_column_slot() {
        let cache = SchemaCache {
            tables: vec!["Customers".to_string()],
            columns: vec!["Email".to_string(), "Name".to_string()],
            relationships: vec![],
            ..SchemaCache::default()
        };
        // After `drop column from Customers: ` the parser
        // expects a column name (existing).
        let cs = cands_with("drop column from Customers: ", 28, &cache);
        assert_eq!(cs, vec!["Email".to_string(), "Name".to_string()]);
    }

    #[test]
    fn schema_cache_offers_relationship_names_at_relationship_slot() {
        let cache = SchemaCache {
            tables: vec![],
            columns: vec![],
            relationships: vec!["cust_orders".to_string(), "ord_items".to_string()],
            ..SchemaCache::default()
        };
        // After `drop relationship ` the parser expects either
        // an identifier (relationship name) or `from`. Schema
        // candidates plus the `from` keyword.
        let cs = cands_with("drop relationship ", 18, &cache);
        assert!(cs.contains(&"cust_orders".to_string()));
        assert!(cs.contains(&"ord_items".to_string()));
        assert!(cs.contains(&"from".to_string()));
    }

    #[test]
    fn schema_candidates_filtered_by_partial_prefix() {
        let cache = SchemaCache {
            tables: vec!["Customers".to_string(), "Orders".to_string()],
            columns: vec![],
            relationships: vec![],
            ..SchemaCache::default()
        };
        // Typed `Cu` after `show data ` — only `Customers`
        // matches.
        let cs = cands_with("show data Cu", 12, &cache);
        assert_eq!(cs, vec!["Customers".to_string()]);
    }

    #[test]
    fn empty_cache_at_table_slot_returns_no_candidates() {
        // Pre-population (or NewName-only positions) yield no
        // identifier candidates.
        let cache = SchemaCache::default();
        let cs = cands_with("show data ", 10, &cache);
        assert!(cs.is_empty(), "got {cs:?}");
    }

    // ---- typing_name_at_cursor (round-3 follow-up) ----

    #[test]
    fn typing_name_fires_at_new_column_slot_with_next_token() {
        // After `add column to table T: ` the parser expects
        // an identifier (NewName slot) followed by `(`. The
        // probe substitutes a placeholder name and reads back
        // that the next token is `(`.
        let t = typing_name_at_cursor("add column to table T: ", 23)
            .expect("should fire at NewName slot");
        assert_eq!(t.next_after_name.as_deref(), Some("`(`"));
    }

    #[test]
    fn typing_name_fires_when_partial_name_already_typed() {
        // Mid-typing the column name. typing_name_at_cursor
        // walks back over the partial to find the slot, then
        // probes forward as if the partial were a complete name.
        let t = typing_name_at_cursor("add column to table T: Na", 25)
            .expect("should fire at NewName slot with partial");
        assert_eq!(t.next_after_name.as_deref(), Some("`(`"));
    }

    #[test]
    fn typing_name_does_not_fire_at_table_name_slot() {
        // `show data ` — the slot is TableName, not NewName.
        // The candidates path (or invalid-ident) handles it;
        // typing_name should not fire.
        assert!(typing_name_at_cursor("show data ", 10).is_none());
    }

    #[test]
    fn typing_name_does_not_fire_at_keyword_slot() {
        // `cr` at position 2 is a keyword slot.
        assert!(typing_name_at_cursor("cr", 2).is_none());
    }

    #[test]
    fn typing_name_yields_no_next_when_probe_succeeds() {
        // `add column to table T: Name (text)` — the user is
        // inside `Name`, and substituting any name there
        // produces a complete command. No useful "next after
        // name" hint.
        let t =
            typing_name_at_cursor("add column to table T: Name (text)", 27).expect("should fire");
        assert_eq!(t.next_after_name, None);
    }

    // ---- invalid_ident_at_cursor (stage 8e) ----

    #[test]
    fn invalid_ident_fires_for_unknown_table_prefix() {
        let cache = SchemaCache {
            tables: vec!["Customers".to_string()],
            ..SchemaCache::default()
        };
        // `show data Cust` matches → no invalid.
        assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
        // `show data Cust` plus a typo: `show data Custp`. No
        // table starts with "Custp" → invalid.
        let invalid =
            invalid_ident_at_cursor("show data Custp", 15, &cache).expect("should be invalid");
        assert_eq!(invalid.range, (10, 15));
        assert_eq!(invalid.found, "Custp");
        assert_eq!(invalid.source, IdentSource::Tables);
    }

    #[test]
    fn invalid_ident_does_not_fire_when_partial_matches_some_schema_entry() {
        let cache = SchemaCache {
            tables: vec!["Customers".to_string(), "Orders".to_string()],
            ..SchemaCache::default()
        };
        // "C" matches Customers (prefix), so not invalid.
        assert!(invalid_ident_at_cursor("show data C", 11, &cache).is_none());
    }

    #[test]
    fn invalid_ident_does_not_fire_in_new_name_slot() {
        // `create table Cust` — Cust is a NewName slot. Even
        // if no schema entry matches, the user invents the
        // name; not invalid.
        let cache = SchemaCache {
            tables: vec!["Existing".to_string()],
            ..SchemaCache::default()
        };
        assert!(invalid_ident_at_cursor("create table Cust", 17, &cache).is_none());
    }

    #[test]
    fn invalid_ident_does_not_fire_when_cursor_not_at_partial_token() {
        let cache = SchemaCache::default();
        // Cursor at a whitespace position — no partial token.
        assert!(invalid_ident_at_cursor("show data ", 10, &cache).is_none());
    }

    #[test]
    fn invalid_ident_does_not_fire_at_keyword_slot() {
        // `cra` at the entry-keyword position — no keyword
        // starts with "cra", but the slot is keyword (not a
        // known-schema slot), so invalid_ident doesn't fire.
        // The render path's regular parse-error overlay handles
        // this case.
        let cache = SchemaCache::default();
        assert!(invalid_ident_at_cursor("cra", 3, &cache).is_none());
    }

    #[test]
    fn new_name_slot_offers_no_candidates_even_with_populated_cache() {
        // `create table ` — the table-name slot is NewName, so even
        // with a populated cache no *schema* candidates are offered
        // (the user invents the name). In advanced mode the sole
        // candidate here is the optional `if` keyword (the
        // `IF NOT EXISTS` prefix, ADR-0035 §4) — never a cached
        // table/column name.
        let cache = SchemaCache {
            tables: vec!["Existing".to_string()],
            columns: vec!["AlsoExisting".to_string()],
            relationships: vec![],
            ..SchemaCache::default()
        };
        let cs = cands_with("create table ", 13, &cache);
        assert!(
            !cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"),
            "NewName slot must not surface schema candidates; got {cs:?}"
        );
        assert_eq!(
            cs,
            vec!["if".to_string()],
            "only the advanced IF NOT EXISTS keyword"
        );
    }

    fn keyword_cand(text: &str) -> Candidate {
        Candidate {
            text: text.to_string(),
            kind: CandidateKind::Keyword,
            mode: ModeClass::Both,
        }
    }

    #[test]
    fn last_completion_next_idx_wraps_around() {
        let mut memo = LastCompletion {
            inserted_range: (0, 0),
            original_text: String::new(),
            candidates: vec![keyword_cand("a"), keyword_cand("b"), keyword_cand("c")],
            selection_idx: 0,
        };
        assert_eq!(memo.next_idx(), 1);
        memo.selection_idx = 1;
        assert_eq!(memo.next_idx(), 2);
        memo.selection_idx = 2;
        assert_eq!(memo.next_idx(), 0);
    }

    #[test]
    fn last_completion_prev_idx_wraps_around() {
        let mut memo = LastCompletion {
            inserted_range: (0, 0),
            original_text: String::new(),
            candidates: vec![keyword_cand("a"), keyword_cand("b"), keyword_cand("c")],
            selection_idx: 0,
        };
        assert_eq!(memo.prev_idx(), 2);
        memo.selection_idx = 2;
        assert_eq!(memo.prev_idx(), 1);
        memo.selection_idx = 1;
        assert_eq!(memo.prev_idx(), 0);
    }

    // ---- Ranker hook (ADR-0024 §ranker-layer) ----

    #[test]
    fn identity_ranker_preserves_input_order() {
        let input = vec![
            Candidate {
                text: "b".to_string(),
                kind: CandidateKind::Keyword,
                mode: ModeClass::Both,
            },
            Candidate {
                text: "a".to_string(),
                kind: CandidateKind::Keyword,
                mode: ModeClass::Both,
            },
            Candidate {
                text: "c".to_string(),
                kind: CandidateKind::Identifier,
                mode: ModeClass::Both,
            },
        ];
        let out = identity_ranker(input.clone());
        assert_eq!(out, input);
    }

    #[test]
    fn ranker_can_reorder_candidates() {
        // Hooks like frequency-based ranking or content-aware
        // priors plug in through `Ranker` without touching the
        // grammar. Smoke-test the call site with a sorter.
        fn alphabetic_ranker(mut c: Vec<Candidate>) -> Vec<Candidate> {
            c.sort_by(|a, b| a.text.cmp(&b.text));
            c
        }
        // `add ` exposes `column`, `1:n`, `index` and
        // `constraint` — the alphabetic ranker reorders them.
        let cache = SchemaCache::default();
        let comp = candidates_at_cursor_with("add ", 4, &cache, alphabetic_ranker)
            .expect("some completion");
        let texts: Vec<String> = comp.candidates.into_iter().map(|c| c.text).collect();
        assert_eq!(
            texts,
            vec![
                "1:n".to_string(),
                "column".to_string(),
                "constraint".to_string(),
                "index".to_string(),
            ]
        );
    }

    // ---- ADR-0032 §10.5 qualified-prefix completion ----

    // ---- SQL function-name completion (issue #15) ----

    #[test]
    fn sql_expr_slot_offers_known_function_candidates() {
        // Issue #15: at a `sql_expr_ident` slot Tab offers the curated
        // function names so a learner can discover them.
        let cache = two_table_schema();
        let cs = cands_with("select * from a where ", 22, &cache);
        for f in ["sum", "avg", "count", "upper", "coalesce"] {
            assert!(
                cs.contains(&f.to_string()),
                "expected function `{f}` offered at WHERE expr slot; got {cs:?}",
            );
        }
    }

    #[test]
    fn projection_slot_offers_known_function_candidates() {
        // Issue #15's headline example: at `select ` (the projection
        // slot, empty prefix) the functions must surface alongside
        // columns — the projection slot is also `sql_expr_ident`, and
        // it must not be swallowed by the value-literal suppression
        // (it carries a schema-ident expectation, so it isn't).
        let cache = two_table_schema();
        let cs = cands_with("select ", 7, &cache);
        for f in ["sum", "count", "upper"] {
            assert!(
                cs.contains(&f.to_string()),
                "expected function `{f}` offered at the projection slot; got {cs:?}",
            );
        }
    }

    #[test]
    fn sql_function_candidates_filter_by_prefix() {
        // `su` narrows to the functions starting `su` — `sum`,
        // `substr` — and excludes non-matches.
        let cache = two_table_schema();
        let input = "select * from a where su";
        let cs = cands_with(input, input.len(), &cache);
        assert!(cs.contains(&"sum".to_string()), "got {cs:?}");
        assert!(cs.contains(&"substr".to_string()), "got {cs:?}");
        assert!(
            !cs.contains(&"avg".to_string()),
            "`avg` does not prefix-match `su`; got {cs:?}",
        );
    }

    #[test]
    fn sql_function_candidates_carry_function_kind() {
        // The hint panel colours functions via their own kind.
        let cache = two_table_schema();
        let input = "select * from a where su";
        let cands = candidates_at_cursor(input, input.len(), &cache)
            .expect("some completion")
            .candidates;
        let sum = cands
            .iter()
            .find(|c| c.text == "sum")
            .expect("`sum` present");
        assert_eq!(sum.kind, CandidateKind::Function);
    }

    #[test]
    fn function_candidates_absent_at_non_expression_slots() {
        // A non-`sql_expr_ident` slot (the table slot of `show data `)
        // must not surface function names.
        let cache = SchemaCache {
            tables: vec!["Customers".to_string()],
            ..SchemaCache::default()
        };
        let cs = cands_with("show data ", 10, &cache);
        for f in ["sum", "count", "upper"] {
            assert!(
                !cs.contains(&f.to_string()),
                "function `{f}` must not appear at the table slot; got {cs:?}",
            );
        }
    }

    #[test]
    fn function_candidate_deduped_against_a_like_named_column() {
        // DA review (#15): a column literally named like a function
        // (`count`) must appear exactly once in the candidate line —
        // the column wins, the redundant function candidate is dropped.
        let mut cache = SchemaCache {
            tables: vec!["a".to_string()],
            columns: vec!["count".to_string()],
            ..SchemaCache::default()
        };
        cache
            .table_columns
            .insert("a".to_string(), vec![TableColumn::new("count", Type::Int)]);
        let input = "select * from a where co";
        let cands = candidates_at_cursor(input, input.len(), &cache)
            .expect("some completion")
            .candidates;
        let count_entries: Vec<_> = cands
            .iter()
            .filter(|c| c.text.eq_ignore_ascii_case("count"))
            .collect();
        assert_eq!(
            count_entries.len(),
            1,
            "`count` must appear once, not duplicated as column + function; got {count_entries:?}",
        );
        assert_eq!(
            count_entries[0].kind,
            CandidateKind::Identifier,
            "the surviving `count` candidate must be the user's column",
        );
        // A non-colliding function at the same slot is unaffected.
        assert!(
            cands
                .iter()
                .any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
            "non-colliding functions still surface; got {cands:?}",
        );
    }

    #[test]
    fn cast_is_not_offered_as_a_function_candidate() {
        // `cast` uses `CAST(x AS type)`, which the call-shape grammar
        // does not parse — it must never be offered (ADR-0031 call
        // shape; sql_functions.rs exclusion).
        let cache = two_table_schema();
        let input = "select * from a where ca";
        let cs = cands_with(input, input.len(), &cache);
        assert!(
            !cs.contains(&"cast".to_string()),
            "`cast` must not be offered as a function candidate; got {cs:?}",
        );
    }

    // ---- typing-time column-typo hint restored (issue #16) ----

    #[test]
    fn invalid_ident_fires_for_genuine_typo_at_sql_expr_slot() {
        // Issue #16: a genuine column typo at a `sql_expr_ident` slot
        // (before FROM is in scope) warns at typing time again — it
        // matches neither a schema column nor a known function name.
        let cache = two_table_schema();
        let inv = invalid_ident_at_cursor("select Agx", 10, &cache)
            .expect("genuine typo at an expr slot must flag");
        assert_eq!(inv.found, "Agx");
        assert_eq!(inv.source, IdentSource::Columns);
    }

    #[test]
    fn invalid_ident_does_not_fire_for_function_prefix_at_sql_expr_slot() {
        // The reason issue #6 dropped the flag: a known function name
        // (or its prefix) must NOT be mis-flagged as an unknown column.
        let cache = two_table_schema();
        assert!(
            invalid_ident_at_cursor("select su", 9, &cache).is_none(),
            "`su` prefixes `sum`/`substr` — must not flag",
        );
        assert!(
            invalid_ident_at_cursor("select sum", 10, &cache).is_none(),
            "`sum` is a known function — must not flag",
        );
    }

    #[test]
    fn invalid_ident_does_not_fire_for_column_prefix_at_sql_expr_slot() {
        // A real column prefix is an in-progress lookup, not a typo.
        let cache = two_table_schema();
        assert!(
            invalid_ident_at_cursor("select na", 9, &cache).is_none(),
            "`na` prefixes the `name` column — must not flag",
        );
    }

    #[test]
    fn invalid_ident_does_not_flag_a_table_alias_used_before_its_from_clause() {
        // Manual-testing bug: in `select … sum(ol.count*…) … from … OrderLines ol …`
        // the projection references alias `ol` whose FROM binding sits
        // *after* the cursor. The leading-only walk had an empty from-scope
        // and wrongly flagged `ol` as an unknown column (a red "ERR" overlay
        // on an otherwise-valid query). The full-input lookahead must
        // recover the scope (ADR-0032 §10.6) so `ol` is not flagged.
        use crate::dsl::types::Type;
        let mut s = SchemaCache::default();
        s.tables.push("OrderLines".into());
        s.columns.push("count".into());
        s.table_columns.insert(
            "OrderLines".into(),
            vec![TableColumn::new("count", Type::Int)],
        );
        let input = "select sum(ol.count) from OrderLines ol";
        let cursor = input.find("ol.count").unwrap() + 2; // right after `ol`
        assert!(
            invalid_ident_at_cursor_in_mode(input, cursor, &s, Mode::Advanced).is_none(),
            "a table alias used before its FROM clause must not be flagged as a bad column",
        );
    }

    #[test]
    fn invalid_ident_fires_for_unknown_generator_after_as() {
        // ADR-0048 D9: an unknown name at the `set <col> as <gen>` slot is
        // flagged `[ERR]` while typing.
        let cache = two_table_schema();
        let input = "seed a set name as bogus";
        let inv = invalid_ident_at_cursor(input, input.len(), &cache)
            .expect("unknown generator must flag");
        assert_eq!(inv.found, "bogus");
        assert_eq!(inv.source, IdentSource::Generators);
    }

    #[test]
    fn invalid_ident_fires_for_unknown_column_in_seed_set_and_column_fill() {
        // ADR-0048: an unknown column at the `set <col>` slot and the
        // `<table>.<col>` column-fill slot is flagged like any other
        // column slot (both are `IdentSource::Columns`).
        let cache = two_table_schema(); // table `a`; columns id, name
        let set_in = invalid_ident_at_cursor("seed a set xyz", 14, &cache)
            .expect("unknown column in `set` must flag");
        assert_eq!(set_in.found, "xyz");
        assert_eq!(set_in.source, IdentSource::Columns);

        let fill = invalid_ident_at_cursor("seed a.xyz", 10, &cache)
            .expect("unknown column in column-fill must flag");
        assert_eq!(fill.source, IdentSource::Columns);
    }

    #[test]
    fn invalid_ident_does_not_fire_for_generator_prefix() {
        // A prefix of a known generator is an in-progress name, not a typo.
        let cache = two_table_schema();
        assert!(
            invalid_ident_at_cursor("seed a set name as ema", 22, &cache).is_none(),
            "`ema` prefixes `email` — must not flag",
        );
        assert!(
            invalid_ident_at_cursor("seed a set name as email", 24, &cache).is_none(),
            "`email` is a known generator — must not flag",
        );
    }

    fn two_table_schema() -> SchemaCache {
        use crate::dsl::types::Type;
        let mut s = SchemaCache::default();
        s.tables.push("a".to_string());
        s.tables.push("b".to_string());
        s.columns.push("id".to_string());
        s.columns.push("name".to_string());
        s.columns.push("total".to_string());
        s.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,
                },
            ],
        );
        s.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,
                },
            ],
        );
        s
    }

    #[test]
    fn qualified_prefix_narrows_to_table_columns() {
        // `select a.|` — candidates should be a's columns
        // alone, not the union of a's and b's.
        let cache = two_table_schema();
        let input = "select a.";
        let cs = cands_with(input, input.len(), &cache);
        assert!(
            cs.contains(&"id".to_string()) && cs.contains(&"name".to_string()),
            "expected a's columns; got {cs:?}",
        );
        assert!(
            !cs.contains(&"total".to_string()),
            "b's `total` must NOT appear under `a.|`; got {cs:?}",
        );
    }

    #[test]
    fn excluded_prefix_completes_to_target_columns() {
        // ADR-0033 §9: `excluded.|` inside a DO UPDATE action
        // completes to the INSERT target table's columns.
        let cache = two_table_schema();
        let input = "insert into a (id, name) values (1, 'x') \
                     on conflict (id) do update set name = excluded.";
        let cs = cands_with(input, input.len(), &cache);
        assert!(
            cs.contains(&"id".to_string()) && cs.contains(&"name".to_string()),
            "excluded.| should offer the target table's columns; got {cs:?}",
        );
        assert!(
            !cs.contains(&"total".to_string()),
            "a column from an unrelated table must not appear; got {cs:?}",
        );
    }

    #[test]
    fn qualified_prefix_with_partial_filters_prefix() {
        // `select a.na` — only `name` (starts with `na`).
        let cache = two_table_schema();
        let input = "select a.na";
        let cs = cands_with(input, input.len(), &cache);
        assert_eq!(cs, vec!["name".to_string()]);
    }

    #[test]
    fn qualified_prefix_alias_narrows_through_alias() {
        // `select * from a x where x.|` — at the cursor, the
        // walker has seen the FROM clause, so `x` is bound to
        // `a`. The qualified-prefix resolves through the alias
        // and narrows to a's columns. (The mid-projection
        // case `select x.| from a x` falls under §10.6's
        // projection-before-FROM problem and is handled by
        // the post-walk fixup pass, not by this engine.)
        let cache = two_table_schema();
        let input = "select * from a x where x.";
        let cs = cands_with(input, input.len(), &cache);
        assert!(
            cs.contains(&"id".to_string()) && cs.contains(&"name".to_string()),
            "expected a's columns via alias `x`; got {cs:?}",
        );
        assert!(
            !cs.contains(&"total".to_string()),
            "b's columns must NOT appear under alias `x` of `a`; got {cs:?}",
        );
    }

    #[test]
    fn qualified_prefix_unresolved_qualifier_no_columns() {
        // `select z.|` where `z` resolves to nothing — empty
        // column candidate list (the structural error path
        // surfaces the unresolved-prefix message).
        let cache = two_table_schema();
        let input = "select z.";
        let cs = cands_with(input, input.len(), &cache);
        // The candidate list should NOT include b's columns
        // (the bug we're fixing — global fallback).
        assert!(
            !cs.contains(&"total".to_string()),
            "unknown qualifier must not fall back to global columns; got {cs:?}",
        );
    }

    #[test]
    fn qualified_prefix_cte_columns_narrow() {
        // `with x as (select id, name from a) select x.|`
        // should offer `id`, `name` only (x's harvested
        // columns).
        let cache = two_table_schema();
        let input = "with x as (select id, name from a) select x.";
        let cs = cands_with(input, input.len(), &cache);
        assert!(
            cs.contains(&"id".to_string()) && cs.contains(&"name".to_string()),
            "expected x's harvested columns; got {cs:?}",
        );
        assert!(
            !cs.contains(&"total".to_string()),
            "b's `total` must not appear under `x.|`; got {cs:?}",
        );
    }

    // ---- Look-ahead probe for the edit scenario ----

    #[test]
    fn lookahead_narrows_unqualified_completion_when_from_follows_cursor() {
        // User edits an existing `select X from a` query, cursor
        // sits at the start of the partial column name. Leading
        // walk sees only `select ` → from_scope is empty. Full
        // input `select X from a` parses; look-ahead picks up
        // `a`, and the column candidates narrow to a's columns.
        let cache = two_table_schema();
        let input = "select X from a";
        // Cursor on the `X` so the partial prefix is `X` and
        // look-ahead has a parseable full input.
        let cursor = "select ".len();
        let cs = cands_with(input, cursor, &cache);
        // The partial `X` is shadowed by a partial-prefix filter
        // so the candidates that come back must START with `X`
        // (case-insensitive). Neither `id`/`name`/`total` start
        // with `X`, so the candidate set is empty rather than
        // global. Move the cursor to before the partial to test
        // narrowing instead.
        assert!(
            !cs.contains(&"total".to_string()),
            "global fallback must not fire; got {cs:?}",
        );
        // Empty-prefix narrowing test:
        let input2 = "select n from a";
        let cursor2 = "select ".len();
        let cs2 = cands_with(input2, cursor2, &cache);
        assert!(
            cs2.contains(&"name".to_string()),
            "expected a's `name` via look-ahead; got {cs2:?}",
        );
        assert!(
            !cs2.contains(&"total".to_string()),
            "b's `total` must NOT appear when FROM is `a` only; got {cs2:?}",
        );
    }

    #[test]
    fn lookahead_qualified_resolves_alias_with_from_after_cursor() {
        // `select x.id from a x` — leading walks `select x.`
        // (no FROM bound yet). Look-ahead walks the full input;
        // x is bound to `a`. The qualified prefix narrows
        // candidates to a's columns alone.
        let cache = two_table_schema();
        let input = "select x.id from a x";
        let cursor = "select x.".len();
        let cs = cands_with(input, cursor, &cache);
        // Partial prefix is `id`, so candidates that match must
        // start with `id`. Only `id` itself does — and it does
        // appear, meaning the qualifier resolved correctly.
        assert!(
            cs.contains(&"id".to_string()),
            "expected `id` via alias `x` look-ahead; got {cs:?}",
        );
        // Empty-prefix variant: cursor right after `x.` (move
        // back from `.id` to `.`).
        let input2 = "select x. from a x";
        // Doesn't parse cleanly mid-projection — leading walk
        // alone produces the right scope ONLY if the full input
        // happens to parse. Use a where-clause anchor instead:
        let input3 = "select * from a x where x. = 1";
        let cursor3 = "select * from a x where x.".len();
        let cs3 = cands_with(input3, cursor3, &cache);
        assert!(
            cs3.contains(&"id".to_string()) && cs3.contains(&"name".to_string()),
            "expected a's columns under alias `x` in WHERE; got {cs3:?}",
        );
        assert!(
            !cs3.contains(&"total".to_string()),
            "b's columns must NOT appear under alias `x` of `a`; got {cs3:?}",
        );
        let _ = input2;
    }

    #[test]
    fn lookahead_multi_table_from_unions_columns() {
        // Two bindings in scope via look-ahead — candidates are
        // the union of both, deduplicated.
        let cache = two_table_schema();
        let input = "select n from a join b on a.id = b.id";
        let cursor = "select ".len();
        let cs = cands_with(input, cursor, &cache);
        assert!(cs.contains(&"name".to_string()));
        assert!(cs.contains(&"total".to_string()));
    }

    #[test]
    fn sql_expr_column_completion_inside_where() {
        // ADR-0031 §5 — column completion works for
        // IdentSource::Columns slots inside SQL expressions.
        // At `select * from a where i|`, the partial prefix `i`
        // walks `where`'s sql_expr, expects an Ident{Columns},
        // and offers `id` (a's column starting with `i`).
        let cache = two_table_schema();
        let input = "select * from a where i";
        let cursor = input.len();
        let cs = cands_with(input, cursor, &cache);
        assert!(
            cs.contains(&"id".to_string()),
            "expected `id` candidate via sql_expr WHERE column slot; got {cs:?}",
        );
    }

    #[test]
    fn sql_expr_column_completion_inside_having() {
        let cache = two_table_schema();
        let input = "select * from a group by id having n";
        let cursor = input.len();
        let cs = cands_with(input, cursor, &cache);
        assert!(
            cs.contains(&"name".to_string()),
            "expected `name` candidate via sql_expr HAVING column slot; got {cs:?}",
        );
    }

    #[test]
    fn lookahead_with_partial_prefix_filters_correctly() {
        // `select na| from a` — narrowing via look-ahead +
        // partial-prefix filter yields just `name`.
        let cache = two_table_schema();
        let input = "select na from a";
        let cursor = "select na".len();
        let cs = cands_with(input, cursor, &cache);
        assert_eq!(cs, vec!["name".to_string()]);
    }

    #[test]
    fn ranker_can_filter_to_empty() {
        // A ranker that returns an empty list collapses the
        // completion to `None`.
        fn empty_ranker(_: Vec<Candidate>) -> Vec<Candidate> {
            Vec::new()
        }
        let cache = SchemaCache::default();
        assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
    }
}
