"""Tests for matrix agent manager functionality."""

from __future__ import annotations

from typing import TYPE_CHECKING, Self
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import nio
import pytest
import yaml

from mindroom.config.main import Config
from mindroom.config.matrix import MindRoomUserConfig
from mindroom.matrix import provisioning
from mindroom.matrix.client import PermanentMatrixStartupError
from mindroom.matrix.state import MatrixState
from mindroom.matrix.users import (
    INTERNAL_USER_AGENT_NAME,
    AgentMatrixUser,
    _ensure_all_agent_users,
    _get_agent_credentials,
    _register_user,
    _save_agent_credentials,
    create_agent_user,
    login_agent_user,
)
from tests.conftest import TEST_ACCESS_TOKEN, TEST_PASSWORD

if TYPE_CHECKING:
    from pathlib import Path

DEFAULT_INTERNAL_USERNAME = MindRoomUserConfig().username


def _recording_httpx_async_client(
    captured_requests: list[tuple[str, dict[str, object]]],
    response: httpx.Response,
) -> type[object]:
    """Build a minimal AsyncClient replacement that records POST payloads."""

    class _FakeAsyncClient:
        def __init__(self, *_: object, **__: object) -> None:
            pass

        async def __aenter__(self) -> Self:
            return self

        async def __aexit__(self, *_: object) -> None:
            return None

        async def post(self, url: str, json: dict[str, object]) -> httpx.Response:
            captured_requests.append((url, json))
            return response

    return _FakeAsyncClient


@pytest.fixture(autouse=True)
def _clear_matrix_registration_token(monkeypatch: pytest.MonkeyPatch) -> None:
    """Keep matrix registration tests deterministic unless explicitly overridden."""
    monkeypatch.delenv("MATRIX_REGISTRATION_TOKEN", raising=False)
    monkeypatch.delenv("MINDROOM_PROVISIONING_URL", raising=False)
    monkeypatch.delenv("MINDROOM_LOCAL_CLIENT_ID", raising=False)
    monkeypatch.delenv("MINDROOM_LOCAL_CLIENT_SECRET", raising=False)


@pytest.fixture
def temp_matrix_users_file(tmp_path: Path) -> Path:
    """Create a temporary matrix_state.yaml file."""
    file_path = tmp_path / "matrix_state.yaml"
    initial_data = {
        "accounts": {
            "bot": {"username": "mindroom_bot", "password": "bot_password_123"},
            "user": {"username": DEFAULT_INTERNAL_USERNAME, "password": "user_password_123"},
        },
        "rooms": {},
    }
    with file_path.open("w") as f:
        yaml.dump(initial_data, f)
    return file_path


@pytest.fixture
def mock_agent_config() -> dict:
    """Mock agent configuration."""
    return {
        "agents": {
            "calculator": {"display_name": "CalculatorAgent"},
            "general": {"display_name": "GeneralAgent"},
        },
    }


class TestAgentMatrixUser:
    """Test AgentMatrixUser dataclass."""

    def test_agent_matrix_user_creation(self) -> None:
        """Test creating an AgentMatrixUser instance."""
        user = AgentMatrixUser(
            agent_name="calculator",
            user_id="@mindroom_calculator:localhost",
            display_name="CalculatorAgent",
            password=TEST_PASSWORD,
            access_token=TEST_ACCESS_TOKEN,
        )
        assert user.agent_name == "calculator"
        assert user.user_id == "@mindroom_calculator:localhost"
        assert user.display_name == "CalculatorAgent"
        assert user.password == TEST_PASSWORD
        assert user.access_token == TEST_ACCESS_TOKEN


