"""
tests/test_event_system.py — M19 Event System test suite (#183).

Coverage:
  - EventType enum completeness
  - CortexEvent dataclass construction / immutability
  - EventEmitter.on() listener registration
  - EventEmitter.emit() dispatches to listener(s)
  - Multiple listeners called in registration order
  - Async listeners supported
  - Listener exception does NOT propagate
  - emit() persists to DB via storage.log_event
  - emit() with storage=None still dispatches (graceful degradation)
  - SQLiteAdapter.log_event persists a row
  - SQLiteAdapter.get_events returns rows, supports filters
  - EventEmitter wired into CortexPlugin
  - HTTP GET /api/v1/events returns events
  - HTTP GET /api/v1/events honours event_type filter
  - HTTP GET /api/v1/events handles storage unavailable (503)
"""
from __future__ import annotations

import asyncio
import json
import os
import tempfile
from datetime import datetime, timezone
from typing import List
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
import pytest_asyncio

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest_asyncio.fixture
async def db_adapter():
    """Fresh SQLiteAdapter backed by a temp file."""
    from cortex.storage.sqlite_adapter import SQLiteAdapter

    with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
        db_path = f.name

    adapter = SQLiteAdapter(db_path=db_path)
    await adapter.initialize()
    yield adapter
    await adapter.close()
    try:
        os.unlink(db_path)
    except OSError:
        pass


@pytest.fixture
def emitter_no_storage():
    """EventEmitter with no storage (storage=None)."""
    from cortex.events import EventEmitter
    return EventEmitter(storage=None)


@pytest_asyncio.fixture
async def emitter(db_adapter):
    """EventEmitter backed by the real SQLiteAdapter."""
    from cortex.events import EventEmitter
    return EventEmitter(storage=db_adapter)


# ---------------------------------------------------------------------------
# 1. EventType enum
# ---------------------------------------------------------------------------

class TestEventTypeEnum:
    """Tests for the EventType enum."""

    def test_all_expected_types_exist(self):
        from cortex.events import EventType
        expected = [
            "MEMORY_CREATED", "MEMORY_UPDATED", "MEMORY_DELETED",
            "MEMORY_PROMOTED", "MEMORY_DEPRECATED",
            "ENTITY_CREATED", "ENTITY_MERGED",
            "CORNERSTONE_SEALED", "CORNERSTONE_DRIFT_DETECTED",
            "SESSION_STARTED", "SESSION_ENDED",
            "DREAM_STARTED", "DREAM_COMPLETED", "SLEEP_COMPLETED",
            "FEEDBACK_SUBMITTED",
        ]
        from cortex.events import EventType
        for name in expected:
            assert hasattr(EventType, name), f"Missing EventType.{name}"

    def test_event_type_count(self):
        from cortex.events import EventType
        # 15 event types defined in the spec
        assert len(EventType) >= 15

    def test_event_type_values_are_strings(self):
        from cortex.events import EventType
        for et in EventType:
            assert isinstance(et.value, str)

    def test_event_type_is_str_subclass(self):
        """EventType should be a str-enum so values JSON-serialise cleanly."""
        from cortex.events import EventType
        assert isinstance(EventType.MEMORY_CREATED, str)


# ---------------------------------------------------------------------------
# 2. CortexEvent dataclass
# ---------------------------------------------------------------------------

class TestCortexEvent:
    def test_basic_construction(self):
        from cortex.events import CortexEvent, EventType
        event = CortexEvent(
            event_type=EventType.MEMORY_CREATED,
            owner_id="user-1",
            payload={"claim_id": "c1"},
        )
        assert event.event_type == EventType.MEMORY_CREATED
        assert event.owner_id == "user-1"
        assert event.payload["claim_id"] == "c1"

    def test_default_timestamp_is_set(self):
        from cortex.events import CortexEvent, EventType
        event = CortexEvent(event_type=EventType.SESSION_STARTED)
        assert event.timestamp is not None
        assert "T" in event.timestamp  # ISO format

    def test_event_is_immutable(self):
        from cortex.events import CortexEvent, EventType
        event = CortexEvent(event_type=EventType.MEMORY_DELETED)
        with pytest.raises((AttributeError, TypeError)):
            event.owner_id = "changed"  # type: ignore[misc]

    def test_session_id_optional(self):
        from cortex.events import CortexEvent, EventType
        event = CortexEvent(event_type=EventType.ENTITY_CREATED)
        assert event.session_id is None

        event2 = CortexEvent(
            event_type=EventType.ENTITY_CREATED,
            session_id="sess-abc",
        )
        assert event2.session_id == "sess-abc"


