"""Matrix room management functions."""

from __future__ import annotations

import time
from pathlib import Path
from typing import TYPE_CHECKING, Any

import nio

from mindroom.logging_config import get_logger
from mindroom.matrix.avatar import check_and_set_avatar
from mindroom.matrix.client import (
    add_room_to_space,
    create_room,
    create_space,
    ensure_room_directory_visibility,
    ensure_room_join_rule,
    ensure_room_name,
    get_joined_rooms,
    join_room,
    leave_room,
    matrix_client,
)
from mindroom.matrix.identity import (
    MatrixID,
    extract_server_name_from_homeserver,
    managed_room_alias_localpart,
    managed_space_alias_localpart,
)
from mindroom.matrix.state import MatrixRoom, MatrixState
from mindroom.matrix.users import INTERNAL_USER_ACCOUNT_KEY
from mindroom.topic_generator import ensure_room_has_topic, generate_room_topic_ai

if TYPE_CHECKING:
    from mindroom.config.main import Config

logger = get_logger(__name__)
_ROOT_SPACE_TOPIC = "Your MindRoom AI workspace"
_ROOT_SPACE_AVATAR_KEY = "root_space"
_AVATARS_DIR = Path(__file__).resolve().parents[3] / "avatars"


def _managed_avatar_path(category: str, avatar_name: str) -> Path:
    """Return the bundled avatar path for a managed room-like entity."""
    return _AVATARS_DIR / category / f"{avatar_name}.png"


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

    Avatar reconciliation is cosmetic, so failures are logged but do not abort
    room or Space creation.
    """
    avatar_path = _managed_avatar_path(avatar_category, avatar_name)
    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,
    *,
    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, room_id=room_id, room_alias=room_alias)
    target_directory_visibility = access_config.get_target_directory_visibility(
        room_key,
        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_updated = await ensure_room_join_rule(client, room_id, target_join_rule)
    directory_visibility_updated = await ensure_room_directory_visibility(client, room_id, target_directory_visibility)
    if join_rule_updated and directory_visibility_updated:
        return True

    logger.warning(
        "Managed room access policy was only partially applied",
        room_key=room_key,
        room_id=room_id,
        join_rule_success=join_rule_updated,
        directory_visibility_success=directory_visibility_updated,
        context=context,
    )
    return False


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 load_rooms() -> dict[str, MatrixRoom]:
    """Load room state from YAML file."""
    state = MatrixState.load()
    return state.rooms


def _get_room_aliases() -> dict[str, str]:
    """Get mapping of room aliases to room IDs."""
    state = MatrixState.load()
    return state.get_room_aliases()


def _get_room_id(room_key: str) -> str | None:
    """Get room ID for a given room key/alias."""
    state = MatrixState.load()
    room = state.get_room(room_key)
    return room.room_id if room else None


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


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


def resolve_room_aliases(room_list: list[str]) -> list[str]:
    """Resolve room aliases to room IDs.

    Args:
        room_list: List of room aliases or IDs

    Returns:
        List of room IDs (aliases resolved to IDs, IDs passed through)

    """
    room_aliases = _get_room_aliases()
    return [room_aliases.get(room, room) for room in room_list]


def get_room_alias_from_id(room_id: str) -> str | None:
    """Get room alias from room ID (reverse lookup).

    Args:
        room_id: Matrix room ID

    Returns:
        Room alias if found, None otherwise

    """
    room_aliases = _get_room_aliases()
    for alias, rid in room_aliases.items():
        if rid == room_id:
            return alias
    return None


async def _ensure_room_exists(  # noqa: C901, PLR0912
    client: nio.AsyncClient,
    room_key: str,
    config: Config,
    room_name: str | None = None,
    power_users: list[str] | None = None,
) -> 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
        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

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

    """
    existing_rooms = load_rooms()

    # 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)
    alias_localpart = managed_room_alias_localpart(room_key)
    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)
        logger.debug(f"Room alias {full_alias} exists on server, 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)
            logger.info(f"Updated state with existing room {room_key} (ID: {room_id})")

        # 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:
            # For existing rooms, ensure they have a topic set
            if room_name is None:
                room_name = _room_key_to_name(room_key)
            await ensure_room_has_topic(client, room_id, room_key, room_name, config)

            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,
                    room_alias=full_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",
                )
        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(f"Removing stale room {room_key} from state")
        _remove_room(room_key)

    # 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)
    logger.info(f"Creating room {room_key} with topic: {topic}")

    created_room_id = await create_room(
        client=client,
        name=room_name,
        alias=alias_localpart,
        topic=topic,
        power_users=power_users or [],
    )

    if created_room_id:
        # Save room info
        _add_room(room_key, created_room_id, full_alias, room_name)
        logger.info(f"Created room {room_key} with ID {created_room_id}")

        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,
                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}",
        )

        return created_room_id
    logger.error(f"Failed to create room {room_key}")
    return None


async def ensure_all_rooms_exist(
    client: nio.AsyncClient,
    config: Config,
) -> 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

    Returns:
        Dict mapping room keys to room IDs

    """
    from mindroom.agents import get_agent_ids_for_room  # noqa: PLC0415

    room_ids = {}

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

    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 = get_agent_ids_for_room(room_key, config)

        # Ensure room exists
        try:
            room_id = await _ensure_room_exists(
                client=client,
                room_key=room_key,
                config=config,
                power_users=power_users,
            )
        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,
) -> 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()
    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)
    alias_localpart = managed_space_alias_localpart()
    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 not await join_room(client, space_room_id):
            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()
        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()
    return space_room_id


async def ensure_root_space(
    client: nio.AsyncClient,
    config: Config,
    room_ids: dict[str, str],
) -> 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)
    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

    server_name = extract_server_name_from_homeserver(client.homeserver)
    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",
    )

    return root_space_id


async def ensure_user_in_rooms(homeserver: str, room_ids: dict[str, str]) -> 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

    """
    state = MatrixState.load()
    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 = extract_server_name_from_homeserver(homeserver)
    user_id = MatrixID.from_username(user_account.username, server_name).full_id

    # Create a client for the user to join rooms
    async with matrix_client(homeserver, user_id) as user_client:
        # Login as the user
        login_response = await user_client.login(password=user_account.password)
        if not isinstance(login_response, nio.LoginResponse):
            logger.error(f"Failed to login as user {user_id}: {login_response}")
            return

        logger.info(f"User {user_id} logged in to join rooms")

        for room_key, room_id in room_ids.items():
            # Try to join the room (will work if invited or room is public)
            join_success = await join_room(user_client, room_id)
            if join_success:
                logger.info(f"User {user_id} joined room {room_key}")
            else:
                logger.warning(f"User {user_id} failed to join room {room_key} - may need invitation")


_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 leave_non_dm_rooms(
    client: nio.AsyncClient,
    room_ids: list[str],
) -> None:
    """Leave all rooms in *room_ids* that are not DM rooms."""
    for room_id in room_ids:
        if await is_dm_room(client, room_id):
            logger.debug(f"Preserving DM room {room_id}")
            continue
        success = await leave_room(client, room_id)
        if success:
            logger.info(f"Left room {room_id}")
        else:
            logger.error(f"Failed to leave room {room_id}")
