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

"""
openCypher TCK (Technology Compliance Kit) test runner for nxCypher.

Parses Gherkin .feature files from the openCypher TCK and executes
each scenario against nxCypher, collecting pass/fail/skip results
for gap analysis.

Usage:
    uv run pytest nxcypher/test_tck.py -v
    uv run pytest nxcypher/test_tck.py -v --tb=no -q  # summary only
    uv run python nxcypher/test_tck.py  # standalone gap report
"""

import os
import re
import pytest
import networkx as nx
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional

from nxcypher import NXCypher

# ---------------------------------------------------------------------------
# Gherkin Parser
# ---------------------------------------------------------------------------

TCK_DIR = Path(__file__).parent.parent / "tck_features"


@dataclass
class Scenario:
    """A single TCK test scenario."""
    feature_file: str
    feature_name: str
    scenario_id: str
    scenario_name: str
    setup_queries: list = field(default_factory=list)
    query: str = ""
    expected_result: Optional[list] = None  # list of dicts
    expected_columns: list = field(default_factory=list)
    expect_empty: bool = False
    expect_error: Optional[str] = None
    expect_ordered: bool = False
    ignore_list_order: bool = False  # "ignoring element order for lists"
    side_effects: Optional[dict] = None
    given_graph: str = "empty"  # "empty" or "any"
    parameters: dict = field(default_factory=dict)  # Query parameters like {1: 0, 2: 1}


def _process_cypher_string_escapes(s: str) -> str:
    """Process Cypher string escape sequences in TCK expected values.

    Handles:
    - \\n, \\t, \\r, \\b, \\f - control characters
    - \\\\ - literal backslash
    - \\' - single quote
    - \\" - double quote
    - \\uXXXX - Unicode escape (4 hex digits)
    """
    result = []
    i = 0
    while i < len(s):
        if s[i] == '\\' and i + 1 < len(s):
            next_char = s[i + 1]
            if next_char == 'n':
                result.append('\n')
                i += 2
            elif next_char == 't':
                result.append('\t')
                i += 2
            elif next_char == 'r':
                result.append('\r')
                i += 2
            elif next_char == 'b':
                result.append('\b')
                i += 2
            elif next_char == 'f':
                result.append('\f')
                i += 2
            elif next_char == '\\':
                result.append('\\')
                i += 2
            elif next_char == "'":
                result.append("'")
                i += 2
            elif next_char == '"':
                result.append('"')
                i += 2
            elif next_char == 'u' and i + 5 < len(s):
                # Unicode escape: \uXXXX
                hex_digits = s[i+2:i+6]
                try:
                    code_point = int(hex_digits, 16)
                    result.append(chr(code_point))
                    i += 6
                except ValueError:
                    # Invalid hex, keep as-is
                    result.append(s[i])
                    i += 1
            else:
                # Unknown escape, keep backslash and next char
                result.append(s[i])
                i += 1
        else:
            result.append(s[i])
            i += 1
    return ''.join(result)


def parse_feature_file(filepath: str) -> list:
    """Parse a .feature file into a list of Scenario objects."""
    with open(filepath, "r", encoding="utf-8") as f:
        content = f.read()

    scenarios = []
    feature_name = ""
    rel_path = os.path.relpath(filepath, TCK_DIR)

    # Extract feature name
    m = re.search(r"Feature:\s*(.+)", content)
    if m:
        feature_name = m.group(1).strip()

    # Extract Background setup queries (applies to all scenarios in the feature)
    background_queries = []
    background_pattern = r'Background:\s*.*?And having executed:\s*"""\s*\n(.*?)"""'
    for m in re.finditer(background_pattern, content, re.DOTALL):
        background_queries.append(m.group(1).strip())

    # Split into scenario blocks (handle both Scenario: and Scenario Outline:)
    scenario_blocks = re.split(r"\n\s*Scenario(?:\s+Outline)?:", content)
    if len(scenario_blocks) < 2:
        return scenarios

    for block in scenario_blocks[1:]:
        # Reconstruct the prefix based on whether it's an Outline
        # We need to check if this block contains "Examples:" which indicates Scenario Outline
        # But for parsing purposes, both are handled the same way
        block = "Scenario:" + block
        scenario = _parse_scenario_block(block, rel_path, feature_name)
        if scenario:
            # Prepend background queries to each scenario's setup queries
            scenario.setup_queries = background_queries + scenario.setup_queries
            scenarios.append(scenario)

    return scenarios


