//! DDL command nodes (ADR-0024 §migration Phase B).
//!
//! Five commands at four entry words: `drop` (drop table /
//! drop column / drop relationship), `add` (add column /
//! add 1:n relationship), `rename` (rename column), `change`
//! (change column). The chumsky-side declarations stay
//! reachable for any input the walker doesn't engage on, but
//! for these entry words the walker is authoritative.
//!
//! Each shape is laid out inline so per-use-site `role`
//! annotations carry meaning end-to-end (e.g.,
//! `parent_table` vs `child_table` for the endpoints clause).

use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
    AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
    IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint,
};
use crate::dsl::grammar::{
    CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word,
    shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
};
use crate::dsl::value::Value;

/// `HintMode` annotation shared by every `NewName` ident slot:
/// the user is inventing a name, so the hint panel forces the
/// "Type a name [then …]" prose rather than offering schema
/// candidates (ADR-0024 §HintMode-per-node).
const NEW_NAME_HINT: HintMode = HintMode::ForceProse("hint.ambient_typing_name");
use crate::dsl::types::Type;
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};

// =================================================================
// Building blocks
// =================================================================

const TABLE_NAME_NEW_IDENT: Node = Node::Ident {
    source: IdentSource::NewName,
    role: "table_name",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};
const TABLE_NAME_NEW: Node = Node::Hinted {
    mode: NEW_NAME_HINT,
    inner: &TABLE_NAME_NEW_IDENT,
};

// `writes_table: true` so that the column-name slots that
// follow the table name in `drop column` / `rename column` /
// `change column` / `add column` can narrow their candidates to
// this table's columns (handoff-12 §2.2). The walker writes
// `current_table` / `current_table_columns` on match; the
// completion engine reads the snapshot. `drop table` has no
// downstream column slot, so the write is harmless there.
const TABLE_NAME_EXISTING: Node = Node::Ident {
    source: IdentSource::Tables,
    role: "table_name",
    validator: None,
    highlight_override: None,
    writes_table: true,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};

const COLUMN_NAME: Node = Node::Ident {
    source: IdentSource::Columns,
    role: "column_name",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};

const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
    source: IdentSource::NewName,
    role: "column_name",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};
const COLUMN_NAME_NEW: Node = Node::Hinted {
    mode: NEW_NAME_HINT,
    inner: &COLUMN_NAME_NEW_IDENT,
};

const RELATIONSHIP_NAME: Node = Node::Ident {
    source: IdentSource::Relationships,
    role: "relationship_name",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};

const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
    source: IdentSource::NewName,
    role: "relationship_name",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
    mode: NEW_NAME_HINT,
    inner: &RELATIONSHIP_NAME_NEW_IDENT,
};

const INDEX_NAME_EXISTING: Node = Node::Ident {
    source: IdentSource::Indexes,
    role: "index_name",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};

const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
    source: IdentSource::NewName,
    role: "index_name",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};
const INDEX_NAME_NEW: Node = Node::Hinted {
    mode: NEW_NAME_HINT,
    inner: &INDEX_NAME_NEW_IDENT,
};

// The column list shared by `add index` / `drop index`: one or
// more existing column names, comma-separated, inside parens.
// `COLUMN_NAME` narrows to the `on <Table>` table's columns
// because that ident carries `writes_table: true`.
const INDEX_COLUMN_LIST: Node = Node::Repeated {
    inner: &COLUMN_NAME,
    separator: Some(&Node::Punct(',')),
    min: 1,
};

// `[to]` and `[table]` connectives.
const TO_OPT: Node = Node::Optional(&Node::Word(Word::keyword("to")));
const FROM_OPT: Node = Node::Optional(&Node::Word(Word::keyword("from")));
const IN_OPT: Node = Node::Optional(&Node::Word(Word::keyword("in")));
const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));

// =================================================================
// drop_table — `drop table <T>`
// =================================================================

const DROP_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);

// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name> [;]` (ADR-0035 §4,
// sub-phase 4c). Same table-only target as the simple `drop table`,
// plus the optional `IF EXISTS` no-op-with-note. The leading concrete
// `table` keyword (not the Optional) keeps the element/dispatch
// matching honest.
static SQL_DROP_IF_EXISTS_NODES: &[Node] = &[
    Node::Word(Word::keyword("if")),
    Node::Word(Word::keyword("exists")),
];
const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES));
static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
    Node::Word(Word::keyword("table")),
    SQL_DROP_IF_EXISTS_OPT,
    TABLE_NAME_EXISTING,
    Node::Optional(&Node::Punct(';')),
];
const SQL_DROP_TABLE_SHAPE: Node = Node::Seq(SQL_DROP_TABLE_SHAPE_NODES);

// Advanced-mode SQL `DROP INDEX [IF EXISTS] <name> [;]` (ADR-0035 §4,
// sub-phase 4d). Name-only — SQL has no positional `on T (cols)` drop
// form (that stays the simple `drop index on …`, which falls back to
// the simple `drop` node). Leads on the concrete `index` keyword; the
// `IF EXISTS` opt is mid-`Seq` (trap-safe, like SQL_DROP_TABLE).
// `INDEX_NAME_EXISTING` has `validator: None`, so `IF EXISTS <absent>`
// still parses and reaches the skip path.
static SQL_DROP_INDEX_SHAPE_NODES: &[Node] = &[
    Node::Word(Word::keyword("index")),
    SQL_DROP_IF_EXISTS_OPT,
    INDEX_NAME_EXISTING,
    Node::Optional(&Node::Punct(';')),
];
const SQL_DROP_INDEX_SHAPE: Node = Node::Seq(SQL_DROP_INDEX_SHAPE_NODES);

// =================================================================
// drop_column — `drop column [from] [table] <T> : <col>`
// =================================================================

// `--cascade` (ADR-0025): opt-in to dropping any index that
// covers the column alongside the column itself. Without it, a
// covered column is refused with a friendly error.
const DROP_COLUMN_CASCADE_OPT: Node = Node::Optional(&Node::Flag("cascade"));

const DROP_COLUMN_NODES: &[Node] = &[
    Node::Word(Word::keyword("column")),
    FROM_OPT,
    TABLE_OPT,
    TABLE_NAME_EXISTING,
    Node::Punct(':'),
    COLUMN_NAME,
    DROP_COLUMN_CASCADE_OPT,
];
const DROP_COLUMN: Node = Node::Seq(DROP_COLUMN_NODES);

// =================================================================
// drop_relationship — `drop relationship (endpoints | name)`
// =================================================================

// `writes_table: true` on each endpoint's table ident so the
// `.<col>` slot that follows narrows to that table's columns
// (handoff-13 §2.2 follow-up). The two endpoints are walked
// sequentially, so `current_table` is correctly the parent
// table while walking `parent_column` and the child table
// while walking `child_column`.
const DR_PARENT_NODES: &[Node] = &[
    Node::Ident {
        source: IdentSource::Tables,
        role: "parent_table",
        validator: None,
        highlight_override: None,
        writes_table: true,
        writes_column: false,
        writes_user_listed_column: false,
        writes_table_alias: false,
        writes_cte_name: false,
        writes_projection_alias: false,
    },
    Node::Punct('.'),
    Node::Ident {
        source: IdentSource::Columns,
        role: "parent_column",
        validator: None,
        highlight_override: None,
        writes_table: false,
        writes_column: false,
        writes_user_listed_column: false,
        writes_table_alias: false,
        writes_cte_name: false,
        writes_projection_alias: false,
    },
];
const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES);

const DR_CHILD_NODES: &[Node] = &[
    Node::Ident {
        source: IdentSource::Tables,
        role: "child_table",
        validator: None,
        highlight_override: None,
        writes_table: true,
        writes_column: false,
        writes_user_listed_column: false,
        writes_table_alias: false,
        writes_cte_name: false,
        writes_projection_alias: false,
    },
    Node::Punct('.'),
    Node::Ident {
        source: IdentSource::Columns,
        role: "child_column",
        validator: None,
        highlight_override: None,
        writes_table: false,
        writes_column: false,
        writes_user_listed_column: false,
        writes_table_alias: false,
        writes_cte_name: false,
        writes_projection_alias: false,
    },
];
const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES);

const DR_ENDPOINTS_NODES: &[Node] = &[
    Node::Word(Word::keyword("from")),
    DR_PARENT,
    Node::Word(Word::keyword("to")),
    DR_CHILD,
];
const DR_ENDPOINTS: Node = Node::Seq(DR_ENDPOINTS_NODES);

const DR_SELECTOR_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME];
const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES);

const DROP_RELATIONSHIP_NODES: &[Node] = &[Node::Word(Word::keyword("relationship")), DR_SELECTOR];
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES);

// =================================================================
// drop_index — `drop index (<name> | on <T> (<col>, …))`
// =================================================================

const DI_POSITIONAL_NODES: &[Node] = &[
    Node::Word(Word::keyword("on")),
    TABLE_NAME_EXISTING,
    Node::Punct('('),
    INDEX_COLUMN_LIST,
    Node::Punct(')'),
];
const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES);

// Positional form first — it opens with the `on` keyword, so a
// bare index name can't be mistaken for it (mirrors DR_SELECTOR).
const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);

const DROP_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), DI_SELECTOR];
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);

// =================================================================
// drop entry — `drop (table|column|relationship|index) ...`
// =================================================================

const DROP_CHOICES: &[Node] = &[
    DROP_COLUMN,
    DROP_RELATIONSHIP,
    DROP_TABLE,
    DROP_INDEX,
    DROP_CONSTRAINT,
];
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);

// =================================================================
// add_column — `add column [to] [table] <T> : <col> ( <type> )`
// =================================================================

const ADD_COLUMN_NODES: &[Node] = &[
    Node::Word(Word::keyword("column")),
    TO_OPT,
    TABLE_OPT,
    TABLE_NAME_EXISTING,
    Node::Punct(':'),
    COLUMN_NAME_NEW,
    Node::Punct('('),
    TYPE_SLOT,
    Node::Punct(')'),
    // ADR-0029: the constraint suffix — shared with `create
    // table`'s column spec.
    COLUMN_CONSTRAINT_SUFFIX,
];
const ADD_COLUMN: Node = Node::Seq(ADD_COLUMN_NODES);

// =================================================================
// add_relationship — `add 1:n relationship [as <name>]
//                    from <T>.<c> to <T>.<c>
//                    [on delete <a>] [on update <a>]
//                    [--create-fk]`
// =================================================================

// `writes_table: true` on each endpoint's table ident so the
// `.<col>` slot narrows to that table's columns (handoff-13
// §2.2 follow-up — mirrors DR_PARENT / DR_CHILD).
// A single FK-endpoint column ident (narrows to the endpoint
// table's columns via the table ident's `writes_table: true`).
const AR_PARENT_COL: Node = Node::Ident {
    source: IdentSource::Columns,
    role: "parent_column",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};
// Compound endpoint: `( a, b, … )` — a comma-separated column list
// in parens (ADR-0043). Same role as the single form, so the
// builder collects either shape uniformly.
const AR_PARENT_COL_LIST: Node = Node::Repeated {
    inner: &AR_PARENT_COL,
    separator: Some(&Node::Punct(',')),
    min: 1,
};
const AR_PARENT_COLS_PAREN_NODES: &[Node] =
    &[Node::Punct('('), AR_PARENT_COL_LIST, Node::Punct(')')];
const AR_PARENT_COLS_PAREN: Node = Node::Seq(AR_PARENT_COLS_PAREN_NODES);
// `from P.(a, b)` (compound) or `from P.col` (single) — Choice on
// the first post-`.` token (`(` vs an ident), so order is safe.
const AR_PARENT_COLS_CHOICES: &[Node] = &[AR_PARENT_COLS_PAREN, AR_PARENT_COL];
const AR_PARENT_COLS: Node = Node::Choice(AR_PARENT_COLS_CHOICES);

const AR_PARENT_NODES: &[Node] = &[
    Node::Ident {
        source: IdentSource::Tables,
        role: "parent_table",
        validator: None,
        highlight_override: None,
        writes_table: true,
        writes_column: false,
        writes_user_listed_column: false,
        writes_table_alias: false,
        writes_cte_name: false,
        writes_projection_alias: false,
    },
    Node::Punct('.'),
    AR_PARENT_COLS,
];
const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES);

const AR_CHILD_COL: Node = Node::Ident {
    source: IdentSource::Columns,
    role: "child_column",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};
const AR_CHILD_COL_LIST: Node = Node::Repeated {
    inner: &AR_CHILD_COL,
    separator: Some(&Node::Punct(',')),
    min: 1,
};
const AR_CHILD_COLS_PAREN_NODES: &[Node] = &[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
const AR_CHILD_COLS_PAREN: Node = Node::Seq(AR_CHILD_COLS_PAREN_NODES);
const AR_CHILD_COLS_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL];
const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES);

const AR_CHILD_NODES: &[Node] = &[
    Node::Ident {
        source: IdentSource::Tables,
        role: "child_table",
        validator: None,
        highlight_override: None,
        writes_table: true,
        writes_column: false,
        writes_user_listed_column: false,
        writes_table_alias: false,
        writes_cte_name: false,
        writes_projection_alias: false,
    },
    Node::Punct('.'),
    AR_CHILD_COLS,
];
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);

const AR_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), RELATIONSHIP_NAME_NEW];
const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));

const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));

// The `1:n` cardinality marker, tagged as the
// `cardinality_one_to_many` clause-concept region (issue #37). A
// distinct sub-`Seq` so the concept span covers exactly `1:n`, not
// the whole relationship form (which the per-form `hint.cmd.*` block
// already serves).
const CARDINALITY_1N_NODES: &[Node] = &[
    Node::Literal("1"),
    Node::Punct(':'),
    Node::Word(Word::keyword("n")),
];
const CARDINALITY_1N: Node = Node::Concept {
    topic: "cardinality_one_to_many",
    inner: &Node::Seq(CARDINALITY_1N_NODES),
};

const ADD_RELATIONSHIP_NODES: &[Node] = &[
    CARDINALITY_1N,
    Node::Word(Word::keyword("relationship")),
    AR_AS_NAME_OPT,
    Node::Word(Word::keyword("from")),
    AR_PARENT,
    Node::Word(Word::keyword("to")),
    AR_CHILD,
    REFERENTIAL_CLAUSES,
    AR_CREATE_FK_OPT,
];
const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);

// =================================================================
// add_index — `add index [as <name>] on <T> (<col>, …)`
// =================================================================

const AI_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), INDEX_NAME_NEW];
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));

const ADD_INDEX_NODES: &[Node] = &[
    Node::Word(Word::keyword("index")),
    AI_AS_NAME_OPT,
    Node::Word(Word::keyword("on")),
    TABLE_NAME_EXISTING,
    Node::Punct('('),
    INDEX_COLUMN_LIST,
    Node::Punct(')'),
];
const ADD_INDEX: Node = Node::Seq(ADD_INDEX_NODES);

// =================================================================
// add entry — `add (column|1:n relationship|index) …`
// =================================================================

const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP, ADD_INDEX, ADD_CONSTRAINT];
const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES);

// =================================================================
// rename_column — `rename column [in] [table] <T> : <col> to <new>`
// =================================================================

const NEW_COLUMN_NAME_IDENT: Node = Node::Ident {
    source: IdentSource::NewName,
    role: "new_column_name",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};
const NEW_COLUMN_NAME: Node = Node::Hinted {
    mode: NEW_NAME_HINT,
    inner: &NEW_COLUMN_NAME_IDENT,
};

