"""
Cucumber/behave step definitions for openCypher TCK tests with nxCypher.

This implements the official openCypher TCK test steps for running against nxCypher.
"""

import re
import sys
from behave import given, when, then, register_type
import networkx as nx
from nxcypher import NXCypher

# Increase recursion limit for deeply nested structures (TCK tests have 40-level nesting)
sys.setrecursionlimit(3000)


def _process_escapes(s: str) -> str:
    """Process escape sequences in a string value from TCK table.

    Handles:
    - \\\\ - literal backslash
    - \\' - single quote
    - \\" - double quote
    - \\n, \\t, \\r, \\b, \\f - control characters
    - \\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 == '/':
                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:
                    result.append(s[i])
                    i += 1
            else:
                result.append(s[i])
                i += 1
        else:
            result.append(s[i])
            i += 1
    return ''.join(result)


def _split_by_comma(s: str) -> list:
    """Split a string by commas, respecting quotes and brackets.

    This properly handles:
    - Commas inside quoted strings: 'a, b' stays together
    - Nested brackets: [1, [2, 3]] splits correctly
    - Mixed nesting: {a: [1, 2], b: 3} splits correctly
    """
    result = []
    current = []
    depth = 0  # Track bracket/brace depth
    in_string = False
    string_char = None
    i = 0

    while i < len(s):
        c = s[i]

        if in_string:
            current.append(c)
            if c == '\\' and i + 1 < len(s):
                # Skip escaped character
                current.append(s[i + 1])
                i += 2
                continue
            elif c == string_char:
                in_string = False
                string_char = None
        elif c in '"\'':
            in_string = True
            string_char = c
            current.append(c)
        elif c in '[{(':
            depth += 1
            current.append(c)
        elif c in ']})':
            depth -= 1
            current.append(c)
        elif c == ',' and depth == 0:
            result.append(''.join(current).strip())
            current = []
        else:
            current.append(c)

        i += 1

    if current:
        result.append(''.join(current).strip())

    return result


# Custom type converter for flexible error matching
def parse_optional(text):
    return text


register_type(Optional=parse_optional)


# ============================================================================
# GIVEN steps
# ============================================================================

@given('an empty graph')
def step_empty_graph(context):
    """Create an empty graph."""
    context.graph = nx.MultiDiGraph()
    context.parameters = {}
    context.side_effects_before = None
    context.procedures = {}


@given('any graph')
def step_any_graph(context):
    """Create any graph (empty is fine)."""
    context.graph = nx.MultiDiGraph()
    context.parameters = {}
    context.side_effects_before = None
    context.procedures = {}


@given('the binary-tree-1 graph')
def step_binary_tree_1(context):
    """Create binary-tree-1 test graph."""
    context.graph = nx.MultiDiGraph()
    context.parameters = {}
    context.procedures = {}
    # Binary tree structure: root with left/right children
    context.graph.add_node(0, labels=['Root'], name='root')
    context.graph.add_node(1, labels=['Child'], name='child1')
    context.graph.add_node(2, labels=['Child'], name='child2')
    context.graph.add_edge(0, 1, type='HAS_LEFT')
    context.graph.add_edge(0, 2, type='HAS_RIGHT')


@given('the binary-tree-2 graph')
def step_binary_tree_2(context):
    """Create binary-tree-2 test graph (deeper tree)."""
    context.graph = nx.MultiDiGraph()
    context.parameters = {}
    context.procedures = {}
    # Deeper binary tree
    context.graph.add_node(0, labels=['Root'], name='root')
    context.graph.add_node(1, labels=['Child'], name='child1')
    context.graph.add_node(2, labels=['Child'], name='child2')
    context.graph.add_node(3, labels=['Child'], name='child3')
    context.graph.add_node(4, labels=['Child'], name='child4')
    context.graph.add_edge(0, 1, type='HAS_LEFT')
    context.graph.add_edge(0, 2, type='HAS_RIGHT')
    context.graph.add_edge(1, 3, type='HAS_LEFT')
    context.graph.add_edge(1, 4, type='HAS_RIGHT')


@given('having executed:')
def step_having_executed(context):
    """Execute setup queries."""
    if not hasattr(context, 'graph'):
        context.graph = nx.MultiDiGraph()
    if not hasattr(context, 'parameters'):
        context.parameters = {}

    nxc = NXCypher(context.graph)
    query = context.text
    try:
        nxc.run(query, context.parameters)
    except Exception as e:
        context.setup_error = e


@given(u'there exists a procedure {proc_signature}:')
def step_procedure_exists(context, proc_signature):
    """Register a mock procedure."""
    if not hasattr(context, 'procedures'):
        context.procedures = {}
    # Parse procedure signature and store mock data from table
    context.procedures[proc_signature] = []
    if context.table:
        for row in context.table:
            context.procedures[proc_signature].append(dict(row))


@given('parameters are:')
def step_given_parameters(context):
    """Set query parameters from a table.

    Handles three formats:
    1. Key-value rows with 'name' and 'value' columns:
       | name       | value |
       | percentile | 0.5   |

    2. Pairs as column headers (no data rows):
       | percentile | 0.5 |
       This means headings=['percentile', '0.5'] and no rows.

    3. TCK format: each row is a key-value pair using first two columns:
       | expr | ['Apa'] |
       | idx  | 0       |
       Here the first row becomes headers in behave, so we need to process
       both headers and data rows as key-value pairs.
    """
    if not hasattr(context, 'parameters'):
        context.parameters = {}

    if context.table:
        headings = context.table.headings
        rows = list(context.table)

        # Check if this is format 1 (explicit name/value columns)
        if 'name' in headings and 'value' in headings:
            for row in rows:
                name = row['name']
                value = row['value']
                context.parameters[name] = _parse_value(value)
        elif len(headings) >= 2:
            # Format 2 or 3: pairs in columns
            # First, process headings as a key-value pair (this is the first row in the feature file)
            context.parameters[headings[0]] = _parse_value(headings[1])
            # Then process any additional rows
            for row in rows:
                row_values = list(row)
                if len(row_values) >= 2:
                    context.parameters[row_values[0]] = _parse_value(row_values[1])


# ============================================================================
# WHEN steps
# ============================================================================

@when('executing query:')
def step_executing_query(context):
    """Execute the main test query."""
    if not hasattr(context, 'graph'):
        context.graph = nx.MultiDiGraph()
    if not hasattr(context, 'parameters'):
        context.parameters = {}

    nxc = NXCypher(context.graph)
    query = context.text

    # Capture graph state before for side effect comparison
    context.nodes_before = set(context.graph.nodes())
    context.edges_before = set(context.graph.edges(keys=True))
    context.labels_before = set()
    context.props_before = 0
    for n in context.graph.nodes():
        data = context.graph.nodes[n]
        if 'labels' in data:
            context.labels_before.update(data['labels'])
        context.props_before += len([k for k in data if k != 'labels'])
    for u, v, k in context.graph.edges(keys=True):
        data = context.graph.edges[u, v, k]
        context.props_before += len([key for key in data if key != 'type'])

    try:
        context.result = nxc.run(query, context.parameters)
        context.error = None
    except Exception as e:
        context.result = None
        context.error = e


@when('executing control query:')
def step_executing_control_query(context):
    """Execute a control query (same as regular query for us)."""
    step_executing_query(context)


# ============================================================================
# THEN steps - Result assertions
# ============================================================================

@then('the result should be empty')
def step_result_empty(context):
    """Verify result is empty."""
    if context.error:
        raise AssertionError(f"Expected empty result but got error: {context.error}")

    if context.result is None:
        return

    # Check if result dict has no columns or all columns are empty
    if not context.result:
        return

    if all(len(v) == 0 for v in context.result.values()):
        return

    raise AssertionError(f"Expected empty result but got: {context.result}")


@then('the result should be, in any order:')
def step_result_unordered(context):
    """Verify result matches expected (any order)."""
    if context.error:
        raise AssertionError(f"Expected result but got error: {context.error}")

    expected_rows = _parse_expected_table(context.table)
    actual_rows = _result_to_rows(context.result)

    if len(actual_rows) != len(expected_rows):
        raise AssertionError(
            f"Row count mismatch: expected {len(expected_rows)}, got {len(actual_rows)}\n"
            f"Expected: {expected_rows}\nActual: {actual_rows}"
        )

    # Compare row sets (order-independent)
    _compare_rows_unordered(expected_rows, actual_rows)


@then('the result should be, in order:')
def step_result_ordered(context):
    """Verify result matches expected (ordered)."""
    if context.error:
        raise AssertionError(f"Expected result but got error: {context.error}")

    expected_rows = _parse_expected_table(context.table)
    actual_rows = _result_to_rows(context.result)

    if len(actual_rows) != len(expected_rows):
        raise AssertionError(
            f"Row count mismatch: expected {len(expected_rows)}, got {len(actual_rows)}\n"
            f"Expected: {expected_rows}\nActual: {actual_rows}"
        )

    # Compare rows in order
    for i, (expected, actual) in enumerate(zip(expected_rows, actual_rows)):
        if not _rows_match(expected, actual):
            raise AssertionError(
                f"Row {i} mismatch:\nExpected: {expected}\nActual: {actual}"
            )


@then('the result should be (ignoring element order for lists):')
def step_result_unordered_lists(context):
    """Verify result matches expected (unordered, with list element order ignored)."""
    step_result_unordered(context)


@then('the result should be, in order (ignoring element order for lists):')
def step_result_ordered_ignore_list_order(context):
    """Verify result matches expected (ordered rows, unordered list elements)."""
    step_result_ordered(context)


# ============================================================================
# THEN steps - Error assertions
# ============================================================================

@then('a {error_type} should be raised at compile time: {error_code}')
def step_compile_error(context, error_type, error_code):
    """Verify that expected compile-time error was raised."""
    if context.error is None:
        raise AssertionError(
            f"Expected {error_type} ({error_code}) but query succeeded with: {context.result}"
        )
    # Error was raised as expected


@then('a {error_type} should be raised at runtime: {error_code}')
def step_runtime_error(context, error_type, error_code):
    """Verify that expected runtime error was raised."""
    if context.error is None:
        raise AssertionError(
            f"Expected {error_type} ({error_code}) but query succeeded with: {context.result}"
        )
    # Error was raised as expected


@then('a {error_type} should be raised at any time: {error_code}')
def step_any_time_error(context, error_type, error_code):
    """Verify that expected error was raised (compile or runtime)."""
    if context.error is None:
        raise AssertionError(
            f"Expected {error_type} ({error_code}) but query succeeded with: {context.result}"
        )
    # Error was raised as expected


# ============================================================================
# THEN steps - Side effects
# ============================================================================

@then('no side effects')
def step_no_side_effects(context):
    """Verify no side effects occurred."""
    # Check node count
    nodes_after = set(context.graph.nodes())
    edges_after = set(context.graph.edges(keys=True))

    nodes_added = nodes_after - context.nodes_before
    nodes_removed = context.nodes_before - nodes_after
    edges_added = edges_after - context.edges_before
    edges_removed = context.edges_before - edges_after

    if nodes_added or nodes_removed or edges_added or edges_removed:
        raise AssertionError(
            f"Expected no side effects but got: "
            f"+{len(nodes_added)} nodes, -{len(nodes_removed)} nodes, "
            f"+{len(edges_added)} relationships, -{len(edges_removed)} relationships"
        )


@then('the side effects should be:')
def step_side_effects(context):
    """Verify side effects match expected."""
    expected = {}
    for row in context.table:
        key = row[0]
        value = int(row[1])
        expected[key] = value

    # Calculate actual side effects
    nodes_after = set(context.graph.nodes())
    edges_after = set(context.graph.edges(keys=True))

    nodes_added = len(nodes_after - context.nodes_before)
    nodes_deleted = len(context.nodes_before - nodes_after)
    rels_added = len(edges_after - context.edges_before)
    rels_deleted = len(context.edges_before - edges_after)

    # Count labels after
    labels_after = set()
    props_after = 0
    for n in context.graph.nodes():
        data = context.graph.nodes[n]
        # Support both 'labels' and '__labels__' keys
        if 'labels' in data:
            labels_after.update(data['labels'])
        elif '__labels__' in data:
            labels_after.update(data['__labels__'])
        props_after += len([k for k in data if k not in ('labels', '__labels__')])
    for u, v, k in context.graph.edges(keys=True):
        data = context.graph.edges[u, v, k]
        props_after += len([key for key in data if key != 'type'])

    labels_added = len(labels_after - context.labels_before)
    labels_deleted = len(context.labels_before - labels_after)
    props_set = max(0, props_after - context.props_before)
    props_deleted = max(0, context.props_before - props_after)

    actual = {
        '+nodes': nodes_added,
        '-nodes': nodes_deleted,
        '+relationships': rels_added,
        '-relationships': rels_deleted,
        '+labels': labels_added,
        '-labels': labels_deleted,
        '+properties': props_set,
        '-properties': props_deleted,
    }

    # Compare expected vs actual
    for key, expected_val in expected.items():
        actual_val = actual.get(key, 0)
        if actual_val != expected_val:
            raise AssertionError(
                f"Side effect mismatch for {key}: expected {expected_val}, got {actual_val}"
            )


# ============================================================================
# Helper functions
# ============================================================================

def _parse_value(value_str):
    """Parse a Cypher literal value from string."""
    if value_str is None:
        return None

    s = str(value_str).strip()

    # null
    if s == 'null':
        return None

    # boolean
    if s == 'true':
        return True
    if s == 'false':
        return False

    # quoted string
    if (s.startswith("'") and s.endswith("'")) or (s.startswith('"') and s.endswith('"')):
        # The table value is in Cypher literal format with Gherkin escaping
        # Since behave doesn't process Gherkin escapes, we need to:
        # 1. Process Gherkin escapes: \\\\ -> \\
        # 2. Process Cypher escapes: \\ -> \
        # We can do this by running escape processing twice
        inner = s[1:-1]
        once = _process_escapes(inner)
        twice = _process_escapes(once)
        return twice

    # integer
    if re.match(r'^-?\d+$', s):
        return int(s)

    # float (including scientific notation)
    if re.match(r'^-?\d+\.\d+$', s):
        return float(s)
    # Scientific notation: 1.5e10, 1E-5, 1.0e+10, etc.
    if re.match(r'^-?\d+\.?\d*[eE][+-]?\d+$', s):
        return float(s)

    # Node pattern like (:A), (:A:B), ({name: 'bar'}), (:A {name: 'bar'})
    if s.startswith('(') and s.endswith(')'):
        return _parse_node_pattern(s)

    # Relationship pattern like [:REL] or [-:REL]
    # Must start with [: or [-: to avoid matching lists with maps like [{id: '001'}]
    if s.startswith('[') and s.endswith(']'):
        inner = s[1:-1].strip()
        if inner.startswith(':') or inner.startswith('-:'):
            return _parse_relationship_pattern(s)

    # list
    if s.startswith('[') and s.endswith(']'):
        inner = s[1:-1].strip()
        if not inner:
            return []
        # Split by comma, respecting quotes and nested brackets
        items = [_parse_value(x) for x in _split_by_comma(inner)]
        return items

    # map/dict
    if s.startswith('{') and s.endswith('}'):
        inner = s[1:-1].strip()
        if not inner:
            return {}
        # Split by comma, respecting quotes and nested brackets
        result = {}
        for pair in _split_by_comma(inner):
            if ':' in pair:
                key, val = pair.split(':', 1)
                result[key.strip()] = _parse_value(val.strip())
        return result

    # Path pattern like <(:A)-[:T]->(:B)>
    if s.startswith('<') and s.endswith('>'):
        return _parse_path_pattern(s)

    # Node/relationship reference (just return as-is for now)
    return s


def _parse_node_pattern(s):
    """Parse a node pattern like (:A), (:A:B), ({name: 'bar'}), (:A {name: 'bar'})."""
    inner = s[1:-1].strip()
    result = {'__type__': 'node'}

    # Extract labels (starting with :)
    labels = []
    props = {}

    # Find property map if present
    prop_start = inner.find('{')
    if prop_start >= 0:
        prop_end = inner.rfind('}')
        prop_str = inner[prop_start:prop_end+1]
        label_part = inner[:prop_start].strip()
        # Parse properties
        prop_inner = prop_str[1:-1].strip()
        if prop_inner:
            for pair in prop_inner.split(','):
                if ':' in pair:
                    key, val = pair.split(':', 1)
                    props[key.strip()] = _parse_value(val.strip())
    else:
        label_part = inner

    # Parse labels
    if label_part:
        # Labels are prefixed with :
        for part in label_part.split(':'):
            part = part.strip()
            if part:
                labels.append(part)

    if labels:
        result['__labels__'] = set(labels)
    if props:
        result.update(props)

    return result


def _parse_relationship_pattern(s):
    """Parse a relationship pattern like [:REL], [:REL {prop: value}]."""
    inner = s[1:-1].strip()
    result = {'__type__': 'relationship'}

    # Find property map if present
    prop_start = inner.find('{')
    if prop_start >= 0:
        prop_end = inner.rfind('}')
        prop_str = inner[prop_start:prop_end+1]
        type_part = inner[:prop_start].strip()
        # Parse properties
        prop_inner = prop_str[1:-1].strip()
        if prop_inner:
            for pair in prop_inner.split(','):
                if ':' in pair:
                    key, val = pair.split(':', 1)
                    result[key.strip()] = _parse_value(val.strip())
    else:
        type_part = inner

    # Parse relationship type
    if type_part.startswith(':'):
        result['__rel_type__'] = type_part[1:].strip()

    return result


def _parse_path_pattern(s):
    """Parse a path pattern like <(:A)-[:T]->(:B)> into a list [node, rel, node, ...]."""
    inner = s[1:-1].strip()  # Remove < >

    # Split the path into segments: node patterns and relationship patterns
    # Pattern: (node)-[rel]->(node) or (node)<-[rel]-(node) or (node)-[rel]-(node)
    path = []
    i = 0

    while i < len(inner):
        # Skip whitespace
        while i < len(inner) and inner[i].isspace():
            i += 1

        if i >= len(inner):
            break

        # Parse node pattern (...)
        if inner[i] == '(':
            # Find matching )
            depth = 0
            start = i
            for j in range(i, len(inner)):
                if inner[j] == '(':
                    depth += 1
                elif inner[j] == ')':
                    depth -= 1
                    if depth == 0:
                        node_str = inner[start:j+1]
                        path.append(_parse_node_pattern(node_str))
                        i = j + 1
                        break
            else:
                i += 1
                continue

        # Parse relationship pattern -[...]-> or <-[...]- or -[...]-
        elif inner[i] in '-<':
            # Find the relationship brackets [...]
            bracket_start = inner.find('[', i)
            if bracket_start >= 0:
                bracket_end = inner.find(']', bracket_start)
                if bracket_end >= 0:
                    rel_str = inner[bracket_start:bracket_end+1]
                    rel = _parse_relationship_pattern(rel_str)
                    # Also parse relationship type from [:TYPE] format
                    rel_inner = rel_str[1:-1].strip()
                    if rel_inner.startswith(':'):
                        rel['__labels__'] = {rel_inner[1:].split()[0].strip()}
                    path.append(rel)
                    # Move past the relationship and any direction markers
                    i = bracket_end + 1
                    while i < len(inner) and inner[i] in '->-':
                        i += 1
                else:
                    i += 1
            else:
                i += 1
        else:
            i += 1

    return path


def _parse_expected_table(table):
    """Parse expected result table into list of dicts."""
    rows = []
    if table:
        for row in table:
            parsed_row = {}
            for heading in table.headings:
                parsed_row[heading] = _parse_value(row[heading])
            rows.append(parsed_row)
    return rows


def _result_to_rows(result):
    """Convert nxCypher result dict to list of row dicts."""
    if not result:
        return []

    num_rows = max((len(v) for v in result.values()), default=0)
    rows = []
    for i in range(num_rows):
        row = {}
        for col, vals in result.items():
            if i < len(vals):
                row[col] = vals[i]
            else:
                row[col] = None
        rows.append(row)
    return rows


def _values_match(expected, actual):
    """Compare two values for equality."""
    # Handle None/null
    if expected is None:
        return actual is None

    # Handle temporal type comparisons (actual is Cypher temporal object)
    # The expected value is a string like '2020-01-15' or '2020-01-15T12:00'
    if hasattr(actual, '_date') or hasattr(actual, '_datetime') or hasattr(actual, '_time'):
        # Compare string representations
        return str(expected) == str(actual)

    # Handle duration comparisons
    if hasattr(actual, 'months') and hasattr(actual, 'days') and hasattr(actual, 'seconds'):
        return str(expected) == str(actual)

    # Handle node pattern comparison (expected is parsed pattern, actual is nxCypher result)
    if isinstance(expected, dict) and expected.get('__type__') == 'node':
        if not isinstance(actual, dict):
            return False
        # Check labels match
        exp_labels = expected.get('__labels__', set())
        act_labels = actual.get('__labels__', set())
        if isinstance(act_labels, (list, tuple)):
            act_labels = set(act_labels)
        if exp_labels and exp_labels != act_labels:
            return False
        # Check properties match (skip __labels__ and __type__ keys)
        for key, val in expected.items():
            if key.startswith('__'):
                continue
            if key not in actual:
                return False
            if not _values_match(val, actual[key]):
                return False
        return True

    # Handle relationship pattern comparison
    if isinstance(expected, dict) and expected.get('__type__') == 'relationship':
        if not isinstance(actual, dict):
            return False
        # Check relationship type - can be in __rel_type__, type, or __labels__
        exp_type = expected.get('__rel_type__')
        exp_labels = expected.get('__labels__', set())
        act_type = actual.get('__rel_type__') or actual.get('type')
        act_labels = actual.get('__labels__', set())
        if isinstance(act_labels, (list, tuple)):
            act_labels = set(act_labels)
        # Match if either type matches or labels contain the expected type
        if exp_type:
            if act_type and exp_type != act_type:
                return False
            # If no act_type, check if exp_type is in act_labels
            if not act_type and exp_type not in act_labels:
                return False
        # Also check expected __labels__ against actual __labels__
        if exp_labels and exp_labels != act_labels:
            return False
        # Check properties match (skip internal keys)
        for key, val in expected.items():
            if key.startswith('__'):
                continue
            if key not in actual:
                return False
            if not _values_match(val, actual[key]):
                return False
        return True

    # Handle regular dicts
    if isinstance(expected, dict) and isinstance(actual, dict):
        # Check if actual is a node (has __labels__)
        if '__labels__' in actual:
            # Compare as node - expected should have matching properties
            for key, val in expected.items():
                if key not in actual:
                    return False
                if not _values_match(val, actual[key]):
                    return False
            return True
        # Regular dict comparison
        if set(expected.keys()) != set(actual.keys()):
            return False
        return all(_values_match(expected[k], actual[k]) for k in expected)

    # Handle lists
    if isinstance(expected, list) and isinstance(actual, list):
        if len(expected) != len(actual):
            return False
        return all(_values_match(e, a) for e, a in zip(expected, actual))

    # Handle numeric comparison with tolerance
    if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
        if isinstance(expected, float) or isinstance(actual, float):
            # Use relative tolerance for very large or very small numbers
            if expected == 0 or actual == 0:
                return abs(expected - actual) < 1e-15
            rel_diff = abs(expected - actual) / max(abs(expected), abs(actual))
            return rel_diff < 1e-9
        return expected == actual

    # String/other comparison
    return expected == actual


def _rows_match(expected_row, actual_row):
    """Compare two rows for equality."""
    if set(expected_row.keys()) != set(actual_row.keys()):
        return False
    return all(_values_match(expected_row[k], actual_row[k]) for k in expected_row)


def _compare_rows_unordered(expected_rows, actual_rows):
    """Compare row sets (order-independent)."""
    matched_actual = [False] * len(actual_rows)

    for expected in expected_rows:
        found = False
        for i, actual in enumerate(actual_rows):
            if not matched_actual[i] and _rows_match(expected, actual):
                matched_actual[i] = True
                found = True
                break

        if not found:
            raise AssertionError(
                f"Expected row not found in actual results:\n"
                f"Expected: {expected}\n"
                f"Actual rows: {actual_rows}"
            )
