# 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.

"""
NXCypher - An openCypher query language interpreter for NetworkX graphs.

You can use this tool to search Python graph data-structures by
data/attribute or by structure, using the same language you'd use
to search in a much larger graph database.

"""

from typing import Any, Dict, Generator, Hashable, List, Callable, Optional, Set, Tuple, Union, TYPE_CHECKING
from dataclasses import dataclass, field

if TYPE_CHECKING:
    from .temporal import QueryClock
from collections import OrderedDict
import random
import re
import string
from functools import lru_cache
import networkx as nx
from nxcypher.struct import (
    EdgeHopKey, EdgeMapping, EdgeWithKey, HopAssignment, Match, NodeMapping, generate_edge_hop_specs,
    generate_hop_assignments, materialize_motif, unify_zero_hop_nodes)
from lark import Lark, Transformer, v_args, Token, Tree
from itertools import chain, product

from nxcypher import isomorphism

from .hinter  import HintType, Hinter
from .indexer import (
    Compare as IndexerCompare, OR as IndexerOr,
    AND as IndexerAnd, ArrayAttributeIndexer, IndexerConditionAST,
    UnsupportedOp as IndexerUnsupportedOp, IndexerConditionRunner)


# === Validation Exception Classes ===

class CypherValidationError(Exception):
    """Base class for Cypher validation errors raised in strict mode."""
    pass


class VariableAlreadyBound(CypherValidationError):
    """Raised when a variable is reused with incompatible bindings.

    Example: CREATE (n:Foo), (n:Bar)
    """
    pass


class VariableTypeConflict(CypherValidationError):
    """Raised when a variable is used as both a node and relationship.

    Example: MATCH ()-[r]-() MATCH (r)
    """
    pass


class NestedAggregation(CypherValidationError):
    """Raised when aggregation functions are nested.

    Example: RETURN count(count(*))
    """
    pass


class NonConstantExpression(CypherValidationError):
    """Raised when non-deterministic functions are used in aggregations.

    Example: RETURN count(rand())
    """
    pass


class InvalidNumberOfArguments(CypherValidationError):
    """Raised when a procedure is called with wrong number of arguments.

    Example: CALL proc(arg1) when proc expects 2 arguments
    """
    pass


class UnknownProcedure(CypherValidationError):
    """Raised when an unknown procedure is called.

    Example: CALL unknown.proc()
    """
    pass


class UnexpectedSyntax(CypherValidationError):
    """Raised when syntax is invalid according to openCypher spec.

    Example: RETURN {k1#k: 1} (invalid map key)
    """
    pass


class InvalidArgumentType(CypherValidationError):
    """Raised when an operator receives operands of invalid types.

    Example: RETURN 123 AND true (number AND boolean is invalid)
    """
    pass


class IntegerOverflow(CypherValidationError):
    """Raised when an integer literal exceeds the valid range.

    Example: RETURN 9223372036854775808 (exceeds Long.MAX_VALUE)
    """
    pass


class InvalidUnicodeLiteral(CypherValidationError):
    r"""Raised when a Unicode escape sequence is invalid.

    Example: RETURN '\uH' (not a valid hex digit)
    """
    pass


class FloatingPointOverflow(CypherValidationError):
    """Raised when a floating point literal overflows.

    Example: RETURN 1.34E999 (exceeds maximum float value)
    """
    pass


class UndefinedVariable(CypherValidationError):
    """Raised when a variable is referenced but not defined in scope."""
    pass


class ColumnNameConflict(CypherValidationError):
    """Raised when multiple columns have the same name in RETURN."""
    pass


class InvalidDelete(CypherValidationError):
    """Raised when DELETE is used incorrectly."""
    pass


class DeleteConnectedNode(CypherValidationError):
    """Raised when deleting a node that has relationships (use DETACH DELETE)."""
    pass


class InvalidParameterUse(CypherValidationError):
    """Raised when parameters are used incorrectly."""
    pass


class NoSingleRelationshipType(CypherValidationError):
    """Raised when relationship needs exactly one type but has multiple or none."""
    pass


class CreatingVarLength(CypherValidationError):
    """Raised when trying to CREATE a variable-length relationship."""
    pass


class MergeReadOwnWrites(CypherValidationError):
    """Raised when MERGE might read its own writes."""
    pass


class AmbiguousAggregationExpression(CypherValidationError):
    """Raised when aggregation expression is ambiguous."""
    pass


class RequiresDirectedRelationship(CypherValidationError):
    """Raised when CREATE/MERGE needs directed relationship."""
    pass


class InvalidClauseComposition(CypherValidationError):
    """Raised when clauses are composed incorrectly (e.g., UNION mix)."""
    pass


class UnknownFunction(CypherValidationError):
    """Raised when calling an unknown function."""
    pass


class InvalidArgumentValue(CypherValidationError):
    """Raised when argument value is invalid (e.g., negative SKIP)."""
    pass


class NumberOutOfRange(CypherValidationError):
    """Raised when numeric argument is out of valid range."""
    pass


class NegativeIntegerArgument(CypherValidationError):
    """Raised when function receives negative integer where positive required."""
    pass


class InvalidArgumentPassingMode(CypherValidationError):
    """Raised when procedure arguments passed incorrectly."""
    pass


class ProcedureNotFound(CypherValidationError):
    """Raised when calling unknown procedure."""
    pass


class InvalidPropertyType(CypherValidationError):
    """Raised when property value has invalid type."""
    pass


# === V4 tree helper functions (used by _eval_tree v4 handlers) ===

def _find_child_v4(node: Tree, data_name: str):
    """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 _has_token_v4(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 _symbol_to_str_v4(sym) -> 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):
        if sym.data in ("symbol", "name"):
            return _symbol_to_str_v4(sym.children[0])
        if sym.data == "reserved_word":
            return str(sym.children[0])
    return str(sym)

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


def _process_cypher_string_escapes(s: str) -> str:
    """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)
    """
    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 == 'u' and i + 5 < len(s):
                # Unicode escape: \uXXXX
                hex_digits = s[i+2:i+6]
                try:
                    code_point = int(hex_digits, 16)
                    result.append(chr(code_point))
                    i += 6
                except ValueError:
                    # Invalid hex, keep as-is
                    result.append(s[i])
                    i += 1
            else:
                # Unknown escape, keep backslash and next char
                result.append(s[i])
                i += 1
        else:
            result.append(s[i])
            i += 1
    return ''.join(result)


_NXCypherGrammar = Lark(
    r"""
start               : query (union_clause query)*

union_clause        : "union"i "all"i -> union_all
                    | "union"i -> union_distinct

query               : many_match_clause where_clause return_clause order_clause? skip_clause? limit_clause?
                    | many_match_clause return_clause order_clause? skip_clause? limit_clause?
                    | many_match_clause where_clause? with_clause+ return_clause order_clause? skip_clause? limit_clause?
                    | many_match_clause with_clause+ return_clause order_clause? skip_clause? limit_clause?
                    | many_match_clause where_clause? with_clause+ unwind_clause return_clause order_clause? skip_clause? limit_clause?
                    | many_match_clause with_clause+ unwind_clause return_clause order_clause? skip_clause? limit_clause?
                    | unwind_clause return_clause order_clause? skip_clause? limit_clause?
                    | unwind_clause mutation_clause+ return_clause?
                    | with_clause+ return_clause order_clause? skip_clause? limit_clause?
                    | many_match_clause where_clause? mutation_clause+ return_clause?
                    | create_clause return_clause?
                    | merge_clause return_clause?
                    | return_clause order_clause? skip_clause? limit_clause?


many_match_clause   : (match_clause | optional_match_clause)+

// Mutation clauses for CREATE, SET, DELETE, REMOVE, MERGE
mutation_clause     : set_clause
                    | delete_clause
                    | create_clause
                    | remove_clause
                    | merge_clause

create_clause       : "create"i create_pattern ("," create_pattern)*
create_pattern      : node_create (edge_create node_create)*
node_create         : "(" CNAME? label_list? json_dict? ")"
label_list          : (":" TYPE)+
edge_create         : "-[" CNAME? (":" TYPE)? json_dict? "]->" -> edge_create_right
                    | "<-[" CNAME? (":" TYPE)? json_dict? "]-" -> edge_create_left

set_clause          : "set"i set_item ("," set_item)*
set_item            : entity_id "=" set_value

set_value           : expression

delete_clause       : "delete"i delete_items -> delete_clause
                    | "detach"i "delete"i delete_items -> detach_delete_clause

delete_items        : CNAME ("," CNAME)*

remove_clause       : "remove"i remove_item ("," remove_item)*
remove_item         : entity_id -> remove_property
                    | CNAME ":" TYPE -> remove_label

merge_clause        : "merge"i create_pattern on_create_clause? on_match_clause?
on_create_clause    : "on"i "create"i "set"i set_item ("," set_item)*
on_match_clause     : "on"i "match"i "set"i set_item ("," set_item)*

match_clause        : "match"i path_clause? node_match (edge_match node_match)*

optional_match_clause : "optional"i "match"i path_clause? node_match (edge_match node_match)*

path_clause         : CNAME EQUAL

where_clause        : "where"i expression

compound_condition  : condition
                    | "(" compound_condition boolean_arithmetic compound_condition ")"
                    | compound_condition boolean_arithmetic compound_condition

condition           : (entity_id | scalar_function) op entity_id_or_value
                    | (entity_id | scalar_function) op_list value_list
                    | sub_query
                    | "not"i edge_exists_condition -> negated_edge_condition
                    | "not"i condition -> condition_not
                    | edge_exists_condition

edge_exists_condition : "(" CNAME ")" edge_pattern "(" CNAME ")"

edge_pattern        : "--" -> edge_undirected
                    | "-->" -> edge_right
                    | "<--" -> edge_left
                    | "-[]->" -> edge_right
                    | "<-[]-" -> edge_left
                    | "-[]-" -> edge_undirected
                    | "-[:" TYPE "]->" -> edge_typed_right
                    | "<-[:" TYPE "]-" -> edge_typed_left
                    | "-[:" TYPE "]-" -> edge_typed_undirected

?entity_id_or_value : entity_id
                    | value
                    | NULL -> null
                    | TRUE -> true
                    | FALSE -> false
                    | list_comprehension

list_comprehension   : "[" CNAME "in"i list_source list_comp_where? "|" list_comp_expr "]"
list_source          : entity_id
                     | value_list
list_comp_where      : "where"i list_comp_condition
list_comp_condition  : CNAME list_comp_op list_comp_value
list_comp_op         : ">" -> comp_gt
                     | "<" -> comp_lt
                     | ">=" -> comp_gte
                     | "<=" -> comp_lte
                     | "=" -> comp_eq
                     | "==" -> comp_eq
                     | "<>" -> comp_neq
list_comp_value      : CNAME
                     | NUMBER
                     | ESTRING
list_comp_expr       : CNAME
                     | CNAME list_comp_arith_op list_comp_value -> list_comp_transform
list_comp_arith_op   : "+" -> arith_add
                     | "-" -> arith_sub
                     | "*" -> arith_mul
                     | "/" -> arith_div

op                  : "==" -> op_eq
                    | "=" -> op_eq
                    | "<>" -> op_neq
                    | ">" -> op_gt
                    | "<" -> op_lt
                    | ">="-> op_gte
                    | "<="-> op_lte
                    | "is"i -> op_is
                    | "contains"i -> op_contains
                    | "starts with"i -> op_starts_with
                    | "ends with"i -> op_ends_with

sub_op              : "EXISTS" -> subop_exist

sub_query           : sub_op "{" sub_query_body "}"

sub_query_body      : many_match_clause where_clause? return_clause?


op_list             : "in"i -> op_in



return_clause       : "return"i distinct_return? return_item ("," return_item)*
return_item         : expression ("AS"i alias)?

arith_expr          : arith_term (arith_add_op arith_term)+
arith_term          : arith_factor (arith_mul_op arith_factor)*
arith_factor        : arith_atom
                    | "(" arith_full_expr ")"
                    | "-" arith_atom -> arith_neg
arith_full_expr     : arith_term (arith_add_op arith_term)*
arith_atom          : entity_id "." attribute_id
                    | entity_id
                    | value
                    | NULL -> null
                    | TRUE -> true
                    | FALSE -> false
                    | scalar_function
                    | aggregation_function
arith_add_op        : "+" -> arith_add
                    | "-" -> arith_sub
arith_mul_op        : "*" -> arith_mul
                    | "/" -> arith_div
                    | "%" -> arith_mod
alias               : CNAME

with_clause         : "with"i distinct_return? with_item ("," with_item)* with_where_clause?
with_item           : expression ("AS"i alias)?
with_where_clause   : "where"i expression

unwind_clause       : "unwind"i expression "AS"i CNAME

aggregation_function : AGGREGATE_FUNC "(" entity_id ( "." attribute_id )? ")"
                     | AGGREGATE_FUNC "(" "*" ")"  -> aggregation_function_star
                     | AGGREGATE_FUNC "(" "DISTINCT"i entity_id ( "." attribute_id )? ")" -> aggregation_function_distinct
AGGREGATE_FUNC       : "COUNT"i | "SUM"i | "AVG"i | "MAX"i | "MIN"i | "COLLECT"i
attribute_id         : CNAME

scalar_function      : "id"i "(" entity_id ")" -> id_function
                     | "tolower"i "(" entity_id ")" -> tolower_function
                     | "toupper"i "(" entity_id ")" -> toupper_function
                     | "trim"i "(" entity_id ")" -> trim_function
                     | "size"i "(" entity_id ")" -> size_function
                     | "head"i "(" entity_id ")" -> head_function
                     | "tail"i "(" entity_id ")" -> tail_function
                     | "type"i "(" entity_id ")" -> type_function
                     | "labels"i "(" entity_id ")" -> labels_function
                     | "keys"i "(" entity_id ")" -> keys_function
                     | "tostring"i "(" entity_id_or_value ")" -> tostring_function
                     | "tointeger"i "(" entity_id_or_value ")" -> tointeger_function
                     | "tofloat"i "(" entity_id_or_value ")" -> tofloat_function
                     | "toboolean"i "(" entity_id_or_value ")" -> toboolean_function
                     | "reverse"i "(" entity_id_or_value ")" -> reverse_function
                     | "ltrim"i "(" entity_id ")" -> ltrim_function
                     | "rtrim"i "(" entity_id ")" -> rtrim_function
                     | "length"i "(" entity_id ")" -> length_function
                     | "abs"i "(" entity_id_or_value ")" -> abs_function
                     | "ceil"i "(" entity_id_or_value ")" -> ceil_function
                     | "floor"i "(" entity_id_or_value ")" -> floor_function
                     | "round"i "(" entity_id_or_value ")" -> round_function
                     | "sign"i "(" entity_id_or_value ")" -> sign_function
                     | "sqrt"i "(" entity_id_or_value ")" -> sqrt_function
                     | "properties"i "(" entity_id ")" -> properties_function
                     | "replace"i "(" entity_id "," entity_id_or_value "," entity_id_or_value ")" -> replace_function
                     | "substring"i "(" entity_id "," entity_id_or_value ("," entity_id_or_value)? ")" -> substring_function
                     | "split"i "(" entity_id "," entity_id_or_value ")" -> split_function
                     | "left"i "(" entity_id "," entity_id_or_value ")" -> left_function
                     | "right"i "(" entity_id "," entity_id_or_value ")" -> right_function
                     | "coalesce"i "(" entity_id_or_value ("," entity_id_or_value)+ ")" -> coalesce_function
                     | "range"i "(" entity_id_or_value "," entity_id_or_value ("," entity_id_or_value)? ")" -> range_function
                     | "exists"i "(" entity_id ")" -> exists_function
                     | case_expression
                     | list_comprehension

case_expression      : "case"i case_when+ case_else? "end"i -> searched_case
                     | "case"i (entity_id | value) case_simple_when+ case_else? "end"i -> simple_case
case_when            : "when"i case_condition "then"i case_result
case_simple_when     : "when"i entity_id_or_value "then"i case_result
case_else            : "else"i case_result
case_condition       : condition
                     | "(" compound_condition ")"
                     | compound_condition
case_result          : entity_id_or_value
                     | scalar_function
                     | aggregation_function

// ===== Unified Expression Hierarchy (openCypher spec) =====
// These rules are added alongside the old system. They will be wired in
// incrementally to replace compound_condition, arith_expr, scalar_function, etc.

expression              : or_expression
or_expression           : xor_expression ("OR"i xor_expression)*
xor_expression          : and_expression ("XOR"i and_expression)*
and_expression          : not_expression ("AND"i not_expression)*
not_expression          : "NOT"i not_expression -> expr_not
                        | edge_exists_condition
                        | comparison_expression
comparison_expression   : str_list_null_expression (comparison_op str_list_null_expression)?
comparison_op           : "=" -> cmp_eq
                        | "==" -> cmp_eq
                        | "<>" -> cmp_neq
                        | "!=" -> cmp_neq
                        | "<" -> cmp_lt
                        | ">" -> cmp_gt
                        | "<=" -> cmp_lte
                        | ">=" -> cmp_gte
                        | "=~" -> cmp_regex
str_list_null_expression : add_sub_expression (string_predicate | list_in_predicate | null_predicate)*
string_predicate        : "STARTS"i "WITH"i add_sub_expression -> pred_starts_with
                        | "ENDS"i "WITH"i add_sub_expression -> pred_ends_with
                        | "CONTAINS"i add_sub_expression -> pred_contains
list_in_predicate       : "IN"i add_sub_expression -> pred_in
null_predicate          : "IS"i "NULL"i -> pred_is_null
                        | "IS"i "NOT"i "NULL"i -> pred_is_not_null
add_sub_expression      : mul_div_mod_expression (add_op mul_div_mod_expression)*
add_op                  : "+" -> expr_add
                        | "-" -> expr_sub
mul_div_mod_expression  : power_expression (mul_op power_expression)*
mul_op                  : "*" -> expr_mul
                        | "/" -> expr_div
                        | "%" -> expr_mod
power_expression        : unary_expression ("^" unary_expression)?
unary_expression        : "-" unary_expression -> expr_negate
                        | "+" unary_expression -> expr_positive
                        | property_or_labels_expression
property_or_labels_expression : expr_atom (property_lookup | list_index_expression)*
property_lookup         : "." CNAME
list_index_expression   : "[" expression "]"
expr_atom               : expr_literal
                        | expr_function_call
                        | expr_count_star
                        | "(" expression ")" -> expr_paren
                        | expr_case
                        | expr_list_literal
                        | expr_map_literal
                        | expr_list_comprehension
                        | sub_query
                        | expr_variable
expr_literal            : NUMBER
                        | ESTRING
                        | NULL -> expr_null
                        | TRUE -> expr_true
                        | FALSE -> expr_false
expr_variable           : CNAME
expr_function_call      : CNAME "(" EXPR_DISTINCT? (expression ("," expression)*)? ")"
EXPR_DISTINCT           : "DISTINCT"i
expr_count_star         : "COUNT"i "(" "*" ")"
expr_case               : "CASE"i expr_case_when+ expr_case_else? "END"i -> expr_searched_case
                        | "CASE"i expression expr_case_simple_when+ expr_case_else? "END"i -> expr_simple_case
expr_case_when          : "WHEN"i expression "THEN"i expression
expr_case_simple_when   : "WHEN"i expression "THEN"i expression
expr_case_else          : "ELSE"i expression
expr_list_literal       : "[" (expression ("," expression)*)? "]"
expr_map_literal        : "{" (CNAME ":" expression ("," CNAME ":" expression)*)? "}"
expr_list_comprehension : "[" CNAME "IN"i expression ("WHERE"i expression)? "|" expression "]"

distinct_return     : "DISTINCT"i
limit_clause        : "limit"i NUMBER
skip_clause         : "skip"i NUMBER

order_clause        : "order"i "by"i order_items

order_items         : order_item ("," order_item)*

order_item          : expression order_direction?

order_direction     : "ASC"i -> asc
                    | "DESC"i -> desc
                    | -> no_direction


?entity_id          : CNAME
                    | CNAME "." CNAME

node_match          : "(" (CNAME)? (json_dict)? ")"
                    | "(" (CNAME)? label_list (json_dict)? ")"
                    | "(" (CNAME)? ":" type_list (json_dict)? ")"

edge_match          : LEFT_ANGLE? "--" RIGHT_ANGLE?
                    | LEFT_ANGLE? "-[]-" RIGHT_ANGLE?
                    | LEFT_ANGLE? "-[" CNAME "]-" RIGHT_ANGLE?
                    | LEFT_ANGLE? "-[" CNAME ":" type_list "]-" RIGHT_ANGLE?
                    | LEFT_ANGLE? "-[" ":" type_list "]-" RIGHT_ANGLE?
                    | LEFT_ANGLE? "-[" "*" MIN_HOP "]-" RIGHT_ANGLE?
                    | LEFT_ANGLE? "-[" "*" MIN_HOP  ".." MAX_HOP "]-" RIGHT_ANGLE?
                    | LEFT_ANGLE? "-[" CNAME "*" MIN_HOP "]-" RIGHT_ANGLE?
                    | LEFT_ANGLE? "-[" CNAME "*" MIN_HOP  ".." MAX_HOP "]-" RIGHT_ANGLE?
                    | LEFT_ANGLE? "-[" ":" type_list "*" MIN_HOP "]-" RIGHT_ANGLE?
                    | LEFT_ANGLE? "-[" ":" type_list "*" MIN_HOP  ".." MAX_HOP "]-" RIGHT_ANGLE?
                    | LEFT_ANGLE? "-[" CNAME ":" type_list "*" MIN_HOP "]-" RIGHT_ANGLE?
                    | LEFT_ANGLE? "-[" CNAME ":" type_list "*" MIN_HOP  ".." MAX_HOP "]-" RIGHT_ANGLE?

value_list          : "[" [value ("," value)*] "]"
type_list           : TYPE ( "|" TYPE )*

LEFT_ANGLE          : "<"
RIGHT_ANGLE         : ">"
EQUAL               : "="
MIN_HOP             : INT
MAX_HOP             : INT
TYPE                : CNAME

json_dict           : "{" json_rule ("," json_rule)* "}"
?json_rule          : CNAME ":" value

boolean_arithmetic  : "and"i -> where_and
                    | "OR"i -> where_or

key                 : CNAME
?value              : ESTRING
                    | NUMBER
                    | NULL -> null
                    | TRUE -> true
                    | FALSE -> false
                    | value_list

NULL.1                : "NULL"i
TRUE.1                : "TRUE"i
FALSE.1               : "FALSE"i


%import common.CNAME            -> CNAME
ESTRING : "\"" /(?:[^"\\]|\\.)*/ "\"" | "'" /(?:[^'\\]|\\.)*/ "'"
%import common.SIGNED_NUMBER    -> NUMBER
%import common.INT              -> INT

%import common.WS
%ignore WS
COMMENT: "//" /[^\n]/*
%ignore COMMENT

""",
    start="start",
)

__version__ = "1.0.1"


_ALPHABET = string.ascii_lowercase + string.digits


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


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)


def _cypher_equals(a, b) -> bool:
    """Cypher-style equality that respects type differences.

    In Cypher, booleans are not equal to integers (true != 1, false != 0).
    Python treats True == 1 and False == 0, but Cypher does not.
    """
    # In Cypher, booleans are not equal to integers
    if isinstance(a, bool) and isinstance(b, (int, float)) and not isinstance(b, bool):
        return False
    if isinstance(b, bool) and isinstance(a, (int, float)) and not isinstance(a, bool):
        return False
    return a == b


def _cypher_compare_key(val):
    """Return a comparison key for Cypher-style mixed-type ordering.

    Cypher ORDER BY type ordering (ascending):
    MAP < NODE < RELATIONSHIP < LIST < PATH < STRING < BOOLEAN < NUMBER < NULL

    NULL values sort to the END in Cypher (like SQL NULL LAST).
    Within each type, values are compared naturally.
    For lists, elements are compared recursively.
    """
    if val is None:
        # None (null) - sorts to END (largest)
        return (99, 0)
    if isinstance(val, dict):
        # Map - check if it's a node (has __labels__) or relationship (has __type__)
        if '__labels__' in val:
            # Node
            return (2, id(val) if hasattr(val, '__hash__') else 0)
        if '__type__' in val:
            # Relationship
            return (3, id(val) if hasattr(val, '__hash__') else 0)
        # Regular map
        return (1, str(sorted(val.items())) if val else '')
    if isinstance(val, list):
        # List - recursively convert elements for comparison
        return (4, tuple(_cypher_compare_key(item) for item in val))
    if isinstance(val, str):
        # String
        return (6, val)
    if isinstance(val, bool):
        # Boolean (must check before int since bool is subclass of int)
        return (7, val)
    if isinstance(val, (int, float)):
        # Number
        return (8, val)
    # Temporal types - compare by string representation
    if hasattr(val, '__str__'):
        return (5, str(val))  # Paths and temporal types between LIST and STRING
    # Unknown type - treat as string
    return (6, str(val))


class _AggregateResults:
    """Dict-like container for aggregate results that supports unhashable keys.

    This class allows keys that contain dicts or lists (unhashable types)
    by storing them in parallel lists rather than a dictionary.
    """
    def __init__(self, keys: list, values: list):
        self._keys = keys
        self._values = values

    def keys(self):
        return self._keys

    def values(self):
        return self._values

    def items(self):
        return zip(self._keys, self._values)

    def __len__(self):
        return len(self._keys)


@dataclass
class CaseExpression:
    """Represents a CASE expression for deferred evaluation."""
    case_type: str  # "searched" or "simple"
    when_clauses: List[Dict]  # List of {condition/value, result} dicts
    else_result: Any  # Default result if no WHEN matches
    compare_value: Any  # For simple CASE: the value being compared


@dataclass
class ListComprehension:
    """Represents a list comprehension for deferred evaluation.

    Syntax: [var IN source WHERE condition | expr]
    Example: [x IN [1,2,3,4,5] WHERE x > 2 | x * 2] -> [6, 8, 10]
    """
    variable: str  # The iteration variable (e.g., 'x')
    source: Any  # The source list - entity_id or literal list
    filter_condition: Optional[Dict]  # {var, op, value} for WHERE clause
    transform: Optional[Dict]  # {var, op, value} for transformation, or None for identity


@dataclass
class EvalContext:
    """Context for expression evaluation."""
    match: Optional[Match] = None       # MATCH/WHERE context (node/edge attribute lookups)
    row: Optional[Dict] = None          # WITH/UNWIND context (row-based lookups)
    graph: Optional[nx.Graph] = None    # The target graph
    return_edges: Optional[Dict] = None # Edge variable mappings
    clock: Optional["QueryClock"] = None  # Temporal clock for statement/transaction/realtime


class SUBOP:
    pass


class SUBOP_EXIST(SUBOP):

    def __call__(self, match: dict, executor: "NXCypherExecutor"):
        executor.clear_matches()
        # backup some settings
        bk_hints = executor._hints
        bk_run_without_return = executor.run_without_return
        bk_limit = executor._limit
        bk_doublecheck_hint = executor._doublecheck_hint_result
        bk_auto_where_hints = executor._auto_where_hints
        bk_auto_node_jsondata_hints = executor._auto_node_jsondata_hints
        # settings such that it favor EXISTS operation
        executor.set_hints([match])
        # we don't need return, and we only need 1 row
        executor.run_without_return = True
        executor._limit = 1
        executor._doublecheck_hint_result = True
        executor._auto_where_hints = False
        executor._auto_node_jsondata_hints = False
        # run and ignore return
        executor.returns()
        # recover the settings
        executor.set_hints(bk_hints)
        executor.run_without_return = bk_run_without_return
        executor._limit = bk_limit
        executor._doublecheck_hint_result = bk_doublecheck_hint
        executor._auto_where_hints = bk_auto_where_hints
        executor._auto_node_jsondata_hints = bk_auto_node_jsondata_hints

        if executor._matches:
            return True
        return False


_SUB_OPERATORS = {
    "EXISTS": SUBOP_EXIST
}


@lru_cache()
def _is_node_attr_match(
    motif_node_id: str, host_node_id: str, motif: nx.Graph, host: nx.Graph
) -> bool:
    """
    Check if a node in the host graph matches the attributes in the motif.

    Arguments:
        motif_node_id (str): The motif node ID
        host_node_id (str): The host graph ID
        motif (nx.Graph): The motif graph
        host (nx.Graph): The host graph

    Returns:
        bool: True if the host node matches the attributes in the motif

    """
    motif_node = motif.nodes[motif_node_id]
    host_node = host.nodes[host_node_id]

    for attr, val in motif_node.items():
        if attr == "__labels__":
            host_types = host_node.get("__labels__", set())
            if val and not val.issubset(host_types):
                return False
            continue
        if host_node.get(attr) != val:
            return False

    return True


@lru_cache()
def _is_edge_attr_match(
    motif_edge_id: Tuple[str, str, Union[int, str]],
    host_edge_id: Tuple[str, str, Union[int, str]],
    motif: Union[nx.Graph, nx.MultiDiGraph],
    host: Union[nx.Graph, nx.MultiDiGraph],
    host_keys: Union[tuple[Hashable], None] = None,
) -> bool:
    """
    Check if an edge in the host graph matches the attributes in the motif,
    including the special '__labels__' set attribute.
    This function formats edges into
    nx.MultiDiGraph format i.e {0: first_relation, 1: ...}.

    Arguments:
        motif_edge_id (str): The motif edge ID
        host_edge_id (str): The host edge ID
        motif (nx.Graph): The motif graph
        host (nx.Graph): The host graph

    Returns:
        bool: True if the host edge matches the attributes in the motif
    """
    motif_u, motif_v = motif_edge_id
    host_u, host_v = host_edge_id

    if host.is_multigraph():
        if host_keys is None and host.has_edge(host_u, host_v):
            host_keys =  host.get_edge_data(host_u, host_v).keys()
        if not host_keys:
            host_edges = []
        else:
            host_edges = [_get_edge_attributes(host, host_u, host_v, k) for k in host_keys]
    else:
        edge = _get_edge_attributes(host, host_u, host_v)
        if edge is None:
            host_edges = []
        else:
            host_edges: list[dict] = [edge]

    # NOTE: assume motif isn't a multi Digraph
    motif_edge = _get_edge_attributes(motif, motif_u, motif_v)

    # zero hop
    if motif_edge is None and not host_edges:
        return True

    if not motif_edge or not host_edges:
        # if there are no edges, they don't match
        return False

    motif_labels = motif_edge.get("__labels__", set())
    motif_labels = motif_labels if motif_labels is not None else set()

    # Internal attributes that are only for query processing, not matching
    INTERNAL_ATTRS = {"__labels__", "__optional__", "__min_hop__", "__max_hop__", "__is_hop__", "__bidirectional__"}

    for host_edge in host_edges:
        host_labels: set = host_edge.get("__labels__", set())
        for attr, val in motif_edge.items():
            if attr == "__labels__":
                if not motif_labels:
                    continue
                elif not host_labels.intersection(motif_labels):
                    break
            elif attr in INTERNAL_ATTRS:
                # Skip internal attributes during matching
                continue
            elif host_edge.get(attr) != val:
                break
        else:
            return True

    return False


def _get_edge_attributes(graph: Union[nx.Graph, nx.MultiDiGraph], u, v, k=None) -> Dict:
    """
    Retrieve edge attributes from a graph, handling both Graph and MultiDiGraph.
    """
    if graph.is_multigraph():
        if k is None and u != v:
            raise ValueError("cannot get edge attribues of None key for multigraph")
        elif k is None:
            # This suggest that u == v and there is no edge (not even self loop edge)
            return None
        return graph.get_edge_data(u, v, key=k)
    else:
        return graph.get_edge_data(u, v)  # Wrap in dict to mimic MultiDiGraph structure


def get_node_from_host(
    host: Union[nx.DiGraph, nx.MultiDiGraph], entity_name, entity_attribute=None,
):
    data = host.nodes[entity_name]
    # We are looking for a node mapping in the target graph:
    if entity_attribute:
        # Get the correct entity from the target host graph,
        # and then return the attribute:
        return data.get(entity_attribute, None)
    return data


def get_edge_from_host(
    host: Union[nx.DiGraph, nx.MultiDiGraph],
    entity_name: list[EdgeWithKey],
    entity_attribute=None
) -> Union[list[dict], dict, Any]:
    """
    Retrieve edge data from a host graph given a list of `EdgeWithKey` objects.

    Parameters
    ----------
    host : nx.DiGraph or nx.MultiDiGraph
        The graph from which to fetch edge attributes.

    entity_name : list[EdgeWithKey]
        A list of edges (u, v, k, hop_count) that identify edges in the host graph.

    entity_attribute : str, optional
        If provided, return only this attribute from the edge data.
        If multiple edges exist, this only works when the returned result is a single dict.
        Otherwise raises TypeError.

    Returns
    -------
    list[dict] | dict | Any
        Case breakdown:
        - If no edges found → `[]`
        - If exactly one edge found:
            - If no `entity_attribute` → the edge's attribute dict
            - If `entity_attribute` provided → value of that attribute or None
        - If multiple edges found:
            - If no `entity_attribute` → list of attribute dicts
            - If `entity_attribute` provided → TypeError

    Raises
    ------
    TypeError
        If `entity_attribute` is requested but multiple edges are returned.

    Notes
    -----
    Internally relies on `_get_edge_attributes(host, u, v, k)` to fetch data.
    """

    edge_data: list[dict[dict]] = (
        _get_edge_attributes(host, e.u, e.v, e.k) for e in entity_name)
    edge_data = [d for d in edge_data if d is not None]
    if not edge_data:
        return []
    if len(edge_data) == 1:
        edge_data = edge_data[0]

    if entity_attribute:
        if isinstance(edge_data, dict):
            return edge_data.get(entity_attribute)
        raise TypeError("cannot access attribute in list")

    else:
        return edge_data


def find_multiedge_keys(
    target_graph: Union[nx.DiGraph, nx.MultiDiGraph],
    match: NodeMapping,
    edge_hop_map: HopAssignment
):
    """
    Determine which edge keys exist in a host graph for each hop in each HopSpec.

    For each HopSpec inside `edge_hop_map`, this function inspects its hop path
    (`hop_spec.nodes`) and looks at every consecutive (u → v) pair.
    It then queries the corresponding host graph edges using the node mapping
    provided in `match`.

    Behavior differs depending on whether the host is a `MultiDiGraph`:

    - **MultiDiGraph:**
        Returns a list of available edge keys for each (u, v) pair.
        If the host graph contains no edge for that pair, returns `[-1]`.

        Example:
            host edges A→B with keys {0, 2, 5}
            → result[(A, B)] = [0, 2, 5]

    - **DiGraph:**
        Edge keys are conceptually always `None`, so:
        - If an edge exists → `[None]`
        - If not → `[-1]`

    Parameters
    ----------
    target_graph : nx.DiGraph or nx.MultiDiGraph
        The host graph in which real edges and multiedge keys are queried.

    match : NodeMapping
        A mapping from motif node names → host node names.

    edge_hop_map : HopAssignment
        A mapping {MapKey -> HopSpec}, where each HopSpec describes a hop path
        (e.g., ['A', 'x1', 'x2', 'B']).

    Returns
    -------
    dict[tuple[str, str], list[int] | list[None] | list[-1]]
        A dictionary mapping each hop (u, v) in motif coordinates to:
            - list of integer edge keys (MultiDiGraph),
            - [None] if a DiGraph edge exists,
            - [-1] if no matching edge exists in host.

    Notes
    -----
    - Using `-1` to indicate “no valid edge” allows the downstream Cartesian
      product generator to still function.
    - Keys are returned *in motif node space*, not host node space.
    """
    result = {}
    for hop_spec in edge_hop_map.values():
        if not hop_spec:
            continue
        edge_paths = hop_spec.nodes

        if isinstance(target_graph, nx.MultiDiGraph):
            for i in range(len(edge_paths)-1):
                start = edge_paths[i]
                end = edge_paths[i+1]
                if hop_spec.hop_count == 0:
                    result[(start, end)] = [-1]
                else:
                    result[(start, end)] = target_graph.get_edge_data(match[start], match[end])
                    result[(start, end)] = list(result[(start, end)].keys()) if result[(start, end)] is not None else [-1]
        else:
            for i in range(len(edge_paths)-1):
                start = edge_paths[i]
                end = edge_paths[i+1]
                if hop_spec.hop_count == 0:
                    result[(start, end)] = [-1]
                else:
                    result[(start, end)] = target_graph.get_edge_data(match[start], match[end])
                    result[(start, end)] = [None] if result[(start, end)] is not None else [-1]
    return result


def generate_multiedge_edge_hop_key(
    edge_hop_map: HopAssignment,
    multi_edge_keys: Dict[tuple[str, str], Optional[int]]
) -> Generator[list[EdgeHopKey], None, None]:
    """
    Generate all possible combinations of edge-hop key assignments across all
    motif edges that may expand into multi-hop paths.

    This function uses the output from `find_multiedge_keys` (a mapping of
    (u, v) → list of edge keys) and computes the Cartesian product of all
    possible key selections, per HopSpec, across all motif edges.

    It yields **one complete assignment at a time**, where each assignment is
    represented as a list of `EdgeHopKey` objects—one per motif edge.

    Example
    -------
    Suppose a motif edge A→B expands into two hops (A→x, x→B) and the host
    MultiDiGraph has keys:
        (A, x): [0, 1]
        (x, B): [5]
    Then the resulting combinations are:
        - keys=(0, 5)
        - keys=(1, 5)

    If a hop has no valid edge (`[-1]`), its corresponding EdgeHopKey will have
    an **empty tuple** (i.e., no key assignment): `keys=tuple()`.

    Parameters
    ----------
    edge_hop_map : HopAssignment
        A mapping {MapKey -> HopSpec}, describing the node paths for each
        motif edge.

    multi_edge_keys : dict[(str, str), list[int] | list[None] | list[-1]]
        Output of `find_multiedge_keys`.
        Maps each hop (u, v) in motif coordinates → possible edge keys.

    Yields
    ------
    list[EdgeHopKey]
        One full multi-edge hop-key assignment.
        The list is ordered by the iteration order of `edge_hop_map`.

    Notes
    -----
    - `-1` entries (meaning “no valid edge”) are converted into an empty
      `tuple()` so the calling code can detect hop failure explicitly.
    - The Cartesian product is computed in two layers:
        1. For each HopSpec (per motif edge): keys across hops.
        2. Across all HopSpecs: product of all per-edge sequences.
    - This generator lazily produces combinations without storing them all,
      making it scalable for large products.
    """
    result: list[list[EdgeHopKey]] = []

    for key, hop_spec in edge_hop_map.items():
        edge_paths = hop_spec.nodes
        edge_path_keys = []
        for i in range(len(edge_paths)-1):
            start = edge_paths[i]
            end = edge_paths[i+1]
            edge_path_keys.append(
                multi_edge_keys[(start, end)]
            )
        result.append(product(*edge_path_keys))

    for row in product(*result):
        ret = []
        for key, val in zip(edge_hop_map.keys(), row):
            ret.append(
                EdgeHopKey(
                    edge_id=key,
                    keys=val if val[0] != -1 else tuple()
                )
            )
        yield ret


CONDITION = Callable[[dict, nx.DiGraph, list], bool]


class Condition:
    ...

class BoolCondition(Condition):
    ...


class AND(BoolCondition):
    def __init__(self, condition_a: CONDITION, condition_b: CONDITION):
        self._condition_a = condition_a
        self._condition_b = condition_b
        self._operator = "and"

    def __call__(self, match: dict, host: nx.DiGraph, return_edges: list) -> bool:
        condition_a, where_a = self._condition_a(match, host, return_edges)
        condition_b, where_b = self._condition_b(match, host, return_edges)
        where_result = [a and b for a, b in zip(where_a, where_b)]
        return (condition_a and condition_b), where_result


class OR(BoolCondition):
    def __init__(self, condition_a: CONDITION, condition_b: CONDITION):
        self._condition_a = condition_a
        self._condition_b = condition_b
        self._operator = "or"

    def __call__(self, match: dict, host: nx.DiGraph, return_edges: list) -> tuple[bool, dict]:
        condition_a, where_a = self._condition_a(match, host, return_edges)
        condition_b, where_b = self._condition_b(match, host, return_edges)
        where_result = [a or b for a, b in zip(where_a, where_b)]
        return (condition_a or condition_b), where_result


class CompareCondition(Condition):
    ...


class LambdaCompareCondition(CompareCondition):
    def __init__(self, operator_function: Callable[[Any, Any], bool], operator: str):
        self._operator_function = operator_function
        self._operator = operator

    def __call__(self, value1, value2):
        return self._operator_function(value1, value2)

    def __str__(self) -> str:
        return f"{self._operator!r} condition at: " + super().__str__()


class CompoundCondition(Condition):
    """compound condition"""
    def __init__(self, should_be: bool, entity_id: str, operator, value):
        self._should_be = should_be
        self._entity_id = entity_id
        self._operator = operator
        self._value = value

    def __str__(self):
        return f"compound of {self._operator} for key {self._entity_id}: value {self._value}"

    # def __call__(self, match: dict, host: nx.DiGraph, return_edges: list, edge_hop_map = None, edge_hop_key = None) -> bool:
    def __call__(self, match: Match, host: nx.DiGraph, return_edges: list) -> bool:
        # Check if this is an ID function call
        if self._entity_id.startswith("ID(") and self._entity_id.endswith(")"):
            # Extract the entity name from ID(entity_name)
            actual_entity_name = self._entity_id[3:-1]  # Remove "ID(" and ")"
            if actual_entity_name in match.node_mappings:
                # Return the node ID directly
                node_id = match.node_mappings[actual_entity_name]
                try:
                    val = self._operator(node_id, self._value)
                except:
                    val = False
                operator_results = [val]
            else:
                raise IndexError(f"Entity {actual_entity_name} not in match.")
        else:
            host_entity_id = self._entity_id.split(".")
            if isinstance(self._operator, SUBOP):
                # SUBOP operator doesn't need a entity id.
                val = self._operator(match.node_mappings, self._value)
            elif host_entity_id[0] in match.node_mappings:
                # Regular entity attribute access
                host_entity_id[0] = match.node_mappings[host_entity_id[0]]
                val = self._operator(get_node_from_host(host, host_entity_id[0], host_entity_id[1]), self._value)
            elif host_entity_id[0] in return_edges:
                # looking for edge...
                entity_name, entity_attribute = _data_path_to_entity_name_attribute(self._entity_id)
                edge_mapping = return_edges[entity_name]

                edge_path = match.mth.edge(*edge_mapping) if match.mth else None
                if edge_path is None:
                    val = None
                else:
                    host_edges = edge_path.edges
                    val = self._operator(get_edge_from_host(host, host_edges, entity_attribute), self._value)
            else:
                raise IndexError(f"Entity {host_entity_id} not in graph.")
        operator_results = [val]
        if val is None:
            val is False
        if val != self._should_be:
            return False, operator_results
        return True, operator_results


class EdgeExistsCondition(Condition):
    """Condition that checks if an edge exists between matched nodes."""

    def __init__(self, source_alias: str, target_alias: str,
                 edge_type: Optional[str] = None, directed: bool = True,
                 direction: str = "right"):
        """
        Initialize an edge existence condition.

        Args:
            source_alias: The alias of the source node in the query
            target_alias: The alias of the target node in the query
            edge_type: Optional edge type/label to match
            directed: Whether to check directed edges only
            direction: "right" (source->target), "left" (target->source), or "both"
        """
        self._source = source_alias
        self._target = target_alias
        self._edge_type = edge_type
        self._directed = directed
        self._direction = direction

    def _check_edge_exists(self, host: nx.Graph, source_node: Any, target_node: Any) -> bool:
        """Check if an edge exists between two nodes."""
        # Determine which edges to check based on direction
        edges_to_check = []
        if self._direction == "right":
            edges_to_check = [(source_node, target_node)]
        elif self._direction == "left":
            edges_to_check = [(target_node, source_node)]
        else:  # undirected - check both directions
            edges_to_check = [(source_node, target_node), (target_node, source_node)]

        for u, v in edges_to_check:
            if not host.has_edge(u, v):
                continue

            if self._edge_type is None:
                return True

            # Check if edge has the specified type
            edge_data = host.get_edge_data(u, v)
            if host.is_multigraph():
                for key_data in edge_data.values():
                    labels = key_data.get("__labels__", set())
                    if isinstance(labels, set) and self._edge_type in labels:
                        return True
            else:
                labels = edge_data.get("__labels__", set())
                if isinstance(labels, set) and self._edge_type in labels:
                    return True

        return False

    def __call__(self, match: Match, host: nx.Graph, return_edges: list) -> tuple:
        source_node = match.node_mappings.get(self._source)
        target_node = match.node_mappings.get(self._target)

        if source_node is None or target_node is None:
            # Node not in match - condition not satisfied
            return False, [False]

        result = self._check_edge_exists(host, source_node, target_node)
        return result, [result]


class NegatedEdgeCondition(Condition):
    """Condition that checks edge does NOT exist between matched nodes."""

    def __init__(self, source_alias: str, target_alias: str,
                 edge_type: Optional[str] = None, directed: bool = True,
                 direction: str = "right"):
        """
        Initialize a negated edge existence condition.

        Args:
            source_alias: The alias of the source node in the query
            target_alias: The alias of the target node in the query
            edge_type: Optional edge type/label to match
            directed: Whether to check directed edges only
            direction: "right" (source->target), "left" (target->source), or "both"
        """
        self._edge_exists = EdgeExistsCondition(
            source_alias, target_alias, edge_type, directed, direction
        )

    def __call__(self, match: Match, host: nx.Graph, return_edges: list) -> tuple:
        exists, _ = self._edge_exists(match, host, return_edges)
        # Negated: return True if edge does NOT exist
        result = not exists
        return result, [result]


def none_wrapper(func) -> Callable[[Any, Any], Union[bool, None]]:
    def inner(x, y) -> Union[bool, None]:
        try:
            return func(x, y)
        except TypeError:
            return None
    return inner


_OPERATORS = {
    "=": LambdaCompareCondition(none_wrapper(lambda x, y: x == y), "="),
    "==": LambdaCompareCondition(none_wrapper(lambda x, y: x == y), "=="),
    ">=": LambdaCompareCondition(none_wrapper(lambda x, y: x >= y), ">="),
    "<=": LambdaCompareCondition(none_wrapper(lambda x, y: x <= y), "<="),
    "<": LambdaCompareCondition(none_wrapper(lambda x, y: x < y), "<"),
    ">": LambdaCompareCondition(none_wrapper(lambda x, y: x > y), ">"),
    "!=": LambdaCompareCondition(none_wrapper(lambda x, y: x != y), "!="),
    "<>": LambdaCompareCondition(none_wrapper(lambda x, y: x != y), "<>"),
    "in": LambdaCompareCondition(lambda x, y: x in y, "in"),
    "contains": LambdaCompareCondition(lambda x, y: y in x, "contains"),
    "is": LambdaCompareCondition(lambda x, y: x is y, "is"),
    "starts_with": LambdaCompareCondition(lambda x, y: x.startswith(y), "starts_with"),
    "ends_with": LambdaCompareCondition(lambda x, y: x.endswith(y), "ends_with"),
}


_BOOL_ARI = {
    "and": AND,
    "or": OR,
}


def _data_path_to_entity_name_attribute(data_path):
    if isinstance(data_path, Token):
        data_path = data_path.value
    if "." in data_path:
        # Use maxsplit=1 to handle expressions with decimal numbers
        # e.g., "[0, 0.0] = [0.0, 0]" should not be split on 0.0
        parts = data_path.split(".", 1)
        entity_name = parts[0]
        entity_attribute = parts[1] if len(parts) > 1 else None
    else:
        entity_name = data_path
        entity_attribute = None

    return entity_name, entity_attribute


# this is to convert WHERE OPERATOR to INDEXER OPERATOR
WHERE_OPERATORS_TO_INDEXER_OPERATORS = {
    "=": "==",
    "==": "==",
    ">": ">",
    ">=": ">=",
    "<": "<",
    "<=": "<=",
}


# this is to conver WHERE NOT OPERATOR to INDEXER OPERATOR
NOT_WHERE_OPERATORS_TO_INDEXER_OPERATORS = {
    "=": "!=",
    "==": "!=",
    ">": "<=",
    ">=": "<",
    "<": ">=",
    "<=": ">",
}


def create_node_indexer(target_graph: nx.DiGraph) -> ArrayAttributeIndexer:
    """create indexer for graph nodes"""
    indexer = ArrayAttributeIndexer(
        entity_ids=list(target_graph.nodes()),
        entity_attributes=list(target_graph.nodes[n] for n in target_graph.nodes))
    return indexer


def to_indexer_ast(condition: Condition, entity_id = None, value = None, should_be=True) -> IndexerConditionAST:
    """convert where condition to IndexerConditionAST which can be run with IndexerConditionRunner"""
    # for condition in condition:
    if isinstance(condition, CompoundCondition):
        return to_indexer_ast(condition=condition._operator,
                                entity_id=condition._entity_id,
                                value=condition._value,
                                should_be=condition._should_be)
    if (isinstance(condition, LambdaCompareCondition) and
        condition._operator in WHERE_OPERATORS_TO_INDEXER_OPERATORS):
        if entity_id.startswith("ID()"):
            entity_id = entity_id[3:-1]
        operator = condition._operator
        if should_be is True:
            operator = WHERE_OPERATORS_TO_INDEXER_OPERATORS[operator]
        else:
            operator = NOT_WHERE_OPERATORS_TO_INDEXER_OPERATORS[operator]
        return IndexerCompare(operator, entity_id, value)
    if isinstance(condition, OR):
        return IndexerOr(
            to_indexer_ast(condition._condition_a, entity_id, value),
            to_indexer_ast(condition._condition_b, entity_id, value),
        )
    if isinstance(condition, AND):
        return IndexerAnd(
            to_indexer_ast(condition._condition_a, entity_id, value),
            to_indexer_ast(condition._condition_b, entity_id, value),
        )
    return IndexerUnsupportedOp(condition, entity_id, value)


def motif_to_indexer_ast(motif: nx.DiGraph) -> IndexerConditionAST:
    # TODO: Test
    ast = None
    for cname, json_data in motif.nodes(data=True):
        for k, v in json_data.items():
            if k == "__labels__":
                continue
            k = cname + "." + k
            if ast is None:
                ast = IndexerCompare("==", k, v)
            else:
                ast = IndexerAnd(
                    ast,
                    IndexerCompare("==", k, v)
                )
    return ast


@dataclass
class Mutation:
    """Represents a pending graph mutation."""
    operation: str  # "CREATE_NODE", "CREATE_EDGE", "SET", "DELETE_NODE", "DELETE_EDGE"
    target: Any  # Node alias, Edge tuple (source, target), or property path
    data: Dict[str, Any]  # Properties, labels, or values


class MutationExecutor:
    """Executes graph mutations after pattern matching."""

    def __init__(self, host_graph: nx.Graph):
        self._host = host_graph
        self._mutations: List[Mutation] = []
        self._created_nodes: Dict[str, Any] = {}  # alias -> node_id
        self._created_edges: Dict[str, Dict] = {}  # alias -> edge data dict
        self._node_counter = 0

    def queue_create_node(self, alias: Optional[str], labels: Set[str], props: Dict):
        """Queue node creation."""
        self._mutations.append(Mutation(
            operation="CREATE_NODE",
            target=alias,
            data={"labels": labels, "properties": props}
        ))

    def queue_create_edge(self, source: str, target: str,
                          rel_type: Optional[str], props: Dict,
                          alias: Optional[str] = None):
        """Queue edge creation."""
        self._mutations.append(Mutation(
            operation="CREATE_EDGE",
            target=(source, target),
            data={"type": rel_type, "properties": props, "alias": alias}
        ))

    def queue_set(self, entity_path: str, value: Any, merge: bool = False):
        """Queue property update.

        Args:
            entity_path: Entity.property or just entity for map assignment
            value: The value to set
            merge: If True, use += semantics (merge maps instead of replace)
        """
        self._mutations.append(Mutation(
            operation="SET",
            target=entity_path,
            data={"value": value, "merge": merge}
        ))

    def queue_set_label(self, entity_alias: str, label: str):
        """Queue label addition."""
        self._mutations.append(Mutation(
            operation="SET_LABEL",
            target=entity_alias,
            data={"label": label}
        ))

    def queue_delete(self, entity_alias: str, detach: bool = False):
        """Queue deletion."""
        self._mutations.append(Mutation(
            operation="DELETE",
            target=entity_alias,
            data={"detach": detach}
        ))

    def queue_remove_property(self, entity_path: str):
        """Queue property removal."""
        self._mutations.append(Mutation(
            operation="REMOVE_PROPERTY",
            target=entity_path,
            data={}
        ))

    def queue_remove_label(self, entity_alias: str, label: str):
        """Queue label removal."""
        self._mutations.append(Mutation(
            operation="REMOVE_LABEL",
            target=entity_alias,
            data={"label": label}
        ))

    def queue_merge(self, pattern: Dict, on_create: List[Dict], on_match: List[Dict]):
        """Queue a MERGE operation.

        Args:
            pattern: Node pattern to match or create
            on_create: SET items to apply when creating
            on_match: SET items to apply when matching
        """
        self._mutations.append(Mutation(
            operation="MERGE",
            target=None,
            data={
                "pattern": pattern,
                "on_create": on_create,
                "on_match": on_match
            }
        ))

    def execute(self, matches: List["Match"],
                eval_expression: Callable = None,
                return_edges: Dict = None) -> Dict[str, List]:
        """Execute all queued mutations.

        Args:
            matches: List of Match objects from pattern matching
            eval_expression: Optional callable for evaluating expression trees
            return_edges: Mapping of relationship variable names to edge ID tuples

        Returns:
            Dictionary with mutation results
        """
        self._return_edges = return_edges or {}
        results = {
            "created_nodes": [],
            "created_edges": [],
            "updated": [],
            "removed": [],
            "deleted": [],
            "merged": []
        }

        # Separate mutations by type to ensure proper execution order:
        # 0. Merge node patterns first (upsert semantics)
        # 1. Create nodes (so edges can reference them)
        # 2. Merge relationship patterns (can now reference created nodes)
        # 3. Create edges
        # 4. Set properties
        # 4.5. Set labels
        # 5. Remove properties/labels
        # 6. Delete (last, so we can reference things before deleting)
        merge_mutations = [m for m in self._mutations if m.operation == "MERGE"]
        # Split MERGE into node and relationship patterns
        merge_node_mutations = [m for m in merge_mutations
                                if m.data.get("pattern", {}).get("type") != "relationship"]
        merge_rel_mutations = [m for m in merge_mutations
                               if m.data.get("pattern", {}).get("type") == "relationship"]
        create_nodes = [m for m in self._mutations if m.operation == "CREATE_NODE"]
        create_edges = [m for m in self._mutations if m.operation == "CREATE_EDGE"]
        set_mutations = [m for m in self._mutations if m.operation == "SET"]
        set_label_mutations = [m for m in self._mutations if m.operation == "SET_LABEL"]
        remove_prop_mutations = [m for m in self._mutations if m.operation == "REMOVE_PROPERTY"]
        remove_label_mutations = [m for m in self._mutations if m.operation == "REMOVE_LABEL"]
        delete_mutations = [m for m in self._mutations if m.operation == "DELETE"]

        # Execute in order
        # 1. MERGE nodes first (upsert on existing graph)
        for mutation in merge_node_mutations:
            merged = self._execute_merge(mutation, matches)
            results["merged"].extend(merged)

        # 2. CREATE nodes (so they can be referenced by edges and relationship MERGE)
        for mutation in create_nodes:
            node_ids = self._execute_create_node(mutation, matches, eval_expression)
            results["created_nodes"].extend(node_ids)

        # 3. MERGE relationships (can now reference created nodes)
        for mutation in merge_rel_mutations:
            merged = self._execute_merge(mutation, matches)
            results["merged"].extend(merged)

        # 4. CREATE edges
        for mutation in create_edges:
            edges = self._execute_create_edge(mutation, matches)
            results["created_edges"].extend(edges)

        for mutation in set_mutations:
            updated = self._execute_set(mutation, matches, eval_expression)
            results["updated"].extend(updated)

        for mutation in set_label_mutations:
            updated = self._execute_set_label(mutation, matches)
            results["updated"].extend(updated)

        for mutation in remove_prop_mutations:
            removed = self._execute_remove_property(mutation, matches)
            results["removed"].extend(removed)

        for mutation in remove_label_mutations:
            removed = self._execute_remove_label(mutation, matches)
            results["removed"].extend(removed)

        for mutation in delete_mutations:
            deleted = self._execute_delete(mutation, matches)
            results["deleted"].extend(deleted)

        return results

    def _execute_create_node(self, mutation: Mutation, matches: List["Match"] = None,
                              eval_expression: Callable = None) -> List:
        """Create a new node in the graph.

        If matches are provided and properties contain expression trees,
        create one node per match with evaluated properties.
        """
        from lark import Tree

        created_ids = []
        raw_props = mutation.data["properties"]

        # Check if any property is an expression tree
        has_expression = any(isinstance(v, Tree) for v in raw_props.values())

        if has_expression and matches and eval_expression:
            # Create one node per match with evaluated properties
            for match in matches:
                node_id = self._generate_node_id()
                props = {}
                for key, val in raw_props.items():
                    if isinstance(val, Tree):
                        # Evaluate expression in context of this match
                        ctx = EvalContext()
                        ctx.match = match
                        ctx.graph = self._host
                        ctx.return_edges = []
                        evaluated = eval_expression(val, ctx)
                        props[key] = evaluated
                    else:
                        props[key] = val

                if mutation.data["labels"]:
                    props["__labels__"] = mutation.data["labels"]

                self._host.add_node(node_id, **props)

                if mutation.target:  # Has alias
                    # Store the last created node for this alias
                    self._created_nodes[mutation.target] = node_id

                created_ids.append(node_id)
        else:
            # No matches or no expression - create single node
            node_id = self._generate_node_id()
            props = {}

            # Evaluate any expression trees even without matches
            for key, val in raw_props.items():
                if isinstance(val, Tree) and eval_expression:
                    # Evaluate expression in empty context
                    ctx = EvalContext()
                    ctx.match = None
                    ctx.graph = self._host
                    ctx.return_edges = []
                    evaluated = eval_expression(val, ctx)
                    props[key] = evaluated
                else:
                    props[key] = val

            if mutation.data["labels"]:
                props["__labels__"] = mutation.data["labels"]

            self._host.add_node(node_id, **props)

            if mutation.target:  # Has alias
                self._created_nodes[mutation.target] = node_id

            created_ids.append(node_id)

        return created_ids

    def _generate_node_id(self) -> str:
        """Generate a unique node ID."""
        node_id = f"_gc_node_{self._node_counter + len(self._host)}"
        while node_id in self._host:
            self._node_counter += 1
            node_id = f"_gc_node_{self._node_counter + len(self._host)}"
        self._node_counter += 1
        return node_id

    def _execute_create_edge(self, mutation: Mutation, matches: List["Match"]) -> List:
        """Create edges based on matched or created nodes."""
        source_alias, target_alias = mutation.target
        created = []
        alias = mutation.data.get("alias")

        # Resolve source and target node IDs
        source_ids = self._resolve_nodes(source_alias, matches)
        target_ids = self._resolve_nodes(target_alias, matches)

        props = mutation.data["properties"].copy()
        rel_type = mutation.data["type"]
        if rel_type:
            props["__labels__"] = {rel_type}

        # Create edge for each combination
        for source_id in source_ids:
            for target_id in target_ids:
                self._host.add_edge(source_id, target_id, **props)
                created.append((source_id, target_id))

                # Track edge by alias if provided
                if alias:
                    edge_data = props.copy()
                    edge_data["_source"] = source_id
                    edge_data["_target"] = target_id
                    self._created_edges[alias] = edge_data

        return created

    def _execute_set(self, mutation: Mutation, matches: List["Match"],
                     eval_expression: Callable = None) -> List:
        """Update properties on matched nodes/edges."""
        entity_path = mutation.target
        value = mutation.data["value"]
        merge = mutation.data.get("merge", False)  # += semantics
        updated = []

        # Parse entity.property or just entity for SET n = {map}
        parts = entity_path.split(".")
        if len(parts) == 1:
            # SET n = {map} or SET n += {map}
            entity_alias = parts[0]
            for match in matches:
                if entity_alias in match.node_mappings:
                    node_id = match.node_mappings[entity_alias]
                    if node_id is not None:
                        resolved_value = value
                        if isinstance(value, Tree) and eval_expression is not None:
                            ctx = EvalContext(
                                match=match, graph=self._host,
                                return_edges=None,
                            )
                            resolved_value = eval_expression(value, ctx)
                        if isinstance(resolved_value, dict):
                            if merge:
                                # SET n += map - merge properties (keep existing, add new)
                                self._host.nodes[node_id].update(resolved_value)
                            else:
                                # SET n = map - replace all properties with the map
                                # Preserve __labels__ if present
                                existing_labels = self._host.nodes[node_id].get("__labels__")
                                self._host.nodes[node_id].clear()
                                if existing_labels:
                                    self._host.nodes[node_id]["__labels__"] = existing_labels
                                self._host.nodes[node_id].update(resolved_value)
                            updated.append((node_id, "*"))
            return updated

        if len(parts) != 2:
            raise ValueError(f"SET requires entity.property format, got: {entity_path}")

        entity_alias, prop_name = parts

        for match in matches:
            if entity_alias in match.node_mappings:
                node_id = match.node_mappings[entity_alias]
                if node_id is not None:
                    resolved_value = value
                    if isinstance(value, Tree) and eval_expression is not None:
                        ctx = EvalContext(
                            match=match, graph=self._host,
                            return_edges=None,
                        )
                        resolved_value = eval_expression(value, ctx)
                    self._host.nodes[node_id][prop_name] = resolved_value
                    updated.append((node_id, prop_name))

        return updated

    def _execute_set_label(self, mutation: Mutation, matches: List["Match"]) -> List:
        """Add a label to matched nodes."""
        entity_alias = mutation.target
        label = mutation.data["label"]
        updated = []

        for match in matches:
            if entity_alias in match.node_mappings:
                node_id = match.node_mappings[entity_alias]
                if node_id is not None and node_id in self._host.nodes:
                    node_data = self._host.nodes[node_id]
                    labels = node_data.get("__labels__", set())
                    if not isinstance(labels, set):
                        labels = set(labels) if labels else set()
                    labels.add(label)
                    node_data["__labels__"] = labels
                    updated.append((node_id, label))

        return updated

    def _execute_delete(self, mutation: Mutation, matches: List["Match"]) -> List:
        """Delete matched nodes."""
        entity_alias = mutation.target
        detach = mutation.data["detach"]
        deleted = []

        nodes_to_delete = set()
        for match in matches:
            if entity_alias in match.node_mappings:
                node_id = match.node_mappings[entity_alias]
                if node_id is not None:
                    nodes_to_delete.add(node_id)

        for node_id in nodes_to_delete:
            if detach:
                self._host.remove_node(node_id)  # Removes all edges too
            else:
                # Check for edges first
                if self._host.degree(node_id) > 0:
                    raise ValueError(
                        f"Cannot delete node {node_id} with edges. Use DETACH DELETE."
                    )
                self._host.remove_node(node_id)
            deleted.append(node_id)

        return deleted

    def _execute_remove_property(self, mutation: Mutation, matches: List["Match"]) -> List:
        """Remove a property from matched nodes or relationships."""
        entity_path = mutation.target
        entity_name, entity_attr = _data_path_to_entity_name_attribute(entity_path)
        removed = []

        if not entity_attr:
            raise ValueError(f"REMOVE requires a property path like n.property, got: {entity_path}")

        for match in matches:
            # Check nodes first
            if entity_name in match.node_mappings:
                node_id = match.node_mappings[entity_name]
                if node_id is not None and node_id in self._host.nodes:
                    node_data = self._host.nodes[node_id]
                    if entity_attr in node_data:
                        del node_data[entity_attr]
                        removed.append(f"{node_id}.{entity_attr}")
            # Check relationships via return_edges mapping
            elif entity_name in self._return_edges:
                edge_tuple = self._return_edges[entity_name]
                if match.edge_mapping and match.mth:
                    try:
                        # Get the edge path for this edge tuple
                        edge_path = match.mth.edge(*edge_tuple)
                        if edge_path is not None:
                            for edge in edge_path.edges:
                                src, tgt = edge.u, edge.v
                                key = edge.k  # multi edge key
                                if self._host.has_edge(src, tgt):
                                    if self._host.is_multigraph():
                                        edge_data = self._host.edges[src, tgt, key] if key is not None else self._host.edges[src, tgt, 0]
                                    else:
                                        edge_data = self._host.edges[src, tgt]
                                    if entity_attr in edge_data:
                                        del edge_data[entity_attr]
                                        removed.append(f"{entity_name}.{entity_attr}")
                    except (KeyError, AttributeError):
                        pass  # Edge not found in this match

        return removed

    def _execute_remove_label(self, mutation: Mutation, matches: List["Match"]) -> List:
        """Remove a label from matched nodes."""
        entity_alias = mutation.target
        label = mutation.data["label"]
        removed = []

        for match in matches:
            if entity_alias in match.node_mappings:
                node_id = match.node_mappings[entity_alias]
                if node_id is not None and node_id in self._host.nodes:
                    node_data = self._host.nodes[node_id]
                    labels = node_data.get("__labels__", set())
                    if isinstance(labels, set) and label in labels:
                        labels.discard(label)
                        removed.append(f"{node_id}:{label}")

        return removed

    def _execute_merge(self, mutation: Mutation, matches: List["Match"]) -> List:
        """Execute MERGE operation - match or create.

        MERGE provides upsert semantics: if the pattern exists, match it;
        if not, create it.
        """
        pattern = mutation.data["pattern"]
        on_create = mutation.data.get("on_create", [])
        on_match = mutation.data.get("on_match", [])
        merged = []

        # Check if this is a relationship MERGE
        if pattern.get("type") == "relationship":
            return self._execute_merge_relationship(pattern, on_create, on_match, matches)

        # Pattern is a dict with: alias, labels, properties
        alias = pattern.get("alias")
        labels = pattern.get("labels", set())
        props = pattern.get("properties", {})

        # Try to find existing node that matches the pattern
        matched_node = None
        for node_id, node_data in self._host.nodes(data=True):
            # Check labels match
            node_labels = node_data.get("__labels__", set())
            if labels and not labels.issubset(node_labels):
                continue

            # Check properties match
            props_match = True
            for key, value in props.items():
                if node_data.get(key) != value:
                    props_match = False
                    break

            if props_match:
                matched_node = node_id
                break

        if matched_node is not None:
            # Node exists - apply ON MATCH SET
            for set_item in on_match:
                if set_item.get("type") == "set_item":
                    path = set_item["path"]
                    _, attr = _data_path_to_entity_name_attribute(path)
                    if attr:
                        self._host.nodes[matched_node][attr] = set_item["value"]
            merged.append({"action": "matched", "node": matched_node})
            if alias:
                self._created_nodes[alias] = matched_node
        else:
            # Node doesn't exist - create it
            node_id = f"_gc_node_{self._node_counter}"
            self._node_counter += 1

            node_props = props.copy()
            if labels:
                node_props["__labels__"] = labels

            # Apply ON CREATE SET
            for set_item in on_create:
                if set_item.get("type") == "set_item":
                    path = set_item["path"]
                    _, attr = _data_path_to_entity_name_attribute(path)
                    if attr:
                        node_props[attr] = set_item["value"]

            self._host.add_node(node_id, **node_props)
            merged.append({"action": "created", "node": node_id})
            if alias:
                self._created_nodes[alias] = node_id

        return merged

    def _execute_merge_relationship(self, pattern: Dict, on_create: List,
                                     on_match: List, matches: List["Match"]) -> List:
        """Execute MERGE for a relationship pattern.

        MERGE (a)-[r:TYPE]->(b) where a and b are already bound.
        """
        merged = []
        source_alias = pattern.get("source_alias")
        target_alias = pattern.get("target_alias")
        rel_alias = pattern.get("rel_alias")
        rel_type = pattern.get("rel_type")
        props = pattern.get("properties", {})

        # Resolve source and target nodes from matches or created nodes
        source_ids = self._resolve_nodes(source_alias, matches) if source_alias else []
        target_ids = self._resolve_nodes(target_alias, matches) if target_alias else []

        for source_id in source_ids:
            for target_id in target_ids:
                # Check if edge already exists
                matched_edge = None
                matched_edge_key = None
                if self._host.has_edge(source_id, target_id):
                    # For MultiDiGraph, iterate over all edges between these nodes
                    if self._host.is_multigraph():
                        all_edges = self._host.get_edge_data(source_id, target_id)
                        for edge_key, edge_data in all_edges.items():
                            edge_type = edge_data.get("__labels__")
                            if edge_type is None:
                                edge_type = edge_data.get("type")

                            type_matches = (rel_type is None or
                                            edge_type == rel_type or
                                            (isinstance(edge_type, set) and rel_type in edge_type))

                            if type_matches:
                                # Check properties match
                                props_match = True
                                for key, value in props.items():
                                    if edge_data.get(key) != value:
                                        props_match = False
                                        break
                                if props_match:
                                    matched_edge = (source_id, target_id)
                                    matched_edge_key = edge_key
                                    break
                    else:
                        # DiGraph - single edge
                        edge_data = self._host.get_edge_data(source_id, target_id)
                        edge_type = edge_data.get("__labels__")
                        if edge_type is None:
                            edge_type = edge_data.get("type")

                        type_matches = (rel_type is None or
                                        edge_type == rel_type or
                                        (isinstance(edge_type, set) and rel_type in edge_type))

                        if type_matches:
                            props_match = True
                            for key, value in props.items():
                                if edge_data.get(key) != value:
                                    props_match = False
                                    break
                            if props_match:
                                matched_edge = (source_id, target_id)

                if matched_edge:
                    # Edge exists - apply ON MATCH SET
                    for set_item in on_match:
                        if set_item.get("type") == "set_item":
                            path = set_item["path"]
                            _, attr = _data_path_to_entity_name_attribute(path)
                            if attr:
                                if self._host.is_multigraph() and matched_edge_key is not None:
                                    self._host.edges[source_id, target_id, matched_edge_key][attr] = set_item["value"]
                                else:
                                    self._host.edges[source_id, target_id][attr] = set_item["value"]
                    merged.append({"action": "matched", "edge": matched_edge})
                else:
                    # Edge doesn't exist - create it
                    edge_props = props.copy()
                    if rel_type:
                        edge_props["__labels__"] = {rel_type}

                    # Apply ON CREATE SET
                    for set_item in on_create:
                        if set_item.get("type") == "set_item":
                            path = set_item["path"]
                            _, attr = _data_path_to_entity_name_attribute(path)
                            if attr:
                                edge_props[attr] = set_item["value"]

                    self._host.add_edge(source_id, target_id, **edge_props)
                    merged.append({"action": "created", "edge": (source_id, target_id)})

                # Track edge by alias
                if rel_alias:
                    # For MultiDiGraph, get_edge_data returns dict of {key: data}
                    # For DiGraph, it returns the edge data directly
                    if self._host.is_multigraph():
                        all_edge_data = self._host.get_edge_data(source_id, target_id)
                        if all_edge_data:
                            # Get the last added edge (highest key)
                            last_key = max(all_edge_data.keys())
                            edge_data = dict(all_edge_data[last_key])
                        else:
                            edge_data = {}
                    else:
                        edge_data = dict(self._host.get_edge_data(source_id, target_id) or {})
                    edge_data["_source"] = source_id
                    edge_data["_target"] = target_id
                    self._created_edges[rel_alias] = edge_data

        return merged

    def _resolve_nodes(self, alias: str, matches: List["Match"]) -> List[Any]:
        """Resolve alias to node ID(s) from created nodes or matches."""
        if alias in self._created_nodes:
            return [self._created_nodes[alias]]

        node_ids = []
        for match in matches:
            if alias in match.node_mappings:
                node_id = match.node_mappings[alias]
                if node_id is not None:
                    node_ids.append(node_id)

        if not node_ids:
            raise ValueError(f"Unknown node alias: {alias}")

        return node_ids


@dataclass
class VariableScope:
    """Tracks variable definitions and types through query execution.

    Attributes:
        variables: Dict[str, str] - Maps variable name to type ('node', 'relationship', 'path', 'value')
        parent: Optional[VariableScope] - Parent scope for nested contexts
    """
    variables: Dict[str, str] = field(default_factory=dict)
    parent: Optional['VariableScope'] = None

    def declare(self, name: str, var_type: str, strict: bool = True):
        """Declare a variable in this scope.

        Args:
            name: Variable name
            var_type: Type ('node', 'relationship', 'path', 'value')
            strict: Whether to raise error on redeclaration

        Raises:
            VariableAlreadyBound: If variable already exists and strict=True
            VariableTypeConflict: If variable exists with different type
        """
        if name in self.variables:
            if strict and self.variables[name] != var_type:
                raise VariableTypeConflict(
                    f"Variable `{name}` already defined as {self.variables[name]}, "
                    f"cannot redefine as {var_type}"
                )
        self.variables[name] = var_type

    def lookup(self, name: str, strict: bool = True) -> Optional[str]:
        """Look up variable type in scope chain.

        Returns: Variable type or None if not found
        Raises: UndefinedVariable if strict=True and not found
        """
        if name in self.variables:
            return self.variables[name]
        if self.parent:
            return self.parent.lookup(name, strict=False)
        if strict:
            raise UndefinedVariable(f"Variable `{name}` not defined")
        return None

    def has(self, name: str) -> bool:
        """Check if variable exists in scope chain."""
        return self.lookup(name, strict=False) is not None

    def create_child(self) -> 'VariableScope':
        """Create a child scope (for WITH clauses)."""
        return VariableScope(variables={}, parent=self)


class NXCypherExecutor:
    def __init__(self, target_graph: nx.Graph, limit: Optional[int] = None, is_optional: bool = False, strict: bool = True):
        self._target_graph = target_graph
        self._strict = strict
        self._scope = VariableScope()  # Add variable scope tracker
        self._entity2alias = dict()
        self._alias2entity = dict()
        self._paths = []
        self._where_condition = None  # type: Optional[CONDITION]
        self._motif = nx.MultiDiGraph()
        self._matches = None
        self._matche_paths = None
        self._return_requests = []
        self._return_edges = {}
        self._aggregate_functions = []
        self._aggregation_attributes = set()
        self._aggregation_placeholders = {}  # Map placeholder -> aggregation key
        self._original_return_requests = set()
        self._post_aggregation_expressions = {}  # Map expression text -> rewritten tree
        self._percentile_expressions = {}  # Map agg_key -> expression Tree for percentile value
        self._distinct = False
        self._order_by = None
        self._order_by_attributes = set()
        self._limit = limit
        self._skip = 0
        self._max_hop = 100
        self._hints: Optional[List[HintType]] = None
        self._parent_executor: Optional["NXCypherExecutor"] = None
        self._child_executors: list["NXCypherExecutor"] = []
        self._optional_child_executors: list["NXCypherExecutor"] = []
        # tell the executor not to check the return values
        self.run_without_return = False
        # level of subquery
        self._level = 0
        # tell the engine to double check hint related nodes and edges structure
        # as they are ignored in isomorphism
        self._doublecheck_hint_result = False
        # whether auto_where_hints should be generated
        self._auto_where_hints = True
        # EXPERIEMENT feature
        self._auto_node_jsondata_hints = True
        node_ids = list(self._target_graph.nodes)
        # EXPERIMENT feature. Array Indexer doesn't update data when nodes in graph are updated.
        self._node_indexer = ArrayAttributeIndexer(
            entity_ids=node_ids,
            entity_attributes=[self._target_graph.nodes[nid] for nid in node_ids]
        )
        # OPTIONAL MATCH support
        self._is_optional = is_optional
        self._optional_nodes: set = set()  # Track which nodes are truly optional
        self._optional_edges: set = set()  # Track which edges are optional
        # Store each OPTIONAL MATCH as a separate pattern for independent processing
        self._optional_patterns: List[Dict] = []  # List of {motif, nodes, edges} dicts
        # WITH clause support - intermediate results
        self._with_results: Optional[Dict[str, List]] = None
        self._with_variable_mappings: Dict[str, str] = {}  # Maps new name -> original name
        # UNWIND clause support
        self._unwind_var: Optional[str] = None  # Variable name to unwind into
        self._unwind_source: Optional[str] = None  # Source entity or list
        # CASE expression support
        self._case_expressions: Dict[str, CaseExpression] = {}  # Maps case_id -> CaseExpression
        # List comprehension support
        self._list_comprehensions: Dict[str, ListComprehension] = {}  # Maps listcomp_id -> ListComprehension
        # Literal return values (for standalone RETURN 1, RETURN 'hello', etc.)
        self._literal_returns: Dict[str, Any] = {}  # Maps placeholder key -> literal value
        # Multi-argument function calls (REPLACE, SUBSTRING, etc.)
        self._func_calls: Dict[str, tuple] = {}  # Maps func_id -> (func_name, arg1, arg2, ...)
        # Expression trees for the unified expression evaluator
        self._expression_trees: Dict[str, Tree] = {}  # Maps expr_id -> expression Tree
        # WHERE expression (new unified system, coexists with _where_condition during migration)
        self._where_expression: Optional[Tree] = None
        # Query parameters (e.g., $param1, $param2)
        self._parameters: Dict[str, Any] = {}
        # Query clock for temporal functions (statement/transaction/realtime)
        self._clock: Optional["QueryClock"] = None

    def create_node_indices(self, node_attribute_keys: list[str]):
        self._node_indexer.create_indices(node_attribute_keys)

    def set_hints(self, hints=None):
        self._hints = hints
        return self

    def set_parameters(self, params: Optional[Dict[str, Any]] = None):
        """Set query parameters for parameter substitution."""
        self._parameters = params or {}
        return self

    def clear_matches(self):
        self._matches = None

    def _lookup(self, data_paths: List[str], offset_limit) -> Dict[str, List]:

        if not data_paths and not self.run_without_return:
            return {}

        motif_nodes = self._motif.nodes()

        # Get true matches FIRST, before processing data paths
        true_matches = self._get_true_matches()
        result = {}
        processed_paths = set()  # Keep track of processed paths

        # Handle expression trees from the new unified expression system
        for data_path in data_paths:
            if data_path in self._expression_trees:
                expr_tree = self._expression_trees[data_path]
                # Check for WITH results first - they take precedence over raw matches
                if self._with_results:
                    # WITH/UNWIND context — evaluate against rows from WITH clause
                    num_rows = max((len(v) for v in self._with_results.values()), default=0)
                    ret = []
                    # If we also have matches from a subsequent MATCH clause, combine them
                    if true_matches:
                        for i in range(num_rows):
                            row = {k: v[i] if i < len(v) else None for k, v in self._with_results.items()}
                            for match in true_matches:
                                # Also add match node mappings to row
                                for var_name, host_node in match.node_mappings.items():
                                    if var_name not in row:
                                        node_data = self._target_graph.nodes.get(host_node, {})
                                        row[var_name] = dict(node_data) if node_data else {}
                                ctx = EvalContext(row=row, graph=self._target_graph, match=match, return_edges=self._return_edges)
                                ret.append(self._eval_expression(expr_tree, ctx))
                    else:
                        for i in range(num_rows):
                            row = {k: v[i] if i < len(v) else None for k, v in self._with_results.items()}
                            ctx = EvalContext(row=row, graph=self._target_graph, return_edges=self._return_edges)
                            ret.append(self._eval_expression(expr_tree, ctx))
                    result[data_path] = ret[offset_limit] if ret else []
                elif true_matches:
                    ret = []
                    for match in true_matches:
                        ctx = EvalContext(
                            match=match, graph=self._target_graph,
                            return_edges=self._return_edges,
                        )
                        ret.append(self._eval_expression(expr_tree, ctx))
                    result[data_path] = ret[offset_limit]
                else:
                    # No matches, no WITH results — standalone expression (RETURN 1+2)
                    ctx = EvalContext(graph=self._target_graph, return_edges=self._return_edges)
                    result[data_path] = [self._eval_expression(expr_tree, ctx)]
                processed_paths.add(data_path)

        # handling RETURN ID(A), scalar functions, etc.
        for data_path in data_paths:
            entity_name, _ = _data_path_to_entity_name_attribute(data_path)
            # Special handling for ID function
            if entity_name.upper().startswith("ID(") and entity_name.endswith(")"):
                # Extract the original entity name
                original_entity = entity_name[3:-1]
                if original_entity in motif_nodes:
                    # Return the node ID directly instead of the node attributes
                    ret = [match.mth.node(original_entity) for match in true_matches]
                    result[data_path] = ret[offset_limit]
                    result[original_entity] = ret[
                        offset_limit
                    ]  # Also store under original entity name
                    processed_paths.add(data_path)  # Mark as processed
                    processed_paths.add(
                        original_entity
                    )  # Mark original also as processed
                    continue

            # Skip if already processed by expression_trees
            if data_path in processed_paths:
                continue

            # Handle scalar functions
            data_path_upper = data_path.upper() if isinstance(data_path, str) else str(data_path).upper()
            scalar_funcs = ["TOLOWER", "TOUPPER", "TRIM", "SIZE", "HEAD", "TAIL", "TYPE", "LABELS", "KEYS",
                            "TOSTRING", "TOINTEGER", "TOFLOAT", "TOBOOLEAN", "REVERSE",
                            "LTRIM", "RTRIM", "LENGTH", "ABS", "CEIL", "FLOOR", "ROUND", "SIGN", "SQRT",
                            "PROPERTIES", "EXISTS", "STARTNODE", "ENDNODE"]
            for func_name in scalar_funcs:
                if data_path_upper.startswith(f"{func_name}(") and data_path_upper.endswith(")"):
                    # Extract the entity from inside the function using original data_path
                    data_path_str = data_path if isinstance(data_path, str) else str(data_path)
                    inner_entity = data_path_str[len(func_name)+1:-1]
                    inner_name, inner_attr = _data_path_to_entity_name_attribute(inner_entity)

                    ret = []

                    # If no matches and inner entity is not a graph entity, treat as literal
                    if not true_matches:
                        literal_val = None
                        if inner_entity in self._literal_returns:
                            literal_val = self._literal_returns[inner_entity]
                        elif inner_name not in motif_nodes and inner_name not in self._return_edges:
                            # Not a graph entity — treat inner_entity as a literal string value
                            # (ESTRING values have already been unquoted by the eval() transformer)
                            literal_val = inner_entity
                        if literal_val is not None:
                            computed = self._apply_scalar_func(func_name, literal_val)
                            result[data_path] = [computed]
                            processed_paths.add(data_path)
                            break

                    for match in true_matches:
                        # Special handling for edge functions (TYPE, STARTNODE, ENDNODE)
                        if inner_name in self._return_edges and func_name in ("TYPE", "STARTNODE", "ENDNODE"):
                            edge_mapping = self._return_edges[inner_name]
                            try:
                                u, v = edge_mapping
                                host_u = match.node_mappings.get(u)
                                host_v = match.node_mappings.get(v)
                                if host_u is not None and host_v is not None:
                                    if func_name == "TYPE":
                                        # For MultiDiGraph, we need to use the edge key from match.edge_mapping
                                        edge_key = None
                                        if match.edge_mapping is not None and (u, v) in match.edge_mapping.edge_key_map:
                                            edge_hop_key = match.edge_mapping.edge_key_map[(u, v)]
                                            if edge_hop_key.keys:
                                                edge_key = edge_hop_key.keys[0]

                                        if self._target_graph.is_multigraph():
                                            if edge_key is not None:
                                                edge_data = self._target_graph.get_edge_data(host_u, host_v, key=edge_key) or {}
                                            else:
                                                # Fallback: get first edge
                                                all_edges = self._target_graph.get_edge_data(host_u, host_v)
                                                if all_edges:
                                                    first_key = next(iter(all_edges.keys()))
                                                    edge_data = all_edges[first_key]
                                                else:
                                                    edge_data = {}
                                        else:
                                            edge_data = self._target_graph.get_edge_data(host_u, host_v) or {}

                                        labels = edge_data.get("__labels__", None)
                                        if isinstance(labels, set):
                                            ret.append(next(iter(labels)) if labels else None)
                                        elif isinstance(labels, (list, tuple)):
                                            ret.append(labels[0] if labels else None)
                                        else:
                                            ret.append(labels)
                                    elif func_name == "STARTNODE":
                                        node_data = dict(self._target_graph.nodes.get(host_u, {}))
                                        ret.append({k: val for k, val in node_data.items() if not k.startswith("__")})
                                    elif func_name == "ENDNODE":
                                        node_data = dict(self._target_graph.nodes.get(host_v, {}))
                                        ret.append({k: val for k, val in node_data.items() if not k.startswith("__")})
                                else:
                                    ret.append(None)
                            except (ValueError, TypeError, KeyError):
                                ret.append(None)
                            continue

                        host_node = match.node_mappings.get(inner_name)
                        if host_node is None:
                            ret.append(None)
                            continue

                        if inner_attr:
                            value = self._target_graph.nodes[host_node].get(inner_attr, None)
                        else:
                            # For type/edge functions, inner_name might be an edge
                            if inner_name in self._return_edges:
                                value = None  # Edge functions handled separately
                            else:
                                value = self._target_graph.nodes[host_node]

                        # Apply the scalar function
                        if value is not None:
                            if func_name == "TOLOWER":
                                ret.append(str(value).lower() if value else None)
                            elif func_name == "TOUPPER":
                                ret.append(str(value).upper() if value else None)
                            elif func_name == "TRIM":
                                ret.append(str(value).strip() if value else None)
                            elif func_name == "SIZE":
                                ret.append(len(value) if hasattr(value, '__len__') else 0)
                            elif func_name == "HEAD":
                                ret.append(value[0] if hasattr(value, '__getitem__') and len(value) > 0 else None)
                            elif func_name == "TAIL":
                                ret.append(list(value[1:]) if hasattr(value, '__getitem__') and len(value) > 1 else [])
                            elif func_name == "LABELS":
                                labels = self._target_graph.nodes[host_node].get("__labels__", set())
                                ret.append(list(labels))
                            elif func_name == "KEYS":
                                attrs = self._target_graph.nodes[host_node]
                                ret.append([k for k, v in attrs.items() if not k.startswith("__") and v is not None])
                            elif func_name == "TYPE":
                                # TYPE is for edges - handled elsewhere
                                ret.append(None)
                            elif func_name == "TOSTRING":
                                ret.append(str(value))
                            elif func_name == "TOINTEGER":
                                try:
                                    ret.append(int(float(value)) if not isinstance(value, bool) else (1 if value else 0))
                                except (ValueError, TypeError):
                                    ret.append(None)
                            elif func_name == "TOFLOAT":
                                try:
                                    ret.append(float(value))
                                except (ValueError, TypeError):
                                    ret.append(None)
                            elif func_name == "TOBOOLEAN":
                                if isinstance(value, bool):
                                    ret.append(value)
                                elif isinstance(value, str):
                                    if value.lower() == "true":
                                        ret.append(True)
                                    elif value.lower() == "false":
                                        ret.append(False)
                                    else:
                                        ret.append(None)
                                else:
                                    ret.append(None)
                            elif func_name == "REVERSE":
                                if isinstance(value, str):
                                    ret.append(value[::-1])
                                elif isinstance(value, list):
                                    ret.append(list(reversed(value)))
                                else:
                                    ret.append(None)
                            elif func_name == "LTRIM":
                                ret.append(str(value).lstrip() if value else None)
                            elif func_name == "RTRIM":
                                ret.append(str(value).rstrip() if value else None)
                            elif func_name == "LENGTH":
                                ret.append(len(value) if hasattr(value, '__len__') else 0)
                            elif func_name == "ABS":
                                ret.append(abs(value) if isinstance(value, (int, float)) else None)
                            elif func_name == "CEIL":
                                import math
                                ret.append(math.ceil(value) if isinstance(value, (int, float)) else None)
                            elif func_name == "FLOOR":
                                import math
                                ret.append(math.floor(value) if isinstance(value, (int, float)) else None)
                            elif func_name == "ROUND":
                                ret.append(round(value) if isinstance(value, (int, float)) else None)
                            elif func_name == "SIGN":
                                if isinstance(value, (int, float)):
                                    ret.append(1 if value > 0 else (-1 if value < 0 else 0))
                                else:
                                    ret.append(None)
                            elif func_name == "SQRT":
                                import math
                                ret.append(math.sqrt(value) if isinstance(value, (int, float)) and value >= 0 else None)
                            elif func_name == "PROPERTIES":
                                attrs = self._target_graph.nodes[host_node]
                                ret.append({k: v for k, v in attrs.items() if not k.startswith("__")})
                            elif func_name == "EXISTS":
                                if inner_attr:
                                    ret.append(inner_attr in self._target_graph.nodes[host_node])
                                else:
                                    ret.append(host_node is not None)
                            else:
                                ret.append(value)
                        else:
                            ret.append(None)

                    result[data_path] = ret[offset_limit]
                    processed_paths.add(data_path)
                    break

            if data_path in processed_paths:
                continue

            # Handle CASE expressions
            if data_path.startswith("CASE_") and data_path in self._case_expressions:
                case_expr = self._case_expressions[data_path]
                ret = []
                for match in true_matches:
                    case_result = self._evaluate_case_expression(case_expr, match)
                    ret.append(case_result)
                result[data_path] = ret[offset_limit]
                processed_paths.add(data_path)
                continue

            # Handle list comprehensions
            if data_path.startswith("LISTCOMP_") and data_path in self._list_comprehensions:
                list_comp = self._list_comprehensions[data_path]
                ret = []
                for match in true_matches:
                    comp_result = self._evaluate_list_comprehension(list_comp, match)
                    ret.append(comp_result)
                result[data_path] = ret[offset_limit]
                processed_paths.add(data_path)
                continue

            # Handle literal return values
            if data_path in self._literal_returns:
                # For standalone RETURN with no MATCH, return a single row
                num_rows = max((len(v) for v in result.values()), default=0) if result else 0
                if num_rows == 0:
                    num_rows = max(len(true_matches), 1)
                result[data_path] = [self._literal_returns[data_path]] * num_rows
                processed_paths.add(data_path)
                continue

            # Handle multi-argument function calls (REPLACE, SUBSTRING, SPLIT, LEFT, RIGHT, COALESCE, RANGE)
            if data_path in self._func_calls:
                func_info = self._func_calls[data_path]
                func_name = func_info[0]
                ret = []
                for match in true_matches:
                    val = self._execute_func_call(func_name, func_info, match)
                    ret.append(val)
                result[data_path] = ret[offset_limit]
                processed_paths.add(data_path)
                continue

            # Handle expression trees (e.g., function calls like rand(), ceil(1.7), count > 0)
            if data_path in self._expression_trees:
                tree = self._expression_trees[data_path]
                ret = []
                # Check if we have WITH results - evaluate per WITH result row
                if self._with_results is not None and self._with_results:
                    num_rows = max(len(v) for v in self._with_results.values())
                    # If we also have matches from a subsequent MATCH clause, combine them
                    if true_matches:
                        for i in range(num_rows):
                            row = {}
                            for key, values in self._with_results.items():
                                if i < len(values):
                                    row[key] = values[i]
                            for match in true_matches:
                                # Also add match node mappings to row
                                for var_name, host_node in match.node_mappings.items():
                                    if var_name not in row:
                                        node_data = self._target_graph.nodes.get(host_node, {})
                                        row[var_name] = dict(node_data) if node_data else {}
                                ctx = EvalContext(row=row, graph=self._target_graph, match=match, return_edges=self._return_edges)
                                val = self._eval_expression(tree, ctx)
                                ret.append(val)
                    else:
                        for i in range(num_rows):
                            row = {}
                            for key, values in self._with_results.items():
                                if i < len(values):
                                    row[key] = values[i]
                            ctx = EvalContext(row=row, graph=self._target_graph, return_edges=self._return_edges)
                            val = self._eval_expression(tree, ctx)
                            ret.append(val)
                elif not true_matches:
                    # No matches - evaluate with empty context
                    ctx = EvalContext(row={}, graph=self._target_graph)
                    val = self._eval_expression(tree, ctx)
                    ret = [val]
                else:
                    for match in true_matches:
                        # Build row context from match bindings
                        row = {}
                        for var_name, host_node in match.node_mappings.items():
                            node_data = self._target_graph.nodes.get(host_node, {})
                            row[var_name] = dict(node_data) if node_data else {}
                        ctx = EvalContext(row=row, graph=self._target_graph, match=match, return_edges=self._return_edges)
                        val = self._eval_expression(tree, ctx)
                        ret.append(val)
                result[data_path] = ret[offset_limit]
                processed_paths.add(data_path)
                continue

            # Check if entity is in _with_results (from CREATE/MERGE or previous WITH)
            in_with_results = self._with_results is not None and entity_name in self._with_results
            # Also check if entity can be resolved through _with_variable_mappings
            # (for original variable names that were aliased in WITH clause)
            can_resolve_via_mapping = (
                self._with_results is not None
                and hasattr(self, '_with_variable_mappings')
                and entity_name in self._with_variable_mappings
                and self._with_variable_mappings[entity_name] in self._with_results
            )
            # Check if entity_name is a RETURN alias (e.g., rel.id where rel is alias for r)
            is_return_alias = entity_name in self._alias2entity
            # Also check if the aliased entity is valid
            aliased_entity_valid = False
            if is_return_alias:
                original_entity = self._alias2entity[entity_name]
                aliased_entity_valid = (
                    original_entity in motif_nodes
                    or original_entity in self._return_edges
                    or (self._with_results is not None and original_entity in self._with_results)
                )
            if (
                entity_name not in motif_nodes
                and entity_name not in self._return_edges
                and entity_name not in self._paths
                and not in_with_results
                and not can_resolve_via_mapping
                and not aliased_entity_valid
                and not data_path.startswith("CASE_")
                and not data_path.startswith("LISTCOMP_")
                and data_path not in self._post_aggregation_expressions
            ):
                raise NotImplementedError(f"Unknown entity name: {data_path}")

        for data_path in data_paths:
            if data_path in processed_paths:  # Skip already processed paths
                continue

            # Skip post-aggregation expressions - they'll be evaluated later
            if data_path in self._post_aggregation_expressions:
                continue

            # Translate alias property paths (e.g., rel.id -> r.id where rel is alias for r)
            original_data_path = data_path
            entity_name, entity_attribute = _data_path_to_entity_name_attribute(
                data_path
            )
            if entity_name in self._alias2entity:
                actual_entity = self._alias2entity[entity_name]
                if entity_attribute:
                    data_path = f"{actual_entity}.{entity_attribute}"
                else:
                    data_path = actual_entity
                entity_name = actual_entity

            if entity_name in motif_nodes:
                # We are looking for a node mapping in the target graph:

                if entity_attribute:
                    # Get the correct entity from the target host graph,
                    # and then return the attribute:
                    def get_node_attr(match, attr):
                        host_node = match.node_mappings.get(entity_name)
                        if host_node is None:  # None from OPTIONAL MATCH
                            return None
                        return self._target_graph.nodes[host_node].get(attr, None)

                    ret = (
                        get_node_attr(match, entity_attribute)
                        for match in true_matches
                    )
                else:
                    # Return the full node dictionary with all attributes
                    def get_node_dict(match):
                        host_node = match.node_mappings.get(entity_name)
                        if host_node is None:  # None from OPTIONAL MATCH
                            return None
                        return self._target_graph.nodes[host_node]

                    ret = (
                        get_node_dict(match)
                        for match in true_matches
                    )

            elif entity_name in self._paths:
                ret = []
                # for mapping, _, _ in true_matches:
                    # mapping = mapping[0]
                for match in true_matches:
                    mapping = match.node_mappings
                    path, nodes = [], list(mapping.values())
                    for x, node in enumerate(nodes):
                        # Edge - only add if there's an actual edge (not for 0-hop self-reference)
                        # TODO: this edge getting might not be correct in the case of MultiGraph
                        if x > 0:
                            prev_node = nodes[x - 1]
                            edge_data = self._target_graph.get_edge_data(prev_node, node)
                            # Skip if no edge exists (e.g., 0-hop path where prev == current)
                            if edge_data is not None:
                                path.append(edge_data)
                            elif prev_node == node:
                                # 0-hop case: skip this duplicate node entirely
                                continue

                        # Node
                        path.append(node)

                    ret.append(path)

            else:
                edge_mapping = self._return_edges[entity_name]
                # We are looking for an edge mapping in the target graph:
                ret = []
                for match in true_matches:
                    edge_path = match.mth.edge(*edge_mapping) if match.mth else None
                    if edge_path is None:
                        ret.append(None)
                    else:
                        host_edges = edge_path.edges
                        ret.append(
                            get_edge_from_host(
                                self._target_graph,
                                host_edges,
                                entity_attribute,
                            )
                        )

            result[original_data_path] = list(ret)[offset_limit]
        return result

    def _format_aggregation_key(self, func, entity, is_distinct=False):
        if is_distinct:
            return f"{func}(DISTINCT {entity})"
        return f"{func}({entity})"

    def aggregate(self, func, results, entity, group_keys, is_distinct=False, agg_key=None):
        # For percentile functions, entity format may be "expr|percentile"
        # Strip the percentile part for data lookup
        data_entity = entity.split('|')[0] if '|' in entity else entity

        # Determine row count — for COUNT(*), entity is "*" and not in results
        if entity == "*":
            num_rows = len(next(iter(results.values()))) if results else 0
        else:
            num_rows = len(results[data_entity])

        # Helper to make values hashable for group keys
        def _make_hashable(val):
            if isinstance(val, dict):
                # Convert dict to frozenset of sorted items for hashing
                try:
                    return ("dict", frozenset((k, _make_hashable(v)) for k, v in sorted(val.items())))
                except TypeError:
                    # Fallback to string representation if nested values are unhashable
                    return ("dict", str(sorted(val.items())))
            elif isinstance(val, list):
                return ("list", tuple(_make_hashable(v) for v in val))
            return val

        # Collect data based on group keys
        # We need to store both the hashable key and the original values for later retrieval
        grouped_data = {}
        grouped_original_keys = {}  # Maps hashable key to original values
        for i in range(num_rows):
            original_values = tuple(results[key][i] for key in group_keys if key in results)
            hashable_key = tuple(_make_hashable(v) for v in original_values)
            if hashable_key not in grouped_data:
                grouped_data[hashable_key] = []
                grouped_original_keys[hashable_key] = original_values
            if entity == "*":
                grouped_data[hashable_key].append(1)  # sentinel for row counting
            else:
                # Use data_entity (computed above) which strips the percentile suffix if present
                grouped_data[hashable_key].append(results[data_entity][i])

        def _collate_data(data, func):
            # for ["COUNT", "SUM", "AVG"], we treat None as 0
            if func in ["COUNT", "SUM", "AVG"]:
                collated_data = [
                    (v or 0)
                    for v in data
                ]
            elif func in ["MAX", "MIN"]:
                collated_data = [
                    v
                    for v in data
                ]
            elif func == "COLLECT":
                collated_data = list(data)
            else:
                collated_data = list(data)

            return collated_data

        # Apply aggregation function
        # Use list-based approach for results since keys might contain dicts (unhashable)
        aggregate_keys = []
        aggregate_values = []
        for hashable_key, data in grouped_data.items():
            # Use original key tuple for results (not hashable version)
            original_key = grouped_original_keys[hashable_key]
            collated_data = _collate_data(data, func)
            result_value = None
            if func == "COUNT":
                non_null_data = [v for v in data if v is not None]
                if is_distinct:
                    # Use _make_hashable to handle dicts and lists in DISTINCT
                    hashable_values = set(_make_hashable(v) for v in non_null_data)
                    result_value = len(hashable_values)
                else:
                    result_value = len(non_null_data)
            elif func == "SUM":
                if is_distinct:
                    # Use _make_hashable for DISTINCT comparison
                    seen = set()
                    unique_data = []
                    for v in collated_data:
                        h = _make_hashable(v)
                        if h not in seen:
                            seen.add(h)
                            unique_data.append(v)
                    collated_data = unique_data
                result_value = sum(collated_data)
            elif func == "AVG":
                if is_distinct:
                    # Use _make_hashable for DISTINCT comparison
                    seen = set()
                    unique_data = []
                    for v in collated_data:
                        h = _make_hashable(v)
                        if h not in seen:
                            seen.add(h)
                            unique_data.append(v)
                    collated_data = unique_data
                result_value = sum(collated_data) / len(collated_data)
            elif func == "MAX":
                # Filter out None values - don't replace with sentinel to avoid type mixing
                non_null = [d for d in collated_data if d is not None]
                if non_null:
                    # Use Cypher comparison key for mixed-type support
                    result_value = max(non_null, key=_cypher_compare_key)
                else:
                    result_value = None
            elif func == "MIN":
                # Filter out None values - don't replace with sentinel to avoid type mixing
                non_null = [d for d in collated_data if d is not None]
                if non_null:
                    # Use Cypher comparison key for mixed-type support
                    result_value = min(non_null, key=_cypher_compare_key)
                else:
                    result_value = None
            elif func == "COLLECT":
                non_null_data = [v for v in collated_data if v is not None]
                if is_distinct:
                    seen = set()
                    unique_data = []
                    for v in non_null_data:
                        hashable_v = str(v) if not isinstance(v, (int, float, str, type(None))) else v
                        if hashable_v not in seen:
                            seen.add(hashable_v)
                            unique_data.append(v)
                    result_value = unique_data
                else:
                    result_value = non_null_data
            elif func == "PERCENTILEDISC":
                # percentileDisc(expr, percentile) - returns discrete percentile
                non_null = [d for d in collated_data if d is not None and isinstance(d, (int, float))]
                if non_null:
                    # Get percentile value - first try expression tree, then entity string
                    percentile = 0.5  # default
                    if agg_key and agg_key in self._percentile_expressions:
                        # Evaluate expression tree (handles parameters, literals, expressions)
                        tree = self._percentile_expressions[agg_key]
                        percentile_val = self._eval_expression(tree, {})
                        if percentile_val is not None and isinstance(percentile_val, (int, float)):
                            percentile = float(percentile_val)
                    elif '|' in entity:
                        # Fallback: extract from entity string (e.g., "n.price|0.5")
                        try:
                            percentile = float(entity.split('|')[1])
                        except (ValueError, IndexError):
                            pass
                    # Validate percentile is in range [0, 1]
                    if percentile < 0 or percentile > 1:
                        raise NumberOutOfRange(f"percentileDisc(): Percentile value must be between 0.0 and 1.0, got {percentile}")
                    sorted_data = sorted(non_null)
                    n = len(sorted_data)
                    # Discrete percentile: round to nearest index
                    idx = int(round(percentile * (n - 1)))
                    idx = max(0, min(idx, n - 1))
                    result_value = sorted_data[idx]
                else:
                    result_value = None
            elif func == "PERCENTILECONT":
                # percentileCont(expr, percentile) - returns continuous (interpolated) percentile
                non_null = [d for d in collated_data if d is not None and isinstance(d, (int, float))]
                if non_null:
                    # Get percentile value - first try expression tree, then entity string
                    percentile = 0.5  # default
                    if agg_key and agg_key in self._percentile_expressions:
                        # Evaluate expression tree (handles parameters, literals, expressions)
                        tree = self._percentile_expressions[agg_key]
                        percentile_val = self._eval_expression(tree, {})
                        if percentile_val is not None and isinstance(percentile_val, (int, float)):
                            percentile = float(percentile_val)
                    elif '|' in entity:
                        # Fallback: extract from entity string (e.g., "n.price|0.5")
                        try:
                            percentile = float(entity.split('|')[1])
                        except (ValueError, IndexError):
                            pass
                    # Validate percentile is in range [0, 1]
                    if percentile < 0 or percentile > 1:
                        raise NumberOutOfRange(f"percentileCont(): Percentile value must be between 0.0 and 1.0, got {percentile}")
                    sorted_data = sorted(non_null)
                    n = len(sorted_data)
                    # Continuous percentile: linear interpolation
                    pos = percentile * (n - 1)
                    lower_idx = int(pos)
                    upper_idx = min(lower_idx + 1, n - 1)
                    fraction = pos - lower_idx
                    result_value = sorted_data[lower_idx] + fraction * (sorted_data[upper_idx] - sorted_data[lower_idx])
                else:
                    result_value = None
            aggregate_keys.append(original_key)
            aggregate_values.append(result_value)
        # Return a dict-like object that supports keys() and values() but stores as lists
        return _AggregateResults(aggregate_keys, aggregate_values)

    def returns(self, ignore_limit=False):
        data_paths = (
            self._return_requests
            + list(self._order_by_attributes)
            + list(self._aggregation_attributes)
        )
        # aliases should already be requested in their original form, so we will remove them for lookup
        # BUT: keep paths that are in WITH results (those take precedence)
        # Also keep self-aliases (a -> a) since they're the same entity
        if self._with_results is not None:
            data_paths = [d for d in data_paths if d not in self._alias2entity or d in self._with_results or self._alias2entity.get(d) == d]
        else:
            data_paths = [d for d in data_paths if d not in self._alias2entity or self._alias2entity.get(d) == d]

        # If we have WITH results, use those for lookups
        if self._with_results is not None:
            results = {}
            for path in data_paths:
                # If this path has an expression tree, use _lookup to evaluate it properly
                if path in self._expression_trees:
                    continue  # Will be handled by missing_paths -> _lookup
                if path in self._with_results:
                    results[path] = self._with_results[path]
                elif path in self._with_variable_mappings:
                    original = self._with_variable_mappings[path]
                    if original in self._with_results:
                        results[path] = self._with_results[original]
                else:
                    # Check if path is a function call on a WITH variable
                    resolved = self._resolve_func_on_with_results(path)
                    if resolved is not None:
                        results[path] = resolved
                    else:
                        # Check if path is a property access on a WITH variable (e.g., n.name)
                        resolved = self._resolve_property_on_with_results(path)
                        if resolved is not None:
                            results[path] = resolved
            # For any paths not found in WITH results, fall back to normal lookup
            missing_paths = [p for p in data_paths if p not in results]
            if missing_paths:
                normal_results = self._lookup(missing_paths, offset_limit=slice(0, None))
                results.update(normal_results)
        else:
            results = self._lookup(
                data_paths,
                offset_limit=slice(0, None),
            )

        # Initialize aggregated_keys before it might be used in post-aggregation expressions
        aggregated_keys = None

        if len(self._aggregate_functions) > 0:
            # For COUNT(*) when results is empty (no other return items),
            # inject a sentinel column so aggregate() knows the row count.
            has_star_agg = any(t[1] == "*" for t in self._aggregate_functions)
            if has_star_agg and not results:
                # Check if we have _with_results from CREATE/MERGE
                if self._with_results:
                    # Get row count from _with_results
                    num_rows = max(len(v) for v in self._with_results.values()) if self._with_results else 0
                    results["__star__"] = [1] * num_rows
                else:
                    true_matches = self._get_true_matches()
                    results["__star__"] = [1] * len(true_matches)

            # Collect entity names from aggregate functions, excluding "*"
            # For percentile functions, entity format may be "expr|percentile" - strip the suffix
            agg_entities = set()
            for func_tuple in self._aggregate_functions:
                entity = func_tuple[1]
                if entity != "*":
                    # Strip percentile suffix if present
                    base_entity = entity.split('|')[0] if '|' in entity else entity
                    agg_entities.add(base_entity)
            group_keys = [
                key
                for key in results.keys()
                if key != "__star__" and not any(key.endswith(ent) for ent in agg_entities)
            ]

            aggregated_results = {}
            for agg_tuple in self._aggregate_functions:
                # Handle both 3-tuple (old format) and 4-tuple (new with display key)
                if len(agg_tuple) == 4:
                    func, entity, is_distinct, func_key = agg_tuple
                else:
                    func, entity, is_distinct = agg_tuple
                    func_key = self._format_aggregation_key(func, entity, is_distinct)
                aggregated_data = self.aggregate(func, results, entity, group_keys, is_distinct, func_key)
                aggregated_values = list(aggregated_data.values())
                aggregated_keys = list(aggregated_data.keys())
                aggregated_results[func_key] = aggregated_values
                self._return_requests.append(func_key)

            # Handle aggregation with no matches and no group keys
            # COUNT returns [0], other aggregations (AVG, MIN, MAX, SUM) return [null]
            if not group_keys and aggregated_keys is not None and len(aggregated_keys) == 0:
                for agg_tuple in self._aggregate_functions:
                    if len(agg_tuple) == 4:
                        func, entity, is_distinct, func_key = agg_tuple
                    else:
                        func, entity, is_distinct = agg_tuple
                        func_key = self._format_aggregation_key(func, entity, is_distinct)
                    if func == "COUNT":
                        aggregated_results[func_key] = [0]
                    elif func == "COLLECT":
                        aggregated_results[func_key] = [[]]  # Empty list for COLLECT
                    else:
                        # AVG, MIN, MAX, SUM return null on empty input
                        aggregated_results[func_key] = [None]
                aggregated_keys = [()]

            results.update(aggregated_results)
            if aggregated_keys is not None:
                for i in range(len(group_keys)):
                    results[group_keys[i]] = [k[i] for k in aggregated_keys]
            # Remove sentinel column used for COUNT(*)
            results.pop("__star__", None)
            # Remove raw aggregation entity data (e.g., 'r.value') that was used for aggregation
            # These are replaced by the aggregated results (e.g., 'SUM(r.value)')
            for entity in self._aggregation_attributes:
                if entity in results and entity not in group_keys:
                    results.pop(entity, None)

        # Evaluate post-aggregation expressions (e.g., count(n) / 60 / 60)
        if self._post_aggregation_expressions:
            # Use aggregated row count, not original match count
            # aggregated_keys length gives us the number of aggregation groups (1 for global aggregation)
            if aggregated_keys is not None:
                num_rows = len(aggregated_keys)
            else:
                num_rows = max(len(v) for v in results.values()) if results else 1

            for expr_text, rewritten_tree in self._post_aggregation_expressions.items():
                # Evaluate rewritten expression for each row
                expr_results = []
                for i in range(num_rows):
                    # Build row context with aggregated values
                    row = {}

                    # Add aggregation placeholder values
                    for placeholder, agg_key in self._aggregation_placeholders.items():
                        if agg_key in results and i < len(results[agg_key]):
                            row[placeholder] = results[agg_key][i]

                    # Evaluate rewritten expression with row context
                    ctx = EvalContext(row=row, graph=self._target_graph, return_edges=self._return_edges)
                    expr_results.append(self._eval_expression(rewritten_tree, ctx))

                results[expr_text] = expr_results

        if self._order_by:
            results = self._apply_order_by(results)

        # Apply DISTINCT before pagination
        if self._distinct:
            results = self._apply_distinct(results)

        # Only after all other transformations, apply pagination
        results = self._apply_pagination(results, ignore_limit)
        self._return_requests = list(map(str, self._return_requests))

        # Build final results respecting the order and aliasing of _return_requests.
        # When an expression appears multiple times, only the first occurrence uses its alias.
        # E.g., RETURN a.id AS a, a.id -> two columns: 'a' (first, aliased) and 'a.id' (second)
        final_results = {}
        used_aliases = set()  # Track which aliases have been used
        for req_key in self._return_requests:
            # Check if this key has an alias that hasn't been used yet
            alias = self._entity2alias.get(req_key)
            if alias and alias not in used_aliases:
                # Use the alias for this occurrence
                output_key = alias
                used_aliases.add(alias)
            else:
                # Use the original key
                output_key = req_key

            # Skip if we already have this output key
            if output_key in final_results:
                continue

            # Get the values for this key
            if req_key in results:
                final_results[output_key] = results[req_key]
            elif req_key in self._alias2entity:
                # req_key is an alias, look up the original
                orig_key = self._alias2entity[req_key]
                if orig_key in results:
                    final_results[output_key] = results[orig_key]

        results = final_results

        # HACK: convert to [None] if edge is None
        for key, values in results.items():
            parsed_values = []
            for v in values:
                if v == [{0: None}]:  # edge is None
                    parsed_values.append([None])
                else:
                    parsed_values.append(v)
            results[key] = parsed_values

        return results

    def _apply_order_by(self, results):
        if self._order_by:
            # Determine the expected row count from non-empty lists
            max_len = max((len(v) for v in results.values()), default=0)
            if max_len == 0:
                return results  # Nothing to sort if all lists are empty

            # Ensure all lists have the same length (pad with None if needed)
            for key in results:
                if len(results[key]) < max_len:
                    results[key] = results[key] + [None] * (max_len - len(results[key]))

            sort_lists = []
            for field, direction in self._order_by:
                # Translate alias to original key if needed
                actual_field = self._alias2entity.get(field, field)
                # Also handle property access on aliases (e.g., rel.id -> r.id)
                if actual_field == field and "." in field:
                    entity, prop = field.split(".", 1)
                    if entity in self._alias2entity:
                        actual_field = f"{self._alias2entity[entity]}.{prop}"
                if actual_field in results:
                    sort_lists.append((results[actual_field], field, direction))
                elif field in results:
                    sort_lists.append((results[field], field, direction))
                else:
                    raise KeyError(f"ORDER BY field '{field}' not found in results")

            if sort_lists:
                # Generate a list of indices sorted by the specified fields
                indices = range(max_len)
                for sort_list, field, direction in reversed(
                    sort_lists
                ):  # reverse to ensure the first sort key is primary
                    if all(isinstance(item, dict) for item in sort_list):
                        # (for edge attributes) If all items in sort_list are dictionaries
                        # example: ([{(0, 'paid'): 9, (1, 'paid'): 40}, {(0, 'paid'): 14}], 'DESC')

                        # sort within each edge first
                        sorted_sublists = []
                        for sublist in sort_list:
                            # Create a new sorted dictionary directly
                            sorted_dict = {}
                            # Get keys sorted by their values
                            sorted_keys = sorted(
                                sublist.keys(),
                                key=lambda k: sublist[k] or 0,  # 0 if `None`
                                reverse=(direction == "DESC"),
                            )
                            # Insert keys in sorted order
                            for k in sorted_keys:
                                sorted_dict[k] = sublist[k]
                            sorted_sublists.append(sorted_dict)
                        sort_list = sorted_sublists

                        # then sort the indices based on the sorted sublists
                        indices = sorted(
                            indices,
                            key=lambda i: list(sort_list[i].values())[0]
                            or 0,  # 0 if `None`
                            reverse=(direction == "DESC"),
                        )
                        # update results with sorted edge attributes list
                        results[field] = sort_list
                    else:
                        # (for node attributes) single values - use Cypher type ordering
                        indices = sorted(
                            indices,
                            key=lambda i: _cypher_compare_key(sort_list[i]),
                            reverse=(direction == "DESC"),
                        )

                # Reorder all lists in results using sorted indices
                for key in results:
                    results[key] = [results[key][i] for i in indices]

        return results

    def _apply_distinct(self, results):
        if self._order_by:
            # Check that ORDER BY attributes are accessible:
            # 1. Directly in return requests (e.g., 'name' when RETURN name)
            # 2. Property of a returned entity (e.g., 'a.name' when RETURN a)
            # 3. In the actual result keys (including aliases)
            # 4. An alias that maps to a returned entity
            result_keys_set = set(results.keys())
            for attr in self._order_by_attributes:
                if attr in self._return_requests:
                    continue  # Directly returned
                if attr in result_keys_set:
                    continue  # In result keys (alias or entity)
                # Check if it's an alias for a returned entity
                if attr in self._alias2entity:
                    original = self._alias2entity[attr]
                    if original in self._return_requests:
                        continue  # Alias for a returned entity
                # Check if it's a property of a returned entity
                if '.' in attr:
                    entity = attr.split('.')[0]
                    if entity in self._return_requests or entity in result_keys_set:
                        continue  # Property of returned entity
                    # Also check alias mapping
                    if entity in self._alias2entity:
                        original = self._alias2entity[entity]
                        if original in self._return_requests or original in result_keys_set:
                            continue
                raise AssertionError(
                    "In a WITH/RETURN with DISTINCT or an aggregation, it is not "
                    "possible to access variables declared before the WITH/RETURN"
                )

        # Use actual result keys (which may already have aliases applied)
        result_keys = list(results.keys())

        # ordered dict to maintain first occurrence of each unique tuple
        unique_rows = OrderedDict()

        # Iterate over each 'row' by index
        for i in range(
            len(next(iter(results.values())))
        ):  # assume all columns are of the same length
            # create a tuple key of all the values from result keys for this row
            # Make the key hashable by converting unhashable types (dicts, lists) to strings
            row_values = []
            for key in result_keys:
                val = results[key][i]
                if isinstance(val, (dict, list)):
                    # Convert to string representation for hashing
                    row_values.append(str(sorted(val.items()) if isinstance(val, dict) else val))
                elif isinstance(val, set):
                    # Convert set to sorted tuple for hashing
                    row_values.append(tuple(sorted(val)))
                else:
                    row_values.append(val)
            row_key = tuple(row_values)

            if row_key not in unique_rows:
                unique_rows[row_key] = (
                    i  # store the index of the first occurrence of this unique row
                )

        # construct the results based on unique indices collected
        distinct_results = {key: [] for key in result_keys}
        for index in unique_rows.values():
            for key in result_keys:
                distinct_results[key].append(results[key][index])

        return distinct_results

    def _apply_pagination(self, results, ignore_limit):
        """
        Apply pagination (skip & limit) to results.

        Args:
            results: Dictionary of result lists
            ignore_limit: Whether to ignore the limit constraint

        Returns:
            Dictionary with paginated results
        """
        # If there are no results, return early
        if not results:
            return results

        # Get the length of any result list (all should be the same length)
        if not any(results.values()):
            return results

        result_length = len(next(iter(results.values())))
        if result_length == 0:
            return results

        # Apply skip first
        start_index = min(self._skip or 0, result_length)

        # Then apply limit if needed
        if self._limit is not None and not ignore_limit:
            end_index = min(start_index + self._limit, result_length)
        else:
            end_index = result_length

        # Apply pagination to all result lists
        paginated_results = {}
        for key, values in results.items():
            paginated_results[key] = values[start_index:end_index]

        return paginated_results

    def _resolve_func_on_with_results(self, path: str) -> Optional[List]:
        """Resolve a scalar/multi-arg function call where the inner entity is in _with_results.

        Returns a list of computed values, or None if this path is not a function call
        on a WITH variable.
        """
        if self._with_results is None:
            return None

        path_upper = path.upper() if isinstance(path, str) else str(path).upper()
        path_str = path if isinstance(path, str) else str(path)

        # Check single-argument scalar functions
        all_funcs = {
            "TOLOWER": lambda v: str(v).lower() if v is not None else None,
            "TOUPPER": lambda v: str(v).upper() if v is not None else None,
            "TRIM": lambda v: str(v).strip() if v is not None else None,
            "SIZE": lambda v: len(v) if v is not None and hasattr(v, '__len__') else (0 if v is not None else None),
            "HEAD": lambda v: v[0] if v is not None and hasattr(v, '__getitem__') and len(v) > 0 else None,
            "TAIL": lambda v: list(v[1:]) if v is not None and hasattr(v, '__getitem__') and len(v) > 1 else [],
            "TOSTRING": lambda v: str(v) if v is not None else None,
            "TOINTEGER": lambda v: self._safe_tointeger(v),
            "TOFLOAT": lambda v: float(v) if v is not None else None,
            "TOBOOLEAN": lambda v: self._safe_toboolean(v),
            "REVERSE": lambda v: v[::-1] if isinstance(v, str) else (list(reversed(v)) if isinstance(v, list) else None),
            "LTRIM": lambda v: str(v).lstrip() if v is not None else None,
            "RTRIM": lambda v: str(v).rstrip() if v is not None else None,
            "LENGTH": lambda v: len(v) if v is not None and hasattr(v, '__len__') else (0 if v is not None else None),
            "ABS": lambda v: abs(v) if isinstance(v, (int, float)) else None,
            "CEIL": lambda v: __import__('math').ceil(v) if isinstance(v, (int, float)) else None,
            "FLOOR": lambda v: __import__('math').floor(v) if isinstance(v, (int, float)) else None,
            "ROUND": lambda v: round(v) if isinstance(v, (int, float)) else None,
            "SIGN": lambda v: (1 if v > 0 else (-1 if v < 0 else 0)) if isinstance(v, (int, float)) else None,
            "SQRT": lambda v: __import__('math').sqrt(v) if isinstance(v, (int, float)) and v >= 0 else None,
            "LABELS": lambda v: self._get_labels(v),
            "KEYS": lambda v: self._get_keys(v),
            "PROPERTIES": lambda v: self._get_properties(v),
        }

        for func_name, func in all_funcs.items():
            if path_upper.startswith(f"{func_name}(") and path_upper.endswith(")"):
                inner_entity = path_str[len(func_name)+1:-1]
                if inner_entity in self._with_results:
                    return [func(v) for v in self._with_results[inner_entity]]
                # Inner entity is not in WITH results — could be a literal value
                # (ESTRING values are already unquoted by eval() transformer)
                # Try to use it as a literal
                return [func(inner_entity)]

        # Check multi-argument function calls
        if path in self._func_calls:
            func_info = self._func_calls[path]
            func_name = func_info[0]
            # Determine the number of rows from with_results
            num_rows = max((len(v) for v in self._with_results.values()), default=1)
            ret = []
            for i in range(num_rows):
                row = {k: v[i] if i < len(v) else None for k, v in self._with_results.items()}
                val = self._execute_func_call_with_row(func_name, func_info, row)
                ret.append(val)
            return ret

        return None

    def _resolve_property_on_with_results(self, path: str) -> Optional[List]:
        """Resolve a property access (e.g., n.name or nestedMap.name.name2) where entity is in _with_results.

        Returns a list of property values, or None if this path is not a property access
        on a WITH variable.
        """
        if self._with_results is None:
            return None

        if not isinstance(path, str) or "." not in path:
            return None

        # Split path into all parts (e.g., "nestedMap.name.name2" -> ["nestedMap", "name", "name2"])
        parts = path.split(".")
        if len(parts) < 2:
            return None

        entity = parts[0]
        props = parts[1:]  # All remaining parts are property names
        if entity not in self._with_results:
            return None

        # Extract property from each value in _with_results[entity]
        values = self._with_results[entity]
        result = []
        for val in values:
            # Navigate through the property chain
            current = val
            for prop in props:
                if current is None:
                    current = None
                    break
                elif isinstance(current, dict):
                    current = current.get(prop)
                elif isinstance(current, str) and current in self._target_graph.nodes:
                    # current is a node ID - look up the property from the graph
                    node_data = self._target_graph.nodes[current]
                    current = node_data.get(prop)
                elif hasattr(current, prop):
                    current = getattr(current, prop)
                else:
                    current = None
                    break
            result.append(current)
        return result

    @staticmethod
    def _safe_tointeger(v):
        if v is None:
            return None
        if isinstance(v, bool):
            return 1 if v else 0
        try:
            return int(float(v))
        except (ValueError, TypeError):
            return None

    @staticmethod
    def _safe_toboolean(v):
        if v is None:
            return None
        if isinstance(v, bool):
            return v
        if isinstance(v, str):
            if v.lower() == "true":
                return True
            elif v.lower() == "false":
                return False
        return None

    def _get_type(self, val):
        """Get the type (edge label) from an edge value."""
        if isinstance(val, str):
            return val
        if isinstance(val, dict):
            labels = val.get("__labels__")
            if labels:
                if isinstance(labels, set):
                    return next(iter(labels)) if labels else None
                if isinstance(labels, (list, tuple)):
                    return labels[0] if labels else None
                return str(labels)
        return None

    def _get_labels(self, val):
        """Get labels from a node value."""
        if isinstance(val, list):
            return val
        if isinstance(val, dict):
            labels = val.get("__labels__")
            if labels:
                if isinstance(labels, set):
                    return sorted(labels)
                if isinstance(labels, (list, tuple)):
                    return list(labels)
                return [str(labels)]
        return []

    def _validate_type(self, value, expected_types, operation):
        """Validate value type matches expected types.

        Args:
            value: Value to validate
            expected_types: Tuple of acceptable types or single type
            operation: Operation name for error message

        Raises:
            InvalidArgumentType: If type doesn't match
        """
        if not isinstance(value, expected_types):
            actual_type = type(value).__name__
            if isinstance(expected_types, tuple):
                expected = ' or '.join(t.__name__ for t in expected_types)
            else:
                expected = expected_types.__name__
            raise InvalidArgumentType(
                f"Type mismatch: {operation} expects {expected}, got {actual_type}"
            )

    def _validate_integer(self, value, operation, allow_negative=True):
        """Validate value is an integer.

        Raises:
            InvalidArgumentType: If not integer
            NegativeIntegerArgument: If negative when not allowed
        """
        if not isinstance(value, (int, float)) or isinstance(value, bool):
            raise InvalidArgumentType(f"{operation} requires integer argument")

        if not allow_negative and value < 0:
            raise NegativeIntegerArgument(f"{operation} requires non-negative integer")

    def _apply_scalar_func(self, func_name: str, value):
        """Apply a scalar function to a single value."""
        import math
        if value is None:
            if func_name == "EXISTS":
                return False
            return None
        funcs = {
            "TOLOWER": lambda v: str(v).lower(),
            "TOUPPER": lambda v: str(v).upper(),
            "TRIM": lambda v: str(v).strip(),
            "SIZE": lambda v: None if v is None else (len(v) if hasattr(v, '__len__') else None),
            "HEAD": lambda v: v[0] if hasattr(v, '__getitem__') and len(v) > 0 else None,
            "TAIL": lambda v: list(v[1:]) if hasattr(v, '__getitem__') and len(v) > 1 else [],
            "TOSTRING": lambda v: str(v),
            "TOINTEGER": lambda v: self._safe_tointeger(v),
            "TOFLOAT": lambda v: float(v) if not isinstance(v, bool) else None,
            "TOBOOLEAN": lambda v: self._safe_toboolean(v),
            "REVERSE": lambda v: v[::-1] if isinstance(v, str) else (list(reversed(v)) if isinstance(v, list) else None),
            "LTRIM": lambda v: str(v).lstrip(),
            "RTRIM": lambda v: str(v).rstrip(),
            "LENGTH": lambda v: len(v) if hasattr(v, '__len__') else 0,
            "ABS": lambda v: abs(v) if isinstance(v, (int, float)) else None,
            "CEIL": lambda v: math.ceil(v) if isinstance(v, (int, float)) else None,
            "FLOOR": lambda v: math.floor(v) if isinstance(v, (int, float)) else None,
            "ROUND": lambda v: round(v) if isinstance(v, (int, float)) else None,
            "SIGN": lambda v: (1 if v > 0 else (-1 if v < 0 else 0)) if isinstance(v, (int, float)) else None,
            "SQRT": lambda v: math.sqrt(v) if isinstance(v, (int, float)) and v >= 0 else None,
            "EXISTS": lambda v: v is not None,
            "PROPERTIES": lambda v: v if isinstance(v, dict) else None,
        }
        if func_name in funcs:
            try:
                return funcs[func_name](value)
            except (ValueError, TypeError):
                return None
        return value

    def _execute_func_call_with_row(self, func_name: str, func_info: tuple, row: dict):
        """Execute a multi-argument function call resolving args from a row dict (for WITH/UNWIND context)."""
        def resolve(arg):
            if arg is None:
                return None
            if isinstance(arg, (int, float, bool)):
                return arg
            if isinstance(arg, str):
                if (arg.startswith('"') and arg.endswith('"')) or (arg.startswith("'") and arg.endswith("'")):
                    return arg[1:-1]
                if arg in row:
                    return row[arg]
                try:
                    return int(arg) if '.' not in arg else float(arg)
                except (ValueError, TypeError):
                    pass
                return arg
            return arg

        if func_name == "REPLACE":
            _, entity_ref, search, replacement = func_info
            value = resolve(entity_ref)
            search_val = resolve(search)
            replacement_val = resolve(replacement)
            if value is None or search_val is None or replacement_val is None:
                return None
            return str(value).replace(str(search_val), str(replacement_val))
        elif func_name == "SUBSTRING":
            _, entity_ref, start, length = func_info
            value = resolve(entity_ref)
            start_val = resolve(start)
            length_val = resolve(length)
            if value is None or start_val is None:
                return None
            s = str(value)
            si = int(start_val)
            if length_val is not None:
                return s[si:si + int(length_val)]
            return s[si:]
        elif func_name == "SPLIT":
            _, entity_ref, delimiter = func_info
            value = resolve(entity_ref)
            delim_val = resolve(delimiter)
            if value is None or delim_val is None:
                return None
            return str(value).split(str(delim_val))
        elif func_name == "LEFT":
            _, entity_ref, length = func_info
            value = resolve(entity_ref)
            length_val = resolve(length)
            if value is None or length_val is None:
                return None
            return str(value)[:int(length_val)]
        elif func_name == "RIGHT":
            _, entity_ref, length = func_info
            value = resolve(entity_ref)
            length_val = resolve(length)
            if value is None or length_val is None:
                return None
            return str(value)[-int(length_val):]
        elif func_name == "COALESCE":
            for arg in func_info[1:]:
                val = resolve(arg)
                if val is not None:
                    return val
            return None
        elif func_name == "RANGE":
            _, start, end, step = func_info
            start_val = resolve(start)
            end_val = resolve(end)
            step_val = resolve(step)
            if start_val is None or end_val is None:
                return None
            step_int = int(step_val) if step_val is not None else 1
            # Neo4j range is inclusive on both ends
            # For positive step, use end + 1; for negative step, use end - 1
            if step_int > 0:
                return list(range(int(start_val), int(end_val) + 1, step_int))
            elif step_int < 0:
                return list(range(int(start_val), int(end_val) - 1, step_int))
            else:
                return []  # step of 0 is invalid
        return None

    def _resolve_func_arg(self, arg, match: Match):
        """Resolve a function argument: if it looks like 'entity.attr', look it up from match; otherwise return as literal."""
        if arg is None:
            return None
        if isinstance(arg, (int, float, bool)):
            return arg
        if isinstance(arg, str):
            # Check if it's a string literal (quoted)
            if (arg.startswith('"') and arg.endswith('"')) or (arg.startswith("'") and arg.endswith("'")):
                return arg[1:-1]
            # Check if it's entity.attribute reference
            if "." in arg:
                entity_name, attr = arg.split(".", 1)
                host_node = match.node_mappings.get(entity_name)
                if host_node is not None:
                    return self._target_graph.nodes[host_node].get(attr, None)
                # Could be edge attribute
                if entity_name in self._return_edges:
                    edge_info = self._return_edges[entity_name]
                    return edge_info.get(attr, None) if isinstance(edge_info, dict) else None
                return None
            # Check if it's a pure entity reference (node variable)
            host_node = match.node_mappings.get(arg)
            if host_node is not None:
                return self._target_graph.nodes[host_node]
            # Try as numeric literal
            try:
                if "." in str(arg):
                    return float(arg)
                return int(arg)
            except (ValueError, TypeError):
                pass
            return arg
        return arg

    def _execute_func_call(self, func_name: str, func_info: tuple, match: Match):
        """Execute a multi-argument function call for a given match."""
        if func_name == "REPLACE":
            _, entity_ref, search, replacement = func_info
            value = self._resolve_func_arg(entity_ref, match)
            search_val = self._resolve_func_arg(search, match)
            replacement_val = self._resolve_func_arg(replacement, match)
            if value is None or search_val is None or replacement_val is None:
                return None
            return str(value).replace(str(search_val), str(replacement_val))

        elif func_name == "SUBSTRING":
            _, entity_ref, start, length = func_info
            value = self._resolve_func_arg(entity_ref, match)
            start_val = self._resolve_func_arg(start, match)
            length_val = self._resolve_func_arg(length, match)
            if value is None or start_val is None:
                return None
            s = str(value)
            start_idx = int(start_val)
            if length_val is not None:
                return s[start_idx:start_idx + int(length_val)]
            return s[start_idx:]

        elif func_name == "SPLIT":
            _, entity_ref, delimiter = func_info
            value = self._resolve_func_arg(entity_ref, match)
            delim_val = self._resolve_func_arg(delimiter, match)
            if value is None or delim_val is None:
                return None
            return str(value).split(str(delim_val))

        elif func_name == "LEFT":
            _, entity_ref, length = func_info
            value = self._resolve_func_arg(entity_ref, match)
            length_val = self._resolve_func_arg(length, match)
            if value is None or length_val is None:
                return None
            return str(value)[:int(length_val)]

        elif func_name == "RIGHT":
            _, entity_ref, length = func_info
            value = self._resolve_func_arg(entity_ref, match)
            length_val = self._resolve_func_arg(length, match)
            if value is None or length_val is None:
                return None
            return str(value)[-int(length_val):]

        elif func_name == "COALESCE":
            # func_info is ("COALESCE", arg1, arg2, ...)
            for arg in func_info[1:]:
                val = self._resolve_func_arg(arg, match)
                if val is not None:
                    return val
            return None

        elif func_name == "RANGE":
            _, start, end, step = func_info
            start_val = self._resolve_func_arg(start, match)
            end_val = self._resolve_func_arg(end, match)
            step_val = self._resolve_func_arg(step, match)
            if start_val is None or end_val is None:
                return None
            step_int = int(step_val) if step_val is not None else 1
            # Neo4j range is inclusive on both ends
            # For positive step, use end + 1; for negative step, use end - 1
            if step_int > 0:
                return list(range(int(start_val), int(end_val) + 1, step_int))
            elif step_int < 0:
                return list(range(int(start_val), int(end_val) - 1, step_int))
            else:
                return []  # step of 0 is invalid

        return None

    # ===== Unified Expression Evaluator (openCypher spec) =====

    def _eval_expression(self, node, ctx: EvalContext) -> Any:
        """Recursively evaluate an expression tree node.

        Handles all openCypher expression types: arithmetic, boolean logic,
        comparisons, string/list/null predicates, function calls, property access, etc.
        """
        import math

        # Python primitives (from ESTRING/NUMBER eval, or literal values)
        if isinstance(node, (int, float)):
            return node
        if isinstance(node, bool):
            return node
        if node is None:
            return None

        # String — could be a variable name or a literal string value
        if isinstance(node, str):
            return self._eval_variable_or_literal(node, ctx)

        # Token — extract value
        if isinstance(node, Token):
            if node.type in ("CNAME", "ID"):
                return self._eval_variable_or_literal(str(node), ctx)
            if node.type == "STRING_LITERAL" or node.type == "CHAR_LITERAL":
                s = str(node)
                if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
                    s = s[1:-1]
                return _process_cypher_string_escapes(s)
            if node.type == "DIGIT":
                s = str(node)
                # Handle hex
                if s.startswith("0x") or s.startswith("0X") or s.startswith("-0x") or s.startswith("-0X"):
                    return int(s, 16) if not s.startswith("-") else -int(s[1:], 16)
                # Handle octal (leading 0 followed by octal digits only)
                import re
                if re.match(r'^-?0[0-7]+$', s):
                    return int(s, 8) if not s.startswith("-") else -int(s[1:], 8)
                # Handle floats
                if "." in s or "e" in s.lower():
                    return float(s)
                return int(s)
            if node.type == "TRUE":
                return True
            if node.type == "FALSE":
                return False
            if node.type == "NULL_W":
                return None
            return node.value if hasattr(node, 'value') else str(node)

        # Tree node — dispatch on rule name
        if isinstance(node, Tree):
            return self._eval_tree(node, ctx)

        # Edge existence conditions (from transformer — callable objects)
        if isinstance(node, EdgeExistsCondition):
            if ctx.match is not None:
                result, _ = node(ctx.match, ctx.graph, ctx.return_edges or [])
                return result
            return None

        if isinstance(node, NegatedEdgeCondition):
            if ctx.match is not None:
                result, _ = node(ctx.match, ctx.graph, ctx.return_edges or [])
                return result
            return None

        # Sub-query result (from sub_query transformer: ["", subop_exist, executor])
        if isinstance(node, list) and len(node) == 3 and isinstance(node[1], SUBOP):
            subop = node[1]
            executor = node[2]
            if ctx.match is not None:
                return subop(ctx.match, executor)
            return False

        # List (from value_list)
        if isinstance(node, list):
            return node

        return node

    def _eval_variable_or_literal(self, name: str, ctx: EvalContext) -> Any:
        """Resolve a variable name from the current evaluation context."""
        # Try row context FIRST (WITH/UNWIND/quantifier loop variables)
        # This ensures inner scope (loop variables) shadow outer scope (match variables)
        if ctx.row is not None:
            if name in ctx.row:
                return ctx.row[name]
        # Try match context (MATCH/WHERE)
        if ctx.match is not None:
            host_node = ctx.match.node_mappings.get(name)
            if host_node is not None:
                # Return the full node dict (property access handled by property_lookup)
                return ctx.graph.nodes[host_node]
            # Check edge variables — resolve to edge data
            if ctx.return_edges and name in ctx.return_edges:
                edge_mapping = ctx.return_edges[name]
                try:
                    host_edges = ctx.match.mth.edge(*edge_mapping).edges
                    return get_edge_from_host(ctx.graph, host_edges)
                except (AttributeError, TypeError, KeyError):
                    return None
            # Check path variables — resolve to path list [node_data, edge_data, node_data, ...]
            if name in self._paths:
                mapping = ctx.match.node_mappings
                host_nodes = list(mapping.values())
                path = []
                for x, host_node in enumerate(host_nodes):
                    # Add edge between previous and current node
                    if x > 0:
                        prev_node = host_nodes[x - 1]
                        edge_data = ctx.graph.get_edge_data(prev_node, host_node)
                        # Skip if no edge exists (e.g., 0-hop path where prev == current)
                        if edge_data is None:
                            if prev_node == host_node:
                                # 0-hop case: skip this duplicate node entirely
                                continue
                        if edge_data is not None:
                            # For MultiDiGraph, get_edge_data returns {key: {...}}
                            if ctx.graph.is_multigraph():
                                # Get edge key from match.edge_mapping if available
                                edge_key = None
                                if ctx.match.edge_mapping is not None:
                                    # Find the motif edge that corresponds to this host edge
                                    for edge_id, edge_hop_key in ctx.match.edge_mapping.edge_key_map.items():
                                        motif_u, motif_v = edge_id
                                        mapped_u = ctx.match.node_mappings.get(motif_u)
                                        mapped_v = ctx.match.node_mappings.get(motif_v)
                                        if mapped_u == prev_node and mapped_v == host_node:
                                            if edge_hop_key.keys:
                                                edge_key = edge_hop_key.keys[0]
                                            break
                                if edge_key is not None and edge_key in edge_data:
                                    edge_data = edge_data[edge_key]
                                elif edge_data:
                                    # Fallback: use first edge
                                    first_key = next(iter(edge_data.keys()))
                                    edge_data = edge_data[first_key]
                                else:
                                    edge_data = {}
                            path.append(dict(edge_data))
                    # Add node data (as dict) with node ID for pattern comprehension support
                    node_data = dict(ctx.graph.nodes.get(host_node, {}))
                    node_data['__id__'] = host_node
                    path.append(node_data)
                return path
        # Check literal returns
        if name in self._literal_returns:
            return self._literal_returns[name]
        # Not found — could be an unresolved variable, return None
        return None

    def _eval_tree(self, node: Tree, ctx: EvalContext) -> Any:
        """Dispatch expression tree evaluation based on rule name."""
        import math
        data = node.data
        children = node.children

        # === Expression wrapper ===
        # v4 grammar: expression can have multiple children as OR operations
        if data == "expression":
            if len(children) == 1:
                return self._eval_expression(children[0], ctx)
            # Multiple children: evaluate as OR operations
            # Children: [expr, OR_token, expr, OR_token, ...] - skip tokens!
            result = self._eval_expression(children[0], ctx)
            # Validate first operand for OR
            self._validate_boolean_operand(result, "OR")
            for child in children[1:]:
                # Skip tokens (OR keywords), only evaluate Tree nodes
                if isinstance(child, Tree):
                    right = self._eval_expression(child, ctx)
                    # Validate each OR operand
                    self._validate_boolean_operand(right, "OR")
                    result = self._cypher_or(result, right)
            return result

        # === Boolean logic (OR > XOR > AND > NOT) ===
        if data == "or_expression":
            # Grammar: xor_expression (OR xor_expression)*
            # Children: [xor_expr, OR, xor_expr, OR, xor_expr, ...]
            # Process only Tree children (skip OR tokens)
            result = self._eval_expression(children[0], ctx)
            # Check if this is actually an OR operation (has multiple operands)
            has_or_op = any(isinstance(c, Token) and str(c).upper() == "OR" for c in children)
            if has_or_op:
                self._validate_boolean_operand(result, "OR")
            for child in children[1:]:
                if isinstance(child, Tree):
                    right = self._eval_expression(child, ctx)
                    if has_or_op:
                        self._validate_boolean_operand(right, "OR")
                    result = self._cypher_or(result, right)
            return result

        if data == "xor_expression":
            # Grammar: and_expression (XOR and_expression)*
            # Children: [and_expr, XOR, and_expr, XOR, and_expr, ...]
            # Process only Tree children (skip XOR tokens)
            result = self._eval_expression(children[0], ctx)
            # Check if this is actually an XOR operation (has multiple operands)
            has_xor_op = any(isinstance(c, Token) and str(c).upper() == "XOR" for c in children)
            if has_xor_op:
                self._validate_boolean_operand(result, "XOR")
            for child in children[1:]:
                if isinstance(child, Tree):
                    right = self._eval_expression(child, ctx)
                    if has_xor_op:
                        self._validate_boolean_operand(right, "XOR")
                    result = self._cypher_xor(result, right)
            return result

        if data == "and_expression":
            # Grammar: not_expression (AND not_expression)*
            # Children: [not_expr, AND, not_expr, AND, not_expr, ...]
            # Process only Tree children (skip AND tokens)
            result = self._eval_expression(children[0], ctx)
            # Check if this is actually an AND operation (has multiple operands)
            has_and_op = any(isinstance(c, Token) and str(c).upper() == "AND" for c in children)
            if has_and_op:
                self._validate_boolean_operand(result, "AND")
            for child in children[1:]:
                if isinstance(child, Tree):
                    right = self._eval_expression(child, ctx)
                    if has_and_op:
                        self._validate_boolean_operand(right, "AND")
                    result = self._cypher_and(result, right)
            return result

        if data == "expr_not":
            val = self._eval_expression(children[0], ctx)
            # Validate operand type for NOT
            self._validate_boolean_operand(val, "NOT")
            if val is None:
                return None
            return not val

        # === Comparison ===
        if data == "comparison_expression":
            left = self._eval_expression(children[0], ctx)
            if len(children) == 1:
                return left
            # Handle chained comparisons: expr (op expr)+
            # For chained comparisons like "1 < x < 3", we need to:
            # 1. Evaluate each comparison pair separately
            # 2. AND all the boolean results together
            # 3. Keep the original value (not boolean) for the next comparison
            result = True  # Start with True, will AND with each comparison
            i = 1
            while i < len(children):
                op_tree = children[i]
                if i + 1 >= len(children):
                    break
                right = self._eval_expression(children[i + 1], ctx)
                # v4 grammar uses comparison_signs with token children
                if isinstance(op_tree, Tree) and op_tree.data == "comparison_signs":
                    op_token = op_tree.children[0] if op_tree.children else None
                    if op_token is not None:
                        comparison_result = self._cypher_compare_v4(str(op_token), left, right)
                    else:
                        comparison_result = None
                else:
                    # Old grammar uses comparison_op with aliases (cmp_eq, etc.)
                    comparison_result = self._cypher_compare(op_tree, left, right)

                # AND the comparison result with accumulated result
                result = self._cypher_and(result, comparison_result)

                # Move right value to left for next comparison (chaining)
                left = right
                i += 2
            return result

        # === String/List/Null predicates ===
        if data == "str_list_null_expression":
            result = self._eval_expression(children[0], ctx)
            for pred in children[1:]:
                if isinstance(pred, Tree):
                    result = self._eval_predicate(pred, result, ctx)
            return result

        # === Arithmetic: addition/subtraction ===
        if data == "add_sub_expression":
            # Check for scientific notation pattern (e.g., 2E-01 parsed as 2E SUB 01)
            if len(children) == 3:
                op = children[1]
                if isinstance(op, Token) and op.type == "SUB":
                    # Try to detect scientific notation
                    sci_result = self._try_parse_scientific_notation(node, ctx)
                    if sci_result is not None:
                        return sci_result
            result = self._eval_expression(children[0], ctx)
            i = 1
            while i < len(children):
                op = children[i]
                if not (i + 1 < len(children)):
                    break
                right = self._eval_expression(children[i + 1], ctx)
                if result is None or right is None:
                    result = None
                else:
                    # Determine operation from either old grammar (Tree data) or v4 (Token type)
                    if isinstance(op, Token) and op.type == "PLUS":
                        is_add = True
                    elif isinstance(op, Token) and op.type == "SUB":
                        is_add = False
                    elif isinstance(op, Tree) and op.data == "expr_add":
                        is_add = True
                    elif isinstance(op, Tree) and op.data == "expr_sub":
                        is_add = False
                    else:
                        is_add = str(op) == "+"
                    if is_add:
                        if isinstance(result, str) or isinstance(right, str):
                            result = str(result) + str(right)
                        elif isinstance(result, list) and isinstance(right, list):
                            result = result + right
                        elif isinstance(result, list):
                            # list + scalar = append element to list
                            result = result + [right]
                        elif isinstance(right, list):
                            # scalar + list = prepend element to list
                            result = [result] + right
                        else:
                            result = result + right
                    else:
                        result = result - right
                i += 2
            return result

        # === Arithmetic: multiplication/division/modulo ===
        if data == "mul_div_mod_expression":
            result = self._eval_expression(children[0], ctx)
            i = 1
            while i < len(children):
                op = children[i]  # mul_op Tree with alias
                right = self._eval_expression(children[i + 1], ctx)
                if result is None or right is None:
                    result = None
                else:
                    op_name = op.data if isinstance(op, Tree) else str(op)
                    if op_name == "expr_mul":
                        result = result * right
                    elif op_name == "expr_div":
                        if isinstance(result, int) and isinstance(right, int):
                            if right == 0:
                                result = None  # Integer division by zero returns null in Cypher
                            else:
                                # Cypher integer division truncates toward zero
                                result = int(result / right)
                        else:
                            # Float division with zero handling
                            if right == 0 or right == 0.0:
                                if result == 0 or result == 0.0:
                                    result = float('nan')  # 0.0 / 0.0 = NaN
                                else:
                                    # x / 0.0 = +/-Infinity
                                    result = float('inf') if result > 0 else float('-inf')
                            else:
                                result = result / right
                    elif op_name == "expr_mod":
                        if right == 0:
                            result = None
                        else:
                            result = result % right
                i += 2
            return result

        # === Power ===
        if data == "power_expression":
            # v4 grammar: power_expression: unary_add_sub_expression (CARET unary_add_sub_expression)*
            # Children can be: [base] or [base, ^, exp, ^, exp, ...]
            # Skip Token children (CARET) and only process Tree children
            base = self._eval_expression(children[0], ctx)
            for child in children[1:]:
                if isinstance(child, Tree):
                    exp = self._eval_expression(child, ctx)
                    if base is None or exp is None:
                        return None
                    base = base ** exp
            return base

        # === Unary ===
        if data == "expr_negate":
            # Special case for MIN_INT: -9223372036854775808
            # The positive value 9223372036854775808 exceeds MAX_INT, but after negation it's valid
            child = children[0]
            if isinstance(child, Tree) and child.data == "num_lit":
                tok = child.children[0] if child.children else None
                if tok is not None:
                    s = str(tok)
                    if s == "9223372036854775808":
                        return -9223372036854775808  # MIN_INT
            val = self._eval_expression(child, ctx)
            if val is None:
                return None
            return -val

        if data == "expr_positive":
            val = self._eval_expression(children[0], ctx)
            return val

        # === Property access / list indexing ===
        if data == "property_or_labels_expression":
            base = self._eval_expression(children[0], ctx)
            for child in children[1:]:
                if isinstance(child, Tree):
                    if child.data == "property_lookup":
                        prop = str(child.children[0])
                        if isinstance(base, dict):
                            base = base.get(prop)
                        elif isinstance(base, str) and ctx.match is not None:
                            # base is a node variable name — resolve its property
                            host_node = ctx.match.node_mappings.get(base)
                            if host_node is not None:
                                base = ctx.graph.nodes[host_node].get(prop)
                            else:
                                base = None
                        else:
                            base = None
                    elif child.data == "list_index_expression":
                        idx = self._eval_expression(child.children[0], ctx)
                        if idx is None:
                            # Null index returns null
                            base = None
                        elif base is not None:
                            # Validate index type - must be integer
                            if not isinstance(idx, (int, float)) or isinstance(idx, bool):
                                if self._strict:
                                    # InvalidArgumentType is defined in this module
                                    raise InvalidArgumentType(
                                        f"Type mismatch: list index must be integer, got {type(idx).__name__}"
                                    )
                                base = None
                            else:
                                try:
                                    base = base[int(idx)]
                                except (IndexError, TypeError, KeyError):
                                    base = None
                        else:
                            base = None
            return base

        # === Atom types ===
        if data == "expr_literal":
            # Children are already Python values due to ESTRING/NUMBER eval
            return children[0] if children else None

        if data == "expr_true":
            return True
        if data == "expr_false":
            return False
        if data == "expr_null":
            return None

        if data == "expr_variable":
            name = str(children[0])
            return self._eval_variable_or_literal(name, ctx)

        if data == "expr_paren":
            return self._eval_expression(children[0], ctx)

        if data == "expr_function_call":
            return self._eval_function_call(children, ctx)

        if data == "expr_count_star":
            # COUNT(*) in expression context — handled by aggregation system
            return None  # Placeholder; aggregation handles this

        if data in ("expr_searched_case", "expr_simple_case"):
            return self._eval_case_expression(node, ctx)

        if data == "expr_list_literal":
            return [self._eval_expression(c, ctx) for c in children]

        if data == "expr_map_literal":
            result = {}
            i = 0
            while i < len(children):
                key = str(children[i])
                val = self._eval_expression(children[i + 1], ctx)
                result[key] = val
                i += 2
            return result

        if data == "expr_list_comprehension":
            return self._eval_list_comprehension_expr(children, ctx)

        # =================================================================
        # V4 grammar node types (from nx_opencypher.lark)
        # =================================================================

        # --- Boolean logic (same names, but NOT uses NOT* prefix) ---
        if data == "not_expression":
            # v4: NOT* comparison_expression
            # Count NOT tokens, eval the expression, apply odd/even negation
            not_count = sum(1 for c in children if isinstance(c, Token) and c.type == "NOT")
            expr_children = [c for c in children if isinstance(c, Tree)]
            if not expr_children:
                return None
            val = self._eval_expression(expr_children[0], ctx)
            # Validate boolean operand if NOT is applied
            if not_count > 0:
                self._validate_boolean_operand(val, "NOT")
            if not_count % 2 == 1:
                if val is None:
                    return None
                return not val
            return val

        # --- Arithmetic (v4 uses PLUS/SUB/MULT/DIV/MOD tokens directly) ---
        if data == "add_sub_expression" and len(children) > 1:
            result = self._eval_expression(children[0], ctx)
            i = 1
            while i < len(children):
                op = children[i]
                if not (i + 1 < len(children)):
                    break
                right = self._eval_expression(children[i + 1], ctx)
                if result is None or right is None:
                    result = None
                elif isinstance(op, Token):
                    if op.type == "PLUS":
                        if isinstance(result, str) or isinstance(right, str):
                            result = str(result) + str(right)
                        elif isinstance(result, list) and isinstance(right, list):
                            result = result + right
                        elif isinstance(result, list):
                            # list + scalar = append element to list
                            result = result + [right]
                        elif isinstance(right, list):
                            # scalar + list = prepend element to list
                            result = [result] + right
                        else:
                            result = result + right
                    elif op.type == "SUB":
                        result = result - right
                i += 2
            return result

        if data == "mult_div_expression":
            result = self._eval_expression(children[0], ctx)
            i = 1
            while i < len(children):
                op = children[i]
                if not (i + 1 < len(children)):
                    break
                right = self._eval_expression(children[i + 1], ctx)
                if result is None or right is None:
                    result = None
                elif isinstance(op, Token):
                    if op.type == "MULT":
                        result = result * right
                    elif op.type == "DIV":
                        if isinstance(result, int) and isinstance(right, int):
                            if right == 0:
                                result = None  # Integer division by zero returns null
                            else:
                                # Cypher integer division truncates toward zero
                                result = int(result / right)
                        else:
                            # Float division with zero handling
                            if right == 0 or right == 0.0:
                                if result == 0 or result == 0.0:
                                    result = float('nan')  # 0.0 / 0.0 = NaN
                                else:
                                    # x / 0.0 = +/-Infinity
                                    result = float('inf') if result > 0 else float('-inf')
                            else:
                                result = result / right
                    elif op.type == "MOD":
                        result = None if right == 0 else result % right
                i += 2
            return result

        # --- Unary (v4) ---
        if data == "unary_add_sub_expression":
            # (PLUS | SUB)? atomic_expression
            if len(children) == 2:
                op = children[0]
                if isinstance(op, Token) and op.type == "SUB":
                    # Special case for MIN_INT: -9223372036854775808
                    # The positive value 9223372036854775808 exceeds MAX_INT,
                    # but after negation it's the valid MIN_INT
                    # This applies to decimal, hex, and octal representations
                    num_str = self._extract_num_lit_string(children[1])
                    if num_str is not None:
                        # Check for MIN_INT in various formats
                        # Decimal: 9223372036854775808
                        # Hex: 0x8000000000000000 or 0X8000000000000000
                        # Octal: 0o1000000000000000000000 or 0O1000000000000000000000
                        if num_str == "9223372036854775808":
                            return -9223372036854775808  # MIN_INT
                        if num_str.lower() == "0x8000000000000000":
                            return -9223372036854775808  # MIN_INT
                        if num_str.lower() == "0o1000000000000000000000":
                            return -9223372036854775808  # MIN_INT
                    val = self._eval_expression(children[1], ctx)
                    return -val if val is not None else None
                val = self._eval_expression(children[1], ctx)
                return val
            return self._eval_expression(children[0], ctx)

        # --- Atomic expression (postfix predicates) ---
        if data == "atomic_expression":
            # property_or_label_expression (string_expression | list_expression | null_expression)*
            base = self._eval_expression(children[0], ctx)
            postfix_children = [c for c in children[1:] if isinstance(c, Tree)]

            # Check for IN followed by index/slice: 3 IN [1,2,3][0..1]
            # Pattern: list_expression with IN, followed by list_expression(s) with indexing
            i = 0
            while i < len(postfix_children):
                child = postfix_children[i]
                if child.data == "list_expression" and _has_token_v4(child, "IN"):
                    # Found IN expression - check if followed by index/slice ops
                    # Get RHS of IN
                    rhs_tree = None
                    for c in child.children:
                        if isinstance(c, Tree):
                            rhs_tree = c
                            break
                    rhs = self._eval_expression(rhs_tree, ctx) if rhs_tree else None

                    # Apply any following index/slice operations to the RHS
                    j = i + 1
                    while j < len(postfix_children):
                        next_child = postfix_children[j]
                        if next_child.data == "list_expression" and not _has_token_v4(next_child, "IN"):
                            # Index or slice operation - apply to rhs
                            rhs = self._eval_v4_postfix(next_child, rhs, ctx)
                            j += 1
                        else:
                            break

                    # Now perform IN check with proper null semantics
                    base = self._cypher_in(base, rhs)
                    i = j  # Skip over the index/slice ops we consumed
                else:
                    base = self._eval_v4_postfix(child, base, ctx)
                    i += 1
            return base

        # --- Property access (v4) ---
        if data == "property_or_label_expression":
            # property_expression node_labels?
            # If there's a node_labels child, this is a label check predicate (e.g., n:Label)
            if len(children) >= 2:
                # Second child should be node_labels
                labels_tree = children[1]
                if isinstance(labels_tree, Tree) and labels_tree.data == "node_labels":
                    # Extract variable name from property_expression
                    var_name = self._extract_variable_name(children[0])
                    if var_name is not None and ctx.match is not None:
                        host_node = ctx.match.node_mappings.get(var_name)
                        if host_node is not None:
                            node_data = ctx.graph.nodes.get(host_node, {})
                            node_labels = node_data.get("__labels__", set())
                            # Extract required labels from node_labels tree
                            required_labels = set()
                            for c in labels_tree.children:
                                if isinstance(c, Tree) and c.data == "name":
                                    required_labels.add(_name_to_str_v4(c))
                                elif isinstance(c, Token) and c.type != "COLON":
                                    required_labels.add(str(c))
                            # Return True if all required labels are present
                            return required_labels.issubset(node_labels)
                        return False  # Node not found in match
                    return False  # Variable not resolved
            # No label check, just evaluate the property expression
            return self._eval_expression(children[0], ctx)

        if data == "property_expression":
            # atom (DOT name)*
            base = self._eval_expression(children[0], ctx)
            i = 1
            while i < len(children):
                c = children[i]
                if isinstance(c, Token) and c.type == "DOT":
                    i += 1
                    if i < len(children):
                        name_node = children[i]
                        # Extract the property name string from v4 name tree
                        if isinstance(name_node, Tree):
                            prop_name = _name_to_str_v4(name_node)
                        else:
                            prop_name = str(name_node)
                        if isinstance(base, dict):
                            base = base.get(prop_name)
                        elif isinstance(base, str) and ctx.match is not None:
                            host_node = ctx.match.node_mappings.get(base)
                            if host_node is not None:
                                base = ctx.graph.nodes[host_node].get(prop_name)
                            else:
                                base = None
                        else:
                            base = None
                i += 1
            return base

        # --- Atom types (v4) ---
        if data == "atom":
            return self._eval_expression(children[0], ctx)

        if data == "symbol":
            name = children[0]
            if isinstance(name, Token):
                s = str(name)
                if s.startswith("`") and s.endswith("`"):
                    s = s[1:-1]
                return self._eval_variable_or_literal(s, ctx)
            return self._eval_variable_or_literal(str(name), ctx)

        if data == "name":
            return self._eval_expression(children[0], ctx)

        if data == "reserved_word":
            return str(children[0])

        # --- Literals (v4) ---
        if data == "literal":
            return self._eval_expression(children[0], ctx)

        if data == "num_lit":
            tok = children[0] if children else None
            if tok is None:
                return 0
            s = str(tok)
            # Integer bounds for 64-bit signed integer (Java Long)
            MAX_INT = 9223372036854775807   # 2^63 - 1
            MIN_INT = -9223372036854775808  # -2^63
            # Handle hex
            if s.startswith("0x") or s.startswith("0X") or s.startswith("-0x") or s.startswith("-0X"):
                result = int(s, 16) if not s.startswith("-") else -int(s[1:], 16)
                if self._strict and (result > MAX_INT or result < MIN_INT):
                    raise IntegerOverflow(f"Integer literal {s} out of range")
                return result
            # Handle modern octal (0o prefix)
            if s.startswith("0o") or s.startswith("0O"):
                result = int(s, 8)
                if self._strict and (result > MAX_INT or result < MIN_INT):
                    raise IntegerOverflow(f"Integer literal {s} out of range")
                return result
            if s.startswith("-0o") or s.startswith("-0O"):
                result = -int(s[1:], 8)
                if self._strict and (result > MAX_INT or result < MIN_INT):
                    raise IntegerOverflow(f"Integer literal {s} out of range")
                return result
            # Handle legacy octal (leading 0 followed by octal digits)
            import re
            if re.match(r'^-?0[0-7]+$', s):
                result = int(s, 8) if not s.startswith("-") else -int(s[1:], 8)
                if self._strict and (result > MAX_INT or result < MIN_INT):
                    raise IntegerOverflow(f"Integer literal {s} out of range")
                return result
            # Handle floats
            if "." in s or "e" in s.lower():
                import math
                result = float(s)
                if self._strict and math.isinf(result):
                    raise FloatingPointOverflow(f"Floating point literal {s} overflows")
                return result
            # Handle decimal integers
            result = int(s)
            if self._strict and (result > MAX_INT or result < MIN_INT):
                raise IntegerOverflow(f"Integer literal {s} out of range")
            return result

        if data == "string_lit" or data == "char_lit":
            tok = children[0] if children else None
            if tok is None:
                return ""
            s = str(tok)
            if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
                s = s[1:-1]
            return _process_cypher_string_escapes(s)

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

        if data == "list_lit":
            ec = None
            for c in children:
                if isinstance(c, Tree) and c.data == "expression_chain":
                    ec = c
                    break
            if ec is None:
                return []
            return [self._eval_expression(c, ctx) for c in ec.children if isinstance(c, Tree)]

        if data == "map_lit":
            result = {}
            for c in children:
                if isinstance(c, Tree) and c.data == "map_pair":
                    key_node = None
                    val_node = None
                    for cc in c.children:
                        if isinstance(cc, Tree) and cc.data == "name":
                            key_node = cc
                        elif isinstance(cc, Tree) and key_node is not None:
                            val_node = cc
                    if key_node and val_node:
                        key = _name_to_str_v4(key_node)
                        result[key] = self._eval_expression(val_node, ctx)
            return result

        # --- Function calls (v4) ---
        if data == "function_invocation":
            return self._eval_v4_function_invocation(node, ctx)

        if data == "count_all":
            return None  # Handled by aggregation system

        # --- CASE expression (v4) ---
        if data == "case_expression":
            return self._eval_v4_case(node, ctx)

        # --- List comprehension (v4) ---
        if data == "list_comprehension":
            return self._eval_v4_list_comprehension(node, ctx)

        # --- Quantifier predicates: ANY/ALL/NONE/SINGLE (v4) ---
        if data == "filter_with":
            return self._eval_v4_filter_with(node, ctx)

        # --- Parenthesized expression ---
        if data == "parenthesized_expression":
            # Children: [LPAREN, expression, RPAREN]
            # Find the expression Tree child (skip tokens)
            for child in children:
                if isinstance(child, Tree):
                    return self._eval_expression(child, ctx)
            return None

        # --- Parameter (v4) ---
        if data == "parameter":
            # Grammar: parameter: DOLLAR (symbol | num_lit)
            # Extract parameter name from children (skip DOLLAR token)
            param_name = None
            for child in children:
                if isinstance(child, Token):
                    if str(child) != "$":
                        param_name = str(child)
                        break
                elif isinstance(child, Tree):
                    # Could be symbol or num_lit tree
                    if child.children:
                        param_name = str(child.children[0])
                        break
            if param_name and param_name in self._parameters:
                return self._parameters[param_name]
            return None

        # --- Reduce expression (v4) ---
        if data == "reduce_expression":
            return self._eval_v4_reduce(node, ctx)

        # --- EXISTS subquery ---
        if data == "subquery_exist":
            # TODO: implement EXISTS subquery
            return None

        # --- Pattern comprehension ---
        if data == "pattern_comprehension":
            return self._eval_pattern_comprehension(node, ctx)

        # --- Relationships chain pattern (edge existence check in WHERE) ---
        if data == "relationships_chain_pattern":
            # This pattern checks if a relationship exists between nodes
            # Grammar: node_pattern pattern_elem_chain+
            return self._eval_relationships_chain_pattern(node, ctx)

        # --- Expression chain (used in function args) ---
        if data == "expression_chain":
            return [self._eval_expression(c, ctx) for c in children if isinstance(c, Tree)]

        # --- String/list/null expressions handled as postfix in v4 ---
        if data == "string_expression":
            # This should be handled via _eval_v4_postfix from atomic_expression
            return None

        if data == "list_expression":
            return None

        if data == "null_expression":
            return None

        # Fallback for unhandled tree types
        if len(children) == 1:
            return self._eval_expression(children[0], ctx)

        return None

    def _eval_predicate(self, pred: Tree, value: Any, ctx: EvalContext) -> Any:
        """Evaluate a string/list/null predicate on a value."""
        if pred.data == "pred_starts_with":
            rhs = self._eval_expression(pred.children[0], ctx)
            if value is None or rhs is None:
                return None
            # String predicates require both operands to be strings
            if not isinstance(value, str) or not isinstance(rhs, str):
                return None
            return value.startswith(rhs)
        if pred.data == "pred_ends_with":
            rhs = self._eval_expression(pred.children[0], ctx)
            if value is None or rhs is None:
                return None
            if not isinstance(value, str) or not isinstance(rhs, str):
                return None
            return value.endswith(rhs)
        if pred.data == "pred_contains":
            rhs = self._eval_expression(pred.children[0], ctx)
            if value is None or rhs is None:
                return None
            if not isinstance(value, str) or not isinstance(rhs, str):
                return None
            return rhs in value
        if pred.data == "pred_in":
            rhs = self._eval_expression(pred.children[0], ctx)
            if rhs is None:
                return None
            if value is None:
                return None
            return value in rhs
        if pred.data == "pred_is_null":
            return value is None
        if pred.data == "pred_is_not_null":
            return value is not None
        return value

    def _eval_function_call(self, children: list, ctx: EvalContext) -> Any:
        """Evaluate a function call expression."""
        import math
        func_name = str(children[0]).upper()
        # Collect argument expressions (skip FUNC_NAME and any DISTINCT token)
        args = []
        has_distinct = False
        for c in children[1:]:
            if isinstance(c, Token) and (str(c).upper() == "DISTINCT" or getattr(c, 'type', '') == "EXPR_DISTINCT"):
                has_distinct = True
                continue
            args.append(c)

        # Special handling for ID() — needs the raw node/edge ID, not the property dict
        if func_name == "ID" and len(args) == 1 and ctx.match is not None:
            var_name = self._extract_variable_name(args[0])
            if var_name is not None:
                host_node = ctx.match.node_mappings.get(var_name)
                if host_node is not None:
                    return host_node
                # Check edge variables
                if ctx.return_edges and var_name in ctx.return_edges:
                    edge_mapping = ctx.return_edges[var_name]
                    try:
                        host_edges = ctx.match.mth.edge(*edge_mapping).edges
                        return host_edges  # Return edge IDs
                    except (AttributeError, TypeError, KeyError):
                        return None

        # Evaluate arguments
        evaluated = [self._eval_expression(a, ctx) for a in args]
        return self._call_function(func_name, evaluated, ctx)

    def _extract_variable_name(self, node) -> Optional[str]:
        """Extract the variable name from an expression tree node (if it's a simple variable)."""
        if isinstance(node, str):
            return node
        if isinstance(node, Token):
            return str(node)
        if isinstance(node, Tree):
            # Unwrap single-child wrappers (both old and v4 grammar)
            unwrap = {"expression", "or_expression", "xor_expression", "and_expression",
                      "not_expression", "comparison_expression", "str_list_null_expression",
                      "add_sub_expression", "mul_div_mod_expression", "mult_div_expression",
                      "power_expression", "unary_expression", "unary_add_sub_expression",
                      "property_or_labels_expression", "property_or_label_expression",
                      "property_expression", "atomic_expression",
                      "expr_atom", "atom", "literal", "parenthesized_expression"}
            if node.data in unwrap and len(node.children) == 1:
                return self._extract_variable_name(node.children[0])
            if node.data == "expr_variable":
                return str(node.children[0])
            # v4 symbol/name nodes
            if node.data == "symbol":
                return _symbol_to_str_v4(node)
            if node.data == "name":
                return _name_to_str_v4(node)
        return None

    def _extract_num_lit_string(self, node) -> Optional[str]:
        """Extract the numeric literal string from an expression tree (if it's a simple number).

        This traverses wrapper nodes to find a num_lit token and returns its string value.
        Used for special handling of MIN_INT (-9223372036854775808).
        """
        if isinstance(node, Token):
            return str(node) if node.type in ("DEC_INT", "HEX_INT", "OCT_INT", "FLOAT") else None
        if isinstance(node, Tree):
            # Unwrap single-child wrappers
            unwrap = {"expression", "or_expression", "xor_expression", "and_expression",
                      "not_expression", "comparison_expression", "str_list_null_expression",
                      "add_sub_expression", "mul_div_mod_expression", "mult_div_expression",
                      "power_expression", "unary_expression", "unary_add_sub_expression",
                      "property_or_labels_expression", "property_or_label_expression",
                      "property_expression", "atomic_expression",
                      "expr_atom", "atom", "literal", "parenthesized_expression"}
            if node.data in unwrap and len(node.children) == 1:
                return self._extract_num_lit_string(node.children[0])
            # Found num_lit - extract the token value
            if node.data == "num_lit":
                if node.children:
                    return str(node.children[0])
        return None

    def _try_parse_scientific_notation(self, tree: Tree, ctx: EvalContext) -> Optional[float]:
        """Try to parse an add_sub_expression as scientific notation (e.g., 2E-01).

        The lexer may split "2E-01" into "2E" (symbol), "-" (SUB), "01" (num_lit).
        This detects that pattern and returns the float value if it matches.
        """
        children = tree.children
        if len(children) != 3:
            return None

        # Extract the left side - should be something like "2E" or "1.5E"
        left_str = self._extract_simple_symbol(children[0])
        if not left_str or len(left_str) < 2:
            return None

        # Check if it ends with 'e' or 'E'
        if left_str[-1].lower() != 'e':
            return None

        # The mantissa should be a valid number
        mantissa = left_str[:-1]
        try:
            float(mantissa)
        except ValueError:
            return None

        # Extract the right side (the exponent)
        right_val = self._eval_expression(children[2], ctx)
        if right_val is None or not isinstance(right_val, (int, float)):
            return None

        # Combine and parse
        try:
            exp_str = str(int(right_val)) if isinstance(right_val, float) and right_val == int(right_val) else str(right_val)
            return float(f"{left_str}-{exp_str}")
        except (ValueError, OverflowError):
            return None

    def _extract_simple_symbol(self, node) -> Optional[str]:
        """Extract a simple symbol string from an expression tree.

        Also handles unary negation, returning e.g. '-1e' for a negated symbol.
        """
        if isinstance(node, Token):
            return str(node)
        if not isinstance(node, Tree):
            return None

        # Unwrap single-child wrapper nodes
        unwrap = {"expression", "xor_expression", "and_expression",
                  "not_expression", "comparison_expression", "add_sub_expression",
                  "mult_div_expression", "power_expression",
                  "atomic_expression", "property_or_label_expression",
                  "property_expression", "atom"}
        if node.data in unwrap and len(node.children) == 1:
            return self._extract_simple_symbol(node.children[0])

        # Handle unary negation: unary_add_sub_expression with 2 children (-, expr)
        if node.data == "unary_add_sub_expression" and len(node.children) == 2:
            op = node.children[0]
            if isinstance(op, Token) and op.type == "SUB":
                inner = self._extract_simple_symbol(node.children[1])
                if inner is not None:
                    return f"-{inner}"

        # Single-child unary_add_sub_expression
        if node.data == "unary_add_sub_expression" and len(node.children) == 1:
            return self._extract_simple_symbol(node.children[0])

        # Symbol node
        if node.data == "symbol":
            for child in node.children:
                if isinstance(child, Token) and child.type == "ID":
                    return str(child)

        # Handle property_expression that looks like a float: 2 . 5e  =>  "2.5e"
        # This happens when the parser treats a decimal like "2.5e" as property access
        if node.data == "property_expression" and len(node.children) >= 3:
            # Pattern: atom/symbol . name where we can reconstruct a float-like string
            parts = []
            for i, child in enumerate(node.children):
                if isinstance(child, Token):
                    if child.type == "DOT":
                        parts.append(".")
                    else:
                        parts.append(str(child))
                elif isinstance(child, Tree):
                    inner = self._extract_simple_symbol(child)
                    if inner is not None:
                        parts.append(inner)
                    else:
                        return None
            # Check if combined parts look like a float ending with 'e' or 'E'
            combined = "".join(parts)
            if combined and combined[-1].lower() == 'e':
                # Validate the mantissa part (everything before the 'e')
                mantissa = combined[:-1]
                try:
                    float(mantissa)
                    return combined
                except ValueError:
                    pass

        # Handle property_or_label_expression wrapper
        if node.data == "property_or_label_expression" and len(node.children) == 1:
            return self._extract_simple_symbol(node.children[0])

        return None

    def _call_function(self, name: str, args: list, ctx: EvalContext) -> Any:
        """Consolidated function dispatch — single implementation for all contexts."""
        import math
        from .temporal import (
            cypher_date, cypher_time, cypher_localtime,
            cypher_datetime, cypher_localdatetime, cypher_duration,
            cypher_datetime_fromepoch, cypher_datetime_fromepochmillis,
            duration_between, duration_inMonths, duration_inDays, duration_inSeconds,
            cypher_date_truncate, cypher_time_truncate, cypher_localtime_truncate,
            cypher_datetime_truncate, cypher_localdatetime_truncate,
            cypher_date_statement, cypher_date_transaction, cypher_date_realtime,
            cypher_time_statement, cypher_time_transaction, cypher_time_realtime,
            cypher_localtime_statement, cypher_localtime_transaction, cypher_localtime_realtime,
            cypher_datetime_statement, cypher_datetime_transaction, cypher_datetime_realtime,
            cypher_localdatetime_statement, cypher_localdatetime_transaction, cypher_localdatetime_realtime
        )

        # Get the query clock for statement-scoped temporal consistency
        clock = getattr(self, '_clock', None)

        # Temporal functions (can have 0 or 1 argument)
        # When no argument, use statement clock for query-scoped consistency
        if name == "DATE":
            return cypher_date(args[0]) if args else cypher_date_statement(clock)
        if name == "TIME":
            return cypher_time(args[0]) if args else cypher_time_statement(clock)
        if name == "LOCALTIME":
            return cypher_localtime(args[0]) if args else cypher_localtime_statement(clock)
        if name == "DATETIME":
            return cypher_datetime(args[0]) if args else cypher_datetime_statement(clock)
        if name == "LOCALDATETIME":
            return cypher_localdatetime(args[0]) if args else cypher_localdatetime_statement(clock)
        if name == "DURATION":
            return cypher_duration(args[0]) if args else cypher_duration()

        # Temporal namespace functions
        if name == "DATETIME.FROMEPOCH" and len(args) >= 1:
            secs = args[0]
            nanos = args[1] if len(args) > 1 else 0
            return cypher_datetime_fromepoch(secs, nanos)
        if name == "DATETIME.FROMEPOCHMILLIS" and len(args) >= 1:
            return cypher_datetime_fromepochmillis(args[0])
        if name == "DURATION.BETWEEN" and len(args) >= 2:
            return duration_between(args[0], args[1])
        if name == "DURATION.INMONTHS" and len(args) >= 2:
            return duration_inMonths(args[0], args[1])
        if name == "DURATION.INDAYS" and len(args) >= 2:
            return duration_inDays(args[0], args[1])
        if name == "DURATION.INSECONDS" and len(args) >= 2:
            return duration_inSeconds(args[0], args[1])

        # Truncation functions
        if name == "DATE.TRUNCATE" and len(args) >= 2:
            unit = args[0]
            temporal_value = args[1]
            map_override = args[2] if len(args) > 2 else None
            return cypher_date_truncate(unit, temporal_value, map_override)
        if name == "TIME.TRUNCATE" and len(args) >= 2:
            unit = args[0]
            temporal_value = args[1]
            map_override = args[2] if len(args) > 2 else None
            return cypher_time_truncate(unit, temporal_value, map_override)
        if name == "LOCALTIME.TRUNCATE" and len(args) >= 2:
            unit = args[0]
            temporal_value = args[1]
            map_override = args[2] if len(args) > 2 else None
            return cypher_localtime_truncate(unit, temporal_value, map_override)
        if name == "DATETIME.TRUNCATE" and len(args) >= 2:
            unit = args[0]
            temporal_value = args[1]
            map_override = args[2] if len(args) > 2 else None
            return cypher_datetime_truncate(unit, temporal_value, map_override)
        if name == "LOCALDATETIME.TRUNCATE" and len(args) >= 2:
            unit = args[0]
            temporal_value = args[1]
            map_override = args[2] if len(args) > 2 else None
            return cypher_localdatetime_truncate(unit, temporal_value, map_override)

        # Current time functions (using query clock for scoped consistency)
        # These functions propagate null: func(null) → null
        if name == "DATE.STATEMENT":
            return None if (args and args[0] is None) else cypher_date_statement(clock)
        if name == "DATE.TRANSACTION":
            return None if (args and args[0] is None) else cypher_date_transaction(clock)
        if name == "DATE.REALTIME":
            return None if (args and args[0] is None) else cypher_date_realtime(clock)
        if name == "TIME.STATEMENT":
            return None if (args and args[0] is None) else cypher_time_statement(clock)
        if name == "TIME.TRANSACTION":
            return None if (args and args[0] is None) else cypher_time_transaction(clock)
        if name == "TIME.REALTIME":
            return None if (args and args[0] is None) else cypher_time_realtime(clock)
        if name == "LOCALTIME.STATEMENT":
            return None if (args and args[0] is None) else cypher_localtime_statement(clock)
        if name == "LOCALTIME.TRANSACTION":
            return None if (args and args[0] is None) else cypher_localtime_transaction(clock)
        if name == "LOCALTIME.REALTIME":
            return None if (args and args[0] is None) else cypher_localtime_realtime(clock)
        if name == "DATETIME.STATEMENT":
            return None if (args and args[0] is None) else cypher_datetime_statement(clock)
        if name == "DATETIME.TRANSACTION":
            return None if (args and args[0] is None) else cypher_datetime_transaction(clock)
        if name == "DATETIME.REALTIME":
            return None if (args and args[0] is None) else cypher_datetime_realtime(clock)
        if name == "LOCALDATETIME.STATEMENT":
            return None if (args and args[0] is None) else cypher_localdatetime_statement(clock)
        if name == "LOCALDATETIME.TRANSACTION":
            return None if (args and args[0] is None) else cypher_localdatetime_transaction(clock)
        if name == "LOCALDATETIME.REALTIME":
            return None if (args and args[0] is None) else cypher_localdatetime_realtime(clock)

        # Zero-argument functions
        if not args:
            if name == "PI":
                return math.pi
            if name == "E":
                return math.e
            if name == "TIMESTAMP":
                import time
                return int(time.time() * 1000)
            if name == "RAND":
                import random
                return random.random()
            return None

        val = args[0]

        # Single-argument functions
        if len(args) == 1:
            if val is None:
                if name == "EXISTS":
                    return False
                if name == "COALESCE":
                    return None
                return None

            funcs = {
                "TOLOWER": lambda v: str(v).lower(),
                "TOUPPER": lambda v: str(v).upper(),
                "TRIM": lambda v: str(v).strip(),
                "LTRIM": lambda v: str(v).lstrip(),
                "RTRIM": lambda v: str(v).rstrip(),
                "SIZE": lambda v: None if v is None else (len(v) if hasattr(v, '__len__') else None),
                "LENGTH": lambda v: None if v is None else (len(v) if hasattr(v, '__len__') else None),
                "HEAD": lambda v: v[0] if hasattr(v, '__getitem__') and len(v) > 0 else None,
                "TAIL": lambda v: list(v[1:]) if hasattr(v, '__getitem__') else [],
                "REVERSE": lambda v: v[::-1] if isinstance(v, str) else (list(reversed(v)) if isinstance(v, list) else None),
                "TOSTRING": lambda v: str(v).lower() if isinstance(v, bool) else str(v),
                "TOINTEGER": lambda v: self._safe_tointeger(v),
                "TOFLOAT": lambda v: float(v) if not isinstance(v, bool) else (1.0 if v else 0.0),
                "TOBOOLEAN": lambda v: self._safe_toboolean(v),
                "ABS": lambda v: abs(v) if isinstance(v, (int, float)) else None,
                "CEIL": lambda v: math.ceil(v) if isinstance(v, (int, float)) else None,
                "FLOOR": lambda v: math.floor(v) if isinstance(v, (int, float)) else None,
                "ROUND": lambda v: round(v) if isinstance(v, (int, float)) else None,
                "SIGN": lambda v: (1 if v > 0 else (-1 if v < 0 else 0)) if isinstance(v, (int, float)) else None,
                "SQRT": lambda v: math.sqrt(v) if isinstance(v, (int, float)) and v >= 0 else None,
                "EXP": lambda v: math.exp(v) if isinstance(v, (int, float)) else None,
                "LOG": lambda v: math.log(v) if isinstance(v, (int, float)) and v > 0 else None,
                "LOG10": lambda v: math.log10(v) if isinstance(v, (int, float)) and v > 0 else None,
                "SIN": lambda v: math.sin(v) if isinstance(v, (int, float)) else None,
                "COS": lambda v: math.cos(v) if isinstance(v, (int, float)) else None,
                "TAN": lambda v: math.tan(v) if isinstance(v, (int, float)) else None,
                "ASIN": lambda v: math.asin(v) if isinstance(v, (int, float)) and -1 <= v <= 1 else None,
                "ACOS": lambda v: math.acos(v) if isinstance(v, (int, float)) and -1 <= v <= 1 else None,
                "ATAN": lambda v: math.atan(v) if isinstance(v, (int, float)) else None,
                "DEGREES": lambda v: math.degrees(v) if isinstance(v, (int, float)) else None,
                "RADIANS": lambda v: math.radians(v) if isinstance(v, (int, float)) else None,
                "EXISTS": lambda v: v is not None,
                "PROPERTIES": lambda v: dict(v) if isinstance(v, dict) else None,
                "TYPE": lambda v: self._get_type(v),
                "LABELS": lambda v: self._get_labels(v),
                "KEYS": lambda v: list(v.keys()) if isinstance(v, dict) else None,
                "ID": lambda v: v,  # Node/edge ID — identity
            }
            if name in funcs:
                try:
                    return funcs[name](val)
                except (ValueError, TypeError, OverflowError):
                    return None

            # Path functions: nodes() and relationships()
            if name == "NODES":
                # nodes(path) returns list of nodes from a path
                # Path format: [node_data0, edge_data01, node_data1, ...]
                if val is None:
                    return None
                if not isinstance(val, list):
                    return None
                # Extract nodes at even indices (0, 2, 4, ...)
                result_nodes = [val[i] for i in range(0, len(val), 2)]
                return result_nodes

            if name == "RELATIONSHIPS":
                # relationships(path) returns list of relationships from a path
                # Path format: [node_data0, edge_data01, node_data1, ...]
                if val is None:
                    return None
                if not isinstance(val, list):
                    return None
                # Extract edges at odd indices (1, 3, 5, ...)
                result_rels = [val[i] for i in range(1, len(val), 2)]
                return result_rels

            return None

        # Multi-argument functions
        if name == "REPLACE" and len(args) >= 3:
            if val is None:
                return None
            search, replacement = args[1], args[2]
            if search is None or replacement is None:
                return None
            return str(val).replace(str(search), str(replacement))

        if name == "SUBSTRING":
            if val is None:
                return None
            start = int(args[1]) if args[1] is not None else 0
            if len(args) >= 3 and args[2] is not None:
                return str(val)[start:start + int(args[2])]
            return str(val)[start:]

        if name == "SPLIT" and len(args) >= 2:
            if val is None or args[1] is None:
                return None
            return str(val).split(str(args[1]))

        if name == "LEFT" and len(args) >= 2:
            if val is None or args[1] is None:
                return None
            return str(val)[:int(args[1])]

        if name == "RIGHT" and len(args) >= 2:
            if val is None or args[1] is None:
                return None
            return str(val)[-int(args[1]):]

        if name == "COALESCE":
            for a in args:
                if a is not None:
                    return a
            return None

        if name == "RANGE":
            start = int(args[0]) if args[0] is not None else None
            end = int(args[1]) if args[1] is not None else None
            step = int(args[2]) if len(args) > 2 and args[2] is not None else 1
            if start is None or end is None:
                return None
            # Neo4j range is inclusive on both ends
            # For positive step, use end + 1; for negative step, use end - 1
            if step > 0:
                return list(range(start, end + 1, step))
            elif step < 0:
                return list(range(start, end - 1, step))
            else:
                return []  # step of 0 is invalid

        return None

    def _eval_case_expression(self, node: Tree, ctx: EvalContext) -> Any:
        """Evaluate a CASE expression tree."""
        children = node.children
        if node.data == "expr_searched_case":
            # CASE WHEN cond THEN result ... ELSE default END
            for child in children:
                if isinstance(child, Tree):
                    if child.data == "expr_case_when":
                        cond = self._eval_expression(child.children[0], ctx)
                        if cond:
                            return self._eval_expression(child.children[1], ctx)
                    elif child.data == "expr_case_else":
                        return self._eval_expression(child.children[0], ctx)
            return None
        elif node.data == "expr_simple_case":
            # CASE expr WHEN value THEN result ... ELSE default END
            compare = self._eval_expression(children[0], ctx)
            for child in children[1:]:
                if isinstance(child, Tree):
                    if child.data == "expr_case_simple_when":
                        when_val = self._eval_expression(child.children[0], ctx)
                        if _cypher_equals(compare, when_val):
                            return self._eval_expression(child.children[1], ctx)
                    elif child.data == "expr_case_else":
                        return self._eval_expression(child.children[0], ctx)
            return None
        return None

    def _eval_list_comprehension_expr(self, children: list, ctx: EvalContext) -> Any:
        """Evaluate a list comprehension expression: [var IN source WHERE cond | expr]"""
        # children: [var_name, source_expr, (optional: where_expr), transform_expr]
        var_name = str(children[0])
        source = self._eval_expression(children[1], ctx)
        if source is None or not isinstance(source, (list, tuple)):
            return []

        # Determine where_expr and transform_expr from remaining children
        where_expr = None
        transform_expr = None
        for c in children[2:]:
            if isinstance(c, Tree):
                # Last Tree child is the transform expression
                if transform_expr is not None:
                    where_expr = transform_expr
                transform_expr = c
            else:
                if transform_expr is not None:
                    where_expr = transform_expr
                transform_expr = c

        result = []
        for item in source:
            # Create a sub-context with the iteration variable
            sub_ctx = EvalContext(
                match=ctx.match,
                row={**(ctx.row or {}), var_name: item},
                graph=ctx.graph,
                return_edges=ctx.return_edges,
            )
            # Check WHERE condition
            if where_expr is not None:
                cond = self._eval_expression(where_expr, sub_ctx)
                if not cond:
                    continue
            # Apply transform
            if transform_expr is not None:
                result.append(self._eval_expression(transform_expr, sub_ctx))
            else:
                result.append(item)
        return result

    # === Path comparison helper ===

    @staticmethod
    def _compare_paths(left, right):
        """
        Compare two paths for equality.

        Paths are represented as lists: [node0, edge_data01, node1, edge_data12, node2, ...]
        Two paths are equal if they have the same node sequence, regardless of edge direction.

        Args:
            left: First path (list)
            right: Second path (list)

        Returns:
            True if paths have the same node sequence, False otherwise
        """
        # Extract nodes from paths (nodes are at even indices: 0, 2, 4, ...)
        left_nodes = [left[i] for i in range(0, len(left), 2)] if isinstance(left, list) else None
        right_nodes = [right[i] for i in range(0, len(right), 2)] if isinstance(right, list) else None

        if left_nodes is None or right_nodes is None:
            return None

        return left_nodes == right_nodes

    @staticmethod
    def _types_comparable_for_ordering(left, right):
        """Check if two values can be compared with ordering operators (<, >, <=, >=).

        In Cypher, ordering comparison is only allowed between:
        - Numbers (int and float) - can be compared with each other
        - Booleans - can be compared with booleans (false < true)
        - Strings - can be compared with strings
        - Lists - can be compared with lists (lexicographical)
        - Temporal types - can be compared with same temporal type

        Maps, nodes, relationships, and paths cannot be
        compared with ordering operators (they return null).
        """
        from .temporal import (CypherDate, CypherTime, CypherLocalTime,
                               CypherDateTime, CypherLocalDateTime, CypherDuration)

        # Booleans - can be compared (false < true)
        if isinstance(left, bool) and isinstance(right, bool):
            return True

        # Numbers (excluding bool, since Python bool is subclass of int)
        left_is_number = isinstance(left, (int, float)) and not isinstance(left, bool)
        right_is_number = isinstance(right, (int, float)) and not isinstance(right, bool)

        if left_is_number and right_is_number:
            return True

        # Strings
        if isinstance(left, str) and isinstance(right, str):
            return True

        # Lists (lexicographical comparison)
        if isinstance(left, list) and isinstance(right, list):
            return True

        # Temporal types - same type comparisons
        if isinstance(left, CypherDate) and isinstance(right, CypherDate):
            return True
        if isinstance(left, CypherLocalTime) and isinstance(right, CypherLocalTime):
            return True
        if isinstance(left, CypherTime) and isinstance(right, CypherTime):
            return True
        if isinstance(left, CypherLocalDateTime) and isinstance(right, CypherLocalDateTime):
            return True
        if isinstance(left, CypherDateTime) and isinstance(right, CypherDateTime):
            return True
        if isinstance(left, CypherDuration) and isinstance(right, CypherDuration):
            return True

        # All other type combinations are not comparable for ordering
        return False

    # === Cypher three-valued logic helpers ===

    def _validate_boolean_operand(self, value, operator):
        """Validate that a value is boolean in strict mode.

        Args:
            value: The value to check
            operator: The operator name (for error messages)

        Raises:
            InvalidArgumentType: If strict mode and value is not boolean/null
        """
        if not self._strict:
            return
        if value is None:
            return  # null is allowed in three-valued logic
        if isinstance(value, bool):
            return
        # Not a boolean - raise error in strict mode
        raise InvalidArgumentType(
            f"Type mismatch: expected Boolean but was {type(value).__name__}"
        )

    @staticmethod
    def _cypher_and(a, b):
        """Three-valued AND: false AND null = false, null AND null = null."""
        if a is False or b is False:
            return False
        if a is None or b is None:
            return None
        return bool(a) and bool(b)

    @staticmethod
    def _cypher_or(a, b):
        """Three-valued OR: true OR null = true, null OR null = null."""
        if a is True or b is True:
            return True
        if a is None or b is None:
            return None
        return bool(a) or bool(b)

    @staticmethod
    def _cypher_xor(a, b):
        """Three-valued XOR."""
        if a is None or b is None:
            return None
        return bool(a) != bool(b)

    @staticmethod
    def _cypher_compare(op_tree, left, right):
        """Apply a comparison operator with null propagation."""
        if left is None or right is None:
            return None
        op = op_tree.data if isinstance(op_tree, Tree) else str(op_tree)

        # Special handling for path comparison (equality and inequality only)
        if isinstance(left, list) and isinstance(right, list):
            # Check if both are paths (alternating nodes and edge data)
            # Paths have odd length and start/end with nodes
            if len(left) % 2 == 1 and len(right) % 2 == 1:
                if op == "cmp_eq":
                    return NXCypherExecutor._compare_paths(left, right)
                if op == "cmp_neq":
                    result = NXCypherExecutor._compare_paths(left, right)
                    return not result if result is not None else None

        try:
            if op == "cmp_eq":
                # Use Cypher list equality for list comparison
                if isinstance(left, list) and isinstance(right, list):
                    return NXCypherExecutor._cypher_list_equal(left, right)
                return left == right
            if op == "cmp_neq":
                # Use Cypher list equality for list comparison
                if isinstance(left, list) and isinstance(right, list):
                    eq_result = NXCypherExecutor._cypher_list_equal(left, right)
                    if eq_result is None:
                        return None
                    return not eq_result
                return left != right
            if op == "cmp_lt":
                return left < right
            if op == "cmp_gt":
                return left > right
            if op == "cmp_lte":
                return left <= right
            if op == "cmp_gte":
                return left >= right
            if op == "cmp_regex":
                if left is None or right is None:
                    return None
                return bool(re.fullmatch(str(right), str(left)))
        except TypeError:
            return None
        return None

    # === V4 grammar comparison ===

    @staticmethod
    def _cypher_compare_v4(op_str: str, left, right):
        """Apply a v4 comparison operator (token string) with null propagation."""
        import math

        op = op_str.strip()

        # NaN handling: NaN is never equal to anything (including itself)
        # NaN = x → False, NaN <> x → True, NaN < x → null, NaN > x → null
        left_is_nan = isinstance(left, float) and math.isnan(left)
        right_is_nan = isinstance(right, float) and math.isnan(right)

        if left_is_nan or right_is_nan:
            if op == "=":
                return False  # NaN is never equal to anything
            if op == "<>":
                return True  # NaN is always not equal to anything
            # For ordering comparisons with NaN:
            # - If comparing to a number, return false (NaN is not comparable)
            # - If comparing to non-number, return null (type mismatch)
            other = right if left_is_nan else left
            if isinstance(other, (int, float)):
                return False
            else:
                return None  # Type mismatch with non-number

        if left is None or right is None:
            return None

        # Special handling for path comparison (equality and inequality only)
        # Paths need at least 3 elements: node, edge_data, node
        # Single-element lists should use regular list comparison
        if isinstance(left, list) and isinstance(right, list):
            # Check if both are paths (alternating nodes and edge data)
            # Paths have odd length >= 3 and start/end with nodes
            if len(left) >= 3 and len(right) >= 3 and len(left) % 2 == 1 and len(right) % 2 == 1:
                # Additional check: paths have dict elements at even indices (nodes)
                left_is_path = all(isinstance(left[i], dict) for i in range(0, len(left), 2) if i < len(left))
                right_is_path = all(isinstance(right[i], dict) for i in range(0, len(right), 2) if i < len(right))
                if left_is_path and right_is_path:
                    if op == "=":
                        return NXCypherExecutor._compare_paths(left, right)
                    if op == "<>":
                        result = NXCypherExecutor._compare_paths(left, right)
                        return not result if result is not None else None

        try:
            if op == "=":
                # Use Cypher list equality for list comparison
                if isinstance(left, list) and isinstance(right, list):
                    return NXCypherExecutor._cypher_list_equal(left, right)
                # Use Cypher map equality for map comparison
                if isinstance(left, dict) and isinstance(right, dict):
                    return NXCypherExecutor._cypher_map_equal(left, right)
                return left == right
            if op == "<>":
                # Use Cypher list equality for list comparison
                if isinstance(left, list) and isinstance(right, list):
                    eq_result = NXCypherExecutor._cypher_list_equal(left, right)
                    if eq_result is None:
                        return None
                    return not eq_result
                # Use Cypher map equality for map comparison
                if isinstance(left, dict) and isinstance(right, dict):
                    eq_result = NXCypherExecutor._cypher_map_equal(left, right)
                    if eq_result is None:
                        return None
                    return not eq_result
                return left != right
            if op == "<":
                # Cross-type comparison for ordering returns null
                # Only allow: numbers with numbers, strings with strings
                if not NXCypherExecutor._types_comparable_for_ordering(left, right):
                    return None
                return left < right
            if op == ">":
                if not NXCypherExecutor._types_comparable_for_ordering(left, right):
                    return None
                return left > right
            if op == "<=":
                if not NXCypherExecutor._types_comparable_for_ordering(left, right):
                    return None
                return left <= right
            if op == ">=":
                if not NXCypherExecutor._types_comparable_for_ordering(left, right):
                    return None
                return left >= right
            if op == "=~":
                return bool(re.fullmatch(str(right), str(left)))
        except TypeError:
            return None
        return None

    # === Cypher list equality with null semantics ===

    @staticmethod
    def _cypher_list_equal(left: list, right: list) -> Any:
        """Compare two lists with Cypher null semantics.

        Returns:
        - False if lists have different lengths
        - True if all elements are equal
        - False if any element definitely doesn't match (different types, different values)
        - None if any element comparison involves null (could match)
        """
        if len(left) != len(right):
            return False

        has_null_comparison = False
        for l_elem, r_elem in zip(left, right):
            # Both are None - this is an unknown comparison
            if l_elem is None and r_elem is None:
                has_null_comparison = True
                continue

            # One is None - could match (unknown), could not match
            if l_elem is None or r_elem is None:
                has_null_comparison = True
                continue

            # Both are lists - recurse
            if isinstance(l_elem, list) and isinstance(r_elem, list):
                elem_result = NXCypherExecutor._cypher_list_equal(l_elem, r_elem)
                if elem_result is False:
                    return False
                if elem_result is None:
                    has_null_comparison = True
                continue

            # Different types - definitely not equal
            if type(l_elem) != type(r_elem):
                return False

            # Same type, compare values
            if l_elem != r_elem:
                return False

        # If we had any null comparisons, result is unknown (null)
        if has_null_comparison:
            return None

        return True

    @staticmethod
    def _cypher_map_equal(left: dict, right: dict) -> Any:
        """Compare two maps with Cypher null semantics.

        Returns:
        - False if maps have different keys
        - True if all values are equal
        - False if any value definitely doesn't match
        - None if any value comparison involves null (could match)
        """
        # Different keys means definitely not equal
        if set(left.keys()) != set(right.keys()):
            return False

        has_null_comparison = False
        for key in left.keys():
            l_val = left[key]
            r_val = right[key]

            # Both are None - this is an unknown comparison
            if l_val is None and r_val is None:
                has_null_comparison = True
                continue

            # One is None - could match (unknown), could not match
            if l_val is None or r_val is None:
                has_null_comparison = True
                continue

            # Both are lists - use list equality
            if isinstance(l_val, list) and isinstance(r_val, list):
                elem_result = NXCypherExecutor._cypher_list_equal(l_val, r_val)
                if elem_result is False:
                    return False
                if elem_result is None:
                    has_null_comparison = True
                continue

            # Both are maps - recurse
            if isinstance(l_val, dict) and isinstance(r_val, dict):
                elem_result = NXCypherExecutor._cypher_map_equal(l_val, r_val)
                if elem_result is False:
                    return False
                if elem_result is None:
                    has_null_comparison = True
                continue

            # Different types - definitely not equal
            if type(l_val) != type(r_val):
                return False

            # Same type, compare values
            if l_val != r_val:
                return False

        # If we had any null comparisons, result is unknown (null)
        if has_null_comparison:
            return None

        return True

    # === Cypher IN operator with null semantics ===

    def _cypher_in(self, value: Any, lst: Any) -> Any:
        """Evaluate 'value IN lst' with proper Cypher null semantics.

        Returns:
        - None if lst is None (or not iterable)
        - True if value is definitively found in lst
        - False if value is definitively not in lst (no potential null matches)
        - None if value might be in lst (due to null comparisons)
        """
        if lst is None:
            return None
        if not isinstance(lst, (list, tuple)):
            # In strict mode, raise error for non-list right operand
            if self._strict:
                raise InvalidArgumentType(
                    f"Type mismatch: expected List but was {type(lst).__name__}"
                )
            return None
        if value is None:
            # value is null - if list contains null, result is null; otherwise False
            return None if None in lst else False

        # For list/tuple values, need to use Cypher list equality semantics
        has_potential_match = False
        for elem in lst:
            if elem is None:
                # null in list - could potentially match
                has_potential_match = True
                continue

            if isinstance(value, list) and isinstance(elem, list):
                # Use Cypher list equality
                eq_result = NXCypherExecutor._cypher_list_equal(value, elem)
                if eq_result is True:
                    return True
                if eq_result is None:
                    # Could match (involves nulls)
                    has_potential_match = True
                # eq_result is False - definitely doesn't match, continue
            else:
                # Simple value comparison
                if value == elem:
                    return True

        # No definitive match found
        if has_potential_match:
            return None  # Could be in list
        return False  # Definitely not in list

    # === V4 postfix predicate evaluation ===

    def _eval_v4_postfix(self, child: Tree, base, ctx: EvalContext):
        """Handle v4 postfix predicates: string_expression, list_expression, null_expression."""
        data = child.data

        if data == "string_expression":
            # string_expression: string_exp_prefix property_or_label_expression
            prefix = _find_child_v4(child, "string_exp_prefix")
            rhs_tree = None
            for c in child.children:
                if isinstance(c, Tree) and c.data != "string_exp_prefix":
                    rhs_tree = c
                    break
            rhs = self._eval_expression(rhs_tree, ctx) if rhs_tree else None
            if base is None or rhs is None:
                return None
            # String predicates require both operands to be strings
            if not isinstance(base, str) or not isinstance(rhs, str):
                return None
            if prefix is not None:
                prefix_tokens = [str(t) for t in prefix.children if isinstance(t, Token)]
                prefix_str = " ".join(prefix_tokens).upper()
                if "STARTS" in prefix_str:
                    return base.startswith(rhs)
                if "ENDS" in prefix_str:
                    return base.endswith(rhs)
                if "CONTAINS" in prefix_str:
                    return rhs in base
            return None

        if data == "list_expression":
            # list_expression: IN property_or_label_expression
            #                | LBRACK (expression? RANGE expression? | expression) RBRACK
            if _has_token_v4(child, "IN"):
                rhs_tree = None
                for c in child.children:
                    if isinstance(c, Tree):
                        rhs_tree = c
                        break
                rhs = self._eval_expression(rhs_tree, ctx) if rhs_tree else None
                if rhs is None:
                    return None
                # Cypher semantics for IN:
                # - x IN [] -> false (empty list never contains anything)
                # - null IN [1, 2] -> null (might match some element)
                # - null IN [1, null] -> null
                if isinstance(rhs, list) and len(rhs) == 0:
                    return False
                if base is None:
                    return None
                return base in rhs
            else:
                # List indexing or slicing: [expr] or [expr..expr]
                exprs = [c for c in child.children if isinstance(c, Tree)]
                has_range = _has_token_v4(child, "RANGE")
                if has_range:
                    # Slice: base[start..end]
                    if base is None:
                        return None

                    # Find RANGE position to determine which bounds are present
                    range_idx = None
                    for i, c in enumerate(child.children):
                        if isinstance(c, Token) and c.type == "RANGE":
                            range_idx = i
                            break

                    if len(exprs) == 0:
                        # list[..] - both bounds omitted, use full list
                        start_i, end_i = None, None
                        start_explicit = end_explicit = False
                    elif len(exprs) == 1:
                        # One bound present - determine if it's start or end
                        expr_idx = child.children.index(exprs[0])
                        if expr_idx < range_idx:
                            # list[expr..] - start is present, end is omitted
                            start_val = self._eval_expression(exprs[0], ctx)
                            if start_val is None:
                                return None  # Explicit null start
                            start_i = int(start_val)
                            end_i = None
                        else:
                            # list[..expr] - start is omitted, end is present
                            end_val = self._eval_expression(exprs[0], ctx)
                            if end_val is None:
                                return None  # Explicit null end
                            start_i = None
                            end_i = int(end_val)
                    else:
                        # Both bounds present: list[expr1..expr2]
                        start_val = self._eval_expression(exprs[0], ctx)
                        end_val = self._eval_expression(exprs[1], ctx)
                        if start_val is None or end_val is None:
                            return None  # Explicit null in either bound
                        start_i = int(start_val)
                        end_i = int(end_val)

                    try:
                        return base[start_i:end_i]
                    except (TypeError, IndexError):
                        return None
                elif exprs:
                    # Index: base[expr]
                    idx = self._eval_expression(exprs[0], ctx)
                    if idx is None:
                        return None  # Null index returns null
                    if base is None:
                        return None
                    # Validate index type - must be numeric (integer or float coercible to int)
                    if not isinstance(idx, (int, float)) or isinstance(idx, bool):
                        if self._strict:
                            # InvalidArgumentType is defined in this module
                            raise InvalidArgumentType(
                                f"Type mismatch: list index must be integer, got {type(idx).__name__}"
                            )
                        return None
                    try:
                        return base[int(idx)]
                    except (TypeError, IndexError, KeyError):
                        return None
            return None

        if data == "null_expression":
            # null_expression: IS NOT? NULL_W
            has_not = _has_token_v4(child, "NOT")
            if has_not:
                return base is not None
            return base is None

        if data == "map_projection":
            # map_projection: LBRACE map_projection_item* RBRACE | LBRACE RBRACE
            if not isinstance(base, dict):
                # base should be a node's properties — resolve if needed
                if isinstance(base, str) and ctx.match is not None:
                    host_node = ctx.match.node_mappings.get(base)
                    if host_node is not None:
                        base = dict(ctx.graph.nodes[host_node])
                    else:
                        base = {}
                else:
                    base = {}
            result = {}
            for item in child.children:
                if isinstance(item, Tree) and item.data == "map_projection_item":
                    # Types: name:expr, .name, symbol, .*
                    item_children = item.children
                    if len(item_children) >= 2 and isinstance(item_children[0], Tree) and item_children[0].data == "name":
                        # name COLON expression
                        key = _name_to_str_v4(item_children[0])
                        val_expr = None
                        for ic in item_children[1:]:
                            if isinstance(ic, Tree):
                                val_expr = ic
                                break
                        if val_expr:
                            result[key] = self._eval_expression(val_expr, ctx)
                    elif _has_token_v4(item, "DOT") and _has_token_v4(item, "MULT"):
                        # .* — include all properties
                        result.update(base)
                    elif _has_token_v4(item, "DOT"):
                        # .name — include property by name
                        for ic in item_children:
                            if isinstance(ic, Tree) and ic.data == "name":
                                key = _name_to_str_v4(ic)
                                result[key] = base.get(key)
                    else:
                        # symbol — include variable value
                        for ic in item_children:
                            if isinstance(ic, Tree) and ic.data == "symbol":
                                key = _symbol_to_str_v4(ic)
                                result[key] = self._eval_variable_or_literal(key, ctx)
            return result

        return base

    # === V4 function invocation ===

    def _eval_v4_function_invocation(self, node: Tree, ctx: EvalContext):
        """Evaluate a v4 function_invocation: invocation_name LPAREN DISTINCT? expression_chain? RPAREN"""
        inv_name = _find_child_v4(node, "invocation_name")
        if inv_name is None:
            return None
        func_name = self._v4_invocation_name_to_str(inv_name).upper()

        has_distinct = _has_token_v4(node, "DISTINCT")

        ec = _find_child_v4(node, "expression_chain")
        args = []
        if ec is not None:
            for c in ec.children:
                if isinstance(c, Tree):
                    args.append(c)

        # Special handling for ID() — needs raw node/edge ID
        if func_name == "ID" and len(args) == 1 and ctx.match is not None:
            var_name = self._extract_variable_name(args[0])
            if var_name is not None:
                host_node = ctx.match.node_mappings.get(var_name)
                if host_node is not None:
                    return host_node
                if ctx.return_edges and var_name in ctx.return_edges:
                    edge_mapping = ctx.return_edges[var_name]
                    try:
                        host_edges = ctx.match.mth.edge(*edge_mapping).edges
                        return host_edges
                    except (AttributeError, TypeError, KeyError):
                        return None

        # Special handling for TYPE() — needs edge type
        if func_name == "TYPE" and len(args) == 1 and ctx.match is not None:
            var_name = self._extract_variable_name(args[0])
            if var_name is not None and ctx.return_edges and var_name in ctx.return_edges:
                edge_mapping = ctx.return_edges[var_name]
                try:
                    u, v = edge_mapping
                    host_u = ctx.match.node_mappings.get(u)
                    host_v = ctx.match.node_mappings.get(v)
                    if host_u is not None and host_v is not None:
                        # For MultiDiGraph, we need to use the edge key from match.edge_mapping
                        edge_key = None
                        if ctx.match.edge_mapping is not None and (u, v) in ctx.match.edge_mapping.edge_key_map:
                            edge_hop_key = ctx.match.edge_mapping.edge_key_map[(u, v)]
                            if edge_hop_key.keys:
                                edge_key = edge_hop_key.keys[0]

                        if ctx.graph.is_multigraph():
                            if edge_key is not None:
                                edge_data = ctx.graph.get_edge_data(host_u, host_v, key=edge_key) or {}
                            else:
                                # Fallback: get first edge
                                all_edges = ctx.graph.get_edge_data(host_u, host_v)
                                if all_edges:
                                    first_key = next(iter(all_edges.keys()))
                                    edge_data = all_edges[first_key]
                                else:
                                    edge_data = {}
                        else:
                            edge_data = ctx.graph.get_edge_data(host_u, host_v) or {}

                        labels = edge_data.get("__labels__", None)
                        # Return as string, not set
                        if isinstance(labels, set):
                            return next(iter(labels)) if labels else None
                        if isinstance(labels, (list, tuple)):
                            return labels[0] if labels else None
                        return labels
                except (ValueError, TypeError, KeyError):
                    return None

        # Special handling for STARTNODE() — return source node data
        if func_name == "STARTNODE" and len(args) == 1 and ctx.match is not None:
            var_name = self._extract_variable_name(args[0])
            if var_name is not None and ctx.return_edges and var_name in ctx.return_edges:
                edge_mapping = ctx.return_edges[var_name]
                try:
                    u, v = edge_mapping
                    host_u = ctx.match.node_mappings.get(u)
                    if host_u is not None:
                        node_data = dict(ctx.graph.nodes.get(host_u, {}))
                        # Filter out internal attributes
                        return {k: v for k, v in node_data.items() if not k.startswith("__")}
                except (ValueError, TypeError, KeyError):
                    return None

        # Special handling for ENDNODE() — return target node data
        if func_name == "ENDNODE" and len(args) == 1 and ctx.match is not None:
            var_name = self._extract_variable_name(args[0])
            if var_name is not None and ctx.return_edges and var_name in ctx.return_edges:
                edge_mapping = ctx.return_edges[var_name]
                try:
                    u, v = edge_mapping
                    host_v = ctx.match.node_mappings.get(v)
                    if host_v is not None:
                        node_data = dict(ctx.graph.nodes.get(host_v, {}))
                        # Filter out internal attributes
                        return {k: v for k, v in node_data.items() if not k.startswith("__")}
                except (ValueError, TypeError, KeyError):
                    return None

        # Special handling for LABELS() — needs node labels
        if func_name == "LABELS" and len(args) == 1 and ctx.match is not None:
            var_name = self._extract_variable_name(args[0])
            if var_name is not None:
                host_node = ctx.match.node_mappings.get(var_name)
                if host_node is not None:
                    node_data = ctx.graph.nodes.get(host_node, {})
                    labels = node_data.get("__labels__", set())
                    if isinstance(labels, set):
                        return sorted(labels)
                    return list(labels) if labels else []

        # Special handling for PROPERTIES()
        if func_name == "PROPERTIES" and len(args) == 1 and ctx.match is not None:
            var_name = self._extract_variable_name(args[0])
            if var_name is not None:
                host_node = ctx.match.node_mappings.get(var_name)
                if host_node is not None:
                    props = dict(ctx.graph.nodes[host_node])
                    props.pop("__labels__", None)
                    return props

        # Special handling for KEYS() — needs node or relationship property keys
        if func_name == "KEYS" and len(args) == 1 and ctx.match is not None:
            var_name = self._extract_variable_name(args[0])
            if var_name is not None:
                # Check for node first
                host_node = ctx.match.node_mappings.get(var_name)
                if host_node is not None:
                    node_data = ctx.graph.nodes.get(host_node, {})
                    # Return list of keys, excluding internal attributes and null values
                    return [k for k, v in node_data.items() if not k.startswith("__") and v is not None]
                # Check for relationship via return_edges
                if ctx.return_edges and var_name in ctx.return_edges:
                    edge_tuple = ctx.return_edges[var_name]
                    try:
                        edge_path = ctx.match.mth.edge(*edge_tuple) if ctx.match and ctx.match.mth else None
                        if edge_path is not None:
                            for edge in edge_path.edges:
                                if ctx.graph.has_edge(edge.u, edge.v):
                                    if ctx.graph.is_multigraph():
                                        edge_data = ctx.graph.edges[edge.u, edge.v, edge.k] if edge.k is not None else ctx.graph.edges[edge.u, edge.v, 0]
                                    else:
                                        edge_data = ctx.graph.edges[edge.u, edge.v]
                                    # Return list of keys, excluding internal attributes and null values
                                    return [k for k, v in edge_data.items() if not k.startswith("__") and v is not None]
                    except (KeyError, AttributeError):
                        pass

        # Special handling for EXISTS() with property access
        if func_name == "EXISTS" and len(args) == 1:
            val = self._eval_expression(args[0], ctx)
            return val is not None

        # Special handling for LENGTH() with path variables
        if func_name == "LENGTH" and len(args) == 1:
            var_name = self._extract_variable_name(args[0])
            if var_name is not None and var_name in self._paths:
                # Evaluate the path variable to get the path list
                path = self._eval_expression(args[0], ctx)
                if path is None:
                    return None
                # Path format: [node, edge, node, edge, node, ...]
                # Length is the number of edges = (len - 1) / 2
                if isinstance(path, list) and len(path) >= 1:
                    # A path with N nodes has N-1 edges
                    # The list format is [node, edge, node, edge, ...]
                    # So for a 2-node path: [n1, e1, n2] -> len=3 -> (3-1)/2 = 1 edge
                    return (len(path) - 1) // 2
                return 0

        # Evaluate arguments
        evaluated = [self._eval_expression(a, ctx) for a in args]
        return self._call_function(func_name, evaluated, ctx)

    @staticmethod
    def _v4_invocation_name_to_str(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_v4(c))
        return ".".join(parts)

    # === V4 CASE expression ===

    def _eval_v4_case(self, node: Tree, ctx: EvalContext):
        """Evaluate v4 case_expression: CASE expression? (WHEN expression THEN expression)+ (ELSE expression)? END"""
        children = node.children
        # Separate into: optional test_expr, WHEN/THEN pairs, optional ELSE
        trees = [c for c in children if isinstance(c, Tree)]
        tokens = [c for c in children if isinstance(c, Token)]
        token_types = [t.type for t in tokens]

        # Find positions of WHEN, THEN, ELSE, END tokens
        when_indices = [i for i, t in enumerate(children) if isinstance(t, Token) and str(t).upper() == "WHEN"]
        then_indices = [i for i, t in enumerate(children) if isinstance(t, Token) and str(t).upper() == "THEN"]
        else_idx = next((i for i, t in enumerate(children) if isinstance(t, Token) and str(t).upper() == "ELSE"), None)

        # Determine if this is simple or searched CASE
        # Simple: CASE <expr> WHEN ... — expression before first WHEN
        # Searched: CASE WHEN ... — no expression before first WHEN
        test_expr = None
        if when_indices:
            first_when = when_indices[0]
            # Check if there's an expression between CASE and first WHEN
            case_idx = next((i for i, t in enumerate(children) if isinstance(t, Token) and str(t).upper() == "CASE"), -1)
            for i in range(case_idx + 1, first_when):
                if isinstance(children[i], Tree):
                    test_expr = children[i]
                    break

        if test_expr is not None:
            # Simple CASE
            compare_val = self._eval_expression(test_expr, ctx)
            for w_idx, t_idx in zip(when_indices, then_indices):
                # WHEN value is at w_idx+1, THEN result is at t_idx+1
                when_expr = children[w_idx + 1] if w_idx + 1 < len(children) else None
                then_expr = children[t_idx + 1] if t_idx + 1 < len(children) else None
                if when_expr is not None:
                    when_val = self._eval_expression(when_expr, ctx)
                    if _cypher_equals(compare_val, when_val):
                        return self._eval_expression(then_expr, ctx) if then_expr else None
        else:
            # Searched CASE
            for w_idx, t_idx in zip(when_indices, then_indices):
                when_expr = children[w_idx + 1] if w_idx + 1 < len(children) else None
                then_expr = children[t_idx + 1] if t_idx + 1 < len(children) else None
                if when_expr is not None:
                    cond = self._eval_expression(when_expr, ctx)
                    if cond is True:
                        return self._eval_expression(then_expr, ctx) if then_expr else None

        # ELSE clause
        if else_idx is not None:
            else_expr = children[else_idx + 1] if else_idx + 1 < len(children) else None
            if else_expr is not None and isinstance(else_expr, Tree):
                return self._eval_expression(else_expr, ctx)

        return None

    # === V4 list comprehension ===

    def _eval_v4_list_comprehension(self, node: Tree, ctx: EvalContext):
        """Evaluate v4 list_comprehension: LBRACK filter_expression (STICK expression)? RBRACK
        filter_expression: symbol IN expression where?
        """
        filter_expr = _find_child_v4(node, "filter_expression")
        if filter_expr is None:
            return []

        # Extract: symbol IN expression where?
        var_name = None
        source_expr = None
        where_tree = None
        sym = _find_child_v4(filter_expr, "symbol")
        if sym is not None:
            var_name = _symbol_to_str_v4(sym)

        # Source expression: first expression child after symbol
        found_in = False
        for c in filter_expr.children:
            if isinstance(c, Token) and c.type == "IN":
                found_in = True
                continue
            if found_in and isinstance(c, Tree):
                if c.data == "where":
                    where_tree = c
                elif source_expr is None:
                    source_expr = c

        if var_name is None or source_expr is None:
            return []

        source = self._eval_expression(source_expr, ctx)
        if source is None or not isinstance(source, (list, tuple)):
            return []

        # Find transform expression (after STICK)
        transform_expr = None
        found_stick = False
        for c in node.children:
            if isinstance(c, Token) and c.type == "STICK":
                found_stick = True
                continue
            if found_stick and isinstance(c, Tree):
                transform_expr = c
                break

        # WHERE clause - where: WHERE expression, so find the expression Tree child
        where_expr = None
        if where_tree is not None:
            for wc in where_tree.children:
                if isinstance(wc, Tree):
                    where_expr = wc
                    break

        result = []
        for item in source:
            sub_ctx = EvalContext(
                match=ctx.match,
                row={**(ctx.row or {}), var_name: item},
                graph=ctx.graph,
                return_edges=ctx.return_edges,
            )
            if where_expr is not None:
                cond = self._eval_expression(where_expr, sub_ctx)
                if not cond:
                    continue
            if transform_expr is not None:
                result.append(self._eval_expression(transform_expr, sub_ctx))
            else:
                result.append(item)
        return result

    # === V4 pattern comprehension ===

    def _eval_pattern_comprehension(self, node: Tree, ctx: EvalContext) -> List:
        """Evaluate pattern comprehension: [var = (pattern) WHERE cond | expr].

        Grammar: pattern_comprehension: "[" (variable "=")? relationships_pattern (WHERE expr)? "|" expr "]"

        Returns a list of transform expression results for each pattern match.
        """
        if ctx.graph is None:
            return []

        children = node.children
        graph = ctx.graph

        # Parse the pattern_comprehension tree
        path_var = None
        pattern_tree = None
        where_expr = None
        transform_expr = None

        # Find lhs (optional variable binding)
        lhs = _find_child_v4(node, "lhs")
        if lhs:
            sym = _find_child_v4(lhs, "symbol")
            if sym:
                path_var = _symbol_to_str_v4(sym)

        # Find relationships_chain_pattern or relationships_pattern
        pattern_tree = _find_child_v4(node, "relationships_chain_pattern")
        if pattern_tree is None:
            pattern_tree = _find_child_v4(node, "relationships_pattern")

        # Find optional WHERE clause
        where_tree = _find_child_v4(node, "where")
        if where_tree:
            for c in where_tree.children:
                if isinstance(c, Tree):
                    where_expr = c
                    break

        # Find transform expression (after STICK |)
        found_stick = False
        for c in children:
            if isinstance(c, Token) and c.type == "STICK":
                found_stick = True
                continue
            if found_stick and isinstance(c, Tree) and c.data == "expression":
                transform_expr = c
                break

        if pattern_tree is None or transform_expr is None:
            return []

        # Extract node patterns and relationship patterns from the pattern
        node_patterns = []
        relationship_patterns = []

        for c in pattern_tree.children:
            if isinstance(c, Tree):
                if c.data == "node_pattern":
                    node_patterns.append(c)
                elif c.data == "pattern_elem_chain":
                    rel_pat = _find_child_v4(c, "relationship_pattern")
                    node_pat = _find_child_v4(c, "node_pattern")
                    if rel_pat:
                        relationship_patterns.append(rel_pat)
                    if node_pat:
                        node_patterns.append(node_pat)

        if len(node_patterns) < 2 or len(relationship_patterns) < 1:
            return []

        # Helper to extract node alias and labels
        def get_node_info(node_pattern: Tree):
            alias = None
            labels = []
            sym = _find_child_v4(node_pattern, "symbol")
            if sym:
                alias = _symbol_to_str_v4(sym)
            node_labels = _find_child_v4(node_pattern, "node_labels")
            if node_labels:
                # node_labels structure: : name (: name)*
                # Each 'name' contains a symbol with the label
                for lc in node_labels.children:
                    if isinstance(lc, Tree) and lc.data == "name":
                        labels.append(_name_to_str_v4(lc))
                    elif isinstance(lc, Tree) and lc.data == "node_label":
                        # Alternative structure: node_label -> name
                        label_name = _find_child_v4(lc, "name")
                        if label_name:
                            labels.append(_name_to_str_v4(label_name))
            return alias, labels

        # Helper to extract relationship info
        def get_rel_info(rel_pattern: Tree):
            has_lt = False
            has_gt = False
            edge_types = []
            rel_var = None

            for c in rel_pattern.children:
                if isinstance(c, Token):
                    if c.type == "LT":
                        has_lt = True
                    elif c.type == "GT":
                        has_gt = True
                elif isinstance(c, Tree) and c.data == "relation_detail":
                    sym = _find_child_v4(c, "symbol")
                    if sym:
                        rel_var = _symbol_to_str_v4(sym)
                    rel_types = _find_child_v4(c, "relationship_types")
                    if rel_types:
                        for tc in rel_types.children:
                            if isinstance(tc, Tree) and tc.data == "name":
                                edge_types.append(_name_to_str_v4(tc))

            if has_lt and not has_gt:
                direction = "left"
            elif has_gt and not has_lt:
                direction = "right"
            else:
                direction = "both"

            return direction, edge_types, rel_var

        source_alias, source_labels = get_node_info(node_patterns[0])
        target_alias, target_labels = get_node_info(node_patterns[1])
        direction, edge_types, rel_var = get_rel_info(relationship_patterns[0])

        # Get the source node(s) from context
        # Check both match context and row context for the source alias
        source_nodes = []
        if source_alias:
            # First check match context (node bound in MATCH clause)
            if ctx.match and ctx.match.node_mappings and source_alias in ctx.match.node_mappings:
                source_nodes = [ctx.match.node_mappings[source_alias]]
            # Then check row context (node from list comprehension variable, etc.)
            elif ctx.row and source_alias in ctx.row:
                node_val = ctx.row[source_alias]
                if isinstance(node_val, dict) and '__id__' in node_val:
                    source_nodes = [node_val['__id__']]
                elif node_val in graph.nodes():
                    source_nodes = [node_val]

        if not source_nodes:
            return []

        # Find all matching edges
        results = []
        for source_node in source_nodes:
            # Get candidate target nodes based on direction
            candidates = set()
            if direction in ("right", "both"):
                if hasattr(graph, 'successors'):
                    candidates.update(graph.successors(source_node))
                else:
                    candidates.update(n for n in graph.neighbors(source_node))
            if direction in ("left", "both"):
                if hasattr(graph, 'predecessors'):
                    candidates.update(graph.predecessors(source_node))
                else:
                    candidates.update(n for n in graph.neighbors(source_node))

            for target_node in candidates:
                # Check target labels
                if target_labels:
                    target_data = graph.nodes.get(target_node, {})
                    target_node_labels = target_data.get('__labels__', set())
                    if not all(lbl in target_node_labels for lbl in target_labels):
                        continue

                # Helper to check if edge matches expected types
                def edge_type_matches(ed, expected_types):
                    if not expected_types:
                        return True
                    # Check 'type' key
                    if ed.get('type') in expected_types:
                        return True
                    # Check '__labels__' (edge types stored as labels)
                    edge_labels = ed.get('__labels__', set())
                    if isinstance(edge_labels, set):
                        if any(t in edge_labels for t in expected_types):
                            return True
                    return False

                # Check if edge exists with proper direction and type
                edge_data = None
                if direction == "right":
                    if graph.has_edge(source_node, target_node):
                        if graph.is_multigraph():
                            for key in graph[source_node][target_node]:
                                ed = graph.edges[source_node, target_node, key]
                                if edge_type_matches(ed, edge_types):
                                    edge_data = ed
                                    break
                        else:
                            ed = graph.edges.get((source_node, target_node), {})
                            if edge_type_matches(ed, edge_types):
                                edge_data = ed
                elif direction == "left":
                    if graph.has_edge(target_node, source_node):
                        if graph.is_multigraph():
                            for key in graph[target_node][source_node]:
                                ed = graph.edges[target_node, source_node, key]
                                if edge_type_matches(ed, edge_types):
                                    edge_data = ed
                                    break
                        else:
                            ed = graph.edges.get((target_node, source_node), {})
                            if edge_type_matches(ed, edge_types):
                                edge_data = ed
                else:  # both directions
                    for u, v in [(source_node, target_node), (target_node, source_node)]:
                        if graph.has_edge(u, v):
                            if graph.is_multigraph():
                                for key in graph[u][v]:
                                    ed = graph.edges[u, v, key]
                                    if edge_type_matches(ed, edge_types):
                                        edge_data = ed
                                        break
                            else:
                                ed = graph.edges.get((u, v), {})
                                if edge_type_matches(ed, edge_types):
                                    edge_data = ed
                        if edge_data:
                            break

                if edge_data is None:
                    continue

                # Build the path representation
                source_data = dict(graph.nodes.get(source_node, {}))
                target_data = dict(graph.nodes.get(target_node, {}))
                path = [source_data, dict(edge_data), target_data]

                # Create sub-context with bound variables
                sub_row = dict(ctx.row) if ctx.row else {}
                if path_var:
                    sub_row[path_var] = path
                if target_alias:
                    sub_row[target_alias] = target_data
                if rel_var:
                    sub_row[rel_var] = edge_data

                sub_ctx = EvalContext(
                    match=ctx.match,
                    row=sub_row,
                    graph=ctx.graph,
                    return_edges=ctx.return_edges,
                )

                # Apply WHERE filter if present
                if where_expr:
                    cond = self._eval_expression(where_expr, sub_ctx)
                    if not cond:
                        continue

                # Evaluate transform expression
                result_val = self._eval_expression(transform_expr, sub_ctx)
                results.append(result_val)

        return results

    # === V4 relationships chain pattern (edge existence) ===

    def _eval_relationships_chain_pattern(self, node: Tree, ctx: EvalContext) -> bool:
        """Evaluate a relationships_chain_pattern for edge existence.

        Grammar: relationships_chain_pattern: node_pattern pattern_elem_chain+

        This is used in WHERE clauses like: WHERE (a)-->(b)
        Returns True if the edge pattern exists, False otherwise.
        """
        if ctx.match is None or ctx.graph is None:
            return False

        children = node.children

        # Extract node patterns and relationship patterns
        node_patterns = []
        relationship_patterns = []

        for c in children:
            if isinstance(c, Tree):
                if c.data == "node_pattern":
                    node_patterns.append(c)
                elif c.data == "pattern_elem_chain":
                    # pattern_elem_chain: relationship_pattern node_pattern
                    rel_pat = _find_child_v4(c, "relationship_pattern")
                    node_pat = _find_child_v4(c, "node_pattern")
                    if rel_pat:
                        relationship_patterns.append(rel_pat)
                    if node_pat:
                        node_patterns.append(node_pat)

        if len(node_patterns) < 2 or len(relationship_patterns) < 1:
            return False

        # Extract node aliases
        def get_node_alias(node_pattern: Tree) -> Optional[str]:
            sym = _find_child_v4(node_pattern, "symbol")
            if sym:
                return _symbol_to_str_v4(sym)
            return None

        # Extract relationship direction and type
        def get_rel_info(rel_pattern: Tree) -> tuple:
            """Returns (direction, edge_types) where direction is 'right', 'left', or 'both'."""
            has_lt = False
            has_gt = False
            edge_types = []

            for c in rel_pattern.children:
                if isinstance(c, Token):
                    if c.type == "LT":
                        has_lt = True
                    elif c.type == "GT":
                        has_gt = True
                elif isinstance(c, Tree) and c.data == "relation_detail":
                    # Extract edge types from relation_detail
                    rel_types = _find_child_v4(c, "relationship_types")
                    if rel_types:
                        for tc in rel_types.children:
                            if isinstance(tc, Tree) and tc.data == "name":
                                edge_types.append(_name_to_str_v4(tc))

            # Determine direction
            if has_lt and not has_gt:
                direction = "left"
            elif has_gt and not has_lt:
                direction = "right"
            elif has_lt and has_gt:
                direction = "both"
            else:
                direction = "both"  # undirected: -[]-

            return (direction, edge_types)

        # For now, handle simple two-node patterns: (a)-->(b) or (a)-->()
        source_alias = get_node_alias(node_patterns[0])
        target_alias = get_node_alias(node_patterns[1])
        direction, edge_types = get_rel_info(relationship_patterns[0])

        # Source must be bound
        if source_alias is None:
            return False

        # Get source node ID from the match
        source_node = ctx.match.node_mappings.get(source_alias)
        if source_node is None:
            return False

        # Get target node ID (may be None for unbound target like ())
        target_node = None
        if target_alias is not None:
            target_node = ctx.match.node_mappings.get(target_alias)
            if target_node is None:
                return False

        edge_type = edge_types[0] if edge_types else None

        # Helper to check if an edge matches the type constraint
        def edge_matches_type(edge_data):
            if edge_type is None:
                return True
            if ctx.graph.is_multigraph():
                for key_data in edge_data.values():
                    labels = key_data.get("__labels__", set())
                    if isinstance(labels, set) and edge_type in labels:
                        return True
                return False
            else:
                labels = edge_data.get("__labels__", set())
                return isinstance(labels, set) and edge_type in labels

        # If target is unbound, check if source has ANY matching edges
        if target_node is None:
            if direction == "right":
                # Check outgoing edges from source
                for neighbor in ctx.graph.successors(source_node):
                    edge_data = ctx.graph.get_edge_data(source_node, neighbor)
                    if edge_data and edge_matches_type(edge_data):
                        return True
            elif direction == "left":
                # Check incoming edges to source
                for neighbor in ctx.graph.predecessors(source_node):
                    edge_data = ctx.graph.get_edge_data(neighbor, source_node)
                    if edge_data and edge_matches_type(edge_data):
                        return True
            else:  # undirected
                # Check both directions
                for neighbor in ctx.graph.successors(source_node):
                    edge_data = ctx.graph.get_edge_data(source_node, neighbor)
                    if edge_data and edge_matches_type(edge_data):
                        return True
                for neighbor in ctx.graph.predecessors(source_node):
                    edge_data = ctx.graph.get_edge_data(neighbor, source_node)
                    if edge_data and edge_matches_type(edge_data):
                        return True
            return False

        # Both nodes are bound - check for specific edge
        edges_to_check = []
        if direction == "right":
            edges_to_check = [(source_node, target_node)]
        elif direction == "left":
            edges_to_check = [(target_node, source_node)]
        else:  # undirected - check both directions
            edges_to_check = [(source_node, target_node), (target_node, source_node)]

        for u, v in edges_to_check:
            if not ctx.graph.has_edge(u, v):
                continue
            edge_data = ctx.graph.get_edge_data(u, v)
            if edge_data and edge_matches_type(edge_data):
                return True

        return False

    # === V4 quantifier predicates (ANY/ALL/NONE/SINGLE) ===

    def _eval_v4_filter_with(self, node: Tree, ctx: EvalContext):
        """Evaluate v4 filter_with: (ALL | ANY | NONE | SINGLE) LPAREN filter_expression RPAREN"""
        # Determine which quantifier
        quantifier = None
        for c in node.children:
            if isinstance(c, Token) and c.type in ("ALL", "ANY", "NONE", "SINGLE"):
                quantifier = c.type
                break

        filter_expr = _find_child_v4(node, "filter_expression")
        if filter_expr is None or quantifier is None:
            return None

        # filter_expression: symbol IN expression where?
        var_name = None
        source_expr = None
        where_tree = None
        sym = _find_child_v4(filter_expr, "symbol")
        if sym:
            var_name = _symbol_to_str_v4(sym)

        found_in = False
        for c in filter_expr.children:
            if isinstance(c, Token) and c.type == "IN":
                found_in = True
                continue
            if found_in and isinstance(c, Tree):
                if c.data == "where":
                    where_tree = c
                elif source_expr is None:
                    source_expr = c

        if var_name is None or source_expr is None:
            return None

        source = self._eval_expression(source_expr, ctx)
        if source is None or not isinstance(source, (list, tuple)):
            return None

        # where tree has: [Token('WHERE'), Tree('expression', ...)]
        # We need the expression, not the WHERE token
        where_expr = None
        if where_tree and where_tree.children:
            for child in where_tree.children:
                if isinstance(child, Tree) and child.data == "expression":
                    where_expr = child
                    break
        if where_expr is None:
            # No predicate — not meaningful, return based on quantifier
            if quantifier == "ALL":
                return True
            if quantifier == "ANY":
                return len(source) > 0
            if quantifier == "NONE":
                return len(source) == 0
            if quantifier == "SINGLE":
                return len(source) == 1

        # Track TRUE, FALSE, and NULL outcomes separately for proper null propagation
        true_count = 0
        false_count = 0
        null_count = 0

        for item in source:
            sub_ctx = EvalContext(
                match=ctx.match,
                row={**(ctx.row or {}), var_name: item},
                graph=ctx.graph,
                return_edges=ctx.return_edges,
            )
            cond = self._eval_expression(where_expr, sub_ctx)
            if cond is True:
                true_count += 1
            elif cond is False:
                false_count += 1
            else:
                # NULL or other non-boolean result
                null_count += 1

        # Apply quantifier semantics with proper NULL propagation
        # Reference: Neo4j Cypher semantics for quantifiers with nulls
        if quantifier == "ALL":
            # ALL: True if all are True, False if any is False, NULL if some NULL and no False
            if false_count > 0:
                return False
            if null_count > 0:
                return None
            return True

        if quantifier == "ANY":
            # ANY: True if any is True, False if all are False, NULL if some NULL and no True
            if true_count > 0:
                return True
            if null_count > 0:
                return None
            return False

        if quantifier == "NONE":
            # NONE: True if none are True, False if any is True, NULL if some NULL and no True
            if true_count > 0:
                return False
            if null_count > 0:
                return None
            return True

        if quantifier == "SINGLE":
            # SINGLE: True if exactly one is True, False if zero or >1 are True,
            # NULL if some NULL and we can't determine (0 or 1 true with nulls)
            if true_count == 1 and null_count == 0:
                return True
            if true_count > 1:
                return False
            if true_count == 0 and null_count == 0:
                return False
            # true_count is 0 or 1, with some nulls - can't determine
            return None

        return None

    # === V4 REDUCE expression ===

    def _eval_v4_reduce(self, node: Tree, ctx: EvalContext):
        """Evaluate v4 reduce_expression: REDUCE LPAREN symbol ASSIGN expression COMMA symbol IN expression STICK expression RPAREN"""
        children = node.children
        # Extract components by position
        # REDUCE ( acc = init , var IN source | body )
        trees = [c for c in children if isinstance(c, Tree)]
        # Expected tree children: symbol, expression(init), symbol, expression(source), expression(body)
        symbols = []
        expressions = []
        for c in children:
            if isinstance(c, Tree):
                if c.data == "symbol":
                    symbols.append(_symbol_to_str_v4(c))
                elif c.data == "lhs":
                    # lhs: symbol ASSIGN -> extract symbol
                    lhs_sym = _find_child_v4(c, "symbol")
                    if lhs_sym:
                        symbols.append(_symbol_to_str_v4(lhs_sym))
                else:
                    expressions.append(c)

        if len(symbols) < 2 or len(expressions) < 3:
            return None

        acc_name = symbols[0]
        var_name = symbols[1]
        init_expr = expressions[0]
        source_expr = expressions[1]
        body_expr = expressions[2]

        accumulator = self._eval_expression(init_expr, ctx)
        source = self._eval_expression(source_expr, ctx)

        if source is None or not isinstance(source, (list, tuple)):
            return accumulator

        for item in source:
            sub_ctx = EvalContext(
                match=ctx.match,
                row={**(ctx.row or {}), acc_name: accumulator, var_name: item},
                graph=ctx.graph,
                return_edges=ctx.return_edges,
            )
            accumulator = self._eval_expression(body_expr, sub_ctx)

        return accumulator

    def _evaluate_case_expression(self, case_expr: CaseExpression, match: Match) -> Any:
        """Evaluate a CASE expression for a given match.

        Args:
            case_expr: The CaseExpression object
            match: The current match being processed

        Returns:
            The result of the CASE expression
        """
        if case_expr.case_type == "searched":
            # Searched CASE: CASE WHEN condition THEN result ...
            for when_clause in case_expr.when_clauses:
                condition = when_clause["condition"]
                result = when_clause["result"]

                # Evaluate the condition
                if self._evaluate_case_condition(condition, match):
                    return self._resolve_case_result(result, match)

        elif case_expr.case_type == "simple":
            # Simple CASE: CASE expr WHEN value THEN result ...
            compare_value = self._resolve_case_value(case_expr.compare_value, match)

            for when_clause in case_expr.when_clauses:
                when_value = when_clause["value"]
                result = when_clause["result"]

                # Resolve the when value
                resolved_when = self._resolve_case_value(when_value, match)

                if _cypher_equals(compare_value, resolved_when):
                    return self._resolve_case_result(result, match)

        # Return ELSE result or None
        if case_expr.else_result is not None:
            return self._resolve_case_result(case_expr.else_result, match)
        return None

    def _evaluate_case_condition(self, condition, match: Match) -> bool:
        """Evaluate a condition within a CASE expression."""
        # The condition is a tuple like (marker, entity_id, operator_obj, value)
        # where marker is True for positive conditions
        if callable(condition):
            # It's already a processed condition function
            try:
                result = condition(match, self._target_graph, self._return_edges)
                if isinstance(result, tuple):
                    return result[0]  # (satisfies, where_results)
                return bool(result)
            except Exception:
                return False

        if isinstance(condition, (list, tuple)):
            if len(condition) == 4:
                # Format: (marker, entity_id, operator_obj, value)
                marker, entity_id, op_obj, value = condition

                # Resolve entity value from match
                entity_value = self._resolve_case_value(entity_id, match)

                # Also resolve the comparison value (might be entity ref like b.age)
                resolved_value = self._resolve_case_value(value, match)

                # The operator object has a compare method or is callable
                if hasattr(op_obj, 'compare'):
                    try:
                        return op_obj.compare(entity_value, resolved_value)
                    except Exception:
                        return False
                elif callable(op_obj):
                    try:
                        return op_obj(entity_value, resolved_value)
                    except Exception:
                        return False

            elif len(condition) >= 3:
                entity, op, value = condition[0], condition[1], condition[2]

                # Resolve entity value from match
                entity_value = self._resolve_case_value(entity, match)

                # Apply operator
                if callable(op):
                    try:
                        return op(entity_value, value)
                    except Exception:
                        return False

        return False

    def _resolve_case_value(self, value, match: Match) -> Any:
        """Resolve a value that might be an entity reference or a literal."""
        if value is None:
            return None

        if isinstance(value, str):
            # Check if it's an entity.attribute reference
            if "." in value:
                entity_name, attr = value.rsplit(".", 1)
                host_node = match.node_mappings.get(entity_name)
                if host_node is not None:
                    return self._target_graph.nodes[host_node].get(attr)
                return None
            # Check if it's an entity reference
            elif value in match.node_mappings:
                host_node = match.node_mappings[value]
                if host_node is not None:
                    return self._target_graph.nodes[host_node]
                return None
            # Strip quotes from string literals
            if (value.startswith('"') and value.endswith('"')) or \
               (value.startswith("'") and value.endswith("'")):
                return value[1:-1]

        return value

    def _resolve_case_result(self, result, match: Match) -> Any:
        """Resolve the result value from a CASE expression."""
        if result is None:
            return None

        # Handle string results (entity refs or literals)
        if isinstance(result, str):
            # Check if it's an entity.attribute reference
            if "." in result:
                entity_name, attr = result.rsplit(".", 1)
                host_node = match.node_mappings.get(entity_name)
                if host_node is not None:
                    return self._target_graph.nodes[host_node].get(attr)
                return None
            # Check if it's an entity reference
            elif result in match.node_mappings:
                host_node = match.node_mappings[result]
                if host_node is not None:
                    return self._target_graph.nodes[host_node]
                return None
            # Strip quotes from string literals
            if (result.startswith('"') and result.endswith('"')) or \
               (result.startswith("'") and result.endswith("'")):
                return result[1:-1]

        return result

    def _evaluate_list_comprehension(self, list_comp: ListComprehension, match: Match) -> List:
        """Evaluate a list comprehension for a given match.

        Args:
            list_comp: The ListComprehension object
            match: The current match being processed

        Returns:
            The resulting list from the comprehension
        """
        # Get the source list
        source_list = self._resolve_list_comp_source(list_comp.source, match)
        if source_list is None:
            return []

        # Ensure it's iterable
        if not isinstance(source_list, (list, tuple)):
            source_list = [source_list]

        result = []
        for item in source_list:
            # Apply filter condition if present
            if list_comp.filter_condition:
                if not self._evaluate_list_comp_condition(item, list_comp.filter_condition):
                    continue

            # Apply transformation if present
            if list_comp.transform:
                transformed = self._apply_list_comp_transform(item, list_comp.transform)
                result.append(transformed)
            else:
                result.append(item)

        return result

    def _resolve_list_comp_source(self, source: Any, match: Match) -> Any:
        """Resolve the source of a list comprehension."""
        if isinstance(source, list):
            return source

        if isinstance(source, str):
            # Check if it's an entity.attribute reference
            if "." in source:
                entity_name, attr = source.rsplit(".", 1)
                host_node = match.node_mappings.get(entity_name)
                if host_node is not None:
                    return self._target_graph.nodes[host_node].get(attr)
                return None
            # Check if it's an entity reference
            elif source in match.node_mappings:
                host_node = match.node_mappings[source]
                if host_node is not None:
                    return self._target_graph.nodes[host_node]
                return None

        return source

    def _evaluate_list_comp_condition(self, item: Any, condition: Dict) -> bool:
        """Evaluate a filter condition for a list comprehension item."""
        op = condition.get("op")
        value = condition.get("value")

        # Parse value if it's a string number
        if isinstance(value, str):
            try:
                value = float(value) if "." in value else int(value)
            except ValueError:
                # Strip quotes from string literals
                if (value.startswith('"') and value.endswith('"')) or \
                   (value.startswith("'") and value.endswith("'")):
                    value = value[1:-1]

        # Apply comparison
        if op == ">" or op == "comp_gt":
            return item > value
        elif op == "<" or op == "comp_lt":
            return item < value
        elif op == ">=" or op == "comp_gte":
            return item >= value
        elif op == "<=" or op == "comp_lte":
            return item <= value
        elif op == "=" or op == "==" or op == "comp_eq":
            return item == value
        elif op == "<>" or op == "comp_neq":
            return item != value

        return True

    def _apply_list_comp_transform(self, item: Any, transform: Dict) -> Any:
        """Apply a transformation to a list comprehension item."""
        op = transform.get("op")
        value = transform.get("value")

        # Parse value if it's a string number
        if isinstance(value, str):
            try:
                value = float(value) if "." in value else int(value)
            except ValueError:
                pass

        # Apply arithmetic operation
        if op == "+" or op == "arith_add":
            return item + value
        elif op == "-" or op == "arith_sub":
            return item - value
        elif op == "*" or op == "arith_mul":
            return item * value
        elif op == "/" or op == "arith_div":
            if isinstance(item, int) and isinstance(value, int):
                if value == 0:
                    return None  # Integer division by zero returns null
                else:
                    # Cypher integer division truncates toward zero
                    return int(item / value)
            else:
                # Float division with zero handling
                if value == 0 or value == 0.0:
                    if item == 0 or item == 0.0:
                        return float('nan')  # 0.0 / 0.0 = NaN
                    else:
                        # x / 0.0 = +/-Infinity
                        return float('inf') if item > 0 else float('-inf')
                else:
                    return item / value

        return item

    def _get_true_matches(self) -> tuple[Match]:
        """Get the true matches after applying WHERE conditions and hints.
        Returns the matches along with their paths.

        Returns:
            List of tuples containing (match, path)
        """
        if not self._matches:
            self_matches = []
            complete = False
            # Track seen edge sets to deduplicate self-loop matches
            # For undirected self-loop patterns, both direction variants match the same edge
            seen_edge_sets = set()

            # Skip main matching loop if all nodes are optional (standalone OPTIONAL MATCH)
            # The OPTIONAL MATCH handling block will handle this case
            has_required_nodes = any(n not in self._optional_nodes for n in self._motif.nodes())

            for my_motif, edge_hop_map, alias, selfloop_alias in self._edge_hop_motifs(self._motif) if has_required_nodes else []:
                # Iteration is complete
                if complete:
                    break

                # alias is provided - no need to rebuild UnionFind
                zero_hop_nodes = set(
                    chain.from_iterable(hs.edge_id for hs in edge_hop_map.values() if hs.hop_count == 0))

                matches = self._matches_iter(my_motif)

                # Collect all valid matches before applying pagination
                for match in matches:
                    match = Match(
                        node_mappings=match,
                        where_results=None,
                        edge_mapping=None
                    )
                    # Handle zero hop nodes
                    # In zero edge hop edges, we check if the node are actually the collapsed node
                    valid_match = True
                    for u1 in zero_hop_nodes:
                        # take out the collapsed to node
                        u2 = alias[u1]
                        if not _is_node_attr_match(
                            u1, match.mth.node(u2), self._motif, self._target_graph
                        ):
                            valid_match = False
                            break
                        # the match might not contain the mapping for the un-collapsed node, let's put it in
                        match.node_mappings[u1] = match.node_mappings[u2]

                    if not valid_match:
                        continue
                    multi_edge_keys = find_multiedge_keys(self._target_graph, match.node_mappings, edge_hop_map)
                    for edge_key_mapping in generate_multiedge_edge_hop_key(edge_hop_map, multi_edge_keys):
                        edge_mapping = EdgeMapping(
                            edge_hop_map=edge_hop_map,
                            edge_key_map={e.edge_id: e for e in edge_key_mapping},
                            node_alias=selfloop_alias if selfloop_alias else None
                        )
                        edges = chain.from_iterable(edge_path.edges for edge_path in edge_mapping.edge_paths)

                        # DOUBLE CHECK EDGE
                        # =================================================
                        # CASE1 multigraph
                        # Since the match_iter isomorphism doesn't return the proper edge key for multigraph
                        # we need to double check them here to make sure matches correct
                        # For example, consider this test case
                        # host = nx.MultiDiGraph()
                        # host.add_node("a", name="Alice", age=30)
                        # host.add_node("b", name="Bob", age=40)
                        # host.add_node("c", name="Charlie", age=50)
                        # host.add_edge("a", "b", __labels__={"friend"}, years=3)
                        # host.add_edge("a", "c", __labels__={"colleague"}, years=10)
                        # host.add_edge("b", "c", __labels__={"colleague"}, duration=10)
                        # host.add_edge("b", "c", __labels__={"mentor"}, years=2)
                        # qry = """
                        # MATCH (a)-[r:colleague]->(b)
                        # RETURN a.name, b.name, r.duration
                        # """
                        # there are two edge between b and c, with label colleague and mentor
                        # the mentor should be rejected here
                        # ================================================
                        # CASE 2: equijoin
                        # The isomorphism doesn't check when we explicitly do the equijoin
                        # G = nx.DiGraph()
                        # G.add_node("x")
                        # G.add_node("y")
                        # G.add_node("z")
                        # G.add_edge("x", "y")
                        # G.add_edge("y", "x")
                        # G.add_edge("x", "x")
                        # G.add_edge("z", "x")
                        # qry = """
                        # MATCH (n)-->(n)
                        # RETURN ID(n)
                        # """
                        # Grandiso return both x and y, but only x is correct, y is not
                        valid_match = True
                        for edge in edges:
                            # SKIP if hop = 0, there is no edge
                            if edge.h == 0:
                                continue
                            motif_u, motif_v = edge.u, edge.v
                            motif_u, motif_v = alias.get(motif_u, motif_u), alias.get(motif_v, motif_v)
                            host_u, host_v = match.mth.node(motif_u), match.mth.node(motif_v)
                            # skip if there is 1 edge between 2 nodes and they are different (not equijoin)
                            if edge.u != edge.v and self._target_graph.number_of_edges(host_u, host_v) < 2:
                                continue
                            host_keys = (edge.k,) if edge.k is not None else tuple()
                            if not _is_edge_attr_match(motif_edge_id=(motif_u, motif_v),
                                                        host_edge_id=(host_u, host_v),
                                                        motif=my_motif,
                                                        host=self._target_graph,
                                                        host_keys=host_keys):
                                valid_match = False
                                break
                        if not valid_match:
                            continue

                        match = Match(
                            node_mappings=match.node_mappings,
                            where_results=None,
                            edge_mapping=edge_mapping
                        )

                        # Apply WHERE condition if present
                        if self._where_expression is not None:
                            # Include parent executor's node mappings for WHERE evaluation
                            # This is needed when WHERE references nodes from a previous MATCH clause
                            eval_match = match
                            if self._parent_executor is not None:
                                parent_matches = list(self._parent_executor._get_true_matches())
                                if parent_matches:
                                    # Combine current match with first parent match
                                    # For proper cross-product, this should iterate, but for now
                                    # we use the first match (which works for single-row parent results)
                                    combined_mappings = dict(parent_matches[0].node_mappings)
                                    combined_mappings.update(match.node_mappings)
                                    eval_match = Match(
                                        node_mappings=combined_mappings,
                                        where_results=None,
                                        edge_mapping=edge_mapping
                                    )
                            # Include WITH results as row context if available
                            # This allows WHERE to reference variables from WITH clause
                            row_ctx = None
                            if self._with_results:
                                num_rows = max((len(v) for v in self._with_results.values()), default=0)
                                if num_rows > 0:
                                    row_ctx = {k: v[0] if v else None for k, v in self._with_results.items()}
                            ctx = EvalContext(match=eval_match, row=row_ctx, graph=self._target_graph, return_edges=self._return_edges)
                            result = self._eval_expression(self._where_expression, ctx)
                            if not result:
                                continue
                            where_results = []
                        elif self._where_condition:
                            satisfies_where, where_results = self._where_condition(
                                match, self._target_graph, self._return_edges
                            )
                            if not satisfies_where:
                                continue
                        else:
                            where_results = []
                        match.where_results = where_results

                        # Deduplicate self-loop matches for undirected patterns
                        # Both direction variants of a bidirectional self-loop match the same edge
                        # Include node mappings in signature to ensure different node matches are kept
                        edge_signature = []
                        for edge_path in edge_mapping.edge_paths:
                            for edge in edge_path.edges:
                                if edge.h > 0:  # Skip zero-hop edges
                                    host_u = match.node_mappings.get(alias.get(edge.u, edge.u), edge.u)
                                    host_v = match.node_mappings.get(alias.get(edge.v, edge.v), edge.v)
                                    # For self-loops, normalize the edge representation
                                    edge_key = (min(host_u, host_v), max(host_u, host_v), edge.k)
                                    edge_signature.append(edge_key)
                        # For node-only patterns, include node mappings in signature
                        if not edge_signature:
                            node_sig = tuple(sorted(match.node_mappings.items()))
                            edge_signature = [node_sig]
                        edge_signature = tuple(sorted(edge_signature))
                        if edge_signature in seen_edge_sets:
                            continue  # Skip duplicate match
                        seen_edge_sets.add(edge_signature)

                        self_matches.append(match)

                        # Check if limit reached; stop ONLY IF we are not ordering
                        if self._is_limit(len(self_matches)) and not self._order_by:
                            complete = True
                            break

                    if complete:
                        break

            # OPTIONAL MATCH handling with left outer join semantics:
            # Process each OPTIONAL MATCH pattern independently (Neo4j behavior)
            if self._optional_patterns:
                # Get required-only matches first
                required_motif = self._get_required_motif()

                if required_motif.number_of_nodes() == 0:
                    # No required nodes - standalone OPTIONAL MATCH
                    # Match the optional patterns directly as if they were required
                    for my_motif, edge_hop_map, alias, selfloop_alias in self._edge_hop_motifs(self._motif):
                        for match in self._matches_iter(my_motif):
                            node_mappings = dict(match)
                            edge_mapping = EdgeMapping(
                                edge_hop_map=edge_hop_map,
                                edge_key_map={},
                                node_alias=selfloop_alias if selfloop_alias else None
                            )
                            final_match = Match(
                                node_mappings=node_mappings,
                                where_results=[],
                                edge_mapping=edge_mapping
                            )

                            # Apply WHERE condition if present
                            if self._where_expression is not None:
                                ctx = EvalContext(match=final_match, row=None, graph=self._target_graph, return_edges=self._return_edges)
                                result = self._eval_expression(self._where_expression, ctx)
                                if not result:
                                    continue
                            elif self._where_condition:
                                satisfies_where, where_results = self._where_condition(
                                    final_match, self._target_graph, self._return_edges
                                )
                                if not satisfies_where:
                                    continue
                                final_match.where_results = where_results

                            self_matches.append(final_match)

                            if self._is_limit(len(self_matches)) and not self._order_by:
                                break

                elif required_motif.number_of_nodes() > 0:
                    # Get base matches from required motif only
                    base_matches = []
                    for my_motif, edge_hop_map, alias, selfloop_alias in self._edge_hop_motifs(required_motif):
                        for match in self._matches_iter(my_motif):
                            node_mappings = dict(match)
                            base_matches.append(node_mappings)

                    # Now process each required match through all optional patterns
                    self_matches = []
                    for base_match in base_matches:
                        current_match = dict(base_match)

                        # Try each optional pattern independently
                        for opt_pattern in self._optional_patterns:
                            opt_nodes = opt_pattern.get("nodes", set())
                            opt_edges = opt_pattern.get("edges", [])

                            if not opt_edges:
                                # Node-only optional
                                for node in opt_nodes:
                                    current_match[node] = None
                                continue

                            # Try to extend match with this optional pattern
                            # Process edges in chain: each found node becomes anchor for next
                            pattern_extended = True
                            for edge_spec in opt_edges:
                                edge_u = edge_spec["u"]  # Source node as written in query
                                edge_v = edge_spec["v"]  # Target node as written in query
                                edges_tuple = edge_spec["edges"]  # Actual edge direction(s)
                                labels = edge_spec.get("labels")

                                # Determine direction from edges tuple
                                # For right (-->): edges = ((u, v),) - u is source in graph
                                # For left (<--): edges = ((v, u),) - v is source in graph
                                # For undirected: both directions
                                anchor_host_node = current_match.get(edge_u)
                                if anchor_host_node is None:
                                    pattern_extended = False
                                    break

                                # Determine if this is an outgoing (right) or incoming (left) edge
                                is_outgoing = edges_tuple[0][0] == edge_u  # First edge starts from u

                                # Get neighbors based on direction
                                if is_outgoing:
                                    if hasattr(self._target_graph, "successors"):
                                        neighbors = list(self._target_graph.successors(anchor_host_node))
                                    else:
                                        neighbors = list(self._target_graph.neighbors(anchor_host_node))
                                    edge_source = anchor_host_node
                                else:
                                    # Incoming edge - use predecessors
                                    if hasattr(self._target_graph, "predecessors"):
                                        neighbors = list(self._target_graph.predecessors(anchor_host_node))
                                    else:
                                        neighbors = list(self._target_graph.neighbors(anchor_host_node))
                                    edge_source = None  # Will be the neighbor

                                found_neighbor = None
                                for neighbor in neighbors:
                                    # Determine edge lookup direction
                                    if is_outgoing:
                                        edge_data = self._target_graph.get_edge_data(anchor_host_node, neighbor)
                                    else:
                                        edge_data = self._target_graph.get_edge_data(neighbor, anchor_host_node)

                                    # Check edge labels if specified
                                    if labels:
                                        if edge_data is None:
                                            continue
                                        # Handle MultiDiGraph - may have multiple edges
                                        if isinstance(edge_data, dict) and not any(k.startswith('__') for k in edge_data.keys() if isinstance(k, str)):
                                            # MultiDiGraph returns {0: {...}, 1: {...}}
                                            edge_matches = False
                                            for key, edata in edge_data.items():
                                                edge_labels = edata.get("__labels__", set())
                                                if labels.issubset(edge_labels):
                                                    edge_matches = True
                                                    break
                                            if not edge_matches:
                                                continue
                                        else:
                                            edge_labels = edge_data.get("__labels__", set())
                                            if not labels.issubset(edge_labels):
                                                continue

                                    # Check target node labels/properties if specified
                                    target_labels = edge_spec.get("vt")
                                    target_props = edge_spec.get("vjs", {})
                                    node_data = self._target_graph.nodes.get(neighbor, {})

                                    if target_labels:
                                        node_labels = node_data.get("__labels__", set())
                                        target_label_set = set(target_labels) if not isinstance(target_labels, set) else target_labels
                                        if not target_label_set.issubset(node_labels):
                                            continue

                                    if target_props:
                                        props_match = all(
                                            node_data.get(k) == v
                                            for k, v in target_props.items()
                                        )
                                        if not props_match:
                                            continue

                                    # Found a match for this edge
                                    found_neighbor = neighbor
                                    break

                                if found_neighbor is not None:
                                    current_match[edge_v] = found_neighbor
                                else:
                                    pattern_extended = False
                                    break

                            # If pattern not fully extended, set truly optional nodes to None
                            # Only set nodes that are not already matched (anchor nodes)
                            if not pattern_extended:
                                for node in opt_nodes:
                                    if node not in current_match or current_match.get(node) is None:
                                        current_match[node] = None
                            else:
                                # Pattern was extended - now check per-pattern WHERE if present
                                opt_where = opt_pattern.get("where")
                                if opt_where is not None:
                                    # Create a temporary match for WHERE evaluation
                                    temp_match = Match(
                                        node_mappings=current_match,
                                        where_results=[],
                                        edge_mapping=None
                                    )
                                    ctx = EvalContext(
                                        match=temp_match,
                                        row=None,
                                        graph=self._target_graph,
                                        return_edges=self._return_edges
                                    )
                                    where_result = self._eval_expression(opt_where, ctx)
                                    if not where_result:
                                        # WHERE doesn't match - set optional nodes to NULL
                                        for node in opt_nodes:
                                            if node not in base_match:  # Don't nullify anchor nodes
                                                current_match[node] = None

                        # Create final match
                        final_match = Match(
                            node_mappings=current_match,
                            where_results=[True],
                            edge_mapping=None
                        )

                        # Apply WHERE condition if present
                        if self._where_expression is not None:
                            # Include WITH results as row context if available
                            row_ctx = None
                            if self._with_results:
                                num_rows = max((len(v) for v in self._with_results.values()), default=0)
                                if num_rows > 0:
                                    row_ctx = {k: v[0] if v else None for k, v in self._with_results.items()}
                            ctx = EvalContext(match=final_match, row=row_ctx, graph=self._target_graph, return_edges=self._return_edges)
                            result = self._eval_expression(self._where_expression, ctx)
                            if not result:
                                continue
                        elif self._where_condition:
                            satisfies_where, where_results = self._where_condition(
                                final_match, self._target_graph, self._return_edges
                            )
                            if not satisfies_where:
                                continue
                            final_match.where_results = where_results

                        self_matches.append(final_match)

                        if self._is_limit(len(self_matches)) and not self._order_by:
                            break

            # Fallback for old-style optional handling (no _optional_patterns)
            elif self._optional_edges:
                required_motif = self._get_required_motif()

                if required_motif.number_of_nodes() > 0:
                    matched_required_nodes = set()
                    for match in self_matches:
                        required_key = tuple(
                            match.node_mappings.get(n)
                            for n in required_motif.nodes()
                        )
                        matched_required_nodes.add(required_key)

                    for my_motif, edge_hop_map, alias, selfloop_alias in self._edge_hop_motifs(required_motif):
                        if complete:
                            break

                        matches = self._matches_iter(my_motif)

                        for match in matches:
                            required_key = tuple(
                                match.get(n) for n in required_motif.nodes()
                            )
                            if required_key in matched_required_nodes:
                                continue

                            node_mappings = dict(match)
                            for optional_node in self._optional_nodes:
                                node_mappings[optional_node] = None

                            final_match = Match(
                                node_mappings=node_mappings,
                                where_results=[True],
                                edge_mapping=None
                            )

                            if self._where_expression is not None:
                                ctx = EvalContext(match=final_match, graph=self._target_graph, return_edges=self._return_edges)
                                result = self._eval_expression(self._where_expression, ctx)
                                if not result:
                                    continue
                            elif self._where_condition:
                                satisfies_where, where_results = self._where_condition(
                                    final_match, self._target_graph, self._return_edges
                                )
                                if not satisfies_where:
                                    continue
                                final_match.where_results = where_results

                            self_matches.append(final_match)

                            if self._is_limit(len(self_matches)) and not self._order_by:
                                complete = True
                                break

                        if complete:
                            break

            # Legacy: if entire executor is optional and still no matches
            if not self_matches and self._is_optional:
                null_mappings = {n: None for n in self._motif.nodes()}
                null_match = Match(
                    node_mappings=null_mappings,
                    where_results=[True],
                    edge_mapping=None
                )
                self_matches.append(null_match)

            self._matches = tuple(self_matches)

        return self._matches

    def _matches_iter(self, motif):
        hinter = Hinter(_is_node_attr_match, _is_edge_attr_match)
        if self._hints:
            hints = self._hints
        elif self._auto_where_hints or self._auto_node_jsondata_hints:
            indexer = self._node_indexer
            condition_asts = []
            if self._auto_node_jsondata_hints and hasattr(self._target_graph, "pred"):
                condition_asts.append(motif_to_indexer_ast(self._motif))
            if self._auto_where_hints:
                condition_asts.append(to_indexer_ast(self._where_condition))

            ast = condition_asts[0]
            for c_ast in condition_asts:
                if c_ast is None:
                    continue
                if ast is None:
                    ast = c_ast
                else:
                    ast = IndexerAnd(ast, c_ast)

            entity_domain = IndexerConditionRunner(indexer=indexer).find(ast)
            hints = hinter.index_domain_to_hints(entity_domain)
        else:
            hints = []

        # Get list of all match iterators
        iterators = []
        for c in nx.weakly_connected_components(motif):

            c_motif = motif.subgraph(c)
            # NOTE: making sure only giving hints to "relevant" nodes.
            c_hints = hinter.take_hints_with_keys(hints, c)
            if self._auto_where_hints:
                c_hints = hinter.eliminate_supersets(c_hints)
            iso_finder = isomorphism.find_motifs_iter(
                c_motif,
                self._target_graph,
                # is_node_structural_match=_is_node_structural_match,
                is_node_attr_match=_is_node_attr_match,
                is_edge_attr_match=_is_edge_attr_match,
                # Giving wrong hint will cause error
                hints=c_hints ,
            )
            iterators.append((iso_finder, c_motif, c_hints))
        # Single match clause iterator
        if iterators and len(iterators) == 1:
            iso_finder, c_motif, c_hints = iterators[0]
            for match in iso_finder:
                # as hints are not checked against node and edge match in isomorphism
                # let's do double check here
                if c_hints and not hinter.doublecheck(
                    host=self._target_graph,
                    motif=c_motif,
                    match=match,
                    hints=c_hints):
                    continue
                yield match
        else:
            iterations, matches = 0, {}
            for x, iterator in enumerate(iterators):
                iso_finder, c_motif, c_hints = iterator
                for match in iso_finder:
                    if self._doublecheck_hint_result and not hinter.doublecheck(
                        host=self._target_graph,
                        motif=c_motif,
                        match=match,
                        hints=c_hints):
                        continue
                    if x not in matches:
                        matches[x] = []
                    matches[x].append(match)
                    iterations += 1
                    if self._is_limit(len(matches[x])):
                        break

            join = []
            for match in matches.values():
                if join:
                    join = [{**a, **b} for a in join for b in match]
                else:
                    join = match
            yield from join

    def _generate_direction_variants(self, motif: nx.MultiDiGraph) -> Generator[nx.MultiDiGraph, None, None]:
        """Generate all direction variants for bidirectional edges.

        For each bidirectional edge, we need to try both directions.
        This generates all combinations of directions for bidirectional edges.

        Arguments:
            motif: The motif graph with possibly bidirectional edges

        Yields:
            Variants of the motif with bidirectional edges resolved to one direction
        """
        # Collect bidirectional edges
        bidirectional_edges = []
        for u, v, attrs in motif.edges(data=True):
            if attrs.get("__bidirectional__", False):
                bidirectional_edges.append((u, v, attrs))

        if not bidirectional_edges:
            # No bidirectional edges, yield the original motif
            yield motif
            return

        # For self-loops (u == v), both directions are identical, so only generate one
        # Build direction options: [False, True] for normal edges, [False] for self-loops
        direction_options = []
        for u, v, _ in bidirectional_edges:
            if u == v:
                # Self-loop: only one direction needed
                direction_options.append([False])
            else:
                # Normal edge: both directions
                direction_options.append([False, True])

        # Generate all direction combinations for bidirectional edges
        # Each bidirectional edge can be either forward (u->v) or backward (v->u)
        for direction_choices in product(*direction_options):
            # Create a new motif with resolved directions
            variant = nx.MultiDiGraph()

            # Copy all nodes
            for node, attrs in motif.nodes(data=True):
                variant.add_node(node, **attrs)

            # Copy edges with bidirectional edges resolved
            bidi_idx = 0
            for u, v, attrs in motif.edges(data=True):
                if attrs.get("__bidirectional__", False):
                    # This is a bidirectional edge
                    reverse = direction_choices[bidi_idx]
                    bidi_idx += 1
                    new_attrs = {k: val for k, val in attrs.items() if k != "__bidirectional__"}
                    if reverse:
                        variant.add_edge(v, u, **new_attrs)
                    else:
                        variant.add_edge(u, v, **new_attrs)
                else:
                    # Non-bidirectional edge, copy as-is
                    variant.add_edge(u, v, **attrs)

            yield variant

    def _generate_selfloop_variants(self, motif: nx.MultiDiGraph) -> Generator[Tuple[nx.MultiDiGraph, dict], None, None]:
        """Generate self-loop variants for edges between anonymous nodes.

        When a pattern like ()-[r]->() is used, the parser creates two different
        anonymous nodes. However, this pattern should also match self-loops where
        the same node has an edge to itself.

        For edges between anonymous nodes, this generates an additional variant
        where both anonymous nodes are unified into a single node with a self-loop.

        Arguments:
            motif: The motif graph with possibly anonymous nodes

        Yields:
            Tuple of (motif variant, selfloop_alias dict)
            - For original motif, alias is empty
            - For self-loop variants, alias maps unified node to the kept node
        """
        # Always yield the original motif first with empty alias
        yield motif, {}

        # Identify edges between anonymous nodes (both ends are anonymous)
        anonymous_edges = []
        for u, v, attrs in motif.edges(data=True):
            u_is_anon = str(u).startswith("_anon_")
            v_is_anon = str(v).startswith("_anon_")
            # Only create self-loop variant if BOTH endpoints are anonymous
            # and they're different nodes (not already a self-loop)
            if u_is_anon and v_is_anon and u != v:
                anonymous_edges.append((u, v, attrs))

        if not anonymous_edges:
            return

        # For each anonymous edge, generate a self-loop variant
        for anon_u, anon_v, edge_attrs in anonymous_edges:
            # Create a variant where anon_v is unified with anon_u (self-loop)
            variant = nx.MultiDiGraph()

            # Track the alias: anon_v -> anon_u (the unified node)
            selfloop_alias = {anon_v: anon_u}

            # Copy nodes, but skip anon_v (it will be merged with anon_u)
            for node, node_attrs in motif.nodes(data=True):
                if node != anon_v:
                    variant.add_node(node, **node_attrs)

            # Copy edges, redirecting anon_v to anon_u
            for u, v, attrs in motif.edges(data=True):
                new_u = anon_u if u == anon_v else u
                new_v = anon_u if v == anon_v else v
                variant.add_edge(new_u, new_v, **attrs)

            yield variant, selfloop_alias

    def _edge_hop_motifs(self, motif: nx.MultiDiGraph) -> Generator[Tuple[nx.Graph, HopAssignment, dict, dict], None, None]:
        """Generate edge-hop-expanded motifs with node unification.

        Arguments:
            motif (nx.Graph): The motif graph

        Yields:
            Tuple[nx.Graph, HopAssignment, dict, dict]:
                - my_motif: Unified and materialized motif
                - edge_hop_map: Original hop assignment (unchanged)
                - alias: Combined alias mapping for node unification
                - selfloop_alias: Selfloop alias mapping for edge lookup only
        """
        # First, expand bidirectional edges into direction variants
        for direction_variant in self._generate_direction_variants(motif):
            # Then, generate self-loop variants for anonymous node edges
            for selfloop_variant, selfloop_alias in self._generate_selfloop_variants(direction_variant):
                hop_specs = generate_edge_hop_specs(selfloop_variant)
                hop_assignments = list(generate_hop_assignments(hop_specs))

                for hop_assignment in hop_assignments:
                    # Step 1: Materialize (without unification)
                    materialized_motif = materialize_motif(hop_assignment, selfloop_variant)

                    # Step 2: Unify zero-hop nodes and get alias
                    my_motif, zerohop_alias = unify_zero_hop_nodes(materialized_motif, hop_assignment.values())

                    # Step 3: Combine selfloop_alias with zerohop_alias for node unification
                    # selfloop_alias maps removed anon nodes to kept ones (for edge lookup)
                    # zerohop_alias maps zero-hop unified nodes (for node unification only)
                    combined_alias = {**selfloop_alias, **zerohop_alias}

                    # Step 4: Yield unified motif, hop_assignment, combined_alias, and selfloop_alias
                    # Note: selfloop_alias is used for edge lookup, combined_alias for node unification
                    yield my_motif, hop_assignment, combined_alias, selfloop_alias

    def _get_required_motif(self) -> nx.MultiDiGraph:
        """Create a motif containing only required (non-optional) edges and nodes.

        Returns:
            A new motif graph with only required patterns.
        """
        if not self._optional_edges and not self._optional_nodes:
            return self._motif

        required_motif = nx.MultiDiGraph()

        # Add all nodes that are not purely optional
        for node, attrs in self._motif.nodes(data=True):
            if node not in self._optional_nodes:
                required_motif.add_node(node, **attrs)

        # Add only non-optional edges
        for u, v, attrs in self._motif.edges(data=True):
            edge_tuple = (u, v)
            if edge_tuple not in self._optional_edges:
                required_motif.add_edge(u, v, **attrs)

        return required_motif

    def _is_limit(self, length):
        """Check if the current number of results has reached the limit.

        Args:
            length: The current number of results.

        Returns:
            True if we've reached the limit, False otherwise.
        """
        return self._limit is not None and length >= (self._limit + self._skip)


class NXCypherTransformer(Transformer):
    def __init__(self, target_graph: nx.Graph, limit: Optional[int] = None):
        self._limit = limit
        self._target_graph = target_graph
        self._executors = [NXCypherExecutor(target_graph, limit)]
        self._match_clause_count = 0
        self._mutation_executor = MutationExecutor(target_graph)
        # Track pending create patterns for edge resolution
        self._pending_create_nodes: List[Dict] = []
        self._pending_create_edges: List[Dict] = []
        # UNION support
        self._union_queries: List[NXCypherExecutor] = []  # Completed query executors
        self._union_types: List[str] = []  # 'all' or 'distinct' for each union

    def return_clause(self, clause):
        # collect all entity identifiers to be returned
        executor = self._executors[-1]

        for item in clause:
            if item is None:
                continue
            if isinstance(item, Tree) and item.data == "return_item":
                alias = self._extract_alias(item)
                # The first child is the expression tree
                expr = item.children[0]
                self._process_return_expression(executor, expr, alias)
            elif isinstance(item, Tree) and item.data == "distinct_return":
                continue  # handled by distinct_return method
            else:
                # Fallback for non-tree items
                pass

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

    def _process_return_expression(self, executor, expr, alias):
        """Process an expression from RETURN, extracting keys for backward compatibility."""
        # Unwrap expression wrapper trees to find the core
        core = self._unwrap_expression(expr)

        # Check if core is an aggregation function
        agg_info = self._detect_aggregation(core)
        if agg_info is not None:
            func, entity, is_distinct = agg_info
            key = executor._format_aggregation_key(func, entity, is_distinct)
            if alias:
                executor._entity2alias[key] = alias
            if entity != "*":
                executor._aggregation_attributes.add(entity)
            executor._aggregate_functions.append((func, entity, is_distinct))
            return

        # Check if core maps to a simple backward-compatible key
        simple_key = self._expr_to_simple_key(core)
        if simple_key is not None:
            key = simple_key
            # Check if this is a literal value
            literal_val = self._expr_literal_value(core)
            if literal_val is not None:
                executor._literal_returns[key] = literal_val
            # Check if this is a scalar function key already processed by old transformer
            if isinstance(core, str) and (core.startswith("CASE_") or core.startswith("LISTCOMP_")):
                pass  # Already registered by old transformer methods
            elif isinstance(core, str) and core in executor._func_calls:
                pass  # Multi-arg func already registered
            executor._original_return_requests.add(key)
            if alias:
                executor._entity2alias[key] = alias
            executor._return_requests.append(key)
            return

        # Complex expression — store tree and use expression text as key
        # This matches Neo4j's behavior of using the expression as the column name
        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 _unwrap_expression(self, node):
        """Unwrap single-child expression wrapper trees to find the core value.

        The expression hierarchy produces deep nesting for simple values:
        expression -> or_expression -> xor_expression -> ... -> expr_atom -> expr_variable -> CNAME

        This method follows single-child paths down to the actual value.
        """
        while isinstance(node, Tree):
            data = node.data
            children = node.children

            # Expression wrappers with exactly one child — unwrap
            if data in ("expression", "or_expression", "xor_expression", "and_expression",
                        "not_expression", "comparison_expression", "str_list_null_expression",
                        "add_sub_expression", "mul_div_mod_expression", "power_expression",
                        "unary_expression", "property_or_labels_expression",
                        "expr_atom", "expr_literal", "return_item"):
                if len(children) == 1:
                    node = children[0]
                    continue
                else:
                    # Multi-child: this is an actual expression (e.g., a + b)
                    return node

            # Terminal expression types — return the core value
            if data == "expr_variable":
                return str(children[0])

            if data == "not_expression":
                if len(children) == 1:
                    node = children[0]
                    continue
                return node

            # Everything else is a complex expression — return as-is
            return node

        # Non-tree value (int, float, str, bool, None, Token)
        if isinstance(node, Token):
            return node.value if hasattr(node, 'value') else str(node)
        return node

    def _detect_aggregation(self, core) -> Optional[Tuple]:
        """Detect if an expression core is an aggregation function call.

        Returns (func_name, entity, is_distinct) or None.
        """
        AGGREGATE_FUNCS = {"COUNT", "SUM", "AVG", "MAX", "MIN", "COLLECT", "PERCENTILEDISC", "PERCENTILECONT"}

        # Check for old-style aggregation trees (still created during migration)
        if isinstance(core, Tree) and core.data in ("aggregation_function", "aggregation_function_star", "aggregation_function_distinct"):
            return self._parse_aggregation_token(core)

        # Check for new expr_count_star
        if isinstance(core, Tree) and core.data == "expr_count_star":
            return ("COUNT", "*", False)

        # Check for new expr_function_call that is actually an aggregate
        if isinstance(core, Tree) and core.data == "expr_function_call":
            func_name = str(core.children[0]).upper()
            if func_name in AGGREGATE_FUNCS:
                # Parse the arguments
                has_distinct = False
                args = []
                for c in core.children[1:]:
                    if isinstance(c, Token) and (str(c).upper() == "DISTINCT" or c.type == "EXPR_DISTINCT"):
                        has_distinct = True
                        continue
                    args.append(c)
                if not args:
                    # COUNT() with no args — shouldn't happen, but treat as COUNT(*)
                    return (func_name, "*", has_distinct)
                # Get the entity reference from the first argument
                arg_core = self._unwrap_expression(args[0])
                if isinstance(arg_core, str):
                    entity = arg_core
                elif isinstance(arg_core, Token):
                    entity = str(arg_core)
                elif isinstance(arg_core, Tree) and arg_core.data == "property_or_labels_expression":
                    # e.g., n.name
                    entity = self._expr_to_simple_key(arg_core)
                    if entity is None:
                        entity = str(arg_core)
                else:
                    entity = str(arg_core)
                return (func_name, entity, has_distinct)

        # Check for string-based aggregation keys (from old transformer)
        if isinstance(core, str):
            upper = core.upper()
            for func in AGGREGATE_FUNCS:
                if upper.startswith(f"{func}(") and upper.endswith(")"):
                    return self._parse_aggregation_token_from_string(core)

        return None

    def _parse_aggregation_token_from_string(self, key: str):
        """Parse an aggregation function from a string key like 'COUNT(n.age)'."""
        upper = key.upper()
        paren = upper.index("(")
        func = upper[:paren]
        inner = key[paren + 1:-1].strip()
        is_distinct = False
        if inner.upper().startswith("DISTINCT "):
            is_distinct = True
            inner = inner[9:].strip()
        if inner == "*":
            return (func, "*", is_distinct)
        return (func, inner, is_distinct)

    def _expr_to_simple_key(self, core) -> Optional[str]:
        """Convert an expression core to a simple backward-compatible string key.

        Returns None if the expression is too complex for a simple key.
        """
        # String (variable name, function key, etc.)
        if isinstance(core, str):
            return core

        # Token
        if isinstance(core, Token):
            return str(core)

        # Python literals
        if isinstance(core, (int, float, bool)) or core is None:
            return str(core)

        # Tree types that map to simple keys
        if isinstance(core, Tree):
            # expr_variable -> bare variable name
            if core.data == "expr_variable":
                return str(core.children[0])

            # str_list_null_expression with postfix predicates (IS NULL, IS NOT NULL, etc.)
            if core.data == "str_list_null_expression" and len(core.children) >= 2:
                base_expr = core.children[0]
                base_key = self._expr_to_simple_key(self._unwrap_expression(base_expr))
                if base_key is not None:
                    # Check for null predicates
                    for child in core.children[1:]:
                        if isinstance(child, Tree):
                            if child.data == "pred_is_null":
                                return f"{base_key} IS NULL"
                            elif child.data == "pred_is_not_null":
                                return f"{base_key} IS NOT NULL"
                            elif child.data == "pred_starts_with":
                                # STARTS WITH requires evaluating the RHS expression
                                if len(child.children) > 0:
                                    rhs = self._expr_to_simple_key(self._unwrap_expression(child.children[0]))
                                    if rhs:
                                        return f"{base_key} STARTS WITH {rhs}"
                            elif child.data == "pred_ends_with":
                                if len(child.children) > 0:
                                    rhs = self._expr_to_simple_key(self._unwrap_expression(child.children[0]))
                                    if rhs:
                                        return f"{base_key} ENDS WITH {rhs}"
                            elif child.data == "pred_contains":
                                if len(child.children) > 0:
                                    rhs = self._expr_to_simple_key(self._unwrap_expression(child.children[0]))
                                    if rhs:
                                        return f"{base_key} CONTAINS {rhs}"
                            elif child.data == "pred_in":
                                if len(child.children) > 0:
                                    rhs = self._expr_to_simple_key(self._unwrap_expression(child.children[0]))
                                    if rhs:
                                        return f"{base_key} IN {rhs}"
                    # If we couldn't match any predicate pattern, fall through
                    return None

            # property_or_labels_expression with exactly atom + property_lookup
            if core.data == "property_or_labels_expression" and len(core.children) == 2:
                base = self._unwrap_expression(core.children[0])
                prop = core.children[1]
                if isinstance(prop, Tree) and prop.data == "property_lookup":
                    base_key = self._expr_to_simple_key(base)
                    if base_key:
                        return f"{base_key}.{str(prop.children[0])}"

            # Literal values
            if core.data == "expr_literal" and len(core.children) == 1:
                child = core.children[0]
                if isinstance(child, (int, float)):
                    return str(child)
                if isinstance(child, str):
                    return child  # ESTRING already unquoted
                return str(child)

            if core.data in ("expr_null",):
                return "None"
            if core.data in ("expr_true",):
                return "True"
            if core.data in ("expr_false",):
                return "False"

            # expr_function_call — generate function key like "TOLOWER(n.name)"
            if core.data == "expr_function_call":
                func_name = str(core.children[0]).upper()
                # Collect non-DISTINCT argument expressions
                args = []
                for c in core.children[1:]:
                    if isinstance(c, Token) and str(c).upper() == "DISTINCT":
                        continue
                    args.append(c)
                if len(args) == 1:
                    arg_key = self._expr_to_simple_key(self._unwrap_expression(args[0]))
                    if arg_key:
                        return f"{func_name}({arg_key})"
                elif len(args) > 1:
                    # Multi-arg function — register in _func_calls and return key
                    return self._register_multi_arg_func(func_name, args)
                else:
                    # Zero-arg function
                    return f"{func_name}()"

            # expr_count_star
            if core.data == "expr_count_star":
                return "COUNT(*)"

            # Old transformer results passed through (CASE_, LISTCOMP_, etc.)
            if core.data in ("searched_case", "simple_case"):
                # These are handled by old case transformer methods
                if core.children and isinstance(core.children[0], str) and core.children[0].startswith("CASE_"):
                    return core.children[0]

            # expr_paren — unwrap
            if core.data == "expr_paren":
                return self._expr_to_simple_key(self._unwrap_expression(core.children[0]))

            # expr_list_comprehension — use LISTCOMP_ prefix for backward compat
            if core.data == "expr_list_comprehension":
                lc_id = f"LISTCOMP_{shortuuid(8)}"
                self._executors[-1]._expression_trees[lc_id] = core
                return lc_id

            # expr_list_literal — return as list value
            if core.data == "expr_list_literal":
                return None  # Complex — use EXPR_ key

            # expr_map_literal
            if core.data == "expr_map_literal":
                return None  # Complex

            # expr_searched_case / expr_simple_case — use CASE_ prefix for backward compat
            if core.data in ("expr_searched_case", "expr_simple_case"):
                case_id = f"CASE_{shortuuid(8)}"
                self._executors[-1]._expression_trees[case_id] = core
                return case_id

        return None

    def _register_multi_arg_func(self, func_name: str, args: list) -> str:
        """Register a multi-argument function call and return its key."""
        executor = self._executors[-1]
        # Resolve arg keys
        resolved_args = []
        for arg in args:
            core = self._unwrap_expression(arg)
            if isinstance(core, str):
                resolved_args.append(core)
            elif isinstance(core, (int, float)):
                resolved_args.append(core)
            elif isinstance(core, Token):
                # Handle NULL_W token as Python None, not as string "null"
                if core.type == "NULL_W":
                    resolved_args.append(None)
                else:
                    resolved_args.append(str(core))
            elif core is None:
                resolved_args.append(None)
            else:
                resolved_args.append(str(core))

        func_id = f"{func_name}_{id(args)}"
        executor._func_calls[func_id] = (func_name, *resolved_args)
        return func_id

    def _expr_literal_value(self, core) -> Optional[Any]:
        """Extract literal Python value from an expression core, or None if not a literal."""
        # Integer bounds for 64-bit signed integer (Java Long)
        MAX_INT = 9223372036854775807   # 2^63 - 1
        MIN_INT = -9223372036854775808  # -2^63

        if isinstance(core, int):
            # Check overflow for integer values
            strict = getattr(self, '_strict', True)
            if strict and (core > MAX_INT or core < MIN_INT):
                raise IntegerOverflow(f"Integer literal {core} out of range")
            return core
        if isinstance(core, float):
            return core
        if isinstance(core, bool):
            return core
        if core is None:
            return None
        if isinstance(core, Tree):
            if core.data == "expr_literal":
                child = core.children[0] if core.children else None
                if isinstance(child, (int, float, str, bool)):
                    if isinstance(child, int):
                        strict = getattr(self, '_strict', True)
                        if strict and (child > MAX_INT or child < MIN_INT):
                            raise IntegerOverflow(f"Integer literal {child} out of range")
                    return child
                return None
            if core.data in ("expr_null",):
                return None
            if core.data in ("expr_true",):
                return True
            if core.data in ("expr_false",):
                return False
            # Handle num_lit directly for integer overflow validation
            if core.data == "num_lit":
                tok = core.children[0] if core.children else None
                if tok is None:
                    return 0
                s = str(tok)
                # Handle floats
                if "." in s or "e" in s.lower():
                    import math
                    result = float(s)
                    strict = getattr(self, '_strict', True)
                    if strict and math.isinf(result):
                        raise FloatingPointOverflow(f"Floating point literal {s} overflows")
                    return result
                # Handle integers
                val = int(s, 0)  # auto-detect base
                strict = getattr(self, '_strict', True)
                if strict and (val > MAX_INT or val < MIN_INT):
                    raise IntegerOverflow(f"Integer literal {s} out of range")
                return val
        # Check if string is a known literal placeholder
        if isinstance(core, str) and core in ("None", "True", "False"):
            return {"None": None, "True": True, "False": False}.get(core)
        return None  # Sentinel: not a literal

    def _parse_aggregation_token(self, item: Tree):
        """
        Parse the aggregation function token and return (func, entity, is_distinct).

            Tree('aggregation_function', [...])          -> ('SUM', 'r.value', False)
            Tree('aggregation_function_star', [...])     -> ('COUNT', '*', False)
            Tree('aggregation_function_distinct', [...]) -> ('COUNT', 'n.name', True)
        """
        if item.data == "aggregation_function_star":
            func = str(item.children[0].value).upper()
            return func, "*", False

        if item.data == "aggregation_function_distinct":
            func = str(item.children[0].value).upper()
            entity = str(item.children[1].value)
            if len(item.children) > 2:
                entity += "." + str(item.children[2].children[0].value)
            return func, entity, True

        # Standard aggregation_function
        func = str(item.children[0].value).upper()  # AGGREGATE_FUNC
        entity = str(item.children[1].value)
        if len(item.children) > 2:
            entity += "." + str(item.children[2].children[0].value)
        return func, entity, False

    def _extract_alias(self, item: Tree):
        """
        Extract the alias from the return item (if it exists)
        """

        if len(item.children) == 1:
            return None
        item_keys = [it.data if isinstance(it, Tree) else None for it in item.children]
        if any(k == "alias" for k in item_keys):
            # get the index of the alias
            alias_index = item_keys.index("alias")
            return str(item.children[alias_index].children[0].value)

        return None

    def set_hints(self, hints=None):
        # self._hints = hints
        self._executors[-1].set_hints(hints)
        return self

    def transform(self, tree, hints=None):
        self.set_hints(hints)
        return super().transform(tree)

    def order_clause(self, order_clause):
        self._executors[-1]._order_by = []
        for item in order_clause[0].children:
            expr = item.children[0]
            # Try to parse as aggregation (handles both aggregation_function and expr_function_call)
            agg_result = None
            if isinstance(expr, Tree):
                agg_result = self._parse_aggregation_token(expr)

            if agg_result is not None:
                # It's an aggregation function - don't add to _order_by_attributes
                # because _aggregation_attributes already tracks the entity
                func, entity, is_distinct = agg_result
                field = self._executors[-1]._format_aggregation_key(func, entity, is_distinct)
            else:
                # Extract field name from expression tree
                # For simple variable references like 'average', extract the CNAME token
                field = self._extract_order_by_field(expr)
                self._executors[-1]._order_by_attributes.add(field)

            # Default to 'ASC' if not specified
            if len(item.children) > 1 and str(item.children[1].data).lower() != "desc":
                direction = "ASC"
            else:
                direction = "DESC"

            self._executors[-1]._order_by.append((field, direction))  # [('n.age', 'DESC'), ...]

    def _extract_order_by_field(self, node):
        """Extract field name from an ORDER BY expression tree.

        Handles:
        - Simple variable: 'average' -> 'average'
        - Property access: 'n.name' -> 'n.name'
        - Aggregation aliases
        """
        if isinstance(node, Token):
            return str(node)

        if isinstance(node, Tree):
            # For entity_id like 'n.name', reconstruct it
            if node.data == "entity_id":
                parts = []
                for child in node.children:
                    if isinstance(child, Token):
                        parts.append(str(child))
                return ".".join(parts)

            # For simple expression trees, find the innermost token
            # Common structure: expression -> or_expression -> ... -> expr_variable -> CNAME
            tokens = list(node.find_data("expr_variable"))
            if tokens:
                for child in tokens[0].children:
                    if isinstance(child, Token):
                        return str(child)

            # Fallback: find any CNAME or ID token
            for child in node.iter_subtrees():
                for c in child.children:
                    if isinstance(c, Token) and c.type in ("CNAME", "ID"):
                        return str(c)

        # Last resort fallback
        return str(node)

    def distinct_return(self, distinct):
        self._executors[-1]._distinct = True

    def limit_clause(self, limit):
        limit_val = int(limit[-1])
        # Validate LIMIT is non-negative
        executor = self._executors[-1]
        executor._validate_integer(limit_val, "LIMIT", allow_negative=False)
        executor._limit = limit_val

    def skip_clause(self, skip):
        skip_val = int(skip[-1])
        # Validate SKIP is non-negative
        executor = self._executors[-1]
        executor._validate_integer(skip_val, "SKIP", allow_negative=False)
        executor._skip = skip_val

    def with_clause(self, clause):
        """Handle WITH clause - pipeline intermediate results.

        WITH acts as a "checkpoint" in the query, computing intermediate results
        and making them available to subsequent clauses.
        """
        executor = self._executors[-1]

        # Save and reset executor state
        original_return_requests = list(executor._return_requests)
        original_aggregate_functions = list(executor._aggregate_functions)
        original_aggregation_attributes = set(executor._aggregation_attributes)

        executor._return_requests = []
        executor._aggregate_functions = []
        executor._aggregation_attributes = set()
        distinct = executor._distinct
        with_aliases = {}
        where_expr_tree = None

        for item in clause:
            if item is None:
                continue
            if isinstance(item, Tree):
                if item.data == "distinct_return":
                    distinct = True
                elif item.data == "with_item":
                    alias = self._extract_alias(item)
                    expr = item.children[0]
                    self._process_return_expression(executor, expr, alias)
                    # Track alias for later renaming
                    if alias:
                        # Find the key that was just added
                        if executor._return_requests:
                            last_key = executor._return_requests[-1]
                            with_aliases[last_key] = alias
                        elif 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[last_key] = alias
                elif item.data == "with_where_clause":
                    # Store the expression tree for WITH WHERE
                    where_expr_tree = item.children[0]
            elif isinstance(item, Token):
                executor._return_requests.append(str(item.value))
            elif isinstance(item, str):
                executor._return_requests.append(item)

        executor._distinct = distinct

        # Get intermediate results
        intermediate_results = executor.returns(ignore_limit=True)

        # Apply WHERE filter using expression evaluation
        if where_expr_tree is not None:
            intermediate_results = self._apply_with_where_expr(
                intermediate_results, where_expr_tree, with_aliases, executor
            )

        # Apply aliases to result keys
        aliased_results = {}
        for key, values in intermediate_results.items():
            new_key = with_aliases.get(key, key)
            # Also check entity2alias from _process_return_expression
            if new_key == key:
                new_key = executor._entity2alias.get(key, key)
            aliased_results[new_key] = values
            if key != new_key:
                executor._with_variable_mappings[new_key] = key

        # Store intermediate results
        executor._with_results = aliased_results

        # Reset executor for subsequent clauses
        executor._return_requests = original_return_requests
        executor._aggregate_functions = original_aggregate_functions
        executor._aggregation_attributes = original_aggregation_attributes
        executor._distinct = False
        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
        executor._motif = nx.MultiDiGraph()
        executor._paths = []  # Also clear path variables as they're now in _with_results
        executor._return_edges = {}  # Clear edge mappings

        return self

    def unwind_clause(self, clause):
        """Handle UNWIND clause - expand a list into rows.

        UNWIND [1, 2, 3] AS x -> creates rows with x=1, x=2, x=3
        UNWIND someList AS item -> expands each element of someList into a row
        """
        executor = self._executors[-1]

        # Parse UNWIND source and variable name
        # Grammar: unwind_clause : "unwind"i (entity_id | value_list) "AS"i CNAME
        # So clause is: [source, var_name]
        source = None
        var_name = None
        tokens_seen = []

        for item in clause:
            if isinstance(item, list):
                # Literal list like [1, 2, 3]
                source = item
            elif isinstance(item, Token):
                if item.type == "CNAME":
                    tokens_seen.append(str(item.value))
            elif isinstance(item, str):
                tokens_seen.append(item)
            elif isinstance(item, Tree):
                if item.data == "value_list":
                    # Parse literal list
                    source = [self._parse_value(v) for v in item.children]
                elif item.data == "entity_id":
                    # Entity reference
                    children = item.children
                    if len(children) == 2:
                        tokens_seen.append(f"{children[0]}.{children[1]}")
                    else:
                        tokens_seen.append(str(children[0]))
                else:
                    # General expression (including list literals like [1, 2, null, -1])
                    # Evaluate the expression to get the list value
                    ctx = EvalContext(graph=self._graph, match=None, row=None)
                    evaluated = self._eval_expression(item, ctx)
                    if isinstance(evaluated, list):
                        source = evaluated
                    elif isinstance(evaluated, str):
                        tokens_seen.append(evaluated)

        # First token is source, second is variable name
        if len(tokens_seen) >= 2:
            source = tokens_seen[0]
            var_name = tokens_seen[1]
        elif len(tokens_seen) == 1 and source is not None:
            # Source was a literal list, token is variable name
            var_name = tokens_seen[0]

        if source is None or var_name is None:
            return self

        # Store UNWIND configuration
        executor._unwind_var = var_name
        executor._unwind_source = source

        # Handle UNWIND - expand source into rows
        expanded_results = {}
        source_data = None

        if isinstance(source, list):
            # Literal list like [1, 2, 3]
            source_data = source
        elif executor._with_results is not None and source in executor._with_results:
            # Reference to WITH result
            source_data = executor._with_results[source]

        if source_data is not None:
            # Expand the data
            if isinstance(source_data, list) and len(source_data) > 0:
                # If source is list of lists (from COLLECT), flatten
                if isinstance(source_data[0], list):
                    all_items = []
                    for row_list in source_data:
                        all_items.extend(row_list)
                    expanded_results[var_name] = all_items
                else:
                    # Simple list
                    expanded_results[var_name] = list(source_data)
            else:
                expanded_results[var_name] = list(source_data) if source_data else []

            # Store as WITH results (or initialize if not present)
            if executor._with_results is None:
                executor._with_results = expanded_results
            else:
                executor._with_results = expanded_results

        return self

    def _parse_value(self, item):
        """Parse a value from the parse tree."""
        if isinstance(item, Token):
            if item.type == "NUMBER":
                return int(item.value) if "." not in item.value else float(item.value)
            elif item.type == "ESTRING":
                return str(item.value).strip('"').strip("'")
            else:
                return str(item.value)
        return item

    def _apply_with_where(self, results, condition, aliases):
        """Apply WHERE condition to intermediate WITH results."""
        if not results:
            return results

        # Get the number of rows
        num_rows = len(next(iter(results.values()))) if results else 0
        if num_rows == 0:
            return results

        # Filter rows based on condition
        filtered_indices = []
        for i in range(num_rows):
            row = {k: v[i] for k, v in results.items()}
            # Also add aliased versions
            for alias, original in aliases.items():
                if original in row and alias not in row:
                    row[alias] = row[original]
            if self._evaluate_with_condition(condition, row):
                filtered_indices.append(i)

        # Build filtered results
        filtered_results = {}
        for key, values in results.items():
            filtered_results[key] = [values[i] for i in filtered_indices]

        return filtered_results

    def _evaluate_with_condition(self, condition, row):
        """Evaluate a condition against a row of WITH results."""
        if callable(condition):
            # Try to evaluate the condition
            try:
                return condition(row)
            except Exception:
                return True
        return True

    def _build_where_condition(self, tree):
        """Build a condition function from a WHERE clause tree."""
        # This is a simplified version - full implementation would need
        # to handle all comparison operators
        return lambda row: True  # Placeholder - implement full logic as needed

    def entity_id(self, entity_id):
        if len(entity_id) == 2:
            return ".".join(entity_id)
        return entity_id.value

    def edge_match(self, edge_tokens):
        def flatten_tokens(edge_tokens):
            flat_tokens = []
            for token in edge_tokens:
                if isinstance(token, Tree):
                    flat_tokens.extend(
                        flatten_tokens(token.children)
                    )  # Recursively flatten the tree
                else:
                    flat_tokens.append(token)
            return flat_tokens

        direction = cname = min_hop = max_hop = None
        edge_types = []
        edge_tokens = flatten_tokens(edge_tokens)

        for token in edge_tokens:
            if token.type == "MIN_HOP":
                min_hop = int(token.value)
            elif token.type == "MAX_HOP":
                max_hop = int(token.value) + 1
            elif token.type == "LEFT_ANGLE":
                direction = "l"
            elif token.type == "RIGHT_ANGLE" and direction == "l":
                direction = "b"
            elif token.type == "RIGHT_ANGLE":
                direction = "r"
            elif token.type == "TYPE":
                edge_types.append(token.value)
            else:
                cname = token

        direction = direction if direction is not None else "b"
        if (min_hop is not None or max_hop is not None) and (direction == "b"):
            raise TypeError("Bidirectional edge does not support edge hopping")

        # Handle the case where no edge types are specified, defaulting to a generic type if needed
        if edge_types == []:
            edge_types = None

        return (cname, edge_types, direction, min_hop, max_hop)

    def node_match(self, node_name):
        cname = node_types = json_data = None
        for item in node_name:
            if not isinstance(item,Tree):
                if not isinstance(item, Token):
                    json_data = item
                elif item.type == "CNAME":
                    cname = item
                elif item.type == "TYPE":
                    node_types = set([item.value])
            elif isinstance(item,Tree):
                if item.data.value in ('type_list', 'label_list'):
                    node_types=set([token.value for token in item.children])
        cname = cname or Token("CNAME", shortuuid())
        json_data = json_data or {}
        node_types = node_types if node_types else set()

        return (cname, node_types, json_data)

    def many_match_clause(self, many_match_clause):
        self._match_clause_count += 1
        return self

    def match_clause_with_where(self, args):
        """Handle MATCH clause with optional WHERE clause.

        The match_clause is processed first (adding nodes/edges to motif),
        then if there's a WHERE clause, it's stored for filtering matches.
        """
        # args[0] is None (from match_clause which returns nothing)
        # args[1] is the WHERE expression if present (from where_clause)
        if len(args) >= 2 and args[1] is not None:
            expr = args[1]
            if isinstance(expr, (EdgeExistsCondition, NegatedEdgeCondition, CompoundCondition)):
                self._executors[-1]._where_condition = expr
            else:
                self._executors[-1]._where_expression = expr
        return

    def optional_match_clause_with_where(self, args):
        """Handle OPTIONAL MATCH clause with optional WHERE clause.

        The optional_match_clause is processed first (adding pattern to _optional_patterns),
        then if there's a WHERE clause, it's stored in the optional pattern for filtering.
        """
        # args[0] is None (from optional_match_clause which returns nothing)
        # args[1] is the WHERE expression if present (from where_clause)
        if len(args) >= 2 and args[1] is not None:
            expr = args[1]
            executor = self._executors[-1]
            if executor._optional_patterns:
                # Store the WHERE expression in the optional pattern
                executor._optional_patterns[-1]["where"] = expr
        return

    def match_clause(self, match_clause: Tuple): # construct the motif
        if self._match_clause_count == len(self._executors):
            subquery_executor = NXCypherExecutor(self._target_graph, self._limit)
            parent_executor = self._executors[-1]
            subquery_executor._parent_executor = parent_executor
            subquery_executor._level = parent_executor._level + 1
            self._executors[-1]._child_executors.append(subquery_executor)
            self._executors.append(subquery_executor)

        if len(match_clause) == 1:
            # This is just a node match:
            u, ut, js = match_clause[0]
            self._executors[-1]._motif.add_node(u.value, __labels__=ut, **js)
            return

        match_clause = match_clause[1:] if not match_clause[0] else match_clause
        for start in range(0, len(match_clause) - 2, 2):
            # u/v (token) - representing variable name of node
            # ut/vt (set) - representing labels
            # ujs/vjs (dict) - representing json rules
            # g (token) - representing variable name of relation
            # t (set) - representing relation labels (types)
            # d (str) - representing direction: r,l,b
            # minh/maxh (int) - min/max hops
            ((u, ut, ujs), (g, t, d, minh, maxh), (v, vt, vjs)) = match_clause[
                start : start + 3
            ]
            if d == "r":
                edges = ((u.value, v.value),)
            elif d == "l":
                edges = ((v.value, u.value),)
            elif d == "b":
                edges = ((u.value, v.value), (v.value, u.value))
            else:
                raise ValueError(f"Not support direction d={d!r}")

            if g:
                self._executors[-1]._return_edges[g.value] = edges[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 > self._executors[-1]._max_hop:
                raise ValueError(f"max hop is caped at 100, found {maxh}!")
            if t:
                t = set([t] if type(t) is str else t)
            self._executors[-1]._motif.add_edges_from(
                edges, __min_hop__=minh, __max_hop__=maxh, __is_hop__=ish, __labels__=t
            )

            self._executors[-1]._motif.add_node(u.value, __labels__=ut, **ujs)
            self._executors[-1]._motif.add_node(v.value, __labels__=vt, **vjs)

    def optional_match_clause(self, match_clause: Tuple):
        """Handle OPTIONAL MATCH clause - stores as separate pattern for independent processing.

        Each OPTIONAL MATCH is stored separately and processed independently during matching.
        This ensures that if one OPTIONAL MATCH fails, it doesn't affect others (Neo4j behavior).
        """
        executor = self._executors[-1]

        # Create a separate pattern for this OPTIONAL MATCH
        optional_pattern = {
            "nodes": set(),  # New nodes introduced by this OPTIONAL MATCH
            "edges": [],     # Edge specifications
            "anchor_node": None,  # Node that connects to the required match
        }

        if len(match_clause) == 1:
            # This is just a node match:
            u, ut, js = match_clause[0]
            optional_pattern["nodes"].add(u.value)
            # Also add to main motif for now (needed for return processing)
            executor._motif.add_node(u.value, __labels__=ut, **js)
            executor._optional_nodes.add(u.value)
            executor._optional_patterns.append(optional_pattern)
            return

        match_clause = match_clause[1:] if not match_clause[0] else match_clause
        for start in range(0, len(match_clause) - 2, 2):
            ((u, ut, ujs), (g, t, d, minh, maxh), (v, vt, vjs)) = match_clause[
                start : start + 3
            ]
            if d == "r":
                edges = ((u.value, v.value),)
            elif d == "l":
                edges = ((v.value, u.value),)
            elif d == "b":
                edges = ((u.value, v.value), (v.value, u.value))
            else:
                raise ValueError(f"Not support direction d={d!r}")

            if g:
                executor._return_edges[g.value] = edges[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 caped at 100, found {maxh}!")
            if t:
                t = set([t] if type(t) is str else t)

            # Store edge info in the optional pattern
            optional_pattern["edges"].append({
                "edges": edges,
                "min_hop": minh,
                "max_hop": maxh,
                "is_hop": ish,
                "labels": t,
                "u": u.value,
                "v": v.value,
                "ut": ut,
                "vt": vt,
                "ujs": ujs,
                "vjs": vjs,
            })

            # Track anchor node (the node that should already be matched)
            if optional_pattern["anchor_node"] is None:
                optional_pattern["anchor_node"] = u.value

            # Add new optional node
            optional_pattern["nodes"].add(v.value)

            # Also add to main motif with optional flag (for compatibility)
            executor._motif.add_edges_from(
                edges, __min_hop__=minh, __max_hop__=maxh, __is_hop__=ish, __labels__=t, __optional__=True
            )
            executor._motif.add_node(u.value, __labels__=ut, **ujs)
            executor._motif.add_node(v.value, __labels__=vt, **vjs)
            executor._optional_nodes.add(v.value)
            for edge in edges:
                executor._optional_edges.add(edge)

        # Store this optional pattern for later independent processing
        executor._optional_patterns.append(optional_pattern)

    def path_clause(self, path_clause: tuple):
        self._executors[-1]._paths.append(path_clause[0])
        return

    def where_clause(self, where_clause: tuple):
        expr = where_clause[0]
        # Return the expression - the parent rule (match_clause_with_where or
        # optional_match_clause_with_where) will handle storing it appropriately
        return expr

    def compound_condition(self, val):
        if len(val) == 1:
            # Check if it's already a condition object (EdgeExistsCondition, NegatedEdgeCondition)
            if isinstance(val[0], (EdgeExistsCondition, NegatedEdgeCondition)):
                val = val[0]
            else:
                val = CompoundCondition(*val[0])
        else:  # len == 3
            compound_a, operator, compound_b = val
            val = operator(compound_a, compound_b)
        return val

    def where_and(self, val):
        return _BOOL_ARI["and"]

    def where_or(self, val):
        return _BOOL_ARI["or"]

    def condition(self, condition):
        # Handle edge existence conditions directly
        if len(condition) == 1 and isinstance(condition[0], (EdgeExistsCondition, NegatedEdgeCondition)):
            return condition[0]

        if len(condition) == 1:  # sub query
            condition = condition[0]

        if len(condition) == 3:
            (entity_id, operator, value) = condition
            return (True, entity_id, operator, value)

    def condition_not(self, processed_condition):
        return (not processed_condition[0][0], *processed_condition[0][1:])

    def edge_exists_condition(self, args):
        """Handle (a)-->(b) pattern - edge existence check."""
        source_alias = str(args[0])
        edge_info = args[1]  # (direction, edge_type)
        target_alias = str(args[2])
        direction, edge_type = edge_info
        return EdgeExistsCondition(source_alias, target_alias, edge_type, direction=direction)

    def negated_edge_condition(self, args):
        """Handle NOT (a)-->(b) pattern - edge non-existence check.

        The grammar 'not'i edge_exists_condition passes the EdgeExistsCondition
        object directly as the first argument.
        """
        # args[0] is the EdgeExistsCondition object from edge_exists_condition
        edge_exists = args[0]
        return NegatedEdgeCondition(
            edge_exists._source, edge_exists._target,
            edge_exists._edge_type, direction=edge_exists._direction
        )

    # Edge pattern direction handlers
    def edge_undirected(self, _):
        return ("both", None)

    def edge_right(self, _):
        return ("right", None)

    def edge_left(self, _):
        return ("left", None)

    def edge_typed_right(self, args):
        edge_type = str(args[0]) if args else None
        return ("right", edge_type)

    def edge_typed_left(self, args):
        edge_type = str(args[0]) if args else None
        return ("left", edge_type)

    def edge_typed_undirected(self, args):
        edge_type = str(args[0]) if args else None
        return ("both", edge_type)

    # Mutation clause handlers

    def _is_create_reference(self, alias: Optional[str]) -> bool:
        """Check if an alias refers to an already-known node (MATCH or prior CREATE).

        In CREATE, a bare ``(a)`` is a *new* node unless ``a`` was previously
        defined by a MATCH clause or an earlier CREATE in the same query.
        """
        if not alias:
            return False
        # Already matched by a MATCH clause?
        motif = self._executors[-1]._motif
        if alias in motif.nodes():
            return True
        # Already queued for creation?
        for m in self._mutation_executor._mutations:
            if m.operation == "CREATE_NODE" and m.target == alias:
                return True
        return False

    def create_clause(self, args):
        """Handle CREATE clause - queues node and edge creations."""
        # Process all pending create patterns
        for pattern in args:
            if pattern is None:
                continue
            # Pattern could be a node dict or list of (node, edge, node) tuples
            if isinstance(pattern, dict):
                if pattern.get("type") == "node":
                    alias = pattern.get("alias")
                    if not self._is_create_reference(alias):
                        self._mutation_executor.queue_create_node(
                            alias=alias,
                            labels=pattern.get("labels", set()),
                            props=pattern.get("properties", {})
                        )
            elif isinstance(pattern, list):
                # Pattern with edges: [(node1, edge, node2), ...]
                for item in pattern:
                    if isinstance(item, dict):
                        if item.get("type") == "node":
                            alias = item.get("alias")
                            if not self._is_create_reference(alias):
                                self._mutation_executor.queue_create_node(
                                    alias=alias,
                                    labels=item.get("labels", set()),
                                    props=item.get("properties", {})
                                )
                        elif item.get("type") == "edge":
                            self._mutation_executor.queue_create_edge(
                                source=item["source"],
                                target=item["target"],
                                rel_type=item.get("rel_type"),
                                props=item.get("properties", {}),
                                alias=item.get("alias")
                            )
        return args

    def create_pattern(self, args):
        """Handle create_pattern - processes nodes and edges in a CREATE pattern."""
        if len(args) == 1:
            # Single node creation
            return args[0]

        # Pattern with edges: node (edge node)+
        # We need to connect edges to their source/target nodes
        result = []
        prev_node = None

        for item in args:
            if isinstance(item, dict) and item.get("type") == "node":
                result.append(item)
                prev_node = item
            elif isinstance(item, dict) and item.get("type") == "edge":
                # Edge needs source/target to be set based on direction and prev_node
                edge = item.copy()
                # We'll set source/target after we know the next node
                edge["_prev_node"] = prev_node
                result.append(edge)

        # Now resolve edge source/target based on adjacent nodes
        resolved = []
        i = 0
        while i < len(result):
            item = result[i]
            if isinstance(item, dict) and item.get("type") == "edge":
                prev_node = result[i - 1] if i > 0 else None
                next_node = result[i + 1] if i + 1 < len(result) else None

                if prev_node and next_node:
                    prev_alias = prev_node.get("alias") or f"_anon_{i-1}"
                    next_alias = next_node.get("alias") or f"_anon_{i+1}"

                    # Ensure anonymous nodes have aliases assigned
                    if not prev_node.get("alias"):
                        prev_node["alias"] = prev_alias
                    if not next_node.get("alias"):
                        next_node["alias"] = next_alias

                    if item.get("direction") == "right":
                        item["source"] = prev_alias
                        item["target"] = next_alias
                    else:  # left direction
                        item["source"] = next_alias
                        item["target"] = prev_alias

                    # Clean up temporary field
                    item.pop("_prev_node", None)

                resolved.append(item)
            else:
                resolved.append(item)
            i += 1

        return resolved

    def node_create(self, args):
        """Handle node_create - creates a node definition.

        If the node only has an alias (no labels, no properties), it's a reference
        to an already-matched node and should not create a new node.
        """
        alias = None
        labels = set()
        properties = {}

        for item in args:
            if isinstance(item, Token):
                if item.type == "CNAME":
                    alias = str(item)
            elif isinstance(item, Tree):
                if item.data in ("type_list", "label_list"):
                    labels = set(str(t) for t in item.children)
            elif isinstance(item, dict):
                properties = item
            elif isinstance(item, set):
                labels = item

        # If just an alias with no labels/properties, it's a reference not a new node
        is_reference = alias and not labels and not properties
        return {
            "type": "node",
            "alias": alias,
            "labels": labels,
            "properties": properties,
            "is_reference": is_reference
        }

    def edge_create_right(self, args):
        """Handle -[...]--> edge creation (right direction)."""
        alias = None
        rel_type = None
        properties = {}

        for item in args:
            if isinstance(item, Token):
                if item.type == "CNAME":
                    alias = str(item)
                elif item.type == "TYPE":
                    rel_type = str(item)
            elif isinstance(item, dict):
                properties = item

        return {"type": "edge", "alias": alias, "rel_type": rel_type,
                "properties": properties, "direction": "right"}

    def edge_create_left(self, args):
        """Handle <-[...]-- edge creation (left direction)."""
        alias = None
        rel_type = None
        properties = {}

        for item in args:
            if isinstance(item, Token):
                if item.type == "CNAME":
                    alias = str(item)
                elif item.type == "TYPE":
                    rel_type = str(item)
            elif isinstance(item, dict):
                properties = item

        return {"type": "edge", "alias": alias, "rel_type": rel_type,
                "properties": properties, "direction": "left"}

    def create_st(self, args):
        """Handle create_st - CREATE pattern"""
        # args[0] is CREATE token, args[1] is pattern result
        pattern = None
        for arg in args:
            if isinstance(arg, Token) and str(arg).upper() == "CREATE":
                continue
            if arg is not None:
                pattern = arg
                break
        if pattern is not None:
            # Call create_clause with the pattern
            self.create_clause([pattern])
        return args

    def pattern(self, args):
        """Handle pattern - list of pattern_parts"""
        # Filter out tokens like COMMA
        parts = [arg for arg in args if not isinstance(arg, Token)]
        if len(parts) == 1:
            return parts[0]
        return parts

    def pattern_part(self, args):
        """Handle pattern_part - optional path name assignment, then pattern_elem"""
        # args could be [symbol, ASSIGN, pattern_elem] or [pattern_elem]
        path_name = None
        pattern_elem = None
        for i, arg in enumerate(args):
            if isinstance(arg, Token):
                if arg.type == "symbol":
                    # Could be path variable name
                    if i + 1 < len(args) and isinstance(args[i + 1], Token) and str(args[i + 1]) == "=":
                        path_name = str(arg)
                continue
            if arg is not None:
                pattern_elem = arg
        return pattern_elem

    def pattern_elem(self, args):
        """Handle pattern_elem - node with optional relationship chains"""
        # args could be [node_dict] or [node_dict, [chain elements...]]
        result = []
        for arg in args:
            if isinstance(arg, dict):
                result.append(arg)
            elif isinstance(arg, list):
                result.extend(arg)

        if len(result) == 1:
            return result[0]

        # Process edges to set source/target
        return self.create_pattern(result)

    def pattern_elem_chain(self, args):
        """Handle pattern_elem_chain - relationship_pattern node_pattern"""
        # Returns list of [edge_dict, node_dict]
        result = []
        for arg in args:
            if isinstance(arg, dict):
                result.append(arg)
        return result

    def node_pattern(self, args):
        """Handle node_pattern - (symbol? node_labels? properties?)"""
        alias = None
        labels = set()
        properties = {}

        for arg in args:
            if isinstance(arg, Token):
                if arg.type == "symbol":
                    alias = str(arg)
                elif str(arg) in ("(", ")"):
                    continue
            elif isinstance(arg, set):
                labels = arg
            elif isinstance(arg, dict):
                if "__labels__" in arg or any(k not in ("type", "alias", "labels", "properties") for k in arg.keys()):
                    # This is a properties dict
                    properties = arg

        return {
            "type": "node",
            "alias": alias,
            "labels": labels,
            "properties": properties
        }

    def relationship_pattern(self, args):
        """Handle relationship_pattern - -[relation_detail?]-> or similar"""
        alias = None
        rel_type = None
        properties = {}
        direction = "right"  # default

        # Determine direction from token sequence
        has_left_arrow = False
        has_right_arrow = False

        for arg in args:
            if isinstance(arg, Token):
                tok_str = str(arg)
                if tok_str == "<":
                    has_left_arrow = True
                elif tok_str == ">":
                    has_right_arrow = True
            elif isinstance(arg, dict) and arg.get("_relation_detail"):
                alias = arg.get("alias")
                rel_type = arg.get("rel_type")
                properties = arg.get("properties", {})

        if has_left_arrow and not has_right_arrow:
            direction = "left"
        else:
            direction = "right"

        return {
            "type": "edge",
            "alias": alias,
            "rel_type": rel_type,
            "properties": properties,
            "direction": direction
        }

    def relation_detail(self, args):
        """Handle relation_detail - [symbol? relationship_types? properties?]"""
        alias = None
        rel_type = None
        properties = {}

        for arg in args:
            if isinstance(arg, Token):
                tok_str = str(arg)
                if tok_str in ("[", "]", ":", "-"):
                    continue
                if arg.type in ("symbol", "ID", "CNAME"):
                    alias = tok_str
            elif isinstance(arg, Tree):
                if arg.data == "symbol":
                    # This is the relationship variable name
                    alias = str(arg.children[0])
                elif arg.data == "relationship_types":
                    # Extract type from relationship_types -> name -> symbol
                    rel_type = self._extract_rel_type(arg)
                elif arg.data == "properties":
                    # Extract properties from properties -> map_lit
                    properties = self._extract_properties(arg)
            elif isinstance(arg, str):
                # This could be the result from relationship_types handler
                if rel_type is None:
                    rel_type = arg
            elif isinstance(arg, dict):
                # Could be already processed properties
                if not arg.get("_relation_detail"):  # Not our own output
                    properties = arg

        return {
            "_relation_detail": True,
            "alias": alias,
            "rel_type": rel_type,
            "properties": properties
        }

    def _extract_rel_type(self, tree):
        """Extract relationship type name from relationship_types tree."""
        if isinstance(tree, Tree):
            for child in tree.children:
                if isinstance(child, Token) and child.type == "COLON":
                    continue
                if isinstance(child, Tree):
                    if child.data == "name":
                        return self._extract_name(child)
                    result = self._extract_rel_type(child)
                    if result:
                        return result
                elif isinstance(child, Token) and child.type in ("ID", "CNAME", "symbol"):
                    return str(child)
        return None

    def _extract_name(self, tree):
        """Extract name string from name tree."""
        if isinstance(tree, Tree):
            for child in tree.children:
                if isinstance(child, Tree) and child.data == "symbol":
                    return str(child.children[0])
                elif isinstance(child, Token):
                    return str(child)
        return None

    def _extract_properties(self, tree):
        """Extract properties dict from properties tree."""
        if isinstance(tree, Tree):
            if tree.data == "properties":
                for child in tree.children:
                    if isinstance(child, Tree) and child.data == "map_lit":
                        return self._extract_map_lit(child)
                    elif isinstance(child, dict):
                        return child
        return {}

    def _extract_map_lit(self, tree):
        """Extract dict from map_lit tree."""
        result = {}
        if isinstance(tree, Tree) and tree.data == "map_lit":
            for child in tree.children:
                if isinstance(child, Tree) and child.data == "map_pair":
                    key, value = self._extract_map_pair(child)
                    if key is not None:
                        result[key] = value
        return result

    def _extract_map_pair(self, tree):
        """Extract key-value pair from map_pair tree."""
        key = None
        value = None
        if isinstance(tree, Tree) and tree.data == "map_pair":
            children = [c for c in tree.children if not (isinstance(c, Token) and c.type == "COLON")]
            if len(children) >= 2:
                # First child is name, second is expression
                key = self._extract_name(children[0])
                value = self._extract_expression_value(children[1])
        return key, value

    def _extract_expression_value(self, tree):
        """Extract literal value from expression tree."""
        if isinstance(tree, Token):
            return self._token_to_value(tree)
        if isinstance(tree, Tree):
            if tree.data in ("num_lit", "literal"):
                for child in tree.children:
                    return self._extract_expression_value(child)
            if tree.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"):
                if len(tree.children) == 1:
                    return self._extract_expression_value(tree.children[0])
        if isinstance(tree, (int, float, str, bool)) or tree is None:
            return tree
        return tree  # Return as-is for complex expressions

    def _token_to_value(self, token):
        """Convert token to Python value."""
        if token.type in ("DIGIT", "INT", "INTEGER"):
            return int(token)
        elif token.type in ("FLOAT", "DECIMAL"):
            return float(token)
        elif token.type in ("TRUE",):
            return True
        elif token.type in ("FALSE",):
            return False
        elif token.type in ("NULL",):
            return None
        elif token.type in ("STRING", "SINGLE_STRING", "DOUBLE_STRING"):
            s = str(token)
            if (s.startswith("'") and s.endswith("'")) or (s.startswith('"') and s.endswith('"')):
                return s[1:-1]
            return s
        return str(token)

    def relationship_types(self, args):
        """Handle relationship_types - :TYPE or :TYPE|TYPE2..."""
        types = []
        for arg in args:
            if isinstance(arg, Token):
                if arg.type != "COLON" and str(arg) != ":":
                    types.append(str(arg))
            elif isinstance(arg, Tree):
                # Handle name tree
                if arg.data == "name":
                    name = self._extract_name(arg)
                    if name:
                        types.append(name)
            elif isinstance(arg, str):
                types.append(arg)
        return types[0] if types else None

    def node_labels(self, args):
        """Handle node_labels - :Label[:Label2...]"""
        labels = set()
        for arg in args:
            if isinstance(arg, Token):
                if str(arg) != ":":
                    labels.add(str(arg))
            elif isinstance(arg, str):
                labels.add(arg)
        return labels

    def set_clause(self, args):
        """Handle SET clause - queues property updates."""
        for item in args:
            if isinstance(item, dict) and item.get("type") == "set_item":
                self._mutation_executor.queue_set(
                    entity_path=item["path"],
                    value=item["value"]
                )
        return args

    def set_item(self, args):
        """Handle set_item - entity.property = value."""
        # args[0] is entity_id (e.g., "n.name"), args[1] is the value
        entity_path = str(args[0])
        value = args[1]
        return {"type": "set_item", "path": entity_path, "value": value}

    def set_value(self, args):
        """Handle set_value - extracts the value from a SET statement."""
        return args[0]

    def delete_clause(self, args):
        """Handle DELETE clause - queues node deletions."""
        # args is the result from delete_items
        if args and isinstance(args[0], list):
            entities = args[0]
        else:
            entities = args

        for entity in entities:
            if isinstance(entity, Token):
                self._mutation_executor.queue_delete(str(entity), detach=False)
            elif isinstance(entity, str):
                self._mutation_executor.queue_delete(entity, detach=False)
        return args

    def detach_delete_clause(self, args):
        """Handle DETACH DELETE clause - queues node deletions with edge removal."""
        if args and isinstance(args[0], list):
            entities = args[0]
        else:
            entities = args

        for entity in entities:
            if isinstance(entity, Token):
                self._mutation_executor.queue_delete(str(entity), detach=True)
            elif isinstance(entity, str):
                self._mutation_executor.queue_delete(entity, detach=True)
        return args

    def delete_items(self, args):
        """Handle delete_items - list of entities to delete."""
        return [str(item) if isinstance(item, Token) else item for item in args]

    def remove_clause(self, args):
        """Handle REMOVE clause - processes remove items."""
        # Each arg is a remove_property or remove_label dict
        for item in args:
            if isinstance(item, dict):
                if item.get("type") == "remove_property":
                    self._mutation_executor.queue_remove_property(item["path"])
                elif item.get("type") == "remove_label":
                    self._mutation_executor.queue_remove_label(item["entity"], item["label"])
        return args

    def remove_property(self, args):
        """Handle remove_property - entity.property."""
        entity_path = str(args[0]) if not isinstance(args[0], str) else args[0]
        return {"type": "remove_property", "path": entity_path}

    def remove_label(self, args):
        """Handle remove_label - entity:Label."""
        entity = str(args[0]) if isinstance(args[0], Token) else args[0]
        label = str(args[1]) if isinstance(args[1], Token) else args[1]
        return {"type": "remove_label", "entity": entity, "label": label}

    def merge_clause(self, args):
        """Handle MERGE clause - upsert semantics."""
        # args[0] is create_pattern (node definition)
        # args[1:] may be on_create_clause and/or on_match_clause
        pattern = args[0]
        on_create = []
        on_match = []

        for arg in args[1:]:
            if isinstance(arg, dict):
                if arg.get("type") == "on_create":
                    on_create = arg.get("items", [])
                elif arg.get("type") == "on_match":
                    on_match = arg.get("items", [])

        # Parse the pattern - it's a create_pattern with nodes
        # For simplicity, handle single node merge
        if isinstance(pattern, dict) and "nodes" in pattern:
            node = pattern["nodes"][0]
            self._mutation_executor.queue_merge(node, on_create, on_match)
        elif isinstance(pattern, dict):
            # Direct node dict
            self._mutation_executor.queue_merge(pattern, on_create, on_match)

        return args

    def on_create_clause(self, args):
        """Handle ON CREATE SET clause."""
        return {"type": "on_create", "items": list(args)}

    def on_match_clause(self, args):
        """Handle ON MATCH SET clause."""
        return {"type": "on_match", "items": list(args)}

    null = lambda self, _: None
    true = lambda self, _: True
    false = lambda self, _: False
    ESTRING = v_args(inline=True)(eval)
    NUMBER = v_args(inline=True)(eval)

    def id_function(self, entity_id):
        entity_name = entity_id[0].value
        # Add the raw entity ID to the return requests as well
        # This ensures tests like test_id can still access res["A"]
        # self._return_requests.append(entity_name)
        # Return a special identifier that will be processed in _lookup method
        return f"ID({entity_name})"

    def tolower_function(self, entity_id):
        entity_name = entity_id[0] if isinstance(entity_id[0], str) else str(entity_id[0].value)
        return f"TOLOWER({entity_name})"

    def toupper_function(self, entity_id):
        entity_name = entity_id[0] if isinstance(entity_id[0], str) else str(entity_id[0].value)
        return f"TOUPPER({entity_name})"

    def trim_function(self, entity_id):
        entity_name = entity_id[0] if isinstance(entity_id[0], str) else str(entity_id[0].value)
        return f"TRIM({entity_name})"

    def size_function(self, entity_id):
        entity_name = entity_id[0] if isinstance(entity_id[0], str) else str(entity_id[0].value)
        return f"SIZE({entity_name})"

    def head_function(self, entity_id):
        entity_name = entity_id[0] if isinstance(entity_id[0], str) else str(entity_id[0].value)
        return f"HEAD({entity_name})"

    def tail_function(self, entity_id):
        entity_name = entity_id[0] if isinstance(entity_id[0], str) else str(entity_id[0].value)
        return f"TAIL({entity_name})"

    def type_function(self, entity_id):
        entity_name = entity_id[0] if isinstance(entity_id[0], str) else str(entity_id[0].value)
        return f"TYPE({entity_name})"

    def labels_function(self, entity_id):
        entity_name = entity_id[0] if isinstance(entity_id[0], str) else str(entity_id[0].value)
        return f"LABELS({entity_name})"

    def keys_function(self, entity_id):
        entity_name = entity_id[0] if isinstance(entity_id[0], str) else str(entity_id[0].value)
        return f"KEYS({entity_name})"

    def _resolve_arg(self, arg):
        """Resolve a function argument to a value or entity reference string."""
        if isinstance(arg, Token):
            return str(arg.value)
        elif isinstance(arg, Tree):
            if arg.data == "entity_id":
                parts = [str(c.value) if isinstance(c, Token) else str(c) for c in arg.children]
                return ".".join(parts)
            elif arg.data in ("null",):
                return None
            elif arg.data in ("true",):
                return True
            elif arg.data in ("false",):
                return False
        elif isinstance(arg, (int, float, str, bool)):
            return arg
        return arg

    def tostring_function(self, args):
        val = self._resolve_arg(args[0])
        return f"TOSTRING({val})"

    def tointeger_function(self, args):
        val = self._resolve_arg(args[0])
        return f"TOINTEGER({val})"

    def tofloat_function(self, args):
        val = self._resolve_arg(args[0])
        return f"TOFLOAT({val})"

    def toboolean_function(self, args):
        val = self._resolve_arg(args[0])
        return f"TOBOOLEAN({val})"

    def reverse_function(self, args):
        val = self._resolve_arg(args[0])
        return f"REVERSE({val})"

    def ltrim_function(self, args):
        entity_name = args[0] if isinstance(args[0], str) else str(args[0].value)
        return f"LTRIM({entity_name})"

    def rtrim_function(self, args):
        entity_name = args[0] if isinstance(args[0], str) else str(args[0].value)
        return f"RTRIM({entity_name})"

    def length_function(self, args):
        entity_name = args[0] if isinstance(args[0], str) else str(args[0].value)
        return f"LENGTH({entity_name})"

    def abs_function(self, args):
        val = self._resolve_arg(args[0])
        return f"ABS({val})"

    def ceil_function(self, args):
        val = self._resolve_arg(args[0])
        return f"CEIL({val})"

    def floor_function(self, args):
        val = self._resolve_arg(args[0])
        return f"FLOOR({val})"

    def round_function(self, args):
        val = self._resolve_arg(args[0])
        return f"ROUND({val})"

    def sign_function(self, args):
        val = self._resolve_arg(args[0])
        return f"SIGN({val})"

    def sqrt_function(self, args):
        val = self._resolve_arg(args[0])
        return f"SQRT({val})"

    def properties_function(self, args):
        entity_name = args[0] if isinstance(args[0], str) else str(args[0].value)
        return f"PROPERTIES({entity_name})"

    def replace_function(self, args):
        entity_name = self._resolve_arg(args[0])
        search = self._resolve_arg(args[1])
        replacement = self._resolve_arg(args[2])
        func_id = f"REPLACE_{id(args)}"
        self._executors[-1]._func_calls[func_id] = ("REPLACE", entity_name, search, replacement)
        return func_id

    def substring_function(self, args):
        entity_name = self._resolve_arg(args[0])
        start = self._resolve_arg(args[1])
        length = self._resolve_arg(args[2]) if len(args) > 2 else None
        func_id = f"SUBSTRING_{id(args)}"
        self._executors[-1]._func_calls[func_id] = ("SUBSTRING", entity_name, start, length)
        return func_id

    def split_function(self, args):
        entity_name = self._resolve_arg(args[0])
        delimiter = self._resolve_arg(args[1])
        func_id = f"SPLIT_{id(args)}"
        self._executors[-1]._func_calls[func_id] = ("SPLIT", entity_name, delimiter)
        return func_id

    def left_function(self, args):
        entity_name = self._resolve_arg(args[0])
        length = self._resolve_arg(args[1])
        func_id = f"LEFT_{id(args)}"
        self._executors[-1]._func_calls[func_id] = ("LEFT", entity_name, length)
        return func_id

    def right_function(self, args):
        entity_name = self._resolve_arg(args[0])
        length = self._resolve_arg(args[1])
        func_id = f"RIGHT_{id(args)}"
        self._executors[-1]._func_calls[func_id] = ("RIGHT", entity_name, length)
        return func_id

    def coalesce_function(self, args):
        resolved = [self._resolve_arg(a) for a in args]
        func_id = f"COALESCE_{id(args)}"
        self._executors[-1]._func_calls[func_id] = ("COALESCE", *resolved)
        return func_id

    def range_function(self, args):
        start = self._resolve_arg(args[0])
        end = self._resolve_arg(args[1])
        step = self._resolve_arg(args[2]) if len(args) > 2 else 1
        func_id = f"RANGE_{id(args)}"
        self._executors[-1]._func_calls[func_id] = ("RANGE", start, end, step)
        return func_id

    def exists_function(self, args):
        entity_name = args[0] if isinstance(args[0], str) else str(args[0].value)
        return f"EXISTS({entity_name})"

    def case_condition(self, items):
        """Parse condition within CASE WHEN."""
        # The condition is already processed by the condition/compound_condition rules
        return items[0] if len(items) == 1 else items

    def case_result(self, items):
        """Parse result value in CASE expression."""
        result = items[0]
        if isinstance(result, Token):
            return str(result.value)
        elif isinstance(result, Tree):
            # Scalar function or aggregation function
            return result
        return result

    def case_when(self, items):
        """Parse WHEN condition THEN result in searched CASE."""
        condition = items[0]
        result = items[1]
        return {"condition": condition, "result": result}

    def case_simple_when(self, items):
        """Parse WHEN value THEN result in simple CASE."""
        value = items[0]
        if isinstance(value, Token):
            value = str(value.value)
        result = items[1]
        return {"value": value, "result": result}

    def case_else(self, items):
        """Parse ELSE result in CASE expression."""
        return items[0]

    def searched_case(self, items):
        """Handle searched CASE expression: CASE WHEN cond THEN result ... ELSE result END."""
        when_clauses = []
        else_result = None

        for item in items:
            if isinstance(item, dict) and "condition" in item:
                when_clauses.append(item)
            elif item is not None:
                else_result = item

        # Store as a special CASE expression object that _lookup can evaluate
        case_expr = CaseExpression(
            case_type="searched",
            when_clauses=when_clauses,
            else_result=else_result,
            compare_value=None
        )
        # Register with executor for later evaluation
        executor = self._executors[-1]
        case_id = f"CASE_{shortuuid()}"
        executor._case_expressions[case_id] = case_expr
        return case_id

    def simple_case(self, items):
        """Handle simple CASE expression: CASE expr WHEN value THEN result ... ELSE result END."""
        compare_value = items[0]
        if isinstance(compare_value, Token):
            compare_value = str(compare_value.value)
        elif isinstance(compare_value, Tree):
            # It's an entity_id - extract it
            if compare_value.data == "entity_id":
                compare_value = self.entity_id(compare_value.children)
            else:
                compare_value = str(compare_value)

        when_clauses = []
        else_result = None

        for item in items[1:]:
            if isinstance(item, dict) and "value" in item:
                when_clauses.append(item)
            elif item is not None:
                else_result = item

        case_expr = CaseExpression(
            case_type="simple",
            when_clauses=when_clauses,
            else_result=else_result,
            compare_value=compare_value
        )
        executor = self._executors[-1]
        case_id = f"CASE_{shortuuid()}"
        executor._case_expressions[case_id] = case_expr
        return case_id

    # List comprehension transformer methods
    def list_comprehension(self, items):
        """Handle list comprehension: [var IN source WHERE cond | expr]."""
        variable = str(items[0].value) if isinstance(items[0], Token) else str(items[0])
        source = items[1]  # list_source result
        filter_condition = None
        transform = None

        # Parse remaining items (filter and/or expression)
        for item in items[2:]:
            if isinstance(item, dict):
                if "filter" in item:
                    filter_condition = item["filter"]
                elif "transform" in item:
                    transform = item["transform"]
                elif "identity" in item:
                    # Just returning the variable, no transform
                    pass

        list_comp = ListComprehension(
            variable=variable,
            source=source,
            filter_condition=filter_condition,
            transform=transform
        )
        executor = self._executors[-1]
        comp_id = f"LISTCOMP_{shortuuid()}"
        executor._list_comprehensions[comp_id] = list_comp
        return comp_id

    def list_source(self, items):
        """Handle list comprehension source (entity_id or value_list)."""
        item = items[0]
        if isinstance(item, list):
            return item
        if isinstance(item, Token):
            return str(item.value)
        if isinstance(item, Tree):
            if item.data == "entity_id":
                return self.entity_id(item.children)
        return str(item) if item else None

    def list_comp_where(self, items):
        """Handle WHERE clause in list comprehension."""
        return {"filter": items[0]}

    def list_comp_condition(self, items):
        """Handle condition in list comprehension WHERE clause."""
        variable = str(items[0].value) if isinstance(items[0], Token) else str(items[0])
        op = items[1]  # Already processed by comp_* methods
        value = items[2]
        return {"var": variable, "op": op, "value": value}

    def comp_gt(self, _):
        return "comp_gt"

    def comp_lt(self, _):
        return "comp_lt"

    def comp_gte(self, _):
        return "comp_gte"

    def comp_lte(self, _):
        return "comp_lte"

    def comp_eq(self, _):
        return "comp_eq"

    def comp_neq(self, _):
        return "comp_neq"

    def list_comp_value(self, items):
        """Handle value in list comprehension."""
        item = items[0]
        if isinstance(item, Token):
            value = item.value
            # Try to parse as number
            try:
                return int(value)
            except ValueError:
                try:
                    return float(value)
                except ValueError:
                    # It's a string or identifier
                    return str(value)
        return str(item)

    def list_comp_expr(self, items):
        """Handle simple expression (just the variable) in list comprehension."""
        variable = str(items[0].value) if isinstance(items[0], Token) else str(items[0])
        return {"identity": variable}

    def list_comp_transform(self, items):
        """Handle transformation expression in list comprehension."""
        variable = str(items[0].value) if isinstance(items[0], Token) else str(items[0])
        op = items[1]  # Already processed by arith_* methods
        value = items[2]
        return {"transform": {"var": variable, "op": op, "value": value}}

    def arith_add(self, _):
        return "arith_add"

    def arith_sub(self, _):
        return "arith_sub"

    def arith_mul(self, _):
        return "arith_mul"

    def arith_div(self, _):
        return "arith_div"

    def value_list(self, items):
        # Empty list [] produces [None] from optional grammar group
        if len(items) == 1 and items[0] is None:
            return []
        return list(items)

    def op(self, operator):
        return operator

    def op_eq(self, _):
        return _OPERATORS["=="]

    def op_neq(self, _):
        return _OPERATORS["<>"]

    def op_gt(self, _):
        return _OPERATORS[">"]

    def op_lt(self, _):
        return _OPERATORS["<"]

    def op_gte(self, _):
        return _OPERATORS[">="]

    def op_lte(self, _):
        return _OPERATORS["<="]

    def op_is(self, _):
        return _OPERATORS["is"]

    def op_in(self, _):
        return _OPERATORS["in"]

    def op_contains(self, _):
        return _OPERATORS["contains"]

    def op_starts_with(self, _):
        return _OPERATORS["starts_with"]

    def op_ends_with(self, _):
        return _OPERATORS["ends_with"]

    def subop_exist(self, val):
        return _SUB_OPERATORS["EXISTS"]()

    def sub_query(self, items):
        executor = self._executors.pop()
        self._match_clause_count -= 1
        # return entity_id = "" to match with other condition
        return ["", items[0], executor]

    def json_dict(self, tup):
        constraints = {}
        for key, value in tup:
            constraints[key] = value
        return constraints

    def json_rule(self, rule):
        return (rule[0].value, rule[1])

    def union_all(self, items):
        """Handle UNION ALL - keep all rows including duplicates."""
        # Save current query executor and prepare for next query
        self._union_queries.append(self._executors[0])
        self._union_types.append('all')
        # Create fresh executor for next query
        self._executors = [NXCypherExecutor(self._target_graph, self._limit)]
        self._match_clause_count = 0
        return 'union_all'

    def union_distinct(self, items):
        """Handle UNION - remove duplicate rows."""
        # Save current query executor and prepare for next query
        self._union_queries.append(self._executors[0])
        self._union_types.append('distinct')
        # Create fresh executor for next query
        self._executors = [NXCypherExecutor(self._target_graph, self._limit)]
        self._match_clause_count = 0
        return 'union_distinct'


class NXCypher:
    """
    The user-facing interface for NXCypher.

    Create an NXCypher object to wrap your NetworkX-flavored graph
    with a Cypher-queryable interface.

    """

    def __init__(self, host_graph: nx.Graph, limit: int = None, strict: bool = True) -> None:
        """
        Create a new NXCypher object to query graphs with Cypher.

        Arguments:
            host_graph (nx.Graph): The host graph to use as a "graph database"
            limit (int): The default limit to apply to queries when not otherwise provided
            strict (bool): Enable strict validation to match Neo4j behavior (default: True).
                          When True, rejects invalid syntax that Neo4j would reject.
                          When False, uses lenient parser that accepts more queries.

        Returns:
            None

        """
        self._host_graph = host_graph
        self._limit = limit
        self._strict = strict
        # V4 interpreter (primary path)
        self._interpreter: Any = None
        # Legacy transformer (kept for fallback)
        self._transformer: Any = None

    def _get_interpreter(self):
        """Lazy-init v4 interpreter."""
        from nxcypher.interpreter import V4QueryInterpreter
        return V4QueryInterpreter(self._host_graph, self._limit, self._strict)

    def _get_legacy_transformer(self):
        """Lazy-init legacy transformer."""
        if self._transformer is None:
            self._transformer = NXCypherTransformer(self._host_graph, self._limit)
        return self._transformer

    @property
    def auto_node_jsondata_hints(self):
        t = self._get_legacy_transformer()
        return t._executors[0]._auto_node_jsondata_hints

    @auto_node_jsondata_hints.setter
    def auto_node_jsondata_hints(self, val: bool):
        """(EXPERIMENT) set auto hint"""
        t = self._get_legacy_transformer()
        t._executors[0]._auto_node_jsondata_hints = val

    @property
    def auto_where_hints(self):
        t = self._get_legacy_transformer()
        return t._executors[0]._auto_where_hints

    @auto_where_hints.setter
    def auto_where_hints(self, val: bool):
        """(EXPERIMENT) set auto hint"""
        t = self._get_legacy_transformer()
        t._executors[0]._auto_where_hints = val

    def create_node_indices(self, keys: list[str]) -> "NXCypher":
        """(EXPERIMENT) create node indices by keys
        Arguments:
            keys (list[str]): list of node keys to make indexes
        Returns:
            NXCypher: self
        """
        t = self._get_legacy_transformer()
        t._executors[0].create_node_indices(keys)

    def run(self, cypher: str, params: Optional[Dict[str, Any]] = None, hints: Optional[List[dict]] = None) -> Dict[str, List]:
        """
        Run a cypher query on the host graph.

        Arguments:
            cypher (str): The cypher query to run
            params (dict): Query parameters (e.g., {'name': 'Alice', 'age': 30})
            hints (list[dict]): A list of partial-mapping hints to pass along
                                to isomorphism.find_motifs

        Returns:
            Dict[str, List]: A dictionary mapping of results, where keys are
                the items the user requested in the RETURN statement, and the
                values are all possible matches of that structure in the graph.

        """
        from nxcypher.interpreter import get_parser
        from .temporal import QueryClock

        # Parse with v4 grammar
        parser = get_parser()
        tree = parser.parse(cypher)

        # Initialize the query clock for temporal functions
        clock = QueryClock()
        clock.start_transaction()

        # Create a fresh interpreter for each query
        interp = self._get_interpreter()

        # Set parameters on the initial executor BEFORE interpretation
        # This is crucial because WITH clauses evaluate expressions during interpretation
        if params:
            interp.executor.set_parameters(params)

        interp.interpret(tree, hints=hints)

        # Set the clock on all executors
        for executor in interp._executors:
            executor._clock = clock

        # Set parameters on all executors (includes any created during interpretation)
        if params:
            for executor in interp._executors:
                executor.set_parameters(params)

        # Check if this is a UNION query
        if interp._union_queries:
            return self._execute_union_query_v4(interp)

        # Check if there are mutations to execute
        mutation_executor = interp._mutation_executor
        if mutation_executor._mutations:
            executor = interp.executor

            # Collect matches from all executors, preserving each match individually
            # so that mutations apply to every matched node, not just the last one
            combined_matches = []

            for exec in interp._executors:
                if exec._motif.number_of_nodes() > 0:
                    exec_matches = list(exec._get_true_matches())
                    combined_matches.extend(exec_matches)

            matches = combined_matches

            # Build eval_expression callback for SET expressions that contain Trees
            def _eval_expression_cb(tree, ctx):
                return executor._eval_expression(tree, ctx)

            # Execute mutations
            mutation_results = mutation_executor.execute(
                matches,
                eval_expression=_eval_expression_cb,
                return_edges=executor._return_edges
            )

            # Make created/merged nodes available to executor for RETURN clause
            if mutation_executor._created_nodes:
                # Populate _with_results with created/merged nodes
                # Store the node's data dict (not just the ID) so property access works
                if executor._with_results is None:
                    executor._with_results = {}
                for alias, node_id in mutation_executor._created_nodes.items():
                    if self._host_graph.has_node(node_id):
                        node_data = dict(self._host_graph.nodes[node_id])
                        executor._with_results[alias] = [node_data]
                    else:
                        executor._with_results[alias] = [node_id]

            # Make created edges available to executor for RETURN clause
            if mutation_executor._created_edges:
                if executor._with_results is None:
                    executor._with_results = {}
                for alias, edge_data in mutation_executor._created_edges.items():
                    executor._with_results[alias] = [edge_data]

            # If there's a return clause, run it
            if executor._return_requests or executor._aggregate_functions:
                return executor.returns()

            return {}

        return interp.executor.returns()

    def _execute_union_query_v4(self, interp) -> Dict[str, List]:
        """Execute a UNION query by combining results from v4 interpreter."""
        all_executors = interp._union_queries + [interp.executor]
        union_types = interp._union_types

        if len(set(union_types)) > 1:
            raise SyntaxError("InvalidClauseComposition: Cannot mix UNION and UNION ALL")

        is_distinct = any(t == 'distinct' for t in union_types)

        all_results: List[Dict[str, List]] = []
        for executor in all_executors:
            result = executor.returns()
            all_results.append(result)

        if not all_results:
            return {}

        columns = list(all_results[0].keys())

        for i, result in enumerate(all_results[1:], 1):
            if len(result.keys()) != len(columns):
                raise SyntaxError("DifferentColumnsInUnion: All sub queries in a UNION must have the same column names")

        combined: Dict[str, List] = {col: [] for col in columns}
        seen_rows: Set[tuple] = set()

        for i, result in enumerate(all_results):
            if not result:
                continue
            first_col = list(result.keys())[0]
            num_rows = len(result[first_col])

            for row_idx in range(num_rows):
                row_values = []
                for col in columns:
                    if col in result and row_idx < len(result[col]):
                        row_values.append(result[col][row_idx])
                    else:
                        row_values.append(None)

                row_tuple = tuple(
                    tuple(v) if isinstance(v, list) else v for v in row_values
                )

                if is_distinct and row_tuple in seen_rows:
                    continue

                if is_distinct:
                    seen_rows.add(row_tuple)

                for j, col in enumerate(columns):
                    combined[col].append(row_values[j])

        return combined
