"""Tests for hook-facing Matrix admin helpers."""

from __future__ import annotations

import importlib
import importlib.util
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch

import nio
import pytest

from mindroom.bot import AgentBot
from mindroom.config.agent import AgentConfig
from mindroom.config.main import Config
from mindroom.config.models import ModelConfig
from mindroom.constants import ROUTER_AGENT_NAME
from mindroom.hooks import HookContext, HookContextSupport
from mindroom.hooks.registry import HookRegistry, HookRegistryState
from mindroom.logging_config import get_logger
from mindroom.matrix.cache import AgentMessageSnapshot
from mindroom.matrix.invited_rooms_store import invited_rooms_path, load_invited_rooms
from mindroom.matrix.state import MatrixState
from mindroom.matrix.users import AgentMatrixUser
from mindroom.orchestrator import _MultiAgentOrchestrator
from tests.conftest import (
    TEST_PASSWORD,
    bind_runtime_paths,
    install_runtime_cache_support,
    orchestrator_runtime_paths,
    runtime_paths_for,
    test_runtime_paths,
)
from tests.identity_helpers import entity_ids, persist_entity_accounts

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable
    from pathlib import Path


def _config(tmp_path: Path) -> Config:
    runtime_paths = test_runtime_paths(tmp_path)
    return bind_runtime_paths(
        Config(models={"default": ModelConfig(provider="test", id="test-model")}),
        runtime_paths,
    )


def _matrix_admin_module() -> object:
    spec = importlib.util.find_spec("mindroom.hooks.matrix_admin")
    assert spec is not None, "mindroom.hooks.matrix_admin should exist"
    return importlib.import_module("mindroom.hooks.matrix_admin")


def _private_room_config(tmp_path: Path) -> Config:
    runtime_paths = test_runtime_paths(tmp_path)
    config = bind_runtime_paths(
        Config(
            agents={"general": AgentConfig(display_name="General", rooms=[])},
            models={"default": ModelConfig(provider="test", id="test-model")},
        ),
        runtime_paths,
    )
    persist_entity_accounts(
        config,
        runtime_paths_for(config),
        usernames={ROUTER_AGENT_NAME: "mindroom_router", "general": "mindroom_general"},
    )
    return config


def _router_user(user_id: str) -> AgentMatrixUser:
    return AgentMatrixUser(
        agent_name=ROUTER_AGENT_NAME,
        user_id=user_id,
        display_name="Router",
        password=TEST_PASSWORD,
    )


def test_hooks_package_reexports_hook_matrix_admin_api() -> None:
    """The public hooks package should export the matrix admin hook API."""
    hooks = importlib.import_module("mindroom.hooks")

    assert hasattr(hooks, "HookMatrixAdmin")
    assert hasattr(hooks, "build_hook_matrix_admin")


@pytest.mark.asyncio
async def test_hook_context_delegates_latest_agent_message_snapshot_reads(tmp_path: Path) -> None:
    """HookContext should route latest-agent-message snapshot reads through the bound helper."""
    config = _config(tmp_path)
    reader = AsyncMock(
        return_value=AgentMessageSnapshot(
            content={"body": "Working...", "msgtype": "m.text"},
            origin_server_ts=2000,
        ),
    )
    context = HookContext(
        event_name="message:enrich",
        plugin_name="workloop",
        settings={},
        config=config,
        runtime_paths=runtime_paths_for(config),
        logger=get_logger("tests.hook_matrix_admin"),
        correlation_id="corr-snapshot",
        runtime_started_at=1234.0,
        agent_message_snapshot_reader=reader,
    )

    snapshot = await context.get_latest_agent_message_snapshot(
        "!room:localhost",
        "@agent:localhost",
        thread_id="$thread_root",
    )

    assert snapshot == AgentMessageSnapshot(
        content={"body": "Working...", "msgtype": "m.text"},
        origin_server_ts=2000,
    )
    reader.assert_awaited_once_with(
        room_id="!room:localhost",
        thread_id="$thread_root",
        sender="@agent:localhost",
        runtime_started_at=1234.0,
    )


