"""Tests for HANASemanticLLMCache — uses mocked hdbcli connection and embeddings."""

from unittest.mock import MagicMock, patch

import pytest
from langchain_core.outputs import Generation

from langchain_hana_cache.llm_cache import HANASemanticLLMCache


# ── 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 mock_embedding():
    """Return a mock embedding model that produces 3-dimensional vectors."""
    emb = MagicMock()
    emb.embed_query.return_value = [0.1, 0.2, 0.3]
    return emb


@pytest.fixture
def cache(mock_conn, mock_embedding):
    """HANASemanticLLMCache with table creation skipped."""
    with patch.object(HANASemanticLLMCache, "setup"):
        c = HANASemanticLLMCache(
            connection=mock_conn,
            embedding=mock_embedding,
            table_name="TEST_CACHE",
            similarity_threshold=0.95,
            ttl_seconds=None,
        )
    return c


@pytest.fixture
def cache_with_ttl(mock_conn, mock_embedding):
    """HANASemanticLLMCache with TTL configured."""
    with patch.object(HANASemanticLLMCache, "setup"):
        c = HANASemanticLLMCache(
            connection=mock_conn,
            embedding=mock_embedding,
            table_name="TEST_CACHE",
            similarity_threshold=0.95,
            ttl_seconds=3600,
        )
    return c


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


class TestSetup:

    def test_creates_table_when_missing(self, mock_conn, mock_embedding):
        cursor = mock_conn.cursor.return_value
        # SYS.TABLES returns 0 (table not found)
        cursor.fetchone.return_value = (0,)

        cache = HANASemanticLLMCache.__new__(HANASemanticLLMCache)
        cache.conn = mock_conn
        cache.embedding = mock_embedding
        cache.table_name = "TEST_CACHE"
        cache._vector_dim = None
        cache.similarity_threshold = 0.95
        cache.ttl_seconds = None
        cache.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) == 1
        mock_conn.commit.assert_called_once()

    def test_skips_creation_when_table_exists(self, mock_conn, mock_embedding):
        cursor = mock_conn.cursor.return_value
        # SYS.TABLES returns 1 (table found)
        cursor.fetchone.return_value = (1,)

        cache = HANASemanticLLMCache.__new__(HANASemanticLLMCache)
        cache.conn = mock_conn
        cache.embedding = mock_embedding
        cache.table_name = "TEST_CACHE"
        cache._vector_dim = None
        cache.similarity_threshold = 0.95
        cache.ttl_seconds = None
        cache.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

    def test_idempotent(self, mock_conn, mock_embedding):
        """setup() does not fail if called twice."""
        cursor = mock_conn.cursor.return_value
        cursor.fetchone.return_value = (1,)

        cache = HANASemanticLLMCache.__new__(HANASemanticLLMCache)
        cache.conn = mock_conn
        cache.embedding = mock_embedding
        cache.table_name = "TEST_CACHE"
        cache._vector_dim = 3
        cache.similarity_threshold = 0.95
        cache.ttl_seconds = None

        cache.setup()
        cache.setup()
        # No error raised

    def test_detects_vector_dimension(self, mock_conn, mock_embedding):
        cursor = mock_conn.cursor.return_value
        cursor.fetchone.return_value = (0,)

        # embed_query returns 5-dimensional vector
        mock_embedding.embed_query.return_value = [0.1, 0.2, 0.3, 0.4, 0.5]

        cache = HANASemanticLLMCache.__new__(HANASemanticLLMCache)
        cache.conn = mock_conn
        cache.embedding = mock_embedding
        cache.table_name = "TEST_CACHE"
        cache._vector_dim = None
        cache.similarity_threshold = 0.95
        cache.ttl_seconds = None
        cache.setup()

        # Verify CREATE TABLE uses detected dimension
        create_sql = [
            c[0][0] for c in cursor.execute.call_args_list if "CREATE TABLE" in str(c)
        ][0]
        assert "REAL_VECTOR(5)" in create_sql

    def test_checks_table_via_sys_tables(self, mock_conn, mock_embedding):
        cursor = mock_conn.cursor.return_value
        cursor.fetchone.return_value = (1,)

        cache = HANASemanticLLMCache.__new__(HANASemanticLLMCache)
        cache.conn = mock_conn
        cache.embedding = mock_embedding
        cache.table_name = "MY_CACHE"
        cache._vector_dim = None
        cache.similarity_threshold = 0.95
        cache.ttl_seconds = None
        cache.setup()

        # First execute call should be the SYS.TABLES check
        first_call = cursor.execute.call_args_list[0]
        assert "SYS.TABLES" in first_call[0][0]
        assert first_call[0][1] == ("MY_CACHE",)


