"""Test that skip_mentions metadata prevents agents from responding to mentions."""

from __future__ import annotations

import json
from dataclasses import replace
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, Mock, patch

import nio
import pytest

from mindroom.bot import AgentBot
from mindroom.config.agent import AgentConfig
from mindroom.config.main import Config
from mindroom.constants import SKIP_MENTIONS_KEY
from mindroom.conversation_resolver import _should_skip_mentions
from mindroom.delivery_gateway import (
    DeliveryGateway,
    DeliveryGatewayDeps,
    EditTextRequest,
    FinalDeliveryRequest,
    ResponseIdentity,
    SendTextRequest,
    StreamingDeliveryRequest,
)
from mindroom.dispatch_source import MESSAGE_SOURCE_KIND
from mindroom.hooks import MessageEnvelope, ResponseDraft
from mindroom.logging_config import get_logger, setup_logging
from mindroom.matrix.users import AgentMatrixUser
from mindroom.message_target import MessageTarget
from tests.conftest import (
    TEST_PASSWORD,
    bind_runtime_paths,
    delivered_matrix_side_effect,
    make_event_cache_mock,
    message_origin,
    runtime_paths_for,
    sync_bot_runtime_state,
    test_runtime_paths,
)
from tests.identity_helpers import entity_ids, persist_entity_accounts

if TYPE_CHECKING:
    from collections.abc import AsyncIterator
    from pathlib import Path


def test_should_skip_mentions_with_metadata() -> None:
    """Test that should_skip_mentions detects the metadata."""
    # Event with skip_mentions metadata
    event_source = {
        "content": {
            "body": "✅ Scheduled task. @email_agent will be mentioned",
            SKIP_MENTIONS_KEY: True,
        },
    }
    assert _should_skip_mentions(event_source) is True


def test_should_skip_mentions_without_metadata() -> None:
    """Test that should_skip_mentions returns False when no metadata."""
    # Normal event without metadata
    event_source = {
        "content": {
            "body": "Regular message @email_agent",
        },
    }
    assert _should_skip_mentions(event_source) is False


def test_should_skip_mentions_explicit_false() -> None:
    """Test that should_skip_mentions returns False when metadata is False."""
    event_source = {
        "content": {
            "body": "Message with explicit false @email_agent",
            SKIP_MENTIONS_KEY: False,
        },
    }
    assert _should_skip_mentions(event_source) is False


def _context_bot(tmp_path: Path, config: Config | None = None) -> AgentBot:
    """Build a real bot so context extraction exercises the resolver runtime path."""
    if config is None:
        config = bind_runtime_paths(
            Config(agents={"email_agent": AgentConfig(display_name="Email Agent")}),
            test_runtime_paths(tmp_path),
        )
    runtime_paths = runtime_paths_for(config)
    current_ids = entity_ids(config, runtime_paths)
    bot = AgentBot(
        agent_user=AgentMatrixUser(
            agent_name="email_agent",
            password=TEST_PASSWORD,
            display_name="Email Agent",
            user_id=current_ids["email_agent"].full_id,
        ),
        storage_path=tmp_path,
        config=config,
        runtime_paths=runtime_paths,
    )
    bot.client = AsyncMock()
    bot.client.user_id = bot.agent_user.user_id
    bot.logger = MagicMock()
    sync_bot_runtime_state(bot)
    return bot


