//! PEG-grammar-driven parser that converts raw GQL text into the
//! [`ast::Statement`] tree consumed by the executor.
//!
//! # Grammar
//!
//! The grammar lives in `src/gql.pest` and is compiled into this crate at
//! build time by [`pest_derive`].  Every public rule in that file has a
//! corresponding `Rule` enum variant generated by the macro on [`GqlParser`].
//!
//! # Build pattern
//!
//! Every `build_*` function in this module follows the same convention:
//!
//! 1. Accept a [`pest::iterators::Pair<Rule>`] whose rule matches what the
//!    function expects.
//! 2. Call `.into_inner()` (consuming the pair) to iterate over child tokens.
//! 3. Construct and return the corresponding AST node.
//!
//! Functions that only ever see one grammar rule use a simple sequential
//! `inner.next()` pattern.  Functions whose children can appear in different
//! orders (or where keyword tokens interleave with structural ones) use a
//! `for p in inner { match p.as_rule() { … } }` dispatch loop.
//!
//! # Keyword tokens in the parse tree
//!
//! Some keywords are declared `@{}` (atomic, non-silent) in the grammar so
//! that pest can apply a word-boundary look-ahead guard immediately after the
//! keyword text without consuming surrounding whitespace.  This means those
//! keywords *appear as tokens* inside `pair.into_inner()` even though they
//! carry no structural value for the builder.  Each affected `build_*`
//! function explicitly skips these tokens before reading the real children.
//! See the inline comments in [`build_match`], [`build_unwind`], and
//! [`build_compare_expr`] for concrete examples.
//!
//! # Main entry point
//!
//! [`parse`] is the only public function.  It drives the top-level
//! `statement` rule (which handles `UNION` chaining) and delegates
//! individual statement variants to [`build_statement`].

use pest::iterators::Pair;
use pest::Parser;

use crate::types::{DbError, Value};

use super::ast::*;

// ── Parser struct (generated by pest_derive) ──────────────────────────────

/// Zero-sized marker struct that implements [`pest::Parser`] via the
/// `pest_derive` macro.  The `#[grammar]` attribute resolves the path
/// relative to `src/` at compile time.
#[derive(pest_derive::Parser)]
#[grammar = "gql.pest"]
pub(crate) struct GqlParser;

// ── Public entry point ────────────────────────────────────────────────────

/// Parse `input` as a GQL statement and return the corresponding [`Statement`] AST.
///
/// Handles the top-level grammar rule:
/// ```text
/// statement = { SOI ~ single_stmt ~ (kw_union ~ kw_all? ~ single_stmt)* ~ ";"? ~ EOI }
/// ```
///
/// A bare single statement is returned directly.  When one or more `UNION`
/// connectives are present the result is wrapped in a [`Statement::Union`].
///
/// # Errors
///
/// Returns [`DbError::Parse`] for any syntax error, using pest's own
/// diagnostic message (which includes line/column info).
pub(crate) fn parse(input: &str) -> Result<Statement, DbError> {
    let mut pairs = GqlParser::parse(Rule::statement, input)
        .map_err(|e| DbError::Parse(e.to_string()))?;

    // `statement` = SOI ~ single_stmt ~ (kw_union ~ kw_all? ~ single_stmt)* ~ ";"? ~ EOI
    let statement = pairs.next().unwrap();
    let mut inner = statement.into_inner().peekable();

    // First single_stmt.
    let first_pair = inner.next().ok_or_else(|| DbError::Parse("empty statement".into()))?;
    let first = build_single_stmt(first_pair)?;

    // Check for UNION tails.
    // Note: pest may emit EOI/SOI tokens in the inner pair list, so we can't
    // rely on inner.peek().is_none() — we must check for kw_union explicitly.
    let has_union = inner.peek().map(|p| p.as_rule() == Rule::kw_union).unwrap_or(false);
    if !has_union {
        return Ok(first);
    }

    let mut branches = vec![first];
    let mut all = false;

    loop {
        // Stop when there are no more kw_union tokens.
        match inner.peek() {
            Some(p) if p.as_rule() == Rule::kw_union => {}
            _ => break,
        }
        inner.next(); // consume kw_union

        // kw_all is optional; if present it applies to the whole UNION chain.
        if inner.peek().map(|p| p.as_rule() == Rule::kw_all).unwrap_or(false) {
            all = true;
            inner.next(); // consume kw_all
        }

        let next = inner
            .next()
            .ok_or_else(|| DbError::Parse("UNION requires a right-hand statement".into()))?;
        // Skip non-single_stmt tokens (EOI, semicolons, etc.)
        if next.as_rule() == Rule::single_stmt {
            branches.push(build_single_stmt(next)?);
        }
    }

    Ok(Statement::Union(UnionStatement { branches, all }))
}

// ── Top-level dispatch ────────────────────────────────────────────────────

/// Unwrap the `single_stmt` wrapper rule and delegate to [`build_statement`].
///
/// Grammar: `single_stmt = { match_with_stmt | match_optional_match_stmt | … }`
///
/// `single_stmt` is a purely structural rule that holds exactly one inner
/// statement pair; this function just peels it off.
fn build_single_stmt(pair: Pair<Rule>) -> Result<Statement, DbError> {
    // single_stmt wraps exactly one actual statement rule.
    let inner = pair
        .into_inner()
        .next()
        .ok_or_else(|| DbError::Parse("empty single_stmt".into()))?;
    build_statement(inner)
}

/// Dispatch on the concrete statement rule to the appropriate `build_*` helper.
///
/// Each arm corresponds to one alternative in the `single_stmt` grammar rule.
/// The mapping is 1-to-1: rule variant → AST variant → builder function.
fn build_statement(pair: Pair<Rule>) -> Result<Statement, DbError> {
    match pair.as_rule() {
        Rule::match_stmt          => Ok(Statement::Match(build_match(pair)?)),
        Rule::optional_match_stmt => Ok(Statement::OptionalMatch(build_match(pair)?)),
        Rule::match_with_stmt     => Ok(Statement::MatchWith(build_match_with(pair)?)),
        Rule::unwind_stmt         => Ok(Statement::Unwind(build_unwind(pair)?)),
        Rule::match_insert_stmt   => Ok(Statement::MatchInsert(build_match_insert(pair)?)),
        Rule::insert_stmt         => Ok(Statement::Insert(build_insert(pair)?)),
        Rule::set_stmt            => Ok(Statement::Set(build_set(pair)?)),
        Rule::remove_stmt         => Ok(Statement::Remove(build_remove(pair)?)),
        Rule::delete_stmt         => Ok(Statement::Delete(build_delete(pair)?)),
        Rule::create_index_stmt   => Ok(Statement::CreateIndex(build_create_index(pair)?)),
        Rule::drop_index_stmt     => Ok(Statement::DropIndex(build_drop_index(pair)?)),
        Rule::show_indexes_stmt   => Ok(Statement::ShowIndexes),
        Rule::call_stmt                  => Ok(Statement::Call(build_call(pair)?)),
        Rule::call_pipeline_stmt         => Ok(Statement::CallPipeline(build_call_pipeline(pair)?)),
        Rule::match_optional_match_stmt  => Ok(Statement::MatchOptionalMatch(build_match_optional_match(pair)?)),
        Rule::truncate_stmt               => Ok(Statement::Truncate),
        Rule::load_csv_nodes_stmt        => Ok(Statement::LoadCsvNodes(build_load_csv_nodes(pair)?)),
        Rule::load_csv_edges_stmt        => Ok(Statement::LoadCsvEdges(build_load_csv_edges(pair)?)),
        Rule::unwind_insert_stmt         => Ok(Statement::UnwindInsert(build_unwind_insert(pair)?)),
        Rule::constraint_stmt            => Ok(Statement::Constraint(build_constraint(pair)?)),
        r => Err(DbError::Parse(format!("unexpected rule: {r:?}"))),
    }
}

// ── UNWIND ────────────────────────────────────────────────────────────────