const RENAME_COLUMN_NODES: &[Node] = &[
    Node::Word(Word::keyword("column")),
    IN_OPT,
    TABLE_OPT,
    TABLE_NAME_EXISTING,
    Node::Punct(':'),
    COLUMN_NAME,
    Node::Word(Word::keyword("to")),
    NEW_COLUMN_NAME,
];
const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);

// =================================================================
// change_column — `change column [in] [table] <T> : <col>
//                  ( <type> ) [--force-conversion | --dont-convert]`
// =================================================================

const CHANGE_FLAG_CHOICES: &[Node] = &[Node::Flag("force-conversion"), Node::Flag("dont-convert")];
const CHANGE_FLAG_OPT: Node = Node::Repeated {
    inner: &Node::Choice(CHANGE_FLAG_CHOICES),
    separator: None,
    min: 0,
};

const CHANGE_COLUMN_NODES: &[Node] = &[
    Node::Word(Word::keyword("column")),
    IN_OPT,
    TABLE_OPT,
    TABLE_NAME_EXISTING,
    Node::Punct(':'),
    COLUMN_NAME,
    Node::Punct('('),
    TYPE_SLOT,
    Node::Punct(')'),
    CHANGE_FLAG_OPT,
];
const CHANGE_COLUMN: Node = Node::Seq(CHANGE_COLUMN_NODES);

// =================================================================
// AST builders
// =================================================================

/// First ident whose role matches.
fn ident<'a>(path: &'a MatchedPath, role: &str) -> Option<&'a str> {
    path.items.iter().find_map(|i| match &i.kind {
        MatchedKind::Ident { role: r, .. } if *r == role => Some(i.text.as_str()),
        _ => None,
    })
}

fn require_ident(path: &MatchedPath, role: &'static str) -> Result<String, ValidationError> {
    ident(path, role)
        .map(str::to_string)
        .ok_or_else(|| ValidationError {
            message_key: "parse.error_wrapper",
            args: vec![("detail", format!("missing {role}"))],
        })
}

/// Every ident whose role matches, in matched (left-to-right)
/// order. Used by the column-list commands.
fn collect_idents(path: &MatchedPath, role: &str) -> Vec<String> {
    path.items
        .iter()
        .filter_map(|i| match &i.kind {
            MatchedKind::Ident { role: r, .. } if *r == role => Some(i.text.clone()),
            _ => None,
        })
        .collect()
}

fn parse_action(words: &[&'static str]) -> ReferentialAction {
    // `set null`, `no action`, `cascade`, `restrict`.
    if words.contains(&"set") && words.contains(&"null") {
        ReferentialAction::SetNull
    } else if words.contains(&"no") && words.contains(&"action") {
        ReferentialAction::NoAction
    } else if words.contains(&"cascade") {
        ReferentialAction::Cascade
    } else if words.contains(&"restrict") {
        ReferentialAction::Restrict
    } else {
        ReferentialAction::default_action()
    }
}

fn build_drop(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
    // Discriminate by the second word matched (the entry was
    // `drop`, the next Word is `table` / `column` / `relationship`).
    let sub = path
        .items
        .iter()
        .filter_map(|i| match &i.kind {
            MatchedKind::Word(w) => Some(*w),
            _ => None,
        })
        .nth(1);
    match sub {
        Some("table") => Ok(Command::DropTable {
            name: require_ident(path, "table_name")?,
        }),
        Some("column") => Ok(Command::DropColumn {
            table: require_ident(path, "table_name")?,
            column: require_ident(path, "column_name")?,
            cascade: path
                .items
                .iter()
                .any(|i| matches!(&i.kind, MatchedKind::Flag("cascade"))),
        }),
        Some("index") => {
            // Positional form has `on` as the third Word.
            let has_on = path
                .items
                .iter()
                .any(|i| matches!(&i.kind, MatchedKind::Word("on")));
            if has_on {
                Ok(Command::DropIndex {
                    selector: IndexSelector::Columns {
                        table: require_ident(path, "table_name")?,
                        columns: collect_idents(path, "column_name"),
                    },
                })
            } else {
                Ok(Command::DropIndex {
                    selector: IndexSelector::Named {
                        name: require_ident(path, "index_name")?,
                    },
                })
            }
        }
        Some("constraint") => build_drop_constraint(path, _source),
        Some("relationship") => {
            // Endpoints form has `from` as the third Word.
            let has_from = path
                .items
                .iter()
                .any(|i| matches!(&i.kind, MatchedKind::Word("from")));
            if has_from {
                Ok(Command::DropRelationship {
                    selector: RelationshipSelector::Endpoints {
                        parent_table: require_ident(path, "parent_table")?,
                        parent_column: require_ident(path, "parent_column")?,
                        child_table: require_ident(path, "child_table")?,
                        child_column: require_ident(path, "child_column")?,
                    },
                })
            } else {
                Ok(Command::DropRelationship {
                    selector: RelationshipSelector::Named {
                        name: require_ident(path, "relationship_name")?,
                    },
                })
            }
        }
        _ => Err(ValidationError {
            message_key: "parse.error_wrapper",
            args: vec![("detail", "unknown drop subcommand".to_string())],
        }),
    }
}

fn build_add(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
    // Second matched Word distinguishes column vs the `1:n
    // relationship` form. The `1` literal counts as a Word
    // (the walker records Literal matches as MatchedKind::Word
    // for AST-builder uniformity).
    let second_word = path
        .items
        .iter()
        .filter_map(|i| match &i.kind {
            MatchedKind::Word(w) => Some(*w),
            _ => None,
        })
        .nth(1);
    match second_word {
        Some("column") => {
            let ty_text = require_ident(path, "type")?;
            let ty = ty_text
                .parse::<crate::dsl::types::Type>()
                .map_err(|_| ValidationError {
                    message_key: "parse.error_wrapper",
                    args: vec![("detail", "unknown type".to_string())],
                })?;
            let (not_null, unique, default, check) = collect_column_constraints(path)?;
            Ok(Command::AddColumn {
                table: require_ident(path, "table_name")?,
                column: require_ident(path, "column_name")?,
                ty,
                not_null,
                unique,
                default,
                check,
            })
        }
        Some("1") => build_add_relationship(path, _source),
        Some("index") => Ok(Command::AddIndex {
            name: ident(path, "index_name").map(str::to_string),
            table: require_ident(path, "table_name")?,
            columns: collect_idents(path, "column_name"),
        }),
        Some("constraint") => build_add_constraint(path, _source),
        _ => Err(ValidationError {
            message_key: "parse.error_wrapper",
            args: vec![("detail", "unknown add subcommand".to_string())],
        }),
    }
}

fn build_add_relationship(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
    // Collect all referential-clause actions in matched order
    // and validate at-most-2 + not-repeated. The `on <delete|
    // update> <action>` sequence shows up as a run of Word
    // matches in the path between `to <child>` and end.
    //
    // Strategy: walk through Word items in order; whenever we
    // see `on`, the next Word is the target, then the action
    // word(s) follow until the next `on`, `--create-fk`, or
    // end-of-path.
    let words: Vec<&'static str> = path
        .items
        .iter()
        .filter_map(|i| match &i.kind {
            MatchedKind::Word(w) => Some(*w),
            _ => None,
        })
        .collect();

    let mut on_delete: Option<ReferentialAction> = None;
    let mut on_update: Option<ReferentialAction> = None;
    let mut i = 0;
    while i < words.len() {
        if words[i] == "on" && i + 1 < words.len() {
            let target = words[i + 1];
            // Action runs from i+2 until the next `on` or end.
            let action_start = i + 2;
            let mut action_end = action_start;
            while action_end < words.len() && words[action_end] != "on" {
                action_end += 1;
            }
            let action = parse_action(&words[action_start..action_end]);
            let slot = match target {
                "delete" => &mut on_delete,
                "update" => &mut on_update,
                _ => {
                    i = action_end;
                    continue;
                }
            };
            if slot.is_some() {
                return Err(ValidationError {
                    message_key: "parse.custom.on_action_specified_twice",
                    args: vec![("target", target.to_string())],
                });
            }
            *slot = Some(action);
            i = action_end;
        } else {
            i += 1;
        }
    }

    let create_fk = path
        .items
        .iter()
        .any(|i| matches!(&i.kind, MatchedKind::Flag("create-fk")));

    // Collect every matched `parent_column` / `child_column` ident, in
    // order — one each for the single-column `from P.col to C.col`
    // form, or the full lists for the parenthesized compound form
    // `from P.(a, b) to C.(x, y)` (ADR-0043).
    let parent_columns = collect_idents(path, "parent_column");
    let child_columns = collect_idents(path, "child_column");
    if parent_columns.is_empty() || child_columns.is_empty() {
        return Err(ValidationError {
            message_key: "parse.error_wrapper",
            args: vec![("detail", "a relationship needs both endpoints".to_string())],
        });
    }
    Ok(Command::AddRelationship {
        name: ident(path, "relationship_name").map(str::to_string),
        parent_table: require_ident(path, "parent_table")?,
        parent_columns,
        child_table: require_ident(path, "child_table")?,
        child_columns,
        on_delete: on_delete.unwrap_or_else(ReferentialAction::default_action),
        on_update: on_update.unwrap_or_else(ReferentialAction::default_action),
        create_fk,
    })
}

fn build_rename_column(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
    Ok(Command::RenameColumn {
        table: require_ident(path, "table_name")?,
        old: require_ident(path, "column_name")?,
        new: require_ident(path, "new_column_name")?,
    })
}

fn build_change_column(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
    let ty_text = require_ident(path, "type")?;
    let ty = ty_text
        .parse::<crate::dsl::types::Type>()
        .map_err(|_| ValidationError {
            message_key: "parse.error_wrapper",
            args: vec![("detail", "unknown type".to_string())],
        })?;

    // Flags: at most one of --force-conversion / --dont-convert.
    let flags: Vec<&'static str> = path
        .items
        .iter()
        .filter_map(|i| match &i.kind {
            MatchedKind::Flag(n) => Some(*n),
            _ => None,
        })
        .collect();
    let mode = match flags.as_slice() {
        [] => ChangeColumnMode::Default,
        [one] => match *one {
            "force-conversion" => ChangeColumnMode::ForceConversion,
            "dont-convert" => ChangeColumnMode::DontConvert,
            _ => ChangeColumnMode::Default,
        },
        _ => {
            // Two or more flags — mutual exclusion fires
            // whether they're the same flag twice or both
            // mutually-exclusive flags appear. Wording mirrors
            // the chumsky parser's `change_column_flags_exclusive`.
            return Err(ValidationError {
                message_key: "parse.custom.change_column_flags_exclusive",
                args: vec![],
            });
        }
    };

    Ok(Command::ChangeColumnType {
        table: require_ident(path, "table_name")?,
        column: require_ident(path, "column_name")?,
        ty,
        mode,
    })
}

/// Build an `add constraint <constraint> to <T>.<col>` command
/// (ADR-0029 §2.2). The `<constraint>` reuses the §2.1
/// `COLUMN_CONSTRAINT` Choice, so exactly one of the four
/// constraint kinds is matched; `collect_column_constraints`
/// recovers it. The §9 redundancy and §5 dry-run checks are
/// execution-time (the parser has no schema) and live in the
/// database worker.
fn build_add_constraint(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
    let (not_null, unique, default, check) = collect_column_constraints(path)?;
    let constraint = if not_null {
        Constraint::NotNull
    } else if unique {
        Constraint::Unique
    } else if let Some(value) = default {
        Constraint::Default(value)
    } else if let Some(expr) = check {
        Constraint::Check(expr)
    } else {
        return Err(ValidationError {
            message_key: "parse.error_wrapper",
            args: vec![("detail", "add constraint needs a constraint".to_string())],
        });
    };
    Ok(Command::AddConstraint {
        table: require_ident(path, "table_name")?,
        column: require_ident(path, "column_name")?,
        constraint,
    })
}

/// Build a `drop constraint <kind> from <T>.<col>` command
/// (ADR-0029 §2.2). `drop` names only the kind — the
/// `DROP_CONSTRAINT_KIND` Choice is payload-free, so the kind
/// is recovered from which keyword(s) the path matched.
fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
    let words: Vec<&'static str> = path
        .items
        .iter()
        .filter_map(|i| match &i.kind {
            MatchedKind::Word(w) => Some(*w),
            _ => None,
        })
        .collect();
    // `not` appears only in the `not null` Seq, so its presence
    // alone identifies the kind.
    let kind = if words.contains(&"not") {
        ConstraintKind::NotNull
    } else if words.contains(&"unique") {
        ConstraintKind::Unique
    } else if words.contains(&"default") {
        ConstraintKind::Default
    } else if words.contains(&"check") {
        ConstraintKind::Check
    } else {
        return Err(ValidationError {
            message_key: "parse.error_wrapper",
            args: vec![(
                "detail",
                "drop constraint needs a constraint kind".to_string(),
            )],
        });
    };
    Ok(Command::DropConstraint {
        table: require_ident(path, "table_name")?,
        column: require_ident(path, "column_name")?,
        kind,
    })
}

// =================================================================
// CommandNodes
// =================================================================

pub static DROP: CommandNode = CommandNode {
    entry: Word::keyword("drop"),
    shape: DROP_SHAPE,
    ast_builder: build_drop,
    help_id: Some("ddl.drop"),
    hint_ids: &[
        "drop_table",
        "drop_column",
        "drop_relationship",
        "drop_index",
        "drop_constraint",
    ],
    usage_ids: &[
        "parse.usage.drop_table",
        "parse.usage.drop_column",
        "parse.usage.drop_relationship",
        "parse.usage.drop_index",
        "parse.usage.drop_constraint",
    ],
};

pub static ADD: CommandNode = CommandNode {
    entry: Word::keyword("add"),
    shape: ADD_SHAPE,
    ast_builder: build_add,
    help_id: Some("ddl.add"),
    // Per-form (ADR-0053 D3): every form is listed so the form-word
    // disambiguation resolves correctly; forms without an authored
    // block yet fall back to tier-2 at render. `add_relationship` is
    // authored as a Phase-B exemplar.
    hint_ids: &[
        "add_column",
        "add_relationship",
        "add_index",
        "add_constraint",
    ],
    usage_ids: &[
        "parse.usage.add_column",
        "parse.usage.add_relationship",
        "parse.usage.add_index",
        "parse.usage.add_constraint",
    ],
};

pub static RENAME: CommandNode = CommandNode {
    entry: Word::keyword("rename"),
    shape: RENAME_COLUMN,
    ast_builder: build_rename_column,
    help_id: Some("ddl.rename"),
    hint_ids: &["rename_column"],
    usage_ids: &["parse.usage.rename_column"],
};

pub static CHANGE: CommandNode = CommandNode {
    entry: Word::keyword("change"),
    shape: CHANGE_COLUMN,
    ast_builder: build_change_column,
    help_id: Some("ddl.change"),
    hint_ids: &["change_column"],
    usage_ids: &["parse.usage.change_column"],
};

// =================================================================
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
//                                          (Phase C)
// =================================================================

const COL_NAME_IDENT: Node = Node::Ident {
    source: IdentSource::NewName,
    role: "col_name",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};
const COL_NAME: Node = Node::Hinted {
    mode: NEW_NAME_HINT,
    inner: &COL_NAME_IDENT,
};

