import asyncio
import json
from pathlib import Path
from typing import Callable

import pytest

from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.session_messages import (
    ListSessionsTool,
    SendSessionMessageTool,
    SessionMessageError,
)
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ToolsConfig
from nanobot.session.manager import SessionManager
from nanobot.session.session_handles import SessionHandle, SessionHandleResolver
from nanobot.session.session_messages import (
    SESSION_MESSAGE_METADATA_KEY,
    session_message_envelope,
)


def _persist(manager: SessionManager, *keys: str) -> None:
    for key in keys:
        manager.save(manager.get_or_create(key))


def _handle(manager: SessionManager, key: str) -> SessionHandle:
    handle = SessionHandleResolver(manager).handle_for_session(key)
    assert handle is not None
    return handle


class _Timer:
    def __init__(self, callback: Callable[[], None]) -> None:
        self.callback = callback
        self.cancelled = False

    def cancel(self) -> None:
        self.cancelled = True

    def fire(self) -> None:
        if not self.cancelled:
            self.callback()


class _Scheduler:
    def __init__(self) -> None:
        self.calls: list[tuple[float, _Timer]] = []

    def __call__(self, delay: float, callback: Callable[[], None]) -> _Timer:
        timer = _Timer(callback)
        self.calls.append((delay, timer))
        return timer


def test_config_and_tool_schema_keep_only_the_basic_reply_contract(
    tmp_path: Path,
) -> None:
    tool = SendSessionMessageTool(
        sessions=SessionManager(tmp_path),
        bus=MessageBus(),
    )

    assert ToolsConfig.model_fields["max_session_messages_per_minute"].default == 6
    assert tool.parameters["required"] == ["to", "content", "expect_reply"]
    timeout = tool.parameters["properties"]["reply_timeout_seconds"]
    assert (timeout["minimum"], timeout["maximum"]) == (5, 60)


@pytest.mark.asyncio
async def test_list_sessions_includes_all_persisted_channels_except_current(
    tmp_path: Path,
) -> None:
    sessions = SessionManager(tmp_path)
    _persist(sessions, "websocket:current", "telegram:other", "slack:team")
    tool = ListSessionsTool(sessions)

    with request_context(RequestContext(
        channel="websocket",
        chat_id="current",
        session_key="websocket:current",
    )):
        result = json.loads(await tool.execute())

    assert set(result) == {
        f"@{_handle(sessions, 'telegram:other').name}",
        f"@{_handle(sessions, 'slack:team').name}",
    }


@pytest.mark.asyncio
async def test_send_publishes_user_input_to_the_existing_target(
    tmp_path: Path,
) -> None:
    sessions = SessionManager(tmp_path)
    _persist(sessions, "websocket:source", "telegram:target")
    bus = MessageBus()
    tool = SendSessionMessageTool(sessions=sessions, bus=bus)
    target = _handle(sessions, "telegram:target")

    sent_to = await tool.enqueue(
        source_session_key="websocket:source",
        target_handle=f"@{target.name}",
        content="Please review this.",
        expect_reply=False,
    )
    inbound = await bus.consume_inbound()
    envelope = session_message_envelope(inbound.metadata)

    assert sent_to == f"@{target.name}"
    assert inbound.channel == "system"
    assert inbound.chat_id == "telegram:target"
    assert inbound.session_key_override == "telegram:target"
    assert inbound.is_user_input
    assert inbound.content == "Please review this."
    assert envelope is not None
    assert envelope["source_session_key"] == "websocket:source"
    assert envelope["target_session_key"] == "telegram:target"
    assert inbound.metadata == {SESSION_MESSAGE_METADATA_KEY: envelope}


@pytest.mark.asyncio
async def test_send_fails_when_target_does_not_exist(tmp_path: Path) -> None:
    sessions = SessionManager(tmp_path)
    _persist(sessions, "websocket:source")
    bus = MessageBus()
    tool = SendSessionMessageTool(sessions=sessions, bus=bus)

    with pytest.raises(SessionMessageError, match="was not found"):
        await tool.enqueue(
            source_session_key="websocket:source",
            target_handle="@zzzz",
            content="Hello",
            expect_reply=False,
        )

    assert bus.inbound.empty()


@pytest.mark.asyncio
async def test_rate_limit_is_per_source_session_and_uses_a_rolling_minute(
    tmp_path: Path,
) -> None:
    sessions = SessionManager(tmp_path)
    _persist(sessions, "websocket:a", "websocket:b", "websocket:target")
    now = 0.0
    tool = SendSessionMessageTool(
        sessions=sessions,
        bus=MessageBus(),
        max_messages_per_minute=1,
        clock=lambda: now,
    )
    target = _handle(sessions, "websocket:target").name

    await tool.enqueue(
        source_session_key="websocket:a",
        target_handle=target,
        content="A1",
        expect_reply=False,
    )
    await tool.enqueue(
        source_session_key="websocket:b",
        target_handle=target,
        content="B1",
        expect_reply=False,
    )
    with pytest.raises(SessionMessageError, match="rate limit"):
        await tool.enqueue(
            source_session_key="websocket:a",
            target_handle=target,
            content="A2",
            expect_reply=False,
        )

    now = 61.0
    await tool.enqueue(
        source_session_key="websocket:a",
        target_handle=target,
        content="A3",
        expect_reply=False,
    )