# ── Lookup ───────────────────────────────────────────────────────────


class TestLookup:

    def test_returns_none_on_cache_miss(self, cache):
        cursor = cache.conn.cursor.return_value
        cursor.fetchone.return_value = None

        result = cache.lookup("What is AI?", "gpt-4o")
        assert result is None

    def test_returns_cached_generations_on_hit(self, cache):
        cursor = cache.conn.cursor.return_value
        response_data = '[{"text": "AI is artificial intelligence."}]'
        cursor.fetchone.return_value = (
            "What is AI?",        # PROMPT_TEXT
            response_data,        # RESPONSE
            "gpt-4o",             # LLM_STRING
            0.98,                 # SIMILARITY
        )

        result = cache.lookup("Tell me about AI", "gpt-4o")

        assert result is not None
        assert len(result) == 1
        assert result[0].text == "AI is artificial intelligence."

    def test_returns_none_below_threshold(self, cache):
        """Similarity below threshold means no match — the SQL WHERE clause filters it."""
        cursor = cache.conn.cursor.return_value
        cursor.fetchone.return_value = None  # HANA returns nothing below threshold

        result = cache.lookup("Something unrelated", "gpt-4o")
        assert result is None

    def test_respects_llm_string_scoping(self, cache):
        """Different LLM_STRING = different cache scope via LLM_STRING_HASH."""
        cursor = cache.conn.cursor.return_value
        cursor.fetchone.return_value = None

        cache.lookup("What is AI?", "gpt-3.5-turbo")

        # Verify LLM_STRING_HASH is passed in the query params
        from langchain_hana_cache.utils import hash_prompt

        params = cursor.execute.call_args[0][1]
        expected_hash = hash_prompt("gpt-3.5-turbo")
        assert expected_hash in params

    def test_respects_ttl(self, cache_with_ttl):
        """TTL adds a SECONDS_BETWEEN filter to the query."""
        cursor = cache_with_ttl.conn.cursor.return_value
        cursor.fetchone.return_value = None

        cache_with_ttl.lookup("What is AI?", "gpt-4o")

        sql = cursor.execute.call_args[0][0]
        assert "SECONDS_BETWEEN" in sql
        params = cursor.execute.call_args[0][1]
        assert 3600 in params

    def test_no_ttl_filter_without_ttl(self, cache):
        """Without TTL, no SECONDS_BETWEEN in the query."""
        cursor = cache.conn.cursor.return_value
        cursor.fetchone.return_value = None

        cache.lookup("What is AI?", "gpt-4o")

        sql = cursor.execute.call_args[0][0]
        assert "SECONDS_BETWEEN" not in sql

    def test_updates_access_on_hit(self, cache):
        cursor = cache.conn.cursor.return_value
        response_data = '[{"text": "cached answer"}]'
        cursor.fetchone.return_value = (
            "original prompt",
            response_data,
            "gpt-4o",
            0.99,
        )

        cache.lookup("similar prompt", "gpt-4o")

        # Should have two execute calls: SELECT + UPDATE
        assert cursor.execute.call_count == 2
        update_sql = cursor.execute.call_args_list[1][0][0]
        assert "LAST_ACCESSED" in update_sql
        assert "HIT_COUNT" in update_sql
        cache.conn.commit.assert_called_once()

    def test_embeds_the_prompt(self, cache):
        cursor = cache.conn.cursor.return_value
        cursor.fetchone.return_value = None

        cache.lookup("What is AI?", "gpt-4o")

        cache.embedding.embed_query.assert_called_once_with("What is AI?")

    def test_multiple_generations_in_response(self, cache):
        cursor = cache.conn.cursor.return_value
        response_data = '[{"text": "answer 1"}, {"text": "answer 2"}]'
        cursor.fetchone.return_value = (
            "prompt",
            response_data,
            "gpt-4o",
            0.97,
        )

        result = cache.lookup("prompt", "gpt-4o")

        assert result is not None
        assert len(result) == 2
        assert result[0].text == "answer 1"
        assert result[1].text == "answer 2"


# ── Update ───────────────────────────────────────────────────────────