// ADR-0029 column-constraint suffix — `not null`, `unique`,
// `default <literal>`, `check (<expr>)`. One shared fragment:
// `create table` uses it here, `add column` reuses it as its
// type suffix, and `add constraint` reuses the individual
// `COLUMN_CONSTRAINT` Choice for its constraint slot.
const NOT_NULL_NODES: &[Node] = &[
    Node::Word(Word::keyword("not")),
    Node::Word(Word::keyword("null")),
];
const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES);

// Tagged as the `unique` clause-concept region (issue #37). Shared
// across `create table … with pk`, `add constraint`, and (as a bare
// keyword) `drop constraint`, so the concept surfaces anywhere a
// unique constraint is being declared or removed.
const UNIQUE_CONSTRAINT: Node = Node::Concept {
    topic: "unique",
    inner: &Node::Word(Word::keyword("unique")),
};

const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
    Node::Word(Word::keyword("default")),
    super::shared::FALLBACK_VALUE_LITERAL,
];
const DEFAULT_CONSTRAINT: Node = Node::Seq(DEFAULT_CONSTRAINT_NODES);

// `check ( <expr> )` — the expression is the ADR-0026 WHERE
// grammar, reached through `Subgrammar` (ADR-0029 §2.1). The
// parentheses match SQL's `CHECK (…)` and give the parser an
// unambiguous end for the expression.
const CHECK_CONSTRAINT_NODES: &[Node] = &[
    Node::Word(Word::keyword("check")),
    Node::Punct('('),
    Node::Subgrammar(&super::expr::OR_EXPR),
    Node::Punct(')'),
];
// Tagged as the `check` clause-concept region (issue #37). The span
// covers the whole `check ( <expr> )`, so a cursor inside the
// predicate still resolves the concept.
const CHECK_CONSTRAINT: Node = Node::Concept {
    topic: "check",
    inner: &Node::Seq(CHECK_CONSTRAINT_NODES),
};

const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
    NOT_NULL_CONSTRAINT,
    UNIQUE_CONSTRAINT,
    DEFAULT_CONSTRAINT,
    CHECK_CONSTRAINT,
];
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);

/// Zero-or-more constraints — the suffix after a column's
/// `(type)` group (ADR-0029 §2.1). `min: 0` so an
/// unconstrained column still matches.
const COLUMN_CONSTRAINT_SUFFIX: Node = Node::Repeated {
    inner: &COLUMN_CONSTRAINT,
    separator: None,
    min: 0,
};

// =================================================================
// add_constraint / drop_constraint — `add constraint <constraint>
//   to <T>.<col>` / `drop constraint <kind> from <T>.<col>`
//   (ADR-0029 §2.2)
// =================================================================

// Payload-free keyword nodes for `drop constraint` — naming the
// kind is enough, since at most one constraint of each kind
// exists per column. `not null` / `unique` reuse the §2.1
// keyword-only nodes; `default` / `check` need bare-keyword
// variants here (their §2.1 forms carry a literal / expression
// payload that `drop` does not take).
const DROP_DEFAULT_KEYWORD: Node = Node::Word(Word::keyword("default"));
const DROP_CHECK_KEYWORD: Node = Node::Word(Word::keyword("check"));
const DROP_CONSTRAINT_KIND_CHOICES: &[Node] = &[
    NOT_NULL_CONSTRAINT,
    UNIQUE_CONSTRAINT,
    DROP_DEFAULT_KEYWORD,
    DROP_CHECK_KEYWORD,
];
const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES);

// The dotted `<Table>.<column>` target — the same `Ident '.'
// Ident` shape `add 1:n relationship` uses for its endpoints.
// `writes_table: true` on the table ident (via `TABLE_NAME_
// EXISTING`) narrows the `.<column>` slot's completion
// candidates to that table's columns.
const CONSTRAINT_TARGET_NODES: &[Node] = &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES);

const ADD_CONSTRAINT_NODES: &[Node] = &[
    Node::Word(Word::keyword("constraint")),
    COLUMN_CONSTRAINT,
    Node::Word(Word::keyword("to")),
    CONSTRAINT_TARGET,
];
const ADD_CONSTRAINT: Node = Node::Seq(ADD_CONSTRAINT_NODES);

const DROP_CONSTRAINT_NODES: &[Node] = &[
    Node::Word(Word::keyword("constraint")),
    DROP_CONSTRAINT_KIND,
    Node::Word(Word::keyword("from")),
    CONSTRAINT_TARGET,
];
const DROP_CONSTRAINT: Node = Node::Seq(DROP_CONSTRAINT_NODES);

const COL_SPEC_NODES: &[Node] = &[
    COL_NAME,
    Node::Punct('('),
    Node::Ident {
        source: IdentSource::Types,
        role: "col_type",
        validator: Some(TYPE_VALIDATOR),
        highlight_override: Some(HighlightClass::Type),
        writes_table: false,
        writes_column: false,
        writes_user_listed_column: false,
        writes_table_alias: false,
        writes_cte_name: false,
        writes_projection_alias: false,
    },
    Node::Punct(')'),
    COLUMN_CONSTRAINT_SUFFIX,
];
const COL_SPEC: Node = Node::Seq(COL_SPEC_NODES);

const SPEC_LIST: Node = Node::Repeated {
    inner: &COL_SPEC,
    separator: Some(&Node::Punct(',')),
    min: 1,
};
const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST);

// The `with pk` keywords, tagged as the `primary_key` clause-concept
// region (issue #37). Wrapping just the keywords (not the column
// `SPEC_LIST` that follows) keeps the concept crisp: a cursor on
// `with pk` teaches primary keys, while a cursor on a column's own
// `unique` / `check` constraint resolves to that narrower concept.
const WITH_PK_MARKER_NODES: &[Node] = &[
    Node::Word(Word::keyword("with")),
    Node::Word(Word::keyword("pk")),
];
const WITH_PK_MARKER: Node = Node::Concept {
    topic: "primary_key",
    inner: &Node::Seq(WITH_PK_MARKER_NODES),
};
const WITH_PK_NODES: &[Node] = &[WITH_PK_MARKER, SPEC_LIST_OPT];
const WITH_PK: Node = Node::Seq(WITH_PK_NODES);
const WITH_PK_OPT: Node = Node::Optional(&WITH_PK);

const CREATE_TABLE_NODES: &[Node] = &[
    Node::Word(Word::keyword("table")),
    TABLE_NAME_NEW,
    WITH_PK_OPT,
];
const CREATE_TABLE: Node = Node::Seq(CREATE_TABLE_NODES);

/// Consume a `check` constraint's `( <expr> )` from `items`,
/// which must be positioned just after the `Word("check")`,
/// and build the ADR-0026 expression (ADR-0029 §2.1). The
/// grammar's `Seq` guarantees the surrounding `(` … `)`;
/// paren depth handles a parenthesised sub-expression inside.
fn consume_check_expr(
    items: &mut std::iter::Peekable<std::slice::Iter<'_, MatchedItem>>,
) -> Result<Expr, ValidationError> {
    items.next(); // the opening `(`
    let mut depth = 1usize;
    let mut expr_items: Vec<MatchedItem> = Vec::new();
    for inner in items.by_ref() {
        match &inner.kind {
            MatchedKind::Punct('(') => {
                depth += 1;
                expr_items.push(inner.clone());
            }
            MatchedKind::Punct(')') => {
                depth -= 1;
                if depth == 0 {
                    break;
                }
                expr_items.push(inner.clone());
            }
            _ => expr_items.push(inner.clone()),
        }
    }
    super::expr::build_expr(&expr_items)
}

/// Collect the ADR-0029 constraint suffix from a
/// single-column command's matched path (`add column`),
/// returning the `(not_null, unique, default, check)` tuple.
/// The scan reacts only to the constraint keywords, so
/// passing the whole path is safe. (`create table`'s
/// multi-column collection is inline in `build_create_table`.)
fn collect_column_constraints(
    path: &MatchedPath,
) -> Result<(bool, bool, Option<Value>, Option<Expr>), ValidationError> {
    let mut not_null = false;
    let mut unique = false;
    let mut default = None;
    let mut check = None;
    let mut items = path.items.iter().peekable();
    while let Some(item) = items.next() {
        match &item.kind {
            MatchedKind::Word("not") => {
                if matches!(
                    items.peek().map(|i| &i.kind),
                    Some(MatchedKind::Word("null"))
                ) {
                    items.next();
                    not_null = true;
                }
            }
            MatchedKind::Word("unique") => unique = true,
            MatchedKind::Word("default") => {
                let value = items
                    .next()
                    .and_then(crate::dsl::grammar::data::item_to_value)
                    .ok_or_else(|| ValidationError {
                        message_key: "parse.error_wrapper",
                        args: vec![("detail", "default needs a value".to_string())],
                    })?;
                default = Some(value);
            }
            MatchedKind::Word("check") => {
                check = Some(consume_check_expr(&mut items)?);
            }
            _ => {}
        }
    }
    Ok((not_null, unique, default, check))
}

/// The friendly error for declaring a constraint a
/// primary-key column already implies (ADR-0029 §9).
fn redundant_pk_constraint(column: &str, constraint: &str) -> ValidationError {
    ValidationError {
        message_key: "parse.custom.constraint_redundant_on_pk",
        args: vec![
            ("column", column.to_string()),
            ("constraint", constraint.to_string()),
        ],
    }
}

fn build_create_table(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
    let name = require_ident(path, "table_name")?;

    // Walk the matched items, segmenting per column: a
    // `col_name` ident stashes the name, the following
    // `col_type` ident finalises the spec, and the constraint
    // tokens after it (ADR-0029 §2.1) attach to that spec.
    let mut columns: Vec<ColumnSpec> = Vec::new();
    let mut pending_name: Option<String> = None;
    let mut items = path.items.iter().peekable();
    while let Some(item) = items.next() {
        match &item.kind {
            MatchedKind::Ident {
                role: "col_name", ..
            } => {
                pending_name = Some(item.text.clone());
            }
            MatchedKind::Ident {
                role: "col_type", ..
            } => {
                let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
                    message_key: "parse.error_wrapper",
                    args: vec![("detail", "unknown type".to_string())],
                })?;
                let col_name = pending_name.take().ok_or_else(|| ValidationError {
                    message_key: "parse.error_wrapper",
                    args: vec![("detail", "column type without a name".to_string())],
                })?;
                columns.push(ColumnSpec::new(col_name, ty));
            }
            // `not null` — the grammar's `Seq` guarantees a
            // `null` Word follows a matched `not` Word.
            MatchedKind::Word("not") => {
                if matches!(
                    items.peek().map(|i| &i.kind),
                    Some(MatchedKind::Word("null"))
                ) {
                    items.next();
                    if let Some(last) = columns.last_mut() {
                        last.not_null = true;
                    }
                }
            }
            MatchedKind::Word("unique") => {
                if let Some(last) = columns.last_mut() {
                    last.unique = true;
                }
            }
            // `default <literal>` — the `Seq` guarantees a value
            // item follows a matched `default` Word.
            MatchedKind::Word("default") => {
                let value = items
                    .next()
                    .and_then(crate::dsl::grammar::data::item_to_value)
                    .ok_or_else(|| ValidationError {
                        message_key: "parse.error_wrapper",
                        args: vec![("detail", "default needs a value".to_string())],
                    })?;
                if let Some(last) = columns.last_mut() {
                    last.default = Some(value);
                }
            }
            // `check ( <expr> )` (ADR-0029 §2.1).
            MatchedKind::Word("check") => {
                let expr = consume_check_expr(&mut items)?;
                if let Some(last) = columns.last_mut() {
                    last.check = Some(expr);
                }
            }
            _ => {}
        }
    }

    // No PK clause OR `with pk` alone (no specs): if `with` was
    // matched, default to id(serial); otherwise reject with the
    // "tables need a primary key" friendly wording.
    if columns.is_empty() {
        let saw_with = path
            .items
            .iter()
            .any(|i| matches!(i.kind, MatchedKind::Word("with")));
        if saw_with {
            columns.push(ColumnSpec::new("id", Type::Serial));
        } else {
            return Err(ValidationError {
                message_key: "parse.custom.create_table_needs_pk",
                args: vec![],
            });
        }
    }

    // Every `with pk` column is part of the primary key
    // (ADR-0029 §2.1). A PK column is already NOT NULL, and a
    // single-column PK is already UNIQUE — declaring those
    // explicitly is a friendly error, not a silent no-op
    // (ADR-0029 §9).
    let single_column_pk = columns.len() == 1;
    for col in &columns {
        if col.not_null {
            return Err(redundant_pk_constraint(&col.name, "NOT NULL"));
        }
        if col.unique && single_column_pk {
            return Err(redundant_pk_constraint(&col.name, "UNIQUE"));
        }
    }

    let primary_key = columns.iter().map(|c| c.name.clone()).collect();

    Ok(Command::CreateTable {
        name,
        columns,
        primary_key,
    })
}

pub static CREATE: CommandNode = CommandNode {
    entry: Word::keyword("create"),
    shape: CREATE_TABLE,
    ast_builder: build_create_table,
    help_id: Some("ddl.create"),
    hint_ids: &["create_table"],
    usage_ids: &["parse.usage.create_table"],
};

// =================================================================
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
// (ADR-0045 / C4). Generates an auto-named junction table with two FKs
// + two 1:n relationships. A *separate* `CommandNode` under the shared
// `create` entry word (the walker dispatches both); the `m` opener is a
// `Literal` (not a keyword) so it never shadows an identifier, mirroring
// the `1` in `add 1:n relationship`.
// =================================================================

const M2N_T1: Node = Node::Ident {
    source: IdentSource::Tables,
    role: "m2n_t1",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};
const M2N_T2: Node = Node::Ident {
    source: IdentSource::Tables,
    role: "m2n_t2",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};
// Optional `as <junction name>` — a *new* table name (the junction),
// so it reuses `TABLE_NAME_NEW` (role `table_name`, `NewName` source +
// hint). The only `table_name` role in this path, so the builder reads
// it directly as the junction name.
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));

// The `m:n` cardinality marker, tagged as the
// `cardinality_many_to_many` clause-concept region (issue #37).
// Mirrors `CARDINALITY_1N` — a distinct sub-`Seq` so the span covers
// exactly `m:n`.
const CARDINALITY_MN_NODES: &[Node] = &[
    Node::Literal("m"),
    Node::Punct(':'),
    Node::Word(Word::keyword("n")),
];
const CARDINALITY_MN: Node = Node::Concept {
    topic: "cardinality_many_to_many",
    inner: &Node::Seq(CARDINALITY_MN_NODES),
};

const CREATE_M2N_NODES: &[Node] = &[
    CARDINALITY_MN,
    Node::Word(Word::keyword("relationship")),
    Node::Word(Word::keyword("from")),
    M2N_T1,
    Node::Word(Word::keyword("to")),
    M2N_T2,
    M2N_AS_NAME_OPT,
];
const CREATE_M2N_SHAPE: Node = Node::Seq(CREATE_M2N_NODES);

fn build_create_m2n(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
    Ok(Command::CreateM2nRelationship {
        t1: require_ident(path, "m2n_t1")?,
        t2: require_ident(path, "m2n_t2")?,
        name: ident(path, "table_name").map(str::to_string),
    })
}

pub static CREATE_M2N: CommandNode = CommandNode {
    entry: Word::keyword("create"),
    shape: CREATE_M2N_SHAPE,
    ast_builder: build_create_m2n,
    help_id: Some("ddl.create_m2n"),
    hint_ids: &["create_m2n"],
    usage_ids: &["parse.usage.create_m2n"],
};

