"""Matrix room management functions."""

from __future__ import annotations

import asyncio
import time
from typing import TYPE_CHECKING, Any

import nio

from mindroom.constants import resolve_avatar_path
from mindroom.entity_resolution import managed_entity_power_user_ids_for_room
from mindroom.logging_config import get_logger
from mindroom.matrix import state as matrix_state
from mindroom.matrix.avatar import check_and_set_avatar
from mindroom.matrix.client_room_admin import (
    RoomJoinOutcome,
    add_room_to_space,
    create_room,
    create_space,
    ensure_managed_room_power_levels,
    ensure_room_admin_power_levels,
    ensure_room_directory_visibility,
    ensure_room_encryption_enabled,
    ensure_room_join_rule,
    ensure_room_name,
    get_joined_rooms,
    join_room,
    leave_room,
)
from mindroom.matrix.state import MatrixState
from mindroom.matrix.users import INTERNAL_USER_ACCOUNT_KEY, INTERNAL_USER_AGENT_NAME, AgentMatrixUser, login_agent_user
from mindroom.matrix_identifiers import (
    extract_server_name_from_homeserver,
    managed_room_alias_localpart,
    managed_space_alias_localpart,
    split_concrete_matrix_user_ids,
)
from mindroom.topic_generator import ensure_room_has_topic, generate_room_topic_ai

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable, Sequence

    from mindroom.config.main import Config
    from mindroom.constants import RuntimePaths

logger = get_logger(__name__)
_ROOT_SPACE_TOPIC = "Your MindRoom AI workspace"
_ROOT_SPACE_AVATAR_KEY = "root_space"


async def _set_room_avatar_if_available(
    client: nio.AsyncClient,
    room_id: str,
    *,
    avatar_category: str,
    avatar_name: str,
    context: str,
    runtime_paths: RuntimePaths,
) -> None:
    """Set a room avatar when a managed asset exists.

    Avatar reconciliation is cosmetic, so failures are logged but do not abort
    room or Space creation.
    """
    avatar_path = resolve_avatar_path(avatar_category, avatar_name, runtime_paths)
    if not avatar_path.exists():
        return

    if await check_and_set_avatar(client, avatar_path, room_id=room_id):
        logger.info(
            "Set avatar for managed Matrix room",
            room_id=room_id,
            avatar_path=str(avatar_path),
            context=context,
        )
        return

    logger.warning(
        "Failed to set avatar for managed Matrix room",
        room_id=room_id,
        avatar_path=str(avatar_path),
        context=context,
    )


async def _configure_managed_room_access(
    client: nio.AsyncClient,
    room_key: str,
    room_id: str,
    config: Config,
    runtime_paths: RuntimePaths,
    *,
    room_alias: str | None = None,
    context: str,
) -> bool:
    """Apply configured room joinability/discoverability policy for one managed room."""
    access_config = config.matrix_room_access
    target_join_rule = access_config.get_target_join_rule(
        room_key,
        runtime_paths,
        room_id=room_id,
        room_alias=room_alias,
    )
    target_directory_visibility = access_config.get_target_directory_visibility(
        room_key,
        runtime_paths,
        room_id=room_id,
        room_alias=room_alias,
    )

    if target_join_rule is None or target_directory_visibility is None:
        logger.info(
            "Skipping managed room access policy",
            room_key=room_key,
            room_id=room_id,
            mode=access_config.mode,
            reason="single_user_private mode keeps invite-only/private behavior",
            context=context,
        )
        return True

    logger.info(
        "Applying managed room access policy",
        room_key=room_key,
        room_id=room_id,
        join_rule=target_join_rule,
        directory_visibility=target_directory_visibility,
        publish_to_room_directory=access_config.publish_to_room_directory,
        context=context,
    )

    join_rule_ok = await ensure_room_join_rule(client, room_id, target_join_rule)
    visibility_ok = await ensure_room_directory_visibility(client, room_id, target_directory_visibility)
    if join_rule_ok and visibility_ok:
        return True

    failed = []
    if not join_rule_ok:
        failed.append(f"join_rule → {target_join_rule}")
    if not visibility_ok:
        failed.append(f"directory_visibility → {target_directory_visibility}")

    logger.error(
        "Managed room access policy failed to fully apply",
        room_key=room_key,
        room_id=room_id,
        failed_components=failed,
        join_rule_success=join_rule_ok,
        directory_visibility_success=visibility_ok,
        context=context,
        hint=(
            "Check earlier log warnings for the specific Matrix API error. "
            "Common causes: insufficient power level in the room, or server-level "
            "room_list_publication_rules restricting directory visibility changes."
        ),
    )
    return False


