"""Phase B: Error Handling tests (Level 1-3).

Level 1: Logging — verify warnings are emitted on failures.
Level 2: Retry — verify exponential backoff for transient errors.
Level 3: Degraded mode — verify embedding_status, SQLite fallback fixes, bugs.
"""
import logging
import time
from unittest.mock import patch

import pytest

from conftest import MockAIClient
from trustmem.ai.client import (
    _is_transient_error,
    retry_on_transient,
)
from trustmem.handler.persistent import PersistentHandler
from trustmem.vectorstore.base import VectorStore


# ============================================================
# Mock AI Clients for error simulation
# ============================================================


class FailingEmbedAIClient:
    """AI client that always fails on embed()."""

    def embed(self, text: str) -> list[float]:
        raise RuntimeError("quota exceeded")

    def llm_judge(self, prompt: str) -> str:
        return '{"facts": [], "entities": [], "relations": []}'


class FailingAfterNAIClient:
    """AI client that fails on first N embed() calls, then succeeds."""

    def __init__(self, fail_count: int = 2, dim: int = 8):
        self._fail_count = fail_count
        self._call_count = 0
        self._dim = dim

    def embed(self, text: str) -> list[float]:
        self._call_count += 1
        if self._call_count <= self._fail_count:
            raise RuntimeError("temporary failure")
        h = hash(text) & 0xFFFFFFFF
        vec = []
        for i in range(self._dim):
            val = ((h >> (i * 4)) & 0xF) / 15.0
            vec.append(val)
        norm = sum(v * v for v in vec) ** 0.5
        if norm > 0:
            vec = [v / norm for v in vec]
        return vec

    def llm_judge(self, prompt: str) -> str:
        return '{"facts": [], "entities": [], "relations": []}'


class WorkingAIClient:
    """AI client that always succeeds."""

    def __init__(self, dim: int = 8):
        self._dim = dim

    def embed(self, text: str) -> list[float]:
        h = hash(text) & 0xFFFFFFFF
        vec = []
        for i in range(self._dim):
            val = ((h >> (i * 4)) & 0xF) / 15.0
            vec.append(val)
        norm = sum(v * v for v in vec) ** 0.5
        if norm > 0:
            vec = [v / norm for v in vec]
        return vec

    def llm_judge(self, prompt: str) -> str:
        return '{"facts": [], "entities": [], "relations": []}'


class FailingVectorStore(VectorStore):
    """VectorStore that fails on upsert."""

    def upsert(self, id, vector, payload):
        raise RuntimeError("vectorstore connection lost")

    def search(self, query_vector, limit=10, filters=None):
        return []

    def delete(self, id):
        pass

    def update_payload(self, id, payload):
        pass


class FailingSearchVectorStore(VectorStore):
    """VectorStore that fails on search."""

    def upsert(self, id, vector, payload):
        pass

    def search(self, query_vector, limit=10, filters=None):
        raise RuntimeError("vectorstore search timeout")

    def delete(self, id):
        pass

    def update_payload(self, id, payload):
        pass


# ============================================================
# Level 1: Logging Tests
# ============================================================


class TestLogging:
    """Level 1: Verify warnings are emitted on failures."""

    def test_embedding_failure_logs_warning(self, caplog):
        """add_memory with embedding failure emits warning log."""
        handler = PersistentHandler(
            database_url=":memory:",
            ai_client=FailingEmbedAIClient(),
        )
        with caplog.at_level(logging.WARNING, logger="trustmem.handler.persistent"):
            result = handler.add_memory("test content", user_id="agent1")

        assert result["memory_id"]  # Memory still saved
        assert any("Embedding failed" in msg for msg in caplog.messages)

    def test_vectorstore_upsert_failure_logs_warning(self, caplog):
        """add_memory with VS upsert failure emits warning log."""
        handler = PersistentHandler(
            database_url=":memory:",
            ai_client=WorkingAIClient(),
            vectorstore=FailingVectorStore(),
        )
        with caplog.at_level(logging.WARNING, logger="trustmem.handler.persistent"):
            result = handler.add_memory("test content", user_id="agent1")

        assert result["memory_id"]  # Memory still saved
        assert any("VectorStore upsert failed" in msg for msg in caplog.messages)

    def test_search_failure_logs_warning(self, caplog):
        """search_memory with VS search failure emits warning log."""
        handler = PersistentHandler(
            database_url=":memory:",
            ai_client=WorkingAIClient(),
            vectorstore=FailingSearchVectorStore(),
        )
        # Add a memory first so SQLite fallback has data
        handler.add_memory("test content about dogs", user_id="agent1")

        with caplog.at_level(logging.WARNING, logger="trustmem.handler.persistent"):
            result = handler.search_memory("dogs", user_id="agent1")

        assert any("Search failed" in msg or "VectorStore search failed" in msg for msg in caplog.messages)

    def test_delete_vs_failure_logs_warning(self, caplog):
        """delete_memory with VS delete failure emits warning log."""
        handler = PersistentHandler(
            database_url=":memory:",
            ai_client=WorkingAIClient(),
        )
        vs = handler._vs
        result = handler.add_memory("test content", user_id="agent1")
        mid = result["memory_id"]

        # Make VS delete fail
        original_delete = vs.delete
        vs.delete = lambda id: (_ for _ in ()).throw(RuntimeError("delete failed"))

        with caplog.at_level(logging.WARNING, logger="trustmem.handler.persistent"):
            deleted = handler.delete_memory(mid)

        assert deleted  # SQLite delete still succeeds
        assert any("VectorStore delete failed" in msg for msg in caplog.messages)