@pytest.mark.asyncio
async def test_build_hook_matrix_admin_resolve_alias_returns_room_id(tmp_path: Path) -> None:
    """Alias resolution should return the resolved room ID on success."""
    module = _matrix_admin_module()
    client = AsyncMock(spec=nio.AsyncClient)
    client.homeserver = "http://localhost:8008"
    client.room_resolve_alias.return_value = nio.RoomResolveAliasResponse(
        room_alias="#personal-user:localhost",
        room_id="!personal:localhost",
        servers=["localhost"],
    )

    admin = module.build_hook_matrix_admin(client, runtime_paths=test_runtime_paths(tmp_path))
    room_id = await admin.resolve_alias("#personal-user:localhost")

    assert room_id == "!personal:localhost"
    client.room_resolve_alias.assert_awaited_once_with("#personal-user:localhost")


@pytest.mark.asyncio
async def test_build_hook_matrix_admin_resolve_alias_returns_none_on_error(tmp_path: Path) -> None:
    """Alias resolution should fail closed on Matrix error responses."""
    module = _matrix_admin_module()
    client = AsyncMock(spec=nio.AsyncClient)
    client.homeserver = "http://localhost:8008"
    client.room_resolve_alias.return_value = nio.RoomResolveAliasError("not found", status_code="M_NOT_FOUND")

    admin = module.build_hook_matrix_admin(client, runtime_paths=test_runtime_paths(tmp_path))
    room_id = await admin.resolve_alias("#personal-user:localhost")

    assert room_id is None


@pytest.mark.asyncio
async def test_build_hook_matrix_admin_delegates_existing_room_helpers(tmp_path: Path) -> None:
    """The hook builder should reuse the existing Matrix helper functions."""
    module = _matrix_admin_module()
    runtime_paths = runtime_paths_for(_config(tmp_path))
    client = AsyncMock(spec=nio.AsyncClient)
    client.homeserver = "http://localhost:8008"

    with (
        patch(
            "mindroom.hooks.matrix_admin.create_room",
            new=AsyncMock(return_value="!created:localhost"),
        ) as mock_create,
        patch("mindroom.hooks.matrix_admin.invite_to_room", new=AsyncMock(return_value=True)) as mock_invite,
        patch(
            "mindroom.hooks.matrix_admin.get_room_members",
            new=AsyncMock(return_value={"@user:localhost", "@mindroom_router:localhost"}),
        ) as mock_members,
        patch("mindroom.hooks.matrix_admin.add_room_to_space", new=AsyncMock(return_value=True)) as mock_add,
    ):
        admin = module.build_hook_matrix_admin(client, runtime_paths=runtime_paths)

        room_id = await admin.create_room(name="Personal Room", alias_localpart="personal-user", topic="Hello")
        invited = await admin.invite_user("!created:localhost", "@user:localhost")
        client.room_kick.return_value = nio.RoomKickResponse()
        kicked = await admin.kick_user(
            "!created:localhost",
            "@user:localhost",
            reason="Reset membership",
        )
        members = await admin.get_room_members("!created:localhost")
        added = await admin.add_room_to_space("!space:localhost", "!created:localhost")
        client.room_put_state.return_value = nio.RoomPutStateResponse.from_dict(
            {"event_id": "$state"},
            room_id="!created:localhost",
        )
        wrote_state = await admin.put_room_state(
            "!created:localhost",
            "com.mindroom.scheduled.task",
            "task123",
            {"status": "pending"},
        )

    assert room_id == "!created:localhost"
    assert invited is True
    assert kicked is True
    assert members == {"@user:localhost", "@mindroom_router:localhost"}
    assert added is True
    assert wrote_state is True
    mock_create.assert_awaited_once_with(
        client=client,
        name="Personal Room",
        alias="personal-user",
        topic="Hello",
        power_users=None,
    )
    mock_invite.assert_awaited_once_with(client, "!created:localhost", "@user:localhost")
    client.room_kick.assert_awaited_once_with(
        "!created:localhost",
        "@user:localhost",
        reason="Reset membership",
    )
    client.room_put_state.assert_awaited_once_with(
        room_id="!created:localhost",
        event_type="com.mindroom.scheduled.task",
        content={"status": "pending"},
        state_key="task123",
    )
    mock_members.assert_awaited_once_with(client, "!created:localhost")
    mock_add.assert_awaited_once()


