"""Tests for P1 high-priority fixes in LogosADK."""

from __future__ import annotations

import asyncio
import time

import pytest

# ---------------------------------------------------------------------------
# P1.1 — Resource leaks: close methods, logging in observer errors
# ---------------------------------------------------------------------------


class TestResourceLeaks:
    """Verify resource leak fixes."""

    def test_sqlite_cache_has_close_method(self):
        """SQLiteCacheBackend should have a close() method."""
        from logos_adk.cache.sqlite import SQLiteCacheBackend
        import tempfile
        import os

        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "test.db")
            backend = SQLiteCacheBackend(path=path)
            assert hasattr(backend, "close")
            assert callable(backend.close)
            backend.close()
            # Double-close should be safe
            backend.close()

    def test_sqlite_cache_del_calls_close(self):
        """SQLiteCacheBackend.__del__ should call close."""
        from logos_adk.cache.sqlite import SQLiteCacheBackend
        import tempfile
        import os

        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "test.db")
            backend = SQLiteCacheBackend(path=path)
            assert hasattr(backend, "__del__")
            # Trigger __del__
            backend.__del__()

    def test_event_bus_observer_errors_logged(self):
        """Observer exceptions should be handled, not silently swallowed."""
        from logos_adk.event_bus import InMemoryEventBus

        async def _run():
            bus = InMemoryEventBus()
            error_raised = False

            async def bad_observer(record):
                nonlocal error_raised
                error_raised = True
                raise ValueError("boom")

            bus.on_publish(bad_observer)
            bus.publish("test.topic", {"key": "value"})
            await asyncio.sleep(0.1)
            assert error_raised

        asyncio.run(_run())

    def test_event_bus_done_callback_suppresses_exception(self):
        """Fire-and-forget tasks should have done_callback that consumes exceptions."""
        from logos_adk.event_bus import InMemoryEventBus

        async def _run():
            bus = InMemoryEventBus()

            async def failing_observer(record):
                raise RuntimeError("task fail")

            bus.on_publish(failing_observer)
            bus.publish("test.topic", {"data": 1})
            await asyncio.sleep(0.1)

        asyncio.run(_run())


# ---------------------------------------------------------------------------
# P1.2 — Connection pooling
# ---------------------------------------------------------------------------


class TestConnectionPooling:
    """Verify connection pooling infrastructure."""

    def test_pooled_connect_fallback_without_pool(self):
        """pooled_connect should work even without psycopg_pool installed."""
        from logos_adk.postgres_utils import get_connection_pool

        # get_connection_pool with a bogus DSN — should return None if psycopg_pool
        # is not installed, or a pool if it is
        pool = get_connection_pool("postgresql://localhost:5432/nonexistent_test_db")
        # Either way, get_connection_pool should not raise
        assert pool is None or pool is not None

    def test_pool_registry_caches_by_dsn(self):
        """Same DSN should return same pool object."""
        from logos_adk.postgres_utils import get_connection_pool

        dsn = "postgresql://localhost:5432/test_pool_cache"
        pool1 = get_connection_pool(dsn)
        pool2 = get_connection_pool(dsn)
        assert pool1 is pool2

    def test_close_all_pools_clears_registry(self):
        """close_all_pools should clear the registry."""
        from logos_adk.postgres_utils import close_all_pools, _pool_registry

        close_all_pools()
        assert len(_pool_registry) == 0


# ---------------------------------------------------------------------------
# P1.3 — Circuit breaker
# ---------------------------------------------------------------------------