/// Build an [`UnwindStatement`] from an `unwind_stmt` parse node.
///
/// Grammar: `unwind_stmt = { kw_unwind ~ expr ~ kw_as ~ ident ~ return_clause }`
///
/// `kw_unwind` and `kw_as` are both `@{}` (atomic, non-silent), so they appear
/// as tokens in the child list.  This function skips them before reading the
/// expression, variable name, and return clause.
fn build_unwind(pair: Pair<Rule>) -> Result<UnwindStatement, DbError> {
    let mut inner = pair.into_inner().peekable();

    // Skip kw_unwind — it's @{} so it appears in the parse tree.
    if inner.peek().map(|p| p.as_rule() == Rule::kw_unwind).unwrap_or(false) {
        inner.next();
    }

    let expr_pair = inner.next().ok_or_else(|| DbError::Parse("UNWIND: missing expr".into()))?;
    let expr = build_expr(expr_pair)?;

    // kw_as is silent; next visible token may be kw_as_atom or the variable ident.
    let var_pair = inner.next().ok_or_else(|| DbError::Parse("UNWIND: missing variable".into()))?;
    // Skip kw_as_atom if it appears (it's an @{} rule, so it shows up).
    let (var_pair, rest_pair) = if var_pair.as_rule() == Rule::kw_as_atom {
        let next = inner.next().ok_or_else(|| DbError::Parse("UNWIND: missing variable after AS".into()))?;
        (next, inner.next())
    } else {
        (var_pair, inner.next())
    };
    let variable = var_pair.as_str().to_string();

    let return_pair = rest_pair.ok_or_else(|| DbError::Parse("UNWIND: missing RETURN".into()))?;
    let return_clause = build_return_clause(return_pair)?;

    Ok(UnwindStatement { expr, variable, return_clause })
}

// ── MATCH…WITH ────────────────────────────────────────────────────────────

/// Build a [`MatchWithStatement`] from a `match_with_stmt` parse node.
///
/// Grammar:
/// ```text
/// match_with_stmt = { kw_match ~ path_mode? ~ graph_patterns ~ where_clause? ~ with_clause ~ return_clause }
/// ```
///
/// Both `with_clause` and `return_clause` are mandatory — their absence
/// produces a parse error rather than defaulting to `None`.
fn build_match_with(pair: Pair<Rule>) -> Result<MatchWithStatement, DbError> {
    let mut inner = pair.into_inner();

    // Optional path_mode comes before graph_patterns; detect by rule.
    let first = inner.next().unwrap();
    let (path_mode, patterns_pair) = if first.as_rule() == Rule::path_mode {
        (build_path_mode(&first), inner.next().unwrap())
    } else {
        (PathMode::Walk, first)
    };

    // graph_patterns = graph_pattern ~ ("," ~ graph_pattern)*
    let patterns = patterns_pair
        .into_inner()
        .filter(|p| p.as_rule() == Rule::graph_pattern)
        .map(build_graph_pattern)
        .collect::<Result<Vec<_>, _>>()?;

    let mut where_clause = None;
    let mut with_clause = None;
    let mut return_clause = None;

    for p in inner {
        match p.as_rule() {
            Rule::where_clause  => where_clause  = Some(build_where(p)?),
            Rule::with_clause   => with_clause   = Some(build_with_clause(p)?),
            Rule::return_clause => return_clause = Some(build_return_clause(p)?),
            _ => {}
        }
    }

    Ok(MatchWithStatement {
        patterns,
        where_clause,
        path_mode,
        with_clause: with_clause.ok_or_else(|| DbError::Parse("MATCH…WITH: WITH clause missing".into()))?,
        return_clause: return_clause.ok_or_else(|| DbError::Parse("MATCH…WITH: RETURN missing".into()))?,
    })
}

/// Build a [`WithClause`] from a `with_clause` parse node.
///
/// Grammar: `with_clause = { kw_with ~ distinct_flag? ~ return_items ~ where_clause? }`
///
/// The `kw_with` keyword is silent (`_{}`) so it does not appear in the child
/// list; only `distinct_flag`, `return_items`, and optionally `where_clause`
/// need to be handled.
fn build_with_clause(pair: Pair<Rule>) -> Result<WithClause, DbError> {
    let mut distinct = false;
    let mut items = Vec::new();
    let mut where_clause = None;

    for p in pair.into_inner() {
        match p.as_rule() {
            Rule::distinct_flag  => distinct = true,
            Rule::return_items   => {
                // Descend into return_items to collect individual return_item pairs.
                items = p
                    .into_inner()
                    .filter(|p| p.as_rule() == Rule::return_item)
                    .map(build_return_item)
                    .collect::<Result<Vec<_>, _>>()?;
            }
            Rule::where_clause => where_clause = Some(build_where(p)?),
            _ => {}
        }
    }

    Ok(WithClause { distinct, items, where_clause })
}

// ── MATCH … OPTIONAL MATCH … RETURN ──────────────────────────────────────

/// Build a [`MatchOptionalMatchStatement`] from a `match_optional_match_stmt` parse node.
///
/// Grammar:
/// ```text
/// match_optional_match_stmt = {
///     kw_match ~ path_mode? ~ graph_patterns ~ where_clause?
///     ~ optional_match_clause+ ~ return_clause
/// }
/// optional_match_clause = { kw_optional ~ kw_match ~ path_mode? ~ graph_patterns ~ where_clause? }
/// ```
///
/// Each `optional_match_clause` child begins with `kw_optional` and `kw_match`
/// tokens (both `@{}`, non-silent); these are skipped before reading the
/// structural children.
fn build_match_optional_match(pair: Pair<Rule>) -> Result<super::ast::MatchOptionalMatchStatement, DbError> {
    use super::ast::{MatchOptionalMatchStatement, OptionalMatchClause};

    let mut inner = pair.into_inner();

    // Optional path_mode for the primary MATCH.
    let first = inner.next().unwrap();
    let (path_mode, patterns_pair) = if first.as_rule() == Rule::path_mode {
        (build_path_mode(&first), inner.next().unwrap())
    } else {
        (PathMode::Walk, first)
    };

    let patterns = patterns_pair
        .into_inner()
        .filter(|p| p.as_rule() == Rule::graph_pattern)
        .map(build_graph_pattern)
        .collect::<Result<Vec<_>, _>>()?;

    let mut where_clause = None;
    let mut optional_clauses: Vec<OptionalMatchClause> = Vec::new();
    let mut return_clause = None;

    for p in inner {
        match p.as_rule() {
            Rule::where_clause => where_clause = Some(build_where(p)?),
            Rule::optional_match_clause => {
                // Children: kw_optional, kw_match (both @{}), path_mode?, graph_patterns, where_clause?
                let mut ci = p.into_inner().peekable();
                // Skip kw_optional and kw_match tokens; they carry no structural information
                // here — the rule variant already tells us this is an OPTIONAL MATCH clause.
                while ci.peek().map(|t| matches!(t.as_rule(), Rule::kw_optional | Rule::kw_match)).unwrap_or(false) {
                    ci.next();
                }
                let cf = ci.next().unwrap();
                let (opt_mode, opt_patterns_pair) = if cf.as_rule() == Rule::path_mode {
                    (build_path_mode(&cf), ci.next().unwrap())
                } else {
                    (PathMode::Walk, cf)
                };
                let opt_patterns = opt_patterns_pair
                    .into_inner()
                    .filter(|t| t.as_rule() == Rule::graph_pattern)
                    .map(build_graph_pattern)
                    .collect::<Result<Vec<_>, _>>()?;
                let opt_where = ci
                    .find(|t| t.as_rule() == Rule::where_clause)
                    .map(build_where)
                    .transpose()?;
                optional_clauses.push(OptionalMatchClause {
                    patterns: opt_patterns,
                    where_clause: opt_where,
                    path_mode: opt_mode,
                });
            }
            Rule::return_clause => return_clause = Some(build_return_clause(p)?),
            _ => {}
        }
    }

    Ok(MatchOptionalMatchStatement {
        patterns,
        where_clause,
        path_mode,
        optional_clauses,
        return_clause: return_clause.ok_or_else(|| DbError::Parse("MATCH…OPTIONAL MATCH: RETURN missing".into()))?,
    })
}

// ── CALL ──────────────────────────────────────────────────────────────────

/// Build a [`CallStatement`] from a `call_stmt` parse node.
///
/// Grammar: `call_stmt = { kw_call ~ ident ~ "(" ~ call_args? ~ ")" ~ yield_clause? }`
///
/// `kw_call` is `@{}` (atomic, non-silent) so it appears as the first token.
/// The builder skips it with `find(… Rule::ident …)` to locate the algorithm
/// name, then handles `call_args` and `yield_clause` in a dispatch loop.
fn build_call(pair: Pair<Rule>) -> Result<super::ast::CallStatement, DbError> {
    let mut inner = pair.into_inner();

    // kw_call is @{} (atomic but not silent) so it appears as the first token.
    // Skip it and read the algorithm name from the following ident.
    let name = inner
        .find(|p| p.as_rule() == Rule::ident)
        .ok_or_else(|| DbError::Parse("CALL: missing algorithm name".into()))?
        .as_str()
        .to_string();

    let mut params = Vec::new();
    let mut yields = None;

    for child in inner {
        match child.as_rule() {
            Rule::call_args => {
                // call_args = { call_arg ~ ("," ~ call_arg)* }
                // call_arg  = { ident ~ ":" ~ expr }
                for arg in child.into_inner().filter(|p| p.as_rule() == Rule::call_arg) {
                    let mut parts = arg.into_inner();
                    let key = parts
                        .next()
                        .ok_or_else(|| DbError::Parse("CALL arg: missing key".into()))?
                        .as_str()
                        .to_string();
                    let val_pair = parts
                        .next()
                        .ok_or_else(|| DbError::Parse("CALL arg: missing value".into()))?;
                    params.push((key, build_expr(val_pair)?));
                }
            }
            Rule::yield_clause => {
                // yield_clause = { kw_yield ~ ident_list }
                // Navigate through ident_list (which nests individual ident rules)
                // to extract the column names as plain strings.
                let cols = child
                    .into_inner()
                    .find(|p| p.as_rule() == Rule::ident_list)
                    .into_iter()
                    .flat_map(|list| list.into_inner())
                    .filter(|p| p.as_rule() == Rule::ident)
                    .map(|p| p.as_str().to_string())
                    .collect();
                yields = Some(cols);
            }
            _ => {}
        }
    }

    Ok(super::ast::CallStatement { name, params, yields })
}