@pytest.mark.asyncio
async def test_rate_limit_releases_expired_source_state_and_keeps_recent_sources(
    tmp_path: Path,
) -> None:
    sessions = SessionManager(tmp_path)
    _persist(
        sessions,
        "websocket:a",
        "websocket:b",
        "websocket:c",
        "websocket:target",
    )
    now = 0.0
    tool = SendSessionMessageTool(
        sessions=sessions,
        bus=MessageBus(),
        max_messages_per_minute=2,
        clock=lambda: now,
    )
    target = _handle(sessions, "websocket:target").name

    for source in ("websocket:a", "websocket:b"):
        await tool.enqueue(
            source_session_key=source,
            target_handle=target,
            content="initial",
            expect_reply=False,
        )
    now = 30.0
    await tool.enqueue(
        source_session_key="websocket:a",
        target_handle=target,
        content="recent",
        expect_reply=False,
    )

    now = 61.0
    await tool.enqueue(
        source_session_key="websocket:c",
        target_handle=target,
        content="trigger cleanup",
        expect_reply=False,
    )

    assert set(tool._sent_at) == {"websocket:a", "websocket:c"}
    await tool.enqueue(
        source_session_key="websocket:a",
        target_handle=target,
        content="within rolling window",
        expect_reply=False,
    )
    with pytest.raises(SessionMessageError, match="rate limit"):
        await tool.enqueue(
            source_session_key="websocket:a",
            target_handle=target,
            content="over limit",
            expect_reply=False,
        )


@pytest.mark.asyncio
async def test_reply_timeout_injects_a_user_input_back_into_the_source(
    tmp_path: Path,
) -> None:
    sessions = SessionManager(tmp_path)
    _persist(sessions, "websocket:source", "websocket:target")
    bus = MessageBus()
    scheduler = _Scheduler()
    tool = SendSessionMessageTool(
        sessions=sessions,
        bus=bus,
        schedule_later=scheduler,
    )
    target = _handle(sessions, "websocket:target")

    await tool.enqueue(
        source_session_key="websocket:source",
        target_handle=target.name,
        content="Question",
        expect_reply=True,
        reply_timeout_seconds=5,
    )
    await bus.consume_inbound()
    delay, timer = scheduler.calls[0]

    assert delay == 5
    timer.fire()
    await asyncio.sleep(0)
    timeout = await bus.consume_inbound()
    assert timeout.chat_id == "websocket:source"
    assert timeout.is_user_input
    assert timeout.content == f"No reply from @{target.name} after 5 seconds."


@pytest.mark.asyncio
async def test_reverse_message_cancels_the_pending_reply_timeout(
    tmp_path: Path,
) -> None:
    sessions = SessionManager(tmp_path)
    _persist(sessions, "websocket:source", "websocket:target")
    bus = MessageBus()
    scheduler = _Scheduler()
    tool = SendSessionMessageTool(
        sessions=sessions,
        bus=bus,
        schedule_later=scheduler,
    )
    source = _handle(sessions, "websocket:source")
    target = _handle(sessions, "websocket:target")

    await tool.enqueue(
        source_session_key=source.session_key,
        target_handle=target.name,
        content="Question",
        expect_reply=True,
        reply_timeout_seconds=5,
    )
    await tool.enqueue(
        source_session_key=target.session_key,
        target_handle=source.name,
        content="Answer",
        expect_reply=False,
    )

    assert scheduler.calls[0][1].cancelled


@pytest.mark.asyncio
async def test_reply_follows_a_recycled_handle(tmp_path: Path) -> None:
    sessions = SessionManager(tmp_path)
    _persist(sessions, "websocket:source", "websocket:target")
    bus = MessageBus()
    tool = SendSessionMessageTool(sessions=sessions, bus=bus)
    source = _handle(sessions, "websocket:source")
    target = _handle(sessions, "websocket:target")

    await tool.enqueue(
        source_session_key=source.session_key,
        target_handle=target.name,
        content="Question",
        expect_reply=False,
    )
    received = await bus.consume_inbound()
    assert sessions.delete_session(source.session_key)
    _persist(sessions, "websocket:replacement")
    replacement = _handle(sessions, "websocket:replacement")
    assert replacement.name == source.name

    with request_context(RequestContext(
        channel="system",
        chat_id=target.session_key,
        session_key=target.session_key,
        metadata=received.metadata,
    )):
        result = await tool.execute(
            to=f"@{source.name}",
            content="Answer",
            expect_reply=False,
        )

    assert result == f"Sent to @{source.name}."
    reply = await bus.consume_inbound()
    assert reply.chat_id == replacement.session_key