@pytest.mark.asyncio
async def test_send_response_with_skip_mentions(tmp_path: Path) -> None:
    """Test that the delivery gateway's send_text adds metadata when skip_mentions is True."""
    config = bind_runtime_paths(
        Config(agents={"email_agent": AgentConfig(display_name="Email Agent")}),
        test_runtime_paths(tmp_path),
    )
    persist_entity_accounts(config, runtime_paths_for(config))
    bot = _context_bot(tmp_path, config)

    # Mock the format_message_with_mentions to return a dict we can check
    mock_content = {"body": "test", "msgtype": "m.text"}

    # Create a test room and event
    room = nio.MatrixRoom(room_id="!test:server", own_user_id="@bot:server")
    event = nio.RoomMessageText.from_dict(
        {
            "content": {
                "body": "!schedule in 5 minutes check email",
                "msgtype": "m.text",
            },
            "sender": "@user:server",
            "event_id": "$event123",
            "room_id": "!test:server",
            "origin_server_ts": 123456789,
        },
    )

    # Patch the function to capture what was passed

    with patch("mindroom.delivery_gateway.format_message_with_mentions") as mock_create:
        mock_create.return_value = mock_content.copy()
        with patch(
            "mindroom.delivery_gateway.send_message_result",
            new=AsyncMock(side_effect=delivered_matrix_side_effect("$response123")),
        ) as mock_send:
            # Call the actual delivery gateway send_text with skip_mentions=True
            target = bot._conversation_resolver.build_message_target(
                room_id=room.room_id,
                thread_id=None,
                reply_to_event_id=event.event_id,
                event_source=event.source,
            )
            await bot._delivery_gateway.send_text(
                SendTextRequest(
                    target=target,
                    response_text="✅ Scheduled. Will notify @email_agent",
                    skip_mentions=True,
                ),
            )

            # Check that send_message was called with content that has skip_mentions
            mock_send.assert_called_once()
            sent_content = mock_send.call_args[0][2]  # Third argument is content
            assert sent_content.get(SKIP_MENTIONS_KEY) is True


@pytest.mark.asyncio
async def test_extract_context_with_skip_mentions(tmp_path: Path) -> None:
    """Test that _extract_message_context ignores mentions when skip_mentions is set."""
    bot = _context_bot(tmp_path)

    # Create room
    room = nio.MatrixRoom(room_id="!test:server", own_user_id="@bot:server")

    # Event with skip_mentions metadata and a mention
    event_with_skip = nio.RoomMessageText.from_dict(
        {
            "content": {
                "body": "✅ Scheduled task. @email_agent will handle it",
                "msgtype": "m.text",
                SKIP_MENTIONS_KEY: True,
                "m.mentions": {
                    "user_ids": ["@mindroom_email_agent:localhost"],
                },
            },
            "sender": "@router:server",
            "event_id": "$event123",
            "room_id": "!test:server",
            "origin_server_ts": 123456789,
        },
    )

    # Extract context - should not detect mentions
    context = await bot._conversation_resolver.extract_message_context(
        room,
        event_with_skip,
        caller_label="skip_mentions_test",
    )

    # Verify mentions were ignored
    assert context.am_i_mentioned is False
    assert context.mentioned_agents == []

    # Now test without skip_mentions - should detect mentions
    event_without_skip = nio.RoomMessageText.from_dict(
        {
            "content": {
                "body": "Hey @email_agent can you help?",
                "msgtype": "m.text",
                "m.mentions": {
                    "user_ids": ["@mindroom_email_agent:localhost"],
                },
            },
            "sender": "@user:server",
            "event_id": "$event456",
            "room_id": "!test:server",
            "origin_server_ts": 123456789,
        },
    )

    # Mock check_agent_mentioned to return that we're mentioned
    with patch("mindroom.conversation_resolver.check_agent_mentioned") as mock_check:
        mock_check.return_value = (["email_agent"], True, False)

        context = await bot._conversation_resolver.extract_message_context(
            room,
            event_without_skip,
            caller_label="skip_mentions_test",
        )

        # Verify mentions were detected
        assert context.am_i_mentioned is True
        assert "email_agent" in context.mentioned_agents


@pytest.mark.asyncio
async def test_extract_context_without_skip_metadata_detects_tool_mentions(tmp_path: Path) -> None:
    """Tool-shaped events without skip metadata should still trigger mention detection."""
    config = bind_runtime_paths(
        Config(agents={"email_agent": AgentConfig(display_name="Email Agent")}),
        test_runtime_paths(tmp_path),
    )
    runtime_paths = runtime_paths_for(config)

    bot = _context_bot(tmp_path, config)

    room = nio.MatrixRoom(room_id="!test:server", own_user_id="@bot:server")
    event = nio.RoomMessageText.from_dict(
        {
            "content": {
                "body": f"{bot.matrix_id.full_id} please continue",
                "msgtype": "m.text",
                "m.mentions": {
                    "user_ids": [bot.matrix_id.full_id],
                },
            },
            "sender": "@mindroom_general:localhost",
            "event_id": "$event789",
            "room_id": "!test:server",
            "origin_server_ts": 123456789,
        },
    )

    context = await bot._conversation_resolver.extract_message_context(
        room,
        event,
        caller_label="skip_mentions_test",
    )

    assert context.am_i_mentioned is True
    assert [agent.full_id for agent in context.mentioned_agents] == [
        entity_ids(config, runtime_paths)["email_agent"].full_id,
    ]