class TestUpdate:

    def test_stores_prompt_and_response(self, cache):
        cursor = cache.conn.cursor.return_value
        generations = [Generation(text="The answer is 42.")]

        cache.update("What is the meaning of life?", "gpt-4o", generations)

        cursor.execute.assert_called_once()
        cache.conn.commit.assert_called_once()

    def test_upsert_sql_used(self, cache):
        cursor = cache.conn.cursor.return_value
        generations = [Generation(text="response")]

        cache.update("prompt", "gpt-4o", generations)

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

    def test_stores_prompt_hash(self, cache):
        cursor = cache.conn.cursor.return_value
        generations = [Generation(text="response")]

        cache.update("test prompt", "gpt-4o", generations)

        params = cursor.execute.call_args[0][1]
        prompt_hash = params[0]
        assert len(prompt_hash) == 64  # SHA-256 hex

    def test_stores_embedding_vector(self, cache):
        cursor = cache.conn.cursor.return_value
        generations = [Generation(text="response")]

        cache.update("test prompt", "gpt-4o", generations)

        cache.embedding.embed_query.assert_called_once_with("test prompt")
        params = cursor.execute.call_args[0][1]
        vector_str = params[2]
        assert "[0.1, 0.2, 0.3]" in vector_str

    def test_stores_llm_string_hash_and_value(self, cache):
        cursor = cache.conn.cursor.return_value
        generations = [Generation(text="response")]

        cache.update("prompt", "gpt-4o", generations)

        from langchain_hana_cache.utils import hash_prompt

        params = cursor.execute.call_args[0][1]
        assert params[3] == hash_prompt("gpt-4o")  # LLM_STRING_HASH
        assert params[4] == "gpt-4o"  # LLM_STRING

    def test_stores_serialized_response(self, cache):
        cursor = cache.conn.cursor.return_value
        generations = [Generation(text="Hello, world!")]

        cache.update("prompt", "gpt-4o", generations)

        params = cursor.execute.call_args[0][1]
        response_data = params[5]
        assert "Hello, world!" in response_data

    def test_duplicate_prompt_upserts(self, cache):
        """Writing the same prompt twice should upsert, not error."""
        cursor = cache.conn.cursor.return_value
        generations = [Generation(text="response")]

        cache.update("same prompt", "gpt-4o", generations)
        cache.update("same prompt", "gpt-4o", generations)

        assert cursor.execute.call_count == 2
        assert cache.conn.commit.call_count == 2


# ── Clear ────────────────────────────────────────────────────────────


class TestClear:

    def test_deletes_all_entries(self, cache):
        cursor = cache.conn.cursor.return_value

        cache.clear()

        cursor.execute.assert_called_once()
        sql = cursor.execute.call_args[0][0]
        assert "DELETE FROM" in sql
        cache.conn.commit.assert_called_once()


# ── Eviction ─────────────────────────────────────────────────────────


class TestEviction:

    def test_evict_expired_removes_old_entries(self, cache_with_ttl):
        cursor = cache_with_ttl.conn.cursor.return_value
        cursor.rowcount = 5

        deleted = cache_with_ttl.evict_expired()

        assert deleted == 5
        sql = cursor.execute.call_args[0][0]
        assert "SECONDS_BETWEEN" in sql
        params = cursor.execute.call_args[0][1]
        assert params == (3600,)
        cache_with_ttl.conn.commit.assert_called_once()

    def test_evict_expired_raises_without_ttl(self, cache):
        with pytest.raises(ValueError, match="no TTL configured"):
            cache.evict_expired()

    def test_evict_lru_keeps_max_entries(self, cache):
        cursor = cache.conn.cursor.return_value
        cursor.rowcount = 3

        deleted = cache.evict_lru(max_entries=100)

        assert deleted == 3
        sql = cursor.execute.call_args[0][0]
        assert "LAST_ACCESSED" in sql
        assert "LIMIT" in sql
        params = cursor.execute.call_args[0][1]
        assert params == (100,)
        cache.conn.commit.assert_called_once()

    def test_evict_lru_default_max(self, cache):
        cursor = cache.conn.cursor.return_value
        cursor.rowcount = 0

        cache.evict_lru()

        params = cursor.execute.call_args[0][1]
        assert params == (1000,)


# ── Context Manager ──────────────────────────────────────────────────


class TestContextManager:

    def test_enter_returns_self(self, cache):
        assert cache.__enter__() is cache

    def test_exit_closes_connection(self, cache):
        cache.__exit__(None, None, None)
        cache.conn.close.assert_called_once()

    def test_exit_ignores_close_errors(self, cache):
        cache.conn.close.side_effect = Exception("connection error")
        cache.__exit__(None, None, None)  # Should not raise
