# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 sol pbc

"""Unit tests for the Callosum message bus.

These tests use mocks to test logic in isolation without real I/O.
"""

import logging
import queue
import shutil
import socket
import tempfile
import threading
import time
from pathlib import Path
from unittest.mock import Mock, patch

import pytest

from solstone.think import callosum as callosum_module
from solstone.think.callosum import CallosumConnection, CallosumServer
from tests.helpers.module_mocks import module_mock


@pytest.fixture
def journal_path(tmp_path, monkeypatch):
    """Set up a temporary journal path."""
    journal = tmp_path / "journal"
    journal.mkdir()
    monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal))
    yield journal


@pytest.fixture
def short_callosum_server(monkeypatch):
    """Start Callosum under a short /tmp path to avoid Unix socket path limits."""
    tmp_dir = tempfile.mkdtemp(dir="/tmp", prefix="callosum_")
    tmp_path = Path(tmp_dir)

    monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))

    server = CallosumServer()
    server_thread = threading.Thread(target=server.start, daemon=True)
    server_thread.start()

    socket_path = tmp_path / "health" / "callosum.sock"
    for _ in range(50):
        if socket_path.exists():
            break
        time.sleep(0.1)
    else:
        pytest.fail("Callosum server did not start in time")

    yield server

    server.stop()
    server_thread.join(timeout=2)
    shutil.rmtree(tmp_dir, ignore_errors=True)


def test_server_broadcast_validates_tract_field():
    """Test that messages without tract field are rejected."""
    server = CallosumServer()

    # Message without tract should be rejected and return False
    invalid_msg = {"event": "test"}
    result = server.broadcast(invalid_msg)

    assert result is False
    # Should not be queued
    assert server.broadcast_queue.qsize() == 0


def test_client_inbound_flood_receives_300kb_under_5s(short_callosum_server):
    server = short_callosum_server
    total_messages = 1024
    payload = "x" * 300
    received = 0
    lock = threading.Lock()
    all_received = threading.Event()

    def callback(message):
        nonlocal received
        if message.get("tract") != "flood":
            return
        with lock:
            received += 1
            if received == total_messages:
                all_received.set()

    client = CallosumConnection()
    client.start(callback=callback)
    try:
        for _ in range(50):
            if server.client_count() >= 1:
                break
            time.sleep(0.1)
        else:
            pytest.fail("Callosum client did not connect in time")

        start = time.monotonic()
        for index in range(total_messages):
            assert server.broadcast(
                {
                    "tract": "flood",
                    "event": "chunk",
                    "index": index,
                    "payload": payload,
                }
            )

        finished = all_received.wait(timeout=5.0)
        elapsed = time.monotonic() - start

        with lock:
            count = received
        assert finished, (
            f"received {count}/{total_messages} callbacks in {elapsed:.2f}s"
        )
    finally:
        client.stop()


def test_client_emit_during_inbound_flood_reaches_server_under_2s(
    short_callosum_server,
):
    server = short_callosum_server
    inbound_started = threading.Event()
    outbound_received = threading.Event()
    stop_producer = threading.Event()
    inbound_count = 0
    lock = threading.Lock()

    def callback(message):
        nonlocal inbound_count
        if message.get("tract") == "flood":
            with lock:
                inbound_count += 1
                if inbound_count >= 20:
                    inbound_started.set()
        elif message.get("tract") == "probe" and message.get("event") == "mid_flood":
            outbound_received.set()

    client = CallosumConnection()
    client.start(callback=callback)

    def producer():
        index = 0
        while not stop_producer.is_set():
            server.broadcast(
                {
                    "tract": "flood",
                    "event": "chunk",
                    "index": index,
                    "payload": "x" * 300,
                }
            )
            index += 1
            time.sleep(0.001)

    producer_thread = threading.Thread(target=producer, daemon=True)
    try:
        for _ in range(50):
            if server.client_count() >= 1:
                break
            time.sleep(0.1)
        else:
            pytest.fail("Callosum client did not connect in time")

        producer_thread.start()
        assert inbound_started.wait(timeout=2.0)

        start = time.monotonic()
        assert client.emit("probe", "mid_flood")
        assert outbound_received.wait(timeout=2.0)
        assert time.monotonic() - start < 2.0
    finally:
        stop_producer.set()
        producer_thread.join(timeout=2)
        client.stop()


