"""
Tests for brynq.server — Core broker file I/O, session management,
cursor tracking, and MCP handler logic.
"""

import asyncio
import json
import os
import time
import uuid
from datetime import datetime, timezone

import pytest


# ── File I/O helpers ──────────────────────────────────────────────────────


class TestAppendJsonlSync:
    def test_creates_file_and_appends(self, broker_dirs):
        from brynq.server import _append_jsonl_sync
        path = broker_dirs["channels"] / "test.jsonl"
        _append_jsonl_sync(path, {"message": "hello"})
        assert path.exists()
        data = json.loads(path.read_text("utf-8").strip())
        assert data["message"] == "hello"

    def test_appends_multiple_entries(self, broker_dirs):
        from brynq.server import _append_jsonl_sync
        path = broker_dirs["channels"] / "test.jsonl"
        _append_jsonl_sync(path, {"message": "first"})
        _append_jsonl_sync(path, {"message": "second"})
        lines = path.read_text("utf-8").strip().splitlines()
        assert len(lines) == 2
        assert json.loads(lines[0])["message"] == "first"
        assert json.loads(lines[1])["message"] == "second"

    def test_serializes_datetime_via_default(self, broker_dirs):
        from brynq.server import _append_jsonl_sync
        path = broker_dirs["channels"] / "test.jsonl"
        now = datetime.now(timezone.utc)
        _append_jsonl_sync(path, {"ts": now})
        data = json.loads(path.read_text("utf-8").strip())
        # json.dumps(default=str) produces str(datetime) which uses space
        # separator instead of 'T', so check str(now) is in the output
        assert str(now) in data["ts"]


class TestReadJsonlSync:
    def test_reads_all_entries(self, broker_dirs):
        from brynq.server import _append_jsonl_sync, _read_jsonl_sync
        path = broker_dirs["channels"] / "test.jsonl"
        for i in range(3):
            _append_jsonl_sync(path, {"i": i})
        entries = _read_jsonl_sync(path)
        assert len(entries) == 3
        assert entries[2]["i"] == 2

    def test_reads_with_offset(self, broker_dirs):
        from brynq.server import _append_jsonl_sync, _read_jsonl_sync
        path = broker_dirs["channels"] / "test.jsonl"
        for i in range(5):
            _append_jsonl_sync(path, {"i": i})
        entries = _read_jsonl_sync(path, offset=3)
        assert len(entries) == 2
        assert entries[0]["i"] == 3

    def test_returns_empty_for_missing_file(self, broker_dirs):
        from brynq.server import _read_jsonl_sync
        path = broker_dirs["channels"] / "nonexistent.jsonl"
        assert _read_jsonl_sync(path) == []

    def test_skips_malformed_lines(self, broker_dirs):
        path = broker_dirs["channels"] / "test.jsonl"
        path.write_text(
            '{"valid": true}\n'
            'NOT JSON\n'
            '{"also": "valid"}\n',
            encoding="utf-8",
        )
        from brynq.server import _read_jsonl_sync
        entries = _read_jsonl_sync(path)
        assert len(entries) == 2


# ── Cursor tracking ──────────────────────────────────────────────────────


class TestCursorTracking:
    def test_default_cursor_is_zero(self, broker_dirs):
        from brynq.server import _read_cursor_sync
        assert _read_cursor_sync("session-1", "general") == 0

    def test_write_then_read_cursor(self, broker_dirs):
        from brynq.server import _read_cursor_sync, _write_cursor_sync
        _write_cursor_sync("session-1", "general", 42)
        assert _read_cursor_sync("session-1", "general") == 42

    def test_cursors_are_per_session_per_channel(self, broker_dirs):
        from brynq.server import _read_cursor_sync, _write_cursor_sync
        _write_cursor_sync("s1", "ch-a", 10)
        _write_cursor_sync("s1", "ch-b", 20)
        _write_cursor_sync("s2", "ch-a", 30)
        assert _read_cursor_sync("s1", "ch-a") == 10
        assert _read_cursor_sync("s1", "ch-b") == 20
        assert _read_cursor_sync("s2", "ch-a") == 30

    def test_cursor_overwrites_previous_value(self, broker_dirs):
        from brynq.server import _read_cursor_sync, _write_cursor_sync
        _write_cursor_sync("s1", "ch", 5)
        _write_cursor_sync("s1", "ch", 99)
        assert _read_cursor_sync("s1", "ch") == 99


# ── Session heartbeat ────────────────────────────────────────────────────