/// The friendly error for a column type without a preceding name —
/// a structural impossibility given the grammar, defended anyway.
fn sql_col_type_without_name() -> ValidationError {
    ValidationError {
        message_key: "parse.error_wrapper",
        args: vec![("detail", "column type without a name".to_string())],
    }
}

/// Build a `Command::SqlCreateTable` from the advanced-mode SQL
/// `CREATE TABLE` shape (ADR-0035 §1, sub-phases 4a + 4a.2). Executes
/// structurally — extracts the same `ColumnSpec`/`primary_key` the
/// simple-mode builder produces so the worker reuses `do_create_table`.
///
/// Surface: columns and types (the §3 alias map incl. `double
/// precision`), `NOT NULL` / `UNIQUE` / column- and table-level
/// `PRIMARY KEY`, and `IF NOT EXISTS` (4a); per-column `DEFAULT` and
/// `CHECK` (raw `sql_expr` text captured by byte span — `sql_expr`
/// builds no AST) and composite `UNIQUE (a, b)` (4a.2). Table-level
/// multi-column `CHECK` and FK are absent from the grammar (4a.3 / 4b).
fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
    let name = require_ident(path, "table_name")?;
    // `if` only appears in the `IF NOT EXISTS` prefix (the `not` of
    // `NOT NULL` never carries an `if`), so its presence is the flag.
    let if_not_exists = path
        .items
        .iter()
        .any(|i| matches!(i.kind, MatchedKind::Word("if")));

    let mut columns: Vec<ColumnSpec> = Vec::new();
    let mut primary_key: Vec<String> = Vec::new();
    let mut unique_constraints: Vec<Vec<String>> = Vec::new();
    let mut check_constraints: Vec<String> = Vec::new();
    let mut foreign_keys: Vec<SqlForeignKey> = Vec::new();
    let mut pending_name: Option<String> = None;
    // Distinguish a table-level `CHECK (…)` from a column-level one
    // (ADR-0035 §4a.3): both are spelled `check (`, and `Word` matches
    // carry no role, so position is the only signal. `column_open` is
    // `true` while a column definition is accepting constraints in the
    // current element; a `check` seen while it is `false` is table-level.
    // `depth` tracks the parens that reach this loop (the outer column
    // list, type length-args `(10, 2)`, and table-`PRIMARY KEY (a, b)` —
    // the `check`/`default`/table-`unique` arms consume their own parens
    // internally, so they never perturb it). An element separator is a
    // comma at the column-list interior, `depth == 1`.
    let mut column_open = false;
    let mut depth = 0usize;
    // A `CONSTRAINT <name>` prefix stashes the name until the following
    // table-level `FOREIGN KEY` consumes it (ADR-0035 §5, 4b).
    let mut pending_fk_name: Option<String> = None;
    let mut items = path.items.iter().peekable();
    while let Some(item) = items.next() {
        match &item.kind {
            // A column name stashes until its type finalises the spec.
            MatchedKind::Ident {
                role: "col_name", ..
            } => {
                pending_name = Some(item.text.clone());
            }
            // Single-word type — resolve through the SQL alias map.
            MatchedKind::Ident {
                role: "col_type", ..
            } => {
                let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
                    message_key: "parse.error_wrapper",
                    args: vec![("detail", "unknown type".to_string())],
                })?;
                let col_name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
                columns.push(ColumnSpec::new(col_name, ty));
                column_open = true;
            }
            // `double precision` — the two-word alias maps to `real`.
            // The grammar guarantees `precision` follows `double`.
            MatchedKind::Word("double") => {
                if matches!(
                    items.peek().map(|i| &i.kind),
                    Some(MatchedKind::Word("precision"))
                ) {
                    items.next();
                }
                let col_name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
                columns.push(ColumnSpec::new(col_name, Type::Real));
                column_open = true;
            }
            // A table-level `PRIMARY KEY (col, …)` column reference.
            MatchedKind::Ident {
                role: "pk_column", ..
            } => {
                primary_key.push(item.text.clone());
            }
            // `not null` column constraint (only once a column exists;
            // the `IF NOT EXISTS` `not` precedes every column).
            MatchedKind::Word("not") => {
                if matches!(
                    items.peek().map(|i| &i.kind),
                    Some(MatchedKind::Word("null"))
                ) {
                    items.next();
                    if let Some(last) = columns.last_mut() {
                        last.not_null = true;
                    }
                }
            }
            // `unique` — table-level `UNIQUE (cols)` when followed by
            // `(`, else a column-level constraint on the last column.
            MatchedKind::Word("unique") => {
                if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
                    items.next(); // consume '('
                    let mut cols: Vec<String> = Vec::new();
                    while let Some(it) = items.peek() {
                        match &it.kind {
                            MatchedKind::Ident {
                                role: "unique_column",
                                ..
                            } => {
                                cols.push(it.text.clone());
                                items.next();
                            }
                            MatchedKind::Punct(',') => {
                                items.next();
                            }
                            MatchedKind::Punct(')') => {
                                items.next();
                                break;
                            }
                            _ => break,
                        }
                    }
                    // Single-column table-level UNIQUE folds into the
                    // column's flag (round-trips via the single-column
                    // path); composite (or a name not among the
                    // columns) becomes a constraint.
                    match columns
                        .iter_mut()
                        .find(|c| cols.len() == 1 && c.name == cols[0])
                    {
                        Some(c) => c.unique = true,
                        None if !cols.is_empty() => unique_constraints.push(cols),
                        None => {}
                    }
                } else if let Some(last) = columns.last_mut() {
                    last.unique = true;
                }
            }
            // `primary key` — either a column-level constraint (mark
            // the most recent column) or the table-level clause (whose
            // `pk_column` idents follow and are collected above).
            MatchedKind::Word("primary") => {
                if matches!(
                    items.peek().map(|i| &i.kind),
                    Some(MatchedKind::Word("key"))
                ) {
                    items.next();
                    // Table-level `PRIMARY KEY (…)` is followed by `(`
                    // (then `pk_column` idents, collected above);
                    // column-level `PRIMARY KEY` is not, and marks the
                    // most-recent column.
                    let table_level =
                        matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('(')));
                    if !table_level && let Some(last) = columns.last() {
                        primary_key.push(last.name.clone());
                    }
                }
            }
            // `default <expr>` — capture the expression's raw SQL text
            // by byte span (`sql_expr` builds no AST). The match is
            // maximal, so the expression runs until a depth-0 element
            // boundary (`,` / `)`) or the next constraint keyword.
            MatchedKind::Word("default") => {
                if let Some((s, e)) = capture_expr_span(&mut items)
                    && let Some(last) = columns.last_mut()
                {
                    last.default_sql = Some(source[s..e].trim().to_string());
                }
            }
            // `check ( <expr> )` — capture the inner expression text
            // (without the wrapping parens) by matching paren depth, then
            // route by element position: a CHECK inside an open column
            // definition is column-level (4a.2); one seen at element
            // start (no column open) is a table-level CHECK (4a.3).
            MatchedKind::Word("check") => {
                if let Some((s, e)) = capture_parenthesised_span(&mut items) {
                    let text = source[s..e].trim().to_string();
                    if column_open {
                        if let Some(last) = columns.last_mut() {
                            last.check_sql = Some(text);
                        }
                    } else {
                        check_constraints.push(text);
                    }
                }
            }
            // `constraint <name>` — stash the name for the table-level
            // `foreign key` that follows (ADR-0035 §5, 4b).
            MatchedKind::Word("constraint") => {
                if let Some(it) = items.next() {
                    pending_fk_name = Some(it.text.clone());
                }
            }
            // Inline `references <parent> [(<col>)] [on …]` — a
            // column-level FK on the current column (ADR-0035 §5, 4b).
            // Auto-named at execution; the FK clause's own parens are
            // consumed in `consume_fk_reference`, so they don't perturb
            // the element-boundary `depth` tracker.
            MatchedKind::Word("references") => {
                // Inline FK is single-column (the column it sits on);
                // a compound FK uses the table-level form (ADR-0043 D4).
                let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
                foreign_keys.push(consume_fk_reference(
                    &mut items,
                    None,
                    vec![child_column],
                    true,
                ));
            }
            // Table-level `[constraint <name>] foreign key (<col>)
            // references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
            MatchedKind::Word("foreign") => {
                if matches!(
                    items.peek().map(|i| &i.kind),
                    Some(MatchedKind::Word("key"))
                ) {
                    items.next(); // `key`
                }
                // `( <child column> [, <child column>]* )` — a compound
                // FK lists multiple child columns (ADR-0043).
                if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
                    items.next();
                }
                let mut child_columns = Vec::new();
                while let Some(it) = items.peek() {
                    match &it.kind {
                        MatchedKind::Punct(')') => break,
                        MatchedKind::Punct(',') => {
                            items.next();
                        }
                        _ => child_columns.push(items.next().expect("peeked").text.clone()),
                    }
                }
                if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
                    items.next();
                }
                // `references <parent> …`
                if matches!(
                    items.peek().map(|i| &i.kind),
                    Some(MatchedKind::Word("references"))
                ) {
                    items.next();
                }
                let fk =
                    consume_fk_reference(&mut items, pending_fk_name.take(), child_columns, false);
                foreign_keys.push(fk);
            }
            // Track paren depth for element-boundary detection. The
            // column/`check`/`default`/table-`unique`/FK arms consume
            // their own parens, so the only parens reaching here are the
            // outer column list, type length-args, and
            // table-`PRIMARY KEY (…)`.
            MatchedKind::Punct('(') => depth += 1,
            MatchedKind::Punct(')') => depth = depth.saturating_sub(1),
            // A comma at the column-list interior ends the current
            // element — the next element starts fresh (no column open).
            MatchedKind::Punct(',') if depth == 1 => column_open = false,
            _ => {}
        }
    }

    // De-dup redundant flags off a sole primary-key column (ADR-0035
    // §6.5): a single-column PK is already NOT NULL + UNIQUE, so
    // emitting them again would create a spurious index. Advanced mode
    // accepts the redundant spelling (real SQL does) rather than
    // rejecting it like simple mode (ADR-0029 §9).
    if primary_key.len() == 1
        && let Some(c) = columns.iter_mut().find(|c| c.name == primary_key[0])
    {
        c.not_null = false;
        c.unique = false;
    }

    Ok(Command::SqlCreateTable {
        name,
        columns,
        primary_key,
        unique_constraints,
        check_constraints,
        foreign_keys,
        if_not_exists,
    })
}

/// Capture the byte span of a `DEFAULT <expr>` expression from the
/// matched-item stream (ADR-0035 §4a.2). Consumes the expression's
/// terminals (tracking paren depth) and stops *without consuming* the
/// next depth-0 element boundary (`,` / `)`) or constraint keyword
/// (`not` / `unique` / `primary` / `check`) — those terminals were
/// matched by the following constraint/element, not by the expression.
/// Returns `(start, end)` byte offsets, or `None` if no expression
/// terminal followed.
fn capture_expr_span<'a, I>(items: &mut std::iter::Peekable<I>) -> Option<(usize, usize)>
where
    I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
{
    let mut depth = 0usize;
    let mut start: Option<usize> = None;
    let mut end = 0usize;
    while let Some(it) = items.peek() {
        match &it.kind {
            MatchedKind::Punct(',' | ')') if depth == 0 => break,
            MatchedKind::Word("not" | "unique" | "primary" | "check") if depth == 0 => break,
            _ => {
                match &it.kind {
                    MatchedKind::Punct('(') => depth += 1,
                    MatchedKind::Punct(')') => depth = depth.saturating_sub(1),
                    _ => {}
                }
                start.get_or_insert(it.span.0);
                end = it.span.1;
                items.next();
            }
        }
    }
    start.map(|s| (s, end))
}

/// Capture the byte span of the contents of a parenthesised group
/// (`CHECK ( <expr> )`) from the matched-item stream — the next item
/// must be the opening `(`. Consumes through the matching `)` (tracking
/// nested parens) and returns the `(start, end)` offsets of the text
/// *between* the parens, or `None` if no `(` follows.
fn capture_parenthesised_span<'a, I>(items: &mut std::iter::Peekable<I>) -> Option<(usize, usize)>
where
    I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
{
    if !matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
        return None;
    }
    let open = items.next()?; // '('
    let inner_start = open.span.1;
    let mut depth = 1usize;
    let mut inner_end = inner_start;
    for it in items.by_ref() {
        match &it.kind {
            MatchedKind::Punct('(') => depth += 1,
            MatchedKind::Punct(')') => {
                depth -= 1;
                if depth == 0 {
                    inner_end = it.span.0;
                    break;
                }
            }
            _ => {}
        }
    }
    Some((inner_start, inner_end))
}

/// Consume the tail of a foreign-key reference from the matched-item
/// stream (ADR-0035 §5, sub-phase 4b): the parent table ident, an
/// optional `( <parent col> )`, and any `on <delete|update> <action>`
/// clauses. The next item must be the parent-table ident (the
/// `references` keyword was already consumed by the caller). The
/// reference's own parens are consumed here, so they never reach the
/// builder's element-boundary `depth` tracker.
fn consume_fk_reference<'a, I>(
    items: &mut std::iter::Peekable<I>,
    name: Option<String>,
    child_columns: Vec<String>,
    inline: bool,
) -> SqlForeignKey
where
    I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
{
    let parent_table = items.next().map_or_else(String::new, |it| it.text.clone());
    // Optional `( <parent column> [, <parent column>]* )` — a
    // compound FK references multiple parent columns (ADR-0043).
    // `None` for the bare `REFERENCES <parent>` form.
    let parent_columns: Option<Vec<String>> =
        if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
            items.next(); // `(`
            let mut cols = Vec::new();
            while let Some(it) = items.peek() {
                match &it.kind {
                    MatchedKind::Punct(')') => break,
                    MatchedKind::Punct(',') => {
                        items.next();
                    }
                    _ => cols.push(items.next().expect("peeked").text.clone()),
                }
            }
            if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
                items.next(); // `)`
            }
            Some(cols)
        } else {
            None
        };
    // `on <delete|update> <action>` clauses, in either order, 0..2.
    let mut on_delete = ReferentialAction::default_action();
    let mut on_update = ReferentialAction::default_action();
    while matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("on"))) {
        items.next(); // `on`
        let target = items.next().map(|it| it.kind.clone());
        let action = consume_referential_action(items);
        match target {
            Some(MatchedKind::Word("delete")) => on_delete = action,
            Some(MatchedKind::Word("update")) => on_update = action,
            _ => {}
        }
    }
    SqlForeignKey {
        name,
        child_columns,
        parent_table,
        parent_columns,
        on_delete,
        on_update,
        inline,
    }
}

/// Read a single referential action (`cascade` / `restrict` /
/// `set null` / `no action`) from the matched-item stream — the
/// two-word forms (`set null`, `no action`) consume their second word.
fn consume_referential_action<'a, I>(items: &mut std::iter::Peekable<I>) -> ReferentialAction
where
    I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
{
    match items.next().map(|it| it.kind.clone()) {
        Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade,
        Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict,
        Some(MatchedKind::Word("set")) => {
            if matches!(
                items.peek().map(|i| &i.kind),
                Some(MatchedKind::Word("null"))
            ) {
                items.next();
            }
            ReferentialAction::SetNull
        }
        Some(MatchedKind::Word("no")) => {
            if matches!(
                items.peek().map(|i| &i.kind),
                Some(MatchedKind::Word("action"))
            ) {
                items.next();
            }
            ReferentialAction::NoAction
        }
        _ => ReferentialAction::default_action(),
    }
}

pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
    entry: Word::keyword("create"),
    shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),
    ast_builder: build_sql_create_table,
    help_id: Some("ddl.sql_create_table"),
    hint_ids: &["sql_create_table"],
    usage_ids: &["parse.usage.sql_create_table"],
};

/// Build a `Command::SqlDropTable` from the advanced-mode SQL
/// `DROP TABLE [IF EXISTS] <name>` shape (ADR-0035 §4, sub-phase 4c).
/// `if` appears only in the `IF EXISTS` prefix, so its presence is the
/// flag (mirroring `build_sql_create_table`'s `if_not_exists`).
fn build_sql_drop_table(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
    Ok(Command::SqlDropTable {
        name: require_ident(path, "table_name")?,
        if_exists: path.contains_word("if"),
    })
}

pub static SQL_DROP_TABLE: CommandNode = CommandNode {
    entry: Word::keyword("drop"),
    shape: SQL_DROP_TABLE_SHAPE,
    ast_builder: build_sql_drop_table,
    help_id: Some("ddl.sql_drop_table"),
    hint_ids: &["sql_drop_table"],
    usage_ids: &["parse.usage.sql_drop_table"],
};

/// Build a `Command::SqlDropIndex` from the advanced-mode SQL
/// `DROP INDEX [IF EXISTS] <name>` shape (ADR-0035 §4, sub-phase 4d).
/// `if` appears only in the `IF EXISTS` prefix, so its presence is the
/// flag (mirroring `build_sql_drop_table`).
fn build_sql_drop_index(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
    Ok(Command::SqlDropIndex {
        name: require_ident(path, "index_name")?,
        if_exists: path.contains_word("if"),
    })
}

pub static SQL_DROP_INDEX: CommandNode = CommandNode {
    entry: Word::keyword("drop"),
    shape: SQL_DROP_INDEX_SHAPE,
    ast_builder: build_sql_drop_index,
    help_id: Some("ddl.sql_drop_index"),
    hint_ids: &["sql_drop_index"],
    usage_ids: &["parse.usage.sql_drop_index"],
};

// =================================================================
// SQL `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)`
// (ADR-0035 §4d). Entry word `create` — `create`'s *second* advanced
// node (alongside SQL_CREATE_TABLE).
// =================================================================

// Leading `[UNIQUE]` prefix as a `Choice` whose every branch starts on a
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
// builder reads `unique` presence via `contains_word("unique")`.
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = &[
    Node::Word(Word::keyword("unique")),
    Node::Word(Word::keyword("index")),
];
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
static SQL_CI_LEAD_CHOICES: &[Node] = &[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);

static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
    Node::Word(Word::keyword("if")),
    Node::Word(Word::keyword("not")),
    Node::Word(Word::keyword("exists")),
];
const SQL_CI_IF_NOT_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_CI_IF_NOT_EXISTS_NODES));

// The name/`on` selector. The **unnamed** (`on`-led) branch comes FIRST,
// relying on `Choice` backtracking — exactly the shipped `DI_SELECTOR`
// pattern (`DI_POSITIONAL` first). A bare `Optional(<name>)` would
// instead greedily consume the `on` keyword (`consume_ident` does not
// reject keywords), breaking the unnamed form.
static SQL_CI_UNNAMED_NODES: &[Node] = &[
    Node::Word(Word::keyword("on")),
    TABLE_NAME_EXISTING,
    Node::Punct('('),
    INDEX_COLUMN_LIST,
    Node::Punct(')'),
];
const SQL_CI_UNNAMED: Node = Node::Seq(SQL_CI_UNNAMED_NODES);
static SQL_CI_NAMED_NODES: &[Node] = &[
    INDEX_NAME_NEW,
    Node::Word(Word::keyword("on")),
    TABLE_NAME_EXISTING,
    Node::Punct('('),
    INDEX_COLUMN_LIST,
    Node::Punct(')'),
];
const SQL_CI_NAMED: Node = Node::Seq(SQL_CI_NAMED_NODES);
static SQL_CI_SELECTOR_CHOICES: &[Node] = &[SQL_CI_UNNAMED, SQL_CI_NAMED];
const SQL_CI_SELECTOR: Node = Node::Choice(SQL_CI_SELECTOR_CHOICES);

static SQL_CREATE_INDEX_SHAPE_NODES: &[Node] = &[
    SQL_CI_LEAD,
    SQL_CI_IF_NOT_EXISTS_OPT,
    SQL_CI_SELECTOR,
    Node::Optional(&Node::Punct(';')),
];
const SQL_CREATE_INDEX_SHAPE: Node = Node::Seq(SQL_CREATE_INDEX_SHAPE_NODES);

/// Build a `Command::SqlCreateIndex` from the advanced-mode SQL
/// `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [<name>] ON <T> (cols)` shape
/// (ADR-0035 §4d). `unique`/`if_not_exists` are keyword-presence flags
/// (`unique` only in the lead; `if` only in `IF NOT EXISTS`); the name
/// is present iff the `SQL_CI_NAMED` branch matched. Columns / table
/// extraction mirrors the simple `add index` builder.
fn build_sql_create_index(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
    Ok(Command::SqlCreateIndex {
        name: ident(path, "index_name").map(str::to_string),
        table: require_ident(path, "table_name")?,
        columns: collect_idents(path, "column_name"),
        unique: path.contains_word("unique"),
        if_not_exists: path.contains_word("if"),
    })
}

pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
    entry: Word::keyword("create"),
    shape: SQL_CREATE_INDEX_SHAPE,
    ast_builder: build_sql_create_index,
    help_id: Some("ddl.sql_create_index"),
    hint_ids: &["sql_create_index"],
    usage_ids: &["parse.usage.sql_create_index"],
};

// =================================================================
// SQL `ALTER TABLE <T> <action>` (ADR-0035 §4, sub-phase 4e).
// `alter` is an advanced-*only* entry word (like `select`/`with`).
// Actions: ADD/DROP/RENAME COLUMN — the `COLUMN` keyword is required
// (reserves bare `RENAME TO` for 4h and `ADD CONSTRAINT` for 4g).
// =================================================================

// The ALTER table slot carries the SQL-family `reject_internal_table`
// validator (parse-time refusal; the executors guard the rest) and
// `writes_table` so the DROP/RENAME column slot narrows to its columns.
const AT_TABLE_NAME: Node = Node::Ident {
    source: IdentSource::Tables,
    role: "table_name",
    validator: Some(super::sql_select::reject_internal_table),
    highlight_override: None,
    writes_table: true,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};

// ADD COLUMN's constraint suffix — the SQL leaf nodes for NOT NULL /
// UNIQUE / DEFAULT / CHECK only. PK and inline REFERENCES are
// deliberately excluded (PK is invalid on ADD COLUMN; REFERENCES is 4g).
static AT_ADD_CONSTRAINT_CHOICES: &[Node] = &[
    Node::Seq(super::sql_create_table::NOT_NULL_NODES),
    Node::Word(Word::keyword("unique")),
    Node::Seq(super::sql_create_table::DEFAULT_NODES),
    Node::Seq(super::sql_create_table::CHECK_NODES),
];
const AT_ADD_CONSTRAINT: Node = Node::Choice(AT_ADD_CONSTRAINT_CHOICES);
const AT_ADD_CONSTRAINT_SUFFIX: Node = Node::Repeated {
    inner: &AT_ADD_CONSTRAINT,
    separator: None,
    min: 0,
};

// The walker's `Choice` selects a branch by its **leading** token and
// does not backtrack into a sibling once a branch's first keyword
// matched. So the action `Choice` keeps ONE branch per leading verb
// (`add`/`drop`/`rename`/`alter`); the `add` and `drop` verbs then
// fan out to an **inner** `Choice` whose branches each lead on a
// *distinct* second keyword (column / constraint / check / unique /
// foreign / primary), so no two same-led branches ever sit in one
// `Choice`.

// `add column <col> <type> [constraints]` — the column-def tail (the
// leading `add` is consumed by `AT_ADD`).
static AT_ADD_COLUMN_TAIL_NODES: &[Node] = &[
    Node::Word(Word::keyword("column")),
    super::sql_create_table::COL_NAME,
    super::sql_create_table::SQL_TYPE,
    AT_ADD_CONSTRAINT_SUFFIX,
];
const AT_ADD_COLUMN_TAIL: Node = Node::Seq(AT_ADD_COLUMN_TAIL_NODES);

// `drop column <col>` / `drop constraint <name>` tails (leading `drop`
// consumed by `AT_DROP`).
static AT_DROP_COLUMN_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("column")), COLUMN_NAME];
const AT_DROP_COLUMN_TAIL: Node = Node::Seq(AT_DROP_COLUMN_TAIL_NODES);

// New-table-name slot for `RENAME TO <new>` (ADR-0035 §6, sub-phase 4h).
// Mirrors the `CREATE TABLE` name slot: `IdentSource::NewName` (a name
// being introduced, not completed from existing tables) + the same
// `reject_internal_table` parse-time validator, so an `__rdbms_*` target
// is refused before submit. Wrapped in `NEW_NAME_HINT` like
// `NEW_COLUMN_NAME`. `writes_table: false` — nothing downstream of
// `rename to <new>` references the schema cache.
const NEW_TABLE_NAME_IDENT: Node = Node::Ident {
    source: IdentSource::NewName,
    role: "new_table_name",
    validator: Some(super::sql_select::reject_internal_table),
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};
const NEW_TABLE_NAME: Node = Node::Hinted {
    mode: NEW_NAME_HINT,
    inner: &NEW_TABLE_NAME_IDENT,
};

// The `rename` verb fans out (like `add`/`drop`, §6.1) to an inner
// `Choice` whose two tails lead on DISTINCT second keywords: `column`
// (rename column) and `to` (rename table — 4h). The walker `Choice`
// selects by the leading token and never backtracks between branches, so
// the distinct keywords keep them apart.
static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[
    Node::Word(Word::keyword("column")),
    COLUMN_NAME,
    Node::Word(Word::keyword("to")),
    NEW_COLUMN_NAME,
];
const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES);
static AT_RENAME_TABLE_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES);
static AT_RENAME_TAIL_CHOICES: &[Node] = &[AT_RENAME_COLUMN_TAIL, AT_RENAME_TABLE_TAIL];
const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES);
static AT_RENAME_NODES: &[Node] = &[Node::Word(Word::keyword("rename")), AT_RENAME_TAIL];
const AT_RENAME: Node = Node::Seq(AT_RENAME_NODES);

// `ALTER COLUMN <col> <action>` (ADR-0035 §4f + Amendment 2). The action
// tail is a Choice on distinct concrete keywords — `type` / `set` /
// `drop` — trap-safe. The type slot reuses SQL_TYPE (the same alias map +
// `double precision` pair the CREATE TABLE / ADD COLUMN forms use).
//
//   TYPE <ty>                 — PostgreSQL shorthand (§4f)
//   SET DATA TYPE <ty>        — ISO canonical synonym (Amendment 2)
//   SET NOT NULL              — documented extension (Amendment 2)
//   SET DEFAULT <expr>        — ISO (Amendment 2), raw sql_expr text
//   DROP NOT NULL             — ISO-ish (Amendment 2)
//   DROP DEFAULT              — ISO (Amendment 2)
//
// `NOT NULL` reused by both SET and DROP tails (distinct sibling leads in
// each). `DEFAULT <expr>` reuses the CREATE TABLE `DEFAULT_NODES` so a
// default is one syntax, captured as raw text (sql_expr builds no AST).
static AT_AC_TYPE_NODES: &[Node] = &[
    Node::Word(Word::keyword("type")),
    super::sql_create_table::SQL_TYPE,
];
const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES);
static AT_AC_NOT_NULL_NODES: &[Node] = &[
    Node::Word(Word::keyword("not")),
    Node::Word(Word::keyword("null")),
];
const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES);
static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[
    Node::Word(Word::keyword("data")),
    Node::Word(Word::keyword("type")),
    super::sql_create_table::SQL_TYPE,
];
const AT_AC_SET_DATA_TYPE: Node = Node::Seq(AT_AC_SET_DATA_TYPE_NODES);
static AT_AC_SET_TAIL_CHOICES: &[Node] = &[
    AT_AC_SET_DATA_TYPE,
    AT_AC_NOT_NULL,
    Node::Seq(super::sql_create_table::DEFAULT_NODES),
];
const AT_AC_SET_TAIL: Node = Node::Choice(AT_AC_SET_TAIL_CHOICES);
static AT_AC_SET_NODES: &[Node] = &[Node::Word(Word::keyword("set")), AT_AC_SET_TAIL];
const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES);
static AT_AC_DROP_TAIL_CHOICES: &[Node] = &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES);
static AT_AC_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_AC_DROP_TAIL];
const AT_AC_DROP: Node = Node::Seq(AT_AC_DROP_NODES);
static AT_ALTER_COLUMN_TAIL_CHOICES: &[Node] = &[AT_AC_TYPE, AT_AC_SET, AT_AC_DROP];
const AT_ALTER_COLUMN_TAIL: Node = Node::Choice(AT_ALTER_COLUMN_TAIL_CHOICES);
static AT_ALTER_COLUMN_NODES: &[Node] = &[
    Node::Word(Word::keyword("alter")),
    Node::Word(Word::keyword("column")),
    COLUMN_NAME,
    AT_ALTER_COLUMN_TAIL,
];
const AT_ALTER_COLUMN: Node = Node::Seq(AT_ALTER_COLUMN_NODES);

// --- 4g: ADD [CONSTRAINT <name>] table-constraint / DROP CONSTRAINT ---
//
// `ADD [CONSTRAINT <name>] (check (…) | unique (…) | foreign key (…)
// references … | primary key (…))` and `DROP CONSTRAINT <name>`
// (ADR-0035 §4g). The constraint bodies reuse the `sql_create_table`
// table-element nodes; the §4g name comes from the `CONSTRAINT <name>`
// prefix (a dedicated `constraint`-led inner branch — never a leading
// `Optional`). UNIQUE/PRIMARY KEY may carry a name syntactically but the
// builder refuses it (composite UNIQUE is anonymous; PK is unsupported).
const CONSTRAINT_NAME: Node = Node::Ident {
    source: IdentSource::NewName,
    role: "constraint_name",
    validator: None,
    highlight_override: None,
    writes_table: false,
    writes_column: false,
    writes_user_listed_column: false,
    writes_table_alias: false,
    writes_cte_name: false,
    writes_projection_alias: false,
};
// The constraint bodies — each leads on a distinct concrete keyword.
static AT_CONSTRAINT_BODY_CHOICES: &[Node] = &[
    super::sql_create_table::TABLE_CHECK,
    super::sql_create_table::TABLE_UNIQUE,
    super::sql_create_table::TABLE_FK,
    // `primary key (…)` parses so the builder can refuse it with a
    // specific message rather than a generic "unexpected `primary`".
    super::sql_create_table::TABLE_PK,
];
const AT_CONSTRAINT_BODY: Node = Node::Choice(AT_CONSTRAINT_BODY_CHOICES);
// `constraint <name> <body>` — the named-constraint tail (leads on the
// concrete `constraint` keyword, so it is a safe `Choice` sibling).
static AT_CONSTRAINT_NAMED_NODES: &[Node] = &[
    Node::Word(Word::keyword("constraint")),
    CONSTRAINT_NAME,
    AT_CONSTRAINT_BODY,
];
const AT_CONSTRAINT_NAMED: Node = Node::Seq(AT_CONSTRAINT_NAMED_NODES);