def _parse_scenario_block(block: str, rel_path: str, feature_name: str) -> Optional[Scenario]:
    """Parse a single scenario block."""
    # Extract scenario name and ID
    m = re.match(r"Scenario:\s*\[(\d+)\]\s*(.+)", block.strip())
    if not m:
        m = re.match(r"Scenario:\s*(.+)", block.strip())
        if not m:
            return None
        scenario_id = "0"
        scenario_name = m.group(1).strip()
    else:
        scenario_id = m.group(1)
        scenario_name = m.group(2).strip()

    scenario = Scenario(
        feature_file=rel_path,
        feature_name=feature_name,
        scenario_id=scenario_id,
        scenario_name=scenario_name,
    )

    # Parse given graph type
    if "Given any graph" in block:
        scenario.given_graph = "any"
    elif "Given an empty graph" in block:
        scenario.given_graph = "empty"
    else:
        m_graph = re.search(r'Given the (\S+) graph', block)
        if m_graph:
            scenario.given_graph = m_graph.group(1)

    # Extract setup queries (And having executed: """...""")
    setup_pattern = r'And having executed:\s*"""\s*\n(.*?)"""'
    for m in re.finditer(setup_pattern, block, re.DOTALL):
        scenario.setup_queries.append(m.group(1).strip())

    # Extract parameters (And parameters are: | name | value | ...)
    param_pattern = r'And parameters are:\s*\n((?:\s*\|.*\n)+)'
    param_match = re.search(param_pattern, block)
    if param_match:
        param_table = param_match.group(1)
        param_lines = [l.strip() for l in param_table.strip().split("\n") if l.strip()]
        for line in param_lines:
            cells = [c.strip() for c in line.split("|") if c.strip()]
            if len(cells) >= 2:
                param_name = cells[0]
                param_value = _parse_cell_value(cells[1])
                scenario.parameters[param_name] = param_value

    # Extract the main query (When executing query: """...""")
    query_pattern = r'When executing query:\s*"""\s*\n(.*?)"""'
    m = re.search(query_pattern, block, re.DOTALL)
    if m:
        scenario.query = m.group(1).strip()

    # Check for expected errors
    error_pattern = r'Then a (\w+Error) should be raised'
    m = re.search(error_pattern, block)
    if m:
        scenario.expect_error = m.group(1)
        return scenario

    # Check for empty result
    if "Then the result should be empty" in block:
        scenario.expect_empty = True
        return scenario

    # Check ordering
    if "the result should be, in order:" in block:
        scenario.expect_ordered = True

    # Check for ignore list order
    if "ignoring element order for lists" in block:
        scenario.ignore_list_order = True

    # Extract expected result table
    result_pattern = r'Then the result should be.*?:\s*\n((?:\s*\|.*\n)+)'
    m = re.search(result_pattern, block)
    if m:
        table_text = m.group(1)
        scenario.expected_columns, scenario.expected_result = _parse_table(table_text)

    # Extract side effects
    side_pattern = r'the side effects should be:\s*\n((?:\s*\|.*\n)+)'
    m = re.search(side_pattern, block)
    if m:
        scenario.side_effects = _parse_side_effects(m.group(1))

    return scenario


def _parse_table(table_text: str) -> tuple:
    """Parse a Gherkin table into columns and list of dicts."""
    lines = [l.strip() for l in table_text.strip().split("\n") if l.strip()]
    if not lines:
        return [], []

    # Parse header
    header = [c.strip() for c in lines[0].split("|") if c.strip()]

    # Parse rows
    rows = []
    for line in lines[1:]:
        cells = [c.strip() for c in line.split("|") if c.strip()]
        if len(cells) == len(header):
            row = {}
            for col, val in zip(header, cells):
                row[col] = _parse_cell_value(val)
            rows.append(row)

    return header, rows


def _parse_cell_value(val: str):
    """Parse a TCK cell value into a Python value."""
    val = val.strip()
    if val == "null":
        return None
    if val == "true":
        return True
    if val == "false":
        return False
    # Quoted string - process Cypher escape sequences
    # TCK tables use double escaping: \\\\ represents \\ in Cypher, which represents \ in the value
    # So we need to process escape sequences twice
    if (val.startswith("'") and val.endswith("'")) or \
       (val.startswith('"') and val.endswith('"')):
        s = val[1:-1]
        # First pass: TCK table escaping (e.g., \\\\ -> \\)
        s = _process_cypher_string_escapes(s)
        # Second pass: Cypher string escaping (e.g., \\ -> \)
        s = _process_cypher_string_escapes(s)
        return s
    # Integer
    try:
        return int(val)
    except ValueError:
        pass
    # Float
    try:
        return float(val)
    except ValueError:
        pass
    # List like [1, 2, 3] or ['a', 'b']
    if val.startswith("[") and val.endswith("]"):
        # Relationship pattern like [:TYPE {prop: val}]
        if val.startswith("[:") or val.startswith("[:")  :
            return _parse_rel_pattern(val)
        return _parse_list_value(val)
    # Node pattern like (:Label {prop: val}) or ({prop: val})
    if val.startswith("(") and val.endswith(")"):
        return _parse_node_pattern(val)
    # Path pattern like <(:A)-[:T]->(:B)>
    if val.startswith("<") and val.endswith(">"):
        return _parse_path_pattern(val)
    # Map literal like {a: 1, b: 2}
    if val.startswith("{") and val.endswith("}"):
        return _parse_cypher_map(val)
    return val


@dataclass
class NodePattern:
    """Parsed TCK node pattern: (:Label1:Label2 {prop: val})"""
    labels: set
    properties: dict

    def matches(self, actual) -> bool:
        """Check if an actual nxCypher node dict matches this pattern."""
        if actual is None:
            return False
        if not isinstance(actual, dict):
            return False
        # Check labels - TCK expects exact match, not subset
        actual_labels = actual.get("__labels__", set())
        if isinstance(actual_labels, str):
            actual_labels = {actual_labels}
        elif isinstance(actual_labels, list):
            actual_labels = set(actual_labels)
        if self.labels != actual_labels:
            return False
        # Check properties
        for key, expected_val in self.properties.items():
            actual_val = actual.get(key)
            if not _values_equal(expected_val, actual_val):
                return False
        return True


@dataclass
class RelPattern:
    """Parsed TCK relationship pattern: [:TYPE {prop: val}]"""
    rel_type: Optional[str]
    properties: dict

    def matches(self, actual) -> bool:
        """Check if an actual nxCypher edge dict matches this pattern."""
        if actual is None:
            return False
        if not isinstance(actual, dict):
            return False
        # Check type
        if self.rel_type:
            actual_labels = actual.get("__labels__", set())
            if isinstance(actual_labels, str):
                actual_labels = {actual_labels}
            elif isinstance(actual_labels, set):
                pass
            else:
                actual_labels = set()
            if self.rel_type not in actual_labels:
                return False
        # Check properties
        for key, expected_val in self.properties.items():
            actual_val = actual.get(key)
            if not _values_equal(expected_val, actual_val):
                return False
        return True