def _managed_room_should_be_encrypted(room_key: str, config: Config) -> bool:
    """Return whether one managed room is configured for Matrix encryption."""
    room_config = config.rooms.get(room_key)
    if room_config is not None and room_config.encrypted is not None:
        return room_config.encrypted
    return config.matrix_room_access.encrypt_managed_rooms


def _room_admin_user_ids(config: Config) -> list[str]:
    """Return configured managed-room admins, skipping wildcard or placeholder entries."""
    concrete_ids, skipped = split_concrete_matrix_user_ids(config.matrix_room_access.room_admins)
    if skipped:
        logger.warning("Skipping non-concrete room admin user IDs", user_ids=skipped)
    return concrete_ids


def _room_key_to_name(room_key: str) -> str:
    """Convert a room key to a human-readable room name.

    Args:
        room_key: The room key (e.g., 'dev', 'analysis_room')

    Returns:
        Human-readable room name (e.g., 'Dev', 'Analysis Room')

    """
    return room_key.replace("_", " ").title()


def _add_room(
    room_key: str,
    room_id: str,
    alias: str,
    name: str,
    runtime_paths: RuntimePaths,
) -> None:
    """Add a new room to the state."""
    state = MatrixState.load(runtime_paths=runtime_paths)
    state.add_room(room_key, room_id, alias, name)
    state.save(runtime_paths=runtime_paths)


def _remove_room(room_key: str, runtime_paths: RuntimePaths) -> bool:
    """Remove a room from the state."""
    state = MatrixState.load(runtime_paths=runtime_paths)
    if room_key in state.rooms:
        del state.rooms[room_key]
        state.save(runtime_paths=runtime_paths)
        return True
    return False


async def _reconcile_joined_existing_room(
    client: nio.AsyncClient,
    room_key: str,
    room_id: str,
    config: Config,
    runtime_paths: RuntimePaths,
    *,
    explicit_room_name: str | None,
    room_alias: str,
    admin_user_ids: Sequence[str] = (),
) -> None:
    """Reconcile name, topic, power levels, encryption, and access policy for one joined managed room."""
    topic_room_name = explicit_room_name or _room_key_to_name(room_key)
    if explicit_room_name is not None:
        await ensure_room_name(client, room_id, explicit_room_name)
    await ensure_room_has_topic(client, room_id, room_key, topic_room_name, config, runtime_paths)
    await ensure_managed_room_power_levels(client, room_id, admin_user_ids)
    if _managed_room_should_be_encrypted(room_key, config):
        await ensure_room_encryption_enabled(client, room_id)
    if config.matrix_room_access.is_multi_user_mode() and config.matrix_room_access.reconcile_existing_rooms:
        await _configure_managed_room_access(
            client=client,
            room_key=room_key,
            room_id=room_id,
            config=config,
            runtime_paths=runtime_paths,
            room_alias=room_alias,
            context="existing_room_reconciliation",
        )
    elif config.matrix_room_access.is_multi_user_mode():
        logger.info(
            "Skipping existing room access reconciliation",
            room_key=room_key,
            room_id=room_id,
            reason="matrix_room_access.reconcile_existing_rooms is false",
        )