/// Build a [`CallPipelineStatement`] from a `call_pipeline_stmt` parse node.
///
/// Grammar:
/// ```text
/// call_pipeline_stmt = {
///     kw_call ~ ident ~ "(" ~ call_args? ~ ")" ~ yield_clause
///     ~ call_pipeline_match? ~ return_clause
/// }
/// call_pipeline_match = { kw_match ~ path_mode? ~ graph_patterns ~ where_clause? }
/// ```
///
/// Unlike [`build_call`], `yield_clause` is mandatory here (not optional), and
/// there may be a follow-on `call_pipeline_match` block that pipes the CALL
/// output into a graph pattern.  `kw_match` inside `call_pipeline_match` is
/// `@{}` (non-silent) and is skipped before reading structural children.
fn build_call_pipeline(pair: Pair<Rule>) -> Result<super::ast::CallPipelineStatement, DbError> {
    use super::ast::{CallPipelineStatement, CallPipelineMatch};

    let mut inner = pair.into_inner();

    // Skip kw_call, then find the algorithm ident.
    let name = inner
        .find(|p| p.as_rule() == Rule::ident)
        .ok_or_else(|| DbError::Parse("CALL pipeline: missing algorithm name".into()))?
        .as_str()
        .to_string();

    let mut params = Vec::new();
    let mut yields: Vec<String> = Vec::new();
    let mut match_clause = None;
    let mut return_clause = None;

    for child in inner {
        match child.as_rule() {
            Rule::call_args => {
                for arg in child.into_inner().filter(|p| p.as_rule() == Rule::call_arg) {
                    let mut parts = arg.into_inner();
                    let key = parts.next().unwrap().as_str().to_string();
                    let val_pair = parts.next().unwrap();
                    params.push((key, build_expr(val_pair)?));
                }
            }
            Rule::yield_clause => {
                // Navigate through ident_list to extract column names as strings.
                yields = child
                    .into_inner()
                    .find(|p| p.as_rule() == Rule::ident_list)
                    .into_iter()
                    .flat_map(|list| list.into_inner())
                    .filter(|p| p.as_rule() == Rule::ident)
                    .map(|p| p.as_str().to_string())
                    .collect();
            }
            Rule::call_pipeline_match => {
                let mut ci = child.into_inner().peekable();
                // kw_match is @{} so it surfaces as a token; skip it.
                if ci.peek().map(|t| t.as_rule() == Rule::kw_match).unwrap_or(false) {
                    ci.next();
                }
                let cf = ci.next().unwrap();
                let (opt_mode, patterns_pair) = if cf.as_rule() == Rule::path_mode {
                    (build_path_mode(&cf), ci.next().unwrap())
                } else {
                    (PathMode::Walk, cf)
                };
                let patterns = patterns_pair
                    .into_inner()
                    .filter(|t| t.as_rule() == Rule::graph_pattern)
                    .map(build_graph_pattern)
                    .collect::<Result<Vec<_>, _>>()?;
                let where_clause = ci
                    .find(|t| t.as_rule() == Rule::where_clause)
                    .map(build_where)
                    .transpose()?;
                match_clause = Some(CallPipelineMatch { patterns, where_clause, path_mode: opt_mode });
            }
            Rule::return_clause => return_clause = Some(build_return_clause(child)?),
            _ => {}
        }
    }

    Ok(CallPipelineStatement {
        name,
        params,
        yields,
        match_clause,
        return_clause: return_clause.ok_or_else(|| DbError::Parse("CALL pipeline: RETURN missing".into()))?,
    })
}

// ── MATCH ──────────────────────────────────────────────────────────────────

/// Build a [`MatchStatement`] from either a `match_stmt` or `optional_match_stmt` parse node.
///
/// Both statement types share an identical child structure after their leading
/// keywords, so a single builder handles both.
///
/// Grammar:
/// ```text
/// match_stmt         = { kw_match ~ path_mode? ~ graph_patterns ~ where_clause? ~ return_clause }
/// optional_match_stmt = { kw_optional ~ kw_match ~ path_mode? ~ graph_patterns ~ where_clause? ~ return_clause }
/// ```
///
/// `kw_optional` is `@{}` (non-silent atomic) and appears as the first child
/// token of `optional_match_stmt`; it is skipped before reading `path_mode?`.
/// `kw_match` and the path-mode keywords (`kw_walk`, `kw_trail`, `kw_simple`)
/// are handled in the leading-keyword skip loop below.
fn build_match(pair: Pair<Rule>) -> Result<MatchStatement, DbError> {
    let mut inner = pair.into_inner().peekable();

    // Skip leading keyword tokens (kw_optional for OPTIONAL MATCH, etc.).
    // These are @{} atomic rules that appear in the parse tree but carry no
    // structural meaning for the builder — the rule variant tells us the statement type.
    // Only kw_optional is unconditionally safe to skip here; kw_walk/trail/simple
    // only appear as *children of path_mode*, not at top level, so we stop at them.
    while inner.peek().map(|p| {
        matches!(p.as_rule(),
            Rule::kw_optional | Rule::kw_match | Rule::kw_walk |
            Rule::kw_trail   | Rule::kw_simple)
    }).unwrap_or(false) {
        // Only skip if it's truly a keyword, not path_mode or graph_patterns.
        // kw_walk/trail/simple could be path_mode children — but path_mode wraps them.
        let r = inner.peek().map(|p| p.as_rule());
        if r == Some(Rule::kw_optional) {
            inner.next();
        } else {
            break;
        }
    }

    // Optional path_mode comes before graph_patterns.
    let first = inner.next().unwrap();
    let (path_mode, patterns_pair) = if first.as_rule() == Rule::path_mode {
        (build_path_mode(&first), inner.next().unwrap())
    } else {
        (PathMode::Walk, first)
    };

    // graph_patterns = graph_pattern ~ ("," ~ graph_pattern)*
    let patterns = patterns_pair
        .into_inner()
        .filter(|p| p.as_rule() == Rule::graph_pattern)
        .map(build_graph_pattern)
        .collect::<Result<Vec<_>, _>>()?;

    let mut where_clause = None;
    let mut return_clause = None;

    for p in inner {
        match p.as_rule() {
            Rule::where_clause => where_clause = Some(build_where(p)?),
            Rule::return_clause => return_clause = Some(build_return_clause(p)?),
            _ => {}
        }
    }

    Ok(MatchStatement {
        patterns,
        where_clause,
        path_mode,
        return_clause: return_clause
            .ok_or_else(|| DbError::Parse("MATCH requires RETURN".into()))?,
    })
}

/// Convert a `path_mode` parse node to a [`PathMode`] enum variant.
///
/// Grammar: `path_mode = { kw_walk | kw_trail | kw_simple }`
///
/// The keywords use word-boundary guards in the grammar (`!(ASCII_ALPHA | "_")`)
/// to prevent matching prefixes like `TRAILBLAZER`.  Here we match on the raw
/// uppercased text rather than the child rule, which is simpler and equally
/// correct since `path_mode` contains exactly one keyword child.
fn build_path_mode(pair: &Pair<Rule>) -> PathMode {
    match pair.as_str().to_uppercase().as_str() {
        "TRAIL" => PathMode::Trail,
        "SIMPLE" => PathMode::Simple,
        _ => PathMode::Walk,
    }
}

/// Build a [`GraphPattern`] (a start node plus zero or more edge–node steps)
/// from a `graph_pattern` parse node.
///
/// Grammar:
/// ```text
/// graph_pattern = { node_pattern ~ edge_chain* }
/// edge_chain    = { edge_pattern ~ node_pattern }
/// ```
fn build_graph_pattern(pair: Pair<Rule>) -> Result<GraphPattern, DbError> {
    let mut inner = pair.into_inner();
    let start = build_node_pattern(inner.next().unwrap())?;

    let mut steps = Vec::new();
    // Each edge_chain pairs one edge pattern with the node it leads to.
    for chain in inner {
        if chain.as_rule() == Rule::edge_chain {
            let mut ci = chain.into_inner();
            let edge = build_edge_pattern(ci.next().unwrap())?;
            let node = build_node_pattern(ci.next().unwrap())?;
            steps.push(EdgePatternStep { edge, node });
        }
    }

    Ok(GraphPattern { start, steps })
}