@dataclass
class PathPattern:
    """Parsed TCK path pattern: <(:A)-[:T]->(:B)>"""
    elements: list  # List of NodePattern and RelPattern alternating

    def matches(self, actual) -> bool:
        """Check if an actual nxCypher path matches this pattern."""
        if actual is None:
            return False
        if not isinstance(actual, list):
            return False
        if len(actual) != len(self.elements):
            return False
        for expected, actual_elem in zip(self.elements, actual):
            if isinstance(expected, NodePattern):
                if not expected.matches(actual_elem):
                    return False
            elif isinstance(expected, RelPattern):
                if not expected.matches(actual_elem):
                    return False
            else:
                return False
        return True


def _parse_path_pattern(val: str):
    """Parse a TCK path pattern like <(:A)-[:T]->(:B)> into a PathPattern."""
    val = val.strip()
    if not (val.startswith("<") and val.endswith(">")):
        return val
    inner = val[1:-1].strip()
    if not inner:
        return PathPattern(elements=[])

    elements = []
    i = 0
    while i < len(inner):
        while i < len(inner) and inner[i] in " \t\n":
            i += 1
        if i >= len(inner):
            break
        if inner[i] == "(":
            depth = 1
            start = i
            i += 1
            while i < len(inner) and depth > 0:
                if inner[i] == "(":
                    depth += 1
                elif inner[i] == ")":
                    depth -= 1
                i += 1
            node_str = inner[start:i]
            node_pattern = _parse_node_pattern(node_str)
            if isinstance(node_pattern, NodePattern):
                elements.append(node_pattern)
        elif inner[i] in "-<":
            start = i
            if inner[i] == "<":
                i += 1
                if i < len(inner) and inner[i] == "-":
                    i += 1
            elif inner[i] == "-":
                i += 1
            rel_type = None
            properties = {}
            if i < len(inner) and inner[i] == "[":
                depth = 1
                rel_start = i
                i += 1
                while i < len(inner) and depth > 0:
                    if inner[i] == "[":
                        depth += 1
                    elif inner[i] == "]":
                        depth -= 1
                    i += 1
                rel_str = inner[rel_start:i]
                rel_pattern = _parse_rel_pattern(rel_str)
                if isinstance(rel_pattern, RelPattern):
                    rel_type = rel_pattern.rel_type
                    properties = rel_pattern.properties
            if i < len(inner) and inner[i] == "-":
                i += 1
            if i < len(inner) and inner[i] == ">":
                i += 1
            elements.append(RelPattern(rel_type=rel_type, properties=properties))
        else:
            i += 1
    return PathPattern(elements=elements)


def _parse_node_pattern(val: str):
    """Parse a TCK node pattern like (:Label {prop: val}) into a NodePattern."""
    val = val.strip()
    if not (val.startswith("(") and val.endswith(")")):
        return val
    inner = val[1:-1].strip()

    labels = set()
    properties = {}

    # Extract labels (e.g., :A:B:C)
    label_match = re.match(r'^((?::\w+)+)', inner)
    if label_match:
        label_str = label_match.group(1)
        labels = {l for l in label_str.split(":") if l}
        inner = inner[label_match.end():].strip()

    # Extract properties {key: val, ...}
    if inner.startswith("{") and inner.endswith("}"):
        properties = _parse_cypher_map(inner)

    return NodePattern(labels=labels, properties=properties)


def _parse_rel_pattern(val: str):
    """Parse a TCK relationship pattern like [:TYPE {prop: val}] into a RelPattern."""
    val = val.strip()
    if not (val.startswith("[") and val.endswith("]")):
        return val
    inner = val[1:-1].strip()

    rel_type = None
    properties = {}

    # Extract type (e.g., :KNOWS)
    type_match = re.match(r'^:(\w+)', inner)
    if type_match:
        rel_type = type_match.group(1)
        inner = inner[type_match.end():].strip()

    # Extract properties
    if inner.startswith("{") and inner.endswith("}"):
        properties = _parse_cypher_map(inner)

    return RelPattern(rel_type=rel_type, properties=properties)


def _parse_cypher_map(map_str: str) -> dict:
    """Parse a Cypher-style map literal {key: val, key2: val2} into a Python dict."""
    map_str = map_str.strip()
    if map_str.startswith("{"):
        map_str = map_str[1:]
    if map_str.endswith("}"):
        map_str = map_str[:-1]
    map_str = map_str.strip()
    if not map_str:
        return {}

    result = {}
    # Simple tokenizer that respects nested brackets and quotes
    i = 0
    while i < len(map_str):
        # Skip whitespace
        while i < len(map_str) and map_str[i] in " \t\n":
            i += 1
        if i >= len(map_str):
            break

        # Read key
        key_start = i
        while i < len(map_str) and map_str[i] not in ":":
            i += 1
        key = map_str[key_start:i].strip()
        if not key:
            break
        i += 1  # skip ':'

        # Skip whitespace
        while i < len(map_str) and map_str[i] in " \t\n":
            i += 1

        # Read value (respecting nesting)
        val_str, i = _read_value_token(map_str, i)
        result[key] = _parse_cell_value(val_str.strip())

        # Skip comma
        while i < len(map_str) and map_str[i] in " \t\n,":
            i += 1

    return result