# ============================================================
# Level 2: Retry Tests
# ============================================================


class _FakeRateLimitError(Exception):
    """Simulates OpenAI RateLimitError."""
    pass


# Make the class name match what _is_transient_error checks
_FakeRateLimitError.__name__ = "RateLimitError"


class _FakeAuthError(Exception):
    """Simulates OpenAI AuthenticationError."""
    pass


_FakeAuthError.__name__ = "AuthenticationError"


class _FakeStatusError(Exception):
    """Simulates OpenAI APIStatusError with status_code."""
    def __init__(self, status_code: int, message: str = "error"):
        super().__init__(message)
        self.status_code = status_code


class TestIsTransientError:
    """Test _is_transient_error classification."""

    def test_rate_limit_is_transient(self):
        assert _is_transient_error(_FakeRateLimitError("rate limited")) is True

    def test_auth_error_is_not_transient(self):
        assert _is_transient_error(_FakeAuthError("invalid key")) is False

    def test_status_429_is_transient(self):
        assert _is_transient_error(_FakeStatusError(429)) is True

    def test_status_503_is_transient(self):
        assert _is_transient_error(_FakeStatusError(503)) is True

    def test_status_400_is_not_transient(self):
        assert _is_transient_error(_FakeStatusError(400)) is False

    def test_status_401_is_not_transient(self):
        assert _is_transient_error(_FakeStatusError(401)) is False

    def test_generic_error_is_not_transient(self):
        assert _is_transient_error(ValueError("bad value")) is False


class TestRetryOnTransient:
    """Level 2: Retry decorator tests."""

    def test_retry_succeeds_after_transient_failures(self):
        """Function succeeds after transient errors are retried."""
        call_count = 0

        @retry_on_transient(max_retries=3, base_delay=0.01)
        def flaky_func():
            nonlocal call_count
            call_count += 1
            if call_count <= 2:
                raise _FakeRateLimitError("rate limited")
            return "success"

        result = flaky_func()
        assert result == "success"
        assert call_count == 3  # 2 failures + 1 success

    def test_no_retry_on_permanent_error(self):
        """Permanent errors are raised immediately without retry."""
        call_count = 0

        @retry_on_transient(max_retries=3, base_delay=0.01)
        def auth_fail():
            nonlocal call_count
            call_count += 1
            raise _FakeAuthError("invalid API key")

        with pytest.raises(_FakeAuthError):
            auth_fail()
        assert call_count == 1  # No retry

    def test_max_retries_then_raise(self):
        """After exhausting retries, the last exception is raised."""
        call_count = 0

        @retry_on_transient(max_retries=2, base_delay=0.01)
        def always_fail():
            nonlocal call_count
            call_count += 1
            raise _FakeRateLimitError("rate limited")

        with pytest.raises(_FakeRateLimitError):
            always_fail()
        assert call_count == 3  # 1 initial + 2 retries

    def test_exponential_backoff_timing(self):
        """Verify delays increase exponentially."""
        delays = []
        original_sleep = time.sleep

        def mock_sleep(seconds):
            delays.append(seconds)
            # Don't actually sleep in tests

        @retry_on_transient(max_retries=3, base_delay=1.0, max_delay=30.0)
        def always_fail():
            raise _FakeRateLimitError("rate limited")

        with patch("trustmem.ai.client.time.sleep", side_effect=mock_sleep):
            with pytest.raises(_FakeRateLimitError):
                always_fail()

        assert len(delays) == 3  # 3 retries = 3 sleeps
        assert delays[0] == pytest.approx(1.0)   # base_delay * 2^0
        assert delays[1] == pytest.approx(2.0)   # base_delay * 2^1
        assert delays[2] == pytest.approx(4.0)   # base_delay * 2^2

    def test_max_delay_cap(self):
        """Verify delay is capped at max_delay."""
        delays = []

        @retry_on_transient(max_retries=5, base_delay=10.0, max_delay=15.0)
        def always_fail():
            raise _FakeRateLimitError("rate limited")

        with patch("trustmem.ai.client.time.sleep", side_effect=lambda s: delays.append(s)):
            with pytest.raises(_FakeRateLimitError):
                always_fail()

        # All delays should be <= max_delay
        assert all(d <= 15.0 for d in delays)
        # Later delays should hit the cap
        assert delays[-1] == 15.0

    def test_no_retry_needed(self):
        """Function that succeeds immediately is not retried."""
        call_count = 0

        @retry_on_transient(max_retries=3, base_delay=0.01)
        def success_func():
            nonlocal call_count
            call_count += 1
            return "ok"

        result = success_func()
        assert result == "ok"
        assert call_count == 1

    def test_status_code_based_retry(self):
        """Retry on status code 503 (APIStatusError)."""
        call_count = 0

        @retry_on_transient(max_retries=2, base_delay=0.01)
        def server_error():
            nonlocal call_count
            call_count += 1
            if call_count <= 1:
                raise _FakeStatusError(503, "service unavailable")
            return "recovered"

        result = server_error()
        assert result == "recovered"
        assert call_count == 2