// The `add` tail: a column def, a named constraint, or one of the bare
// (unnamed) constraint bodies — each branch leads on a distinct keyword
// (column / constraint / check / unique / foreign / primary).
static AT_ADD_TAIL_CHOICES: &[Node] = &[
    AT_ADD_COLUMN_TAIL,
    AT_CONSTRAINT_NAMED,
    super::sql_create_table::TABLE_CHECK,
    super::sql_create_table::TABLE_UNIQUE,
    super::sql_create_table::TABLE_FK,
    super::sql_create_table::TABLE_PK,
];
const AT_ADD_TAIL: Node = Node::Choice(AT_ADD_TAIL_CHOICES);
static AT_ADD_NODES: &[Node] = &[Node::Word(Word::keyword("add")), AT_ADD_TAIL];
const AT_ADD: Node = Node::Seq(AT_ADD_NODES);

// The `drop` tail: a column or a named constraint (distinct second
// keywords `column` / `constraint`).
static AT_DROP_CONSTRAINT_TAIL_NODES: &[Node] =
    &[Node::Word(Word::keyword("constraint")), CONSTRAINT_NAME];
const AT_DROP_CONSTRAINT_TAIL: Node = Node::Seq(AT_DROP_CONSTRAINT_TAIL_NODES);
static AT_DROP_TAIL_CHOICES: &[Node] = &[AT_DROP_COLUMN_TAIL, AT_DROP_CONSTRAINT_TAIL];
const AT_DROP_TAIL: Node = Node::Choice(AT_DROP_TAIL_CHOICES);
static AT_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_DROP_TAIL];
const AT_DROP: Node = Node::Seq(AT_DROP_NODES);

// One branch per leading verb (`add`/`drop`/`rename`/`alter`) — distinct
// concrete keywords, trap-safe. (The branch's `alter` is the action
// word; the entry-word `alter` was already consumed by dispatch.) The
// second-keyword fan-out happens in `AT_ADD` / `AT_DROP`'s inner Choice.
static AT_ACTION_CHOICES: &[Node] = &[AT_ADD, AT_DROP, AT_RENAME, AT_ALTER_COLUMN];
const AT_ACTION: Node = Node::Choice(AT_ACTION_CHOICES);

static SQL_ALTER_TABLE_SHAPE_NODES: &[Node] = &[
    Node::Word(Word::keyword("table")),
    AT_TABLE_NAME,
    AT_ACTION,
    Node::Optional(&Node::Punct(';')),
];
const SQL_ALTER_TABLE_SHAPE: Node = Node::Seq(SQL_ALTER_TABLE_SHAPE_NODES);

/// Build the single `ColumnSpec` for an `ALTER TABLE … ADD COLUMN`
/// (ADR-0035 §4e). Mirrors the SQL `CREATE TABLE` per-column extraction
/// for one column: DEFAULT/CHECK are captured as **raw text** by byte
/// span (`sql_expr` builds no AST — 4a.2), so the executor consumes
/// `default_sql`/`check_sql`.
fn build_alter_add_column_spec(
    path: &MatchedPath,
    source: &str,
) -> Result<ColumnSpec, ValidationError> {
    let mut spec: Option<ColumnSpec> = None;
    let mut pending_name: Option<String> = None;
    let mut items = path.items.iter().peekable();
    while let Some(item) = items.next() {
        match &item.kind {
            MatchedKind::Ident {
                role: "col_name", ..
            } => {
                pending_name = Some(item.text.clone());
            }
            MatchedKind::Ident {
                role: "col_type", ..
            } => {
                let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
                    message_key: "parse.error_wrapper",
                    args: vec![("detail", "unknown type".to_string())],
                })?;
                let name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
                spec = Some(ColumnSpec::new(name, ty));
            }
            MatchedKind::Word("double") => {
                if matches!(
                    items.peek().map(|i| &i.kind),
                    Some(MatchedKind::Word("precision"))
                ) {
                    items.next();
                }
                let name = pending_name.take().ok_or_else(sql_col_type_without_name)?;
                spec = Some(ColumnSpec::new(name, Type::Real));
            }
            MatchedKind::Word("not") => {
                if matches!(
                    items.peek().map(|i| &i.kind),
                    Some(MatchedKind::Word("null"))
                ) {
                    items.next();
                    if let Some(s) = spec.as_mut() {
                        s.not_null = true;
                    }
                }
            }
            MatchedKind::Word("unique") => {
                if let Some(s) = spec.as_mut() {
                    s.unique = true;
                }
            }
            MatchedKind::Word("default") => {
                if let Some((start, end)) = capture_expr_span(&mut items)
                    && let Some(s) = spec.as_mut()
                {
                    s.default_sql = Some(source[start..end].trim().to_string());
                }
            }
            MatchedKind::Word("check") => {
                if let Some((start, end)) = capture_parenthesised_span(&mut items)
                    && let Some(s) = spec.as_mut()
                {
                    s.check_sql = Some(source[start..end].trim().to_string());
                }
            }
            _ => {}
        }
    }
    spec.ok_or_else(|| ValidationError {
        message_key: "parse.error_wrapper",
        args: vec![("detail", "add column needs a name and type".to_string())],
    })
}

/// Extract the `ALTER COLUMN <col> TYPE <type>` action (ADR-0035 §4f).
/// The type slot reuses SQL_TYPE, so the target-type extraction mirrors
/// `build_alter_add_column_spec`'s: a `col_type` ident via
/// `Type::from_sql_name` (alias map applied), or the two-word
/// `double precision` → `Type::Real`.
fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, ValidationError> {
    let column = require_ident(path, "column_name")?;
    let mut ty: Option<Type> = None;
    let mut items = path.items.iter().peekable();
    while let Some(item) = items.next() {
        match &item.kind {
            MatchedKind::Ident {
                role: "col_type", ..
            } => {
                ty = Some(
                    Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
                        message_key: "parse.error_wrapper",
                        args: vec![("detail", "unknown type".to_string())],
                    })?,
                );
            }
            MatchedKind::Word("double") => {
                if matches!(
                    items.peek().map(|i| &i.kind),
                    Some(MatchedKind::Word("precision"))
                ) {
                    items.next();
                }
                ty = Some(Type::Real);
            }
            _ => {}
        }
    }
    let ty = ty.ok_or_else(|| ValidationError {
        message_key: "parse.error_wrapper",
        args: vec![("detail", "alter column needs a target type".to_string())],
    })?;
    Ok(AlterTableAction::AlterColumnType { column, ty })
}

/// Build an `ALTER COLUMN <c> SET/DROP NOT NULL` / `SET/DROP DEFAULT`
/// action (ADR-0035 Amendment 2). `SET DATA TYPE` is handled by the
/// `type`-keyword branch, so this only sees the four constraint forms.
/// The 2×2 is decided by `set` (vs drop) and `default` (vs not null);
/// `SET DEFAULT`'s value is captured as raw `sql_expr` text by span,
/// reusing `build_alter_add_column_spec`'s mechanism (no AST — 4a.2).
fn build_alter_column_attr(
    path: &MatchedPath,
    source: &str,
) -> Result<AlterTableAction, ValidationError> {
    let column = require_ident(path, "column_name")?;
    let is_set = path.contains_word("set");
    let is_default = path.contains_word("default");
    Ok(match (is_set, is_default) {
        (true, true) => {
            let mut items = path.items.iter().peekable();
            let mut default_sql: Option<String> = None;
            while let Some(item) = items.next() {
                if matches!(&item.kind, MatchedKind::Word("default"))
                    && let Some((start, end)) = capture_expr_span(&mut items)
                {
                    default_sql = Some(source[start..end].trim().to_string());
                }
            }
            let default_sql = default_sql.ok_or_else(|| ValidationError {
                message_key: "parse.error_wrapper",
                args: vec![("detail", "set default needs a value".to_string())],
            })?;
            AlterTableAction::SetColumnDefault {
                column,
                default_sql,
            }
        }
        (false, true) => AlterTableAction::DropColumnDefault { column },
        (true, false) => AlterTableAction::SetColumnNotNull { column },
        (false, false) => AlterTableAction::DropColumnNotNull { column },
    })
}

/// Build `Command::SqlAlterTable` (ADR-0035 §4e/§4f/§4g). Exactly one
/// action `Choice` branch matched; the builder recovers which from the
/// matched words. Discrimination order matters:
///
/// 1. **`type`** first — unique to ALTER COLUMN TYPE (ADD COLUMN's type
///    is a `col_type` *ident*, not the literal word), and an `alter
///    column …` input also contains `column`, so it must be caught
///    before the column branch.
/// 2. **`column`** — the column ops (add/drop/rename column), routed by
///    `add`/`rename`/else-drop. Checked before the bare `add`/`drop`
///    keywords so `add column … unique`/`… check` (a column constraint)
///    still routes to AddColumn.
/// 3. **`add`** — a table-level constraint (CHECK / UNIQUE / FK / the
///    refused PRIMARY KEY).
/// 4. **`rename`** — `rename to <new>` (table rename, 4h). Reached only
///    when `column` is absent (caught by step 2), so a lone `rename`
///    means the table form. The new name binds a *distinct* role
///    (`new_table_name`), so it never collides with the `table_name`
///    target slot.
/// 5. else **`drop`** — `drop constraint <name>`.
fn build_sql_alter_table(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
    let table = require_ident(path, "table_name")?;
    let action = if path.contains_word("type") {
        // covers `TYPE <ty>` and the ISO synonym `SET DATA TYPE <ty>`
        build_alter_column_type(path)?
    } else if path.contains_word("column")
        && !path.contains_word("add")
        && !path.contains_word("rename")
        && (path.contains_word("set")
            || path.contains_word("default")
            || (path.contains_word("not") && path.contains_word("null")))
    {
        // ADR-0035 Amendment 2: `ALTER COLUMN <c> SET/DROP NOT NULL` and
        // `SET/DROP DEFAULT`. `add column … not null/default` is excluded
        // by `!add`; plain `drop column` lacks the attribute markers.
        build_alter_column_attr(path, source)?
    } else if path.contains_word("column") {
        if path.contains_word("add") {
            AlterTableAction::AddColumn(Box::new(build_alter_add_column_spec(path, source)?))
        } else if path.contains_word("rename") {
            AlterTableAction::RenameColumn {
                old: require_ident(path, "column_name")?,
                new: require_ident(path, "new_column_name")?,
            }
        } else {
            AlterTableAction::DropColumn {
                column: require_ident(path, "column_name")?,
            }
        }
    } else if path.contains_word("add") {
        build_alter_add_table_constraint(path, source)?
    } else if path.contains_word("rename") {
        AlterTableAction::RenameTable {
            new: require_ident(path, "new_table_name")?,
        }
    } else {
        AlterTableAction::DropConstraint {
            name: require_ident(path, "constraint_name")?,
        }
    };
    Ok(Command::SqlAlterTable { table, action })
}

/// Build the `ADD [CONSTRAINT <name>] (CHECK | UNIQUE | FOREIGN KEY | …)`
/// action (ADR-0035 §4g). The body is discriminated by its leading
/// concrete keyword. The optional `CONSTRAINT <name>` prefix becomes the
/// action-level `name` (used by CHECK + FK at execution; refused on
/// UNIQUE). `ADD PRIMARY KEY` parses (for a clean message) but is
/// refused — every playground table already has a PK.
fn build_alter_add_table_constraint(
    path: &MatchedPath,
    source: &str,
) -> Result<AlterTableAction, ValidationError> {
    let name = ident(path, "constraint_name").map(str::to_string);
    if path.contains_word("primary") {
        return Err(ValidationError {
            message_key: "parse.custom.alter_add_primary_key",
            args: vec![],
        });
    }
    let constraint = if path.contains_word("check") {
        TableConstraint::Check {
            expr_sql: capture_table_check_sql(path, source)?,
        }
    } else if path.contains_word("unique") {
        if name.is_some() {
            return Err(ValidationError {
                message_key: "parse.custom.alter_named_unique",
                args: vec![],
            });
        }
        TableConstraint::Unique {
            columns: collect_idents(path, "unique_column"),
        }
    } else {
        // FOREIGN KEY — the §4g name lives at the action level, so the
        // FK body itself is parsed unnamed.
        TableConstraint::ForeignKey(build_alter_fk(path))
    };
    Ok(AlterTableAction::AddTableConstraint {
        name,
        constraint: Box::new(constraint),
    })
}

/// Capture the raw SQL text of an `ADD … CHECK (<expr>)` (ADR-0035 §4g).
/// `sql_expr` is validate-only, so the expression is captured by byte
/// span — the 4a.2 / 4e mechanism.
fn capture_table_check_sql(path: &MatchedPath, source: &str) -> Result<String, ValidationError> {
    let mut items = path.items.iter().peekable();
    while let Some(item) = items.next() {
        if matches!(item.kind, MatchedKind::Word("check"))
            && let Some((start, end)) = capture_parenthesised_span(&mut items)
        {
            return Ok(source[start..end].trim().to_string());
        }
    }
    Err(ValidationError {
        message_key: "parse.error_wrapper",
        args: vec![("detail", "add check needs an expression".to_string())],
    })
}

/// Build the `SqlForeignKey` for an `ADD [CONSTRAINT <name>] FOREIGN KEY
/// (<col>) REFERENCES <P>[(<col>)] [ON …]` (ADR-0035 §4g). Mirrors the
/// table-level FK walk in `build_sql_create_table`, reusing
/// `consume_fk_reference`. The name is supplied at the action level (so
/// the FK is parsed unnamed here).
fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
    let mut items = path.items.iter().peekable();
    // Advance to the `foreign` keyword.
    while items
        .peek()
        .is_some_and(|it| !matches!(it.kind, MatchedKind::Word("foreign")))
    {
        items.next();
    }
    items.next(); // `foreign`
    if matches!(
        items.peek().map(|i| &i.kind),
        Some(MatchedKind::Word("key"))
    ) {
        items.next();
    }
    if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
        items.next();
    }
    // `( <child column> [, <child column>]* )` — compound FK (ADR-0043).
    let mut child_columns = Vec::new();
    while let Some(it) = items.peek() {
        match &it.kind {
            MatchedKind::Punct(')') => break,
            MatchedKind::Punct(',') => {
                items.next();
            }
            _ => child_columns.push(items.next().expect("peeked").text.clone()),
        }
    }
    if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
        items.next();
    }
    if matches!(
        items.peek().map(|i| &i.kind),
        Some(MatchedKind::Word("references"))
    ) {
        items.next();
    }
    // `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
    consume_fk_reference(&mut items, None, child_columns, false)
}

pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
    entry: Word::keyword("alter"),
    shape: SQL_ALTER_TABLE_SHAPE,
    ast_builder: build_sql_alter_table,
    help_id: Some("ddl.sql_alter_table"),
    hint_ids: &["sql_alter_table"],
    usage_ids: &["parse.usage.sql_alter_table"],
};

// =================================================================
// Tests — `create table` column constraints (ADR-0029 §2.1, §9)
// =================================================================

#[cfg(test)]
mod constraint_tests {
    use super::{Command, Constraint, ConstraintKind};
    use crate::dsl::command::ColumnSpec;
    use crate::dsl::parser::parse_command;
    use crate::dsl::value::Value;