class TestMatrixUserManagement:
    """Test matrix user management functions."""

    def test_load_matrix_users(self, temp_matrix_users_file: Path) -> None:
        """Test loading matrix users from file."""
        with patch("mindroom.matrix.state.MATRIX_STATE_FILE", temp_matrix_users_file):
            state = MatrixState.load()

        assert "bot" in state.accounts
        assert state.accounts["bot"].username == "mindroom_bot"
        assert "user" in state.accounts
        assert state.accounts["user"].username == DEFAULT_INTERNAL_USERNAME

    def test_load_matrix_users_no_file(self, tmp_path: Path) -> None:
        """Test loading matrix users when file doesn't exist."""
        missing_file = tmp_path / "missing_matrix_state.yaml"
        with patch("mindroom.matrix.state.MATRIX_STATE_FILE", missing_file):
            state = MatrixState.load()
        assert state.accounts == {}
        assert state.rooms == {}

    def test_save_matrix_users(self, tmp_path: Path) -> None:
        """Test saving matrix users to file."""
        file_path = tmp_path / "test_users.yaml"

        with patch("mindroom.matrix.state.MATRIX_STATE_FILE", file_path):
            state = MatrixState()
            state.add_account("agent_test", "mindroom_test", "test_pass")
            state.save()

        # Verify the file was written correctly
        with file_path.open() as f:
            saved_data = yaml.safe_load(f)
        assert "accounts" in saved_data
        assert "agent_test" in saved_data["accounts"]
        assert saved_data["accounts"]["agent_test"]["username"] == "mindroom_test"

    @patch("mindroom.matrix.state.MatrixState.load")
    def test_get_agent_credentials(self, mock_load: MagicMock) -> None:
        """Test getting agent credentials."""
        mock_state = MatrixState()
        mock_state.add_account("agent_calculator", "mindroom_calculator", "calc_pass")
        mock_load.return_value = mock_state

        creds = _get_agent_credentials("calculator")
        assert creds is not None
        assert creds["username"] == "mindroom_calculator"
        assert creds["password"] == "calc_pass"  # noqa: S105

        # Test non-existent agent
        creds = _get_agent_credentials("nonexistent")
        assert creds is None

    @patch("mindroom.matrix.state.MatrixState.save")
    @patch("mindroom.matrix.state.MatrixState.load")
    def test_save_agent_credentials(self, mock_load: MagicMock, mock_save: MagicMock) -> None:
        """Test saving agent credentials."""
        mock_state = MatrixState()
        mock_state.add_account("bot", "bot", "pass")
        mock_load.return_value = mock_state

        _save_agent_credentials("calculator", "mindroom_calculator", "calc_pass")

        # Verify the account was added
        assert "agent_calculator" in mock_state.accounts
        assert mock_state.accounts["agent_calculator"].username == "mindroom_calculator"
        assert mock_state.accounts["agent_calculator"].password == "calc_pass"  # noqa: S105
        mock_save.assert_called_once()