async def _ensure_room_exists(
    client: nio.AsyncClient,
    room_key: str,
    config: Config,
    runtime_paths: RuntimePaths,
    room_name: str | None = None,
    power_users: list[str] | None = None,
    admin_user_ids: Sequence[str] = (),
) -> str | None:
    """Ensure a room exists, creating it if necessary.

    Args:
        client: Matrix client to use for room creation
        room_key: The room key/alias (without domain)
        config: Configuration with agent settings for topic generation
        runtime_paths: Explicit runtime context for room aliases, topics, and avatars
        room_name: Display name for the room (defaults to room_key with underscores replaced)
        power_users: List of user IDs to grant power levels to
        admin_user_ids: Concrete Matrix user IDs granted room admin power (100)

    Returns:
        Room ID if room exists or was created, None on failure

    """
    existing_rooms = matrix_state.load_rooms(runtime_paths=runtime_paths)

    # First, try to resolve the room alias on the server
    # This handles cases where the room exists on server but not in our state
    server_name = extract_server_name_from_homeserver(client.homeserver, runtime_paths=runtime_paths)
    alias_localpart = managed_room_alias_localpart(room_key, runtime_paths=runtime_paths)
    full_alias = f"#{alias_localpart}:{server_name}"

    response = await client.room_resolve_alias(full_alias)
    if isinstance(response, nio.RoomResolveAliasResponse):
        room_id = str(response.room_id)
        explicit_room_name = room_name
        logger.debug("managed_room_alias_resolved", room_key=room_key, room_alias=full_alias, room_id=room_id)

        # Update our state if needed
        if room_key not in existing_rooms or existing_rooms[room_key].room_id != room_id:
            if room_name is None:
                room_name = _room_key_to_name(room_key)
            _add_room(room_key, room_id, full_alias, room_name, runtime_paths)
            logger.info("managed_room_state_updated", room_key=room_key, room_id=room_id, room_alias=full_alias)

        # Room existence and room membership are separate concerns. Existing
        # private rooms may be managed outside MindRoom, so don't force a join
        # attempt here just to record that the alias resolves.
        joined_room = room_id in client.rooms
        if not joined_room:
            joined_room_ids = await get_joined_rooms(client)
            joined_room = joined_room_ids is not None and room_id in joined_room_ids

        if joined_room:
            await _reconcile_joined_existing_room(
                client,
                room_key,
                room_id,
                config,
                runtime_paths,
                explicit_room_name=explicit_room_name,
                room_alias=full_alias,
                admin_user_ids=admin_user_ids,
            )
        else:
            logger.warning(
                "Managed room exists but service account is not joined; skipping existing-room reconciliation",
                room_key=room_key,
                room_id=room_id,
                room_alias=full_alias,
                hint=(
                    "If this room should be router-managed, invite the router or make the room joinable. "
                    "If it is externally managed, this warning is expected."
                ),
            )
        return room_id

    # Room alias doesn't exist on server, so we can create it
    if room_key in existing_rooms:
        # Remove stale entry from state
        logger.debug("managed_room_state_entry_removed", room_key=room_key)
        _remove_room(room_key, runtime_paths=runtime_paths)

    # Create the room
    if room_name is None:
        room_name = _room_key_to_name(room_key)

    # Generate a contextual topic for the room using AI
    topic = await generate_room_topic_ai(room_key, room_name, config, runtime_paths)
    logger.info("managed_room_creation_started", room_key=room_key, topic=topic)

    created_room_id = await create_room(
        client=client,
        name=room_name,
        alias=alias_localpart,
        topic=topic,
        power_users=power_users or [],
        admin_users=list(admin_user_ids),
        encrypted=_managed_room_should_be_encrypted(room_key, config),
    )

    if created_room_id:
        # Save room info
        _add_room(room_key, created_room_id, full_alias, room_name, runtime_paths)
        logger.info("managed_room_created", room_key=room_key, room_id=created_room_id, room_alias=full_alias)

        if config.matrix_room_access.is_multi_user_mode():
            await _configure_managed_room_access(
                client=client,
                room_key=room_key,
                room_id=created_room_id,
                config=config,
                runtime_paths=runtime_paths,
                room_alias=full_alias,
                context="new_room_creation",
            )
        else:
            logger.info(
                "Created room with single-user/private defaults",
                room_key=room_key,
                room_id=created_room_id,
                mode=config.matrix_room_access.mode,
                join_rule="invite",
                directory_visibility="private",
            )

        await _set_room_avatar_if_available(
            client,
            created_room_id,
            avatar_category="rooms",
            avatar_name=room_key,
            context=f"managed_room:{room_key}",
            runtime_paths=runtime_paths,
        )

        return created_room_id
    logger.error("managed_room_creation_failed", room_key=room_key, room_alias=full_alias)
    return None