# ---------------------------------------------------------------------------
# 3. EventEmitter — listener registration
# ---------------------------------------------------------------------------

class TestListenerRegistration:
    def test_on_registers_listener(self, emitter_no_storage):
        from cortex.events import EventType
        cb = MagicMock()
        emitter_no_storage.on(EventType.MEMORY_CREATED, cb)
        assert cb in emitter_no_storage.listeners[EventType.MEMORY_CREATED]

    def test_on_used_as_decorator(self, emitter_no_storage):
        from cortex.events import EventType

        @emitter_no_storage.on(EventType.SESSION_ENDED)
        def my_handler(event):
            pass

        assert my_handler in emitter_no_storage.listeners[EventType.SESSION_ENDED]

    def test_listener_count(self, emitter_no_storage):
        from cortex.events import EventType
        assert emitter_no_storage.listener_count(EventType.MEMORY_CREATED) == 0
        emitter_no_storage.on(EventType.MEMORY_CREATED, MagicMock())
        assert emitter_no_storage.listener_count(EventType.MEMORY_CREATED) == 1

    def test_off_unregisters_listener(self, emitter_no_storage):
        from cortex.events import EventType
        cb = MagicMock()
        emitter_no_storage.on(EventType.ENTITY_MERGED, cb)
        emitter_no_storage.off(EventType.ENTITY_MERGED, cb)
        assert emitter_no_storage.listener_count(EventType.ENTITY_MERGED) == 0


# ---------------------------------------------------------------------------
# 4. EventEmitter — emit dispatches to listener(s)
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
class TestEmitDispatch:
    async def test_emit_calls_listener(self, emitter_no_storage):
        from cortex.events import CortexEvent, EventType
        received: List[CortexEvent] = []

        emitter_no_storage.on(EventType.MEMORY_CREATED, received.append)

        event = CortexEvent(event_type=EventType.MEMORY_CREATED, owner_id="u1")
        await emitter_no_storage.emit(event)

        assert len(received) == 1
        assert received[0] is event

    async def test_emit_calls_multiple_listeners_in_order(self, emitter_no_storage):
        from cortex.events import CortexEvent, EventType
        call_order: List[str] = []

        emitter_no_storage.on(EventType.SESSION_STARTED, lambda _: call_order.append("first"))
        emitter_no_storage.on(EventType.SESSION_STARTED, lambda _: call_order.append("second"))
        emitter_no_storage.on(EventType.SESSION_STARTED, lambda _: call_order.append("third"))

        await emitter_no_storage.emit(CortexEvent(event_type=EventType.SESSION_STARTED))

        assert call_order == ["first", "second", "third"]

    async def test_emit_async_listener(self, emitter_no_storage):
        from cortex.events import CortexEvent, EventType
        received: List[CortexEvent] = []

        async def async_handler(event):
            received.append(event)

        emitter_no_storage.on(EventType.DREAM_COMPLETED, async_handler)
        event = CortexEvent(event_type=EventType.DREAM_COMPLETED)
        await emitter_no_storage.emit(event)

        assert len(received) == 1

    async def test_emit_listener_exception_does_not_propagate(self, emitter_no_storage):
        from cortex.events import CortexEvent, EventType

        def bad_listener(event):
            raise RuntimeError("boom")

        emitter_no_storage.on(EventType.FEEDBACK_SUBMITTED, bad_listener)

        # Should not raise
        event = CortexEvent(event_type=EventType.FEEDBACK_SUBMITTED)
        await emitter_no_storage.emit(event)  # no exception expected

    async def test_emit_without_storage_still_dispatches(self, emitter_no_storage):
        from cortex.events import CortexEvent, EventType
        received = []
        emitter_no_storage.on(EventType.SLEEP_COMPLETED, received.append)

        await emitter_no_storage.emit(CortexEvent(event_type=EventType.SLEEP_COMPLETED))

        assert len(received) == 1

    async def test_emit_persists_to_db(self, emitter, db_adapter):
        """emit() should write a row to the event_log table."""
        from cortex.events import CortexEvent, EventType

        event = CortexEvent(
            event_type=EventType.MEMORY_CREATED,
            owner_id="test-owner",
            payload={"claim_id": "abc"},
        )
        await emitter.emit(event)

        rows = await db_adapter.get_events(owner_id="test-owner")
        assert len(rows) >= 1
        assert rows[0]["event_type"] == "memory.created"
        assert rows[0]["owner_id"] == "test-owner"

    async def test_emit_no_listener_for_type(self, emitter_no_storage):
        """emit() with no listeners registered must not raise."""
        from cortex.events import CortexEvent, EventType

        event = CortexEvent(event_type=EventType.ENTITY_CREATED)
        await emitter_no_storage.emit(event)  # no exception