def _gateway_with_mocks(tmp_path: Path) -> tuple[DeliveryGateway, AsyncMock, AsyncMock]:
    """Build a direct DeliveryGateway test harness."""
    config = bind_runtime_paths(
        Config(agents={"email_agent": AgentConfig(display_name="Email Agent")}),
        test_runtime_paths(tmp_path),
    )
    runtime_paths = runtime_paths_for(config)
    persist_entity_accounts(config, runtime_paths)
    before_hooks = AsyncMock()
    after_hooks = AsyncMock()
    response_hooks = MagicMock()
    response_hooks._apply_before_response = before_hooks
    response_hooks.emit_after_response = after_hooks
    conversation_cache = SimpleNamespace(
        get_latest_thread_event_id_if_needed=AsyncMock(return_value=None),
        notify_outbound_message=Mock(),
    )
    gateway = DeliveryGateway(
        DeliveryGatewayDeps(
            runtime=SimpleNamespace(
                client=AsyncMock(),
                config=config,
                enable_streaming=True,
                orchestrator=None,
                event_cache=make_event_cache_mock(),
            ),
            runtime_paths=runtime_paths,
            agent_name="email_agent",
            logger=MagicMock(),
            redact_message_event=AsyncMock(return_value=True),
            resolver=SimpleNamespace(
                build_message_target=MagicMock(),
                deps=SimpleNamespace(conversation_cache=conversation_cache),
            ),
            response_hooks=response_hooks,
        ),
    )
    return gateway, before_hooks, after_hooks


def _delivery_envelope() -> MessageEnvelope:
    """Build a minimal response envelope for delivery gateway tests."""
    return MessageEnvelope(
        source_event_id="$event123",
        target=MessageTarget.resolve("!test:server", "$thread", "$event123"),
        body="hello",
        attachment_ids=(),
        mentioned_agents=(),
        agent_name="email_agent",
        origin=message_origin(sender_id="@user:server", requester_id="@user:server", source_kind=MESSAGE_SOURCE_KIND),
    )


@pytest.mark.asyncio
async def test_delivery_gateway_send_text_logs_target_thread_context(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
) -> None:
    """Direct send logs should include the resolved target room/thread."""
    gateway, _, _ = _gateway_with_mocks(tmp_path)
    config = gateway.deps.runtime.config
    target = MessageTarget.resolve("!test:server", "$thread", "$event123")
    monkeypatch.setenv("MINDROOM_LOG_FORMAT", "json")
    setup_logging(level="INFO", runtime_paths=runtime_paths_for(config))
    capsys.readouterr()
    gateway = DeliveryGateway(replace(gateway.deps, logger=get_logger("tests.delivery")))

    gateway.deps.resolver.deps.conversation_cache.get_latest_thread_event_id_if_needed = AsyncMock(
        return_value="$latest",
    )
    with patch(
        "mindroom.delivery_gateway.send_message_result",
        new=AsyncMock(side_effect=delivered_matrix_side_effect("$response")),
    ):
        event_id = await gateway.send_text(
            SendTextRequest(
                target=target,
                response_text="formatted response",
            ),
        )

    payload = json.loads(capsys.readouterr().err.strip().splitlines()[-1])
    assert event_id == "$response"
    assert payload["event"] == "Sent response"
    assert payload["room_id"] == "!test:server"
    assert payload["thread_id"] == "$thread"
    gateway.deps.resolver.deps.conversation_cache.get_latest_thread_event_id_if_needed.assert_awaited_once_with(
        "!test:server",
        "$thread",
        "$event123",
        caller_label="delivery_send_text",
    )