async def ensure_all_rooms_exist(
    client: nio.AsyncClient,
    config: Config,
    runtime_paths: RuntimePaths,
) -> dict[str, str]:
    """Ensure all configured rooms exist and invite user account.

    Args:
        client: Matrix client to use for room creation
        config: Configuration with room settings
        runtime_paths: Explicit runtime context for room resolution and state updates

    Returns:
        Dict mapping room keys to room IDs

    """
    room_ids = {}

    # Get all configured rooms
    all_rooms = config.get_all_configured_rooms()

    # Configured room admins are room-independent; filter and warn once per pass.
    admin_user_ids = _room_admin_user_ids(config)

    for room_key in all_rooms:
        # Skip if this is a room ID (starts with !)
        if room_key.startswith("!"):
            # This is a room ID, not a room key/alias - skip it
            continue

        # Get power users for this room
        power_users = managed_entity_power_user_ids_for_room(room_key, config, runtime_paths)

        # Ensure room exists
        room_config = config.rooms.get(room_key)
        room_name = None
        if room_config is not None:
            room_name = room_config.display_name or _room_key_to_name(room_key)
        try:
            room_id = await _ensure_room_exists(
                client=client,
                room_key=room_key,
                config=config,
                runtime_paths=runtime_paths,
                room_name=room_name,
                power_users=power_users,
                admin_user_ids=admin_user_ids,
            )
        except RuntimeError:
            logger.exception(
                "Failed to ensure managed room; continuing with remaining rooms",
                room_key=room_key,
            )
            continue

        if room_id:
            room_ids[room_key] = room_id

    return room_ids


async def _ensure_root_space_exists(
    client: nio.AsyncClient,
    config: Config,
    runtime_paths: RuntimePaths,
) -> str | None:
    """Ensure the configured root Matrix Space exists and return its room ID."""
    if not config.matrix_space.enabled:
        return None

    state = MatrixState.load(runtime_paths=runtime_paths)
    joined_room_ids = await get_joined_rooms(client) or []
    if state.space_room_id and state.space_room_id in joined_room_ids:
        return state.space_room_id

    server_name = extract_server_name_from_homeserver(client.homeserver, runtime_paths=runtime_paths)
    alias_localpart = managed_space_alias_localpart(runtime_paths=runtime_paths)
    full_alias = f"#{alias_localpart}:{server_name}"
    response = await client.room_resolve_alias(full_alias)
    if isinstance(response, nio.RoomResolveAliasResponse):
        space_room_id = str(response.room_id)
        joined_space = space_room_id in client.rooms or space_room_id in joined_room_ids
        if not joined_space and await join_room(client, space_room_id) is not RoomJoinOutcome.JOINED:
            logger.warning(
                "Resolved existing root space but router could not join it; skipping reconciliation",
                space_room_id=space_room_id,
                space_alias=full_alias,
            )
            return None
        if state.space_room_id != space_room_id:
            state.set_space_room_id(space_room_id)
            state.save(runtime_paths=runtime_paths)
        return space_room_id

    space_room_id = await create_space(
        client=client,
        name=config.matrix_space.name,
        alias=alias_localpart,
        topic=_ROOT_SPACE_TOPIC,
    )
    if space_room_id is None:
        return None

    state.set_space_room_id(space_room_id)
    state.save(runtime_paths=runtime_paths)
    return space_room_id


async def ensure_root_space(
    client: nio.AsyncClient,
    config: Config,
    runtime_paths: RuntimePaths,
    room_ids: dict[str, str],
    *,
    admin_user_ids: set[str] | None = None,
) -> str | None:
    """Ensure the optional root Matrix Space exists and links the supplied managed rooms."""
    if not config.matrix_space.enabled:
        return None

    root_space_id = await _ensure_root_space_exists(client, config, runtime_paths)
    if root_space_id is None:
        return None

    if not await ensure_room_name(client, root_space_id, config.matrix_space.name):
        logger.warning("Failed to set root space name; skipping child linking", space_id=root_space_id)
        return None

    if admin_user_ids and not await ensure_room_admin_power_levels(client, root_space_id, admin_user_ids):
        logger.warning("Failed to grant root space admin power; skipping child linking", space_id=root_space_id)
        return None

    server_name = extract_server_name_from_homeserver(client.homeserver, runtime_paths=runtime_paths)
    for room_id in dict.fromkeys(room_ids.values()):
        if not await add_room_to_space(client, root_space_id, room_id, server_name):
            logger.warning(
                "Failed to link room to root space; aborting reconciliation",
                space_id=root_space_id,
                room_id=room_id,
            )
            return None

    await _set_room_avatar_if_available(
        client,
        root_space_id,
        avatar_category="spaces",
        avatar_name=_ROOT_SPACE_AVATAR_KEY,
        context="root_space",
        runtime_paths=runtime_paths,
    )

    return root_space_id