class TestCircuitBreaker:
    """Verify circuit breaker implementation."""

    def test_initial_state_is_closed(self):
        from logos_adk.circuit_breaker import CircuitBreaker

        cb = CircuitBreaker("test")
        assert cb.state == CircuitBreaker.CLOSED
        assert cb.failure_count == 0

    def test_stays_closed_on_success(self):
        from logos_adk.circuit_breaker import CircuitBreaker

        cb = CircuitBreaker("test")
        with cb:
            pass
        assert cb.state == CircuitBreaker.CLOSED
        assert cb.failure_count == 0

    def test_opens_after_threshold_failures(self):
        from logos_adk.circuit_breaker import CircuitBreaker, CircuitBreakerConfig, CircuitOpenError

        cb = CircuitBreaker("test", config=CircuitBreakerConfig(failure_threshold=3))
        for _ in range(3):
            try:
                with cb:
                    raise RuntimeError("fail")
            except RuntimeError:
                pass
        assert cb.state == CircuitBreaker.OPEN
        with pytest.raises(CircuitOpenError):
            with cb:
                pass

    def test_transitions_to_half_open_after_timeout(self):
        from logos_adk.circuit_breaker import CircuitBreaker, CircuitBreakerConfig

        cb = CircuitBreaker(
            "test",
            config=CircuitBreakerConfig(
                failure_threshold=2,
                reset_timeout_seconds=0.1,
            ),
        )
        for _ in range(2):
            try:
                with cb:
                    raise RuntimeError("fail")
            except RuntimeError:
                pass
        assert cb.state == CircuitBreaker.OPEN
        time.sleep(0.15)
        assert cb.state == CircuitBreaker.HALF_OPEN

    def test_half_open_success_closes_circuit(self):
        from logos_adk.circuit_breaker import CircuitBreaker, CircuitBreakerConfig

        cb = CircuitBreaker(
            "test",
            config=CircuitBreakerConfig(
                failure_threshold=2,
                reset_timeout_seconds=0.1,
                half_open_max_calls=1,
            ),
        )
        for _ in range(2):
            try:
                with cb:
                    raise RuntimeError("fail")
            except RuntimeError:
                pass
        time.sleep(0.15)
        assert cb.state == CircuitBreaker.HALF_OPEN
        with cb:
            pass  # success
        assert cb.state == CircuitBreaker.CLOSED

    def test_half_open_failure_reopens_circuit(self):
        from logos_adk.circuit_breaker import CircuitBreaker, CircuitBreakerConfig

        cb = CircuitBreaker(
            "test",
            config=CircuitBreakerConfig(
                failure_threshold=2,
                reset_timeout_seconds=0.1,
            ),
        )
        for _ in range(2):
            try:
                with cb:
                    raise RuntimeError("fail")
            except RuntimeError:
                pass
        time.sleep(0.15)
        assert cb.state == CircuitBreaker.HALF_OPEN
        try:
            with cb:
                raise RuntimeError("fail again")
        except RuntimeError:
            pass
        assert cb.state == CircuitBreaker.OPEN

    def test_reset_clears_state(self):
        from logos_adk.circuit_breaker import CircuitBreaker, CircuitBreakerConfig

        cb = CircuitBreaker("test", config=CircuitBreakerConfig(failure_threshold=2))
        for _ in range(2):
            try:
                with cb:
                    raise RuntimeError("fail")
            except RuntimeError:
                pass
        assert cb.state == CircuitBreaker.OPEN
        cb.reset()
        assert cb.state == CircuitBreaker.CLOSED
        assert cb.failure_count == 0

    def test_async_context_manager(self):
        from logos_adk.circuit_breaker import CircuitBreaker

        cb = CircuitBreaker("test")

        async def run():
            async with cb:
                return 42

        result = asyncio.run(run())
        assert result == 42
        assert cb.state == CircuitBreaker.CLOSED

    def test_async_failure_counts(self):
        from logos_adk.circuit_breaker import CircuitBreaker, CircuitBreakerConfig

        cb = CircuitBreaker("test", config=CircuitBreakerConfig(failure_threshold=2))

        async def fail():
            async with cb:
                raise ValueError("async fail")

        async def run():
            for _ in range(2):
                with pytest.raises(ValueError):
                    await fail()

        asyncio.run(run())
        assert cb.state == CircuitBreaker.OPEN

    def test_excluded_exceptions_not_counted(self):
        from logos_adk.circuit_breaker import CircuitBreaker, CircuitBreakerConfig

        cb = CircuitBreaker(
            "test",
            config=CircuitBreakerConfig(
                failure_threshold=2,
                excluded_exceptions=(ValueError,),
            ),
        )
        for _ in range(5):
            try:
                with cb:
                    raise ValueError("excluded")
            except ValueError:
                pass
        # Should still be closed — ValueError is excluded
        assert cb.state == CircuitBreaker.CLOSED
        assert cb.failure_count == 0

    def test_circuit_open_error_attributes(self):
        from logos_adk.circuit_breaker import CircuitOpenError

        err = CircuitOpenError("my-breaker", 15.5)
        assert err.name == "my-breaker"
        assert err.retry_after == 15.5
        assert "my-breaker" in str(err)

    def test_mcp_client_has_circuit_breaker(self):
        """MCPClient should integrate circuit breaker."""
        from logos_adk.mcp.client import MCPClient

        client = MCPClient(command="echo hello")
        assert hasattr(client, "_circuit_breaker")
        from logos_adk.circuit_breaker import CircuitBreaker

        assert isinstance(client._circuit_breaker, CircuitBreaker)


# ---------------------------------------------------------------------------
# P1.4 — Dead Letter Queue
# ---------------------------------------------------------------------------


