//! DSL parser (ADR-0024).
//!
//! The chumsky+lexer pipeline has been retired (ADR-0024 §migration
//! Phase F minimal). `parse_command` now routes every input through
//! the unified-grammar walker in `crate::dsl::walker`. The walker
//! reads source bytes directly — there is no separate token pre-pass.
//!
//! This module remains the public entry point for parsing because
//! consumers depend on `ParseError`'s shape (the `expected`,
//! `position`, `at_eof` fields drive completion, hint rendering,
//! and the input-renderer's error overlay). It also produces the
//! synthetic "unknown command" error when the input's first
//! identifier-shape token isn't a registered entry word.

use tracing::trace;

use crate::dsl::command::Command;
use crate::mode::Mode;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
    Invalid {
        message: String,
        position: usize,
        /// True when the parse failed because more input was
        /// expected — i.e. a structural failure with no
        /// next-token to point at. Used by the input renderer
        /// (ADR-0022 §4) to distinguish "incomplete but
        /// plausible" from "definite error" mid-typing.
        ///
        /// Custom errors raised by `try_map` are conservatively
        /// classified as `at_eof = true` because we cannot, at
        /// this layer, tell apart "tables need at least one
        /// column" (incomplete: more input would help) from
        /// "--force-conversion and --dont-convert are mutually
        /// exclusive" (definite: user must remove a token).
        /// Erring on `true` means custom-error inputs do not
        /// get a live error overlay; the parse error still
        /// fires on submit. A future refinement may carry an
        /// explicit `is_definite` tag through custom errors.
        at_eof: bool,
        /// Human-rendered names of patterns the parser was
        /// looking for at the failure point: `\`create\``,
        /// `identifier`, etc. Same forms `humanise()` uses
        /// inside the `message` sentence, but as discrete
        /// items so callers (the hint panel, ADR-0022 §6)
        /// can render them in their own framing. Empty for
        /// custom errors (which have no expected-set
        /// framing).
        expected: Vec<String>,
    },
    Empty,
}

impl std::fmt::Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Invalid { message, .. } => {
                f.write_str(&crate::t!("parse.error_wrapper", detail = message,))
            }
            Self::Empty => f.write_str(&crate::t!("parse.empty")),
        }
    }
}

impl std::error::Error for ParseError {}

impl ParseError {
    #[must_use]
    pub const fn position(&self) -> Option<usize> {
        match self {
            Self::Invalid { position, .. } => Some(*position),
            Self::Empty => None,
        }
    }

    #[must_use]
    pub const fn at_eof(&self) -> bool {
        match self {
            Self::Invalid { at_eof, .. } => *at_eof,
            Self::Empty => true,
        }
    }
}

/// Parse a single DSL command end-to-end.
///
/// Routes through the unified-grammar walker (ADR-0024
/// §architecture). If the walker doesn't engage (the input's
/// first identifier-shape token isn't a registered entry word),
/// produces a synthetic "unknown command" error naming every
/// valid entry keyword.
///
/// Schemaless variant: schema-aware nodes
/// (`Ident { source: Tables }` with `writes_table` enabled,
/// `DynamicSubgrammar`) fall back to schema-unaware behaviour.
/// Use `parse_command_with_schema` to enable typed value slots
/// (ADR-0024 §Phase D).
///
/// Defaults to **advanced**-mode grammar (the full surface) —
/// callers that need simple-mode gating (ADR-0030 §2) use
/// [`parse_command_in_mode`] or
/// [`parse_command_with_schema_in_mode`] instead.
pub fn parse_command(input: &str) -> Result<Command, ParseError> {
    parse_command_inner(input, None, Mode::Advanced)
}

/// Schema-aware parse entry point (ADR-0024 §Phase D).
///
/// Threads a `SchemaCache` reference through `WalkContext` so
/// the walker can populate `current_table` / `current_column`
/// from existing entities and `DynamicSubgrammar` factories
/// can unfold per-column typed value slots.
///
/// Defaults to **advanced**-mode grammar; for simple-mode
/// gating use [`parse_command_with_schema_in_mode`].
pub fn parse_command_with_schema(
    input: &str,
    schema: &crate::completion::SchemaCache,
) -> Result<Command, ParseError> {
    parse_command_inner(input, Some(schema), Mode::Advanced)
}

/// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple`
/// the walker gates SQL-only commands and produces the
/// "this is SQL" hint instead of executing them.
pub fn parse_command_in_mode(input: &str, mode: Mode) -> Result<Command, ParseError> {
    parse_command_inner(input, None, mode)
}

/// Schema-aware, mode-aware parse.
///
/// Combines ADR-0024 §Phase D (schema-aware typed slots) with
/// ADR-0030 §2 (mode-gated SQL grammar). The execution path
/// (`App::dispatch_dsl`) and the live overlay / completion /
/// highlight call sites use this so simple-mode users do not
/// see advanced-mode SQL surfaced.
pub fn parse_command_with_schema_in_mode(
    input: &str,
    schema: &crate::completion::SchemaCache,
    mode: Mode,
) -> Result<Command, ParseError> {
    parse_command_inner(input, Some(schema), mode)
}