async def ensure_user_in_rooms(
    homeserver: str,
    room_ids: dict[str, str],
    runtime_paths: RuntimePaths,
) -> None:
    """Ensure the user account is a member of all specified rooms.

    Args:
        homeserver: Matrix homeserver URL
        room_ids: Dict mapping room keys to room IDs
        runtime_paths: Explicit runtime context for server-name resolution.

    """
    state = matrix_state.matrix_state_for_runtime(runtime_paths)
    user_account = state.get_account(INTERNAL_USER_ACCOUNT_KEY)
    if not user_account:
        logger.warning("No user account found, skipping user room membership")
        return

    server_name = user_account.domain or extract_server_name_from_homeserver(homeserver, runtime_paths=runtime_paths)
    user_id = f"@{user_account.username}:{server_name}"
    user_client = await login_agent_user(
        homeserver,
        AgentMatrixUser(
            agent_name=INTERNAL_USER_AGENT_NAME,
            user_id=user_id,
            display_name=INTERNAL_USER_AGENT_NAME,
            password=user_account.password,
            device_id=user_account.device_id,
            access_token=user_account.access_token,
        ),
        runtime_paths,
    )
    try:
        logger.info("matrix_user_logged_in", user_id=user_client.user_id)

        for room_key, room_id in room_ids.items():
            join_outcome = await join_room(user_client, room_id)
            if join_outcome is RoomJoinOutcome.JOINED:
                logger.info("matrix_user_joined_room", user_id=user_client.user_id, room_id=room_id, room_key=room_key)
            else:
                logger.warning(
                    "matrix_user_room_join_failed",
                    user_id=user_client.user_id,
                    room_id=room_id,
                    room_key=room_key,
                )
    finally:
        await user_client.close()


_DM_ROOM_CACHE: dict[tuple[str, str], tuple[float, bool]] = {}
_DIRECT_ROOMS_CACHE: dict[str, tuple[float, set[str]]] = {}
_DM_ROOM_TTL: float = 300  # seconds
_DIRECT_ROOMS_TTL: float = 300  # seconds


def _dm_cache_key(client: nio.AsyncClient, room_id: str) -> tuple[str, str]:
    """Build a cache key that is scoped per user.

    DM membership via ``m.direct`` is account-specific, so room-only cache keys
    can leak incorrect results between different bot users.
    """
    return (str(client.user_id or ""), room_id)


async def _get_direct_room_ids(client: nio.AsyncClient) -> set[str]:
    """Get DM room IDs from the user's ``m.direct`` account data.

    Results are cached per user for ``_DIRECT_ROOMS_TTL`` seconds so that
    newly created DM rooms are picked up without a restart.
    """
    user_id = str(client.user_id or "")
    if not user_id:
        return set()

    cached = _DIRECT_ROOMS_CACHE.get(user_id)
    if cached is not None:
        ts, room_ids = cached
        if time.monotonic() - ts < _DIRECT_ROOMS_TTL:
            return room_ids

    response = await client.list_direct_rooms()
    if isinstance(response, nio.DirectRoomsResponse):
        direct_room_ids = {room_id for room_ids in response.rooms.values() for room_id in room_ids}
        _DIRECT_ROOMS_CACHE[user_id] = (time.monotonic(), direct_room_ids)
        return direct_room_ids
    if isinstance(response, nio.DirectRoomsErrorResponse) and response.status_code == "M_NOT_FOUND":
        # No m.direct account data is a stable empty state for this user.
        _DIRECT_ROOMS_CACHE[user_id] = (time.monotonic(), set())

    return set()