def _read_value_token(s: str, start: int) -> tuple:
    """Read a single value token from a Cypher map, respecting brackets and quotes."""
    i = start
    if i >= len(s):
        return "", i

    # Quoted string
    if s[i] in "'\"":
        quote = s[i]
        i += 1
        while i < len(s) and s[i] != quote:
            if s[i] == "\\" and i + 1 < len(s):
                i += 2
            else:
                i += 1
        if i < len(s):
            i += 1  # skip closing quote
        return s[start:i], i

    # Brackets
    if s[i] in "[{(":
        open_char = s[i]
        close_char = {"[": "]", "{": "}", "(": ")"}[open_char]
        depth = 1
        i += 1
        while i < len(s) and depth > 0:
            if s[i] == open_char:
                depth += 1
            elif s[i] == close_char:
                depth -= 1
            elif s[i] in "'\"":
                quote = s[i]
                i += 1
                while i < len(s) and s[i] != quote:
                    i += 1
            i += 1
        return s[start:i], i

    # Bare token (number, null, true, false, identifier, or function call)
    # Include parentheses in bare tokens for function-like syntax: date({...})
    while i < len(s) and s[i] not in ", \t\n":
        # Handle nested brackets within bare tokens (e.g. date({year: 2020}))
        if s[i] in "({[":
            oc = s[i]
            cc = {"(": ")", "{": "}", "[": "]"}[oc]
            d = 1
            i += 1
            while i < len(s) and d > 0:
                if s[i] == oc:
                    d += 1
                elif s[i] == cc:
                    d -= 1
                elif s[i] in "'\"":
                    q = s[i]
                    i += 1
                    while i < len(s) and s[i] != q:
                        i += 1
                i += 1
        else:
            i += 1
    return s[start:i], i


def _parse_list_value(val: str):
    """Parse a TCK list value like [1, 2, 3] into a Python list."""
    val = val.strip()
    if not (val.startswith("[") and val.endswith("]")):
        return val
    inner = val[1:-1].strip()
    if not inner:
        return []

    # Tokenize respecting nesting
    items = []
    i = 0
    while i < len(inner):
        while i < len(inner) and inner[i] in " \t\n":
            i += 1
        if i >= len(inner):
            break
        old_i = i
        token, i = _read_value_token(inner, i)
        if i == old_i:
            # No progress — skip this character to avoid infinite loop
            i += 1
            continue
        if token.strip():
            items.append(_parse_cell_value(token.strip()))
        # Skip comma and whitespace
        while i < len(inner) and inner[i] in " \t\n,":
            i += 1

    return items


def _normalize_for_comparison(val):
    """Normalize string representations of boolean/null values for comparison.

    Returns:
        True for string "true"
        False for string "false"
        None for string "null"
        The value unchanged otherwise
    """
    if isinstance(val, str):
        if val == "true":
            return True
        if val == "false":
            return False
        if val == "null":
            return None
    return val


def _normalize_column_name(col: str) -> str:
    """Normalize a column name for comparison.

    Handles:
    - Case differences: REVERSE vs reverse
    - Quote differences: reverse('x') vs REVERSE(x)
    - Whitespace differences
    - DISTINCT keyword inside aggregations
    - Operator spacing: count(a)+3 vs count(a) + 3
    - Map/object literal spacing: {a: 1, b: 'foo'} vs {a:1,b:'foo'}

    Returns a canonical form: lowercase, normalized whitespace, no quotes in args.
    """
    import re

    # Normalize whitespace first (collapse multiple spaces to single space)
    col = " ".join(col.split())

    # Normalize whitespace around arithmetic and comparison operators
    # This ensures count(a)+3 and count(a) + 3 both normalize to the same form
    operators = ['+', '-', '*', '/', '%', '=', '<', '>', '<=', '>=', '<>']
    for op in operators:
        # Add spaces around operators
        col = re.sub(rf'\s*{re.escape(op)}\s*', f' {op} ', col)
    # Clean up multiple spaces again
    col = " ".join(col.split())

    # Normalize map/object literal spacing: remove spaces around : and ,
    # This handles {a: 1, b: 'foo'} vs {a:1,b:'foo'}
    col = re.sub(r'\s*:\s*', ':', col)
    col = re.sub(r'\s*,\s*', ',', col)

    # Check if it's a function call pattern like func(args)
    func_match = re.match(r"^(\w+)\((.+)\)$", col)
    if func_match:
        func_name = func_match.group(1).lower()
        args = func_match.group(2).strip()

        # Handle DISTINCT keyword (case-insensitive)
        distinct_prefix = ""
        if args.lower().startswith("distinct "):
            distinct_prefix = "distinct "
            args = args[9:].strip()  # Remove 'distinct ' (9 chars)

        # Strip quotes from arguments
        normalized_args = []
        for arg in _split_func_args(args):
            arg = arg.strip()
            # Strip outer quotes
            if (arg.startswith("'") and arg.endswith("'")) or (
                arg.startswith('"') and arg.endswith('"')
            ):
                arg = arg[1:-1]
            # Recursively normalize nested function calls
            arg = _normalize_column_name(arg) if "(" in arg else arg.lower()
            normalized_args.append(arg)
        return f"{func_name}({distinct_prefix}{','.join(normalized_args)})"
    return col.lower()


def _split_func_args(args_str: str) -> list:
    """Split function arguments respecting nested parentheses and quotes."""
    args = []
    current = []
    depth = 0
    in_quote = False
    quote_char = ''

    for ch in args_str:
        if in_quote:
            current.append(ch)
            if ch == quote_char:
                in_quote = False
        elif ch in ("'", '"'):
            in_quote = True
            quote_char = ch
            current.append(ch)
        elif ch == '(':
            depth += 1
            current.append(ch)
        elif ch == ')':
            depth -= 1
            current.append(ch)
        elif ch == ',' and depth == 0:
            args.append(''.join(current))
            current = []
        else:
            current.append(ch)

    if current:
        args.append(''.join(current))

    return args