def test_client_receives_during_outbound_flood(short_callosum_server):
    server = short_callosum_server
    inbound_received = threading.Event()
    stop_producer = threading.Event()
    inbound_count = 0
    lock = threading.Lock()

    def callback(message):
        nonlocal inbound_count
        if message.get("tract") != "inbound":
            return
        with lock:
            inbound_count += 1
            if inbound_count >= 20:
                inbound_received.set()

    client = CallosumConnection()
    client.start(callback=callback)

    def producer():
        index = 0
        while not stop_producer.is_set():
            try:
                client.send_queue.put_nowait(
                    {"tract": "outbound", "event": "flood", "index": index}
                )
                index += 1
            except queue.Full:
                time.sleep(0.001)

    producer_thread = threading.Thread(target=producer, daemon=True)
    try:
        for _ in range(50):
            if server.client_count() >= 1:
                break
            time.sleep(0.1)
        else:
            pytest.fail("Callosum client did not connect in time")

        producer_thread.start()
        for index in range(20):
            assert server.broadcast(
                {"tract": "inbound", "event": "probe", "index": index}
            )

        assert inbound_received.wait(timeout=2.0)
    finally:
        stop_producer.set()
        producer_thread.join(timeout=2)
        client.stop()


def test_client_disconnected_idle_blocks_and_drops_emit(tmp_path, monkeypatch):
    client = CallosumConnection(socket_path=tmp_path / "missing.sock")
    original_wait = client.stop_event.wait
    wait_calls = 0
    block_reached = threading.Event()

    def counted_wait(timeout=None):
        nonlocal wait_calls
        wait_calls += 1
        block_reached.set()
        return original_wait(timeout)

    monkeypatch.setattr(client.stop_event, "wait", counted_wait)

    drop_logged = threading.Event()
    drop_message = "Dropping message (not connected): test/event"

    class _DropWatcher(logging.Handler):
        def emit(self, record: logging.LogRecord) -> None:
            if drop_message in record.getMessage():
                drop_logged.set()

    logger = logging.getLogger("solstone.think.callosum")
    previous_level = logger.level
    logger.setLevel(logging.INFO)
    handler = _DropWatcher()
    logger.addHandler(handler)
    try:
        client.start()
        try:
            # Wait until the reconnect loop has entered its disconnected idle block
            # (first stop_event.wait), then emit while still disconnected.
            assert block_reached.wait(timeout=2.0)
            assert client.emit("test", "event")
            assert drop_logged.wait(timeout=2.0)
        finally:
            client.stop()
    finally:
        logger.removeHandler(handler)
        logger.setLevel(previous_level)

    assert 1 <= wait_calls <= 20


def test_client_does_not_join_partial_json_across_reconnect(monkeypatch):
    client = CallosumConnection()
    delivered = []

    read1, write1 = socket.socketpair()
    read2, write2 = socket.socketpair()
    write1.sendall(b"xx")
    write2.sendall(b"x")

    sock1 = Mock()
    sock2 = Mock()
    sock1.fileno.return_value = read1.fileno()
    sock2.fileno.return_value = read2.fileno()

    first_calls = 0

    def recv_first(_n):
        nonlocal first_calls
        read1.recv(1)
        first_calls += 1
        if first_calls == 1:
            return b'{"tract":"split"'
        return b""

    def recv_second(_n):
        read2.recv(1)
        client.stop_event.set()
        return b',"event":"joined"}\n'

    sock1.recv.side_effect = recv_first
    sock2.recv.side_effect = recv_second

    socket_constructor = Mock(side_effect=[sock1, sock2])
    clock = Mock(side_effect=[2.0, 4.0, 6.0, 8.0, 10.0])
    monkeypatch.setattr(
        callosum_module,
        "socket",
        module_mock(callosum_module.socket, socket=socket_constructor),
    )
    monkeypatch.setattr(
        callosum_module,
        "time",
        module_mock(callosum_module.time, time=clock),
    )
    try:
        client.start(callback=delivered.append)
        if client.thread is not None:
            client.thread.join(timeout=1.0)
        assert delivered == []
    finally:
        for handle in (read1, write1, read2, write2):
            handle.close()


def test_client_callback_exception_does_not_kill_thread(short_callosum_server, caplog):
    server = short_callosum_server
    delivered = []
    second_received = threading.Event()

    def callback(message):
        if message.get("tract") != "callback":
            return
        delivered.append(message["event"])
        if message["event"] == "first":
            raise RuntimeError("boom")
        if message["event"] == "second":
            second_received.set()

    client = CallosumConnection()
    client.start(callback=callback)
    try:
        for _ in range(50):
            if server.client_count() >= 1:
                break
            time.sleep(0.1)
        else:
            pytest.fail("Callosum client did not connect in time")

        with caplog.at_level(logging.ERROR, logger="solstone.think.callosum"):
            assert server.broadcast({"tract": "callback", "event": "first"})
            assert server.broadcast({"tract": "callback", "event": "second"})
            assert second_received.wait(timeout=2.0)

        assert delivered == ["first", "second"]
        assert "Callback error: boom" in caplog.text
    finally:
        client.stop()