def _is_two_member_group_room(client: nio.AsyncClient, room_id: str) -> bool:
    """Check if nio models this room as an unnamed two-member group.

    Rooms with an explicit topic are excluded because DMs almost never have one,
    while small project rooms often do.
    """
    room_lookup = client.rooms
    if not isinstance(room_lookup, dict):
        return False

    room = room_lookup.get(room_id)
    if room is None or not room.is_group or room.member_count != 2:
        return False
    return not room.topic


def _has_is_direct_marker(state_events: list[dict[str, Any]]) -> bool:
    """Check ``m.room.member`` state events for the ``is_direct`` flag."""
    for event in state_events:
        if event.get("type") != "m.room.member":
            continue

        content = event.get("content")
        if isinstance(content, dict) and content.get("is_direct") is True:
            return True

    return False


async def is_dm_room(client: nio.AsyncClient, room_id: str) -> bool:
    """Check if a room is a Direct Message (DM) room.

    Detection uses multiple signals in this order:
    1. ``m.direct`` account data (via ``/account_data/m.direct``)
    2. Nio's in-memory room model for 2-member ad-hoc rooms
    3. Room state events with ``is_direct=true``

    Args:
        client: The Matrix client
        room_id: The room ID to check

    Returns:
        True if the room is a DM room, False otherwise

    """
    cache_key = _dm_cache_key(client, room_id)
    cached = _DM_ROOM_CACHE.get(cache_key)
    if cached is not None:
        ts, is_dm = cached
        if time.monotonic() - ts < _DM_ROOM_TTL:
            return is_dm

    direct_room_ids = await _get_direct_room_ids(client)
    if room_id in direct_room_ids:
        _DM_ROOM_CACHE[cache_key] = (time.monotonic(), True)
        return True

    # Preserve DM-like rooms even when servers don't expose `is_direct` in state.
    if _is_two_member_group_room(client, room_id):
        _DM_ROOM_CACHE[cache_key] = (time.monotonic(), True)
        return True

    # Get the room state events, specifically member events.
    response = await client.room_get_state(room_id)
    if not isinstance(response, nio.RoomGetStateResponse):
        return False

    is_dm = _has_is_direct_marker(response.events)
    _DM_ROOM_CACHE[cache_key] = (time.monotonic(), is_dm)
    return is_dm


async def filter_non_dm_rooms(client: nio.AsyncClient, room_ids: list[str]) -> list[str]:
    """Return rooms from *room_ids* that are not DM rooms."""
    return [room_id for room_id in room_ids if not await is_dm_room(client, room_id)]


async def _leave_room_and_cleanup(
    client: nio.AsyncClient,
    room_id: str,
    *,
    on_room_left: Callable[[str], Awaitable[None]],
) -> None:
    """Finish one leave outcome and its confirmed cleanup as one operation."""
    success = await leave_room(client, room_id)
    if success:
        logger.info("room_left", room_id=room_id)
        await on_room_left(room_id)
    else:
        logger.error("room_leave_failed", room_id=room_id)


async def _await_leave_operation(task: asyncio.Task[None]) -> asyncio.CancelledError | None:
    """Await a leave operation to completion without letting caller cancellation abort cleanup."""
    cancellation: asyncio.CancelledError | None = None
    while True:
        try:
            await asyncio.shield(task)
        except asyncio.CancelledError as exc:
            if task.done():
                task.result()
                return cancellation or exc
            cancellation = cancellation or exc
        else:
            return cancellation


async def leave_non_dm_rooms(
    client: nio.AsyncClient,
    room_ids: list[str],
    *,
    on_room_left: Callable[[str], Awaitable[None]],
) -> None:
    """Leave non-DM rooms and clean each confirmed departure before continuing."""
    for room_id in room_ids:
        if await is_dm_room(client, room_id):
            logger.debug("dm_room_preserved", room_id=room_id)
            continue
        operation = asyncio.create_task(
            _leave_room_and_cleanup(
                client,
                room_id,
                on_room_left=on_room_left,
            ),
            name="matrix_leave_room_and_cleanup",
        )
        cancellation = await _await_leave_operation(operation)
        if cancellation is not None:
            raise cancellation