@pytest.mark.asyncio
async def test_hook_matrix_admin_kick_user_returns_false_on_error(tmp_path: Path) -> None:
    """User kicks should fail closed on Matrix error responses."""
    module = _matrix_admin_module()
    client = AsyncMock(spec=nio.AsyncClient)
    client.homeserver = "http://localhost:8008"
    client.room_kick.return_value = nio.RoomKickError("forbidden", status_code="M_FORBIDDEN")

    admin = module.build_hook_matrix_admin(client, runtime_paths=test_runtime_paths(tmp_path))

    assert await admin.kick_user("!room:localhost", "@user:localhost") is False
    client.room_kick.assert_awaited_once_with("!room:localhost", "@user:localhost", reason=None)


@pytest.mark.asyncio
async def test_hook_matrix_admin_invite_user_with_config_delegates_to_raw_invite(tmp_path: Path) -> None:
    """Single-user invite should not run managed private-room reconciliation."""
    module = _matrix_admin_module()
    config = _private_room_config(tmp_path)
    runtime_paths = runtime_paths_for(config)
    client = AsyncMock(spec=nio.AsyncClient)
    client.homeserver = "http://localhost:8008"

    with patch("mindroom.hooks.matrix_admin.invite_to_room", new=AsyncMock(return_value=False)) as mock_invite:
        admin = module.build_hook_matrix_admin(client, runtime_paths=runtime_paths, config=config)
        invited = await admin.invite_user("!created:localhost", "@user:localhost")

    assert invited is False
    client.joined_members.assert_not_awaited()
    mock_invite.assert_awaited_once_with(client, "!created:localhost", "@user:localhost")
    assert load_invited_rooms(invited_rooms_path(runtime_paths.storage_root, ROUTER_AGENT_NAME)) == set()
    assert load_invited_rooms(invited_rooms_path(runtime_paths.storage_root, "general")) == set()


@pytest.mark.asyncio
async def test_hook_matrix_admin_create_room_persists_room_for_managed_creator(tmp_path: Path) -> None:
    """A managed bot's created room is recorded so lifecycle cleanup preserves it for the creator."""
    module = _matrix_admin_module()
    config = _private_room_config(tmp_path)
    runtime_paths = runtime_paths_for(config)
    ids = entity_ids(config, runtime_paths)
    client = AsyncMock(spec=nio.AsyncClient)
    client.homeserver = "http://localhost:8008"
    client.user_id = ids[ROUTER_AGENT_NAME].full_id

    with patch("mindroom.hooks.matrix_admin.create_room", new=AsyncMock(return_value="!private:localhost")):
        admin = module.build_hook_matrix_admin(client, runtime_paths=runtime_paths, config=config)
        room_id = await admin.create_room(name="Private Room", alias_localpart="private-user")

    assert room_id == "!private:localhost"
    assert "!private:localhost" in load_invited_rooms(
        invited_rooms_path(runtime_paths.storage_root, ROUTER_AGENT_NAME),
    )
    # Only the creator records the room; invited bots rely on the invite-accept lifecycle instead.
    assert load_invited_rooms(invited_rooms_path(runtime_paths.storage_root, "general")) == set()