    /// Parse a `create table` and return its column specs.
    fn create_columns(input: &str) -> Vec<ColumnSpec> {
        match parse_command(input).expect("create table should parse") {
            Command::CreateTable { columns, .. } => columns,
            other => panic!("expected CreateTable, got {other:?}"),
        }
    }

    #[test]
    fn create_table_parses_a_text_default() {
        // `grade` is the (single) PK column; `default` is
        // allowed on a PK column (ADR-0029 §9).
        let cols = create_columns("create table T with pk grade(text) default 'A'");
        assert_eq!(cols.len(), 1);
        assert_eq!(cols[0].default, Some(Value::Text("A".to_string())));
        assert!(!cols[0].not_null && !cols[0].unique);
    }

    #[test]
    fn create_table_parses_a_numeric_default_on_a_compound_pk_member() {
        let cols = create_columns("create table T with pk a(int), b(int) default 7");
        assert_eq!(cols.len(), 2);
        assert_eq!(cols[1].default, Some(Value::Number("7".to_string())));
    }

    #[test]
    fn not_null_on_a_pk_column_is_a_redundancy_error() {
        // Every `create table` column is a primary-key column,
        // so `not null` is always redundant there (ADR-0029 §9).
        assert!(parse_command("create table T with pk id(serial) not null").is_err());
    }

    #[test]
    fn unique_on_a_single_column_pk_is_a_redundancy_error() {
        assert!(parse_command("create table T with pk code(text) unique").is_err());
    }

    #[test]
    fn unique_on_a_compound_pk_member_is_allowed() {
        // A compound PK does not make its members individually
        // unique, so an explicit `unique` is meaningful there.
        let cols = create_columns("create table T with pk a(int) unique, b(text)");
        assert_eq!(cols.len(), 2);
        assert!(cols[0].unique, "`a` carries an explicit UNIQUE");
        assert!(!cols[1].unique);
    }

    #[test]
    fn an_unconstrained_create_table_still_parses() {
        let cols = create_columns("create table T with pk id(serial), name(text)");
        assert_eq!(cols.len(), 2);
        assert!(
            cols.iter()
                .all(|c| !c.not_null && !c.unique && c.default.is_none())
        );
    }

    #[test]
    fn add_column_parses_its_constraint_suffix() {
        match parse_command("add column to T: tier (int) not null default 0")
            .expect("add column should parse")
        {
            Command::AddColumn {
                not_null,
                unique,
                default,
                ..
            } => {
                assert!(not_null);
                assert!(!unique);
                assert_eq!(default, Some(Value::Number("0".to_string())));
            }
            other => panic!("expected AddColumn, got {other:?}"),
        }
    }

    #[test]
    fn add_column_parses_a_unique_constraint() {
        match parse_command("add column to T: email (text) unique").expect("parse") {
            Command::AddColumn {
                unique, not_null, ..
            } => {
                assert!(unique);
                assert!(!not_null);
            }
            other => panic!("expected AddColumn, got {other:?}"),
        }
    }

    #[test]
    fn create_table_parses_a_check_constraint() {
        let cols = create_columns("create table T with pk age(int) check (age >= 0)");
        assert_eq!(cols.len(), 1);
        assert!(cols[0].check.is_some(), "the column carries a CHECK");
    }

    #[test]
    fn add_column_parses_a_check_constraint() {
        match parse_command("add column to T: age (int) check (age >= 0 and age < 150)")
            .expect("parse")
        {
            Command::AddColumn { check, .. } => {
                assert!(check.is_some(), "the column carries a CHECK");
            }
            other => panic!("expected AddColumn, got {other:?}"),
        }
    }

    #[test]
    fn check_with_a_parenthesised_sub_expression_parses() {
        // The check's own parens plus a nested group — the
        // builder's paren-depth scan must pair them correctly.
        let cols = create_columns("create table T with pk n(int) check ((n > 0) or (n < -10))");
        assert!(cols[0].check.is_some());
    }

    // --- `add constraint` / `drop constraint` (ADR-0029 §2.2) ---

    #[test]
    fn add_constraint_not_null_parses() {
        match parse_command("add constraint not null to Users.email").expect("parse") {
            Command::AddConstraint {
                table,
                column,
                constraint,
            } => {
                assert_eq!(table, "Users");
                assert_eq!(column, "email");
                assert_eq!(constraint, Constraint::NotNull);
            }
            other => panic!("expected AddConstraint, got {other:?}"),
        }
    }

    #[test]
    fn add_constraint_unique_parses() {
        match parse_command("add constraint unique to Users.email").expect("parse") {
            Command::AddConstraint { constraint, .. } => {
                assert_eq!(constraint, Constraint::Unique);
            }
            other => panic!("expected AddConstraint, got {other:?}"),
        }
    }

    #[test]
    fn add_constraint_default_parses() {
        match parse_command("add constraint default 18 to Users.age").expect("parse") {
            Command::AddConstraint { constraint, .. } => {
                assert_eq!(
                    constraint,
                    Constraint::Default(Value::Number("18".to_string()))
                );
            }
            other => panic!("expected AddConstraint, got {other:?}"),
        }
    }

    #[test]
    fn add_constraint_check_parses() {
        match parse_command("add constraint check (age >= 0) to Users.age").expect("parse") {
            Command::AddConstraint {
                column, constraint, ..
            } => {
                assert_eq!(column, "age");
                assert!(matches!(constraint, Constraint::Check(_)));
            }
            other => panic!("expected AddConstraint, got {other:?}"),
        }
    }

    #[test]
    fn drop_constraint_not_null_parses() {
        match parse_command("drop constraint not null from Users.email").expect("parse") {
            Command::DropConstraint {
                table,
                column,
                kind,
            } => {
                assert_eq!(table, "Users");
                assert_eq!(column, "email");
                assert_eq!(kind, ConstraintKind::NotNull);
            }
            other => panic!("expected DropConstraint, got {other:?}"),
        }
    }

    #[test]
    fn drop_constraint_each_kind_parses() {
        for (input, expected) in [
            ("drop constraint unique from T.c", ConstraintKind::Unique),
            ("drop constraint default from T.c", ConstraintKind::Default),
            ("drop constraint check from T.c", ConstraintKind::Check),
        ] {
            match parse_command(input).expect("parse") {
                Command::DropConstraint { kind, .. } => assert_eq!(kind, expected),
                other => panic!("expected DropConstraint for {input:?}, got {other:?}"),
            }
        }
    }
}

// =================================================================
// Tests — advanced-mode SQL `DROP TABLE [IF EXISTS]` (ADR-0035 §4, 4c)
// =================================================================

#[cfg(test)]
mod sql_drop_table_tests {
    use crate::dsl::command::Command;
    use crate::dsl::parser::parse_command_in_mode;
    use crate::mode::Mode;

    fn drop_fields(input: &str) -> (String, bool) {
        match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
            Command::SqlDropTable { name, if_exists } => (name, if_exists),
            other => panic!("expected SqlDropTable, got {other:?}"),
        }
    }

    #[test]
    fn drop_table_parses_as_sql_drop_table_in_advanced_mode() {
        let (name, if_exists) = drop_fields("drop table Orders");
        assert_eq!(name, "Orders");
        assert!(!if_exists);
    }

    #[test]
    fn if_exists_sets_the_flag() {
        let (name, if_exists) = drop_fields("drop table if exists Orders");
        assert_eq!(name, "Orders");
        assert!(if_exists);
        // trailing semicolon tolerated
        assert!(drop_fields("drop table if exists Orders;").1);
    }

    #[test]
    fn simple_drop_table_in_simple_mode_is_the_dsl_command() {
        // In simple mode the SQL node is gated; `drop table T` is the
        // simple `DropTable` (which has no `if_exists`).
        match parse_command_in_mode("drop table Orders", Mode::Simple).expect("parses") {
            Command::DropTable { name } => assert_eq!(name, "Orders"),
            other => panic!("expected DropTable, got {other:?}"),
        }
    }

    #[test]
    fn other_drops_fall_back_to_the_simple_node_in_advanced_mode() {
        // `drop column` / `drop relationship` are not SQL DROP TABLE —
        // they fall through to the simple `drop` node even in advanced.
        assert!(matches!(
            parse_command_in_mode("drop column from Orders: note", Mode::Advanced).expect("parses"),
            Command::DropColumn { .. }
        ));
        assert!(matches!(
            parse_command_in_mode(
                "drop relationship Customers_id_to_Orders_CustId",
                Mode::Advanced
            )
            .expect("parses"),
            Command::DropRelationship { .. }
        ));
    }
}

#[cfg(test)]
mod sql_drop_index_tests {
    use crate::dsl::command::{Command, IndexSelector};
    use crate::dsl::parser::parse_command_in_mode;
    use crate::mode::Mode;

    fn drop_index_fields(input: &str) -> (String, bool) {
        match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
            Command::SqlDropIndex { name, if_exists } => (name, if_exists),
            other => panic!("expected SqlDropIndex, got {other:?}"),
        }
    }

    #[test]
    fn drop_index_parses_as_sql_drop_index_in_advanced_mode() {
        let (name, if_exists) = drop_index_fields("drop index Orders_CustId_idx");
        assert_eq!(name, "Orders_CustId_idx");
        assert!(!if_exists);
    }

    #[test]
    fn if_exists_sets_the_flag() {
        let (name, if_exists) = drop_index_fields("drop index if exists ix");
        assert_eq!(name, "ix");
        assert!(if_exists);
        // trailing semicolon tolerated
        assert!(drop_index_fields("drop index if exists ix;").1);
    }

    #[test]
    fn drop_table_and_drop_index_each_dispatch_to_the_right_advanced_node() {
        // `drop` now has *two* advanced nodes (SQL_DROP_TABLE +
        // SQL_DROP_INDEX); the dispatcher must try both and pick the
        // shape that matches (ADR-0035 §4d — the second-advanced-node
        // case).
        assert!(matches!(
            parse_command_in_mode("drop table Orders", Mode::Advanced).expect("parses"),
            Command::SqlDropTable { .. }
        ));
        assert!(matches!(
            parse_command_in_mode("drop index ix", Mode::Advanced).expect("parses"),
            Command::SqlDropIndex { .. }
        ));
    }

    #[test]
    fn positional_drop_index_falls_back_to_the_simple_node_in_advanced_mode() {
        // The SQL form is name-only; `drop index on T (cols)` is the
        // simple positional form. The name-only SQL shape can't fully
        // match it (trailing `(cols)`), so it falls back to the simple
        // `drop` node's `DropIndex { Columns }` even in advanced mode.
        match parse_command_in_mode("drop index on Orders (CustId)", Mode::Advanced)
            .expect("parses")
        {
            Command::DropIndex {
                selector: IndexSelector::Columns { table, columns },
            } => {
                assert_eq!(table, "Orders");
                assert_eq!(columns, vec!["CustId".to_string()]);
            }
            other => panic!("expected positional DropIndex, got {other:?}"),
        }
    }

    #[test]
    fn named_drop_index_in_simple_mode_is_the_dsl_command() {
        // In simple mode the SQL node is gated; `drop index ix` is the
        // simple `DropIndex { Named }`.
        match parse_command_in_mode("drop index ix", Mode::Simple).expect("parses") {
            Command::DropIndex {
                selector: IndexSelector::Named { name },
            } => assert_eq!(name, "ix"),
            other => panic!("expected named DropIndex, got {other:?}"),
        }
    }
}

#[cfg(test)]
mod sql_create_index_tests {
    use crate::dsl::command::Command;
    use crate::dsl::parser::parse_command_in_mode;
    use crate::mode::Mode;

    struct Ci {
        name: Option<String>,
        table: String,
        columns: Vec<String>,
        unique: bool,
        if_not_exists: bool,
    }

    fn ci(input: &str) -> Ci {
        match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
            Command::SqlCreateIndex {
                name,
                table,
                columns,
                unique,
                if_not_exists,
            } => Ci {
                name,
                table,
                columns,
                unique,
                if_not_exists,
            },
            other => panic!("expected SqlCreateIndex, got {other:?}"),
        }
    }

    #[test]
    fn named_create_index_parses() {
        let c = ci("create index ix on Customers (email)");
        assert_eq!(c.name.as_deref(), Some("ix"));
        assert_eq!(c.table, "Customers");
        assert_eq!(c.columns, vec!["email".to_string()]);
        assert!(!c.unique);
        assert!(!c.if_not_exists);
    }

    #[test]
    fn unnamed_create_index_leaves_name_none() {
        // The unnamed form: the optional name must NOT swallow `on`
        // (the `DI_SELECTOR`-style on-led-first selector handles it).
        let c = ci("create index on Customers (email)");
        assert_eq!(c.name, None);
        assert_eq!(c.table, "Customers");
        assert_eq!(c.columns, vec!["email".to_string()]);
    }

    #[test]
    fn unique_sets_the_flag() {
        let c = ci("create unique index ux on Customers (email)");
        assert!(c.unique);
        assert_eq!(c.name.as_deref(), Some("ux"));
        // unnamed unique form too
        let c2 = ci("create unique index on Customers (email)");
        assert!(c2.unique);
        assert_eq!(c2.name, None);
    }

    #[test]
    fn if_not_exists_sets_the_flag() {
        let c = ci("create index if not exists ix on Customers (email)");
        assert!(c.if_not_exists);
        assert_eq!(c.name.as_deref(), Some("ix"));
        // combined with unique + unnamed + trailing semicolon
        let c2 = ci("create unique index if not exists on Customers (email);");
        assert!(c2.unique && c2.if_not_exists);
        assert_eq!(c2.name, None);
    }

    #[test]
    fn multi_column_index_parses() {
        let c = ci("create index on Orders (CustId, Date)");
        assert_eq!(c.columns, vec!["CustId".to_string(), "Date".to_string()]);
    }

    #[test]
    fn create_table_and_create_index_each_dispatch_to_the_right_advanced_node() {
        // `create` now has *two* advanced nodes (SQL_CREATE_TABLE +
        // SQL_CREATE_INDEX); the dispatcher must try both (ADR-0035 §4d).
        assert!(matches!(
            parse_command_in_mode("create table T (id int primary key)", Mode::Advanced)
                .expect("parses"),
            Command::SqlCreateTable { .. }
        ));
        assert!(matches!(
            parse_command_in_mode("create index ix on T (id)", Mode::Advanced).expect("parses"),
            Command::SqlCreateIndex { .. }
        ));
        assert!(matches!(
            parse_command_in_mode("create unique index ux on T (id)", Mode::Advanced)
                .expect("parses"),
            Command::SqlCreateIndex { unique: true, .. }
        ));
    }

    #[test]
    fn simple_create_table_dsl_still_parses_in_advanced_mode() {
        // The `create table … with pk …` DSL form falls back to the
        // simple node even with two advanced `create` nodes present.
        assert!(matches!(
            parse_command_in_mode("create table T with pk id(serial)", Mode::Advanced)
                .expect("parses"),
            Command::CreateTable { .. }
        ));
    }
}

#[cfg(test)]
mod sql_alter_table_tests {
    use crate::dsl::command::{AlterTableAction, ColumnSpec, Command, TableConstraint};
    use crate::dsl::parser::parse_command_in_mode;
    use crate::mode::Mode;

    fn alter(input: &str) -> (String, AlterTableAction) {
        match parse_command_in_mode(input, Mode::Advanced).expect("should parse") {
            Command::SqlAlterTable { table, action } => (table, action),
            other => panic!("expected SqlAlterTable, got {other:?}"),
        }
    }