def _values_equal(expected, actual, ignore_list_order: bool = False) -> bool:
    """Compare a parsed TCK expected value with an actual nxCypher value."""
    # Normalize string boolean/null values before comparison
    expected = _normalize_for_comparison(expected)
    actual = _normalize_for_comparison(actual)

    # None/null
    if expected is None and actual is None:
        return True
    if expected is None or actual is None:
        return False

    # NodePattern matching
    if isinstance(expected, NodePattern):
        return expected.matches(actual)

    # RelPattern matching
    if isinstance(expected, RelPattern):
        return expected.matches(actual)

    # PathPattern matching
    if isinstance(expected, PathPattern):
        return expected.matches(actual)

    # List comparison (recursive normalization via _values_equal)
    if isinstance(expected, list) and isinstance(actual, list):
        if len(expected) != len(actual):
            return False
        if ignore_list_order:
            # Compare sets of elements for unordered list comparison
            # Use a matching approach for complex elements
            unmatched_expected = list(expected)
            for a in actual:
                found = False
                for i, e in enumerate(unmatched_expected):
                    if _values_equal(e, a, ignore_list_order):
                        unmatched_expected.pop(i)
                        found = True
                        break
                if not found:
                    return False
            return len(unmatched_expected) == 0
        return all(_values_equal(e, a, ignore_list_order) for e, a in zip(expected, actual))

    # Dict comparison (recursive normalization via _values_equal)
    if isinstance(expected, dict) and isinstance(actual, dict):
        if set(expected.keys()) != set(actual.keys()):
            return False
        return all(_values_equal(expected[k], actual[k], ignore_list_order) for k in expected)

    # Numeric comparison (int/float interchangeable)
    if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
        if isinstance(expected, float) or isinstance(actual, float):
            return abs(float(expected) - float(actual)) < 1e-9
        return expected == actual

    # String comparison
    if isinstance(expected, str) and isinstance(actual, str):
        return expected == actual

    # Type mismatch — try string coercion as last resort
    return str(expected) == str(actual)


def _parse_side_effects(table_text: str) -> dict:
    """Parse side effects table."""
    effects = {}
    lines = [l.strip() for l in table_text.strip().split("\n") if l.strip()]
    for line in lines:
        cells = [c.strip() for c in line.split("|") if c.strip()]
        if len(cells) == 2:
            effects[cells[0]] = int(cells[1])
    return effects


# ---------------------------------------------------------------------------
# Named Graph Fixtures
# ---------------------------------------------------------------------------

def _build_binary_tree_1() -> nx.DiGraph:
    """Build the binary-tree-1 TCK fixture graph."""
    G = nx.DiGraph()
    # Nodes
    for name, label in [("a", "A")] + [(n, "X") for n in [
        "b1", "b2", "b3", "b4", "c11", "c12", "c21", "c22", "c31", "c32", "c41", "c42"
    ]]:
        G.add_node(name, name=name, __labels__={label})
    # Edges
    G.add_edge("a", "b1", __labels__="KNOWS")
    G.add_edge("a", "b2", __labels__="KNOWS")
    G.add_edge("a", "b3", __labels__="FOLLOWS")
    G.add_edge("a", "b4", __labels__="FOLLOWS")
    for src, tgt in [("b1","c11"),("b1","c12"),("b2","c21"),("b2","c22"),
                     ("b3","c31"),("b3","c32"),("b4","c41"),("b4","c42")]:
        G.add_edge(src, tgt, __labels__="FRIEND")
    for src, tgt in [("b1","b2"),("b2","b3"),("b3","b4"),("b4","b1")]:
        G.add_edge(src, tgt, __labels__="FRIEND")
    return G


def _build_binary_tree_2() -> nx.DiGraph:
    """Build the binary-tree-2 TCK fixture graph (alternating X/Y labels on leaves)."""
    G = nx.DiGraph()
    G.add_node("a", name="a", __labels__={"A"})
    for name in ["b1", "b2", "b3", "b4"]:
        G.add_node(name, name=name, __labels__={"X"})
    # Alternating X/Y labels on leaves
    for name, label in [("c11","X"),("c12","Y"),("c21","X"),("c22","Y"),
                        ("c31","X"),("c32","Y"),("c41","X"),("c42","Y")]:
        G.add_node(name, name=name, __labels__={label})
    # Same edge structure as binary-tree-1
    G.add_edge("a", "b1", __labels__="KNOWS")
    G.add_edge("a", "b2", __labels__="KNOWS")
    G.add_edge("a", "b3", __labels__="FOLLOWS")
    G.add_edge("a", "b4", __labels__="FOLLOWS")
    for src, tgt in [("b1","c11"),("b1","c12"),("b2","c21"),("b2","c22"),
                     ("b3","c31"),("b3","c32"),("b4","c41"),("b4","c42")]:
        G.add_edge(src, tgt, __labels__="FRIEND")
    for src, tgt in [("b1","b2"),("b2","b3"),("b3","b4"),("b4","b1")]:
        G.add_edge(src, tgt, __labels__="FRIEND")
    return G


NAMED_GRAPHS = {
    "binary-tree-1": _build_binary_tree_1,
    "binary-tree-2": _build_binary_tree_2,
}


# ---------------------------------------------------------------------------
# TCK Executor
# ---------------------------------------------------------------------------

_CYPHER_STMT_KW = re.compile(
    r"^\s*(?:CREATE|MATCH|OPTIONAL\s+MATCH|UNWIND|WITH|FOREACH|CALL|MERGE|DELETE|DETACH|SET|REMOVE|RETURN)\b",
    re.IGNORECASE,
)

# Write-only clause keywords that start independent statements
_WRITE_CLAUSE_KW = re.compile(
    r"^\s*(?:CREATE|MERGE|DELETE|DETACH\s+DELETE)\b",
    re.IGNORECASE,
)