# ---------------------------------------------------------------------------
# 5. SQLiteAdapter event methods
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
class TestSQLiteEventMethods:
    async def test_log_event_returns_id(self, db_adapter):
        row_id = await db_adapter.log_event(
            event_type="memory.created",
            owner_id="alice",
            timestamp=_now_iso(),
            payload_json=json.dumps({"x": 1}),
        )
        assert isinstance(row_id, str)
        assert len(row_id) > 0

    async def test_get_events_returns_logged_event(self, db_adapter):
        ts = _now_iso()
        await db_adapter.log_event(
            event_type="session.started",
            owner_id="bob",
            timestamp=ts,
        )
        rows = await db_adapter.get_events(owner_id="bob")
        assert any(r["event_type"] == "session.started" for r in rows)

    async def test_get_events_event_type_filter(self, db_adapter):
        ts = _now_iso()
        await db_adapter.log_event("memory.created", owner_id="carol", timestamp=ts)
        await db_adapter.log_event("entity.merged", owner_id="carol", timestamp=ts)

        rows = await db_adapter.get_events(owner_id="carol", event_type="memory.created")
        assert all(r["event_type"] == "memory.created" for r in rows)

    async def test_get_events_limit(self, db_adapter):
        ts = _now_iso()
        for _ in range(10):
            await db_adapter.log_event("dream.started", owner_id="dave", timestamp=ts)

        rows = await db_adapter.get_events(owner_id="dave", limit=3)
        assert len(rows) <= 3

    async def test_get_events_owner_isolation(self, db_adapter):
        ts = _now_iso()
        await db_adapter.log_event("memory.deleted", owner_id="eve", timestamp=ts)
        await db_adapter.log_event("memory.deleted", owner_id="frank", timestamp=ts)

        rows = await db_adapter.get_events(owner_id="eve")
        assert all(r["owner_id"] == "eve" for r in rows)


# ---------------------------------------------------------------------------
# 6. CortexPlugin wiring
# ---------------------------------------------------------------------------

class TestCortexPluginWiring:
    def test_plugin_has_event_emitter(self):
        from cortex.api.plugin import CortexPlugin
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db_path = f.name
        try:
            plugin = CortexPlugin(db_path=db_path)
            assert hasattr(plugin, "event_emitter"), "CortexPlugin must expose event_emitter"
            # emitter may be None if import failed, but attribute must exist
        finally:
            try:
                os.unlink(db_path)
            except OSError:
                pass

    def test_plugin_emitter_is_event_emitter_instance(self):
        from cortex.api.plugin import CortexPlugin
        from cortex.events import EventEmitter
        with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
            db_path = f.name
        try:
            plugin = CortexPlugin(db_path=db_path)
            assert isinstance(plugin.event_emitter, EventEmitter)
        finally:
            try:
                os.unlink(db_path)
            except OSError:
                pass