class TestMatrixRegistration:
    """Test Matrix user registration functions."""

    @pytest.mark.asyncio
    async def test_register_user_success(self) -> None:
        """Test successful user registration."""
        mock_client = AsyncMock()
        # Mock successful registration
        mock_response = MagicMock(spec=nio.RegisterResponse)
        mock_response.user_id = "@test_user:localhost"
        mock_response.access_token = "test_token"  # noqa: S105
        mock_response.device_id = "test_device"
        mock_client.register.return_value = mock_response
        mock_login_response = MagicMock(spec=nio.LoginResponse)
        mock_client.login.return_value = mock_login_response
        mock_client.set_displayname.return_value = AsyncMock()

        with patch("mindroom.matrix.users.matrix_client") as mock_matrix_client:
            mock_matrix_client.return_value.__aenter__.return_value = mock_client

            user_id = await _register_user("http://localhost:8008", "test_user", "test_pass", "Test User")

            assert user_id == "@test_user:localhost"
            mock_client.register.assert_called_once()
            mock_client.set_displayname.assert_called_once_with("Test User")
            mock_matrix_client.assert_called_once()

    @pytest.mark.asyncio
    async def test_register_user_already_exists(self) -> None:
        """Test registration when user already exists."""
        mock_client = AsyncMock()
        # Mock user already exists error
        mock_response = MagicMock(spec=nio.ErrorResponse)
        mock_response.status_code = "M_USER_IN_USE"
        mock_client.register.return_value = mock_response
        mock_client.login.return_value = MagicMock(spec=nio.LoginResponse)
        mock_client.set_displayname.return_value = AsyncMock()

        with patch("mindroom.matrix.users.matrix_client") as mock_matrix_client:
            mock_matrix_client.return_value.__aenter__.return_value = mock_client

            user_id = await _register_user("http://localhost:8008", "existing_user", "test_pass", "Existing User")

            assert user_id == "@existing_user:localhost"
            mock_matrix_client.assert_called_once()
            mock_client.login.assert_called_once_with("test_pass")
            mock_client.set_displayname.assert_called_once_with("Existing User")

    @pytest.mark.asyncio
    async def test_register_user_already_exists_login_failure(self) -> None:
        """Test registration failure when user exists but provided password is invalid."""
        mock_client = AsyncMock()
        mock_response = MagicMock(spec=nio.ErrorResponse)
        mock_response.status_code = "M_USER_IN_USE"
        mock_client.register.return_value = mock_response
        mock_client.login.return_value = MagicMock(spec=nio.LoginError)

        with patch("mindroom.matrix.users.matrix_client") as mock_matrix_client:
            mock_matrix_client.return_value.__aenter__.return_value = mock_client

            with pytest.raises(ValueError, match="Matrix account collision"):
                await _register_user("http://localhost:8008", "existing_user", "wrong_pass", "Existing User")

    @pytest.mark.asyncio
    async def test_register_user_failure(self) -> None:
        """Test registration failure."""
        mock_client = AsyncMock()
        # Mock registration failure
        mock_response = MagicMock(spec=nio.ErrorResponse)
        mock_response.status_code = "M_FORBIDDEN"
        mock_response.message = "Forbidden"
        mock_client.register.return_value = mock_response

        with patch("mindroom.matrix.users.matrix_client") as mock_matrix_client:
            mock_matrix_client.return_value.__aenter__.return_value = mock_client

            with pytest.raises(PermanentMatrixStartupError, match="Failed to register user"):
                await _register_user("http://localhost:8008", "test_user", "test_pass", "Test User")

            mock_matrix_client.assert_called_once()

    @pytest.mark.asyncio
    async def test_register_user_uses_registration_token_when_configured(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """When MATRIX_REGISTRATION_TOKEN is set, register via token auth flow."""
        test_pass = "test_pass"  # noqa: S105
        registration_token = "token-123"  # noqa: S105
        monkeypatch.setenv("MATRIX_REGISTRATION_TOKEN", registration_token)

        mock_client = AsyncMock()
        mock_client.login.return_value = nio.LoginResponse(
            user_id="@test_user:localhost",
            device_id="TEST_DEVICE",
            access_token=TEST_ACCESS_TOKEN,
        )
        mock_client.set_displayname.return_value = AsyncMock()
        captured_requests: list[tuple[str, dict[str, object]]] = []

        with (
            patch(
                "mindroom.matrix.users.httpx.AsyncClient",
                _recording_httpx_async_client(
                    captured_requests,
                    httpx.Response(200, json={"user_id": "@test_user:localhost"}),
                ),
            ),
            patch("mindroom.matrix.users.matrix_client") as mock_matrix_client,
        ):
            mock_matrix_client.return_value.__aenter__.return_value = mock_client

            user_id = await _register_user("http://localhost:8008", "test_user", test_pass, "Test User")

            assert user_id == "@test_user:localhost"
            assert captured_requests == [
                (
                    "http://localhost:8008/_matrix/client/v3/register",
                    {
                        "username": "test_user",
                        "password": test_pass,
                        "device_name": "mindroom_agent",
                        "auth": {
                            "type": "m.login.registration_token",
                            "token": registration_token,
                        },
                    },
                ),
            ]
            mock_client.register.assert_not_called()
            mock_client.register_with_token.assert_not_called()
            mock_client.login.assert_called_once_with(test_pass)
            mock_client.set_displayname.assert_called_once_with("Test User")

    @pytest.mark.asyncio
    async def test_register_user_falls_back_to_matrix_nio_token_flow_on_uiaa_challenge(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Spec-strict homeservers should fall back to matrix-nio's interactive token flow."""
        test_pass = "test_pass"  # noqa: S105
        registration_token = "token-123"  # noqa: S105
        monkeypatch.setenv("MATRIX_REGISTRATION_TOKEN", registration_token)

        mock_client = AsyncMock()
        mock_client.register_with_token.return_value = nio.RegisterResponse(
            user_id="@test_user:localhost",
            device_id="TEST_DEVICE",
            access_token=TEST_ACCESS_TOKEN,
        )
        mock_client.set_displayname.return_value = AsyncMock()
        captured_requests: list[tuple[str, dict[str, object]]] = []

        with (
            patch(
                "mindroom.matrix.users.httpx.AsyncClient",
                _recording_httpx_async_client(
                    captured_requests,
                    httpx.Response(
                        401,
                        json={
                            "session": "sess-123",
                            "flows": [{"stages": ["m.login.registration_token"]}],
                        },
                    ),
                ),
            ),
            patch("mindroom.matrix.users.matrix_client") as mock_matrix_client,
        ):
            mock_matrix_client.return_value.__aenter__.return_value = mock_client

            user_id = await _register_user("http://localhost:8008", "test_user", test_pass, "Test User")

            assert user_id == "@test_user:localhost"
            assert captured_requests == [
                (
                    "http://localhost:8008/_matrix/client/v3/register",
                    {
                        "username": "test_user",
                        "password": test_pass,
                        "device_name": "mindroom_agent",
                        "auth": {
                            "type": "m.login.registration_token",
                            "token": registration_token,
                        },
                    },
                ),
            ]
            mock_client.register_with_token.assert_called_once_with(
                username="test_user",
                password=test_pass,
                registration_token=registration_token,
                device_name="mindroom_agent",
            )
            mock_client.login.assert_not_called()
            mock_client.set_displayname.assert_called_once_with("Test User")

    @pytest.mark.asyncio
    async def test_register_user_uses_provisioning_service_register_agent_when_configured(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """When provisioning client creds are set, use register-agent provisioning flow."""
        test_pass = "test_pass"  # noqa: S105
        monkeypatch.setenv("MINDROOM_PROVISIONING_URL", "https://provisioning.example")
        monkeypatch.setenv("MINDROOM_LOCAL_CLIENT_ID", "client-123")
        client_secret = "secret-123"  # noqa: S105
        monkeypatch.setenv("MINDROOM_LOCAL_CLIENT_SECRET", client_secret)

        with (
            patch(
                "mindroom.matrix.users.provisioning.register_user_via_provisioning_service",
                new_callable=AsyncMock,
            ) as mock_register,
            patch("mindroom.matrix.users.matrix_client") as mock_matrix_client,
            patch(
                "mindroom.matrix.users.provisioning.registration_token_from_env",
                return_value=None,
            ),
        ):
            mock_register.return_value = MagicMock(
                status="created",
                user_id="@test_user:localhost",
            )

            user_id = await _register_user("http://localhost:8008", "test_user", test_pass, "Test User")

            assert user_id == "@test_user:localhost"
            mock_register.assert_called_once_with(
                provisioning_url="https://provisioning.example",
                client_id="client-123",
                client_secret=client_secret,
                homeserver="http://localhost:8008",
                username="test_user",
                password=test_pass,
                display_name="Test User",
            )
            mock_matrix_client.assert_not_called()

    @pytest.mark.asyncio
    async def test_register_user_provisioning_user_in_use_logs_in_and_syncs_display(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """When provisioning reports user exists, login locally and sync display name."""
        test_pass = "test_pass"  # noqa: S105
        monkeypatch.setenv("MINDROOM_PROVISIONING_URL", "https://provisioning.example")
        monkeypatch.setenv("MINDROOM_LOCAL_CLIENT_ID", "client-123")
        monkeypatch.setenv("MINDROOM_LOCAL_CLIENT_SECRET", "secret-123")

        mock_client = AsyncMock()
        mock_client.login.return_value = nio.LoginResponse(
            user_id="@test_user:localhost",
            device_id="TEST_DEVICE",
            access_token=TEST_ACCESS_TOKEN,
        )
        mock_client.set_displayname.return_value = AsyncMock()

        with (
            patch(
                "mindroom.matrix.users.provisioning.register_user_via_provisioning_service",
                new_callable=AsyncMock,
            ) as mock_register,
            patch("mindroom.matrix.users.matrix_client") as mock_matrix_client,
            patch(
                "mindroom.matrix.users.provisioning.registration_token_from_env",
                return_value=None,
            ),
        ):
            mock_register.return_value = MagicMock(
                status="user_in_use",
                user_id="@test_user:localhost",
            )
            mock_matrix_client.return_value.__aenter__.return_value = mock_client

            user_id = await _register_user("http://localhost:8008", "test_user", test_pass, "Test User")

            assert user_id == "@test_user:localhost"
            mock_client.login.assert_called_once_with(test_pass)
            mock_client.set_displayname.assert_called_once_with("Test User")

    @pytest.mark.asyncio
    async def test_register_user_provisioning_user_in_use_uses_local_server_name(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """When provisioning returns mismatched user_id, local server-name-derived ID should be used."""
        test_pass = "test_pass"  # noqa: S105
        monkeypatch.setenv("MINDROOM_PROVISIONING_URL", "https://provisioning.example")
        monkeypatch.setenv("MINDROOM_LOCAL_CLIENT_ID", "client-123")
        monkeypatch.setenv("MINDROOM_LOCAL_CLIENT_SECRET", "secret-123")

        mock_client = AsyncMock()
        mock_client.login.return_value = nio.LoginResponse(
            user_id="@test_user:mindroom.chat",
            device_id="TEST_DEVICE",
            access_token=TEST_ACCESS_TOKEN,
        )
        mock_client.set_displayname.return_value = AsyncMock()

        with (
            patch(
                "mindroom.matrix.users.provisioning.register_user_via_provisioning_service",
                new_callable=AsyncMock,
            ) as mock_register,
            patch("mindroom.matrix.users.matrix_client") as mock_matrix_client,
            patch(
                "mindroom.matrix.users.provisioning.registration_token_from_env",
                return_value=None,
            ),
            patch(
                "mindroom.matrix.users.extract_server_name_from_homeserver",
                return_value="mindroom.chat",
            ),
        ):
            mock_register.return_value = MagicMock(
                status="user_in_use",
                user_id="@test_user:internal-matrix",
            )
            mock_matrix_client.return_value.__aenter__.return_value = mock_client

            user_id = await _register_user("https://internal-matrix:8448", "test_user", test_pass, "Test User")

            assert user_id == "@test_user:mindroom.chat"
            mock_matrix_client.assert_called_once_with(
                "https://internal-matrix:8448",
                user_id="@test_user:mindroom.chat",
            )

    @pytest.mark.asyncio
    async def test_register_user_missing_provisioning_client_credentials_is_explicit(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Provisioning URL without local client creds should fail with actionable guidance."""
        monkeypatch.setenv("MINDROOM_PROVISIONING_URL", "https://provisioning.example")
        monkeypatch.delenv("MINDROOM_LOCAL_CLIENT_ID", raising=False)
        monkeypatch.delenv("MINDROOM_LOCAL_CLIENT_SECRET", raising=False)

        with pytest.raises(PermanentMatrixStartupError, match="mindroom connect --pair-code"):
            await _register_user("http://localhost:8008", "test_user", "test_pass", "Test User")

    @pytest.mark.asyncio
    async def test_register_user_via_provisioning_service_invalid_credentials_is_permanent(self) -> None:
        """Credential revocation from the provisioning service should stop startup retries."""
        client_secret = "secret-123"  # noqa: S105
        password = "test_pass"  # noqa: S105

        class _FakeResponse:
            is_success = False
            status_code = 403
            text = "forbidden"

        class _FakeAsyncClient:
            def __init__(self, *_: object, **__: object) -> None:
                pass

            async def __aenter__(self) -> Self:
                return self

            async def __aexit__(self, *_: object) -> None:
                return None

            async def post(self, *_: object, **__: object) -> _FakeResponse:
                return _FakeResponse()

        with (
            patch.object(provisioning.httpx, "AsyncClient", _FakeAsyncClient),
            pytest.raises(PermanentMatrixStartupError, match="invalid or revoked"),
        ):
            await provisioning.register_user_via_provisioning_service(
                provisioning_url="https://provisioning.example",
                client_id="client-123",
                client_secret=client_secret,
                homeserver="http://localhost:8008",
                username="test_user",
                password=password,
                display_name="Test User",
            )

    @pytest.mark.asyncio
    async def test_register_user_via_provisioning_service_invalid_json_is_permanent(self) -> None:
        """Invalid provisioning responses should not trigger endless retries."""
        client_secret = "secret-123"  # noqa: S105
        password = "test_pass"  # noqa: S105

        class _FakeResponse:
            is_success = True

            def json(self) -> object:
                msg = "bad json"
                raise ValueError(msg)

        class _FakeAsyncClient:
            def __init__(self, *_: object, **__: object) -> None:
                pass

            async def __aenter__(self) -> Self:
                return self

            async def __aexit__(self, *_: object) -> None:
                return None

            async def post(self, *_: object, **__: object) -> _FakeResponse:
                return _FakeResponse()

        with (
            patch.object(provisioning.httpx, "AsyncClient", _FakeAsyncClient),
            pytest.raises(PermanentMatrixStartupError, match="invalid JSON"),
        ):
            await provisioning.register_user_via_provisioning_service(
                provisioning_url="https://provisioning.example",
                client_id="client-123",
                client_secret=client_secret,
                homeserver="http://localhost:8008",
                username="test_user",
                password=password,
                display_name="Test User",
            )

    @pytest.mark.asyncio
    async def test_register_user_missing_token_error_is_explicit(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Unknown register errors should become actionable when token flow is required."""
        monkeypatch.delenv("MATRIX_REGISTRATION_TOKEN", raising=False)

        mock_client = AsyncMock()
        mock_client.register.return_value = nio.ErrorResponse("unknown error")

        with (
            patch("mindroom.matrix.users.matrix_client") as mock_matrix_client,
            patch("mindroom.matrix.users._homeserver_requires_registration_token", new_callable=AsyncMock) as mock_req,
        ):
            mock_matrix_client.return_value.__aenter__.return_value = mock_client
            mock_req.return_value = True

            with pytest.raises(PermanentMatrixStartupError, match="Set MATRIX_REGISTRATION_TOKEN"):
                await _register_user("http://localhost:8008", "test_user", "test_pass", "Test User")


class TestAgentUserCreation:
    """Test agent user creation functions."""

    @pytest.mark.asyncio
    @patch("mindroom.matrix.users._register_user")
    @patch("mindroom.matrix.users._save_agent_credentials")
    @patch("mindroom.matrix.users._get_agent_credentials")
    async def test_create_agent_user_new(
        self,
        mock_get_creds: MagicMock,
        mock_save_creds: MagicMock,
        mock_register: AsyncMock,
    ) -> None:
        """Test creating a new agent user."""
        mock_get_creds.return_value = None  # No existing credentials
        mock_register.return_value = "@mindroom_calculator:localhost"

        agent_user = await create_agent_user("http://localhost:8008", "calculator", "CalculatorAgent")

        assert agent_user.agent_name == "calculator"
        assert agent_user.user_id == "@mindroom_calculator:localhost"
        assert agent_user.display_name == "CalculatorAgent"
        assert agent_user.password

        mock_save_creds.assert_called_once()
        mock_register.assert_called_once()

    @pytest.mark.asyncio
    @patch("mindroom.matrix.users.matrix_client")
    @patch("mindroom.matrix.users._save_agent_credentials")
    @patch("mindroom.matrix.users._get_agent_credentials")
    async def test_create_agent_user_existing_credentials_log_in_directly(
        self,
        mock_get_creds: MagicMock,
        mock_save_creds: MagicMock,
        mock_matrix_client: MagicMock,
    ) -> None:
        """Existing credentials should be reused via login without re-registration."""
        mock_get_creds.return_value = {
            "username": "mindroom_calculator",
            "password": "existing_pass",
        }
        mock_client = AsyncMock()
        mock_client.login.return_value = MagicMock(spec=nio.LoginResponse)
        mock_matrix_client.return_value.__aenter__.return_value = mock_client

        agent_user = await create_agent_user("http://localhost:8008", "calculator", "CalculatorAgent")

        assert agent_user.password == "existing_pass"  # noqa: S105
        mock_save_creds.assert_not_called()  # Should not save again
        mock_matrix_client.assert_called_once_with(
            "http://localhost:8008",
            user_id="@mindroom_calculator:localhost",
        )
        mock_client.login.assert_called_once_with("existing_pass")
        mock_client.set_displayname.assert_called_once_with("CalculatorAgent")

    @pytest.mark.asyncio
    @patch("mindroom.matrix.users._register_user")
    @patch("mindroom.matrix.users.matrix_client")
    @patch("mindroom.matrix.users._save_agent_credentials")
    @patch("mindroom.matrix.users._get_agent_credentials")
    async def test_create_agent_user_existing_credentials_retry_registration_on_login_failure(
        self,
        mock_get_creds: MagicMock,
        mock_save_creds: MagicMock,
        mock_matrix_client: MagicMock,
        mock_register: AsyncMock,
    ) -> None:
        """Existing credentials should retry registration when login no longer works."""
        mock_get_creds.return_value = {
            "username": "mindroom_calculator",
            "password": "stale_pass",
        }
        mock_client = AsyncMock()
        mock_client.login.return_value = MagicMock(spec=nio.LoginError)
        mock_matrix_client.return_value.__aenter__.return_value = mock_client
        mock_register.return_value = "@mindroom_calculator:localhost"

        agent_user = await create_agent_user("http://localhost:8008", "calculator", "CalculatorAgent")

        assert agent_user.password == "stale_pass"  # noqa: S105
        mock_save_creds.assert_not_called()
        mock_client.login.assert_called_once_with("stale_pass")
        mock_register.assert_called_once_with(
            homeserver="http://localhost:8008",
            username="mindroom_calculator",
            password="stale_pass",  # noqa: S106
            display_name="CalculatorAgent",
        )

    @pytest.mark.asyncio
    @patch("mindroom.matrix.users._register_user")
    @patch("mindroom.matrix.users._get_agent_credentials")
    async def test_create_internal_user_rejects_username_change(
        self,
        mock_get_creds: MagicMock,
        mock_register: AsyncMock,
    ) -> None:
        """Internal username cannot change once credentials already exist."""
        mock_get_creds.return_value = {
            "username": DEFAULT_INTERNAL_USERNAME,
            "password": "existing_pass",
        }

        with pytest.raises(PermanentMatrixStartupError, match="cannot be changed"):
            await create_agent_user(
                "http://localhost:8008",
                INTERNAL_USER_AGENT_NAME,
                "MindRoomUser",
                username="alice_internal",
            )

        mock_register.assert_not_called()


class TestAgentLogin:
    """Test agent login functionality."""

    @pytest.mark.asyncio
    async def test_login_agent_user_success(self) -> None:
        """Test successful agent login."""
        agent_user = AgentMatrixUser(
            agent_name="calculator",
            user_id="@mindroom_calculator:localhost",
            display_name="CalculatorAgent",
            password=TEST_PASSWORD,
        )

        with patch("mindroom.matrix.users.login") as mock_login:
            mock_client = AsyncMock()
            mock_client.access_token = "new_token"  # noqa: S105
            mock_login.return_value = mock_client

            client = await login_agent_user("http://localhost:8008", agent_user)

            assert client == mock_client
            assert agent_user.access_token == "new_token"  # noqa: S105
            mock_login.assert_called_once_with("http://localhost:8008", agent_user.user_id, agent_user.password)

    @pytest.mark.asyncio
    async def test_login_agent_user_failure(self) -> None:
        """Test failed agent login."""
        agent_user = AgentMatrixUser(
            agent_name="calculator",
            user_id="@mindroom_calculator:localhost",
            display_name="CalculatorAgent",
            password=TEST_PASSWORD,
        )

        with patch("mindroom.matrix.users.login") as mock_login:
            # Mock failed login
            mock_login.side_effect = ValueError("Failed to login @mindroom_calculator:localhost: Login error")

            with pytest.raises(ValueError, match="Failed to login"):
                await login_agent_user("http://localhost:8008", agent_user)


class TestEnsureAllAgentUsers:
    """Test ensuring all agents have user accounts."""

    @pytest.mark.asyncio
    @patch("mindroom.matrix.users.create_agent_user")
    async def test_ensure_all_agent_users(
        self,
        mock_create_user: AsyncMock,
    ) -> None:
        """Test ensuring all configured agents have users."""
        # Load real configuration
        config = Config.from_yaml()

        # Mock user creation - router is created first, then configured agents
        mock_users = []
        # Router user
        mock_users.append(AgentMatrixUser("router", "@mindroom_router:localhost", "RouterAgent", "router_pass"))
        # Create mock users for all configured agents
        for agent_name in config.agents:
            user_id = f"@mindroom_{agent_name}:localhost"
            display_name = config.agents[agent_name].display_name or f"{agent_name.title()}Agent"
            mock_users.append(AgentMatrixUser(agent_name, user_id, display_name, f"pass_{agent_name}"))
        # Create mock users for all configured teams
        for team_name in config.teams:
            user_id = f"@mindroom_{team_name}:localhost"
            display_name = config.teams[team_name].display_name or f"{team_name.title()}Team"
            mock_users.append(AgentMatrixUser(team_name, user_id, display_name, f"pass_{team_name}"))

        mock_create_user.side_effect = mock_users

        agent_users = await _ensure_all_agent_users("http://localhost:8008", config)

        # Should have router + all configured agents + all configured teams
        expected_count = 1 + len(config.agents) + len(config.teams)
        assert len(agent_users) == expected_count
        assert "router" in agent_users
        assert mock_create_user.call_count == expected_count

    @pytest.mark.asyncio
    @patch("mindroom.matrix.users.create_agent_user")
    async def test_ensure_all_agent_users_with_error(
        self,
        mock_create_user: AsyncMock,
    ) -> None:
        """Test handling errors when creating agent users."""
        # Load real configuration
        config = Config.from_yaml()

        # Mock user creation with a failure - create list of results with one error
        mock_results = []
        mock_results.append(AgentMatrixUser("router", "@mindroom_router:localhost", "RouterAgent", "router_pass"))

        # Add successful agents first
        for i, agent_name in enumerate(config.agents):
            if i == 0:  # First agent succeeds
                user_id = f"@mindroom_{agent_name}:localhost"
                display_name = config.agents[agent_name].display_name or f"{agent_name.title()}Agent"
                mock_results.append(AgentMatrixUser(agent_name, user_id, display_name, f"pass_{agent_name}"))
            elif i == 1:  # Second agent raises an exception
                # Create a mock that will raise an exception when awaited
                mock_error = AsyncMock(side_effect=Exception("Failed to create user"))
                mock_create_user.side_effect = [*mock_results, mock_error]
                break

        agent_users = await _ensure_all_agent_users("http://localhost:8008", config)

        # Should still return the successful ones (router + first agent)
        assert len(agent_users) >= 2  # At least router + one agent
        assert "router" in agent_users