    fn added_spec(input: &str) -> ColumnSpec {
        match alter(input).1 {
            AlterTableAction::AddColumn(spec) => *spec,
            other => panic!("expected AddColumn, got {other:?}"),
        }
    }

    #[test]
    fn add_column_plain() {
        let (table, action) = alter("alter table T add column note text");
        assert_eq!(table, "T");
        match action {
            AlterTableAction::AddColumn(spec) => {
                assert_eq!(spec.name, "note");
                assert_eq!(spec.ty, crate::dsl::types::Type::Text);
                assert!(!spec.not_null && !spec.unique);
                assert!(spec.default_sql.is_none() && spec.check_sql.is_none());
            }
            other => panic!("expected AddColumn, got {other:?}"),
        }
    }

    #[test]
    fn add_column_with_not_null_and_unique() {
        let spec = added_spec("alter table T add column code text not null unique");
        assert!(spec.not_null && spec.unique);
    }

    #[test]
    fn add_column_with_default_and_check_capture_raw_text() {
        // DEFAULT / CHECK are captured as raw SQL text (sql_expr is
        // validate-only) — ADR-0035 §4e.
        let spec = added_spec("alter table T add column qty int default 0 check (qty >= 0)");
        assert_eq!(spec.default_sql.as_deref(), Some("0"));
        assert_eq!(spec.check_sql.as_deref(), Some("qty >= 0"));
    }

    #[test]
    fn add_column_accepts_sql_type_alias() {
        // `varchar(255)` → text, length discarded (ADR-0035 §3).
        let spec = added_spec("alter table T add column name varchar(255)");
        assert_eq!(spec.ty, crate::dsl::types::Type::Text);
    }

    #[test]
    fn drop_column() {
        match alter("alter table T drop column note").1 {
            AlterTableAction::DropColumn { column } => assert_eq!(column, "note"),
            other => panic!("expected DropColumn, got {other:?}"),
        }
    }

    #[test]
    fn rename_column() {
        match alter("alter table T rename column a to b").1 {
            AlterTableAction::RenameColumn { old, new } => {
                assert_eq!(old, "a");
                assert_eq!(new, "b");
            }
            other => panic!("expected RenameColumn, got {other:?}"),
        }
        // trailing semicolon tolerated
        assert!(matches!(
            alter("alter table T rename column a to b;").1,
            AlterTableAction::RenameColumn { .. }
        ));
    }

    #[test]
    fn rename_table() {
        // ADR-0035 §6 / 4h: `rename to <new>` — the `rename` verb fans out
        // on a distinct second keyword (`to` vs `column`).
        let (table, action) = alter("alter table Orders rename to Purchases");
        assert_eq!(table, "Orders");
        match action {
            AlterTableAction::RenameTable { new } => assert_eq!(new, "Purchases"),
            other => panic!("expected RenameTable, got {other:?}"),
        }
        // trailing semicolon tolerated
        assert!(matches!(
            alter("alter table Orders rename to Purchases;").1,
            AlterTableAction::RenameTable { .. }
        ));
    }

    #[test]
    fn rename_table_does_not_steal_rename_column() {
        // The two `rename` tails coexist: `rename to` → table,
        // `rename column … to …` → column. Neither misroutes.
        assert!(matches!(
            alter("alter table T rename to U").1,
            AlterTableAction::RenameTable { .. }
        ));
        assert!(matches!(
            alter("alter table T rename column a to b").1,
            AlterTableAction::RenameColumn { .. }
        ));
    }

    #[test]
    fn rename_to_internal_target_refused_at_parse() {
        // The target slot carries the `reject_internal_table` validator
        // (mirroring CREATE TABLE), so an `__rdbms_*` target is refused
        // before submit — engine-neutral, not a raw engine error.
        assert!(
            parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err()
        );
    }

    #[test]
    fn alter_column_type_parses() {
        // ADR-0035 §4f: the fourth action, discriminated by the `type`
        // keyword (ADD COLUMN's type is an ident, not the literal word).
        let (table, action) = alter("alter table T alter column qty type int");
        assert_eq!(table, "T");
        match action {
            AlterTableAction::AlterColumnType { column, ty } => {
                assert_eq!(column, "qty");
                assert_eq!(ty, crate::dsl::types::Type::Int);
            }
            other => panic!("expected AlterColumnType, got {other:?}"),
        }
        // trailing semicolon tolerated
        assert!(matches!(
            alter("alter table T alter column qty type int;").1,
            AlterTableAction::AlterColumnType { .. }
        ));
    }

    #[test]
    fn alter_column_type_accepts_sql_type_alias() {
        // `integer` → int, `double precision` → real (ADR-0035 §3),
        // reusing SQL_TYPE for the type slot.
        match alter("alter table T alter column n type integer").1 {
            AlterTableAction::AlterColumnType { ty, .. } => {
                assert_eq!(ty, crate::dsl::types::Type::Int);
            }
            other => panic!("expected AlterColumnType, got {other:?}"),
        }
        match alter("alter table T alter column n type double precision").1 {
            AlterTableAction::AlterColumnType { ty, .. } => {
                assert_eq!(ty, crate::dsl::types::Type::Real);
            }
            other => panic!("expected AlterColumnType, got {other:?}"),
        }
    }

    #[test]
    fn four_branch_dispatch_still_routes_the_column_actions() {
        // The new `alter column type` branch does not steal add/drop/
        // rename: each still routes to its own action.
        assert!(matches!(
            alter("alter table T add column note text").1,
            AlterTableAction::AddColumn(_)
        ));
        assert!(matches!(
            alter("alter table T drop column note").1,
            AlterTableAction::DropColumn { .. }
        ));
        assert!(matches!(
            alter("alter table T rename column a to b").1,
            AlterTableAction::RenameColumn { .. }
        ));
        assert!(matches!(
            alter("alter table T alter column a type text").1,
            AlterTableAction::AlterColumnType { .. }
        ));
    }

    // --- ADR-0035 Amendment 2: ALTER COLUMN constraint gap-fill ---

    #[test]
    fn alter_column_set_data_type_is_a_type_synonym() {
        // ISO `SET DATA TYPE` is the canonical form; it yields the same
        // AlterColumnType action as the PostgreSQL `TYPE` shorthand.
        match alter("alter table T alter column qty set data type int").1 {
            AlterTableAction::AlterColumnType { column, ty } => {
                assert_eq!(column, "qty");
                assert_eq!(ty, crate::dsl::types::Type::Int);
            }
            other => panic!("expected AlterColumnType, got {other:?}"),
        }
        // alias map still applies through the synonym
        assert!(matches!(
            alter("alter table T alter column n set data type double precision").1,
            AlterTableAction::AlterColumnType {
                ty: crate::dsl::types::Type::Real,
                ..
            }
        ));
    }

    #[test]
    fn alter_column_set_not_null_parses() {
        let (table, action) = alter("alter table T alter column email set not null");
        assert_eq!(table, "T");
        match action {
            AlterTableAction::SetColumnNotNull { column } => assert_eq!(column, "email"),
            other => panic!("expected SetColumnNotNull, got {other:?}"),
        }
    }

    #[test]
    fn alter_column_drop_not_null_parses() {
        match alter("alter table T alter column email drop not null").1 {
            AlterTableAction::DropColumnNotNull { column } => assert_eq!(column, "email"),
            other => panic!("expected DropColumnNotNull, got {other:?}"),
        }
    }

    #[test]
    fn alter_column_set_default_captures_raw_expr() {
        match alter("alter table T alter column qty set default 0").1 {
            AlterTableAction::SetColumnDefault {
                column,
                default_sql,
            } => {
                assert_eq!(column, "qty");
                assert_eq!(default_sql, "0");
            }
            other => panic!("expected SetColumnDefault, got {other:?}"),
        }
        // a parenthesised expression default round-trips as raw text
        match alter("alter table T alter column qty set default (1 + 1)").1 {
            AlterTableAction::SetColumnDefault { default_sql, .. } => {
                assert_eq!(default_sql, "(1 + 1)");
            }
            other => panic!("expected SetColumnDefault, got {other:?}"),
        }
    }

    #[test]
    fn alter_column_drop_default_parses() {
        match alter("alter table T alter column qty drop default").1 {
            AlterTableAction::DropColumnDefault { column } => assert_eq!(column, "qty"),
            other => panic!("expected DropColumnDefault, got {other:?}"),
        }
    }

    #[test]
    fn alter_column_gap_fill_does_not_steal_the_existing_actions() {
        // The new set/drop column-attribute forms must not misroute the
        // top-level add/drop/rename-column or the bare `type` form.
        assert!(matches!(
            alter("alter table T add column note text not null").1,
            AlterTableAction::AddColumn(_)
        ));
        assert!(matches!(
            alter("alter table T drop column note").1,
            AlterTableAction::DropColumn { .. }
        ));
        assert!(matches!(
            alter("alter table T alter column a type text").1,
            AlterTableAction::AlterColumnType { .. }
        ));
        assert!(matches!(
            alter("alter table T drop constraint c_chk").1,
            AlterTableAction::DropConstraint { .. }
        ));
    }

    #[test]
    fn type_discriminator_probe_column_named_type() {
        // PROBE (DA): the `type`-keyword discriminator keys on the literal
        // `type` *keyword* node, which only the ALTER COLUMN TYPE branch
        // carries. Verify a column whose *name* is `type` does not get
        // misrouted (it is an ident, not a Word). If `type` is reserved
        // and rejected as an ident, the parse errors — either outcome is
        // fine; the failure we guard against is silent misrouting to
        // AlterColumnType (which would then error on a missing type).
        let dropped = parse_command_in_mode("alter table T drop column type", Mode::Advanced);
        match dropped {
            Ok(Command::SqlAlterTable {
                action: AlterTableAction::DropColumn { column },
                ..
            }) => assert_eq!(column, "type", "a column named `type` drops correctly"),
            Ok(other) => panic!("`drop column type` misrouted to {other:?}"),
            Err(_) => { /* `type` rejected as an ident — acceptable, no misroute */ }
        }
        // And the real ALTER COLUMN TYPE still routes (sanity).
        assert!(matches!(
            parse_command_in_mode("alter table T alter column c type int", Mode::Advanced),
            Ok(Command::SqlAlterTable {
                action: AlterTableAction::AlterColumnType { .. },
                ..
            })
        ));
    }

    #[test]
    fn add_table_check_unnamed_and_named() {
        // ADR-0035 §4g: table-level CHECK, unnamed and named.
        match alter("alter table T add check (a < b)").1 {
            AlterTableAction::AddTableConstraint { name, constraint } => {
                assert_eq!(name, None);
                assert!(
                    matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b")
                );
            }
            other => panic!("expected AddTableConstraint/Check, got {other:?}"),
        }
        match alter("alter table T add constraint a_lt_b check (a < b)").1 {
            AlterTableAction::AddTableConstraint { name, constraint } => {
                assert_eq!(name.as_deref(), Some("a_lt_b"));
                assert!(matches!(*constraint, TableConstraint::Check { .. }));
            }
            other => panic!("expected named AddTableConstraint/Check, got {other:?}"),
        }
    }

    #[test]
    fn add_composite_unique() {
        match alter("alter table T add unique (a, b)").1 {
            AlterTableAction::AddTableConstraint { name, constraint } => {
                assert_eq!(name, None);
                assert!(
                    matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()])
                );
            }
            other => panic!("expected AddTableConstraint/Unique, got {other:?}"),
        }
    }

    #[test]
    fn named_unique_is_refused() {
        // §4g: composite UNIQUE is anonymous in our model — naming it is
        // refused by the BUILDER (it parses, then the builder rejects),
        // so the error is the friendly message, not a parse error.
        let err = parse_command_in_mode(
            "alter table T add constraint u unique (a, b)",
            Mode::Advanced,
        )
        .expect_err("a named UNIQUE constraint is refused");
        assert!(
            err.to_string()
                .to_lowercase()
                .contains("unique constraint cannot be named"),
            "expected the builder's named-UNIQUE refusal, got: {err}"
        );
    }

    #[test]
    fn add_primary_key_is_refused() {
        // §4g: adding a PK to an existing table is refused by the BUILDER
        // (it parses for a clean message, then the builder rejects it).
        let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced)
            .expect_err("ADD PRIMARY KEY is refused");
        assert!(
            err.to_string()
                .to_lowercase()
                .contains("primary key is fixed at creation"),
            "expected the builder's ADD-PRIMARY-KEY refusal, got: {err}"
        );
    }

    #[test]
    fn add_foreign_key_named_and_bare() {
        // `add foreign key (col) references P(id)` and the bare
        // `references P` form; named via the CONSTRAINT prefix.
        match alter("alter table C add foreign key (pid) references P(id)").1 {
            AlterTableAction::AddTableConstraint { name, constraint } => {
                assert_eq!(name, None);
                match *constraint {
                    TableConstraint::ForeignKey(fk) => {
                        assert_eq!(fk.child_columns, vec!["pid".to_string()]);
                        assert_eq!(fk.parent_table, "P");
                        assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
                    }
                    other => panic!("expected ForeignKey, got {other:?}"),
                }
            }
            other => panic!("expected AddTableConstraint/FK, got {other:?}"),
        }
        match alter("alter table C add constraint fk_p foreign key (pid) references P").1 {
            AlterTableAction::AddTableConstraint { name, constraint } => {
                assert_eq!(name.as_deref(), Some("fk_p"));
                match *constraint {
                    TableConstraint::ForeignKey(fk) => {
                        assert_eq!(
                            fk.parent_columns, None,
                            "bare reference resolves at execution"
                        );
                    }
                    other => panic!("expected ForeignKey, got {other:?}"),
                }
            }
            other => panic!("expected named AddTableConstraint/FK, got {other:?}"),
        }
    }

    #[test]
    fn drop_constraint_by_name() {
        match alter("alter table T drop constraint a_lt_b").1 {
            AlterTableAction::DropConstraint { name } => assert_eq!(name, "a_lt_b"),
            other => panic!("expected DropConstraint, got {other:?}"),
        }
    }

    #[test]
    fn six_branch_dispatch_still_routes_column_actions() {
        // The two new add/drop-constraint branches do not steal the four
        // column actions.
        assert!(matches!(
            alter("alter table T add column note text").1,
            AlterTableAction::AddColumn(_)
        ));
        assert!(matches!(
            alter("alter table T add column code text unique").1,
            AlterTableAction::AddColumn(_),
        ));
        assert!(matches!(
            alter("alter table T drop column note").1,
            AlterTableAction::DropColumn { .. }
        ));
        assert!(matches!(
            alter("alter table T rename column a to b").1,
            AlterTableAction::RenameColumn { .. }
        ));
        assert!(matches!(
            alter("alter table T alter column a type text").1,
            AlterTableAction::AlterColumnType { .. }
        ));
    }

    #[test]
    fn alter_is_advanced_only() {
        // No simple `alter`; in simple mode it does not parse as a
        // command (the dispatcher emits the "this is SQL" hint).
        assert!(parse_command_in_mode("alter table T drop column c", Mode::Simple).is_err());
    }

    #[test]
    fn internal_table_is_rejected_at_parse() {
        // The ALTER table slot carries `reject_internal_table`.
        assert!(
            parse_command_in_mode(
                "alter table __rdbms_playground_columns drop column table_name",
                Mode::Advanced
            )
            .is_err()
        );
    }
}