@pytest.mark.asyncio
async def test_hook_matrix_admin_create_room_does_not_persist_for_unmanaged_creator(tmp_path: Path) -> None:
    """A non-managed creator records nothing, even when config is present."""
    module = _matrix_admin_module()
    config = _private_room_config(tmp_path)
    runtime_paths = runtime_paths_for(config)
    client = AsyncMock(spec=nio.AsyncClient)
    client.homeserver = "http://localhost:8008"
    client.user_id = "@stranger:localhost"

    with patch("mindroom.hooks.matrix_admin.create_room", new=AsyncMock(return_value="!private:localhost")):
        admin = module.build_hook_matrix_admin(client, runtime_paths=runtime_paths, config=config)
        room_id = await admin.create_room(name="Private Room", alias_localpart="private-user")

    assert room_id == "!private:localhost"
    assert load_invited_rooms(invited_rooms_path(runtime_paths.storage_root, ROUTER_AGENT_NAME)) == set()
    assert load_invited_rooms(invited_rooms_path(runtime_paths.storage_root, "general")) == set()


@pytest.mark.asyncio
async def test_hook_matrix_admin_created_room_survives_lifecycle_cleanup(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    """A room the router creates must survive its own lifecycle cleanup."""
    module = _matrix_admin_module()
    config = _private_room_config(tmp_path)
    runtime_paths = runtime_paths_for(config)
    ids = entity_ids(config, runtime_paths)
    client = AsyncMock(spec=nio.AsyncClient)
    client.homeserver = "http://localhost:8008"
    client.user_id = ids[ROUTER_AGENT_NAME].full_id

    with patch("mindroom.hooks.matrix_admin.create_room", new=AsyncMock(return_value="!private:localhost")):
        admin = module.build_hook_matrix_admin(client, runtime_paths=runtime_paths, config=config)
        await admin.create_room(name="Private Room", alias_localpart="private-user")

    bot = AgentBot(
        agent_user=_router_user(ids[ROUTER_AGENT_NAME].full_id),
        storage_path=tmp_path,
        config=config,
        runtime_paths=runtime_paths,
        rooms=[],
    )
    install_runtime_cache_support(bot)
    bot.client = AsyncMock()
    left_room_ids: list[str] = []

    async def mock_leave_non_dm_rooms(
        _client: AsyncMock,
        room_ids: list[str],
        *,
        on_room_left: Callable[[str], Awaitable[None]],
    ) -> list[str]:
        left_room_ids.extend(room_ids)
        for room_id in room_ids:
            await on_room_left(room_id)
        return room_ids

    monkeypatch.setattr(
        "mindroom.bot_room_lifecycle.get_joined_rooms",
        AsyncMock(return_value=["!private:localhost", "!old:localhost"]),
    )
    monkeypatch.setattr("mindroom.bot_room_lifecycle.leave_non_dm_rooms", mock_leave_non_dm_rooms)
    monkeypatch.setattr("mindroom.bot_room_lifecycle.matrix_state_for_runtime", lambda *_args, **_kwargs: MatrixState())

    await bot.leave_unconfigured_rooms()

    assert bot._room_lifecycle.invited_rooms == {"!private:localhost"}
    assert left_room_ids == ["!old:localhost"]


def test_hook_context_support_prefers_orchestrator_router_matrix_admin(tmp_path: Path) -> None:
    """Router hook support should reuse the orchestrator router admin surface when available."""
    config = _config(tmp_path)
    orchestrator = MagicMock()
    sentinel = object()
    orchestrator.hook_matrix_admin.return_value = sentinel
    runtime = SimpleNamespace(
        client=AsyncMock(spec=nio.AsyncClient),
        orchestrator=orchestrator,
        config=config,
        runtime_started_at=0.0,
    )
    support = HookContextSupport(
        runtime=runtime,
        logger=get_logger("tests.hook_matrix_admin"),
        runtime_paths=runtime_paths_for(config),
        agent_name="router",
        hook_registry_state=HookRegistryState(HookRegistry.empty()),
        hook_send_message=AsyncMock(),
    )

    assert hasattr(support, "matrix_admin")
    with patch("mindroom.hooks.matrix_admin.build_hook_matrix_admin", return_value=sentinel) as mock_build:
        admin = support.matrix_admin()

    assert admin is sentinel
    orchestrator.hook_matrix_admin.assert_called_once_with()
    mock_build.assert_not_called()


def test_hook_context_support_builds_router_matrix_admin_without_orchestrator(tmp_path: Path) -> None:
    """Router hook support should build from the live router client when no orchestrator exists."""
    config = _config(tmp_path)
    runtime = SimpleNamespace(
        client=AsyncMock(spec=nio.AsyncClient),
        orchestrator=None,
        config=config,
        runtime_started_at=0.0,
    )
    support = HookContextSupport(
        runtime=runtime,
        logger=get_logger("tests.hook_matrix_admin"),
        runtime_paths=runtime_paths_for(config),
        agent_name="router",
        hook_registry_state=HookRegistryState(HookRegistry.empty()),
        hook_send_message=AsyncMock(),
    )
    sentinel = object()

    assert hasattr(support, "matrix_admin")
    with patch("mindroom.hooks.matrix_admin.build_hook_matrix_admin", return_value=sentinel) as mock_build:
        admin = support.matrix_admin()

    assert admin is sentinel
    mock_build.assert_called_once_with(runtime.client, runtime_paths_for(config), config=config)


def test_hook_context_support_falls_back_to_orchestrator_router_matrix_admin(tmp_path: Path) -> None:
    """Non-router hooks should use the orchestrator router admin surface."""
    config = _config(tmp_path)
    orchestrator = MagicMock()
    sentinel = object()
    orchestrator.hook_matrix_admin.return_value = sentinel
    runtime = SimpleNamespace(
        client=AsyncMock(spec=nio.AsyncClient),
        orchestrator=orchestrator,
        config=config,
        runtime_started_at=0.0,
    )
    support = HookContextSupport(
        runtime=runtime,
        logger=get_logger("tests.hook_matrix_admin"),
        runtime_paths=runtime_paths_for(config),
        agent_name="code",
        hook_registry_state=HookRegistryState(HookRegistry.empty()),
        hook_send_message=AsyncMock(),
    )

    assert hasattr(support, "matrix_admin")
    admin = support.matrix_admin()

    assert admin is sentinel
    orchestrator.hook_matrix_admin.assert_called_once_with()


def test_hook_context_support_returns_none_without_router_matrix_admin(tmp_path: Path) -> None:
    """When no router admin client is available, matrix_admin should be unavailable."""
    config = _config(tmp_path)
    runtime = SimpleNamespace(
        client=None,
        orchestrator=None,
        config=config,
        runtime_started_at=0.0,
    )
    support = HookContextSupport(
        runtime=runtime,
        logger=get_logger("tests.hook_matrix_admin"),
        runtime_paths=runtime_paths_for(config),
        agent_name="code",
        hook_registry_state=HookRegistryState(HookRegistry.empty()),
        hook_send_message=AsyncMock(),
    )

    assert hasattr(support, "matrix_admin")
    assert support.matrix_admin() is None


@pytest.mark.asyncio
async def test_emit_config_reloaded_context_includes_matrix_admin(tmp_path: Path) -> None:
    """config:reloaded should expose the router-backed matrix admin helper."""
    runtime_paths = orchestrator_runtime_paths(tmp_path)
    config = bind_runtime_paths(
        Config(models={"default": ModelConfig(provider="test", id="test-model")}),
        runtime_paths,
    )
    orchestrator = _MultiAgentOrchestrator(runtime_paths=runtime_paths)
    orchestrator.config = config
    orchestrator.hook_registry = MagicMock()
    orchestrator.hook_registry.has_hooks.return_value = True
    router_client = AsyncMock(spec=nio.AsyncClient)
    router_client.homeserver = "http://localhost:8008"
    orchestrator.agent_bots["router"] = SimpleNamespace(
        client=router_client,
        _hook_send_message=AsyncMock(),
    )

    with patch("mindroom.orchestrator.emit", new=AsyncMock()) as mock_emit:
        await orchestrator._emit_config_reloaded(
            new_config=config,
            changed_entities={"router"},
            added_entities=set(),
            removed_entities=set(),
            plugin_changes=(),
        )

    context = mock_emit.await_args.args[2]
    assert context.matrix_admin is not None