fn parse_command_inner(
    input: &str,
    schema: Option<&crate::completion::SchemaCache>,
    mode: Mode,
) -> Result<Command, ParseError> {
    // `trace`, not `debug`: parsing is a hot path — the live overlay /
    // completion (completion.rs) re-parse per keystroke, probing
    // candidates in a loop, so a per-parse `debug` line would flood. The
    // executed-command story lives at `debug` in db.rs (one per submit).
    trace!(
        len = input.len(),
        mode = ?mode,
        schema_aware = schema.is_some(),
        "parse: begin"
    );
    if input.trim().is_empty() {
        trace!("parse: empty input");
        return Err(ParseError::Empty);
    }
    let result =
        try_walker_route(input, schema, mode).unwrap_or_else(|| Err(unknown_command_error(input)));
    match &result {
        Ok(cmd) => trace!(command = cmd.verb(), "parse: ok"),
        Err(e) => trace!(error = %e, "parse: rejected"),
    }
    result
}

/// Synthetic ParseError for inputs whose first identifier-shape
/// token isn't a registered command entry word.
fn unknown_command_error(source: &str) -> ParseError {
    use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
    let entries: Vec<String> = crate::dsl::grammar::entry_words_alphabetised()
        .into_iter()
        .map(|w| format!("`{w}`"))
        .collect();
    let joined = oxford_join(&entries);
    let start = skip_whitespace(source, 0);
    let (position, found_word) = consume_ident(source, start)
        .map_or_else(|| (start, None), |(s, e)| (s, Some(&source[s..e])));
    let message = found_word.map_or_else(
        || format!("expected one of {joined}"),
        |w| format!("expected one of {joined}, found `{w}`"),
    );
    ParseError::Invalid {
        message,
        position,
        at_eof: false,
        expected: entries,
    }
}

/// Walker route. Returns `None` when the walker doesn't engage
/// (input doesn't start with a registered entry keyword); the
/// router falls through to the synthetic "unknown command"
/// error.
fn try_walker_route(
    source: &str,
    schema: Option<&crate::completion::SchemaCache>,
    mode: Mode,
) -> Option<Result<Command, ParseError>> {
    use crate::dsl::walker::{self, outcome::WalkBound};
    let mut ctx = schema.map_or_else(walker::context::WalkContext::new, |s| {
        walker::context::WalkContext::with_schema(s)
    });
    ctx.mode = mode;
    let (result, command) = walker::walk(source, WalkBound::EndOfInput, &mut ctx);
    let result = result?;
    Some(walker_outcome_to_parse_result(source, result, command))
}

fn walker_outcome_to_parse_result(
    source: &str,
    result: crate::dsl::walker::outcome::WalkResult,
    command: Option<Command>,
) -> Result<Command, ParseError> {
    use crate::dsl::walker::outcome::WalkOutcome;
    match result.outcome {
        WalkOutcome::Match { .. } => command.ok_or_else(|| ParseError::Invalid {
            message: crate::t!(
                "parse.error_wrapper",
                detail = String::from("AST builder failed")
            ),
            position: 0,
            at_eof: false,
            expected: Vec::new(),
        }),
        WalkOutcome::Incomplete { position, expected } => Err(ParseError::Invalid {
            message: format_walker_error(source, position, true, &expected),
            position,
            at_eof: true,
            expected: expected.iter().map(format_expectation).collect(),
        }),
        WalkOutcome::Mismatch { position, expected } => Err(ParseError::Invalid {
            message: format_walker_error(source, position, false, &expected),
            position,
            at_eof: false,
            expected: expected.iter().map(format_expectation).collect(),
        }),
        WalkOutcome::ValidationFailed { position, error } => {
            // Runtime catalog lookup: walker carries the catalog
            // key + args at `Node::Ident` validators (e.g.,
            // `mode.unknown`). The `t!` macro requires a literal
            // key, so we call `friendly::translate` directly.
            let arg_refs: Vec<(&str, &dyn std::fmt::Display)> = error
                .args
                .iter()
                .map(|(k, v)| (*k, v as &dyn std::fmt::Display))
                .collect();
            let message = crate::friendly::translate(error.message_key, &arg_refs);
            // Mirror the chumsky-side custom-error convention
            // (parser.rs `into_parse_error`): treat validation
            // errors as `at_eof = true` so the input renderer
            // classifies them as IncompleteAtEof rather than a
            // mid-input definite error. Live overlay is
            // suppressed; the on-submit error still fires.
            Err(ParseError::Invalid {
                message,
                position,
                at_eof: true,
                expected: Vec::new(),
            })
        }
    }
}

fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String {
    use crate::dsl::grammar::IdentSource;
    use crate::dsl::walker::outcome::Expectation;
    match e {
        // ADR-0042 G1: the bare `1` that opens `add 1:n
        // relationship …` is the project's only `Literal("1")`
        // (grammar `ddl.rs`); on its own in an expected-set it is
        // cryptic — a learner cannot know it begins a
        // relationship. Render it as the named construct in error
        // wording. This is render-only: completion/hints read the
        // raw `Expectation::Literal("1")` directly (offering the
        // literal `1` to type), so the candidate surface is
        // unchanged.
        Expectation::Literal("1") => "`1:n relationship`".to_string(),
        Expectation::Word(w) | Expectation::Literal(w) => format!("`{w}`"),
        Expectation::Ident { source, .. } => match source {
            // Match `IdentSlot::expected_label` outputs so the
            // completion engine's round-trip (via
            // `IdentSlot::from_expected_label`) still resolves
            // the schema-cache lookup for these slots.
            IdentSource::Tables => "table name".to_string(),
            IdentSource::Columns => "column name".to_string(),
            IdentSource::Relationships => "relationship name".to_string(),
            IdentSource::Indexes => "index name".to_string(),
            IdentSource::Types => "type".to_string(),
            IdentSource::Generators => "generator name".to_string(),
            IdentSource::NewName | IdentSource::Free => "identifier".to_string(),
        },
        Expectation::Punct(c) => format!("`{c}`"),
        Expectation::NumberLit => "number".to_string(),
        Expectation::StringLit => "string literal".to_string(),
        Expectation::BlobLit => "blob literal".to_string(),
        Expectation::Flag(name) => format!("`--{name}`"),
        Expectation::BarePath => "path".to_string(),
        Expectation::EndOfInput => "end of input".to_string(),
    }
}

/// ADR-0042 G2: a projection start (`select |`, or the projection
/// position inside a subquery / CTE body) expects the full
/// expression first-set — 14 alternatives — plus the SELECT
/// quantifiers `distinct` and `all`. Those two quantifiers are
/// jointly expectable *only* at a projection start, so their joint
/// presence is a precise signature for collapsing the noisy list
/// into one gloss. Render-only: this fires inside
/// `format_walker_error` (the error message), not in the expected
/// set the completion/hint layer consumes.
fn is_select_projection_start(expected: &[crate::dsl::walker::outcome::Expectation]) -> bool {
    use crate::dsl::walker::outcome::Expectation;
    let has_word = |w: &str| {
        expected
            .iter()
            .any(|e| matches!(e, Expectation::Word(x) if x.eq_ignore_ascii_case(w)))
    };
    has_word("distinct") && has_word("all")
}

/// ADR-0042 §3: detect the `… CROSS JOIN <table> on …` mistake. A
/// CROSS JOIN takes no `ON` clause; the grammar rejects the `on`,
/// but the bare structural error ("expected end of input") doesn't
/// teach why. `on` is unexpected at this position *only* when the
/// most recent join is a CROSS join — every other join flavour
/// requires `on`, so there `on` would be in the expected set, not a
/// failure. Detection: the failing token is the keyword `on`, and
/// the last `join` word in the consumed prefix is immediately
/// preceded by `cross`. Render-only; no grammar change.
fn is_cross_join_on(source: &str, position: usize) -> bool {
    let rest = source[position.min(source.len())..].trim_start();
    let next_is_on = {
        let mut chars = rest.chars();
        let starts_on = rest.len() >= 2 && rest[..2].eq_ignore_ascii_case("on");
        let boundary = chars
            .nth(2)
            .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
        starts_on && boundary
    };
    if !next_is_on {
        return false;
    }
    let consumed = &source[..position.min(source.len())];
    let words: Vec<&str> = consumed
        .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
        .filter(|w| !w.is_empty())
        .collect();
    match words.iter().rposition(|w| w.eq_ignore_ascii_case("join")) {
        Some(i) if i > 0 => words[i - 1].eq_ignore_ascii_case("cross"),
        _ => false,
    }
}

fn format_walker_error(
    source: &str,
    position: usize,
    at_eof: bool,
    expected: &[crate::dsl::walker::outcome::Expectation],
) -> String {
    if is_cross_join_on(source, position) {
        let consumed = source[..position.min(source.len())].trim_end();
        let prefix = if consumed.is_empty() {
            String::new()
        } else {
            format!("after `{consumed}`, ")
        };
        return format!("{prefix}{}", crate::t!("parse.cross_join_no_on"));
    }
    let joined = if is_select_projection_start(expected) {
        crate::t!("parse.expect.select_projection")
    } else {
        let parts: Vec<String> = expected.iter().map(format_expectation).collect();
        oxford_join(&parts)
    };

    // Mirror the chumsky-side wording: "after `<consumed>`,
    // expected …" when the parser already consumed something
    // before the failure point. The `<consumed>` text trims
    // trailing whitespace and is rendered between backticks.
    let consumed = source[..position.min(source.len())].trim_end();
    let prefix = if consumed.is_empty() {
        String::new()
    } else {
        format!("after `{consumed}`, ")
    };

    if at_eof {
        if joined.is_empty() {
            crate::t!("parse.empty")
        } else if prefix.is_empty() {
            format!("expected {joined}, found end of input")
        } else {
            format!("{prefix}expected {joined}, found end of input")
        }
    } else if joined.is_empty() {
        "unexpected input".to_string()
    } else if prefix.is_empty() {
        format!("expected {joined}")
    } else {
        format!("{prefix}expected {joined}")
    }
}