# Read clause keywords that start compound statements (MATCH...CREATE is one statement)
_READ_CLAUSE_KW = re.compile(
    r"^\s*(?:MATCH|OPTIONAL\s+MATCH|UNWIND|WITH|CALL|FOREACH)\b",
    re.IGNORECASE,
)


def _strip_cypher_comments(block: str) -> str:
    """Strip ``//`` line comments from a Cypher block, respecting string literals."""
    lines = block.split("\n")
    cleaned: list[str] = []
    for line in lines:
        in_str = False
        quote_char = ""
        result = []
        i = 0
        while i < len(line):
            ch = line[i]
            if in_str:
                result.append(ch)
                if ch == quote_char:
                    in_str = False
            elif ch in ("'", '"'):
                in_str = True
                quote_char = ch
                result.append(ch)
            elif ch == "/" and i + 1 < len(line) and line[i + 1] == "/":
                break  # rest of line is comment
            else:
                result.append(ch)
            i += 1
        cleaned.append("".join(result))
    return "\n".join(cleaned)


def _split_cypher_statements(block: str) -> list[str]:
    """Split a multi-statement Cypher block into individual statements.

    TCK setup blocks may contain multiple top-level statements, e.g.::

        CREATE (a:A)
        CREATE (b:B)
        CREATE (a)-[:R]->(b)

    Rules:
    - ``CREATE`` after ``CREATE`` = two separate statements
    - ``MATCH ... CREATE`` = one compound statement (read+write)
    - ``UNWIND ... CREATE`` = one compound statement
    - ``CREATE`` after ``MATCH`` continues the same statement

    Also strips ``//`` comments.
    """
    text = _strip_cypher_comments(block)

    stmts: list[str] = []
    current: list[str] = []
    current_starts_with_read = False

    for line in text.split("\n"):
        if _CYPHER_STMT_KW.match(line) and current:
            # Decide whether this line starts a NEW statement or continues the current one.
            # A write clause (CREATE/MERGE/DELETE) starts a new statement only if the
            # current accumulated statement is also a write-only statement.
            # A read clause (MATCH/UNWIND/WITH) always starts a new statement.
            if _READ_CLAUSE_KW.match(line):
                # New read clause = always a new statement
                stmt = "\n".join(current).strip()
                if stmt:
                    stmts.append(stmt)
                current = [line]
                current_starts_with_read = True
            elif _WRITE_CLAUSE_KW.match(line):
                # Write clause after any clause = continuation
                # This handles CREATE after CREATE with shared variable bindings
                # (e.g., CREATE (a) CREATE (a)-[:R]->(b) where a is reused)
                current.append(line)
            else:
                # Other clause (SET, REMOVE, RETURN, etc.) = continuation
                current.append(line)
        else:
            if not current and _CYPHER_STMT_KW.match(line):
                current_starts_with_read = bool(_READ_CLAUSE_KW.match(line))
            current.append(line)

    if current:
        stmt = "\n".join(current).strip()
        if stmt:
            stmts.append(stmt)

    return stmts if stmts else [block.strip()]


def execute_scenario(scenario: Scenario) -> dict:
    """
    Execute a single TCK scenario against nxCypher.

    Returns dict with:
        status: "pass" | "fail" | "error" | "skip"
        message: description of result
    """
    # Create graph based on fixture type
    # Use MultiDiGraph to support multiple edges between same nodes (Cypher allows this)
    if scenario.given_graph in NAMED_GRAPHS:
        G = NAMED_GRAPHS[scenario.given_graph]()
    else:
        G = nx.MultiDiGraph()

    # Run setup queries
    for setup_q in scenario.setup_queries:
        for stmt in _split_cypher_statements(setup_q):
            try:
                NXCypher(G).run(stmt)
            except Exception as e:
                return {"status": "error", "message": f"Setup failed: {e}"}

    # Substitute parameters in query
    query = scenario.query
    if scenario.parameters:
        for param_name, param_value in scenario.parameters.items():
            # Parameters are referenced as $name in Cypher
            if isinstance(param_value, str):
                replacement = f"'{param_value}'"
            elif param_value is None:
                replacement = "null"
            elif isinstance(param_value, bool):
                replacement = "true" if param_value else "false"
            else:
                replacement = str(param_value)
            query = query.replace(f"${param_name}", replacement)

    # Run the main query
    try:
        result = NXCypher(G).run(query)
    except Exception as e:
        if scenario.expect_error:
            return {"status": "pass", "message": f"Expected error raised: {e}"}
        return {"status": "error", "message": f"{type(e).__name__}: {e}"}

    # If we expected an error but didn't get one
    if scenario.expect_error:
        return {"status": "fail", "message": f"Expected {scenario.expect_error} but query succeeded"}

    # Check empty result
    if scenario.expect_empty:
        if not result or all(len(v) == 0 for v in result.values()):
            return {"status": "pass", "message": "Empty result as expected"}
        return {"status": "fail", "message": f"Expected empty result, got: {result}"}

    # If no expected result defined, just check it didn't crash
    if scenario.expected_result is None:
        return {"status": "pass", "message": "Query executed successfully (no result assertion)"}

    # Compare results
    return _compare_results(result, scenario.expected_columns, scenario.expected_result,
                           scenario.expect_ordered, scenario.ignore_list_order)