class TestHeartbeat:
    def test_write_json_sync_creates_file(self, broker_dirs):
        from brynq.server import _write_json_sync
        path = broker_dirs["sessions"] / "test.json"
        _write_json_sync(path, {"session_id": "test", "heartbeat": "now"})
        assert path.exists()
        data = json.loads(path.read_text("utf-8"))
        assert data["session_id"] == "test"

    def test_update_heartbeat_sync_writes_session(self, broker_dirs, monkeypatch):
        import brynq.server as srv
        monkeypatch.setattr(srv, "SESSION_ID", "test-sess")
        monkeypatch.setattr(srv, "SESSION_NAME", "test-name")
        monkeypatch.setattr(srv, "PLATFORM", "test-plat")
        srv._update_heartbeat_sync()
        path = broker_dirs["sessions"] / "test-sess.json"
        assert path.exists()
        data = json.loads(path.read_text("utf-8"))
        assert data["session_id"] == "test-sess"
        assert data["name"] == "test-name"
        assert data["platform"] == "test-plat"
        assert "heartbeat" in data


# ── Stale session cleanup ────────────────────────────────────────────────


class TestStaleSessionCleanup:
    def test_removes_stale_sessions(self, broker_dirs, monkeypatch):
        from brynq.server import _cleanup_stale_sessions_sync, STALE_THRESHOLD_SECONDS
        import brynq.server as srv
        # Create a stale session (heartbeat far in the past)
        stale_time = datetime(2020, 1, 1, tzinfo=timezone.utc).isoformat()
        session_file = broker_dirs["sessions"] / "old-session.json"
        session_file.write_text(json.dumps({
            "session_id": "old-session",
            "name": "stale-agent",
            "heartbeat": stale_time,
        }))
        removed = _cleanup_stale_sessions_sync()
        assert "stale-agent" in removed
        assert not session_file.exists()

    def test_keeps_fresh_sessions(self, broker_dirs):
        from brynq.server import _cleanup_stale_sessions_sync
        fresh_time = datetime.now(timezone.utc).isoformat()
        session_file = broker_dirs["sessions"] / "fresh-session.json"
        session_file.write_text(json.dumps({
            "session_id": "fresh-session",
            "name": "active-agent",
            "heartbeat": fresh_time,
        }))
        removed = _cleanup_stale_sessions_sync()
        assert "active-agent" not in removed
        assert session_file.exists()

    def test_removes_corrupt_session_files(self, broker_dirs):
        from brynq.server import _cleanup_stale_sessions_sync
        bad_file = broker_dirs["sessions"] / "corrupt.json"
        bad_file.write_text("NOT VALID JSON")
        removed = _cleanup_stale_sessions_sync()
        assert "corrupt" in removed
        assert not bad_file.exists()


# ── MCP handler logic (sync wrappers for async handlers) ─────────────────


class TestHandlePost:
    def test_post_creates_channel_entry(self, broker_dirs, monkeypatch):
        """Test the core posting logic via the sync file I/O path."""
        from brynq.server import _append_jsonl_sync, _read_jsonl_sync, SESSION_ID
        import brynq.server as srv

        channel = "test-post"
        path = broker_dirs["channels"] / f"{channel}.jsonl"
        entry = {
            "id": uuid.uuid4().hex[:12],
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "session_id": SESSION_ID,
            "session_name": "tester",
            "platform": "test",
            "channel": channel,
            "msg_type": "info",
            "message": "Hello from test",
        }
        _append_jsonl_sync(path, entry)
        messages = _read_jsonl_sync(path)
        assert len(messages) == 1
        assert messages[0]["message"] == "Hello from test"
        assert messages[0]["channel"] == "test-post"

    def test_post_empty_message_skipped(self, broker_dirs):
        """Verify that empty message is not written (handled by enforcer)."""
        from brynq.server import _read_jsonl_sync
        path = broker_dirs["channels"] / "test.jsonl"
        # In production, empty messages are blocked by enforcer before writing.
        # We just verify the file doesn't exist yet (no write happened).
        assert not path.exists()


class TestHandleRead:
    def test_read_returns_new_messages(self, broker_dirs, monkeypatch):
        from brynq.server import (
            _append_jsonl_sync, _read_jsonl_sync,
            _read_cursor_sync, _write_cursor_sync,
        )
        import brynq.server as srv
        monkeypatch.setattr(srv, "SESSION_ID", "reader-1")

        path = broker_dirs["channels"] / "general.jsonl"
        # Another session posts two messages
        _append_jsonl_sync(path, {"session_id": "writer-1", "message": "msg1"})
        _append_jsonl_sync(path, {"session_id": "writer-1", "message": "msg2"})

        # Read from offset 0
        cursor = _read_cursor_sync("reader-1", "general")
        assert cursor == 0
        messages = _read_jsonl_sync(path, offset=cursor)
        new_msgs = [m for m in messages if m.get("session_id") != "reader-1"]
        assert len(new_msgs) == 2

        # Advance cursor
        _write_cursor_sync("reader-1", "general", cursor + len(messages))
        assert _read_cursor_sync("reader-1", "general") == 2

        # Second read returns nothing new
        messages2 = _read_jsonl_sync(path, offset=2)
        assert len(messages2) == 0

    def test_read_filters_own_messages(self, broker_dirs, monkeypatch):
        from brynq.server import _append_jsonl_sync, _read_jsonl_sync
        import brynq.server as srv
        monkeypatch.setattr(srv, "SESSION_ID", "me")

        path = broker_dirs["channels"] / "general.jsonl"
        _append_jsonl_sync(path, {"session_id": "me", "message": "my msg"})
        _append_jsonl_sync(path, {"session_id": "other", "message": "their msg"})

        messages = _read_jsonl_sync(path)
        new_msgs = [m for m in messages if m.get("session_id") != "me"]
        assert len(new_msgs) == 1
        assert new_msgs[0]["message"] == "their msg"