# ============================================================
# Level 3: Degraded Mode Tests
# ============================================================


class TestEmbeddingStatus:
    """Level 3: Verify embedding_status in add_memory return value."""

    def test_embedding_status_success(self):
        """Normal case: embedding_status='success'."""
        handler = PersistentHandler(
            database_url=":memory:",
            ai_client=WorkingAIClient(),
        )
        result = handler.add_memory("test content", user_id="agent1")
        assert result["embedding_status"] == "success"
        assert result["memory_id"]

    def test_embedding_status_failed(self):
        """Embedding failure: embedding_status='failed', memory still saved."""
        handler = PersistentHandler(
            database_url=":memory:",
            ai_client=FailingEmbedAIClient(),
        )
        result = handler.add_memory("test content", user_id="agent1")
        assert result["embedding_status"] == "failed"
        assert result["memory_id"]
        assert result["confidence"] in ("high", "medium", "low")

        # Memory is in SQLite
        listed = handler.list_memories(user_id="agent1")
        assert listed["count"] == 1

    def test_embedding_status_partial(self):
        """VS upsert failure: embedding_status='partial'."""
        handler = PersistentHandler(
            database_url=":memory:",
            ai_client=WorkingAIClient(),
            vectorstore=FailingVectorStore(),
        )
        result = handler.add_memory("test content", user_id="agent1")
        assert result["embedding_status"] == "partial"
        assert result["memory_id"]

    def test_memory_saved_after_embedding_failure(self):
        """After embedding failure, memory is still persisted in SQLite."""
        handler = PersistentHandler(
            database_url=":memory:",
            ai_client=FailingEmbedAIClient(),
        )
        result = handler.add_memory("I love dogs and cats", user_id="agent1")
        assert result["memory_id"]
        assert result["embedding_status"] == "failed"

        # Memory is saved and retrievable via list_memories
        listed = handler.list_memories(user_id="agent1")
        assert listed["count"] == 1
        assert listed["memories"][0]["content"] == "I love dogs and cats"


class TestProvenanceBugFix:
    """Bug fix: _find_provenance_parents was using r.get('memory_id') instead of r.get('id')."""

    def test_vectorstore_search_results_have_id_key(self):
        """VectorStore search results must have 'id' key (not 'memory_id')."""
        ai = WorkingAIClient()
        handler = PersistentHandler(
            database_url=":memory:",
            ai_client=ai,
        )
        vs = handler._vs

        # Add memories so the vectorstore has entries to return
        handler.add_memory("I like chocolate ice cream", user_id="agent1")
        handler.add_memory("I like chocolate ice cream very much", user_id="agent1")

        # Verify VS returns results with correct key structure
        vec = ai.embed("test query")
        search_results = vs.search(query_vector=vec, limit=5)
        assert len(search_results) > 0, "Expected search results from vectorstore"
        for r in search_results:
            assert "id" in r, "VectorStore search result must have 'id' key"
            assert r.get("id") is not None