def _compare_results(actual: dict, expected_cols: list, expected_rows: list, ordered: bool,
                    ignore_list_order: bool = False) -> dict:
    """Compare actual nxCypher results with expected TCK results."""
    # Check column count
    if not expected_cols:
        if not expected_rows:
            if not actual or all(len(v) == 0 for v in actual.values()):
                return {"status": "pass", "message": "Empty result as expected"}
        return {"status": "pass", "message": "No columns to check"}

    # Check row count
    actual_len = max((len(v) for v in actual.values()), default=0) if actual else 0
    expected_len = len(expected_rows)

    if actual_len != expected_len:
        return {
            "status": "fail",
            "message": f"Row count mismatch: expected {expected_len}, got {actual_len}"
        }

    if actual_len == 0:
        return {"status": "pass", "message": "Empty result as expected"}

    # Build a mapping of normalized column names to actual column names
    actual_col_map = {}
    for acol in actual:
        norm = _normalize_column_name(acol)
        actual_col_map[norm] = acol

    # Build actual rows as list of dicts for comparison
    actual_rows = []
    for i in range(actual_len):
        row = {}
        for col in expected_cols:
            val = None
            # Try exact column name match first
            if col in actual:
                val = actual[col][i] if i < len(actual[col]) else None
            else:
                # Try normalized column name match (handles case and quote differences)
                norm_col = _normalize_column_name(col)
                if norm_col in actual_col_map:
                    acol = actual_col_map[norm_col]
                    val = actual[acol][i] if i < len(actual[acol]) else None
                else:
                    # Fallback: try case-insensitive match
                    for acol in actual:
                        if acol.lower() == col.lower():
                            val = actual[acol][i] if i < len(actual[acol]) else None
                            break
            row[col] = val
        actual_rows.append(row)

    # Compare rows
    if ordered:
        # Ordered comparison — rows must match in sequence
        for i, (exp_row, act_row) in enumerate(zip(expected_rows, actual_rows)):
            for col in expected_cols:
                if not _values_equal(exp_row.get(col), act_row.get(col), ignore_list_order):
                    return {
                        "status": "fail",
                        "message": (
                            f"Row {i} col '{col}' mismatch: "
                            f"expected {exp_row.get(col)!r}, got {act_row.get(col)!r}"
                        )
                    }
    else:
        # Unordered comparison — find a matching permutation
        unmatched_expected = list(range(len(expected_rows)))
        unmatched_actual = list(range(len(actual_rows)))

        for ei in list(unmatched_expected):
            exp_row = expected_rows[ei]
            for ai in list(unmatched_actual):
                act_row = actual_rows[ai]
                if _row_matches(exp_row, act_row, expected_cols, ignore_list_order):
                    unmatched_expected.remove(ei)
                    unmatched_actual.remove(ai)
                    break

        if unmatched_expected:
            ei = unmatched_expected[0]
            exp_row = expected_rows[ei]
            return {
                "status": "fail",
                "message": (
                    f"No match for expected row {ei}: {exp_row}. "
                    f"Actual rows: {actual_rows}"
                )
            }

    return {"status": "pass", "message": f"All {actual_len} rows match"}


def _row_matches(expected_row: dict, actual_row: dict, cols: list, ignore_list_order: bool = False) -> bool:
    """Check if two rows match on all columns."""
    for col in cols:
        if not _values_equal(expected_row.get(col), actual_row.get(col), ignore_list_order):
            return False
    return True


# ---------------------------------------------------------------------------
# Collect all scenarios
# ---------------------------------------------------------------------------

def collect_all_scenarios() -> list:
    """Collect all TCK scenarios from feature files."""
    scenarios = []
    for feature_file in sorted(TCK_DIR.rglob("*.feature")):
        scenarios.extend(parse_feature_file(str(feature_file)))
    return scenarios


ALL_SCENARIOS = collect_all_scenarios()


# ---------------------------------------------------------------------------
# Unsupported Features (xfail categories)
# ---------------------------------------------------------------------------

# Features that are fundamentally unsupported by NetworkX and will never be
# implemented. These are marked as xfail so they don't count as failures.
#
# Categories are matched against the feature file path (e.g., "clauses/call/...")
# The subcategory is extracted from the path as the second component.

UNSUPPORTED_CATEGORIES = {
    # Stored procedures (CALL clause) - Neo4j-specific, not supported
    "call": "Stored procedures (CALL clause) not supported by NetworkX",
    # Temporal types - Date/Time/Duration types are Neo4j-specific
    "temporal": "Temporal types (Date/Time/Duration) not supported by NetworkX",
}

# Specific scenario patterns that are unsupported (matched against feature_file + scenario_name)
# Use this for individual scenarios that can't be supported even if the category is partially supported
UNSUPPORTED_SCENARIOS: dict = {
    # CREATE with SKIP/LIMIT - mutations should execute before SKIP/LIMIT (complex edge case)
    ("clauses/create/Create6.feature", "Skipping and limiting to a few results after creating nodes does not affect the result set nor the side effects"): "CREATE/UNWIND/SKIP/LIMIT interaction not fully supported",
    ("clauses/create/Create6.feature", "Skipping zero result and limiting to all results after creating nodes does not affect the result set nor the side effects"): "CREATE/UNWIND/SKIP/LIMIT interaction not fully supported",
}


def _is_unsupported_feature(scenario) -> tuple:
    """
    Check if a scenario tests a feature that is fundamentally unsupported.

    Returns:
        (is_unsupported: bool, reason: str)
    """
    # Extract subcategory from feature_file path (e.g., "clauses/call/Call1.feature" -> "call")
    parts = scenario.feature_file.split("/")
    subcategory = parts[1] if len(parts) >= 2 else ""

    # Check if the subcategory is in the unsupported list
    if subcategory in UNSUPPORTED_CATEGORIES:
        return True, UNSUPPORTED_CATEGORIES[subcategory]

    # Check if this specific scenario is unsupported
    scenario_key = (scenario.feature_file, scenario.scenario_name)
    if scenario_key in UNSUPPORTED_SCENARIOS:
        return True, UNSUPPORTED_SCENARIOS[scenario_key]

    # NOTE: SyntaxError tests are NO LONGER marked as xfail
    # With strict validation enabled (default), nxCypher now properly validates
    # syntax and raises CypherValidationError for invalid queries, matching Neo4j behavior.
    # These tests should now PASS (error raised as expected).
    #
    # if scenario.expect_error == "SyntaxError":
    #     return True, "Parser validation not as strict as Neo4j"

    return False, ""