class TestDeadLetterQueue:
    """Verify DLQ functionality in EventBus."""

    def test_default_max_attempts(self):
        from logos_adk.event_bus import InMemoryEventBus

        bus = InMemoryEventBus()
        assert bus.max_attempts == 5

    def test_custom_max_attempts(self):
        from logos_adk.event_bus import InMemoryEventBus

        bus = InMemoryEventBus(max_attempts=3)
        assert bus.max_attempts == 3

    def test_event_moves_to_dead_letter_after_max_attempts(self):
        from logos_adk.event_bus import InMemoryEventBus

        bus = InMemoryEventBus(max_attempts=2)
        record = bus.publish("test.dlq", {"data": "hello"})

        # Claim and requeue twice (attempt goes to 1, then 2)
        claimed = bus.claim("worker", ["test.dlq"])
        assert claimed is not None
        assert claimed.attempt == 1
        bus.requeue(claimed.id, "worker", error="fail 1")

        claimed2 = bus.claim("worker", ["test.dlq"])
        assert claimed2 is not None
        assert claimed2.attempt == 2
        # Now requeue — attempt=2 >= max_attempts=2 → dead_letter
        bus.requeue(claimed2.id, "worker", error="fail 2")

        event = bus.get_event(record.id)
        assert event is not None
        assert event.status == "dead_letter"
        assert event.last_error == "fail 2"

    def test_dead_letter_not_claimable(self):
        from logos_adk.event_bus import InMemoryEventBus

        bus = InMemoryEventBus(max_attempts=1)
        record = bus.publish("test.dlq", {"data": 1})

        claimed = bus.claim("worker", ["test.dlq"])
        assert claimed is not None
        bus.requeue(claimed.id, "worker", error="fail")

        event = bus.get_event(record.id)
        assert event.status == "dead_letter"

        # Should not be claimable
        claimed_again = bus.claim("worker", ["test.dlq"])
        assert claimed_again is None

    def test_list_dead_letters(self):
        from logos_adk.event_bus import InMemoryEventBus

        bus = InMemoryEventBus(max_attempts=1)
        r1 = bus.publish("test.dlq", {"item": 1})
        r2 = bus.publish("test.dlq", {"item": 2})
        r3 = bus.publish("other.topic", {"item": 3})

        # Move r1 and r3 to dead letter
        for eid in [r1.id, r2.id, r3.id]:
            claimed = bus.claim("worker", ["test.dlq", "other.topic"])
            if claimed:
                bus.requeue(claimed.id, "worker", error="fail")

        dead = bus.list_dead_letters()
        assert len(dead) == 3

        dead_topic = bus.list_dead_letters(topic="test.dlq")
        assert len(dead_topic) == 2

    def test_requeue_dead_letter(self):
        from logos_adk.event_bus import InMemoryEventBus

        bus = InMemoryEventBus(max_attempts=1)
        record = bus.publish("test.dlq", {"data": 1})

        claimed = bus.claim("worker", ["test.dlq"])
        bus.requeue(claimed.id, "worker", error="fail")

        event = bus.get_event(record.id)
        assert event.status == "dead_letter"

        # Requeue from dead letter
        result = bus.requeue_dead_letter(record.id)
        assert result is True

        event = bus.get_event(record.id)
        assert event.status == "queued"
        assert event.attempt == 0
        assert event.last_error is None

        # Can be claimed again
        claimed_again = bus.claim("worker", ["test.dlq"])
        assert claimed_again is not None

    def test_requeue_dead_letter_returns_false_for_nonexistent(self):
        from logos_adk.event_bus import InMemoryEventBus

        bus = InMemoryEventBus()
        assert bus.requeue_dead_letter("nonexistent") is False

    def test_requeue_dead_letter_returns_false_for_non_dead(self):
        from logos_adk.event_bus import InMemoryEventBus

        bus = InMemoryEventBus()
        record = bus.publish("test.topic", {"data": 1})
        assert bus.requeue_dead_letter(record.id) is False

    def test_normal_requeue_below_max_attempts(self):
        """Events below max_attempts should requeue normally."""
        from logos_adk.event_bus import InMemoryEventBus

        bus = InMemoryEventBus(max_attempts=5)
        record = bus.publish("test.topic", {"data": 1})

        claimed = bus.claim("worker", ["test.topic"])
        bus.requeue(claimed.id, "worker", error="fail 1")

        event = bus.get_event(record.id)
        assert event.status == "queued"
        assert event.last_error == "fail 1"

    def test_sqlite_dlq(self):
        """SQLiteEventBus should support DLQ."""
        import tempfile
        import os
        from logos_adk.event_bus import SQLiteEventBus

        with tempfile.TemporaryDirectory() as tmp:
            path = os.path.join(tmp, "test_dlq.db")
            bus = SQLiteEventBus(path=path, max_attempts=1)

            record = bus.publish("test.dlq", {"data": 1})
            claimed = bus.claim("worker", ["test.dlq"])
            assert claimed is not None
            bus.requeue(claimed.id, "worker", error="fail")

            event = bus.get_event(record.id)
            assert event.status == "dead_letter"

            # List dead letters
            dead = bus.list_dead_letters()
            assert len(dead) == 1

            # Requeue from dead letter
            result = bus.requeue_dead_letter(record.id)
            assert result is True

            event = bus.get_event(record.id)
            assert event.status == "queued"
            assert event.attempt == 0