def test_server_broadcast_validates_event_field():
    """Test that messages without event field are rejected."""
    server = CallosumServer()

    # Message without event should be rejected and return False
    invalid_msg = {"tract": "test"}
    result = server.broadcast(invalid_msg)

    assert result is False
    # Should not be queued
    assert server.broadcast_queue.qsize() == 0


def test_server_broadcast_adds_timestamp(monkeypatch):
    """Test that server adds timestamp if not present."""
    server = CallosumServer()

    # Valid message without timestamp
    msg = {"tract": "test", "event": "hello"}

    monkeypatch.setattr(
        callosum_module,
        "now_ms",
        Mock(return_value=1234567890),
    )
    result = server.broadcast(msg)

    assert result is True
    # Message should be queued with timestamp added
    queued_msg = server.broadcast_queue.get_nowait()
    assert queued_msg["tract"] == "test"
    assert queued_msg["event"] == "hello"
    assert queued_msg["ts"] == 1234567890  # milliseconds


def test_server_broadcast_preserves_custom_timestamp():
    """Test that custom timestamp in message is preserved."""
    server = CallosumServer()

    custom_ts = 9999999999
    msg = {"tract": "test", "event": "hello", "ts": custom_ts}

    result = server.broadcast(msg)

    assert result is True
    # Should preserve custom timestamp
    queued_msg = server.broadcast_queue.get_nowait()
    assert queued_msg["ts"] == custom_ts


def test_server_broadcast_removes_dead_clients():
    """Test that _send_to_clients removes clients that fail to receive."""
    server = CallosumServer()

    # Create mock clients - one working, one dead
    working_client = Mock()
    dead_client = Mock()
    dead_client.sendall.side_effect = Exception("Connection broken")
    dead_client.settimeout = Mock()
    working_client.settimeout = Mock()

    server.clients = [working_client, dead_client]

    # Call _send_to_clients directly (the method used by _writer_loop)
    msg = {"tract": "test", "event": "hello", "ts": 12345}
    server._send_to_clients(msg)

    # Dead client should be removed
    assert working_client in server.clients
    assert dead_client not in server.clients
    assert len(server.clients) == 1

    # Dead client socket should be closed
    dead_client.close.assert_called_once()


def test_server_handle_client_warns_on_utf8_split(caplog):
    server = CallosumServer()
    survivor = Mock()
    bad_conn = Mock()
    bad_conn.settimeout = Mock()
    # First recv returns the first byte of a 3-byte char (invalid on its own);
    # decode raises UnicodeDecodeError. b"" is a safety net to end the loop.
    bad_conn.recv.side_effect = ["⚠️".encode("utf-8")[:1], b""]
    server.clients = [survivor]
    with caplog.at_level(logging.WARNING, logger="solstone.think.callosum"):
        server._handle_client(bad_conn)
    assert "utf-8 split" in caplog.text
    assert "[server]" in caplog.text
    assert any(r.levelno == logging.WARNING for r in caplog.records)
    # Offending client dropped + closed (existing finally cleanup); survivor kept.
    assert bad_conn not in server.clients
    assert survivor in server.clients
    bad_conn.close.assert_called_once()


def test_client_emit_returns_false_when_not_started():
    """Test that emit() returns False and logs warning if start() not called yet."""
    client = CallosumConnection()

    # emit() should return False and log when thread not started
    with patch("solstone.think.callosum.logger") as mock_logger:
        result = client.emit("test", "hello")
        assert result is False
        mock_logger.warning.assert_called_once()
        assert "Thread not running" in mock_logger.warning.call_args[0][0]


def test_client_emit_queues_message():
    """Test that emit() queues message when thread is running."""
    client = CallosumConnection()

    # Setup running thread
    mock_thread = Mock()
    mock_thread.is_alive.return_value = True
    client.thread = mock_thread

    result = client.emit("test", "hello", data="world", count=42)

    assert result is True
    # Message should be in queue
    assert client.send_queue.qsize() == 1
    msg = client.send_queue.get_nowait()
    assert msg["tract"] == "test"
    assert msg["event"] == "hello"
    assert msg["data"] == "world"
    assert msg["count"] == 42