/// Build a [`NodePattern`] from a `node_pattern` parse node.
///
/// Grammar: `node_pattern = { "(" ~ node_var? ~ (node_label)* ~ properties_inline? ~ ")" }`
///
/// Each `node_label` child has the form `:ident`; `.into_inner().next()` skips
/// the colon separator token and yields the bare label identifier.
fn build_node_pattern(pair: Pair<Rule>) -> Result<NodePattern, DbError> {
    let mut variable = None;
    let mut labels = Vec::new();
    let mut properties = Vec::new();

    for p in pair.into_inner() {
        match p.as_rule() {
            Rule::node_var => variable = Some(p.as_str().to_string()),
            Rule::node_label => {
                // node_label = { ":" ~ ident }; descend to get the ident text.
                labels.push(p.into_inner().next().unwrap().as_str().to_string());
            }
            Rule::properties_inline => properties = build_property_constraints(p)?,
            _ => {}
        }
    }

    Ok(NodePattern { variable, labels, properties })
}

/// Build an [`EdgePattern`] from an `edge_pattern` parse node.
///
/// Grammar:
/// ```text
/// edge_pattern = {
///     left_arrow  ~ "[" ~ edge_inner? ~ "]" ~ "-"
///   | "-"         ~ "[" ~ edge_inner? ~ "]" ~ right_arrow
///   | "-"         ~ "[" ~ edge_inner? ~ "]" ~ "-"
///   | left_arrow
///   | right_arrow
///   | "-"
/// }
/// edge_inner = { edge_var? ~ edge_label? ~ properties_inline? ~ quantifier? }
/// ```
///
/// Direction is inferred from the raw source text rather than the grammar
/// alternative that matched, because the arrow tokens (`<-`, `->`) are silent
/// (`_{}`) and therefore do not appear in the child list.
fn build_edge_pattern(pair: Pair<Rule>) -> Result<EdgePattern, DbError> {
    // Inspect raw text before consuming the pair, because direction arrows
    // are silent rules and do not appear as child tokens.
    let raw = pair.as_str();
    let direction = if raw.starts_with('<') {
        EdgeDirection::Incoming
    } else if raw.ends_with('>') {
        EdgeDirection::Outgoing
    } else {
        EdgeDirection::Either
    };

    let mut variable = None;
    let mut label = None;
    let mut properties = Vec::new();
    let mut quantifier = None;

    for p in pair.into_inner() {
        if p.as_rule() == Rule::edge_inner {
            for inner in p.into_inner() {
                match inner.as_rule() {
                    Rule::edge_var => variable = Some(inner.as_str().to_string()),
                    Rule::edge_label => {
                        // edge_label = { ":" ~ ident }; descend to get the label text.
                        label = Some(
                            inner.into_inner().next().unwrap().as_str().to_string(),
                        );
                    }
                    Rule::properties_inline => {
                        properties = build_property_constraints(inner)?;
                    }
                    Rule::quantifier => {
                        quantifier = Some(build_quantifier(inner)?);
                    }
                    _ => {}
                }
            }
        }
    }

    Ok(EdgePattern { variable, label, properties, direction, quantifier })
}

/// Build a [`PathQuantifier`] from a `quantifier` parse node.
///
/// Grammar:
/// ```text
/// quantifier  = { star_quant | plus_quant | brace_quant }
/// star_quant  = { "*" ~ quant_range? }
/// plus_quant  = { "+" }
/// brace_quant = { "{" ~ nat_int ~ ("," ~ nat_int?)? ~ "}" }
/// quant_range = { nat_int ~ (".." ~ nat_int?)? }
/// ```
///
/// The raw text of `quant_range` is inspected to distinguish three cases:
///
/// | Input | `range.as_str()` | Result |
/// |-------|-----------------|--------|
/// | `*2`  | `"2"`           | exact 2 hops (`min=2, max=Some(2)`) |
/// | `*2..3` | `"2..3"`      | 2 to 3 hops (`min=2, max=Some(3)`) |
/// | `*2..` | `"2.."`        | 2 or more hops (`min=2, max=None`) |
fn build_quantifier(pair: Pair<Rule>) -> Result<PathQuantifier, DbError> {
    let child = pair.into_inner().next().unwrap();
    match child.as_rule() {
        Rule::star_quant => {
            match child.into_inner().next() {
                // Plain `*` with no range — any number of hops (0 or more).
                None => Ok(PathQuantifier { min: 0, max: None }),
                Some(range) => {
                    // quant_range = nat_int ~ (".." ~ nat_int?)?
                    // Use the raw text to distinguish:
                    //   "*2"    → range.as_str() = "2"    → exact 2 hops
                    //   "*2..3" → range.as_str() = "2..3" → 2 to 3 hops
                    //   "*2.."  → range.as_str() = "2.."  → 2 or more hops (open-ended)
                    let is_range = range.as_str().contains("..");
                    let mut ri = range.into_inner();
                    let lo: u32 = ri
                        .next()
                        .unwrap()
                        .as_str()
                        .parse()
                        .map_err(|_| DbError::Parse("invalid quantifier bound".into()))?;
                    let hi: Option<u32> = ri
                        .next()
                        .map(|p| p.as_str().parse())
                        .transpose()
                        .map_err(|_| DbError::Parse("invalid quantifier upper bound".into()))?;
                    if is_range {
                        // "2.." → open-ended (hi = None); "2..3" → bounded (hi = Some(3)).
                        Ok(PathQuantifier { min: lo, max: hi })
                    } else {
                        // "2" → exact count.
                        Ok(PathQuantifier { min: lo, max: Some(lo) })
                    }
                }
            }
        }
        // `+` is syntactic sugar for `*1..` (one or more hops).
        Rule::plus_quant => Ok(PathQuantifier { min: 1, max: None }),
        Rule::brace_quant => {
            // Capture the raw text before consuming inner to check for comma presence.
            let raw = child.as_str().to_string();
            let mut ci = child.into_inner();
            let lo: u32 = ci
                .next()
                .unwrap()
                .as_str()
                .parse()
                .map_err(|_| DbError::Parse("invalid quantifier bound".into()))?;
            if raw.contains(',') {
                // {lo,} → unbounded from lo; {lo,hi} → bounded
                let hi: Option<u32> = ci
                    .next()
                    .map(|p| p.as_str().parse())
                    .transpose()
                    .map_err(|_| DbError::Parse("invalid quantifier upper bound".into()))?;
                Ok(PathQuantifier { min: lo, max: hi })
            } else {
                // {n} → exact count.
                Ok(PathQuantifier { min: lo, max: Some(lo) })
            }
        }
        r => Err(DbError::Parse(format!("unexpected quantifier rule: {r:?}"))),
    }
}

/// Build the list of [`PropertyConstraint`]s from a `properties_inline` parse node.
///
/// Grammar:
/// ```text
/// properties_inline    = { "{" ~ prop_constraint_list? ~ "}" }
/// prop_constraint_list = { prop_constraint ~ ("," ~ prop_constraint)* }
/// prop_constraint      = { ident ~ ":" ~ expr }
/// ```
///
/// The `ident` is the property key; the `expr` is the required value
/// (evaluated at match time — only equality constraints are supported inline).
fn build_property_constraints(pair: Pair<Rule>) -> Result<Vec<PropertyConstraint>, DbError> {
    let mut constraints = Vec::new();
    for p in pair.into_inner() {
        if p.as_rule() == Rule::prop_constraint_list {
            for constraint in p.into_inner() {
                if constraint.as_rule() == Rule::prop_constraint {
                    let mut ci = constraint.into_inner();
                    let key = ci.next().unwrap().as_str().to_string();
                    let value = build_expr(ci.next().unwrap())?;
                    constraints.push(PropertyConstraint { key, value });
                }
            }
        }
    }
    Ok(constraints)
}

/// Extract the filter [`Expr`] from a `where_clause` parse node.
///
/// Grammar: `where_clause = { kw_where ~ expr }`
///
/// `kw_where` is silent (`_{}`), so the only child is the expression itself.
fn build_where(pair: Pair<Rule>) -> Result<Expr, DbError> {
    build_expr(pair.into_inner().next().unwrap())
}

