"""Tests for BaseActor (core/actor.py)."""

from __future__ import annotations

import asyncio
import signal
from unittest.mock import MagicMock, patch

import pytest

from loom.bus.memory import InMemoryBus
from loom.core.actor import BaseActor

# ---------------------------------------------------------------------------
# Concrete test subclasses
# ---------------------------------------------------------------------------


class EchoActor(BaseActor):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.received: list[dict] = []

    async def handle_message(self, data):
        self.received.append(data)


class FailingActor(BaseActor):
    async def handle_message(self, data):
        raise RuntimeError("boom")


class SlowActor(BaseActor):
    """Records timestamps to verify sequential processing order."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.order: list[int] = []

    async def handle_message(self, data):
        self.order.append(data["seq"])
        await asyncio.sleep(0.01)


# ---------------------------------------------------------------------------
# 1. Constructor with explicit bus uses that bus
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_constructor_with_explicit_bus():
    bus = InMemoryBus()
    actor = EchoActor("test-1", bus=bus)
    assert actor._bus is bus


# ---------------------------------------------------------------------------
# 2. Constructor without bus creates a bus (NATSBus)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_constructor_without_bus_sets_bus():
    with patch("loom.core.actor.NATSBus", create=True) as _:
        # The import happens lazily inside __init__; just verify _bus is set.
        from loom.bus.nats_adapter import NATSBus  # noqa: F401

        actor = EchoActor("test-2", nats_url="nats://localhost:4222")
        assert actor._bus is not None


# ---------------------------------------------------------------------------
# 3. connect delegates to bus.connect
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_connect_delegates_to_bus():
    bus = InMemoryBus()
    actor = EchoActor("test-3", bus=bus)
    assert not bus._connected
    await actor.connect()
    assert bus._connected


# ---------------------------------------------------------------------------
# 4. disconnect calls bus.close
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_disconnect_calls_bus_close():
    bus = InMemoryBus()
    actor = EchoActor("test-4", bus=bus)
    await actor.connect()
    assert bus._connected
    await actor.disconnect()
    assert not bus._connected


# ---------------------------------------------------------------------------
# 5. subscribe delegates to bus.subscribe
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_subscribe_delegates_to_bus():
    bus = InMemoryBus()
    actor = EchoActor("test-5", bus=bus)
    await actor.subscribe("test.subject")
    assert actor._sub is not None
    assert actor._sub.subject == "test.subject"
    assert "test.subject" in bus._subscribers


# ---------------------------------------------------------------------------
# 6. publish delegates to bus.publish
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_publish_delegates_to_bus():
    bus = InMemoryBus()
    actor = EchoActor("test-6", bus=bus)
    sub = await bus.subscribe("out.subject")
    await actor.publish("out.subject", {"key": "value"})
    msg = await asyncio.wait_for(sub.__anext__(), timeout=1.0)
    assert msg == {"key": "value"}


# ---------------------------------------------------------------------------
# 7. _process_one calls handle_message with data
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_process_one_calls_handle_message():
    bus = InMemoryBus()
    actor = EchoActor("test-7", bus=bus)
    actor._semaphore = asyncio.Semaphore(1)
    await actor._process_one({"hello": "world"})
    assert actor.received == [{"hello": "world"}]


# ---------------------------------------------------------------------------
# 8. _process_one catches exceptions — actor stays alive
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_process_one_catches_exceptions():
    bus = InMemoryBus()
    actor = FailingActor("test-8", bus=bus)
    actor._semaphore = asyncio.Semaphore(1)
    # Should not raise — the error is logged but swallowed.
    await actor._process_one({"will": "fail"})


# ---------------------------------------------------------------------------
# 9. _request_shutdown sets _running=False and sets shutdown_event
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_request_shutdown_sets_flags():
    bus = InMemoryBus()
    actor = EchoActor("test-9", bus=bus)
    actor._running = True
    actor._shutdown_event = asyncio.Event()
    assert not actor._shutdown_event.is_set()

    actor._request_shutdown(signal.SIGTERM)

    assert actor._running is False
    assert actor._shutdown_event.is_set()


# ---------------------------------------------------------------------------
# 10. max_concurrent=1 processes sequentially (verify order)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_sequential_processing_preserves_order():
    bus = InMemoryBus()
    actor = SlowActor("test-10", bus=bus, max_concurrent=1)
    actor._semaphore = asyncio.Semaphore(1)

    for i in range(5):
        await actor._process_one({"seq": i})

    assert actor.order == [0, 1, 2, 3, 4]


# ---------------------------------------------------------------------------
# 11. Semaphore has correct value for max_concurrent
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_semaphore_respects_max_concurrent():
    bus = InMemoryBus()
    actor = EchoActor("test-11", bus=bus, max_concurrent=3)
    # Semaphore is created in run(); simulate that.
    actor._semaphore = asyncio.Semaphore(actor.max_concurrent)
    assert actor._semaphore._value == 3


# ---------------------------------------------------------------------------
# 12. _process_one acquires and releases semaphore
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_process_one_acquires_and_releases_semaphore():
    bus = InMemoryBus()
    actor = EchoActor("test-12", bus=bus)
    actor._semaphore = asyncio.Semaphore(1)

    # Before: semaphore is available (value=1).
    assert actor._semaphore._value == 1
    await actor._process_one({"data": 1})
    # After: semaphore is released back (value=1).
    assert actor._semaphore._value == 1
    assert actor.received == [{"data": 1}]


# ---------------------------------------------------------------------------
# 13. run() processes messages then disconnects on shutdown
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_run_processes_messages_and_shuts_down():
    """run() processes messages from the bus and disconnects on shutdown."""
    bus = InMemoryBus()
    actor = EchoActor("test-13", bus=bus)

    async def _feed_and_shutdown():
        # Wait for actor to subscribe
        for _ in range(50):
            if actor._running:
                break
            await asyncio.sleep(0.01)

        # Feed messages
        await bus.publish("test.run", {"msg": 1})
        await bus.publish("test.run", {"msg": 2})
        # Give time to process
        await asyncio.sleep(0.05)
        # Trigger shutdown — sets _running=False
        actor._request_shutdown(signal.SIGTERM)
        # Send one more message to unblock the subscription iterator
        # (async for waits for next message; the _running check only fires
        # when a message arrives)
        await asyncio.sleep(0.01)
        if actor._sub:
            await actor._sub.unsubscribe()

    with patch.object(actor, "_install_signal_handlers"):
        task = asyncio.create_task(actor.run("test.run"))
        feeder = asyncio.create_task(_feed_and_shutdown())
        await asyncio.wait_for(asyncio.gather(task, feeder), timeout=3.0)

    assert {"msg": 1} in actor.received
    assert {"msg": 2} in actor.received
    assert not bus._connected


# ---------------------------------------------------------------------------
# 14. run() with max_concurrent > 1 fires concurrent tasks
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_run_concurrent_processing():
    """With max_concurrent > 1, multiple messages are processed concurrently."""
    bus = InMemoryBus()
    actor = SlowActor("test-14", bus=bus, max_concurrent=3)

    async def _feed_and_shutdown():
        for _ in range(50):
            if actor._running:
                break
            await asyncio.sleep(0.01)

        # Feed 3 messages quickly
        for i in range(3):
            await bus.publish("test.concurrent", {"seq": i})
        await asyncio.sleep(0.15)
        actor._request_shutdown(signal.SIGTERM)
        await asyncio.sleep(0.01)
        if actor._sub:
            await actor._sub.unsubscribe()

    with patch.object(actor, "_install_signal_handlers"):
        task = asyncio.create_task(actor.run("test.concurrent"))
        feeder = asyncio.create_task(_feed_and_shutdown())
        await asyncio.wait_for(asyncio.gather(task, feeder), timeout=5.0)

    # All 3 should have been processed
    assert sorted(actor.order) == [0, 1, 2]


# ---------------------------------------------------------------------------
# 15. run() handles CancelledError gracefully
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_run_handles_cancelled_error():
    """Cancelling the run task should not raise."""
    bus = InMemoryBus()
    actor = EchoActor("test-15", bus=bus)

    with patch.object(actor, "_install_signal_handlers"):
        task = asyncio.create_task(actor.run("test.cancel"))
        # Wait for actor to start
        for _ in range(50):
            if actor._running:
                break
            await asyncio.sleep(0.01)
        task.cancel()
        try:
            await task
        except asyncio.CancelledError:
            pass

    assert not actor._running


# ---------------------------------------------------------------------------
# 16. run() disconnects even on error
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_run_disconnects_on_exit():
    """run() calls disconnect in finally block regardless of exit reason."""
    bus = InMemoryBus()
    actor = EchoActor("test-16", bus=bus)

    with patch.object(actor, "_install_signal_handlers"):
        task = asyncio.create_task(actor.run("test.disconnect"))
        for _ in range(50):
            if actor._running:
                break
            await asyncio.sleep(0.01)

        assert bus._connected
        actor._request_shutdown(signal.SIGTERM)
        # Unsubscribe to unblock the iteration loop
        await asyncio.sleep(0.01)
        if actor._sub:
            await actor._sub.unsubscribe()
        await asyncio.wait_for(task, timeout=3.0)

    # Bus should be disconnected after run() exits
    assert not bus._connected


# ---------------------------------------------------------------------------
# 17. _install_signal_handlers registers SIGTERM and SIGINT
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_install_signal_handlers():
    """_install_signal_handlers registers handlers for SIGTERM and SIGINT."""
    bus = InMemoryBus()
    actor = EchoActor("test-17", bus=bus)

    mock_loop = MagicMock()
    with patch("asyncio.get_running_loop", return_value=mock_loop):
        actor._install_signal_handlers()

    assert mock_loop.add_signal_handler.call_count == 2
    registered_signals = {call.args[0] for call in mock_loop.add_signal_handler.call_args_list}
    assert signal.SIGTERM in registered_signals
    assert signal.SIGINT in registered_signals