def test_client_emit_returns_false_when_queue_full():
    """Test that emit() returns False when queue is full."""
    client = CallosumConnection()

    # Setup running thread
    mock_thread = Mock()
    mock_thread.is_alive.return_value = True
    client.thread = mock_thread

    # Fill the queue
    for i in range(1000):
        client.send_queue.put({"tract": "test", "event": f"msg{i}"})

    # Next emit should fail
    with patch("solstone.think.callosum.logger") as mock_logger:
        result = client.emit("test", "overflow")
        assert result is False
        mock_logger.warning.assert_called()
        assert "Queue full" in mock_logger.warning.call_args[0][0]


def test_client_run_loop_warns_on_utf8_split(caplog, monkeypatch):
    client = CallosumConnection()
    mock_sock = Mock()
    read_sock, write_sock = socket.socketpair()
    write_sock.sendall(b"x")
    mock_sock.fileno.return_value = read_sock.fileno()

    def fake_recv(_n):
        client.stop_event.set()  # loop exits at next queue-drain
        return "⚠️".encode("utf-8")[:1]

    mock_sock.recv.side_effect = fake_recv
    monkeypatch.setattr(
        callosum_module,
        "socket",
        module_mock(
            callosum_module.socket,
            socket=Mock(return_value=mock_sock),
        ),
    )
    monkeypatch.setattr(
        callosum_module,
        "time",
        module_mock(callosum_module.time, time=Mock(return_value=2.0)),
    )
    try:
        with caplog.at_level(logging.WARNING, logger="solstone.think.callosum"):
            client._run_loop()
    finally:
        read_sock.close()
        write_sock.close()
    assert "utf-8 split" in caplog.text
    assert "[client]" in caplog.text
    assert any(r.levelno == logging.WARNING for r in caplog.records)
    # Socket dropped/closed as part of the reset.
    mock_sock.close.assert_called()


def test_client_start_creates_thread():
    """Test that start() creates and starts background thread."""
    client = CallosumConnection()

    def callback(msg):
        pass

    client.start(callback=callback)

    assert client.thread is not None
    assert client.thread.is_alive()
    assert client.callback is callback

    # Cleanup
    client.stop()


def test_client_start_idempotent():
    """Test that calling start() multiple times is safe."""
    client = CallosumConnection()

    client.start()
    first_thread = client.thread

    # Call start again
    client.start()

    # Should still have same thread (not restarted)
    assert client.thread is first_thread

    # Cleanup
    client.stop()


def test_client_stop_stops_thread():
    """Test that stop() stops the background thread."""
    client = CallosumConnection()

    # Setup running thread
    mock_thread = Mock()
    mock_thread.is_alive.return_value = False
    client.thread = mock_thread

    client.stop()

    # Should set stop event and join thread
    assert client.stop_event.is_set()
    mock_thread.join.assert_called_once_with(timeout=0.5)


def test_server_socket_path_from_env(journal_path):
    """Test that server uses SOLSTONE_JOURNAL env var for socket path."""
    server = CallosumServer()

    expected_path = journal_path / "health" / "callosum.sock"
    assert server.socket_path == expected_path


def test_server_socket_path_custom():
    """Test that server accepts custom socket path."""
    custom_path = Path("/tmp/custom.sock")
    server = CallosumServer(socket_path=custom_path)

    assert server.socket_path == custom_path


def test_client_socket_path_from_env(journal_path):
    """Test that client uses SOLSTONE_JOURNAL env var for socket path."""
    client = CallosumConnection()

    expected_path = journal_path / "health" / "callosum.sock"
    assert client.socket_path == expected_path


def test_client_socket_path_custom():
    """Test that client accepts custom socket path."""
    custom_path = Path("/tmp/custom.sock")
    client = CallosumConnection(socket_path=custom_path)

    assert client.socket_path == custom_path


def test_callosum_send_empty_journal(tmp_path, monkeypatch):
    """Test that callosum_send() works with an empty journal directory."""
    from solstone.think.callosum import callosum_send

    monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))

    # No server listening at tmp_path, so send will fail gracefully
    result = callosum_send("test", "event", data="value")
    assert isinstance(result, bool)


def test_callosum_send_with_custom_path():
    """Test that callosum_send() accepts custom socket path."""
    from solstone.think.callosum import callosum_send

    # Use non-existent socket - should return False but not crash
    custom_path = Path("/tmp/nonexistent_callosum.sock")
    result = callosum_send("test", "event", socket_path=custom_path, data="value")

    # Should fail gracefully (no server listening)
    assert result is False


def test_callosum_send_classified_returns_exception_class_name(tmp_path):
    """Test classified send reports the swallowed exception class name."""
    from solstone.think.callosum import callosum_send_classified

    custom_path = tmp_path / "nonexistent_callosum.sock"
    result = callosum_send_classified(
        "test", "event", socket_path=custom_path, data="value"
    )

    assert result == "FileNotFoundError"