/// Build a [`ReturnClause`] from a `return_clause` parse node.
///
/// Grammar:
/// ```text
/// return_clause = {
///     kw_return ~ distinct_flag? ~ return_items
///     ~ order_by_clause? ~ (limit_clause | offset_clause)*
/// }
/// order_item = { expr ~ (kw_asc | kw_desc)? }
/// ```
///
/// `kw_return` is silent; the children are `distinct_flag?`, `return_items`,
/// and optional sorting/paging clauses.  The `ORDER BY` direction defaults to
/// ascending when no `DESC` keyword is present.
fn build_return_clause(pair: Pair<Rule>) -> Result<ReturnClause, DbError> {
    let mut distinct = false;
    let mut items = Vec::new();
    let mut order_by = Vec::new();
    let mut limit = None;
    let mut offset = None;

    for p in pair.into_inner() {
        match p.as_rule() {
            Rule::distinct_flag => distinct = true,
            Rule::return_items => {
                for item in p.into_inner() {
                    if item.as_rule() == Rule::return_item {
                        items.push(build_return_item(item)?);
                    }
                }
            }
            Rule::order_by_clause => {
                for oi in p.into_inner() {
                    if oi.as_rule() == Rule::order_item {
                        let mut oii = oi.into_inner();
                        let expr = build_expr(oii.next().unwrap())?;
                        // Direction defaults to ascending; DESC flips it.
                        let ascending = !oii
                            .next()
                            .map(|d| d.as_str().to_uppercase() == "DESC")
                            .unwrap_or(false);
                        order_by.push(OrderByItem { expr, ascending });
                    }
                }
            }
            Rule::limit_clause => {
                limit = Some(build_expr(p.into_inner().next().unwrap())?);
            }
            Rule::offset_clause => {
                offset = Some(build_expr(p.into_inner().next().unwrap())?);
            }
            _ => {}
        }
    }

    Ok(ReturnClause { distinct, items, order_by, limit, offset })
}

/// Build a single [`ReturnItem`] from a `return_item` parse node.
///
/// Grammar: `return_item = { "*" | expr ~ (kw_as ~ ident)? }`
///
/// The wildcard `*` is handled before consuming inner children because
/// `pair.as_str() == "*"` is cheaper than descending into a one-child list.
///
/// `kw_as_atom` is the atomic delegate for the silent `kw_as` rule; it is
/// `@{}` so it appears as a child token.  It is filtered out before reading
/// the alias identifier so that `inner.next()` reliably yields the alias name.
fn build_return_item(pair: Pair<Rule>) -> Result<ReturnItem, DbError> {
    let raw = pair.as_str().trim();
    if raw == "*" {
        return Ok(ReturnItem { expr: Expr::Star, alias: None });
    }

    let mut inner = pair.into_inner();
    let expr = build_expr(inner.next().unwrap())?;
    // kw_as_atom (the atomic delegate for kw_as) appears in the parse tree even through the
    // silent kw_as wrapper — skip it so that the next token is the alias ident.
    let alias = inner
        .filter(|p| p.as_rule() != Rule::kw_as_atom)
        .next()
        .map(|a| a.as_str().to_string());
    Ok(ReturnItem { expr, alias })
}

// ── INSERT ─────────────────────────────────────────────────────────────────

/// Build an [`InsertStatement`] from an `insert_stmt` parse node.
///
/// Grammar:
/// ```text
/// insert_stmt     = { kw_insert ~ insert_elements }
/// insert_elements = { insert_element ~ ("," ~ insert_element)* }
/// insert_element  = { insert_edge | insert_node }
/// ```
///
/// `kw_insert` is silent, so the children start with `insert_elements`.
/// Each `insert_element` wraps either an `insert_node` or `insert_edge`.
fn build_insert(pair: Pair<Rule>) -> Result<InsertStatement, DbError> {
    let mut elements = Vec::new();
    for p in pair.into_inner() {
        if p.as_rule() == Rule::insert_elements {
            for elem in p.into_inner() {
                if elem.as_rule() == Rule::insert_element {
                    let child = elem.into_inner().next().unwrap();
                    match child.as_rule() {
                        Rule::insert_node => elements.push(InsertElement::Node(build_insert_node(child)?)),
                        Rule::insert_edge => elements.push(InsertElement::Edge(build_insert_edge(child)?)),
                        _ => {}
                    }
                }
            }
        }
    }
    Ok(InsertStatement { elements })
}

/// Build an [`InsertNode`] from an `insert_node` parse node.
///
/// Grammar: `insert_node = { "(" ~ node_var? ~ (node_label)* ~ properties_inline? ~ ")" }`
///
/// The structure is identical to `node_pattern` but the result type is
/// [`InsertNode`] (with [`PropertyAssignment`]) rather than [`NodePattern`]
/// (with [`PropertyConstraint`]).
fn build_insert_node(pair: Pair<Rule>) -> Result<InsertNode, DbError> {
    let mut variable = None;
    let mut labels = Vec::new();
    let mut properties = Vec::new();

    for p in pair.into_inner() {
        match p.as_rule() {
            Rule::node_var => variable = Some(p.as_str().to_string()),
            Rule::node_label => {
                // node_label = { ":" ~ ident }; descend to get the ident text.
                labels.push(p.into_inner().next().unwrap().as_str().to_string());
            }
            Rule::properties_inline => {
                // Reuse build_property_constraints and convert to PropertyAssignment.
                for pc in build_property_constraints(p)? {
                    properties.push(PropertyAssignment { key: pc.key, value: pc.value });
                }
            }
            _ => {}
        }
    }

    Ok(InsertNode { variable, labels, properties })
}

/// Build an [`InsertEdge`] from an `insert_edge` parse node.
///
/// Grammar (three alternatives):
/// ```text
/// insert_edge = {
///     "(" ~ ident ~ ")" ~ "-"  ~ "[" ~ ident? ~ ":" ~ ident ~ properties_inline? ~ "]" ~ "->" ~ "(" ~ ident ~ ")"
///   | "(" ~ ident ~ ")" ~ "<-" ~ "[" ~ ident? ~ ":" ~ ident ~ properties_inline? ~ "]" ~ "-"  ~ "(" ~ ident ~ ")"
///   | "(" ~ ident ~ ")" ~ "-"  ~ "[" ~ ident? ~ ":" ~ ident ~ properties_inline? ~ "]" ~ "-"  ~ "(" ~ ident ~ ")"
/// }
/// ```
///
/// All three alternatives reduce to the same flat `ident` list (because the
/// arrow tokens are not `ident`).  The expected layout is:
///
/// | idents length | `idents[0]` | `idents[1]` | `idents[2]` | `idents[3]` |
/// |---|---|---|---|---|
/// | 3 | from_var | label | to_var | — |
/// | 4 | from_var | edge_var | label | to_var |
///
/// This is a simplified heuristic.  A stricter implementation would parse the
/// bracket sub-tokens directly.
fn build_insert_edge(pair: Pair<Rule>) -> Result<InsertEdge, DbError> {
    // Grammar: (from_var) -[var?:label props?]-> (to_var)
    let raw = pair.as_str();
    let directed = raw.contains("->") || raw.contains("<-");

    let mut idents: Vec<String> = Vec::new();
    let mut edge_label = String::new();
    let mut properties = Vec::new();

    for p in pair.into_inner() {
        match p.as_rule() {
            Rule::ident => idents.push(p.as_str().to_string()),
            Rule::properties_inline => {
                for pc in build_property_constraints(p)? {
                    properties.push(PropertyAssignment { key: pc.key, value: pc.value });
                }
            }
            _ => {}
        }
    }

    // idents: [from_var, (optional edge_var), label_ident, to_var] — depends on grammar match
    // This is simplified; a real parser would parse sub-tokens more carefully.
    // For now: idents[0]=from, last=to, middle=label or edge_var+label.
    let from_var = idents.first().cloned().unwrap_or_default();
    let to_var = idents.last().cloned().unwrap_or_default();

    if idents.len() >= 3 {
        edge_label = idents[idents.len() - 2].clone();
    }

    Ok(InsertEdge { from_var, to_var, label: edge_label, properties, directed })
}

// ── MATCH+INSERT ────────────────────────────────────────────────────────────

