"""Tests for autodebug_replay/sql_replay.py -- SQL query replay engine.

These tests use the internal classes directly (_ReplayCursor, _ReplayConnection,
_TxState) so that psycopg2/psycopg need not be installed in the test environment.
"""

from __future__ import annotations

import pytest

import autodebug_replay.sql_replay as sql_replay_mod
from autodebug_replay.sql_replay import (
    _ReplayConnection,
    _ReplayCursor,
    _TxState,
    uninstall,
)
from autodebug_replay.transcripts import ReplayDivergence, TranscriptIndex

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_index(records):
    """Build a TranscriptIndex from a list of SQL record dicts."""
    return TranscriptIndex(records=records)


def _sql_record(
    seq: int,
    sql_canonical: str,
    tx_id: str = "conn-1-0",
    seq_in_tx: int = 1,
    rows=None,
    rowcount=None,
    description=None,
    exception=None,
):
    """Convenience factory for a single SQL transcript record."""
    rec = {
        "type": "SQL",
        "seq": seq,
        "sql_canonical": sql_canonical,
        "tx_id": tx_id,
        "seq_in_tx": seq_in_tx,
        "rows": rows or [],
        "rowcount": rowcount if rowcount is not None else (len(rows) if rows else 0),
        "description": description,
    }
    if exception is not None:
        rec["exception"] = exception
    return rec


# ---------------------------------------------------------------------------
# 1. fetchall() returns the rows from the record
# ---------------------------------------------------------------------------


def test_replay_cursor_returns_rows():
    """_ReplayCursor.fetchall() must return all rows supplied in the record."""
    rows = [[1, "alice"], [2, "bob"], [3, "carol"]]
    index = _make_index([
        _sql_record(seq=1, sql_canonical="SELECT id, name FROM users", rows=rows),
    ])
    tx_state = _TxState("conn-1")
    cursor = _ReplayCursor(index, tx_state)

    cursor.execute("SELECT id, name FROM users")
    result = cursor.fetchall()

    assert result == rows


# ---------------------------------------------------------------------------
# 2. fetchone() returns rows one at a time, then None
# ---------------------------------------------------------------------------


def test_replay_cursor_fetchone():
    """fetchone() must return rows sequentially, then None when exhausted."""
    rows = [["a"], ["b"], ["c"]]
    index = _make_index([
        _sql_record(seq=1, sql_canonical="SELECT letter FROM alphabet", rows=rows),
    ])
    tx_state = _TxState("conn-1")
    cursor = _ReplayCursor(index, tx_state)

    cursor.execute("SELECT letter FROM alphabet")

    assert cursor.fetchone() == ["a"]
    assert cursor.fetchone() == ["b"]
    assert cursor.fetchone() == ["c"]
    assert cursor.fetchone() is None


# ---------------------------------------------------------------------------
# 3. fetchmany(2) returns correct count
# ---------------------------------------------------------------------------


def test_replay_cursor_fetchmany():
    """fetchmany(size) must return at most *size* rows."""
    rows = [[1], [2], [3], [4], [5]]
    index = _make_index([
        _sql_record(seq=1, sql_canonical="SELECT n FROM numbers", rows=rows),
    ])
    tx_state = _TxState("conn-1")
    cursor = _ReplayCursor(index, tx_state)

    cursor.execute("SELECT n FROM numbers")

    batch1 = cursor.fetchmany(2)
    assert batch1 == [[1], [2]]

    batch2 = cursor.fetchmany(2)
    assert batch2 == [[3], [4]]

    batch3 = cursor.fetchmany(2)
    assert batch3 == [[5]]

    batch4 = cursor.fetchmany(2)
    assert batch4 == []


# ---------------------------------------------------------------------------
# 4. execute() consumes a SQL record and checks sql_canonical
# ---------------------------------------------------------------------------


def test_replay_cursor_execute_matches_sql():
    """execute() must consume the next SQL record and match sql_canonical.

    Whitespace normalization means extra spaces/newlines are collapsed.
    """
    index = _make_index([
        _sql_record(seq=1, sql_canonical="INSERT INTO t (x) VALUES (?)", rows=[]),
    ])
    tx_state = _TxState("conn-1")
    cursor = _ReplayCursor(index, tx_state)

    # The actual SQL has extra whitespace -- should still match after normalization
    cursor.execute("INSERT  INTO  t  (x)  VALUES  (?)", params=("hello",))

    assert cursor.rowcount == 0
    assert cursor._rows == []


def test_replay_cursor_select_projection_order_equivalent():
    """SELECT projection order differences should match via SQL fingerprint."""
    index = _make_index([
        _sql_record(seq=1, sql_canonical="SELECT b, a FROM users WHERE id = 10", rows=[]),
    ])
    tx_state = _TxState("conn-1")
    cursor = _ReplayCursor(index, tx_state)

    # Same logical query with projection order swapped and case/spacing changed.
    cursor.execute("select a, b from users where id = 10")
    assert cursor.rowcount == 0


