"""Unit tests for PGGraphStorage chunk-level transaction batch paths.

Covers the PR-2 chunk-level fallback: ``upsert_nodes_batch`` /
``upsert_edges_batch`` group per-row Cypher into payload/record-bounded chunks
run in a single transaction; ``remove_nodes`` runs all chunks in ONE
transaction (all-or-nothing); ``remove_edges`` runs one transaction per chunk.
"""

import pytest
from unittest.mock import AsyncMock

from lightrag.kg.postgres_impl import PGGraphStorage


# ---------------------------------------------------------------------------
# Capture harness
# ---------------------------------------------------------------------------


class _Capture:
    def __init__(self) -> None:
        self.calls: list[dict] = []  # every connection.execute(sql, *args)
        self.tx_count = 0  # connection.transaction() opens
        self.run_count = 0  # _run_with_retry invocations (= chunks issued)


class _FakeTransaction:
    async def __aenter__(self):
        return self

    async def __aexit__(self, exc_type, exc, tb):
        return False


class _FakeConnection:
    def __init__(self, capture: _Capture):
        self._capture = capture

    def transaction(self):
        self._capture.tx_count += 1
        return _FakeTransaction()

    async def execute(self, sql, *args):
        self._capture.calls.append({"sql": sql, "args": args})
        return ""


def make_graph_storage(
    *,
    max_upsert_records: int | None = None,
    max_upsert_payload: int | None = None,
    max_delete_records: int | None = None,
) -> tuple[PGGraphStorage, _Capture]:
    storage = PGGraphStorage.__new__(PGGraphStorage)
    storage.workspace = "test_ws"
    storage.namespace = "test_graph"
    storage.graph_name = "test_graph"
    storage.__post_init__()  # resolves the chunk-level batch limits
    if max_upsert_records is not None:
        storage._max_upsert_records_per_batch = max_upsert_records
    if max_upsert_payload is not None:
        storage._max_upsert_payload_bytes = max_upsert_payload
    if max_delete_records is not None:
        storage._max_delete_records_per_batch = max_delete_records

    capture = _Capture()
    conn = _FakeConnection(capture)

    async def fake_run_with_retry(operation, **_kwargs):
        capture.run_count += 1
        return await operation(conn)

    storage.db = AsyncMock()
    storage.db._run_with_retry = AsyncMock(side_effect=fake_run_with_retry)
    return storage, capture


def _sql_count(capture: _Capture, needle: str) -> int:
    return sum(1 for c in capture.calls if needle in c["sql"])


# ---------------------------------------------------------------------------
# upsert_nodes_batch
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_upsert_nodes_batch_splits_into_chunk_transactions():
    storage, cap = make_graph_storage(max_upsert_records=2)
    nodes = [(f"n{i}", {"entity_id": f"n{i}", "v": str(i)}) for i in range(5)]

    await storage.upsert_nodes_batch(nodes)

    # 5 nodes / cap 2 => 3 chunks, each its own _run_with_retry + transaction.
    assert cap.run_count == 3
    assert cap.tx_count == 3
    # One MERGE per node.
    assert _sql_count(cap, "MERGE (n:base") == 5


@pytest.mark.asyncio
async def test_upsert_nodes_batch_dedupes_last_write_wins():
    storage, cap = make_graph_storage()
    await storage.upsert_nodes_batch(
        [
            ("A", {"entity_id": "A", "weight": "first"}),
            ("A", {"entity_id": "A", "weight": "second"}),
        ]
    )

    merge_calls = [c for c in cap.calls if "MERGE (n:base" in c["sql"]]
    assert len(merge_calls) == 1
    assert '"second"' in merge_calls[0]["sql"]
    assert '"first"' not in merge_calls[0]["sql"]


@pytest.mark.asyncio
async def test_upsert_nodes_batch_splits_by_payload_bytes():
    # Disable the record cap, force a small byte budget so each big node lands
    # in its own chunk.
    storage, cap = make_graph_storage(max_upsert_records=0, max_upsert_payload=200)
    nodes = [(f"n{i}", {"entity_id": f"n{i}", "blob": "X" * 150}) for i in range(3)]

    await storage.upsert_nodes_batch(nodes)

    assert cap.run_count == 3