/// Build a [`MatchInsertStatement`] from a `match_insert_stmt` parse node.
///
/// Grammar:
/// ```text
/// match_insert_stmt = { kw_match ~ graph_patterns ~ where_clause? ~ kw_insert ~ insert_elements }
/// ```
///
/// Note that `match_insert_stmt` uses `graph_patterns` (with a comma-separated
/// list rule) rather than `graph_pattern`, allowing disconnected subpatterns
/// to be specified — e.g. `MATCH (a:Person), (b:Person) INSERT (a)-[:KNOWS]->(b)`.
fn build_match_insert(pair: Pair<Rule>) -> Result<MatchInsertStatement, DbError> {
    let mut patterns = Vec::new();
    let mut where_clause = None;
    let mut elements = Vec::new();

    for p in pair.into_inner() {
        match p.as_rule() {
            Rule::graph_patterns => {
                // graph_patterns = graph_pattern ~ ("," ~ graph_pattern)*
                for gp in p.into_inner() {
                    if gp.as_rule() == Rule::graph_pattern {
                        patterns.push(build_graph_pattern(gp)?);
                    }
                }
            }
            Rule::where_clause => where_clause = Some(build_where(p)?),
            Rule::insert_elements => {
                for elem in p.into_inner() {
                    if elem.as_rule() == Rule::insert_element {
                        let child = elem.into_inner().next().unwrap();
                        match child.as_rule() {
                            Rule::insert_node => elements.push(InsertElement::Node(build_insert_node(child)?)),
                            Rule::insert_edge => elements.push(InsertElement::Edge(build_insert_edge(child)?)),
                            _ => {}
                        }
                    }
                }
            }
            _ => {}
        }
    }

    Ok(MatchInsertStatement { patterns, where_clause, elements })
}

// ── SET ────────────────────────────────────────────────────────────────────

/// Build a [`SetStatement`] from a `set_stmt` parse node.
///
/// Grammar:
/// ```text
/// set_stmt  = { kw_match ~ graph_pattern ~ where_clause? ~ kw_set ~ set_items }
/// set_items = { set_item ~ ("," ~ set_item)* }
/// set_item  = { ident ~ ("." ~ ident ~ "=" ~ expr | ":" ~ ident) }
/// ```
///
/// `kw_match` and `kw_set` are silent, so the children start with
/// `graph_pattern`.
fn build_set(pair: Pair<Rule>) -> Result<SetStatement, DbError> {
    let mut inner = pair.into_inner();
    let match_pattern = build_graph_pattern(inner.next().unwrap())?;

    let mut where_clause = None;
    let mut assignments = Vec::new();

    for p in inner {
        match p.as_rule() {
            Rule::where_clause => where_clause = Some(build_where(p)?),
            Rule::set_items => {
                for item in p.into_inner() {
                    if item.as_rule() == Rule::set_item {
                        assignments.push(build_set_item(item)?);
                    }
                }
            }
            _ => {}
        }
    }

    Ok(SetStatement { match_pattern, where_clause, assignments })
}

/// Build a [`SetItem`] from a `set_item` parse node.
///
/// Grammar: `set_item = { ident ~ ("." ~ ident ~ "=" ~ expr | ":" ~ ident) }`
///
/// The two alternatives share the same leading `ident` (the variable name).
/// They are distinguished by inspecting the raw source text:
/// - a `.` indicates `n.prop = expr` (property assignment)
/// - a `:` indicates `n:Label` (label addition)
///
/// The raw string is captured *before* `pair.into_inner()` is called because
/// `into_inner()` consumes the pair and the text would no longer be accessible.
fn build_set_item(pair: Pair<Rule>) -> Result<SetItem, DbError> {
    let raw = pair.as_str().to_string();
    let mut inner = pair.into_inner();
    let variable = inner.next().unwrap().as_str().to_string();
    let second = inner.next().unwrap();

    if raw.contains('.') {
        // `n.key = expr` — property assignment
        let key = second.as_str().to_string();
        let value = build_expr(inner.next().unwrap())?;
        Ok(SetItem::Property { variable, key, value })
    } else {
        // `n:Label` — label addition
        Ok(SetItem::AddLabel { variable, label: second.as_str().to_string() })
    }
}

// ── REMOVE ─────────────────────────────────────────────────────────────────

/// Build a [`RemoveStatement`] from a `remove_stmt` parse node.
///
/// Grammar:
/// ```text
/// remove_stmt  = { kw_match ~ graph_pattern ~ where_clause? ~ kw_remove ~ remove_items }
/// remove_items = { remove_item ~ ("," ~ remove_item)* }
/// remove_item  = { ident ~ ("." ~ ident | ":" ~ ident) }
/// ```
fn build_remove(pair: Pair<Rule>) -> Result<RemoveStatement, DbError> {
    let mut inner = pair.into_inner();
    let match_pattern = build_graph_pattern(inner.next().unwrap())?;

    let mut where_clause = None;
    let mut items = Vec::new();

    for p in inner {
        match p.as_rule() {
            Rule::where_clause => where_clause = Some(build_where(p)?),
            Rule::remove_items => {
                for item in p.into_inner() {
                    if item.as_rule() == Rule::remove_item {
                        items.push(build_remove_item(item)?);
                    }
                }
            }
            _ => {}
        }
    }

    Ok(RemoveStatement { match_pattern, where_clause, items })
}

/// Build a [`RemoveItem`] from a `remove_item` parse node.
///
/// Grammar: `remove_item = { ident ~ ("." ~ ident | ":" ~ ident) }`
///
/// Like [`build_set_item`], the two alternatives are distinguished by
/// inspecting the raw source text for `.` (property removal) vs `:` (label
/// removal).  The raw string is read *before* consuming the pair.
fn build_remove_item(pair: Pair<Rule>) -> Result<RemoveItem, DbError> {
    // Read raw string before consuming the pair.
    let raw = pair.as_str().to_string();
    let mut inner = pair.into_inner();
    let variable = inner.next().unwrap().as_str().to_string();
    let second = inner.next().unwrap();

    // Distinguish property vs label removal by checking if there's a `.` or `:`
    if raw.contains('.') {
        Ok(RemoveItem::Property { variable, key: second.as_str().to_string() })
    } else {
        Ok(RemoveItem::Label { variable, label: second.as_str().to_string() })
    }
}

// ── DELETE ─────────────────────────────────────────────────────────────────

/// Build a [`DeleteStatement`] from a `delete_stmt` parse node.
///
/// Grammar (two alternatives — `MATCH … DELETE` and legacy `DELETE MATCH …`):
/// ```text
/// delete_stmt = {
///     kw_match ~ graph_pattern ~ where_clause? ~ (kw_detach ~ kw_delete | kw_delete) ~ ident_list
///   | (kw_detach ~ kw_delete | kw_delete) ~ kw_match? ~ graph_pattern? ~ where_clause? ~ ident_list
/// }
/// ```
///
/// `DETACH` is detected by inspecting the uppercased raw source text, which
/// works across both alternatives without needing to track which alternative
/// matched.  When no `graph_pattern` is present (possible in the legacy form)
/// an empty anonymous pattern is substituted so that downstream code never
/// sees `None`.
fn build_delete(pair: Pair<Rule>) -> Result<DeleteStatement, DbError> {
    let raw = pair.as_str().to_uppercase();
    let detach = raw.contains("DETACH");

    let mut match_pattern = None;
    let mut where_clause = None;
    let mut variables = Vec::new();

    for p in pair.into_inner() {
        match p.as_rule() {
            Rule::graph_pattern => match_pattern = Some(build_graph_pattern(p)?),
            Rule::where_clause => where_clause = Some(build_where(p)?),
            Rule::ident_list => {
                // ident_list = { ident ~ ("," ~ ident)* }
                variables = p.into_inner().map(|i| i.as_str().to_string()).collect();
            }
            _ => {}
        }
    }

    // Substitute an anonymous empty pattern when the grammar alternative omits graph_pattern.
    let match_pattern = match_pattern.unwrap_or_else(|| GraphPattern {
        start: NodePattern { variable: None, labels: vec![], properties: vec![] },
        steps: vec![],
    });

    Ok(DeleteStatement { match_pattern, where_clause, variables, detach })
}

// ── Expressions ────────────────────────────────────────────────────────────

/// Route an expression parse node to the appropriate expression builder.
///
/// The expression grammar is stratified into layers to encode operator
/// precedence:
/// ```text
/// expr → or_expr → and_expr → not_expr → compare_expr
///      → add_expr → mul_expr → unary_expr → primary
/// ```
///
/// Each layer is handled by a dedicated helper.  The `expr` rule itself is
/// transparent (it contains exactly one `or_expr`), so this function simply
/// recurses into it.
fn build_expr(pair: Pair<Rule>) -> Result<Expr, DbError> {
    match pair.as_rule() {
        Rule::expr => build_expr(pair.into_inner().next().unwrap()),
        // kw_or / kw_and are now @{} (atomic, non-silent), so they appear in the tree.
        // build_binary_chain reads the operator text from those tokens directly.
        Rule::or_expr => build_binary_chain(pair, &[("OR", BinOp::Or)]),
        Rule::and_expr => build_binary_chain(pair, &[("AND", BinOp::And)]),
        Rule::not_expr => {
            let mut inner = pair.into_inner();
            let first = inner.next().unwrap();
            // kw_not is now @{} (non-silent atomic), so it appears as the first token
            // when the NOT keyword is present; otherwise the first token is compare_expr.
            if first.as_rule() == Rule::kw_not {
                Ok(Expr::Not(Box::new(build_expr(inner.next().unwrap())?)))
            } else {
                build_expr(first)
            }
        }
        Rule::compare_expr => build_compare_expr(pair),
        Rule::add_expr => build_binary_chain(
            pair,
            &[("+", BinOp::Add), ("-", BinOp::Sub)],
        ),
        Rule::mul_expr => build_binary_chain(
            pair,
            &[("*", BinOp::Mul), ("/", BinOp::Div), ("%", BinOp::Mod)],
        ),
        Rule::unary_expr => build_unary_expr(pair),
        Rule::primary => build_primary(pair),
        r => Err(DbError::Parse(format!("unexpected expr rule: {r:?}"))),
    }
}