def test_replay_cursor_where_and_predicate_order_equivalent():
    """Simple AND predicate reorder should match by SQL fingerprint."""
    index = _make_index([
        _sql_record(seq=1, sql_canonical="SELECT * FROM users WHERE b = 2 AND a = 1", rows=[]),
    ])
    tx_state = _TxState("conn-1")
    cursor = _ReplayCursor(index, tx_state)

    cursor.execute("select * from users where a = 1 and b = 2")
    assert cursor.rowcount == 0


def test_replay_cursor_placeholder_style_equivalent():
    """Placeholder and literal normalization should reduce benign SQL divergence."""
    index = _make_index([
        _sql_record(seq=1, sql_canonical="SELECT * FROM users WHERE id = ? AND name = ?", rows=[]),
    ])
    tx_state = _TxState("conn-1")
    cursor = _ReplayCursor(index, tx_state)

    cursor.execute("select * from users where name = :name and id = $1")
    assert cursor.rowcount == 0


# ---------------------------------------------------------------------------
# 5. SQL mismatch raises ReplayDivergence with code "MISMATCH"
# ---------------------------------------------------------------------------


def test_sql_mismatch_raises_divergence():
    """Executing the wrong SQL must raise ReplayDivergence(code='MISMATCH')."""
    index = _make_index([
        _sql_record(seq=1, sql_canonical="SELECT 1"),
    ])
    tx_state = _TxState("conn-1")
    cursor = _ReplayCursor(index, tx_state)

    with pytest.raises(ReplayDivergence) as exc_info:
        cursor.execute("SELECT 999")

    assert exc_info.value.code == "MISMATCH"
    assert "expected_sql" in exc_info.value.detail
    assert "actual_sql" in exc_info.value.detail
    assert exc_info.value.detail["expected_sql"] == "SELECT 1"
    assert exc_info.value.detail["actual_sql"] == "SELECT 999"


# ---------------------------------------------------------------------------
# 6. commit() increments the tx_counter (rotates transaction state)
# ---------------------------------------------------------------------------


def test_tx_state_rotates_on_commit():
    """_ReplayConnection.commit() must increment the tx counter in _TxState
    and reset the per-transaction sequence number.
    """
    records = [
        _sql_record(seq=1, sql_canonical="INSERT INTO t VALUES (1)", tx_id="conn-1-0", seq_in_tx=1),
        _sql_record(seq=2, sql_canonical="SELECT 1", tx_id="conn-1-1", seq_in_tx=1),
    ]
    index = _make_index(records)
    conn = _ReplayConnection(index, "conn-1")

    # First transaction
    cur1 = conn.cursor()
    cur1.execute("INSERT INTO t VALUES (1)")
    assert conn._tx_state.tx_id == "conn-1-0"
    assert conn._tx_state._tx_counter == 0

    # Commit rotates
    conn.commit()
    assert conn._tx_state._tx_counter == 1
    assert conn._tx_state.tx_id == "conn-1-1"
    assert conn._tx_state._seq_in_tx == 0  # reset after rotate

    # Second transaction works with the new tx_id
    cur2 = conn.cursor()
    cur2.execute("SELECT 1")
    assert conn._tx_state._seq_in_tx == 1


# ---------------------------------------------------------------------------
# 7. Divergence code is "SQL_DIVERGENCE" (not the old typo "SQL_REPLAYGENCE")
# ---------------------------------------------------------------------------


def test_divergence_code_is_sql_divergence():
    """The tx_id/seq_in_tx mismatch divergence must use the code
    'SQL_DIVERGENCE' -- not the old typo 'SQL_REPLAYGENCE'.
    """
    # Record expects tx_id="conn-1-0", seq_in_tx=1
    index = _make_index([
        _sql_record(seq=1, sql_canonical="SELECT 1", tx_id="conn-1-0", seq_in_tx=1),
    ])
    # Create a TxState whose tx_id will NOT match the record
    tx_state = _TxState("conn-99")  # tx_id will be "conn-99-0" != "conn-1-0"
    cursor = _ReplayCursor(index, tx_state)

    with pytest.raises(ReplayDivergence) as exc_info:
        cursor.execute("SELECT 1")

    assert exc_info.value.code == "SQL_DIVERGENCE"
    assert exc_info.value.code != "SQL_REPLAYGENCE"


# ---------------------------------------------------------------------------
# 8. uninstall() resets _proxy_counter to 0
# ---------------------------------------------------------------------------


def test_uninstall_resets_counter():
    """Calling sql_replay.uninstall() must reset the module-level
    _proxy_counter back to 0.
    """
    # Manually bump the counter to simulate prior connections
    sql_replay_mod._proxy_counter = 5
    assert sql_replay_mod._proxy_counter == 5

    uninstall()

    assert sql_replay_mod._proxy_counter == 0
