"""Tests for HANASaver — uses a mocked hdbcli connection."""

import base64
import json
import sys
from unittest.mock import MagicMock, call, patch

import pytest

from langgraph_checkpoint_hana.saver import (
    HANASaver,
    _CHECKPOINTS_TABLE,
    _WRITES_TABLE,
)


# ── Fixtures ─────────────────────────────────────────────────────────


@pytest.fixture
def mock_conn():
    """Return a mock hdbcli connection with a mock cursor."""
    conn = MagicMock()
    cursor = MagicMock()
    conn.cursor.return_value = cursor
    return conn


@pytest.fixture
def saver(mock_conn):
    """HANASaver with table creation skipped."""
    with patch.object(HANASaver, "setup"):
        s = HANASaver(conn=mock_conn)
    return s


@pytest.fixture
def thread_config():
    return {
        "configurable": {
            "thread_id": "thread-1",
            "checkpoint_ns": "",
        }
    }


@pytest.fixture
def sample_checkpoint():
    return {
        "v": 4,
        "ts": "2026-02-23T12:00:00+00:00",
        "id": "cp-001",
        "channel_values": {"messages": []},
        "channel_versions": {"__start__": 1},
        "versions_seen": {},
    }


# ── Construction ─────────────────────────────────────────────────────


class TestConstruction:

    def test_init_stores_connection(self, mock_conn):
        with patch.object(HANASaver, "setup"):
            saver = HANASaver(conn=mock_conn)
        assert saver.conn is mock_conn

    def test_from_conn_info(self):
        mock_dbapi = MagicMock()
        mock_dbapi.connect.return_value = MagicMock()
        mock_hdbcli = MagicMock()
        mock_hdbcli.dbapi = mock_dbapi

        with patch.dict("sys.modules", {"hdbcli": mock_hdbcli, "hdbcli.dbapi": mock_dbapi}):
            saver = HANASaver.from_conn_info(
                address="host.hana.cloud",
                port=443,
                user="DBADMIN",
                password="secret",
            )
            mock_dbapi.connect.assert_called_once_with(
                address="host.hana.cloud",
                port=443,
                user="DBADMIN",
                password="secret",
                encrypt=True,
                autocommit=False,
            )
            assert isinstance(saver, HANASaver)

    def test_from_env(self):
        env = {
            "HANA_HOST": "host.hana.cloud",
            "HANA_PORT": "443",
            "HANA_USER": "DBADMIN",
            "HANA_PASSWORD": "secret",
        }
        with patch.dict("os.environ", env), \
             patch.object(HANASaver, "from_conn_info", return_value=MagicMock()) as mock_factory:
            HANASaver.from_env()
            mock_factory.assert_called_once_with(
                address="host.hana.cloud",
                port=443,
                user="DBADMIN",
                password="secret",
            )

    def test_context_manager(self, mock_conn):
        with patch.object(HANASaver, "setup") as mock_setup:
            saver = HANASaver(conn=mock_conn)
            with saver:
                mock_setup.assert_called_once()
            mock_conn.close.assert_called_once()


# ── Setup ────────────────────────────────────────────────────────────


class TestSetup:

    def test_creates_tables_when_missing(self, mock_conn):
        cursor = mock_conn.cursor.return_value
        # SYS.TABLES returns 0 for both tables (not found)
        cursor.fetchone.side_effect = [(0,), (0,)]

        saver = HANASaver.__new__(HANASaver)
        saver.conn = mock_conn
        saver.serde = MagicMock()
        HANASaver.setup(saver)

        # Should have executed CREATE TABLE for both
        execute_calls = cursor.execute.call_args_list
        create_calls = [c for c in execute_calls if "CREATE TABLE" in str(c)]
        assert len(create_calls) == 2
        assert mock_conn.commit.call_count == 2

    def test_skips_creation_when_tables_exist(self, mock_conn):
        cursor = mock_conn.cursor.return_value
        # SYS.TABLES returns 1 for both tables (found)
        cursor.fetchone.side_effect = [(1,), (1,)]

        saver = HANASaver.__new__(HANASaver)
        saver.conn = mock_conn
        saver.serde = MagicMock()
        saver.setup()

        execute_calls = cursor.execute.call_args_list
        create_calls = [c for c in execute_calls if "CREATE TABLE" in str(c)]
        assert len(create_calls) == 0


# ── get_tuple ────────────────────────────────────────────────────────


class TestGetTuple:

    def test_returns_none_when_no_checkpoint(self, saver, thread_config):
        cursor = saver.conn.cursor.return_value
        cursor.fetchone.return_value = None

        result = saver.get_tuple(thread_config)
        assert result is None

    def test_fetches_latest_when_no_checkpoint_id(self, saver, thread_config):
        cursor = saver.conn.cursor.return_value

        # Mock serde
        saver.serde = MagicMock()
        saver.serde.loads_typed.return_value = {"id": "cp-001", "v": 4}

        cursor.fetchone.return_value = (
            "cp-001",       # checkpoint_id
            None,           # parent_checkpoint_id
            "msgpack",      # type
            base64.b64encode(b'{"test": 1}').decode("ascii"),  # checkpoint_data (base64)
            '{"source": "input"}',  # metadata_data
        )
        cursor.fetchall.return_value = []  # no pending writes

        result = saver.get_tuple(thread_config)

        assert result is not None
        assert result.config["configurable"]["checkpoint_id"] == "cp-001"
        assert result.metadata == {"source": "input"}
        assert result.parent_config is None

    def test_fetches_specific_checkpoint_id(self, saver):
        config = {
            "configurable": {
                "thread_id": "thread-1",
                "checkpoint_ns": "",
                "checkpoint_id": "cp-002",
            }
        }
        cursor = saver.conn.cursor.return_value
        saver.serde = MagicMock()
        saver.serde.loads_typed.return_value = {"id": "cp-002", "v": 4}

        cursor.fetchone.return_value = (
            "cp-002", "cp-001", "msgpack", base64.b64encode(b'{}').decode("ascii"), '{}'
        )
        cursor.fetchall.return_value = []

        result = saver.get_tuple(config)
        assert result is not None
        assert result.config["configurable"]["checkpoint_id"] == "cp-002"
        assert result.parent_config["configurable"]["checkpoint_id"] == "cp-001"

        # Verify SQL contains the checkpoint_id filter
        sql = cursor.execute.call_args_list[0][0][0]
        assert "AND checkpoint_id = ?" in sql


