# Copyright 2025-2026 Gregorio Elias Roecker Momm and nxCypher contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Top-down interpreter for the v4 openCypher Lark grammar.

Walks the parse tree produced by `nx_opencypher.lark` and feeds data into
NXCypherExecutor / MutationExecutor.
"""

from __future__ import annotations

import random
import string
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple

import networkx as nx
from lark import Lark, Token, Tree

# We import the classes we need from __init__ — they must already be available.
# To avoid circular imports at module level, we use a lazy import pattern.

_ALPHABET = string.ascii_lowercase + string.digits


def _shortuuid(k: int = 4) -> str:
    return "".join(random.choices(_ALPHABET, k=k))


class InvalidUnicodeLiteral(Exception):
    """Raised when a Unicode escape sequence is invalid."""
    pass


def _process_cypher_escapes(s: str, strict: bool = True) -> str:
    r"""Process Cypher string escape sequences.

    Handles:
    - \n, \t, \r, \b, \f - control characters
    - \\ - literal backslash
    - \' - single quote
    - \" - double quote
    - \uXXXX - Unicode escape (4 hex digits)
    - \UXXXXXXXX - Unicode escape (8 hex digits)

    Args:
        s: The string to process (with quotes already stripped)
        strict: If True, raise InvalidUnicodeLiteral for invalid Unicode escapes

    Raises:
        InvalidUnicodeLiteral: If strict=True and an invalid Unicode escape is found
    """
    result = []
    i = 0
    while i < len(s):
        if s[i] == '\\' and i + 1 < len(s):
            next_char = s[i + 1]
            if next_char == 'n':
                result.append('\n')
                i += 2
            elif next_char == 't':
                result.append('\t')
                i += 2
            elif next_char == 'r':
                result.append('\r')
                i += 2
            elif next_char == 'b':
                result.append('\b')
                i += 2
            elif next_char == 'f':
                result.append('\f')
                i += 2
            elif next_char == '\\':
                result.append('\\')
                i += 2
            elif next_char == "'":
                result.append("'")
                i += 2
            elif next_char == '"':
                result.append('"')
                i += 2
            elif next_char == '/':
                result.append('/')
                i += 2
            elif next_char == 'u':
                # Unicode escape: \uXXXX (4 hex digits)
                if i + 5 <= len(s):
                    hex_digits = s[i+2:i+6]
                    if all(c in '0123456789abcdefABCDEF' for c in hex_digits):
                        code_point = int(hex_digits, 16)
                        result.append(chr(code_point))
                        i += 6
                        continue
                # Invalid Unicode escape
                if strict:
                    raise InvalidUnicodeLiteral(f"Invalid Unicode escape sequence at position {i}")
                result.append(s[i])
                i += 1
            elif next_char == 'U':
                # Unicode escape: \UXXXXXXXX (8 hex digits)
                if i + 9 <= len(s):
                    hex_digits = s[i+2:i+10]
                    if all(c in '0123456789abcdefABCDEF' for c in hex_digits):
                        code_point = int(hex_digits, 16)
                        result.append(chr(code_point))
                        i += 10
                        continue
                # Invalid Unicode escape
                if strict:
                    raise InvalidUnicodeLiteral(f"Invalid Unicode escape sequence at position {i}")
                result.append(s[i])
                i += 1
            else:
                # Unknown escape, keep backslash
                result.append(s[i])
                i += 1
        else:
            result.append(s[i])
            i += 1
    return ''.join(result)


def _tree_to_expression_text(node, in_unary: bool = False) -> str:
    """Reconstruct the expression text from a parse tree for column naming.

    This is used to generate column names for expressions without aliases,
    matching Neo4j's behavior of using the expression text as the column name.
    """
    # Token types that should have spaces around them (binary operators)
    # Note: v4 grammar uses ASSIGN/GE/LE, older grammar uses EQ/GTE/LTE
    SPACED_OPS = {"PLUS", "SUB", "MULT", "DIV", "MOD", "EQ", "NEQ", "LT", "GT", "LTE", "GTE",
                  "AND", "OR", "XOR", "CARET", "ASSIGN", "GE", "LE"}
    if isinstance(node, Token):
        tok_str = str(node)
        # For unary operators, don't add spaces
        if in_unary and node.type in ("SUB", "PLUS"):
            return tok_str
        # Add spaces around binary operators
        if node.type in SPACED_OPS:
            return f" {tok_str} "
        # Add space after comma
        if node.type == "COMMA":
            return ", "
        return tok_str
    if isinstance(node, Tree):
        # Handle unary expression trees specially - no spaces around the operator
        # Check if this is a unary expression (has exactly 2 children: operator and operand)
        if node.data in ("expr_negate", "expr_positive", "unary_add_sub_expression"):
            # Check if first child is an operator token (unary usage)
            if len(node.children) >= 2 and isinstance(node.children[0], Token) and node.children[0].type in ("SUB", "PLUS"):
                return ''.join(_tree_to_expression_text(c, in_unary=True) for c in node.children)
        return ''.join(_tree_to_expression_text(c, in_unary=False) for c in node.children)
    return str(node)


# ---------------------------------------------------------------------------
# Grammar parser (singleton)
# ---------------------------------------------------------------------------

_grammar_path = Path(__file__).parent / "grammars" / "nx_opencypher.lark"


def _build_parser() -> Lark:
    return Lark(
        _grammar_path.read_text(),
        parser="earley",
        start="start",
    )


# Lazy singleton
_parser: Lark | None = None


def get_parser() -> Lark:
    global _parser
    if _parser is None:
        _parser = _build_parser()
    return _parser


# ---------------------------------------------------------------------------
# Aggregate function names
# ---------------------------------------------------------------------------

_AGGREGATE_FUNCS = frozenset({"COUNT", "SUM", "AVG", "MAX", "MIN", "COLLECT", "PERCENTILEDISC", "PERCENTILECONT"})


# ---------------------------------------------------------------------------
# Helpers to walk v4 trees
# ---------------------------------------------------------------------------

def _find_child(node: Tree, data_name: str) -> Tree | None:
    """Find the first child Tree with the given data name."""
    for c in node.children:
        if isinstance(c, Tree) and c.data == data_name:
            return c
    return None


def _find_children(node: Tree, data_name: str) -> list[Tree]:
    """Find all child Trees with the given data name."""
    return [c for c in node.children if isinstance(c, Tree) and c.data == data_name]


def _has_token(node: Tree, token_type: str) -> bool:
    """Check if any child is a Token with the given type."""
    for c in node.children:
        if isinstance(c, Token) and c.type == token_type:
            return True
    return False


def _get_token(node: Tree, token_type: str) -> Token | None:
    for c in node.children:
        if isinstance(c, Token) and c.type == token_type:
            return c
    return None


def _symbol_to_str(sym: Tree) -> str:
    """Convert a v4 `symbol` tree node to a plain string."""
    if isinstance(sym, Token):
        val = str(sym)
        if val.startswith("`") and val.endswith("`"):
            return val[1:-1]
        return val
    if isinstance(sym, Tree) and sym.data == "symbol":
        return _symbol_to_str(sym.children[0])
    if isinstance(sym, Tree) and sym.data == "name":
        return _symbol_to_str(sym.children[0])
    if isinstance(sym, Tree) and sym.data == "reserved_word":
        return str(sym.children[0])
    return str(sym)


def _name_to_str(node: Tree) -> str:
    """Convert a v4 `name` tree node to a plain string."""
    return _symbol_to_str(node)


# ---------------------------------------------------------------------------
# V4QueryInterpreter
# ---------------------------------------------------------------------------

class V4QueryInterpreter:
    """Top-down interpreter that walks a v4 parse tree and populates
    NXCypherExecutor / MutationExecutor state."""

    def __init__(self, target_graph: nx.Graph, limit: int | None = None, strict: bool = True):
        # Lazy import to avoid circular dependency
        from nxcypher import NXCypherExecutor, MutationExecutor

        self._target_graph = target_graph
        self._limit = limit
        self._strict = strict
        self._executors: list = [NXCypherExecutor(target_graph, limit, strict=strict)]
        self._match_clause_count = 0
        self._mutation_executor = MutationExecutor(target_graph)
        # UNION support
        self._union_queries: list = []
        self._union_types: list[str] = []
        # Variable binding tracker for strict mode validation
        self._variable_bindings: dict = {} if strict else None

    @property
    def executor(self):
        return self._executors[-1]

    # ------------------------------------------------------------------ #
    # Variable Binding Validation (Strict Mode)                           #
    # ------------------------------------------------------------------ #

    def _validate_and_bind_variable(self, var_name: str, var_type: str,
                                     labels: set = None, properties: dict = None):
        """Validate and register a variable binding in strict mode.

        Args:
            var_name: Variable name
            var_type: 'node', 'relationship', or 'path'
            labels: Set of labels (for nodes)
            properties: Inline properties (for validation)

        Raises:
            VariableAlreadyBound: If variable reused with different labels/properties
            VariableTypeConflict: If variable type changes (node → relationship)
        """
        if not self._strict or self._variable_bindings is None:
            return

        from nxcypher import VariableAlreadyBound, VariableTypeConflict

        if var_name in self._variable_bindings:
            existing = self._variable_bindings[var_name]

            # Check type conflict (node vs relationship vs path)
            if existing['type'] != var_type:
                raise VariableTypeConflict(
                    f"Variable `{var_name}` already bound as {existing['type']}, "
                    f"cannot rebind as {var_type}"
                )

            # Check label conflict (for nodes)
            if var_type == 'node' and labels and existing.get('labels'):
                if labels != existing['labels']:
                    raise VariableAlreadyBound(
                        f"Variable `{var_name}` already bound with labels "
                        f"{existing['labels']}, cannot rebind with {labels}"
                    )

            # Check property conflict
            if properties and existing.get('properties'):
                # If both have properties, they should match
                if properties != existing['properties']:
                    raise VariableAlreadyBound(
                        f"Variable `{var_name}` already bound with different properties"
                    )
        else:
            # First binding - register it
            self._variable_bindings[var_name] = {
                'type': var_type,
                'labels': labels,
                'properties': properties
            }

    def _infer_expression_type(self, expr: Tree) -> Optional[str]:
        """Infer the type of an expression for variable binding validation.

        Returns:
            'node', 'relationship', 'path' for graph elements
            'value' for literals and non-graph expressions
            None if the type cannot be determined (e.g., simple variable reference)
        """
        if not isinstance(expr, Tree):
            # Token - likely a literal value
            return 'value'

        data = expr.data

        # Literal values
        if data in ("literal", "bool_lit", "num_lit", "string_lit", "char_lit",
                    "list_lit", "map_lit", "null_lit"):
            return 'value'

        # Check for simple variable reference (inherit type from existing binding)
        if data == "symbol":
            var_name = _symbol_to_str(expr)
            if self._variable_bindings and var_name in self._variable_bindings:
                return self._variable_bindings[var_name]['type']
            return None  # Unknown variable, don't track

        # Expression containers - check inner expression
        if data in ("expression", "or_expression", "xor_expression", "and_expression",
                    "not_expression", "comparison_expression", "add_sub_expression",
                    "mult_div_expression", "power_expression", "unary_expression",
                    "unary_add_sub_expression", "atomic_expression",
                    "property_or_label_expression", "property_expression",
                    "parenthesized_expression"):
            # Check if this is a simple symbol wrapper
            for c in expr.children:
                if isinstance(c, Tree):
                    if c.data == "symbol":
                        var_name = _symbol_to_str(c)
                        if self._variable_bindings and var_name in self._variable_bindings:
                            return self._variable_bindings[var_name]['type']
                    elif c.data not in ("LPAREN", "RPAREN"):
                        # Recurse into nested expression
                        result = self._infer_expression_type(c)
                        if result and result != 'value':
                            # Propagate non-value types (node, relationship, path)
                            return result
                        elif result == 'value':
                            # Keep checking other children - might find a graph element
                            continue
            # If we only found 'value' types or nothing, check if it's a single child
            # that might be a variable reference
            if len(expr.children) == 1:
                child = expr.children[0]
                if isinstance(child, Tree):
                    result = self._infer_expression_type(child)
                    if result:
                        return result
            # Complex expression - assume value
            return 'value'

        # Atom types
        if data == "atom":
            for c in expr.children:
                if isinstance(c, Tree):
                    return self._infer_expression_type(c)
                elif isinstance(c, Token):
                    # Literal token
                    return 'value'

        # Case expression returns value
        if data == "case_expression":
            return 'value'

        # List/map comprehension returns value
        if data in ("list_comprehension", "pattern_comprehension"):
            return 'value'

        # Function invocations - most return values
        if data == "function_invocation":
            return 'value'

        # Aggregations return values
        if data in ("count_all",):
            return 'value'

        return None  # Can't determine type

    # ------------------------------------------------------------------ #
    # Main entry                                                          #
    # ------------------------------------------------------------------ #

    def interpret(self, tree: Tree, hints=None):
        if hints:
            self.executor.set_hints(hints)
        self._visit(tree)

    def _visit(self, node):
        if not isinstance(node, Tree):
            return node
        handler = getattr(self, f"_handle_{node.data}", None)
        if handler:
            return handler(node)
        # Default: visit children in order
        for child in node.children:
            if isinstance(child, Tree):
                self._visit(child)

    # ------------------------------------------------------------------ #
    # Step 1: Top-level navigation                                        #
    # ------------------------------------------------------------------ #

    def _handle_start(self, node: Tree):
        self._visit(node.children[0])

    def _handle_script(self, node: Tree):
        for c in node.children:
            if isinstance(c, Tree):
                self._visit(c)

    def _handle_statement_list(self, node: Tree):
        for c in node.children:
            if isinstance(c, Tree) and c.data == "statement":
                self._visit(c)

    def _handle_statement(self, node: Tree):
        for c in node.children:
            if isinstance(c, Tree):
                self._visit(c)

    def _handle_empty_input(self, node: Tree):
        pass

    def _handle_query(self, node: Tree):
        for c in node.children:
            if isinstance(c, Tree):
                self._visit(c)

    def _handle_regular_query(self, node: Tree):
        # First child is single_query, rest are union_st
        children = node.children
        if children:
            self._visit(children[0])  # single_query
        for c in children[1:]:
            if isinstance(c, Tree) and c.data == "union_st":
                self._handle_union_st(c)

    def _handle_single_query(self, node: Tree):
        self._visit(node.children[0])

    def _handle_multi_part_q(self, node: Tree):
        # ((reading_statement | updating_statement)* with_st)+ single_part_q
        for child in node.children:
            if isinstance(child, Tree):
                self._visit(child)

    def _handle_single_part_q(self, node: Tree):
        for child in node.children:
            if isinstance(child, Tree):
                self._visit(child)

    def _handle_reading_statement(self, node: Tree):
        for child in node.children:
            if isinstance(child, Tree):
                self._visit(child)

    def _handle_updating_statement(self, node: Tree):
        for child in node.children:
            if isinstance(child, Tree):
                self._visit(child)

    # ------------------------------------------------------------------ #
    # Step 2: MATCH / CREATE / MERGE pattern extraction                   #
    # ------------------------------------------------------------------ #

    def _handle_match_st(self, node: Tree):
        """Handle MATCH or OPTIONAL MATCH."""
        is_optional = _has_token(node, "OPTIONAL")
        pattern_where = _find_child(node, "pattern_where")
        if pattern_where is None:
            return

        pattern = _find_child(pattern_where, "pattern")
        where = _find_child(pattern_where, "where")

        if pattern is None:
            return

        # Extract pattern parts -> node/edge triples
        parts = self._extract_pattern(pattern)

        if is_optional:
            # Mark executor as optional for proper null-row handling
            self.executor._is_optional = True
            self._apply_optional_match(parts)
            # WHERE clause for OPTIONAL MATCH goes into the optional pattern
            if where is not None:
                for c in where.children:
                    if isinstance(c, Tree):
                        # Store WHERE in the last optional pattern
                        if self.executor._optional_patterns:
                            self.executor._optional_patterns[-1]["where"] = c
                        break
        else:
            self._apply_match(parts)
            # WHERE clause for regular MATCH goes into executor._where_expression
            if where is not None:
                for c in where.children:
                    if isinstance(c, Tree):
                        self.executor._where_expression = c
                        break

    def _apply_match(self, parts: list):
        """Feed extracted pattern into executor's motif."""
        from nxcypher import NXCypherExecutor

        executor = self.executor

        if self._match_clause_count == len(self._executors):
            sub = NXCypherExecutor(self._target_graph, self._limit, strict=self._strict)
            sub._parent_executor = executor
            sub._level = executor._level + 1
            # Inherit _with_results from parent (for MATCH after WITH)
            if executor._with_results is not None:
                sub._with_results = executor._with_results
            executor._child_executors.append(sub)
            self._executors.append(sub)
            executor = self.executor

        for part in parts:
            path_var, nodes, edges = part
            # Store path variable if present
            if path_var:
                executor._paths.append(path_var)
            if len(nodes) == 1 and len(edges) == 0:
                # Lone node
                alias, labels, props = nodes[0]
                alias = alias or f"_anon_{_shortuuid()}"
                executor._motif.add_node(alias, __labels__=labels, **props)
            else:
                # Chain: n0 -e0-> n1 -e1-> n2 ...
                # Pre-compute aliases for all nodes in the chain.
                # This ensures anonymous nodes get a consistent alias and
                # named nodes (like cyclic references) reuse the same alias.
                node_aliases = []
                for n_info in nodes:
                    node_aliases.append(n_info[0] or f"_anon_{_shortuuid()}")

                for i, edge_info in enumerate(edges):
                    n0 = nodes[i]
                    n1 = nodes[i + 1]
                    a0 = node_aliases[i]
                    a1 = node_aliases[i + 1]

                    e_alias, e_types, direction, minh, maxh, e_props = edge_info

                    if direction == "r":
                        edge_pairs = ((a0, a1),)
                        is_bidirectional = False
                    elif direction == "l":
                        edge_pairs = ((a1, a0),)
                        is_bidirectional = False
                    else:  # "b" — bidirectional
                        # Use a single edge with __bidirectional__=True
                        # The matching engine will try both directions
                        edge_pairs = ((a0, a1),)
                        is_bidirectional = True

                    if e_alias:
                        executor._return_edges[e_alias] = edge_pairs[0]

                    ish = minh is None and maxh is None
                    minh = minh if minh is not None else 1
                    maxh = maxh if maxh is not None else minh + 1
                    if maxh > executor._max_hop:
                        raise ValueError(f"max hop is capped at 100, found {maxh}!")
                    e_types_set = set(e_types) if e_types else None
                    executor._motif.add_edges_from(
                        edge_pairs,
                        __min_hop__=minh, __max_hop__=maxh,
                        __is_hop__=ish, __labels__=e_types_set,
                        __bidirectional__=is_bidirectional,
                        **e_props,
                    )
                    executor._motif.add_node(a0, __labels__=n0[1], **n0[2])
                    executor._motif.add_node(a1, __labels__=n1[1], **n1[2])

        self._match_clause_count += 1

    def _apply_optional_match(self, parts: list):
        """Handle OPTIONAL MATCH with left outer join semantics."""
        executor = self.executor
        # Get existing nodes from the motif (anchor nodes, not optional)
        existing_nodes = set(executor._motif.nodes())

        for part in parts:
            path_var, nodes, edges = part

            if path_var:
                executor._paths.append(path_var)

            optional_pattern = {"nodes": set(), "edges": [], "anchor_node": None}

            if len(nodes) == 1 and len(edges) == 0:
                alias, labels, props = nodes[0]
                alias = alias or f"_anon_{_shortuuid()}"
                optional_pattern["nodes"].add(alias)
                executor._motif.add_node(alias, __labels__=labels, **props)
                if alias not in existing_nodes:
                    executor._optional_nodes.add(alias)
                executor._optional_patterns.append(optional_pattern)
            else:
                # Pre-compute aliases for all nodes in the chain
                node_aliases = []
                for n_info in nodes:
                    node_aliases.append(n_info[0] or f"_anon_{_shortuuid()}")

                for i, edge_info in enumerate(edges):
                    n0 = nodes[i]
                    n1 = nodes[i + 1]
                    a0 = node_aliases[i]
                    a1 = node_aliases[i + 1]
                    e_alias, e_types, direction, minh, maxh, e_props = edge_info

                    if direction == "r":
                        edge_pairs = ((a0, a1),)
                        is_bidirectional = False
                    elif direction == "l":
                        edge_pairs = ((a1, a0),)
                        is_bidirectional = False
                    else:
                        # Use a single edge with __bidirectional__=True
                        edge_pairs = ((a0, a1),)
                        is_bidirectional = True

                    if e_alias:
                        executor._return_edges[e_alias] = edge_pairs[0]

                    ish = minh is None and maxh is None
                    minh = minh if minh is not None else 1
                    maxh = maxh if maxh is not None else minh + 1
                    e_types_set = set(e_types) if e_types else None
                    executor._motif.add_edges_from(
                        edge_pairs,
                        __min_hop__=minh, __max_hop__=maxh,
                        __is_hop__=ish, __labels__=e_types_set,
                        __optional__=True, __bidirectional__=is_bidirectional,
                        **e_props,
                    )
                    executor._motif.add_node(a0, __labels__=n0[1], **n0[2])
                    executor._motif.add_node(a1, __labels__=n1[1], **n1[2])

                    optional_pattern["nodes"].update([a0, a1])
                    edge_spec = {
                        "u": a0, "v": a1, "edges": edge_pairs,
                        "labels": e_types_set, "vt": n1[1], "vjs": n1[2],
                        "bidirectional": is_bidirectional,
                    }
                    optional_pattern["edges"].append(edge_spec)

                    if a0 not in existing_nodes:
                        executor._optional_nodes.add(a0)
                    if a1 not in existing_nodes:
                        executor._optional_nodes.add(a1)

                    for edge_pair in edge_pairs:
                        executor._optional_edges.add(edge_pair)

                executor._optional_patterns.append(optional_pattern)

    def _extract_pattern(self, pattern: Tree) -> list:
        """Extract pattern_part list from a `pattern` tree.

        Returns list of (nodes, edges) tuples where:
          nodes = [(alias, labels_set, props_dict), ...]
          edges = [(alias, types, direction, min_hop, max_hop, props_dict), ...]
        """
        results = []
        for child in pattern.children:
            if isinstance(child, Tree) and child.data == "pattern_part":
                results.append(self._extract_pattern_part(child))
        return results

    def _extract_pattern_part(self, part: Tree) -> tuple:
        """Extract a single pattern_part.

        Grammar: pattern_part: (symbol ASSIGN)? pattern_elem
        """
        path_var = None
        pattern_elem = None
        for c in part.children:
            if isinstance(c, Tree):
                if c.data == "symbol":
                    path_var = _symbol_to_str(c)
                elif c.data == "pattern_elem":
                    pattern_elem = c
        if pattern_elem is None:
            # Might be directly inside
            pattern_elem = part

        # Validate path variable binding in strict mode
        if path_var:
            self._validate_and_bind_variable(path_var, 'path')

        nodes, edges = self._extract_pattern_elem(pattern_elem)
        return (path_var, nodes, edges)

    def _extract_pattern_elem(self, elem: Tree) -> tuple:
        """Extract nodes and edges from a pattern_elem.

        Grammar: pattern_elem: node_pattern pattern_elem_chain* | LPAREN pattern_elem RPAREN | shortest_path_pattern
        """
        nodes = []
        edges = []

        for child in elem.children:
            if isinstance(child, Tree):
                if child.data == "node_pattern":
                    nodes.append(self._extract_node_pattern(child))
                elif child.data == "pattern_elem_chain":
                    e, n = self._extract_pattern_elem_chain(child)
                    edges.append(e)
                    nodes.append(n)
                elif child.data == "pattern_elem":
                    # Wrapped in parens
                    return self._extract_pattern_elem(child)
                elif child.data == "shortest_path_pattern":
                    # TODO: handle shortest path
                    return self._extract_pattern_elem(child.children[-1])

        return (nodes, edges)

    def _extract_pattern_elem_chain(self, chain: Tree) -> tuple:
        """Extract one edge + node from pattern_elem_chain.

        Grammar: pattern_elem_chain: relationship_pattern node_pattern
        """
        edge_info = None
        node_info = None
        for c in chain.children:
            if isinstance(c, Tree):
                if c.data == "relationship_pattern":
                    edge_info = self._extract_relationship_pattern(c)
                elif c.data == "node_pattern":
                    node_info = self._extract_node_pattern(c)
        return (edge_info, node_info)

    def _extract_node_pattern(self, node: Tree) -> tuple:
        """Extract (alias, labels_set, properties_dict) from node_pattern.

        Grammar: node_pattern: LPAREN symbol? node_labels? properties? RPAREN
        """
        alias = None
        labels = set()
        props = {}

        for child in node.children:
            if isinstance(child, Tree):
                if child.data == "symbol":
                    alias = _symbol_to_str(child)
                elif child.data == "node_labels":
                    labels = self._extract_node_labels(child)
                elif child.data == "properties":
                    props = self._extract_properties(child)
            elif isinstance(child, Token):
                if child.type not in ("LPAREN", "RPAREN"):
                    # Could be a symbol token directly
                    pass

        # Validate variable binding in strict mode
        if alias:
            self._validate_and_bind_variable(
                alias, 'node',
                labels=labels if labels else None,
                properties=props if props else None
            )

        return (alias, labels, props)

    def _extract_node_labels(self, labels_node: Tree) -> set:
        """Extract labels from node_labels: (COLON name)+"""
        result = set()
        for child in labels_node.children:
            if isinstance(child, Tree) and child.data == "name":
                result.add(_name_to_str(child))
            elif isinstance(child, Token) and child.type not in ("COLON",):
                result.add(str(child))
        return result

    def _extract_relationship_pattern(self, rel: Tree) -> tuple:
        """Extract (alias, types, direction, min_hop, max_hop, props) from relationship_pattern.

        Grammar: relationship_pattern: LT SUB relation_detail? SUB GT? | SUB relation_detail? SUB GT?
        """
        has_lt = False
        has_gt = False
        alias = None
        types = []
        min_hop = None
        max_hop = None
        props = {}

        for child in rel.children:
            if isinstance(child, Token):
                if child.type == "LT":
                    has_lt = True
                elif child.type == "GT":
                    has_gt = True
            elif isinstance(child, Tree) and child.data == "relation_detail":
                alias, types, min_hop, max_hop, props = self._extract_relation_detail(child)

        # Determine direction
        if has_lt and not has_gt:
            direction = "l"
        elif has_gt and not has_lt:
            direction = "r"
        elif has_lt and has_gt:
            direction = "b"
        else:
            direction = "b"  # undirected: -[]-

        # Validate variable binding in strict mode
        if alias:
            self._validate_and_bind_variable(
                alias, 'relationship',
                labels=None,
                properties=props if props else None
            )

        return (alias, types, direction, min_hop, max_hop, props)

    def _extract_relation_detail(self, detail: Tree) -> tuple:
        """Extract from relation_detail: LBRACK symbol? relationship_types? range_lit? properties? RBRACK"""
        alias = None
        types = []
        min_hop = None
        max_hop = None
        props = {}

        for child in detail.children:
            if isinstance(child, Tree):
                if child.data == "symbol":
                    alias = _symbol_to_str(child)
                elif child.data == "relationship_types":
                    types = self._extract_relationship_types(child)
                elif child.data == "range_lit":
                    min_hop, max_hop = self._extract_range_lit(child)
                elif child.data == "properties":
                    props = self._extract_properties(child)

        return (alias, types, min_hop, max_hop, props)

    def _extract_relationship_types(self, types_node: Tree) -> list:
        """Extract from relationship_types: COLON name (STICK COLON? name)*"""
        result = []
        for child in types_node.children:
            if isinstance(child, Tree) and child.data == "name":
                result.append(_name_to_str(child))
        return result

    def _extract_range_lit(self, range_node: Tree) -> tuple:
        """Extract (min_hop, max_hop) from range_lit: MULT num_lit? (RANGE num_lit?)?"""
        nums = []
        has_range = False
        for child in range_node.children:
            if isinstance(child, Tree) and child.data == "num_lit":
                nums.append(self._num_lit_to_int(child))
            elif isinstance(child, Token) and child.type == "RANGE":
                has_range = True

        if has_range:
            # In Cypher, *.. means 1 or more (not 0 or more)
            # *0.. means 0 or more, *2.. means 2 or more
            min_hop = nums[0] if len(nums) >= 1 else 1
            max_hop = (nums[1] + 1) if len(nums) >= 2 else None  # Cypher range is inclusive
            return (min_hop, max_hop)
        elif len(nums) == 1:
            # Exact hop count: [*2] means exactly 2 hops
            return (nums[0], nums[0] + 1)
        else:
            # Just [*] — variable length, any number of hops
            return (1, None)

    def _num_lit_to_int(self, node: Tree) -> int:
        """Convert a num_lit tree to an integer."""
        for child in node.children:
            if isinstance(child, Token) and child.type == "DIGIT":
                return int(str(child))
        return 0

    def _extract_properties(self, props_node: Tree) -> dict:
        """Extract properties from properties: map_lit | parameter | legacy_parameter"""
        for child in props_node.children:
            if isinstance(child, Tree):
                if child.data == "map_lit":
                    return self._extract_map_lit(child)
                elif child.data in ("parameter", "legacy_parameter"):
                    # Parameters are not allowed in node/relationship patterns
                    if self._strict:
                        from nxcypher import InvalidParameterUse
                        raise InvalidParameterUse(
                            "Parameters are not allowed as node pattern predicates. "
                            "Use properties with explicit keys instead."
                        )
        return {}

    def _extract_map_lit(self, map_node: Tree) -> dict:
        """Extract dict from map_lit: LBRACE (map_pair (COMMA map_pair)*)? RBRACE"""
        result = {}
        for child in map_node.children:
            if isinstance(child, Tree) and child.data == "map_pair":
                key, value = self._extract_map_pair(child)
                result[key] = value
        return result

    def _extract_map_pair(self, pair: Tree) -> tuple:
        """Extract (key, value) from map_pair: name COLON expression"""
        key = None
        value = None
        for child in pair.children:
            if isinstance(child, Tree):
                if child.data == "name":
                    key = _name_to_str(child)
                else:
                    # expression — evaluate as literal for pattern properties
                    value = self._eval_literal_expression(child)
        return (key, value)

    def _eval_literal_expression(self, expr) -> Any:
        """Evaluate a simple literal expression (for pattern properties).

        For non-trivial expressions, returns the tree for later evaluation.
        """
        if isinstance(expr, Token):
            return self._token_to_value(expr)

        if not isinstance(expr, Tree):
            return expr

        data = expr.data
        children = expr.children

        # Single-child wrappers — unwrap
        if len(children) == 1:
            if data in ("expression", "xor_expression", "and_expression",
                        "not_expression", "comparison_expression",
                        "add_sub_expression", "mult_div_expression",
                        "power_expression", "unary_add_sub_expression",
                        "atomic_expression", "property_or_label_expression",
                        "property_expression", "atom", "literal",
                        "parenthesized_expression"):
                return self._eval_literal_expression(children[0])

        if data == "num_lit":
            return self._parse_num_lit(expr)

        if data == "string_lit":
            return self._parse_string_lit(expr)

        if data == "char_lit":
            return self._parse_string_lit(expr)

        if data == "bool_lit":
            tok = children[0]
            return str(tok).upper() == "TRUE"

        if data == "list_lit":
            ec = _find_child(expr, "expression_chain")
            if ec is None:
                return []
            return [self._eval_literal_expression(c) for c in ec.children
                    if isinstance(c, Tree)]

        if data == "map_lit":
            return self._extract_map_lit(expr)

        # Handle unary negation: unary_add_sub_expression with 2 children (-, expr)
        if data == "unary_add_sub_expression" and len(children) == 2:
            op = children[0]
            val = self._eval_literal_expression(children[1])
            if isinstance(op, Token):
                if op.type == "SUB":
                    if isinstance(val, (int, float)):
                        return -val
                    # Can't negate a non-number literal, return tree for later eval
                    return expr
                elif op.type == "PLUS":
                    return val
            return val

        # NULL
        for c in children:
            if isinstance(c, Token) and c.type == "NULL_W":
                return None

        # Check if this is an expression that looks like scientific notation (e.g., 2E - 01 -> 2E-01)
        # This happens when the lexer/parser splits "2E-01" into "2E" (ID) "-" (SUB) "01" (DIGIT)
        if data == "add_sub_expression":
            sci_lit = self._try_parse_scientific_notation_expr(expr)
            if sci_lit is not None:
                return sci_lit

        # Handle function invocations for temporal functions in property values
        if data == "function_invocation":
            func_name_node = _find_child(expr, "invocation_name")
            if func_name_node is not None:
                func_name = self._invocation_name_to_str(func_name_node).lower()
                if func_name in ("date", "localtime", "time", "datetime", "localdatetime", "duration"):
                    # Evaluate the temporal function
                    from nxcypher import EvalContext
                    from nxcypher.temporal import (
                        cypher_date, cypher_localtime, cypher_time,
                        cypher_datetime, cypher_localdatetime, cypher_duration
                    )
                    # Extract the argument (map literal)
                    ec = _find_child(expr, "expression_chain")
                    if ec is not None and ec.children:
                        arg_expr = ec.children[0]
                        arg = self._eval_literal_expression(arg_expr)
                        if func_name == "date":
                            return cypher_date(arg)
                        elif func_name == "localtime":
                            return cypher_localtime(arg)
                        elif func_name == "time":
                            return cypher_time(arg)
                        elif func_name == "datetime":
                            return cypher_datetime(arg)
                        elif func_name == "localdatetime":
                            return cypher_localdatetime(arg)
                        elif func_name == "duration":
                            return cypher_duration(arg)
                    else:
                        # No argument - call with no args to get current time/date
                        if func_name == "date":
                            return cypher_date()
                        elif func_name == "localtime":
                            return cypher_localtime()
                        elif func_name == "time":
                            return cypher_time()
                        elif func_name == "datetime":
                            return cypher_datetime()
                        elif func_name == "localdatetime":
                            return cypher_localdatetime()
                        elif func_name == "duration":
                            return cypher_duration()

        # For complex expressions in pattern properties, return None
        # (they'll be evaluated at runtime if needed)
        return expr

    def _parse_num_lit(self, node: Tree) -> int | float:
        """Parse a num_lit tree into a Python number."""
        tok = node.children[0] if node.children else None
        if tok is None:
            return 0
        s = str(tok)
        return self._parse_number_string(s)

    def _parse_number_string(self, s: str) -> int | float:
        """Parse a number string (handling hex, octal, float)."""
        from nxcypher import IntegerOverflow
        import re

        # Integer bounds for 64-bit signed integer (Java Long)
        MAX_INT = 9223372036854775807   # 2^63 - 1
        MIN_INT = -9223372036854775808  # -2^63

        def _check_overflow(val: int, literal: str) -> int:
            if self._strict and (val > MAX_INT or val < MIN_INT):
                raise IntegerOverflow(f"Integer literal {literal} out of range")
            return val

        # Handle hex
        if s.startswith("0x") or s.startswith("0X"):
            return _check_overflow(int(s, 16), s)
        if s.startswith("-0x") or s.startswith("-0X"):
            return _check_overflow(-int(s[1:], 16), s)
        # Handle modern octal (0o prefix)
        if s.startswith("0o") or s.startswith("0O"):
            return _check_overflow(int(s, 8), s)
        if s.startswith("-0o") or s.startswith("-0O"):
            return _check_overflow(-int(s[1:], 8), s)
        # Handle legacy octal (leading zero with only octal digits)
        if re.match(r'^-?0[0-7]+$', s):
            val = int(s, 8) if not s.startswith("-") else -int(s[1:], 8)
            return _check_overflow(val, s)
        # Handle float
        if "." in s or "e" in s.lower():
            import math
            from nxcypher import FloatingPointOverflow
            result = float(s)
            if self._strict and math.isinf(result):
                raise FloatingPointOverflow(f"Floating point literal {s} overflows")
            return result
        return _check_overflow(int(s), s)

    def _parse_string_lit(self, node: Tree) -> str:
        """Parse a string_lit or char_lit tree, unquoting the value."""
        tok = node.children[0] if node.children else None
        if tok is None:
            return ""
        s = str(tok)
        # Remove surrounding quotes
        if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
            s = s[1:-1]
        # Process Cypher escape sequences including Unicode
        return _process_cypher_escapes(s, strict=self._strict)

    def _try_parse_scientific_notation_expr(self, expr: Tree) -> float | None:
        """Try to parse an expression tree as scientific notation.

        Similar to _try_parse_scientific_notation but returns just the value, not the key.
        Used in _eval_literal_expression for list/map elements.
        """
        if not isinstance(expr, Tree) or expr.data != "add_sub_expression":
            return None

        if len(expr.children) < 3:
            return None

        left = expr.children[0]
        op = expr.children[1]
        right = expr.children[2]

        if not isinstance(op, Token) or op.type != "SUB":
            return None

        # Try to extract the left side
        left_core = self._unwrap_v4_expression(left)
        left_str = None
        if isinstance(left_core, str):
            left_str = left_core
        elif isinstance(left_core, Token) and left_core.type == "ID":
            left_str = str(left_core)

        if left_str and len(left_str) >= 2 and left_str[-1].lower() == 'e':
            mantissa = left_str[:-1]
            try:
                float(mantissa)
            except (ValueError, OverflowError):
                return None

            # Extract right side value
            right_core = self._unwrap_v4_expression(right)
            right_value = None
            if isinstance(right_core, Tree) and right_core.data == "num_lit":
                right_value = self._parse_num_lit(right_core)
            elif isinstance(right_core, Token) and right_core.type == "DIGIT":
                right_value = self._parse_number_string(str(right_core))
            elif isinstance(right_core, (int, float)):
                right_value = right_core

            if right_value is not None:
                try:
                    combined = f"{left_str}-{int(right_value)}" if isinstance(right_value, float) and right_value == int(right_value) else f"{left_str}-{right_value}"
                    return float(combined)
                except (ValueError, OverflowError):
                    return None

        return None

    def _try_parse_scientific_notation(self, core) -> tuple[float, str] | None:
        """Try to parse an expression as scientific notation (e.g., 1e - 5 -> 1e-5).

        Returns (value, key_str) if it's scientific notation, None otherwise.
        """
        if not isinstance(core, Tree) or core.data != "add_sub_expression":
            return None

        # add_sub_expression has at least 3 children: expr SUB expr
        if len(core.children) < 3:
            return None

        # Check if pattern is: DIGIT SUB DIGIT (where DIGIT looks like mantissa + exponent marker)
        left = core.children[0]
        op = core.children[1]
        right = core.children[2]

        # Op must be SUB
        if not isinstance(op, Token) or op.type != "SUB":
            return None

        # Try to extract the left side as a number that looks like exponent notation
        left_core = self._unwrap_v4_expression(left)

        # left_core can be a string (from symbol), Token (ID), or other
        left_str = None
        if isinstance(left_core, str):
            left_str = left_core
        elif isinstance(left_core, Token) and left_core.type == "ID":
            left_str = str(left_core)

        if left_str:
            # Check if it looks like mantissa + e (e.g., "1e", "2E")
            if len(left_str) >= 2 and left_str[-1].lower() == 'e':
                mantissa = left_str[:-1]
                # Check if mantissa is a valid number
                try:
                    float(mantissa)
                except (ValueError, OverflowError):
                    return None

                # Try to extract right side as a number
                right_core = self._unwrap_v4_expression(right)

                # right_core should be a num_lit Tree (unwrapped from literal->num_lit)
                right_value = None
                if isinstance(right_core, Tree) and right_core.data == "num_lit":
                    right_value = self._parse_num_lit(right_core)
                elif isinstance(right_core, Token) and right_core.type == "DIGIT":
                    right_value = self._parse_number_string(str(right_core))
                elif isinstance(right_core, (int, float)):
                    right_value = right_core

                if right_value is not None:
                    try:
                        # Reconstruct as scientific notation
                        combined = f"{left_str}-{int(right_value)}" if isinstance(right_value, float) and right_value == int(right_value) else f"{left_str}-{right_value}"
                        value = float(combined)
                        return (value, combined)
                    except (ValueError, OverflowError):
                        return None

        return None

    def _token_to_value(self, tok: Token) -> Any:
        """Convert a Token to a Python value."""
        if tok.type == "DIGIT":
            s = str(tok)
            if "." in s or "e" in s.lower():
                return float(s)
            return int(s)
        if tok.type in ("STRING_LITERAL", "CHAR_LITERAL"):
            return self._parse_string_lit(Tree("string_lit", [tok]))
        if tok.type == "TRUE":
            return True
        if tok.type == "FALSE":
            return False
        if tok.type == "NULL_W":
            return None
        return str(tok)

    # ------------------------------------------------------------------ #
    # Step 3: RETURN / WITH / UNWIND / ORDER BY                           #
    # ------------------------------------------------------------------ #

    def _handle_return_st(self, node: Tree):
        """Handle RETURN projection_body."""
        pb = _find_child(node, "projection_body")
        if pb is None:
            return
        self._process_projection_body(pb, is_with=False)

    def _handle_with_st(self, node: Tree):
        """Handle WITH projection_body where?"""
        pb = _find_child(node, "projection_body")
        where = _find_child(node, "where")
        if pb is None:
            return
        self._process_with(pb, where)

    def _process_projection_body(self, pb: Tree, is_with: bool = False):
        """Process projection_body: DISTINCT? projection_items order_st? skip_st? limit_st?"""
        executor = self.executor

        # Check DISTINCT
        if _has_token(pb, "DISTINCT"):
            executor._distinct = True

        # Process projection_items
        items = _find_child(pb, "projection_items")
        if items is not None:
            self._process_projection_items(items, executor)

        # ORDER BY
        order = _find_child(pb, "order_st")
        if order is not None:
            self._process_order_st(order, executor)

        # SKIP
        skip = _find_child(pb, "skip_st")
        if skip is not None:
            for c in skip.children:
                if isinstance(c, Tree):
                    val = self._eval_literal_expression(c)
                    if isinstance(val, (int, float)):
                        skip_val = int(val)
                        executor._validate_integer(skip_val, "SKIP", allow_negative=False)
                        executor._skip = skip_val
                    break

        # LIMIT
        limit = _find_child(pb, "limit_st")
        if limit is not None:
            for c in limit.children:
                if isinstance(c, Tree):
                    val = self._eval_literal_expression(c)
                    if isinstance(val, (int, float)):
                        limit_val = int(val)
                        executor._validate_integer(limit_val, "LIMIT", allow_negative=False)
                        executor._limit = limit_val
                    break

        executor._alias2entity.update({v: k for k, v in executor._entity2alias.items()})

    def _process_projection_items(self, items: Tree, executor):
        """Process projection_items: (MULT | projection_item) (COMMA projection_item)*"""
        # Handle RETURN *
        if _has_token(items, "MULT"):
            # Return all matched variables
            for node_name in executor._motif.nodes():
                executor._return_requests.append(node_name)
            if executor._with_results:
                for key in executor._with_results:
                    if key not in executor._return_requests:
                        executor._return_requests.append(key)
            return

        for child in items.children:
            if isinstance(child, Tree) and child.data == "projection_item":
                self._process_projection_item(child, executor)

    def _process_projection_item(self, item: Tree, executor):
        """Process a single projection_item: expression (AS symbol)?"""
        expr = None
        alias = None

        for child in item.children:
            if isinstance(child, Tree):
                if child.data == "symbol":
                    alias = _symbol_to_str(child)
                elif expr is None:
                    expr = child  # First non-symbol tree is the expression

        if expr is None:
            return

        self._register_return_expression(executor, expr, alias)

    def _register_return_expression(self, executor, expr: Tree, alias: str | None):
        """Classify an expression and register it with the executor.

        Handles: simple variables, property access, aggregations, complex expressions.
        """
        # Unwrap to find the core
        core = self._unwrap_v4_expression(expr)

        # Check for aggregation
        agg = self._detect_v4_aggregation(core)
        if agg is not None:
            func, entity, is_distinct, entity_tree, func_original, percentile_tree = agg
            # Use original case function name for display key
            key = executor._format_aggregation_key(func_original, entity, is_distinct)
            if alias:
                executor._entity2alias[key] = alias
            if entity != "*":
                # For percentile functions, entity may include "|percentile_value" suffix
                # Use base entity for data lookup
                base_entity = entity.split('|')[0] if '|' in entity else entity
                executor._aggregation_attributes.add(base_entity)
                # Store expression tree for complex arguments (lists, maps, etc.)
                if entity_tree is not None:
                    executor._expression_trees[base_entity] = entity_tree
            # Store percentile expression tree for runtime evaluation
            if percentile_tree is not None:
                executor._percentile_expressions[key] = percentile_tree
            # Store uppercase func for internal use, but key uses original case
            executor._aggregate_functions.append((func, entity, is_distinct, key))
            return

        # Check if expression contains embedded aggregations
        if self._contains_v4_aggregation(expr):
            # Extract all aggregations from the tree
            aggregations = self._extract_v4_aggregations_from_tree(expr)

            if aggregations:
                # Register each aggregation and create placeholders
                agg_map = {}  # Map node id -> placeholder
                for idx, (agg_subtree, func, entity, is_distinct, func_original, percentile_tree) in enumerate(aggregations):
                    placeholder = f"__AGG_{idx}__"
                    agg_map[id(agg_subtree)] = placeholder

                    # Use original case for display key
                    agg_key = executor._format_aggregation_key(func_original, entity, is_distinct)

                    # Register in _aggregate_functions with display key
                    executor._aggregate_functions.append((func, entity, is_distinct, agg_key))

                    # Map placeholder to aggregation key for later lookup
                    executor._aggregation_placeholders[placeholder] = agg_key

                    # Store percentile expression tree for runtime evaluation
                    if percentile_tree is not None:
                        executor._percentile_expressions[agg_key] = percentile_tree

                    if entity != "*":
                        # For percentile functions, entity may include "|percentile_value" suffix
                        # Use base entity for data lookup
                        base_entity = entity.split('|')[0] if '|' in entity else entity
                        executor._aggregation_attributes.add(base_entity)

                # Rewrite expression tree with placeholders
                rewritten_tree = self._replace_v4_aggregations_with_placeholders(expr, agg_map)

                # Use expression text as key (for TCK compliance)
                expr_text = _tree_to_expression_text(expr)

                # Store as post-aggregation expression
                executor._post_aggregation_expressions[expr_text] = rewritten_tree
                executor._original_return_requests.add(expr_text)
                if alias:
                    executor._entity2alias[expr_text] = alias
                executor._return_requests.append(expr_text)
                return

        # Check if this is a scientific notation number parsed as subtraction (e.g., 1e-5 -> 1e - 5)
        scientific_lit = self._try_parse_scientific_notation(core)
        if scientific_lit is not None:
            lit_value, lit_str = scientific_lit
            executor._literal_returns[lit_str] = lit_value
            executor._original_return_requests.add(lit_str)
            if alias:
                executor._entity2alias[lit_str] = alias
            executor._return_requests.append(lit_str)
            return

        # Check for simple key
        simple = self._v4_expr_to_simple_key(core)
        if simple is not None:
            # Check if it's a literal
            lit = self._v4_expr_literal_value(core)
            # Handle NULL_W token specially - lit is None but we want to store it
            is_null_literal = isinstance(core, Token) and core.type == "NULL_W"
            if lit is not None or is_null_literal:
                executor._literal_returns[simple] = lit  # lit is None for null literal
            # Store expression tree for function invocations or other evaluable expressions
            if isinstance(core, Tree) and core.data in ("atomic_expression", "function_invocation"):
                executor._expression_trees[simple] = core
            executor._original_return_requests.add(simple)
            if alias:
                executor._entity2alias[simple] = alias
            executor._return_requests.append(simple)
            return

        # Complex expression — store tree
        # Use expression text as column name for TCK compliance
        expr_text = _tree_to_expression_text(expr)
        executor._expression_trees[expr_text] = expr
        executor._original_return_requests.add(expr_text)
        if alias:
            executor._entity2alias[expr_text] = alias
        executor._return_requests.append(expr_text)

    def _make_hashable(self, value):
        """Convert a value to a hashable form for DISTINCT comparison."""
        if value is None:
            return None
        if isinstance(value, (bool, int, float, str)):
            return value
        if isinstance(value, list):
            return tuple(self._make_hashable(v) for v in value)
        if isinstance(value, dict):
            return tuple(sorted((k, self._make_hashable(v)) for k, v in value.items()))
        # For other types, use string representation
        return str(value)

    def _unwrap_v4_expression(self, node):
        """Unwrap single-child wrapper trees in the v4 grammar to find the core."""
        _WRAPPERS = frozenset({
            "expression", "xor_expression", "and_expression", "not_expression",
            "comparison_expression", "add_sub_expression", "mult_div_expression",
            "power_expression", "unary_add_sub_expression", "atomic_expression",
            "property_or_label_expression", "property_expression", "atom",
            "literal", "parenthesized_expression",
        })

        while isinstance(node, Tree):
            # parenthesized_expression has 3 children: LPAREN expr RPAREN
            # Extract the middle (expression) child
            if node.data == "parenthesized_expression":
                tree_children = [c for c in node.children if isinstance(c, Tree)]
                if tree_children:
                    node = tree_children[0]
                    continue
            if node.data in _WRAPPERS and len(node.children) == 1:
                node = node.children[0]
                continue
            # Terminal types
            if node.data == "symbol":
                return _symbol_to_str(node)
            if node.data == "name":
                return _name_to_str(node)
            return node

        if isinstance(node, Token):
            return node
        return node

    def _detect_v4_aggregation(self, core) -> tuple | None:
        """Detect aggregation in a v4 expression core.

        Returns (func_name, entity_key, is_distinct, entity_tree) or None.
        entity_tree is the Tree for complex expressions that need evaluation.
        """
        if isinstance(core, Tree):
            # count_all: COUNT LPAREN DISTINCT? MULT RPAREN
            if core.data == "count_all":
                has_distinct = _has_token(core, "DISTINCT")
                # Get original case of COUNT token
                count_tok = _get_token(core, "COUNT")
                func_name_original = str(count_tok) if count_tok else "count"
                return ("COUNT", "*", has_distinct, None, func_name_original, None)

            # function_invocation: invocation_name LPAREN DISTINCT? expression_chain? RPAREN
            if core.data == "function_invocation":
                inv_name = _find_child(core, "invocation_name")
                if inv_name is None:
                    return None
                func_name_original = self._invocation_name_to_str(inv_name)
                func_name = func_name_original.upper()
                if func_name not in _AGGREGATE_FUNCS:
                    return None

                has_distinct = _has_token(core, "DISTINCT")
                ec = _find_child(core, "expression_chain")
                if ec is None:
                    # Return original case for display (5th element), None for percentile_tree (6th element)
                    return (func_name, "*", has_distinct, None, func_name_original, None)

                # Get entity from first argument
                args = [c for c in ec.children if isinstance(c, Tree)]
                if not args:
                    return (func_name, "*", has_distinct, None, func_name_original, None)

                entity_core = self._unwrap_v4_expression(args[0])
                entity_key = self._v4_expr_to_simple_key(entity_core)
                entity_tree = None
                if entity_key is None:
                    # Use expression text for complex expressions (lists, maps, etc.)
                    entity_key = _tree_to_expression_text(args[0])
                    entity_tree = args[0]  # Store tree for later evaluation

                # For percentile functions, extract the second argument (percentile value)
                percentile_tree = None
                if func_name in ("PERCENTILEDISC", "PERCENTILECONT") and len(args) > 1:
                    percentile_tree = args[1]  # Store tree for evaluation at aggregation time
                    # Try to extract literal for entity key (but still store tree for runtime eval)
                    percentile_core = self._unwrap_v4_expression(args[1])
                    percentile_val = self._extract_literal_value(percentile_core)
                    if percentile_val is not None:
                        # Encode percentile in entity key for later use
                        entity_key = f"{entity_key}|{percentile_val}"

                # Return original case for display (5th element), add percentile tree as 6th element
                return (func_name, entity_key, has_distinct, entity_tree, func_name_original, percentile_tree)

        return None

    def _extract_literal_value(self, node):
        """Extract a literal value (number, string) from a parse tree node."""
        if node is None:
            return None

        if isinstance(node, Token):
            # Direct token value
            val = str(node)
            try:
                if '.' in val:
                    return float(val)
                return int(val)
            except ValueError:
                return val

        if isinstance(node, Tree):
            # Handle literal tree
            if node.data in ("literal", "num_lit"):
                if node.children:
                    return self._extract_literal_value(node.children[0])
            # Handle wrapped expressions
            if node.data in ("expression", "atomic_expression", "property_or_label_expression",
                            "property_expression", "atom"):
                if len(node.children) == 1:
                    return self._extract_literal_value(node.children[0])
            # Look for literal in children
            for child in node.children:
                if isinstance(child, Token):
                    val = str(child)
                    try:
                        if '.' in val:
                            return float(val)
                        return int(val)
                    except ValueError:
                        pass
        return None

    def _contains_v4_aggregation(self, node) -> bool:
        """Recursively check if expression tree contains any aggregation function."""
        if not isinstance(node, Tree):
            return False

        # Check if this node is an aggregation
        if node.data in ("count_all",):
            return True
        if node.data == "function_invocation":
            inv_name = _find_child(node, "invocation_name")
            if inv_name:
                func_name = self._invocation_name_to_str(inv_name).upper()
                if func_name in _AGGREGATE_FUNCS:
                    return True

        # Recursively check children
        for child in node.children:
            if self._contains_v4_aggregation(child):
                return True
        return False

    def _extract_v4_aggregations_from_tree(self, node, aggregations=None):
        """Extract all aggregation subtrees from expression.

        Returns list of (subtree, func, entity, is_distinct, func_original, percentile_tree) tuples.
        """
        if aggregations is None:
            aggregations = []

        if not isinstance(node, Tree):
            return aggregations

        # Check if this node is an aggregation
        agg_info = self._detect_v4_aggregation(node)
        if agg_info is not None:
            func, entity, is_distinct, _entity_tree, func_original, percentile_tree = agg_info
            aggregations.append((node, func, entity, is_distinct, func_original, percentile_tree))
            # Don't recurse into this aggregation's children
            return aggregations

        # Recursively check children
        for child in node.children:
            self._extract_v4_aggregations_from_tree(child, aggregations)

        return aggregations

    def _replace_v4_aggregations_with_placeholders(self, node, agg_map):
        """Replace aggregation subtrees with placeholder variables.

        Args:
            node: Expression tree
            agg_map: Dict mapping node id() -> placeholder string

        Returns:
            New tree with aggregations replaced by symbol placeholders
        """
        from lark import Token

        # Check if this node should be replaced
        if id(node) in agg_map:
            placeholder = agg_map[id(node)]
            # Create a symbol node for placeholder (v4 uses symbol not expr_variable)
            return Tree('symbol', [Token('IDENTIFIER', placeholder)])

        if not isinstance(node, Tree):
            return node

        # Recursively replace in children
        new_children = []
        for child in node.children:
            if isinstance(child, Tree):
                new_children.append(self._replace_v4_aggregations_with_placeholders(child, agg_map))
            else:
                new_children.append(child)

        return Tree(node.data, new_children)

    def _invocation_name_to_str(self, inv: Tree) -> str:
        """Convert invocation_name: symbol (DOT symbol)* to string."""
        parts = []
        for c in inv.children:
            if isinstance(c, Tree) and c.data == "symbol":
                parts.append(_symbol_to_str(c))
        return ".".join(parts)

    def _v4_expr_to_simple_key(self, core) -> str | None:
        """Convert a v4 expression core to a simple string key."""
        # Check Token BEFORE str because Token is a subclass of str
        if isinstance(core, Token):
            if core.type == "DIGIT":
                return str(core)
            if core.type in ("STRING_LITERAL", "CHAR_LITERAL"):
                # For simple keys (column names), preserve the quotes as they appear in the query
                return str(core)
            if core.type in ("TRUE", "FALSE"):
                return str(core).capitalize()
            if core.type == "NULL_W":
                return "null"
            return str(core)

        # Plain strings (variable names from _symbol_to_str, etc.)
        if isinstance(core, str):
            return core

        if isinstance(core, (int, float, bool)):
            return str(core)
        if core is None:
            return "null"

        if isinstance(core, Tree):
            data = core.data

            if data == "num_lit":
                return str(self._parse_num_lit(core))

            if data == "string_lit" or data == "char_lit":
                # For simple keys (column names), preserve quotes as they appear in the query
                tok = core.children[0] if core.children else None
                return str(tok) if tok else ""

            if data == "bool_lit":
                return str(core.children[0]).capitalize()

            if data == "count_all":
                return "COUNT(*)"

            # property_expression: atom (DOT name)*
            if data == "property_expression" and len(core.children) > 1:
                parts = []
                for c in core.children:
                    if isinstance(c, Tree):
                        if c.data == "atom":
                            inner = self._unwrap_v4_expression(c)
                            base = self._v4_expr_to_simple_key(inner)
                            if base:
                                parts.append(base)
                            else:
                                # Base is complex (e.g., list[1]), can't make simple key
                                # Return None to treat whole expression as complex
                                return None
                        elif c.data == "name":
                            parts.append(_name_to_str(c))
                    elif isinstance(c, Token) and c.type == "DOT":
                        continue
                if parts:
                    return ".".join(parts)

            # function_invocation (non-aggregate)
            if data == "function_invocation":
                inv = _find_child(core, "invocation_name")
                if inv:
                    # Preserve original case for result key
                    func_name_original = self._invocation_name_to_str(inv)
                    ec = _find_child(core, "expression_chain")
                    if ec is None:
                        return f"{func_name_original}()"
                    args = [c for c in ec.children if isinstance(c, Tree)]
                    if len(args) == 1:
                        arg_core = self._unwrap_v4_expression(args[0])
                        arg_key = self._v4_expr_to_simple_key(arg_core)
                        if arg_key:
                            return f"{func_name_original}({arg_key})"

            if data == "atomic_expression" and len(core.children) >= 2:
                base_expr = core.children[0]
                base_key = self._v4_expr_to_simple_key(self._unwrap_v4_expression(base_expr))
                if base_key is not None:
                    for child in core.children[1:]:
                        if isinstance(child, Tree) and child.data == "null_expression":
                            has_not = _has_token(child, "NOT")
                            if has_not:
                                return f"{base_key} IS NOT NULL"
                            else:
                                return f"{base_key} IS NULL"

        return None

    def _v4_expr_literal_value(self, core) -> Any | None:
        """Extract a literal value from a v4 expression core, or None if not a literal."""
        if isinstance(core, (int, float)):
            return core
        if isinstance(core, bool):
            return core
        if core is None:
            return None

        if isinstance(core, Token):
            if core.type == "DIGIT":
                return self._parse_number_string(str(core))
            if core.type in ("STRING_LITERAL", "CHAR_LITERAL"):
                return self._parse_string_lit(Tree("string_lit", [core]))
            if core.type == "TRUE":
                return True
            if core.type == "FALSE":
                return False
            if core.type == "NULL_W":
                return None
            return None

        if isinstance(core, Tree):
            if core.data == "num_lit":
                return self._parse_num_lit(core)
            if core.data in ("string_lit", "char_lit"):
                return self._parse_string_lit(core)
            if core.data == "bool_lit":
                return str(core.children[0]).upper() == "TRUE"

        return None

    def _process_order_st(self, order: Tree, executor):
        """Process order_st: ORDER BY order_item (COMMA order_item)*"""
        executor._order_by = []
        for child in order.children:
            if isinstance(child, Tree) and child.data == "order_item":
                self._process_order_item(child, executor)

    def _process_order_item(self, item: Tree, executor):
        """Process order_item: expression (ASCENDING | ASC | DESCENDING | DESC)?"""
        expr = None
        direction = "ASC"

        for child in item.children:
            if isinstance(child, Tree):
                if expr is None:
                    expr = child
            elif isinstance(child, Token):
                if child.type in ("DESC", "DESCENDING"):
                    direction = "DESC"

        if expr is None:
            return

        # Try to get a simple key
        core = self._unwrap_v4_expression(expr)
        agg = self._detect_v4_aggregation(core)
        if agg:
            func, entity, is_distinct, _entity_tree, func_original, _percentile_tree = agg
            field = executor._format_aggregation_key(func_original, entity, is_distinct)
            if entity != "*":
                executor._order_by_attributes.add(entity)
        elif self._contains_v4_aggregation(expr):
            # ORDER BY expression contains embedded aggregations (e.g., $age + avg(person.age) - 1000)
            # Handle similarly to RETURN post-aggregation expressions
            aggregations = self._extract_v4_aggregations_from_tree(expr)

            if aggregations:
                # Register each aggregation and create placeholders
                agg_map = {}  # Map node id -> placeholder
                for idx, (agg_subtree, func, entity, is_distinct, func_original, percentile_tree) in enumerate(aggregations):
                    # Check if this aggregation is already registered
                    agg_key = executor._format_aggregation_key(func_original, entity, is_distinct)
                    existing_placeholder = None
                    for ph, key in executor._aggregation_placeholders.items():
                        if key == agg_key:
                            existing_placeholder = ph
                            break

                    if existing_placeholder:
                        placeholder = existing_placeholder
                    else:
                        placeholder = f"__AGG_{len(executor._aggregation_placeholders)}__"
                        executor._aggregate_functions.append((func, entity, is_distinct, agg_key))
                        executor._aggregation_placeholders[placeholder] = agg_key
                        # Store percentile expression tree for runtime evaluation
                        if percentile_tree is not None:
                            executor._percentile_expressions[agg_key] = percentile_tree
                        if entity != "*":
                            # For percentile functions, entity may include "|percentile_value" suffix
                            # Use base entity for data lookup
                            base_entity = entity.split('|')[0] if '|' in entity else entity
                            executor._aggregation_attributes.add(base_entity)

                    agg_map[id(agg_subtree)] = placeholder

                # Rewrite expression tree with placeholders
                rewritten_tree = self._replace_v4_aggregations_with_placeholders(expr, agg_map)

                # Use expression text as key
                expr_text = _tree_to_expression_text(expr)

                # Store as post-aggregation expression
                executor._post_aggregation_expressions[expr_text] = rewritten_tree
                field = expr_text
            else:
                # Fallback to expression tree
                expr_id = f"EXPR_{_shortuuid(8)}"
                executor._expression_trees[expr_id] = expr
                field = expr_id
                executor._order_by_attributes.add(field)
        else:
            simple = self._v4_expr_to_simple_key(core)
            if simple:
                field = simple
                executor._order_by_attributes.add(field)
            else:
                # Complex expression — store as expression tree
                expr_id = f"EXPR_{_shortuuid(8)}"
                executor._expression_trees[expr_id] = expr
                field = expr_id
                executor._order_by_attributes.add(field)

        executor._order_by.append((field, direction))

    def _process_with(self, pb: Tree, where: Tree | None):
        """Process WITH clause."""
        executor = self.executor

        # Execute any pending mutations before processing WITH
        # This ensures that SET clauses before WITH update the graph
        # so that property access and aggregations see the updated values
        if self._mutation_executor and self._mutation_executor._mutations:
            # Collect matches from all executors
            combined_matches = []
            for exec in self._executors:
                if exec._motif.number_of_nodes() > 0:
                    exec_matches = list(exec._get_true_matches())
                    combined_matches.extend(exec_matches)

            # Build eval_expression callback
            def _eval_expression_cb(tree, ctx):
                return executor._eval_expression(tree, ctx)

            # Execute mutations
            self._mutation_executor.execute(combined_matches, eval_expression=_eval_expression_cb)
            
            # Clear mutations after execution so they don't run again
            self._mutation_executor._mutations = []

        # Save state
        orig_return_requests = list(executor._return_requests)
        orig_agg_funcs = list(executor._aggregate_functions)
        orig_agg_attrs = set(executor._aggregation_attributes)

        executor._return_requests = []
        executor._aggregate_functions = []
        executor._aggregation_attributes = set()
        distinct = executor._distinct
        with_aliases = {}  # key -> list of aliases (to handle identical expressions with different aliases)

        # Check DISTINCT
        if _has_token(pb, "DISTINCT"):
            distinct = True

        # Process items
        items = _find_child(pb, "projection_items")
        if items is not None:
            # Handle RETURN *
            if _has_token(items, "MULT"):
                for node_name in executor._motif.nodes():
                    executor._return_requests.append(node_name)
                if executor._with_results:
                    for key in executor._with_results:
                        if key not in executor._return_requests:
                            executor._return_requests.append(key)
            else:
                for child in items.children:
                    if isinstance(child, Tree) and child.data == "projection_item":
                        # Get alias
                        alias = None
                        sym = _find_child(child, "symbol")
                        if sym is not None:
                            alias = _symbol_to_str(sym)

                        # Get expression (first non-symbol tree child)
                        expr = None
                        for cc in child.children:
                            if isinstance(cc, Tree) and cc.data != "symbol":
                                expr = cc
                                break

                        if expr is not None:
                            self._register_return_expression(executor, expr, alias)

                            # Track variable type for WITH-bound variables in strict mode
                            if self._strict and alias:
                                expr_type = self._infer_expression_type(expr)
                                if expr_type:
                                    self._validate_and_bind_variable(alias, expr_type)

                            if alias and executor._return_requests:
                                last_key = executor._return_requests[-1]
                                with_aliases.setdefault(last_key, []).append(alias)
                            elif alias and executor._aggregate_functions:
                                agg_tuple = executor._aggregate_functions[-1]
                                if len(agg_tuple) == 4:
                                    last_key = agg_tuple[3]  # Display key
                                else:
                                    func, entity, is_dist = agg_tuple
                                    last_key = executor._format_aggregation_key(func, entity, is_dist)
                                with_aliases.setdefault(last_key, []).append(alias)

        # Handle ORDER BY, SKIP, LIMIT within WITH's projection_body
        order = _find_child(pb, "order_st")
        if order is not None:
            self._process_order_st(order, executor)

        skip = _find_child(pb, "skip_st")
        if skip is not None:
            for c in skip.children:
                if isinstance(c, Tree):
                    val = self._eval_literal_expression(c)
                    if isinstance(val, (int, float)):
                        skip_val = int(val)
                        executor._validate_integer(skip_val, "SKIP", allow_negative=False)
                        executor._skip = skip_val
                    elif isinstance(val, Tree):
                        # Complex expression (e.g., toInteger(rand()*9)) - use full eval
                        from nxcypher import EvalContext
                        ctx = EvalContext(row={}, graph=executor._target_graph)
                        val = executor._eval_expression(val, ctx)
                        if isinstance(val, (int, float)):
                            skip_val = int(val)
                            executor._validate_integer(skip_val, "SKIP", allow_negative=False)
                            executor._skip = skip_val
                    break

        limit = _find_child(pb, "limit_st")
        if limit is not None:
            for c in limit.children:
                if isinstance(c, Tree):
                    val = self._eval_literal_expression(c)
                    if isinstance(val, (int, float)):
                        limit_val = int(val)
                        executor._validate_integer(limit_val, "LIMIT", allow_negative=False)
                        executor._limit = limit_val
                    elif isinstance(val, Tree):
                        # Complex expression (e.g., toInteger(ceil(1.7))) - use full eval
                        from nxcypher import EvalContext
                        ctx = EvalContext(row={}, graph=executor._target_graph)
                        val = executor._eval_expression(val, ctx)
                        if isinstance(val, (int, float)):
                            limit_val = int(val)
                            executor._validate_integer(limit_val, "LIMIT", allow_negative=False)
                            executor._limit = limit_val
                    break

        # Update reverse alias mapping (alias -> entity) for WITH results lookup
        executor._alias2entity.update({v: k for k, v in executor._entity2alias.items()})

        # Extract WHERE expression if present
        where_expr = None
        if where is not None:
            for c in where.children:
                if isinstance(c, Tree):
                    where_expr = c
                    break

        if where_expr is not None:
            # WITH ... WHERE: filter on raw matches BEFORE DISTINCT
            # This allows WHERE to access pre-WITH variables
            intermediate = self._filter_and_project_with_where(
                executor, where_expr, with_aliases, distinct
            )
        else:
            # No WHERE - just get results normally
            executor._distinct = distinct
            intermediate = executor.returns(ignore_limit=False)

        # Apply aliases
        # with_aliases is now Dict[str, List[str]] to handle identical expressions with different aliases
        aliased = {}
        for key, values in intermediate.items():
            aliases = with_aliases.get(key, [])
            if aliases:
                # Apply all aliases for this key (handles identical expressions)
                for alias in aliases:
                    aliased[alias] = values
                    executor._with_variable_mappings[alias] = key
            else:
                # No WITH alias, try entity2alias
                new_key = executor._entity2alias.get(key, key)
                aliased[new_key] = values
                if key != new_key:
                    executor._with_variable_mappings[new_key] = key

        # Store
        executor._with_results = aliased

        # Restore
        executor._return_requests = orig_return_requests
        executor._aggregate_functions = orig_agg_funcs
        executor._aggregation_attributes = orig_agg_attrs
        executor._distinct = False
        executor._order_by = None
        executor._skip = 0
        executor._limit = self._limit
        executor._matches = None
        # Clear the motif to prevent _get_true_matches from re-computing old matches
        # A subsequent MATCH clause will rebuild the motif as needed
        import networkx as nx
        executor._motif = nx.MultiDiGraph()
        executor._paths = []  # Clear path variables as they're now in _with_results
        executor._return_edges = {}  # Clear edge mappings
        # Clear alias mappings so that subsequent RETURN clauses can use the
        # aliased names directly from _with_results
        executor._entity2alias = {}
        executor._alias2entity = {}

    def _filter_and_project_with_where(
        self, executor, where_expr: Tree, with_aliases: dict, distinct: bool
    ) -> dict:
        """Filter raw matches with WHERE, then project and optionally apply DISTINCT.

        This method handles the correct order of operations for WITH ... WHERE:
        1. Get raw matches from MATCH
        2. For each match, project the WITH expressions
        3. Evaluate the WHERE condition (with access to BOTH pre-WITH variables AND projected aliases)
        4. Keep matches that pass WHERE
        5. Apply DISTINCT if needed

        Args:
            executor: The executor instance
            where_expr: The WHERE expression tree
            with_aliases: Mapping from expression keys to alias names
            distinct: Whether DISTINCT was specified
        """
        from nxcypher import EvalContext

        # Get raw matches
        true_matches = list(executor._get_true_matches())

        # Build projection expressions from return_requests
        projection_keys = list(executor._return_requests)

        # Handle UNWIND results (when there are no MATCH results)
        if not true_matches and executor._with_results:
            num_rows = max((len(v) for v in executor._with_results.values()), default=0)
            projected_rows = []

            for row_idx in range(num_rows):
                # Build binding from UNWIND results
                binding = {k: v[row_idx] if row_idx < len(v) else None
                           for k, v in executor._with_results.items()}

                # Project the WITH expressions
                row = {}
                for key in projection_keys:
                    if key in executor._expression_trees:
                        expr_tree = executor._expression_trees[key]
                        ctx = EvalContext(row=binding, graph=self._target_graph)
                        row[key] = executor._eval_expression(expr_tree, ctx)
                    elif key in binding:
                        row[key] = binding[key]
                    else:
                        row[key] = None

                # Build WHERE context with both pre-WITH and projected values
                where_binding = dict(binding)
                for key, aliases in with_aliases.items():
                    if key in row:
                        for alias in aliases:
                            where_binding[alias] = row[key]
                for key, value in row.items():
                    if key not in where_binding:
                        where_binding[key] = value

                # Evaluate WHERE
                where_ctx = EvalContext(row=where_binding, graph=self._target_graph)
                where_result = executor._eval_expression(where_expr, where_ctx)

                if where_result is True:
                    projected_rows.append(row)

            # Apply DISTINCT if needed
            if distinct:
                seen = set()
                unique_rows = []
                for row in projected_rows:
                    frozen = tuple(sorted((k, self._make_hashable(v)) for k, v in row.items()))
                    if frozen not in seen:
                        seen.add(frozen)
                        unique_rows.append(row)
                projected_rows = unique_rows

            # Convert to columnar format
            result = {key: [] for key in projection_keys}
            for row in projected_rows:
                for key in projection_keys:
                    result[key].append(row.get(key))

            return result

        if not true_matches:
            return {}

        # Project and filter matches
        projected_rows = []
        for match in true_matches:
            # Build context with access to all matched variables
            binding = {}
            for node_alias in executor._motif.nodes():
                node_id = match.node_mappings.get(node_alias)
                if node_id is not None and self._target_graph.has_node(node_id):
                    binding[node_alias] = dict(self._target_graph.nodes[node_id])
                    binding[node_alias]["__id__"] = node_id
                else:
                    binding[node_alias] = None

            # Also get edge data if available
            for edge_alias, edge_pair in executor._return_edges.items():
                u, v = edge_pair
                u_id = match.node_mappings.get(u)
                v_id = match.node_mappings.get(v)
                if u_id is not None and v_id is not None:
                    if self._target_graph.has_edge(u_id, v_id):
                        # For MultiDiGraph, get_edge_data returns dict of key->data
                        # We take the first edge if there are multiple
                        edge_dict = self._target_graph.get_edge_data(u_id, v_id)
                        if edge_dict:
                            # Get first edge's data
                            first_key = next(iter(edge_dict))
                            edge_data = dict(edge_dict[first_key])
                            binding[edge_alias] = edge_data
                        else:
                            binding[edge_alias] = None
                    else:
                        binding[edge_alias] = None
                else:
                    binding[edge_alias] = None

            # Create initial context for projecting WITH expressions
            ctx = EvalContext(row=binding, graph=self._target_graph, match=match,
                              return_edges=executor._return_edges)

            # First, project the WITH expressions to get the aliased values
            row = {}
            for key in projection_keys:
                if key in executor._expression_trees:
                    # Complex expression - evaluate it
                    expr_tree = executor._expression_trees[key]
                    row[key] = executor._eval_expression(expr_tree, ctx)
                elif key in binding:
                    # Simple variable reference
                    row[key] = binding[key]
                else:
                    # Try to resolve as property access (e.g., "a.name")
                    if "." in key:
                        parts = key.split(".", 1)
                        var_name, prop = parts
                        if var_name in binding and binding[var_name] is not None:
                            row[key] = binding[var_name].get(prop)
                        else:
                            row[key] = None
                    else:
                        row[key] = None

            # Now build the WHERE context with BOTH pre-WITH variables AND projected aliases
            where_binding = dict(binding)  # Start with pre-WITH variables
            # Add projected values under their aliases
            for key, aliases in with_aliases.items():
                if key in row:
                    for alias in aliases:
                        where_binding[alias] = row[key]
            # Also add projected values under their original keys
            for key, value in row.items():
                if key not in where_binding:
                    where_binding[key] = value

            # Evaluate WHERE condition with access to both scopes
            where_ctx = EvalContext(row=where_binding, graph=self._target_graph, match=match,
                              return_edges=executor._return_edges)
            where_result = executor._eval_expression(where_expr, where_ctx)

            if where_result is True:
                # WHERE passed - keep this row
                projected_rows.append(row)

        if not projected_rows:
            return {key: [] for key in projection_keys}

        # Apply DISTINCT if needed
        if distinct:
            seen = set()
            unique_rows = []
            for row in projected_rows:
                # Create a hashable representation
                row_tuple = tuple(
                    (k, tuple(sorted(v.items())) if isinstance(v, dict) else v)
                    for k, v in sorted(row.items())
                )
                if row_tuple not in seen:
                    seen.add(row_tuple)
                    unique_rows.append(row)
            projected_rows = unique_rows

        # Convert row-based to column-based format
        results = {key: [] for key in projection_keys}
        for row in projected_rows:
            for key in projection_keys:
                results[key].append(row.get(key))

        return results

    def _filter_with_where(
        self, results: dict, where_expr: Tree, aliases: dict, executor
    ) -> dict:
        """Filter WITH intermediate results using a WHERE expression.

        Note: This method is deprecated. Use _filter_and_project_with_where instead.
        """
        from nxcypher import EvalContext

        if not results:
            return results

        cols = list(results.keys())
        num_rows = max((len(v) for v in results.values()), default=0)
        if num_rows == 0:
            return results

        # Build alias->key reverse map
        key_from_alias = {v: k for k, v in aliases.items()}

        keep_indices = []
        for i in range(num_rows):
            row = {}
            for col in cols:
                row[col] = results[col][i] if i < len(results[col]) else None
            # Also populate with original keys for expression evaluation
            for alias, orig_key in key_from_alias.items():
                if orig_key in results:
                    row[orig_key] = results[orig_key][i] if i < len(results[orig_key]) else None

            ctx = EvalContext(row=row, graph=self._target_graph)
            val = executor._eval_expression(where_expr, ctx)
            if val is True:
                keep_indices.append(i)

        filtered = {}
        for col in cols:
            filtered[col] = [results[col][i] for i in keep_indices if i < len(results[col])]
        return filtered

    def _handle_unwind_st(self, node: Tree):
        """Handle UNWIND expression AS symbol."""
        executor = self.executor
        from nxcypher import EvalContext

        # Grammar: unwind_st: UNWIND expression AS symbol
        expr = None
        var_name = None

        for child in node.children:
            if isinstance(child, Tree):
                if child.data == "symbol":
                    var_name = _symbol_to_str(child)
                elif expr is None:
                    expr = child

        if var_name is None or expr is None:
            return

        # Evaluate the source expression
        source_data = None

        # Try evaluating as literal first
        lit = self._eval_literal_expression(expr)
        if isinstance(lit, list):
            # Evaluate any unevaluated Tree elements (e.g., function calls like date())
            evaluated_list = []
            ctx = EvalContext(row={}, graph=self._target_graph)
            for item in lit:
                if isinstance(item, Tree):
                    evaluated_list.append(executor._eval_expression(item, ctx))
                else:
                    evaluated_list.append(item)
            source_data = evaluated_list
        elif isinstance(lit, Tree):
            # Complex expression — need to evaluate against current context
            if executor._with_results is not None:
                # Evaluate against WITH results - per-row expansion
                num_rows = max((len(v) for v in executor._with_results.values()), default=0)
                if num_rows > 0:
                    # Per-row evaluation and cross-join
                    existing_keys = list(executor._with_results.keys())
                    new_results = {k: [] for k in existing_keys}
                    new_results[var_name] = []

                    for i in range(num_rows):
                        row = {k: v[i] if i < len(v) else None for k, v in executor._with_results.items()}
                        ctx = EvalContext(row=row, graph=self._target_graph)
                        val = executor._eval_expression(expr, ctx)

                        # Get list of values to unwind for this row
                        if isinstance(val, list):
                            row_values = val
                        elif val is not None:
                            row_values = [val]
                        else:
                            row_values = []

                        # Create new rows for each value in this row's list
                        for new_val in row_values:
                            for key in existing_keys:
                                existing_vals = executor._with_results[key]
                                if i < len(existing_vals):
                                    new_results[key].append(existing_vals[i])
                                else:
                                    new_results[key].append(None)
                            new_results[var_name].append(new_val)

                    executor._with_results = new_results
                    return  # Already handled the cross-join
                source_data = []
            elif executor._motif.number_of_nodes() > 0:
                # Evaluate against MATCH results
                matches = list(executor._get_true_matches())
                all_items = []
                for match in matches:
                    ctx = EvalContext(
                        match=match, graph=self._target_graph,
                        return_edges=executor._return_edges
                    )
                    val = executor._eval_expression(expr, ctx)
                    if isinstance(val, list):
                        all_items.extend(val)
                    elif val is not None:
                        all_items.append(val)
                source_data = all_items
            else:
                # Try direct evaluation
                ctx = EvalContext(graph=self._target_graph)
                val = executor._eval_expression(expr, ctx)
                if isinstance(val, list):
                    source_data = val
                elif val is not None:
                    source_data = [val]
        else:
            # Simple value — check if it's a reference to a WITH variable
            if isinstance(lit, str) and executor._with_results and lit in executor._with_results:
                source_data = executor._with_results[lit]
                # If list of lists, flatten
                if source_data and isinstance(source_data[0], list):
                    flat = []
                    for item in source_data:
                        flat.extend(item)
                    source_data = flat
            elif lit is not None:
                source_data = [lit]

        if source_data is None:
            source_data = []

        # Store as WITH results - handle cross-join with existing results
        if executor._with_results is None or len(executor._with_results) == 0:
            # First UNWIND - just set the results
            executor._with_results = {var_name: list(source_data)}
        else:
            # Subsequent UNWIND - do a cross-join (Cartesian product)
            # For each existing row, create N new rows (one for each value in source_data)
            existing_keys = list(executor._with_results.keys())
            num_existing_rows = max((len(v) for v in executor._with_results.values()), default=0)

            # Build new expanded results
            new_results = {k: [] for k in existing_keys}
            new_results[var_name] = []

            for row_idx in range(num_existing_rows):
                for new_val in source_data:
                    # Copy existing values for this row
                    for key in existing_keys:
                        existing_vals = executor._with_results[key]
                        if row_idx < len(existing_vals):
                            new_results[key].append(existing_vals[row_idx])
                        else:
                            new_results[key].append(None)
                    # Add new value
                    new_results[var_name].append(new_val)

            executor._with_results = new_results

    # ------------------------------------------------------------------ #
    # Step 6: Mutation clauses                                            #
    # ------------------------------------------------------------------ #

    def _handle_create_st(self, node: Tree):
        """Handle CREATE pattern."""
        pattern = _find_child(node, "pattern")
        if pattern is None:
            return

        parts = self._extract_pattern(pattern)
        executor = self.executor

        # Check if we have UNWIND/WITH context - need to create nodes for each row
        if executor._with_results is not None:
            # Get number of rows from WITH/UNWIND results
            num_rows = max((len(v) for v in executor._with_results.values()), default=0)
            if num_rows > 0:
                from nxcypher import EvalContext

                for part in parts:
                    path_var, nodes, edges = part

                    # Create nodes for each UNWIND row
                    for row_idx in range(num_rows):
                        # Build context for this row
                        row = {k: v[row_idx] if row_idx < len(v) else None
                               for k, v in executor._with_results.items()}

                        if len(nodes) == 1 and len(edges) == 0:
                            alias, labels, raw_props = nodes[0]
                            if alias and self._is_create_reference(alias):
                                continue

                            # Evaluate property expressions in context of this row
                            props = {}
                            for key, val in raw_props.items():
                                if isinstance(val, Tree):
                                    ctx = EvalContext(row=row, graph=self._target_graph)
                                    props[key] = executor._eval_expression(val, ctx)
                                else:
                                    props[key] = val

                            # Create node immediately
                            node_id = self._mutation_executor._generate_node_id()
                            if labels:
                                props["__labels__"] = set(labels) if not isinstance(labels, set) else labels
                            self._target_graph.add_node(node_id, **props)
                            if alias:
                                self._mutation_executor._created_nodes[alias] = node_id
                        else:
                            # Pattern with edges - create nodes and edges for this row
                            row_node_ids = {}
                            for i, n_info in enumerate(nodes):
                                n_alias, n_labels, raw_props = n_info
                                if n_alias and self._is_create_reference(n_alias):
                                    if n_alias in self._mutation_executor._created_nodes:
                                        row_node_ids[n_alias] = self._mutation_executor._created_nodes[n_alias]
                                    continue

                                props = {}
                                for key, val in raw_props.items():
                                    if isinstance(val, Tree):
                                        ctx = EvalContext(row=row, graph=self._target_graph)
                                        props[key] = executor._eval_expression(val, ctx)
                                    else:
                                        props[key] = val

                                node_id = self._mutation_executor._generate_node_id()
                                if n_labels:
                                    props["__labels__"] = set(n_labels) if not isinstance(n_labels, set) else n_labels
                                self._target_graph.add_node(node_id, **props)
                                if n_alias:
                                    self._mutation_executor._created_nodes[n_alias] = node_id
                                    row_node_ids[n_alias] = node_id
                                else:
                                    row_node_ids[f"_pos_{i}"] = node_id

                            for i, edge_info in enumerate(edges):
                                e_alias, e_types, direction, minh, maxh, e_props = edge_info
                                n0_alias = nodes[i][0] or f"_pos_{i}"
                                n1_alias = nodes[i + 1][0] or f"_pos_{i + 1}"
                                rel_type = e_types[0] if e_types else None

                                source_id = row_node_ids.get(n0_alias) or self._mutation_executor._created_nodes.get(n0_alias)
                                target_id = row_node_ids.get(n1_alias) or self._mutation_executor._created_nodes.get(n1_alias)

                                if source_id is None or target_id is None:
                                    continue

                                if direction == "l":
                                    source_id, target_id = target_id, source_id

                                props = {}
                                for key, val in e_props.items():
                                    if isinstance(val, Tree):
                                        ctx = EvalContext(row=row, graph=self._target_graph)
                                        props[key] = executor._eval_expression(val, ctx)
                                    else:
                                        props[key] = val

                                if rel_type:
                                    props["__labels__"] = {rel_type}

                                self._target_graph.add_edge(source_id, target_id, **props)
                                # Track edge by alias if provided
                                if e_alias:
                                    edge_data = props.copy()
                                    edge_data["_source"] = source_id
                                    edge_data["_target"] = target_id
                                    self._mutation_executor._created_edges[e_alias] = edge_data
                return

        # No UNWIND context - use deferred execution
        for part in parts:
            path_var, nodes, edges = part
            if len(nodes) == 1 and len(edges) == 0:
                alias, labels, props = nodes[0]
                if alias and self._is_create_reference(alias):
                    continue
                self._mutation_executor.queue_create_node(alias, labels, props)
            else:
                # Generate stable aliases for anonymous nodes first
                # so both node creation and edge creation use the same alias
                node_aliases = []
                for i, n_info in enumerate(nodes):
                    alias, labels, props = n_info
                    # Generate alias for anonymous nodes
                    effective_alias = alias or f"_anon_{_shortuuid()}"
                    node_aliases.append(effective_alias)
                    if alias and self._is_create_reference(alias):
                        continue
                    self._mutation_executor.queue_create_node(effective_alias, labels, props)

                for i, edge_info in enumerate(edges):
                    e_alias, e_types, direction, minh, maxh, e_props = edge_info
                    n0_alias = node_aliases[i]
                    n1_alias = node_aliases[i + 1]
                    rel_type = e_types[0] if e_types else None

                    if direction == "l":
                        source, target = n1_alias, n0_alias
                    else:
                        source, target = n0_alias, n1_alias

                    self._mutation_executor.queue_create_edge(source, target, rel_type, e_props, e_alias)

    def _is_create_reference(self, alias: str) -> bool:
        """Check if an alias refers to an already-matched or already-created node."""
        if not alias:
            return False
        # Check motif (from MATCH) in all executors
        for exec in self._executors:
            if alias in exec._motif.nodes():
                return True
        # Check already-queued CREATE nodes
        for m in self._mutation_executor._mutations:
            if m.operation == "CREATE_NODE" and m.target == alias:
                return True
        return False

    def _handle_set_st(self, node: Tree):
        """Handle SET set_item (COMMA set_item)*"""
        for child in node.children:
            if isinstance(child, Tree) and child.data == "set_item":
                self._process_set_item(child)

    def _process_set_item(self, item: Tree):
        """Process set_item: property_expression ASSIGN expression | symbol (ASSIGN | ADD_ASSIGN) expression | symbol node_labels"""
        # Try to find property_expression = expression
        prop_expr = _find_child(item, "property_expression")
        if prop_expr is not None:
            # property_expression: atom (DOT name)*
            path = self._property_expression_to_path(prop_expr)
            # Find the value expression (last expression in children)
            value_expr = None
            for c in item.children:
                if isinstance(c, Tree) and c.data != "property_expression":
                    value_expr = c
            if value_expr is not None and path:
                value = self._eval_literal_expression(value_expr)
                self._mutation_executor.queue_set(path, value)
            return

        # symbol = expression or symbol += expression or symbol :Label
        sym = _find_child(item, "symbol")
        if sym is None:
            return
        sym_str = _symbol_to_str(sym)

        # Check for node_labels
        nl = _find_child(item, "node_labels")
        if nl is not None:
            # SET n:Label — add label
            for label in self._extract_node_labels(nl):
                self._mutation_executor.queue_set_label(sym_str, label)
            return

        # Find value expression and detect += operator
        is_add_assign = False
        for c in item.children:
            if isinstance(c, Token) and (c.type == "ADD_ASSIGN" or str(c) == "+="):
                is_add_assign = True

        for c in item.children:
            if isinstance(c, Tree) and c.data not in ("symbol", "node_labels"):
                value = self._eval_literal_expression(c)
                # symbol = expression means set all properties
                # symbol += expression means merge properties
                self._mutation_executor.queue_set(sym_str, value, merge=is_add_assign)
                return

    def _property_expression_to_path(self, prop_expr: Tree) -> str:
        """Convert property_expression: atom (DOT name)* to a dotted path string."""
        parts = []
        for c in prop_expr.children:
            if isinstance(c, Tree):
                if c.data == "atom":
                    inner = self._unwrap_v4_expression(c)
                    if isinstance(inner, str):
                        parts.append(inner)
                    else:
                        parts.append(str(inner))
                elif c.data == "name":
                    parts.append(_name_to_str(c))
        return ".".join(parts)

    def _handle_delete_st(self, node: Tree):
        """Handle DETACH? DELETE expression_chain"""
        detach = _has_token(node, "DETACH")
        ec = _find_child(node, "expression_chain")
        if ec is None:
            return

        for child in ec.children:
            if isinstance(child, Tree):
                core = self._unwrap_v4_expression(child)
                if isinstance(core, str):
                    self._mutation_executor.queue_delete(core, detach=detach)

    def _handle_remove_st(self, node: Tree):
        """Handle REMOVE remove_item (COMMA remove_item)*"""
        for child in node.children:
            if isinstance(child, Tree) and child.data == "remove_item":
                self._process_remove_item(child)

    def _process_remove_item(self, item: Tree):
        """Process remove_item: symbol node_labels | property_expression"""
        prop_expr = _find_child(item, "property_expression")
        if prop_expr is not None:
            path = self._property_expression_to_path(prop_expr)
            if path:
                self._mutation_executor.queue_remove_property(path)
            return

        # symbol node_labels — remove label
        sym = _find_child(item, "symbol")
        nl = _find_child(item, "node_labels")
        if sym and nl:
            entity = _symbol_to_str(sym)
            for label in self._extract_node_labels(nl):
                self._mutation_executor.queue_remove_label(entity, label)

    def _handle_merge_st(self, node: Tree):
        """Handle MERGE pattern_part merge_action*"""
        pp = _find_child(node, "pattern_part")
        if pp is None:
            return

        path_var, nodes, edges = self._extract_pattern_part(pp)

        if not nodes:
            return

        on_create = []
        on_match = []

        for child in node.children:
            if isinstance(child, Tree) and child.data == "merge_action":
                self._process_merge_action(child, on_create, on_match)

        if edges:
            # Handle relationship MERGE: (a)-[r:TYPE]->(b)
            # edges = [(alias, types, direction, min_hop, max_hop, props), ...]
            # nodes = [(alias, labels, props), ...] with at least 2 nodes
            e_alias, e_types, e_direction, _, _, e_props = edges[0]
            rel_type = e_types[0] if e_types else None

            # Source and target nodes
            source_node = nodes[0]
            target_node = nodes[1] if len(nodes) > 1 else None

            if target_node:
                source_alias = source_node[0]
                target_alias = target_node[0]

                pattern = {
                    "type": "relationship",
                    "source_alias": source_alias,
                    "target_alias": target_alias,
                    "rel_alias": e_alias,
                    "rel_type": rel_type,
                    "direction": e_direction,
                    "properties": e_props or {},
                }
                self._mutation_executor.queue_merge(pattern, on_create, on_match)
            return

        # Handle single-node MERGE
        alias, labels, props = nodes[0]
        pattern = {"alias": alias, "labels": labels, "properties": props}
        self._mutation_executor.queue_merge(pattern, on_create, on_match)

    def _process_merge_action(self, action: Tree, on_create: list, on_match: list):
        """Process merge_action: ON (MATCH | CREATE) set_st"""
        is_create = _has_token(action, "CREATE")
        set_st = _find_child(action, "set_st")
        if set_st is None:
            return

        items = []
        for child in set_st.children:
            if isinstance(child, Tree) and child.data == "set_item":
                prop_expr = _find_child(child, "property_expression")
                if prop_expr:
                    path = self._property_expression_to_path(prop_expr)
                    for c in child.children:
                        if isinstance(c, Tree) and c.data not in ("property_expression",):
                            val = self._eval_literal_expression(c)
                            items.append({"type": "set_item", "path": path, "value": val})

        if is_create:
            on_create.extend(items)
        else:
            on_match.extend(items)

    # ------------------------------------------------------------------ #
    # UNION                                                               #
    # ------------------------------------------------------------------ #

    def _handle_union_st(self, node: Tree):
        """Handle UNION ALL? single_query"""
        from nxcypher import NXCypherExecutor

        is_all = _has_token(node, "ALL")

        # Save current executor
        self._union_queries.append(self.executor)
        self._union_types.append("all" if is_all else "distinct")

        # Create fresh executor
        new_exec = NXCypherExecutor(self._target_graph, self._limit, strict=self._strict)
        self._executors[-1] = new_exec
        self._match_clause_count = 0

        # Visit the single_query child
        for child in node.children:
            if isinstance(child, Tree) and child.data == "single_query":
                self._visit(child)

    # ------------------------------------------------------------------ #
    # CALL / standalone_call (stub)                                       #
    # ------------------------------------------------------------------ #

    def _handle_standalone_call(self, node: Tree):
        """Handle standalone CALL procedure."""
        raise NotImplementedError(
            "CALL clause (stored procedures) is not supported by nxCypher. "
            "NetworkX does not have a stored procedure mechanism."
        )

    def _handle_query_call_st(self, node: Tree):
        """Handle in-query CALL procedure."""
        raise NotImplementedError(
            "CALL clause (stored procedures) is not supported by nxCypher. "
            "NetworkX does not have a stored procedure mechanism."
        )