/// Build a left-associative chain of binary operations at one precedence level.
///
/// Used for `or_expr` (OR), `and_expr` (AND), `add_expr` (+/−), and
/// `mul_expr` (*/%/).
///
/// Each grammar rule at these levels has the form:
/// `rule = { sub_expr ~ (op_keyword ~ sub_expr)* }`
///
/// The `ops` table maps operator text (uppercased) to a [`BinOp`] variant.
/// Operator tokens are non-silent (`@{}`), so they appear interleaved with the
/// operand pairs in `inner`.
fn build_binary_chain(pair: Pair<Rule>, ops: &[(&str, BinOp)]) -> Result<Expr, DbError> {
    let mut inner = pair.into_inner();
    let mut left = build_expr(inner.next().unwrap())?;

    while let Some(op_or_rhs) = inner.next() {
        let op_str = op_or_rhs.as_str().to_uppercase();
        let op = ops
            .iter()
            .find(|(s, _)| *s == op_str)
            .map(|(_, o)| *o)
            .ok_or_else(|| DbError::Parse(format!("unknown op: {op_str}")))?;
        let right = build_expr(inner.next().unwrap())?;
        left = Expr::BinOp(Box::new(left), op, Box::new(right));
    }

    Ok(left)
}

/// Build a comparison expression from a `compare_expr` parse node.
///
/// Grammar:
/// ```text
/// compare_expr = { add_expr ~ (cmp_op ~ add_expr | kw_in ~ add_expr | is_null_suffix)? }
/// is_null_suffix = { kw_is ~ not_flag? ~ kw_null }
/// not_flag = { kw_not }
/// ```
///
/// Three suffixes are possible:
/// - `cmp_op add_expr` → a standard comparison (`=`, `<>`, `<`, `<=`, `>`, `>=`)
/// - `kw_in add_expr`  → membership test (`IN`)
/// - `is_null_suffix`  → null check (`IS NULL` / `IS NOT NULL`)
///
/// For `IS [NOT] NULL`, `kw_is_atom` and `kw_null_atom` are `@{}` (non-silent)
/// and appear as inner tokens of `is_null_suffix`.  The presence of the
/// optional `not_flag` child determines whether this is `IS NULL` or `IS NOT NULL`.
fn build_compare_expr(pair: Pair<Rule>) -> Result<Expr, DbError> {
    let mut inner = pair.into_inner();
    let lhs = build_expr(inner.next().unwrap())?;

    match inner.next() {
        None => Ok(lhs),
        Some(p) if p.as_rule() == Rule::cmp_op => {
            let rhs = build_expr(inner.next().unwrap())?;
            let op = match p.as_str() {
                "=" => BinOp::Eq,
                "<>" => BinOp::Neq,
                "<" => BinOp::Lt,
                "<=" => BinOp::Lte,
                ">" => BinOp::Gt,
                ">=" => BinOp::Gte,
                s => return Err(DbError::Parse(format!("unknown cmp op: {s}"))),
            };
            Ok(Expr::BinOp(Box::new(lhs), op, Box::new(rhs)))
        }
        Some(p) if p.as_rule() == Rule::kw_in => {
            let rhs = build_expr(inner.next().unwrap())?;
            Ok(Expr::BinOp(Box::new(lhs), BinOp::In, Box::new(rhs)))
        }
        Some(p) if p.as_rule() == Rule::is_null_suffix => {
            // kw_is_atom and kw_null_atom both appear as inner pairs (they're @{}).
            // not_flag appears only for IS NOT NULL. Search specifically for it.
            let is_not = p.into_inner().any(|ip| ip.as_rule() == Rule::not_flag);
            Ok(Expr::IsNull(Box::new(lhs), is_not))
        }
        _ => Ok(lhs),
    }
}

/// Build a unary negation expression from a `unary_expr` parse node.
///
/// Grammar: `unary_expr = { "-" ~ unary_expr | primary }`
///
/// Unary minus is represented as `0 - expr` (using [`BinOp::Sub`]) rather than
/// a dedicated `Expr::Neg` variant, keeping the AST smaller.  The raw source
/// text is inspected to determine whether the minus sign is present before
/// descending into the child pair.
fn build_unary_expr(pair: Pair<Rule>) -> Result<Expr, DbError> {
    let raw = pair.as_str();
    let mut inner = pair.into_inner();
    let first = inner.next().unwrap();
    if raw.starts_with('-') && first.as_rule() == Rule::unary_expr {
        let e = build_expr(first)?;
        Ok(Expr::BinOp(
            Box::new(Expr::Literal(Value::Int(0))),
            BinOp::Sub,
            Box::new(e),
        ))
    } else {
        build_expr(first)
    }
}

/// Build an atomic primary expression from a `primary` parse node.
///
/// Grammar:
/// ```text
/// primary = {
///     "(" ~ expr ~ ")"
///   | func_call
///   | list_literal
///   | literal
///   | prop_access
///   | var_ref
///   | star
/// }
/// ```
///
/// Parenthesised expressions re-enter `build_expr` via the `expr` arm;
/// `list_literal` produces `Expr::List`; all other forms map 1-to-1 to an
/// `Expr` variant.
fn build_primary(pair: Pair<Rule>) -> Result<Expr, DbError> {
    let child = pair.into_inner().next().unwrap();
    match child.as_rule() {
        Rule::literal => Ok(Expr::Literal(build_literal(child)?)),
        Rule::func_call => build_func_call(child),
        Rule::prop_access => {
            // prop_access = { ident ~ "." ~ ident }
            let mut inner = child.into_inner();
            let obj = inner.next().unwrap().as_str().to_string();
            let key = inner.next().unwrap().as_str().to_string();
            Ok(Expr::Property(Box::new(Expr::Var(obj)), key))
        }
        Rule::var_ref => Ok(Expr::Var(child.as_str().to_string())),
        Rule::star => Ok(Expr::Star),
        // Parenthesised expression: "(" ~ expr ~ ")" — re-enter the expression builder.
        Rule::expr => build_expr(child),
        Rule::list_literal => {
            // list_literal = { "[" ~ (expr ~ ("," ~ expr)*)? ~ "]" }
            // The brackets are silent, so all children are expr pairs.
            let items: Result<Vec<Expr>, DbError> = child.into_inner().map(build_expr).collect();
            Ok(Expr::List(items?))
        }
        Rule::param => {
            // param = @{ "$" ~ ident }  — strip leading "$"
            let name = child.as_str()[1..].to_string();
            Ok(Expr::Param(name))
        }
        r => Err(DbError::Parse(format!("unexpected primary: {r:?}"))),
    }
}

/// Build a function-call expression from a `func_call` parse node.
///
/// Grammar: `func_call = { ident ~ "(" ~ (expr ~ ("," ~ expr)*)? ~ ")" }`
///
/// The function name is lowercased for case-insensitive dispatch in the
/// executor (e.g. `COUNT`, `count`, `Count` all resolve to the same handler).
fn build_func_call(pair: Pair<Rule>) -> Result<Expr, DbError> {
    let mut inner = pair.into_inner();
    let name = inner.next().unwrap().as_str().to_lowercase();
    let args: Result<Vec<Expr>, DbError> = inner.map(build_expr).collect();
    Ok(Expr::Call(name, args?))
}

/// Build a [`Value`] from a `literal` parse node.
///
/// Grammar:
/// ```text
/// literal = { float | integer | string | boolean | null_lit }
/// ```
///
/// `float` is tried before `integer` in the grammar so that `3.14` is never
/// misidentified as integer `3`.  String values have their surrounding quote
/// characters stripped; quote character is whichever matched (`"` or `'`).
fn build_literal(pair: Pair<Rule>) -> Result<Value, DbError> {
    let child = pair.into_inner().next().unwrap();
    match child.as_rule() {
        Rule::integer => {
            let n: i64 = child
                .as_str()
                .parse()
                .map_err(|e: std::num::ParseIntError| DbError::Parse(e.to_string()))?;
            Ok(Value::Int(n))
        }
        Rule::float => {
            let n: f64 = child
                .as_str()
                .parse()
                .map_err(|e: std::num::ParseFloatError| DbError::Parse(e.to_string()))?;
            Ok(Value::Float(n))
        }
        Rule::string => {
            let s = child.as_str();
            // Strip surrounding quotes (first and last character).
            let inner = &s[1..s.len() - 1];
            Ok(Value::String(inner.to_string()))
        }
        Rule::boolean => Ok(Value::Bool(child.as_str().to_uppercase() == "TRUE")),
        Rule::null_lit => Ok(Value::Null),
        r => Err(DbError::Parse(format!("unexpected literal rule: {r:?}"))),
    }
}