# ---------------------------------------------------------------------------
# Pytest parametrized tests
# ---------------------------------------------------------------------------


def _scenario_id(scenario):
    """Generate a readable test ID."""
    base = os.path.splitext(scenario.feature_file)[0].replace("/", "::")
    return f"{base}::[{scenario.scenario_id}]"


def _get_scenario_marks(scenario):
    """Get pytest marks for a scenario based on unsupported features."""
    is_unsupported, reason = _is_unsupported_feature(scenario)
    if is_unsupported:
        return pytest.param(scenario, marks=pytest.mark.xfail(reason=reason, strict=False))
    return scenario


@pytest.mark.parametrize(
    "scenario",
    [_get_scenario_marks(s) for s in ALL_SCENARIOS],
    ids=[_scenario_id(s) for s in ALL_SCENARIOS],
)
def test_tck_scenario(scenario):
    """Run a single openCypher TCK scenario."""
    result = execute_scenario(scenario)

    if result["status"] == "error":
        pytest.xfail(f"NOT IMPLEMENTED: {result['message']}")
    elif result["status"] == "skip":
        pytest.skip(result["message"])
    elif result["status"] == "fail":
        pytest.fail(f"{result['message']}")
    # pass is the default


# ---------------------------------------------------------------------------
# Standalone gap analysis report
# ---------------------------------------------------------------------------

def generate_report():
    """Generate a gap analysis report."""
    scenarios = collect_all_scenarios()
    results = {"pass": [], "fail": [], "error": [], "skip": []}

    for s in scenarios:
        r = execute_scenario(s)
        r["scenario"] = f"{s.feature_file} [{s.scenario_id}] {s.scenario_name}"
        r["feature"] = s.feature_name
        r["category"] = s.feature_file.split("/")[0] if "/" in s.feature_file else "other"
        r["subcategory"] = s.feature_file.split("/")[1] if s.feature_file.count("/") >= 1 else "other"
        results[r["status"]].append(r)

    total = len(scenarios)
    passed = len(results["pass"])
    failed = len(results["fail"])
    errors = len(results["error"])
    skipped = len(results["skip"])

    print("=" * 70)
    print("openCypher TCK Gap Analysis Report for nxCypher")
    print("=" * 70)
    print(f"\nTotal scenarios: {total}")
    print(f"  PASS:  {passed:4d} ({100*passed/total:.1f}%)")
    print(f"  FAIL:  {failed:4d} ({100*failed/total:.1f}%)")
    print(f"  ERROR: {errors:4d} ({100*errors/total:.1f}%) - not implemented / parse errors")
    print(f"  SKIP:  {skipped:4d} ({100*skipped/total:.1f}%)")

    # Group errors by subcategory
    print(f"\n{'='*70}")
    print("Errors by category (not implemented features):")
    print(f"{'='*70}")
    error_cats = {}
    for r in results["error"]:
        key = r["subcategory"]
        if key not in error_cats:
            error_cats[key] = []
        error_cats[key].append(r)

    for cat in sorted(error_cats.keys()):
        items = error_cats[cat]
        print(f"\n  {cat} ({len(items)} errors):")
        for item in items[:5]:
            msg = item["message"][:80]
            print(f"    - [{item['scenario'].split(']')[0].split('[')[-1]}] {msg}")
        if len(items) > 5:
            print(f"    ... and {len(items)-5} more")

    # Group failures by subcategory
    if results["fail"]:
        print(f"\n{'='*70}")
        print("Failures by category (wrong results):")
        print(f"{'='*70}")
        fail_cats = {}
        for r in results["fail"]:
            key = r["subcategory"]
            if key not in fail_cats:
                fail_cats[key] = []
            fail_cats[key].append(r)

        for cat in sorted(fail_cats.keys()):
            items = fail_cats[cat]
            print(f"\n  {cat} ({len(items)} failures):")
            for item in items[:5]:
                msg = item["message"][:80]
                print(f"    - [{item['scenario'].split(']')[0].split('[')[-1]}] {msg}")
            if len(items) > 5:
                print(f"    ... and {len(items)-5} more")

    # Summary by category
    print(f"\n{'='*70}")
    print("Summary by feature area:")
    print(f"{'='*70}")
    all_cats = {}
    for status, items in results.items():
        for item in items:
            key = item["subcategory"]
            if key not in all_cats:
                all_cats[key] = {"pass": 0, "fail": 0, "error": 0, "skip": 0, "total": 0}
            all_cats[key][status] += 1
            all_cats[key]["total"] += 1

    print(f"\n  {'Category':<25} {'Total':>5} {'Pass':>5} {'Fail':>5} {'Error':>5} {'Rate':>6}")
    print(f"  {'-'*25} {'-'*5} {'-'*5} {'-'*5} {'-'*5} {'-'*6}")
    for cat in sorted(all_cats.keys()):
        c = all_cats[cat]
        rate = 100 * c["pass"] / c["total"] if c["total"] else 0
        print(f"  {cat:<25} {c['total']:>5} {c['pass']:>5} {c['fail']:>5} {c['error']:>5} {rate:>5.1f}%")

    print(f"\n  {'TOTAL':<25} {total:>5} {passed:>5} {failed:>5} {errors:>5} {100*passed/total:>5.1f}%")


if __name__ == "__main__":
    generate_report()