# ── put ──────────────────────────────────────────────────────────────


class TestPut:

    def test_stores_checkpoint(self, saver, thread_config, sample_checkpoint):
        saver.serde = MagicMock()
        saver.serde.dumps_typed.return_value = ("msgpack", b'{"data": "ok"}')

        result = saver.put(thread_config, sample_checkpoint, {"source": "input"}, {})

        cursor = saver.conn.cursor.return_value
        cursor.execute.assert_called_once()
        saver.conn.commit.assert_called_once()

        assert result["configurable"]["checkpoint_id"] == "cp-001"
        assert result["configurable"]["thread_id"] == "thread-1"

    def test_upsert_sql_used(self, saver, thread_config, sample_checkpoint):
        saver.serde = MagicMock()
        saver.serde.dumps_typed.return_value = ("msgpack", b'{}')

        saver.put(thread_config, sample_checkpoint, {}, {})

        sql = saver.conn.cursor.return_value.execute.call_args[0][0]
        assert "UPSERT" in sql
        assert "WITH PRIMARY KEY" in sql


# ── put_writes ───────────────────────────────────────────────────────


class TestPutWrites:

    def test_stores_writes(self, saver, thread_config):
        config = {**thread_config, "configurable": {**thread_config["configurable"], "checkpoint_id": "cp-001"}}
        saver.serde = MagicMock()
        saver.serde.dumps_typed.return_value = ("msgpack", b'{"val": 1}')

        writes = [("messages", {"role": "user", "content": "hi"})]
        saver.put_writes(config, writes, task_id="task-1")

        cursor = saver.conn.cursor.return_value
        assert cursor.execute.call_count == 1
        saver.conn.commit.assert_called_once()

    def test_handles_none_values(self, saver, thread_config):
        config = {**thread_config, "configurable": {**thread_config["configurable"], "checkpoint_id": "cp-001"}}

        writes = [("channel_a", None)]
        saver.put_writes(config, writes, task_id="task-1")

        cursor = saver.conn.cursor.return_value
        args = cursor.execute.call_args[0][1]
        # type and blob should be None
        assert args[7] is None  # type
        assert args[8] is None  # blob_data


# ── delete_thread ────────────────────────────────────────────────────


class TestDeleteThread:

    def test_deletes_writes_and_checkpoints(self, saver):
        cursor = saver.conn.cursor.return_value

        saver.delete_thread("thread-99")

        assert cursor.execute.call_count == 2
        calls = [c[0][0] for c in cursor.execute.call_args_list]
        assert any(_WRITES_TABLE in c for c in calls)
        assert any(_CHECKPOINTS_TABLE in c for c in calls)
        saver.conn.commit.assert_called_once()

    def test_raises_on_failure(self, saver):
        cursor = saver.conn.cursor.return_value
        cursor.execute.side_effect = Exception("DB error")

        with pytest.raises(Exception, match="DB error"):
            saver.delete_thread("thread-99")


# ── list ─────────────────────────────────────────────────────────────


class TestList:

    def test_returns_empty_iterator_when_no_results(self, saver, thread_config):
        cursor = saver.conn.cursor.return_value
        cursor.fetchall.return_value = []

        results = list(saver.list(thread_config))
        assert results == []

    def test_applies_limit(self, saver, thread_config):
        cursor = saver.conn.cursor.return_value
        cursor.fetchall.return_value = []

        list(saver.list(thread_config, limit=5))

        sql = cursor.execute.call_args[0][0]
        assert "LIMIT 5" in sql

    def test_applies_before_filter(self, saver, thread_config):
        cursor = saver.conn.cursor.return_value
        cursor.fetchall.return_value = []
        before = {"configurable": {"checkpoint_id": "cp-005"}}

        list(saver.list(thread_config, before=before))

        sql = cursor.execute.call_args[0][0]
        assert "checkpoint_id < ?" in sql

    def test_applies_metadata_filter(self, saver, thread_config):
        cursor = saver.conn.cursor.return_value
        saver.serde = MagicMock()
        saver.serde.loads_typed.side_effect = [
            {"id": "cp-1"}, {"id": "cp-2"}
        ]

        b64_empty = base64.b64encode(b"{}").decode("ascii")
        cursor.fetchall.return_value = [
            ("cp-1", None, "msgpack", b64_empty, json.dumps({"source": "input"})),
            ("cp-2", None, "msgpack", b64_empty, json.dumps({"source": "loop"})),
        ]

        results = list(saver.list(thread_config, filter={"source": "input"}))
        assert len(results) == 1
        assert results[0].metadata["source"] == "input"