fn oxford_join(items: &[String]) -> String {
    match items.len() {
        0 => String::new(),
        1 => items[0].clone(),
        2 => format!("{} or {}", items[0], items[1]),
        _ => {
            let last = items.len() - 1;
            let head = items[..last].join(", ");
            format!("{}, or {}", head, items[last])
        }
    }
}

// ADR-0024 Phase F: the chumsky-side `command_parser` and its
// per-command sub-parsers (replay, export/import, mode/messages,
// the DDL family, data commands) are deleted. The unified-grammar
// walker in `crate::dsl::walker` is the sole parse path.
// `try_parse_replay_with_bare_path` and `try_parse_app_path_command`
// — the source-slice helpers that handled bare paths before the
// walker existed — are also gone; `BarePath` in the walker
// supersedes them.

// =========================================================
// Tests
// =========================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::dsl::action::ReferentialAction;
    use crate::dsl::command::{
        ChangeColumnMode, ColumnSpec, IndexSelector, RelationshipSelector, RowFilter,
    };
    use crate::dsl::types::Type;
    use crate::dsl::value::Value;
    use pretty_assertions::assert_eq;

    // These helpers parse in **Simple mode** — the DSL surface
    // (ADR-0003). The tests in this module exercise the DSL
    // grammar (`insert`/`update`/`delete` Forms A/B/C, the
    // `--all-rows` rail, DDL, app commands), all of which are
    // canonical in Simple mode. Since sub-phase 3j made
    // `insert`/`update`/`delete` shared entry words (ADR-0033 §2,
    // Amendment 3), parsing these in Advanced mode would route the
    // overlap to the SQL command variants; the SQL surface is
    // covered by `tests/sql_*.rs` instead. No SQL-only command
    // (`select`/`with`) is tested through these helpers.
    fn ok(input: &str) -> Command {
        parse_command_in_mode(input, Mode::Simple)
            .unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}"))
    }

    fn err(input: &str) -> ParseError {
        parse_command_in_mode(input, Mode::Simple).expect_err("expected parse error")
    }

    fn err_message(input: &str) -> String {
        match err(input) {
            ParseError::Invalid { message, .. } => message,
            ParseError::Empty => panic!("unexpected empty error"),
        }
    }

    #[test]
    fn structural_error_for_show_data_without_arg() {
        // ADR-0022 stage 8c: `ident_ctx(IdentSlot::TableName)`
        // labels the expected slot with "table name" so the
        // error reads as the more specific "expected table
        // name" rather than the generic "expected identifier".
        let msg = err_message("show data");
        assert!(msg.contains("after `show data`"), "{msg}");
        assert!(msg.contains("expected table name"), "{msg}");
        assert!(msg.contains("found end of input"), "{msg}");
    }

    #[test]
    fn structural_error_for_change_column_with_swapped_args() {
        let msg = err_message("change column Rich in Customers: Rich (text)");
        assert!(msg.contains("after `change column Rich`"), "{msg}");
        assert!(msg.contains("expected `:`"), "{msg}");
    }

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

    #[test]
    fn bare_create_table_errors_with_helpful_message() {
        let e = err("create table Customers");
        match e {
            ParseError::Invalid { message, .. } => {
                assert!(
                    message.contains("with pk"),
                    "error should mention `with pk`:\n{message}"
                );
            }
            ParseError::Empty => panic!("unexpected empty error"),
        }
    }

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

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

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

    #[test]
    fn create_table_pk_accepts_any_user_type() {
        for ty in Type::all() {
            let input = format!("create table T with pk col({})", ty.keyword());
            let cmd = ok(&input);
            if let Command::CreateTable {
                columns,
                primary_key,
                ..
            } = cmd
            {
                assert_eq!(columns[0].ty, *ty);
                assert_eq!(primary_key, vec!["col".to_string()]);
            } else {
                panic!("expected CreateTable for {input}");
            }
        }
    }

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

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

    #[test]
    fn drop_column_simple() {
        assert_eq!(
            ok("drop column from table Customers: Email"),
            Command::DropColumn {
                table: "Customers".to_string(),
                column: "Email".to_string(),
                cascade: false,
            }
        );
    }

    #[test]
    fn drop_column_accepts_bare_identifiers() {
        assert_eq!(
            ok("drop column Customers: Email"),
            Command::DropColumn {
                table: "Customers".to_string(),
                column: "Email".to_string(),
                cascade: false,
            }
        );
        assert_eq!(
            ok("drop column from Customers: Email"),
            Command::DropColumn {
                table: "Customers".to_string(),
                column: "Email".to_string(),
                cascade: false,
            }
        );
        assert_eq!(
            ok("drop column table Customers: Email"),
            Command::DropColumn {
                table: "Customers".to_string(),
                column: "Email".to_string(),
                cascade: false,
            }
        );
    }

    #[test]
    fn rename_column_simple() {
        assert_eq!(
            ok("rename column in table Customers: OldName to NewName"),
            Command::RenameColumn {
                table: "Customers".to_string(),
                old: "OldName".to_string(),
                new: "NewName".to_string(),
            }
        );
    }

    #[test]
    fn rename_column_accepts_bare_identifiers() {
        assert_eq!(
            ok("rename column Customers: A to B"),
            Command::RenameColumn {
                table: "Customers".to_string(),
                old: "A".to_string(),
                new: "B".to_string(),
            }
        );
        assert_eq!(
            ok("rename column in Customers: A to B"),
            Command::RenameColumn {
                table: "Customers".to_string(),
                old: "A".to_string(),
                new: "B".to_string(),
            }
        );
        assert_eq!(
            ok("rename column table Customers: A to B"),
            Command::RenameColumn {
                table: "Customers".to_string(),
                old: "A".to_string(),
                new: "B".to_string(),
            }
        );
    }

    #[test]
    fn change_column_simple() {
        assert_eq!(
            ok("change column in table Customers: Score (int)"),
            Command::ChangeColumnType {
                table: "Customers".to_string(),
                column: "Score".to_string(),
                ty: Type::Int,
                mode: ChangeColumnMode::Default,
            }
        );
    }

    #[test]
    fn change_column_accepts_bare_identifiers() {
        assert_eq!(
            ok("change column Customers: Score (real)"),
            Command::ChangeColumnType {
                table: "Customers".to_string(),
                column: "Score".to_string(),
                ty: Type::Real,
                mode: ChangeColumnMode::Default,
            }
        );
    }

    #[test]
    fn change_column_keywords_are_case_insensitive() {
        assert_eq!(
            ok("CHANGE COLUMN IN TABLE Customers: Score (TEXT)"),
            Command::ChangeColumnType {
                table: "Customers".to_string(),
                column: "Score".to_string(),
                ty: Type::Text,
                mode: ChangeColumnMode::Default,
            }
        );
    }

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

    #[test]
    fn change_column_with_dont_convert_flag() {
        assert_eq!(
            ok("change column Customers: Score (int) --dont-convert"),
            Command::ChangeColumnType {
                table: "Customers".to_string(),
                column: "Score".to_string(),
                ty: Type::Int,
                mode: ChangeColumnMode::DontConvert,
            }
        );
    }

    #[test]
    fn change_column_rejects_both_flags() {
        let e = err("change column Customers: Score (int) --force-conversion --dont-convert");
        match e {
            ParseError::Invalid { message, .. } => {
                assert!(
                    message.contains("--force-conversion") && message.contains("--dont-convert"),
                    "expected both flag names in error: {message}"
                );
                assert!(
                    message.contains("mutually exclusive") || message.contains("pick one"),
                    "{message}"
                );
            }
            ParseError::Empty => panic!("unexpected empty error"),
        }
    }

    #[test]
    fn change_column_rejects_both_flags_in_either_order() {
        let e = err("change column T: c (int) --dont-convert --force-conversion");
        match e {
            ParseError::Invalid { message, .. } => {
                assert!(message.contains("mutually exclusive"), "{message}");
            }
            ParseError::Empty => panic!("unexpected empty error"),
        }
    }

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

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

    #[test]
    fn add_column_with_each_supported_type() {
        for ty in Type::all() {
            let input = format!("add column to table T: C ({})", ty.keyword());
            assert_eq!(
                ok(&input),
                Command::AddColumn {
                    table: "T".to_string(),
                    column: "C".to_string(),
                    ty: *ty,
                    not_null: false,
                    unique: false,
                    default: None,
                    check: None,
                }
            );
        }
    }

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

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

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

    #[test]
    fn add_column_tolerates_whitespace_around_punctuation() {
        assert_eq!(
            ok("add column to table T:Name(text)"),
            Command::AddColumn {
                table: "T".to_string(),
                column: "Name".to_string(),
                ty: Type::Text,
                not_null: false,
                unique: false,
                default: None,
                check: None,
            }
        );
    }

    #[test]
    fn empty_input_is_an_explicit_empty_error() {
        assert_eq!(parse_command(""), Err(ParseError::Empty));
        assert_eq!(parse_command("   "), Err(ParseError::Empty));
    }

    #[test]
    fn unknown_command_errors() {
        let e = err("frobulate Customers");
        assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
    }

    #[test]
    fn unknown_type_errors_with_alternatives_listed() {
        let e = err("add column to table T: Name (varchar)");
        match e {
            ParseError::Invalid { message, .. } => {
                assert!(
                    message.contains("varchar"),
                    "error should mention the bad type: {message}"
                );
                assert!(
                    message.contains("expected one of"),
                    "error should list valid alternatives: {message}"
                );
                assert!(
                    message.contains("text") && message.contains("shortid"),
                    "error should name the alternatives: {message}"
                );
            }
            ParseError::Empty => panic!("unexpected empty error"),
        }
    }

    #[test]
    fn unknown_pk_type_errors_with_alternatives_listed() {
        let e = err("create table T with pk id(varchar)");
        match e {
            ParseError::Invalid { message, .. } => {
                assert!(message.contains("varchar"), "{message}");
                assert!(message.contains("expected one of"), "{message}");
            }
            ParseError::Empty => panic!("unexpected empty error"),
        }
    }

    #[test]
    fn trailing_garbage_errors() {
        let e = err("create table Customers with pk and pickles");
        assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
    }

    #[test]
    fn identifier_must_start_with_letter_or_underscore() {
        let e = err("create table 1Customers with pk");
        assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
    }

    fn rel(
        name: Option<&str>,
        parent: (&str, &str),
        child: (&str, &str),
        on_delete: ReferentialAction,
        on_update: ReferentialAction,
        create_fk: bool,
    ) -> Command {
        Command::AddRelationship {
            name: name.map(String::from),
            parent_table: parent.0.to_string(),
            parent_columns: vec![parent.1.to_string()],
            child_table: child.0.to_string(),
            child_columns: vec![child.1.to_string()],
            on_delete,
            on_update,
            create_fk,
        }
    }

    #[test]
    fn add_relationship_minimal() {
        assert_eq!(
            ok("add 1:n relationship from Customers.Id to Orders.CustId"),
            rel(
                None,
                ("Customers", "Id"),
                ("Orders", "CustId"),
                ReferentialAction::NoAction,
                ReferentialAction::NoAction,
                false,
            )
        );
    }

    #[test]
    fn add_relationship_with_name() {
        assert_eq!(
            ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId"),
            rel(
                Some("cust_orders"),
                ("Customers", "Id"),
                ("Orders", "CustId"),
                ReferentialAction::NoAction,
                ReferentialAction::NoAction,
                false,
            )
        );
    }

    #[test]
    fn add_relationship_with_on_delete() {
        assert_eq!(
            ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade"),
            rel(
                None,
                ("Customers", "Id"),
                ("Orders", "CustId"),
                ReferentialAction::Cascade,
                ReferentialAction::NoAction,
                false,
            )
        );
    }

    #[test]
    fn add_relationship_with_on_delete_set_null() {
        assert_eq!(
            ok("add 1:n relationship from Customers.Id to Orders.CustId on delete set null"),
            rel(
                None,
                ("Customers", "Id"),
                ("Orders", "CustId"),
                ReferentialAction::SetNull,
                ReferentialAction::NoAction,
                false,
            )
        );
    }

    #[test]
    fn add_relationship_with_both_actions_in_either_order() {
        let expected = rel(
            None,
            ("Customers", "Id"),
            ("Orders", "CustId"),
            ReferentialAction::Cascade,
            ReferentialAction::SetNull,
            false,
        );
        assert_eq!(
            ok(
                "add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"
            ),
            expected
        );
        assert_eq!(
            ok(
                "add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"
            ),
            expected
        );
    }

    #[test]
    fn add_relationship_repeated_clause_errors() {
        let e = err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
        match e {
            ParseError::Invalid { message, .. } => {
                assert!(message.contains("specified twice"), "{message}");
            }
            ParseError::Empty => panic!("unexpected empty error"),
        }
    }

    #[test]
    fn add_relationship_with_create_fk_flag() {
        assert_eq!(
            ok("add 1:n relationship from Customers.Id to Orders.CustId --create-fk"),
            rel(
                None,
                ("Customers", "Id"),
                ("Orders", "CustId"),
                ReferentialAction::NoAction,
                ReferentialAction::NoAction,
                true,
            )
        );
    }

    #[test]
    fn add_relationship_with_name_actions_and_flag() {
        assert_eq!(
            ok(
                "add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"
            ),
            rel(
                Some("cust_orders"),
                ("Customers", "Id"),
                ("Orders", "CustId"),
                ReferentialAction::Cascade,
                ReferentialAction::NoAction,
                true,
            )
        );
    }

    #[test]
    fn add_relationship_keywords_are_case_insensitive() {
        assert_eq!(
            ok("ADD 1:N RELATIONSHIP FROM Customers.Id TO Orders.CustId ON DELETE CASCADE"),
            rel(
                None,
                ("Customers", "Id"),
                ("Orders", "CustId"),
                ReferentialAction::Cascade,
                ReferentialAction::NoAction,
                false,
            )
        );
    }

    #[test]
    fn add_relationship_unknown_action_errors() {
        let e = err("add 1:n relationship from C.id to O.cid on delete obliterate");
        assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
    }

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

    #[test]
    fn insert_short_form_omitting_values_keyword() {
        assert_eq!(
            ok("insert into Customers ('Alice')"),
            Command::Insert {
                table: "Customers".to_string(),
                columns: None,
                values: vec![Value::Text("Alice".to_string())],
            }
        );
    }

    #[test]
    fn insert_short_form_without_column_list() {
        assert_eq!(
            ok("insert into Customers values ('Alice', 'a@b.com')"),
            Command::Insert {
                table: "Customers".to_string(),
                columns: None,
                values: vec![
                    Value::Text("Alice".to_string()),
                    Value::Text("a@b.com".to_string()),
                ],
            }
        );
    }

    #[test]
    fn insert_accepts_mixed_value_kinds() {
        assert_eq!(
            ok("insert into T values (1, 3.14, 'hi', true, null)"),
            Command::Insert {
                table: "T".to_string(),
                columns: None,
                values: vec![
                    Value::Number("1".to_string()),
                    Value::Number("3.14".to_string()),
                    Value::Text("hi".to_string()),
                    Value::Bool(true),
                    Value::Null,
                ],
            }
        );
    }

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

    #[test]
    fn string_literal_supports_escaped_single_quote() {
        assert_eq!(
            ok("insert into T values ('don''t panic')"),
            Command::Insert {
                table: "T".to_string(),
                columns: None,
                values: vec![Value::Text("don't panic".to_string())],
            }
        );
    }

    #[test]
    fn update_with_where() {
        assert_eq!(
            ok("update Customers set Name='Alice' where id=1"),
            Command::Update {
                table: "Customers".to_string(),
                assignments: vec![("Name".to_string(), Value::Text("Alice".to_string()))],
                filter: RowFilter::eq("id", Value::Number("1".to_string())),
            }
        );
    }

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

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

    #[test]
    fn update_without_where_or_flag_errors() {
        let e = err("update Customers set Active=false");
        assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
    }

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

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

    #[test]
    fn delete_without_where_or_flag_errors() {
        let e = err("delete from Customers");
        assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
    }

    // =====================================================
    // Sub-phase 3j — shared-entry-word dispatch (ADR-0033 §2,
    // Amendment 1 / Amendment 3).
    //
    // `insert` / `update` / `delete` are *shared* entry words: a
    // `Simple` DSL node and an `Advanced` SQL node both register
    // under each. A command's identity is the outcome of the
    // mode-rooted grammar path:
    //   - Advanced mode tries the SQL shape first and falls back to
    //     the DSL shape only when the SQL shape *structurally* can't
    //     match (e.g. the DSL-only `--all-rows` flag). A content
    //     rejection (a `__rdbms_*` target) on the SQL shape is
    //     surfaced, never masked by the DSL fallback.
    //   - Simple mode commits the DSL shape; it points the user at
    //     advanced mode ("this is SQL") only when the input is
    //     SQL-only (the DSL shape structurally mismatches and the SQL
    //     shape matches — e.g. a `returning` tail). A DSL command
    //     that is merely incomplete or has a bad value still commits
    //     the DSL node so the user sees DSL completion / DSL errors.
    // The §6/§7 parity guarantees mean the two variants execute to
    // identical effects for an overlapping input.
    // =====================================================

    #[test]
    fn advanced_ambiguous_insert_routes_to_sql() {
        assert!(matches!(
            parse_command_in_mode("insert into Orders values (1, 2)", Mode::Advanced),
            Ok(Command::SqlInsert { .. })
        ));
    }

    #[test]
    fn advanced_ambiguous_update_routes_to_sql() {
        assert!(matches!(
            parse_command_in_mode("update Orders set total = 0 where id = 1", Mode::Advanced,),
            Ok(Command::SqlUpdate { .. })
        ));
    }

    #[test]
    fn advanced_ambiguous_delete_routes_to_sql() {
        assert!(matches!(
            parse_command_in_mode("delete from Orders where id = 1", Mode::Advanced),
            Ok(Command::SqlDelete { .. })
        ));
    }

    #[test]
    fn advanced_dsl_only_delete_falls_back_to_dsl() {
        // `--all-rows` is DSL-only; the SQL DELETE shape can't consume
        // the trailing flag, so dispatch falls back to the DSL node.
        assert_eq!(
            parse_command_in_mode("delete from Orders --all-rows", Mode::Advanced).unwrap(),
            Command::Delete {
                table: "Orders".to_string(),
                filter: RowFilter::AllRows,
            },
        );
    }

    #[test]
    fn simple_mode_data_commands_reject_internal_tables() {
        // ADR-0030 §6 ("every table-source slot") / `/runda` finding
        // B: the DSL data-command target slots reject `__rdbms_*`
        // internal tables in simple mode too — matching the SQL
        // grammar. Without this, simple-mode DML could read/write the
        // internal metadata tables while advanced-mode SQL rejected
        // them.
        for input in [
            "insert into __rdbms_playground_columns values (1)",
            "update __rdbms_playground_columns set x = 1 where id = 1",
            "delete from __rdbms_playground_columns where id = 1",
            "show data __rdbms_playground_columns",
            "show table __rdbms_playground_relationships",
        ] {
            assert!(
                parse_command_in_mode(input, Mode::Simple).is_err(),
                "internal table must be rejected in simple mode: {input:?}",
            );
        }
    }

    #[test]
    fn advanced_internal_table_insert_is_rejected_not_fallen_back() {
        // The SQL insert's `reject_internal_table` rail must surface
        // even though the DSL insert node lacks it: a content
        // rejection commits the SQL candidate rather than falling
        // through to the DSL node that would accept it.
        assert!(
            parse_command_in_mode(
                "insert into __rdbms_playground_columns values (1)",
                Mode::Advanced,
            )
            .is_err(),
        );
    }

    #[test]
    fn simple_dsl_delete_stays_dsl() {
        assert_eq!(
            parse_command_in_mode("delete from Orders where id = 1", Mode::Simple).unwrap(),
            Command::Delete {
                table: "Orders".to_string(),
                filter: RowFilter::eq("id", Value::Number("1".to_string())),
            },
        );
    }

    #[test]
    fn simple_sql_only_entry_word_points_at_advanced_mode() {
        // A SQL-only *entry word* (`select`) has no DSL form, so
        // simple mode emits the "this is SQL" hint at the parse level
        // (ADR-0030 §2).
        match parse_command_in_mode("select Name from Orders", Mode::Simple) {
            Err(ParseError::Invalid { message, .. }) => assert!(
                message.contains("advanced"),
                "expected the this-is-SQL hint, got: {message}",
            ),
            other => panic!("expected the this-is-SQL hint, got {other:?}"),
        }
    }

    #[test]
    fn simple_shared_word_with_sql_construct_is_a_dsl_parse_error() {
        // `returning` is SQL-only, but `delete` is a *shared* entry
        // word, so simple mode commits the DSL shape and surfaces a
        // DSL parse error (ADR-0033 Amendment 3). The "(valid as SQL
        // in advanced mode)" pointer is added at the hint layer
        // (input_render), not in the parsed command/error here.
        assert!(matches!(
            parse_command_in_mode("delete from Orders where id = 1 returning *", Mode::Simple,),
            Err(ParseError::Invalid { .. })
        ));
    }

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

    #[test]
    fn drop_relationship_by_name() {
        assert_eq!(
            ok("drop relationship cust_orders"),
            Command::DropRelationship {
                selector: RelationshipSelector::Named {
                    name: "cust_orders".to_string()
                }
            }
        );
    }

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

    #[test]
    fn drop_relationship_by_endpoints() {
        assert_eq!(
            ok("drop relationship from Customers.Id to Orders.CustId"),
            Command::DropRelationship {
                selector: RelationshipSelector::Endpoints {
                    parent_table: "Customers".to_string(),
                    parent_column: "Id".to_string(),
                    child_table: "Orders".to_string(),
                    child_column: "CustId".to_string(),
                }
            }
        );
    }

    // --- add index / drop index (ADR-0025) ---

    #[test]
    fn add_index_named() {
        assert_eq!(
            ok("add index as idx_email on Customers (Email)"),
            Command::AddIndex {
                name: Some("idx_email".to_string()),
                table: "Customers".to_string(),
                columns: vec!["Email".to_string()],
            }
        );
    }

    #[test]
    fn add_index_unnamed() {
        assert_eq!(
            ok("add index on Customers (Email)"),
            Command::AddIndex {
                name: None,
                table: "Customers".to_string(),
                columns: vec!["Email".to_string()],
            }
        );
    }

    #[test]
    fn add_index_composite_columns() {
        assert_eq!(
            ok("add index on Orders (CustId, Date)"),
            Command::AddIndex {
                name: None,
                table: "Orders".to_string(),
                columns: vec!["CustId".to_string(), "Date".to_string()],
            }
        );
    }

    #[test]
    fn drop_index_by_name() {
        assert_eq!(
            ok("drop index idx_email"),
            Command::DropIndex {
                selector: IndexSelector::Named {
                    name: "idx_email".to_string(),
                },
            }
        );
    }

    #[test]
    fn drop_index_by_columns() {
        assert_eq!(
            ok("drop index on Customers (Email)"),
            Command::DropIndex {
                selector: IndexSelector::Columns {
                    table: "Customers".to_string(),
                    columns: vec!["Email".to_string()],
                },
            }
        );
    }

    #[test]
    fn drop_column_cascade_flag() {
        assert_eq!(
            ok("drop column Customers: Email --cascade"),
            Command::DropColumn {
                table: "Customers".to_string(),
                column: "Email".to_string(),
                cascade: true,
            }
        );
    }

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

    // --- replay <path> ---

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

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

    #[test]
    fn replay_with_quoted_path_supports_whitespace() {
        assert_eq!(
            ok("replay 'my project/seed.commands'"),
            Command::Replay {
                path: "my project/seed.commands".to_string(),
            }
        );
    }

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

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

    #[test]
    fn replay_without_path_errors() {
        let e = err("replay");
        assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
    }

    #[test]
    fn replay_with_empty_quoted_path_errors() {
        // The quoted-path form of `replay` goes through chumsky.
        // An empty quoted path `''` lexes as a StringLiteral with
        // an empty payload, which the parser accepts as
        // syntactically valid; the runtime rejects an empty path
        // before any I/O. Test pinned to the runtime layer rather
        // than the parser layer to match the new architecture.
        // (The pre-tokenizer parser caught this at parse time via
        // `path_literal`'s try_map; under the lexer split, that
        // check moves down a layer.)
        match parse_command("replay ''") {
            Ok(Command::Replay { path }) => assert_eq!(path, ""),
            other => panic!("expected Replay with empty path, got {other:?}"),
        }
    }
}