# ---------------------------------------------------------------------------
# 7. HTTP endpoint
# ---------------------------------------------------------------------------

def _make_mock_storage_with_events(events: list) -> MagicMock:
    """Return a mock StorageAdapter whose get_events returns *events*."""
    storage = MagicMock()
    storage.initialize = AsyncMock()
    storage.close = AsyncMock()
    storage.get_events = AsyncMock(return_value=events)
    storage.log_event = AsyncMock(return_value="fake-id")
    # Extra methods called by lifespan/middleware
    storage.log_request = AsyncMock(return_value=None)
    return storage


def _make_http_client(events: list):
    """Context manager: yield a TestClient whose storage returns *events*."""
    from contextlib import contextmanager
    from starlette.testclient import TestClient
    from unittest.mock import patch as _patch
    from cortex.api.http_server import create_app

    @contextmanager
    def _ctx():
        mock_storage = _make_mock_storage_with_events(events)
        mock_plugin = MagicMock()
        mock_plugin.storage = mock_storage
        mock_plugin._init_errors = []

        app = create_app()
        # Patch both CortexPlugin (so our mock is injected) and
        # _validate_config (so MagicMock config fields don't raise TypeError)
        with _patch("cortex.api.plugin.CortexPlugin") as MockClass, \
             _patch("cortex.config.validate_config", return_value=[]):
            MockClass.return_value = mock_plugin
            with TestClient(app, raise_server_exceptions=True) as tc:
                yield tc

    return _ctx()


class TestHTTPEventsEndpoint:
    def test_get_events_returns_200(self):
        with _make_http_client([]) as client:
            resp = client.get("/api/v1/events")
        assert resp.status_code == 200

    def test_get_events_response_shape(self):
        with _make_http_client([]) as client:
            resp = client.get("/api/v1/events")
        data = resp.json()
        assert "events" in data
        assert "count" in data
        assert isinstance(data["events"], list)

    def test_get_events_returns_stored_events(self):
        """Events returned by mock storage appear in the HTTP response."""
        fake_events = [
            {
                "id": "e1",
                "owner_id": "default",
                "event_type": "memory.created",
                "timestamp": _now_iso(),
                "payload_json": json.dumps({"claim_id": "xyz"}),
                "session_id": None,
                "created_at": _now_iso(),
            }
        ]
        with _make_http_client(fake_events) as client:
            resp = client.get("/api/v1/events?owner_id=default")
        data = resp.json()
        assert data["count"] >= 1
        assert any(e["event_type"] == "memory.created" for e in data["events"])

    def test_get_events_event_type_filter_passed_to_storage(self):
        """The event_type query param is forwarded to storage.get_events."""
        from starlette.testclient import TestClient
        from unittest.mock import patch as _patch
        from cortex.api.http_server import create_app

        mock_storage = _make_mock_storage_with_events([])
        mock_plugin = MagicMock()
        mock_plugin.storage = mock_storage
        mock_plugin._init_errors = []

        app = create_app()
        with _patch("cortex.api.plugin.CortexPlugin") as MockClass, \
             _patch("cortex.config.validate_config", return_value=[]):
            MockClass.return_value = mock_plugin
            with TestClient(app, raise_server_exceptions=True) as tc:
                tc.get("/api/v1/events?owner_id=alice&event_type=entity.merged")

        mock_storage.get_events.assert_called_once_with(
            owner_id="alice",
            event_type="entity.merged",
            limit=50,
            after=None,
        )

    def test_get_events_payload_decoded(self):
        """payload_json is decoded to a dict in the response."""
        fake_events = [
            {
                "id": "e2",
                "owner_id": "default",
                "event_type": "entity.merged",
                "timestamp": _now_iso(),
                "payload_json": json.dumps({"keep_id": "a", "merged_id": "b"}),
                "session_id": None,
                "created_at": _now_iso(),
            }
        ]
        with _make_http_client(fake_events) as client:
            resp = client.get("/api/v1/events")
        data = resp.json()
        assert data["events"][0]["payload"] == {"keep_id": "a", "merged_id": "b"}