@pytest.mark.asyncio
async def test_delivery_gateway_send_text_records_threaded_outbound_message(tmp_path: Path) -> None:
    """Threaded sends should write through to the conversation cache immediately."""
    gateway, _, _ = _gateway_with_mocks(tmp_path)
    target = MessageTarget.resolve("!test:server", "$thread", None)
    gateway.deps.resolver.deps.conversation_cache.get_latest_thread_event_id_if_needed = AsyncMock(
        return_value="$latest",
    )

    with patch(
        "mindroom.delivery_gateway.send_message_result",
        new=AsyncMock(side_effect=delivered_matrix_side_effect("$response")),
    ) as send:
        event_id = await gateway.send_text(
            SendTextRequest(
                target=target,
                response_text="formatted response",
                retry_sync_recovery=True,
            ),
        )

    assert event_id == "$response"
    assert send.await_args.kwargs["retry_sync_recovery"] is True
    gateway.deps.resolver.deps.conversation_cache.notify_outbound_message.assert_called_once()
    record_args = gateway.deps.resolver.deps.conversation_cache.notify_outbound_message.call_args.args
    assert record_args[0] == "!test:server"
    assert record_args[1] == "$response"
    assert record_args[2]["m.relates_to"]["event_id"] == "$thread"
    assert record_args[2]["m.relates_to"]["m.in_reply_to"]["event_id"] == "$latest"
    gateway.deps.resolver.deps.conversation_cache.get_latest_thread_event_id_if_needed.assert_awaited_once_with(
        "!test:server",
        "$thread",
        None,
        caller_label="delivery_send_text",
    )


@pytest.mark.asyncio
async def test_delivery_gateway_edit_text_records_threaded_outbound_edit(tmp_path: Path) -> None:
    """Threaded edits should treat edit_message success as an event ID and write through immediately."""
    gateway, _, _ = _gateway_with_mocks(tmp_path)
    target = MessageTarget.resolve("!test:server", "$thread", "$root")
    gateway.deps.resolver.deps.conversation_cache.get_latest_thread_event_id_if_needed = AsyncMock(
        return_value="$latest",
    )

    with patch(
        "mindroom.delivery_gateway.edit_message_result",
        new=AsyncMock(side_effect=delivered_matrix_side_effect("$edit-event")),
    ) as edit:
        edited = await gateway.edit_text(
            EditTextRequest(
                target=target,
                event_id="$original",
                new_text="updated response",
                retry_sync_recovery=True,
            ),
        )

    assert edited is True
    assert edit.await_args.kwargs["retry_sync_recovery"] is True
    gateway.deps.resolver.deps.conversation_cache.notify_outbound_message.assert_called_once()
    record_args = gateway.deps.resolver.deps.conversation_cache.notify_outbound_message.call_args.args
    assert record_args[0] == "!test:server"
    assert record_args[1] == "$edit-event"
    assert record_args[2]["m.relates_to"]["rel_type"] == "m.replace"
    assert record_args[2]["m.relates_to"]["event_id"] == "$original"
    assert "m.relates_to" not in record_args[2]["m.new_content"]
    # The fallback the lookup would resolve is popped by the edit envelope, asserted above,
    # so the threaded edit path must not pay for a wait_for_thread_idle to compute it.
    gateway.deps.resolver.deps.conversation_cache.get_latest_thread_event_id_if_needed.assert_not_awaited()


@pytest.mark.asyncio
async def test_delivery_gateway_deliver_stream_labels_latest_thread_lookup(tmp_path: Path) -> None:
    """Streaming delivery should attribute its latest-thread lookup."""
    gateway, _, _ = _gateway_with_mocks(tmp_path)
    target = MessageTarget.resolve("!test:server", "$thread", "$root")
    gateway.deps.resolver.deps.conversation_cache.get_latest_thread_event_id_if_needed = AsyncMock(
        return_value="$latest",
    )

    async def stream() -> AsyncIterator[str]:
        yield "hello"

    with patch(
        "mindroom.delivery_gateway.send_streaming_response",
        new=AsyncMock(return_value=SimpleNamespace(event_id="$stream", final_visible_body="hello")),
    ):
        await gateway.deliver_stream(
            StreamingDeliveryRequest(
                target=target,
                response_stream=stream(),
                existing_event_id="$existing",
            ),
        )

    gateway.deps.resolver.deps.conversation_cache.get_latest_thread_event_id_if_needed.assert_awaited_once_with(
        "!test:server",
        "$thread",
        "$root",
        "$existing",
        caller_label="delivery_stream",
    )