class TestHandleBroadcast:
    def test_broadcast_posts_to_all_channels(self, broker_dirs):
        from brynq.server import _append_jsonl_sync, _read_jsonl_sync

        # Create two channels
        _append_jsonl_sync(broker_dirs["channels"] / "dev.jsonl", {"message": "seed"})
        _append_jsonl_sync(broker_dirs["channels"] / "ops.jsonl", {"message": "seed"})

        # Simulate broadcast by appending to all channels
        broadcast_msg = {"message": "system restart", "msg_type": "alert"}
        for f in broker_dirs["channels"].glob("*.jsonl"):
            _append_jsonl_sync(f, broadcast_msg)

        # Each channel should have 2 messages (seed + broadcast)
        dev_msgs = _read_jsonl_sync(broker_dirs["channels"] / "dev.jsonl")
        ops_msgs = _read_jsonl_sync(broker_dirs["channels"] / "ops.jsonl")
        assert len(dev_msgs) == 2
        assert len(ops_msgs) == 2
        assert dev_msgs[1]["message"] == "system restart"


class TestHandleChannels:
    def test_lists_existing_channels(self, broker_dirs):
        from brynq.server import _append_jsonl_sync

        _append_jsonl_sync(broker_dirs["channels"] / "alpha.jsonl", {"message": "a"})
        _append_jsonl_sync(broker_dirs["channels"] / "beta.jsonl", {"message": "b"})

        channel_files = sorted(broker_dirs["channels"].glob("*.jsonl"))
        names = [f.stem for f in channel_files]
        assert "alpha" in names
        assert "beta" in names

    def test_empty_broker_returns_no_channels(self, broker_dirs):
        channel_files = list(broker_dirs["channels"].glob("*.jsonl"))
        assert len(channel_files) == 0


class TestHandleHistory:
    def test_history_returns_recent_messages(self, broker_dirs):
        from brynq.server import _append_jsonl_sync, _read_jsonl_sync

        path = broker_dirs["channels"] / "general.jsonl"
        for i in range(10):
            _append_jsonl_sync(path, {"message": f"msg-{i}", "session_id": "s1"})

        all_msgs = _read_jsonl_sync(path)
        recent = all_msgs[-5:]
        assert len(recent) == 5
        assert recent[0]["message"] == "msg-5"
        assert recent[-1]["message"] == "msg-9"


class TestHandleStatus:
    def test_status_shows_unread_count(self, broker_dirs, monkeypatch):
        from brynq.server import (
            _append_jsonl_sync, _read_cursor_sync, _write_cursor_sync,
        )
        import brynq.server as srv
        monkeypatch.setattr(srv, "SESSION_ID", "status-test")

        path = broker_dirs["channels"] / "general.jsonl"
        for i in range(5):
            _append_jsonl_sync(path, {"message": f"m{i}"})

        # Read 3, leaving 2 unread
        _write_cursor_sync("status-test", "general", 3)
        total = len(path.read_text("utf-8").strip().splitlines())
        cursor = _read_cursor_sync("status-test", "general")
        unread = max(0, total - cursor)
        assert unread == 2


class TestHandleInbox:
    def test_inbox_aggregates_unread_across_channels(self, broker_dirs, monkeypatch):
        from brynq.server import (
            _append_jsonl_sync, _read_cursor_sync, _write_cursor_sync,
        )
        import brynq.server as srv
        monkeypatch.setattr(srv, "SESSION_ID", "inbox-test")

        # Create two channels with messages
        for i in range(3):
            _append_jsonl_sync(
                broker_dirs["channels"] / "dev.jsonl",
                {"message": f"dev-{i}"},
            )
        for i in range(2):
            _append_jsonl_sync(
                broker_dirs["channels"] / "ops.jsonl",
                {"message": f"ops-{i}"},
            )

        # Read 1 from dev, 0 from ops
        _write_cursor_sync("inbox-test", "dev", 1)

        total_unread = 0
        for f in broker_dirs["channels"].glob("*.jsonl"):
            ch = f.stem
            total = len(f.read_text("utf-8").strip().splitlines())
            cursor = _read_cursor_sync("inbox-test", ch)
            total_unread += max(0, total - cursor)

        assert total_unread == 4  # 2 unread in dev + 2 unread in ops
