# 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
from dataclasses import dataclass
from collections import OrderedDict
import random
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)


_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?
                    | many_match_clause where_clause? mutation_clause+ return_clause?
                    | create_clause return_clause?
                    | merge_clause return_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? (":" type_list)? json_dict? ")"
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           : entity_id_or_value
                    | entity_id

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 compound_condition

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         : (entity_id | aggregation_function | scalar_function | entity_id "." attribute_id) ( "AS"i alias )?
alias               : CNAME

with_clause         : "with"i distinct_return? with_item ("," with_item)* with_where_clause?
with_item           : (entity_id | aggregation_function | scalar_function) ("AS"i alias)?
with_where_clause   : "where"i compound_condition

unwind_clause       : "unwind"i (entity_id | value_list) "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" | "SUM" | "AVG" | "MAX" | "MIN" | "COLLECT"
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
                     | 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

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          : (entity_id | aggregation_function) order_direction?

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


?entity_id          : CNAME
                    | CNAME "." CNAME

node_match          : "(" (CNAME)? (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

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


%import common.CNAME            -> CNAME
%import common.ESCAPED_STRING   -> 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))


@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


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.intersection(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__"}

    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]

                host_edges = match.mth.edge(*edge_mapping).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:
        entity_name, entity_attribute = data_path.split(".")
    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._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):
        """Queue edge creation."""
        self._mutations.append(Mutation(
            operation="CREATE_EDGE",
            target=(source, target),
            data={"type": rel_type, "properties": props}
        ))

    def queue_set(self, entity_path: str, value: Any):
        """Queue property update."""
        self._mutations.append(Mutation(
            operation="SET",
            target=entity_path,
            data={"value": value}
        ))

    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"]) -> Dict[str, List]:
        """Execute all queued mutations.

        Args:
            matches: List of Match objects from pattern matching

        Returns:
            Dictionary with mutation results
        """
        results = {
            "created_nodes": [],
            "created_edges": [],
            "updated": [],
            "removed": [],
            "deleted": [],
            "merged": []
        }

        # Separate mutations by type to ensure proper execution order:
        # 0. Merge first (upsert semantics)
        # 1. Create nodes (so edges can reference them)
        # 2. Create edges
        # 3. Set properties
        # 4. Remove properties/labels
        # 5. Delete (last, so we can reference things before deleting)
        merge_mutations = [m for m in self._mutations if m.operation == "MERGE"]
        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"]
        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
        for mutation in merge_mutations:
            merged = self._execute_merge(mutation, matches)
            results["merged"].extend(merged)

        for mutation in create_nodes:
            node_id = self._execute_create_node(mutation)
            results["created_nodes"].append(node_id)

        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)
            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) -> Any:
        """Create a new node in the graph."""
        # Generate unique node ID
        node_id = f"_gc_node_{self._node_counter}"
        self._node_counter += 1

        props = mutation.data["properties"].copy()
        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

        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 = []

        # 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()
        if mutation.data["type"]:
            props["__labels__"] = {mutation.data["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))

        return created

    def _execute_set(self, mutation: Mutation, matches: List["Match"]) -> List:
        """Update properties on matched nodes/edges."""
        entity_path = mutation.target
        value = mutation.data["value"]
        updated = []

        # Parse entity.property
        parts = entity_path.split(".")
        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:
                    self._host.nodes[node_id][prop_name] = value
                    updated.append((node_id, prop_name))

        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."""
        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:
            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}")

        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 = []

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


class NXCypherExecutor:
    def __init__(self, target_graph: nx.Graph, limit: Optional[int] = None, is_optional: bool = False):
        self._target_graph = target_graph
        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._original_return_requests = set()
        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

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

        # 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

            # Handle scalar functions: TOLOWER, TOUPPER, TRIM, SIZE, HEAD, TAIL, TYPE, LABELS, KEYS
            # Use original data_path for detection since _data_path_to_entity_name_attribute splits on "."
            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"]
            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 = []
                    for match in true_matches:
                        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 in attrs.keys() if not k.startswith("__")])
                            elif func_name == "TYPE":
                                # TYPE is for edges - handled elsewhere
                                ret.append(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

            if (
                entity_name not in motif_nodes
                and entity_name not in self._return_edges
                and entity_name not in self._paths
                and not data_path.startswith("CASE_")
                and not data_path.startswith("LISTCOMP_")
            ):
                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

            entity_name, entity_attribute = _data_path_to_entity_name_attribute(
                data_path
            )

            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
                        # TODO: this edge getting might not be correct in the case of MultiGraph
                        if x > 0:
                            path.append(
                                self._target_graph.get_edge_data(nodes[x - 1], node)
                            )

                        # 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:
                    host_edges = match.mth.edge(*edge_mapping).edges
                    ret.append(
                        get_edge_from_host(
                            self._target_graph,
                            host_edges,
                            entity_attribute,
                        )
                    )

            result[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):
        # 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[entity])

        # Collect data based on group keys
        grouped_data = {}
        for i in range(num_rows):
            group_tuple = tuple(results[key][i] for key in group_keys if key in results)
            if group_tuple not in grouped_data:
                grouped_data[group_tuple] = []
            if entity == "*":
                grouped_data[group_tuple].append(1)  # sentinel for row counting
            else:
                grouped_data[group_tuple].append(results[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
        aggregate_results = {}
        for group, data in grouped_data.items():
            collated_data = _collate_data(data, func)
            if func == "COUNT":
                if is_distinct:
                    aggregate_results[group] = len(set(v for v in data if v is not None))
                else:
                    aggregate_results[group] = len(collated_data)
            elif func == "SUM":
                if is_distinct:
                    collated_data = list(set(collated_data))
                aggregate_results[group] = sum(collated_data)
            elif func == "AVG":
                if is_distinct:
                    collated_data = list(set(collated_data))
                aggregate_results[group] = sum(collated_data) / len(collated_data)
            elif func == "MAX":
                aggregate_results[group] = max([(d if d is not None else -float("inf")) for d in collated_data])
            elif func == "MIN":
                aggregate_results[group] = min([(d if d is not None else float("inf")) for d in collated_data])
            elif func == "COLLECT":
                if is_distinct:
                    seen = set()
                    unique_data = []
                    for v in collated_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)
                    aggregate_results[group] = unique_data
                else:
                    aggregate_results[group] = collated_data
        # aggregate_results = [v for v in aggregate_results.values()]
        return aggregate_results

    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
        data_paths = [d for d in data_paths if d not in self._alias2entity]

        # If we have WITH results, use those for lookups
        if self._with_results is not None:
            results = {}
            for path in data_paths:
                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]
            # 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),
            )
        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:
                true_matches = self._get_true_matches()
                results["__star__"] = [1] * len(true_matches)

            # Collect entity names from aggregate functions, excluding "*"
            agg_entities = set(
                func_tuple[1] for func_tuple in self._aggregate_functions if func_tuple[1] != "*"
            )
            group_keys = [
                key
                for key in results.keys()
                if key != "__star__" and not any(key.endswith(ent) for ent in agg_entities)
            ]

            aggregated_results = {}
            aggregated_keys = None
            for func, entity, is_distinct in self._aggregate_functions:
                aggregated_data = self.aggregate(func, results, entity, group_keys, is_distinct)
                aggregated_values = list(aggregated_data.values())
                aggregated_keys = list(aggregated_data.keys())
                func_key = self._format_aggregation_key(func, entity, is_distinct)
                aggregated_results[func_key] = aggregated_values
                self._return_requests.append(func_key)

            # Handle COUNT(*) with no matches and no group keys -> return [0]
            if not group_keys and aggregated_keys is not None and len(aggregated_keys) == 0:
                for func, entity, is_distinct in self._aggregate_functions:
                    if func == "COUNT":
                        func_key = self._format_aggregation_key(func, entity, is_distinct)
                        aggregated_results[func_key] = [0]
                        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)

        # update the results with the given alias(es)
        results = {self._entity2alias.get(k, k): v for k, v in results.items()}

        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))

        # Only include keys that were asked for in `RETURN` in the final results
        results = {
            self._entity2alias.get(key, key): values
            for key, values in results.items()
            if key in self._return_requests
            or self._alias2entity.get(key, key) in self._return_requests
        }

        # 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:
            sort_lists = [
                (results[field], field, direction)
                for field, direction in self._order_by
            ]

            if sort_lists:
                # Generate a list of indices sorted by the specified fields
                indices = range(
                    len(next(iter(results.values())))
                )  # Safe because all lists are assumed to be of the same length
                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
                        indices = sorted(
                            indices,
                            key=lambda i: float("inf") if sort_list[i] is None else 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:
            assert self._order_by_attributes.issubset(self._return_requests), (
                "In a WITH/RETURN with DISTINCT or an aggregation, it is not possible to access variables declared before the WITH/RETURN"
            )

        # ordered dict to maintain the first occurrence of each unique tuple based on return requests
        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 return requests for this row
            row_key = tuple(
                results[key][i] for key in self._return_requests if key in results
            )

            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 self._return_requests}
        for row_key, index in unique_rows.items():
            for _, key in enumerate(self._return_requests):
                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 _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 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":
            return item / value if value != 0 else None

        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

            for my_motif, edge_hop_map, alias in self._edge_hop_motifs(self._motif):
                # 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}
                        )
                        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_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
                        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:
                    # Get base matches from required motif only
                    base_matches = []
                    for my_motif, edge_hop_map, 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 all optional nodes to None
                            if not pattern_extended:
                                for node in opt_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_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 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_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 _edge_hop_motifs(self, motif: nx.MultiDiGraph) -> Generator[Tuple[nx.Graph, HopAssignment, dict], None, None]:
        """Generate edge-hop-expanded motifs with node unification.

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

        Yields:
            Tuple[nx.Graph, HopAssignment, dict]:
                - my_motif: Unified and materialized motif
                - edge_hop_map: Original hop assignment (unchanged)
                - alias: Mapping from original nodes to representatives
        """
        hop_specs = generate_edge_hop_specs(motif)
        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, motif)

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

            # Step 3: Yield unified motif, original hop_assignment, and alias
            yield my_motif, hop_assignment, 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:
            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

        for item in clause:
            if item:
                alias = self._extract_alias(item)
                item = item.children[0] if isinstance(item, Tree) else item
                if isinstance(item, Tree) and item.data in ("aggregation_function", "aggregation_function_star", "aggregation_function_distinct"):
                    func, entity, is_distinct = self._parse_aggregation_token(item)
                    if alias:
                        self._executors[-1]._entity2alias[
                            self._executors[-1]._format_aggregation_key(func, entity, is_distinct)
                        ] = alias
                    if entity != "*":
                        self._executors[-1]._aggregation_attributes.add(entity)
                    self._executors[-1]._aggregate_functions.append((func, entity, is_distinct))
                elif isinstance(item, Tree) and item.data == "scalar_function":
                    # Handle scalar functions including CASE expressions
                    # The children[0] should be the result (e.g., CASE_xxxx for CASE expressions)
                    scalar_result = item.children[0]
                    if isinstance(scalar_result, str):
                        item = scalar_result
                    elif isinstance(scalar_result, Token):
                        item = str(scalar_result.value)
                    else:
                        item = str(scalar_result)
                    self._executors[-1]._original_return_requests.add(item)
                    if alias:
                        self._executors[-1]._entity2alias[item] = alias
                    self._executors[-1]._return_requests.append(item)
                else:
                    # Token is a subclass of str, but we want plain strings
                    if isinstance(item, Token):
                        item = str(item.value)
                    elif not isinstance(item, str):
                        item = str(item)
                    self._executors[-1]._original_return_requests.add(item)

                    if alias:
                        self._executors[-1]._entity2alias[item] = alias
                    self._executors[-1]._return_requests.append(item)

        self._executors[-1]._alias2entity.update({v: k for k, v in self._executors[-1]._entity2alias.items()})

    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)
            return func, "*", False

        if item.data == "aggregation_function_distinct":
            func = str(item.children[0].value)
            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)  # 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:
            if (
                isinstance(item.children[0], Tree)
                and item.children[0].data in ("aggregation_function", "aggregation_function_star", "aggregation_function_distinct")
            ):
                func, entity, is_distinct = self._parse_aggregation_token(item.children[0])
                field = self._executors[-1]._format_aggregation_key(func, entity, is_distinct)
                if entity != "*":
                    self._executors[-1]._order_by_attributes.add(entity)
            else:
                field = str(
                    item.children[0]
                )  # assuming the field name is the first child
                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 distinct_return(self, distinct):
        self._executors[-1]._distinct = True

    def limit_clause(self, limit):
        limit = int(limit[-1])
        self._executors[-1]._limit = limit

    def skip_clause(self, skip):
        skip = int(skip[-1])
        self._executors[-1]._skip = skip

    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]

        # Get current results from the executor
        # Set run_without_return temporarily to avoid validation errors
        original_return_requests = list(executor._return_requests)
        original_aggregate_functions = list(executor._aggregate_functions)
        original_aggregation_attributes = set(executor._aggregation_attributes)

        # Parse WITH items to determine what to keep
        with_items = []
        with_aliases = {}
        with_aggregates = []
        # distinct_return method already sets executor._distinct before this runs
        distinct = executor._distinct
        where_condition = 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":
                    # Parse the WITH item
                    alias = self._extract_alias(item)
                    inner = item.children[0] if item.children else None
                    if isinstance(inner, Tree) and inner.data in ("aggregation_function", "aggregation_function_star", "aggregation_function_distinct"):
                        func, entity, is_distinct = self._parse_aggregation_token(inner)
                        key = executor._format_aggregation_key(func, entity, is_distinct)
                        with_aggregates.append((func, entity, is_distinct))
                        if alias:
                            with_aliases[key] = alias
                        with_items.append(key)
                    else:
                        # Regular entity reference
                        if isinstance(inner, str):
                            entity = inner
                        elif isinstance(inner, Token):
                            entity = str(inner.value)
                        elif isinstance(inner, Tree) and inner.data == "entity_id":
                            entity = self.entity_id(inner.children)
                        else:
                            entity = str(inner) if inner else None

                        if entity:
                            with_items.append(entity)
                            if alias:
                                with_aliases[entity] = alias
                elif item.data == "with_where_clause":
                    # Handle WHERE within WITH
                    where_condition = self._build_where_condition(item.children[0])
            elif isinstance(item, Token):
                # Simple entity reference
                with_items.append(str(item.value))
            elif isinstance(item, str):
                with_items.append(item)

        # Configure executor to compute WITH items
        executor._return_requests = []
        executor._aggregate_functions = []
        executor._aggregation_attributes = set()
        executor._distinct = distinct

        for item in with_items:
            if item not in [executor._format_aggregation_key(f, e, d) for f, e, d in with_aggregates]:
                executor._return_requests.append(item)

        for func, entity, is_distinct in with_aggregates:
            executor._aggregate_functions.append((func, entity, is_distinct))
            if entity != "*":
                executor._aggregation_attributes.add(entity)

        # Get intermediate results
        intermediate_results = executor.returns(ignore_limit=True)

        # Apply WHERE filter if present
        if where_condition is not None:
            filtered_results = self._apply_with_where(intermediate_results, where_condition, with_aliases)
            intermediate_results = filtered_results

        # Apply aliases to result keys
        aliased_results = {}
        for key, values in intermediate_results.items():
            new_key = with_aliases.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  # Reset for next clause
        executor._matches = None  # Clear matches to force re-computation

        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]))

        # 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 =='type_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(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):
        self._executors[-1]._where_condition = where_clause[0]

    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 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" and not pattern.get("is_reference"):
                    self._mutation_executor.queue_create_node(
                        alias=pattern.get("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" and not item.get("is_reference"):
                            self._mutation_executor.queue_create_node(
                                alias=item.get("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", {})
                            )
        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 == "type_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 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 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):
        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) -> 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

        Returns:
            None

        """

        self._transformer = NXCypherTransformer(host_graph, limit)
        self._host_graph = host_graph

    @property
    def auto_node_jsondata_hints(self):
        return self._transformer._executors[0]._auto_node_jsondata_hints

    @auto_node_jsondata_hints.setter
    def auto_node_jsondata_hints(self, val: bool):
        """(EXPERIMENT) set auto hint"""
        self._transformer._executors[0]._auto_node_jsondata_hints = val

    @property
    def auto_where_hints(self):
        return self._transformer._executors[0]._auto_where_hints

    @auto_where_hints.setter
    def auto_where_hints(self, val: bool):
        """(EXPERIMENT) set auto hint"""
        self._transformer._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
        """
        self._transformer._executors[0].create_node_indices(keys)

    def run(self, cypher: str, hints: Optional[List[dict]] = None) -> Dict[str, List]:
        """
        Run a cypher query on the host graph.

        Arguments:
            cypher (str): The cypher query to run
            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.

        """
        self._transformer.transform(_NXCypherGrammar.parse(cypher), hints=hints)

        # Check if this is a UNION query
        if self._transformer._union_queries:
            return self._execute_union_query()

        # Check if there are mutations to execute
        mutation_executor = self._transformer._mutation_executor
        if mutation_executor._mutations:
            # Get matches if there are any (for SET/DELETE that reference matched nodes)
            executor = self._transformer._executors[0]

            # Check if there's a motif to match (MATCH clause present)
            if executor._motif.number_of_nodes() > 0:
                matches = list(executor._get_true_matches())
            else:
                # No MATCH clause, just CREATE - use empty matches
                matches = []

            # Execute mutations
            mutation_results = mutation_executor.execute(matches)

            # If there's a return clause, run it
            if executor._return_requests:
                return executor.returns()

            # Otherwise return mutation results
            return mutation_results

        return self._transformer._executors[0].returns()

    def _execute_union_query(self) -> Dict[str, List]:
        """Execute a UNION query by combining results from multiple queries."""
        # Get all query executors (union_queries + final current executor)
        all_executors = self._transformer._union_queries + [self._transformer._executors[0]]
        union_types = self._transformer._union_types

        # Execute each query and collect results
        all_results: List[Dict[str, List]] = []
        for executor in all_executors:
            result = executor.returns()
            all_results.append(result)

        if not all_results:
            return {}

        # Get column names from first result
        columns = list(all_results[0].keys())

        # Combine results
        combined: Dict[str, List] = {col: [] for col in columns}

        # For tracking unique rows in UNION (distinct)
        seen_rows: Set[tuple] = set()

        for i, result in enumerate(all_results):
            # Determine if this result should be distinct (based on preceding union type)
            # First result has no preceding union, subsequent results use union_types[i-1]
            is_distinct = i > 0 and union_types[i - 1] == 'distinct'

            # Get number of rows in this result
            if not result:
                continue
            first_col = list(result.keys())[0]
            num_rows = len(result[first_col])

            for row_idx in range(num_rows):
                # Build row tuple for uniqueness check
                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
                )

                # For UNION (distinct), skip duplicate rows
                if is_distinct and row_tuple in seen_rows:
                    continue

                seen_rows.add(row_tuple)

                # Add row to combined results
                for j, col in enumerate(columns):
                    combined[col].append(row_values[j])

        return combined