@pytest.mark.asyncio
async def test_delivery_gateway_edit_text_skips_dead_room_mode_relation(tmp_path: Path) -> None:
    """Room-mode edits must not compute a relation discarded by the edit envelope."""
    gateway, _, _ = _gateway_with_mocks(tmp_path)
    gateway.deps.runtime.config.agents["email_agent"].thread_mode = "room"
    target = MessageTarget.resolve("!test:server", "$thread", "$event123", room_mode=True)

    captured_content: dict[str, object] = {}

    async def record_send(
        _client: object,
        _room_id: str,
        content: dict[str, object],
        **_kwargs: object,
    ) -> object:
        captured_content.update(content)
        return await delivered_matrix_side_effect("$edit-event")(_client, _room_id, content)

    with (
        patch.object(
            Config,
            "get_entity_thread_mode",
            new=MagicMock(side_effect=AssertionError("edit path must not resolve thread mode")),
        ) as mode_lookup,
        patch(
            "mindroom.matrix.client_delivery.send_message_result",
            new=AsyncMock(side_effect=record_send),
        ),
    ):
        edited = await gateway.edit_text(
            EditTextRequest(
                target=target,
                event_id="$original",
                new_text="updated response",
            ),
        )

    assert edited is True
    assert captured_content["m.relates_to"] == {"rel_type": "m.replace", "event_id": "$original"}
    replacement = captured_content["m.new_content"]
    assert isinstance(replacement, dict)
    assert "m.relates_to" not in replacement
    mode_lookup.assert_not_called()


@pytest.mark.asyncio
async def test_delivery_gateway_deliver_final_uses_send_text_for_new_messages(tmp_path: Path) -> None:
    """Final delivery should route fresh sends through the gateway helper only."""
    gateway, before_hooks, after_hooks = _gateway_with_mocks(tmp_path)
    before_hooks.return_value = ResponseDraft(
        response_text="raw response",
        response_kind="ai",
        tool_trace=None,
        extra_content=None,
        envelope=_delivery_envelope(),
    )

    parsed = MagicMock()
    parsed.formatted_text = "formatted response"
    parsed.option_map = None
    parsed.options_list = None

    with (
        patch.object(DeliveryGateway, "send_text", new=AsyncMock(return_value="$response")) as mock_send_text,
        patch("mindroom.delivery_gateway.interactive.parse_and_format_interactive", return_value=parsed),
    ):
        result = await gateway.deliver_final(
            FinalDeliveryRequest(
                target=_delivery_envelope().target,
                existing_event_id=None,
                response_text="raw response",
                identity=ResponseIdentity(
                    response_kind="ai",
                    response_envelope=_delivery_envelope(),
                    correlation_id="corr-1",
                ),
                tool_trace=None,
                extra_content=None,
            ),
        )

    mock_send_text.assert_awaited_once()
    assert mock_send_text.await_args.args[0].retry_sync_recovery is True
    after_hooks.assert_not_awaited()
    assert result.event_id == "$response"
    assert result.delivery_kind == "sent"


@pytest.mark.asyncio
async def test_delivery_gateway_deliver_final_uses_edit_text_for_existing_messages(tmp_path: Path) -> None:
    """Final delivery should route edits through the gateway helper only."""
    gateway, before_hooks, after_hooks = _gateway_with_mocks(tmp_path)
    before_hooks.return_value = ResponseDraft(
        response_text="raw response",
        response_kind="ai",
        tool_trace=None,
        extra_content=None,
        envelope=_delivery_envelope(),
    )

    parsed = MagicMock()
    parsed.formatted_text = "formatted response"
    parsed.option_map = None
    parsed.options_list = None

    with (
        patch.object(DeliveryGateway, "edit_text", new=AsyncMock(return_value=True)) as mock_edit_text,
        patch("mindroom.delivery_gateway.interactive.parse_and_format_interactive", return_value=parsed),
    ):
        result = await gateway.deliver_final(
            FinalDeliveryRequest(
                target=_delivery_envelope().target,
                existing_event_id="$existing",
                response_text="raw response",
                identity=ResponseIdentity(
                    response_kind="ai",
                    response_envelope=_delivery_envelope(),
                    correlation_id="corr-2",
                ),
                tool_trace=None,
                extra_content=None,
            ),
        )

    mock_edit_text.assert_awaited_once()
    assert mock_edit_text.await_args.args[0].retry_sync_recovery is True
    after_hooks.assert_not_awaited()
    assert result.event_id == "$existing"
    assert result.delivery_kind == "edited"