// ── Index management parsers ────────────────────────────────────────────────

/// Build a [`CreateIndexStatement`] from a `create_index_stmt` parse node.
///
/// Grammar: `create_index_stmt = { kw_create ~ kw_index ~ kw_on ~ ":" ~ ident ~ "(" ~ ident ~ ")" }`
///
/// All keywords are silent; the two `ident` children are the label and property
/// name respectively.
fn build_create_index(pair: Pair<Rule>) -> Result<CreateIndexStatement, DbError> {
    let mut inner = pair.into_inner();
    let label    = inner.next().unwrap().as_str().to_string();
    let property = inner.next().unwrap().as_str().to_string();
    Ok(CreateIndexStatement { label, property })
}

/// Build a [`DropIndexStatement`] from a `drop_index_stmt` parse node.
///
/// Grammar: `drop_index_stmt = { kw_drop ~ kw_index ~ kw_on ~ ":" ~ ident ~ "(" ~ ident ~ ")" }`
///
/// Identical structure to `create_index_stmt`; the two `ident` children are the
/// label and property name.
fn build_drop_index(pair: Pair<Rule>) -> Result<DropIndexStatement, DbError> {
    let mut inner = pair.into_inner();
    let label    = inner.next().unwrap().as_str().to_string();
    let property = inner.next().unwrap().as_str().to_string();
    Ok(DropIndexStatement { label, property })
}

// ── LOAD CSV ──────────────────────────────────────────────────────────────────

/// Build a [`LoadCsvNodesStatement`] from a `load_csv_nodes_stmt` parse node.
///
/// Grammar:
/// ```pest
/// load_csv_nodes_stmt = { kw_load ~ kw_csv ~ kw_nodes ~ kw_from ~ string ~ (kw_label ~ ident)? }
/// ```
///
/// All keywords are silent.  The two possible children are:
/// 1. `string` — the file path (always present).
/// 2. `ident`  — the default label (present only when `LABEL` clause given).
fn build_load_csv_nodes(pair: Pair<Rule>) -> Result<LoadCsvNodesStatement, DbError> {
    let mut inner = pair.into_inner();

    // First child is always the path string literal.
    let raw = inner.next().ok_or_else(|| DbError::Parse("LOAD CSV NODES: missing path".into()))?.as_str();
    let path = raw.trim_matches(|c| c == '\'' || c == '"').to_string();

    // Optional LABEL ident.
    let label = inner.next().map(|p| p.as_str().to_string());

    Ok(LoadCsvNodesStatement { path, label })
}

/// Build a [`LoadCsvEdgesStatement`] from a `load_csv_edges_stmt` parse node.
///
/// Grammar:
/// ```pest
/// load_csv_edges_stmt = { kw_load ~ kw_csv ~ kw_edges ~ kw_from ~ string ~ (kw_label ~ ident)? }
/// ```
fn build_load_csv_edges(pair: Pair<Rule>) -> Result<LoadCsvEdgesStatement, DbError> {
    let mut inner = pair.into_inner();

    let raw = inner.next().ok_or_else(|| DbError::Parse("LOAD CSV EDGES: missing path".into()))?.as_str();
    let path = raw.trim_matches(|c| c == '\'' || c == '"').to_string();

    let label = inner.next().map(|p| p.as_str().to_string());

    Ok(LoadCsvEdgesStatement { path, label })
}

// ── UNWIND … INSERT ──────────────────────────────────────────────────────────

/// Build an [`UnwindInsertStatement`] from an `unwind_insert_stmt` parse node.
///
/// Grammar: `unwind_insert_stmt = { kw_unwind ~ expr ~ kw_as ~ ident ~ kw_insert ~ insert_elements }`
fn build_unwind_insert(pair: Pair<Rule>) -> Result<UnwindInsertStatement, DbError> {
    let mut inner = pair.into_inner().peekable();

    // kw_unwind is @{} (non-silent atomic), so it appears as first token; skip it.
    if inner.peek().map(|p| p.as_rule() == Rule::kw_unwind).unwrap_or(false) {
        inner.next();
    }

    let expr_pair = inner.next().ok_or_else(|| DbError::Parse("UNWIND INSERT: missing expr".into()))?;
    let expr = build_expr(expr_pair)?;

    // kw_as_atom may appear as a visible token; skip it.
    if inner.peek().map(|p| p.as_rule() == Rule::kw_as_atom).unwrap_or(false) {
        inner.next();
    }

    let variable = inner
        .next()
        .ok_or_else(|| DbError::Parse("UNWIND INSERT: missing variable".into()))?
        .as_str()
        .to_string();

    let mut elements = Vec::new();
    for p in inner {
        if p.as_rule() == Rule::insert_elements {
            for elem in p.into_inner() {
                if elem.as_rule() == Rule::insert_element {
                    let child = elem.into_inner().next().unwrap();
                    match child.as_rule() {
                        Rule::insert_node => elements.push(InsertElement::Node(build_insert_node(child)?)),
                        Rule::insert_edge => elements.push(InsertElement::Edge(build_insert_edge(child)?)),
                        _ => {}
                    }
                }
            }
        }
    }

    Ok(UnwindInsertStatement { expr, variable, elements })
}

// ── CONSTRAINT statements ─────────────────────────────────────────────────────

/// Build a [`ConstraintStatement`] from a `constraint_stmt` parse node.
///
/// Grammar:
/// ```pest
/// constraint_stmt   = {
///     (kw_create ~ kw_constraint ~ constraint_kind ~ kw_on ~ constraint_target)
///   | (kw_drop   ~ kw_constraint ~ constraint_kind ~ kw_on ~ constraint_target)
///   | (kw_show   ~ kw_constraints)
/// }
/// constraint_kind   = { kw_unique | (kw_type_kw ~ kw_is_atom ~ value_type) }
/// constraint_target = { ":" ~ ident ~ "(" ~ ident ~ ")" }
/// value_type        = { ^"INTEGER" | ^"FLOAT" | ^"STRING" | ^"BOOLEAN" }
/// ```
fn build_constraint(pair: Pair<Rule>) -> Result<ConstraintStatement, DbError> {
    let raw_upper = pair.as_str().to_uppercase();

    // Determine operation from raw text before consuming children.
    let is_show   = raw_upper.trim_start().starts_with("SHOW");
    let is_create = raw_upper.trim_start().starts_with("CREATE");

    if is_show {
        return Ok(ConstraintStatement { op: ConstraintOp::Show });
    }

    // Parse children for CREATE / DROP.
    let mut kind_opt: Option<ConstraintKind> = None;
    let mut label = String::new();
    let mut property = String::new();

    for p in pair.into_inner() {
        match p.as_rule() {
            Rule::constraint_kind => {
                // Either kw_unique or kw_type_kw + kw_is_atom + value_type.
                let raw_kind = p.as_str().to_uppercase();
                if raw_kind.trim_start().starts_with("UNIQUE") {
                    kind_opt = Some(ConstraintKind::Unique);
                } else {
                    // "TYPE IS <vt>"
                    let vt_str = p
                        .into_inner()
                        .find(|t| t.as_rule() == Rule::value_type)
                        .map(|t| t.as_str().to_uppercase())
                        .unwrap_or_default();
                    let vk = match vt_str.as_str() {
                        "INTEGER" => ValueKind::Integer,
                        "FLOAT"   => ValueKind::Float,
                        "STRING"  => ValueKind::String,
                        "BOOLEAN" => ValueKind::Boolean,
                        s => return Err(DbError::Parse(format!("unknown value type: {s}"))),
                    };
                    kind_opt = Some(ConstraintKind::Type(vk));
                }
            }
            Rule::constraint_target => {
                // ":" ~ ident ~ "(" ~ ident ~ ")"
                let mut ti = p.into_inner().filter(|t| t.as_rule() == Rule::ident);
                label    = ti.next().unwrap_or_else(|| panic!("constraint target: missing label")).as_str().to_string();
                property = ti.next().unwrap_or_else(|| panic!("constraint target: missing property")).as_str().to_string();
            }
            _ => {}
        }
    }

    let kind = kind_opt.ok_or_else(|| DbError::Parse("constraint: missing kind".into()))?;
    let op = if is_create {
        ConstraintOp::Create { kind, label, property }
    } else {
        ConstraintOp::Drop { kind, label, property }
    };
    Ok(ConstraintStatement { op })
}