@pytest.mark.asyncio
async def test_upsert_nodes_batch_missing_entity_id_raises_before_tx():
    storage, cap = make_graph_storage()
    with pytest.raises(ValueError, match="entity_id"):
        await storage.upsert_nodes_batch([("A", {"foo": "bar"})])
    # The builder runs before the transaction opens, so nothing was issued.
    assert cap.run_count == 0


@pytest.mark.asyncio
async def test_upsert_nodes_batch_empty_noop():
    storage, cap = make_graph_storage()
    await storage.upsert_nodes_batch([])
    assert cap.run_count == 0


# ---------------------------------------------------------------------------
# upsert_edges_batch
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_upsert_edges_batch_splits_into_chunk_transactions():
    storage, cap = make_graph_storage(max_upsert_records=2)
    edges = [(f"s{i}", f"t{i}", {"w": str(i)}) for i in range(5)]

    await storage.upsert_edges_batch(edges)

    # 5 edges / cap 2 => 3 chunks; each chunk = one transaction.
    assert cap.run_count == 3
    assert cap.tx_count == 3
    # One CREATE cypher per edge, and exactly one graph-wide lock per chunk.
    assert _sql_count(cap, "CREATE (source)-[r:DIRECTED") == 5
    assert _sql_count(cap, "pg_advisory_xact_lock") == 3


@pytest.mark.asyncio
async def test_upsert_edges_batch_takes_one_graph_lock_per_chunk():
    """The batch chunk takes a single graph-wide advisory lock (keyed on
    graph_name only), not one lock per edge -- bounded regardless of edge count."""
    storage, cap = make_graph_storage()
    await storage.upsert_edges_batch(
        [(f"s{i}", f"t{i}", {"w": str(i)}) for i in range(4)]
    )

    lock_calls = [c for c in cap.calls if "pg_advisory_xact_lock" in c["sql"]]
    assert len(lock_calls) == 1  # one chunk, one lock (not 4)
    assert lock_calls[0]["args"] == ("test_graph",)  # graph-keyed, no endpoints
    assert _sql_count(cap, "CREATE (source)-[r:DIRECTED") == 4
    # The lock is acquired before any edge cypher in the chunk.
    assert "pg_advisory_xact_lock" in cap.calls[0]["sql"]


@pytest.mark.asyncio
async def test_upsert_edges_batch_empty_noop():
    storage, cap = make_graph_storage()
    await storage.upsert_edges_batch([])
    assert cap.run_count == 0


# ---------------------------------------------------------------------------
# remove_nodes — all chunks in ONE transaction (all-or-nothing)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_remove_nodes_chunks_share_one_transaction():
    storage, cap = make_graph_storage(max_delete_records=2)
    await storage.remove_nodes([f"n{i}" for i in range(5)])

    # One transaction wraps all chunks (preserves single-statement atomicity).
    assert cap.run_count == 1
    assert cap.tx_count == 1
    # 5 ids / cap 2 => 3 bounded IN [...] DETACH DELETE statements.
    assert _sql_count(cap, "DETACH DELETE n") == 3


@pytest.mark.asyncio
async def test_remove_nodes_empty_noop():
    storage, cap = make_graph_storage()
    await storage.remove_nodes([])
    assert cap.run_count == 0


# ---------------------------------------------------------------------------
# remove_edges — one transaction per chunk
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_remove_edges_one_transaction_per_chunk():
    storage, cap = make_graph_storage(max_delete_records=2)
    edges = [(f"s{i}", f"t{i}") for i in range(5)]

    await storage.remove_edges(edges)

    # 5 edges / cap 2 => 3 chunks, each its own transaction.
    assert cap.run_count == 3
    assert cap.tx_count == 3
    # One DELETE r statement per edge.
    assert _sql_count(cap, "DELETE r") == 5


@pytest.mark.asyncio
async def test_remove_edges_empty_noop():
    storage, cap = make_graph_storage()
    await storage.remove_edges([])
    assert cap.run_count == 0
