"""Team collaboration service for CleverAgents.

Orchestrates multi-user connection handling, role-based access control,
concurrent session management, and optimistic-locking conflict resolution
for shared project access.

The ``TeamCollaborationService`` is the primary entry point for all
team collaboration operations.  It delegates to the domain models in
``cleveragents.domain.models.core.team_collab`` and coordinates
session registration, permission checks, and version-stamp management.

Based on issue #863 — feat(server): team collaboration features.
"""

from __future__ import annotations

import threading

import structlog

from cleveragents.domain.models.core.team_collab import (
    ConflictDetectionResult,
    ConflictResolutionStrategy,
    SessionRegistry,
    TeamMember,
    TeamMemberExistsError,
    TeamMemberNotFoundError,
    TeamPermission,
    TeamRole,
    UserSession,
    VersionConflictError,
    VersionStamp,
)

__all__ = [
    "TeamCollaborationService",
]

_logger = structlog.get_logger(__name__)


class TeamCollaborationService:
    """Central service for team collaboration features.

    Manages team membership, concurrent user sessions, permission
    enforcement, and optimistic-locking conflict resolution.

    All operations are designed for concurrent access.  The underlying
    ``SessionRegistry`` uses thread-safe locking; the member registry
    and version-stamp registry are each protected by a ``threading.RLock``.
    """

    def __init__(self) -> None:
        """Initialise the service with empty registries."""
        self._members: dict[str, TeamMember] = {}
        self._members_lock = threading.RLock()
        self._session_registry = SessionRegistry()
        self._version_stamps: dict[str, VersionStamp] = {}
        self._version_stamps_lock = threading.RLock()
        self._logger = _logger.bind(service="team_collab")

    # ------------------------------------------------------------------
    # Properties
    # ------------------------------------------------------------------

    @property
    def session_registry(self) -> SessionRegistry:
        """Return the underlying session registry."""
        return self._session_registry

    # ------------------------------------------------------------------
    # Team member management
    # ------------------------------------------------------------------

    def add_member(
        self,
        user_id: str,
        display_name: str,
        email: str,
        role: TeamRole,
        caller_user_id: str | None = None,
    ) -> TeamMember:
        """Add a new member to the team.

        Args:
            user_id: Unique user identifier.
            display_name: Human-readable name.
            email: Email address.
            role: Team role for the new member.
            caller_user_id: If provided, enforces ``manage_members`` permission.

        Returns:
            The newly created ``TeamMember``.

        Raises:
            ValueError: If any argument is empty.
            PermissionError: If *caller_user_id* lacks ``manage_members``.
            TeamMemberExistsError: If the user is already a member.
        """
        if not user_id or not user_id.strip():
            raise ValueError("user_id must be a non-empty string")
        if not display_name or not display_name.strip():
            raise ValueError("display_name must be a non-empty string")
        if not email or not email.strip():
            raise ValueError("email must be a non-empty string")

        if caller_user_id is not None:
            self.enforce_permission(caller_user_id, TeamPermission.MANAGE_MEMBERS)

        with self._members_lock:
            if user_id in self._members:
                raise TeamMemberExistsError(
                    f"User '{user_id}' is already a team member"
                )

            member = TeamMember(
                user_id=user_id,
                display_name=display_name,
                email=email,
                role=role,
            )
            self._members[user_id] = member

        self._logger.info(
            "member_added",
            user_id=user_id,
            role=str(role),
        )
        return member

    def remove_member(self, user_id: str, caller_user_id: str | None = None) -> bool:
        """Remove a member from the team.

        Also unregisters all active sessions for the removed user.

        Args:
            user_id: The user to remove.
            caller_user_id: If provided, enforces ``manage_members`` permission.

        Returns:
            ``True`` if the member was found and removed.

        Raises:
            ValueError: If *user_id* is empty.
            PermissionError: If *caller_user_id* lacks ``manage_members``.
            TeamMemberNotFoundError: If the user is not a member.
        """
        if not user_id or not user_id.strip():
            raise ValueError("user_id must be a non-empty string")

        if caller_user_id is not None:
            self.enforce_permission(caller_user_id, TeamPermission.MANAGE_MEMBERS)

        with self._members_lock:
            if user_id not in self._members:
                raise TeamMemberNotFoundError(f"User '{user_id}' is not a team member")
            del self._members[user_id]

        # Session cleanup outside the lock to avoid cross-lock deadlocks.
        user_sessions = self._session_registry.get_sessions_for_user(user_id)
        for session in user_sessions:
            self._session_registry.unregister(session.session_id)

        self._logger.info("member_removed", user_id=user_id)
        return True

    def get_member(self, user_id: str) -> TeamMember:
        """Retrieve a team member by user ID.

        Args:
            user_id: The user to look up.

        Returns:
            The ``TeamMember`` instance.

        Raises:
            ValueError: If *user_id* is empty.
            TeamMemberNotFoundError: If the user is not a member.
        """
        if not user_id or not user_id.strip():
            raise ValueError("user_id must be a non-empty string")

        with self._members_lock:
            member = self._members.get(user_id)
        if member is None:
            raise TeamMemberNotFoundError(f"User '{user_id}' is not a team member")
        return member

    def list_members(self) -> list[TeamMember]:
        """Return all team members.

        Returns:
            A list of all ``TeamMember`` instances.
        """
        with self._members_lock:
            return list(self._members.values())

    def update_member_role(
        self,
        user_id: str,
        new_role: TeamRole,
        caller_user_id: str | None = None,
    ) -> TeamMember:
        """Change a member's role.

        Args:
            user_id: The user whose role to change.
            new_role: The new role to assign.
            caller_user_id: If provided, enforces ``manage_members`` permission.

        Returns:
            The updated ``TeamMember``.

        Raises:
            ValueError: If *user_id* is empty.
            PermissionError: If *caller_user_id* lacks ``manage_members``.
            TeamMemberNotFoundError: If the user is not a member.
        """
        if not user_id or not user_id.strip():
            raise ValueError("user_id must be a non-empty string")

        if caller_user_id is not None:
            self.enforce_permission(caller_user_id, TeamPermission.MANAGE_MEMBERS)

        with self._members_lock:
            member = self._members.get(user_id)
            if member is None:
                raise TeamMemberNotFoundError(f"User '{user_id}' is not a team member")
            member.role = new_role

        self._logger.info(
            "member_role_updated",
            user_id=user_id,
            new_role=str(new_role),
        )
        return member

    # ------------------------------------------------------------------
    # Permission checks
    # ------------------------------------------------------------------

    def check_permission(
        self,
        user_id: str,
        permission: TeamPermission,
    ) -> bool:
        """Check whether a user has a specific permission.

        Args:
            user_id: The user to check.
            permission: The permission to verify.

        Returns:
            ``True`` if the user's role grants the permission.

        Raises:
            ValueError: If *user_id* is empty.
            TeamMemberNotFoundError: If the user is not a member.
        """
        member = self.get_member(user_id)
        return member.has_permission(permission)

    def enforce_permission(
        self,
        user_id: str,
        permission: TeamPermission,
    ) -> None:
        """Enforce that a user has a specific permission.

        Args:
            user_id: The user to check.
            permission: The required permission.

        Raises:
            ValueError: If *user_id* is empty.
            TeamMemberNotFoundError: If the user is not a member.
            PermissionError: If the user lacks the permission.
        """
        if not self.check_permission(user_id, permission):
            member = self.get_member(user_id)
            raise PermissionError(
                f"User '{user_id}' (role={member.role}) lacks '{permission}' permission"
            )

    # ------------------------------------------------------------------
    # Session management
    # ------------------------------------------------------------------

    def register_session(
        self,
        user_id: str,
        project_id: str,
        metadata: dict[str, object] | None = None,
        caller_user_id: str | None = None,
    ) -> UserSession:
        """Register a new session for a team member.

        Args:
            user_id: The user establishing the session.
            project_id: The project the session is scoped to.
            metadata: Optional session metadata.
            caller_user_id: If provided, enforces ``read`` permission.

        Returns:
            The newly created ``UserSession``.

        Raises:
            ValueError: If arguments are empty.
            PermissionError: If *caller_user_id* lacks ``read``.
            TeamMemberNotFoundError: If the user is not a member.
        """
        if not user_id or not user_id.strip():
            raise ValueError("user_id must be a non-empty string")
        if not project_id or not project_id.strip():
            raise ValueError("project_id must be a non-empty string")

        if caller_user_id is not None:
            self.enforce_permission(caller_user_id, TeamPermission.READ)

        member = self.get_member(user_id)

        session = UserSession(
            session_id=self._session_registry.generate_session_id(),
            user_id=user_id,
            display_name=member.display_name,
            project_id=project_id,
            metadata=dict(metadata) if metadata else {},
        )
        self._session_registry.register(session)
        self._logger.info(
            "session_registered",
            session_id=session.session_id,
            user_id=user_id,
            project_id=project_id,
        )
        return session

    def unregister_session(self, session_id: str) -> bool:
        """Unregister (disconnect) a session.

        Args:
            session_id: The session to remove.

        Returns:
            ``True`` if the session was found and removed.
        """
        if not session_id or not session_id.strip():
            raise ValueError("session_id must be a non-empty string")
        removed = self._session_registry.unregister(session_id)
        if removed:
            self._logger.info("session_unregistered", session_id=session_id)
        return removed

    def get_active_sessions_for_project(
        self,
        project_id: str,
    ) -> list[UserSession]:
        """Return all active sessions for a project.

        Args:
            project_id: The project to query.

        Returns:
            A list of active sessions.
        """
        if not project_id or not project_id.strip():
            raise ValueError("project_id must be a non-empty string")
        return self._session_registry.get_sessions_for_project(project_id)

    # ------------------------------------------------------------------
    # Version stamps and conflict resolution
    # ------------------------------------------------------------------

    def create_version_stamp(
        self,
        resource_id: str,
        user_id: str,
        caller_user_id: str | None = None,
    ) -> VersionStamp:
        """Create an initial version stamp for a resource.

        Args:
            resource_id: The resource to track.
            user_id: The user creating the resource.
            caller_user_id: If provided, enforces ``write`` permission.

        Returns:
            A new ``VersionStamp`` at version 1.

        Raises:
            ValueError: If arguments are empty.
            PermissionError: If *caller_user_id* lacks ``write``.
        """
        if not resource_id or not resource_id.strip():
            raise ValueError("resource_id must be a non-empty string")
        if not user_id or not user_id.strip():
            raise ValueError("user_id must be a non-empty string")

        if caller_user_id is not None:
            self.enforce_permission(caller_user_id, TeamPermission.WRITE)

        stamp = VersionStamp(
            resource_id=resource_id,
            version=1,
            updated_by=user_id,
        )
        with self._version_stamps_lock:
            self._version_stamps[resource_id] = stamp
        self._logger.info(
            "version_stamp_created",
            resource_id=resource_id,
            version=1,
        )
        return stamp

    def get_version_stamp(self, resource_id: str) -> VersionStamp | None:
        """Retrieve the current version stamp for a resource.

        Args:
            resource_id: The resource to look up.

        Returns:
            The current ``VersionStamp``, or ``None`` if untracked.
        """
        if not resource_id or not resource_id.strip():
            raise ValueError("resource_id must be a non-empty string")
        with self._version_stamps_lock:
            return self._version_stamps.get(resource_id)

    def update_version(
        self,
        resource_id: str,
        expected_version: int,
        user_id: str,
        strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.REJECT,
        caller_user_id: str | None = None,
    ) -> ConflictDetectionResult:
        """Attempt to update a resource's version stamp.

        Implements optimistic locking: the caller provides the version
        they last read (``expected_version``).  If the current version
        matches, the stamp is incremented atomically.  If it does not
        match, the configured ``strategy`` determines the outcome.

        Args:
            resource_id: The resource to update.
            expected_version: The version the caller expects.
            user_id: The user performing the update.
            strategy: How to handle conflicts.
            caller_user_id: If provided, enforces ``write`` permission.

        Returns:
            A ``ConflictDetectionResult`` describing the outcome.

        Raises:
            ValueError: If arguments are invalid.
            PermissionError: If *caller_user_id* lacks ``write``.
            VersionConflictError: If strategy is ``reject`` and conflict found.
        """
        if not resource_id or not resource_id.strip():
            raise ValueError("resource_id must be a non-empty string")
        if not user_id or not user_id.strip():
            raise ValueError("user_id must be a non-empty string")
        if expected_version < 1:
            raise ValueError("expected_version must be >= 1")

        if caller_user_id is not None:
            self.enforce_permission(caller_user_id, TeamPermission.WRITE)

        with self._version_stamps_lock:
            current = self._version_stamps.get(resource_id)
            if current is None:
                # Resource not tracked yet — create initial stamp.
                stamp = VersionStamp(
                    resource_id=resource_id,
                    version=1,
                    updated_by=user_id,
                )
                self._version_stamps[resource_id] = stamp
                self._logger.info(
                    "version_stamp_created",
                    resource_id=resource_id,
                    version=1,
                )
                return ConflictDetectionResult(
                    detected=False,
                    resource_id=resource_id,
                    resolved=True,
                    details=f"New resource created at version {stamp.version}",
                )

            if current.version == expected_version:
                # No conflict — increment
                new_stamp = current.increment(user_id)
                self._version_stamps[resource_id] = new_stamp
                return ConflictDetectionResult(
                    detected=False,
                    resource_id=resource_id,
                    resolved=True,
                    details=f"Updated to version {new_stamp.version}",
                )

            # Conflict detected — capture version before any update.
            conflict_version = current.version
            self._logger.warning(
                "version_conflict",
                resource_id=resource_id,
                expected=expected_version,
                actual=conflict_version,
                strategy=str(strategy),
            )

            if strategy == ConflictResolutionStrategy.LAST_WRITER_WINS:
                new_stamp = current.increment(user_id)
                self._version_stamps[resource_id] = new_stamp
                return ConflictDetectionResult(
                    detected=True,
                    resource_id=resource_id,
                    strategy=strategy,
                    resolved=True,
                    details=(
                        f"Conflict resolved (last-writer-wins): "
                        f"version {conflict_version} -> {new_stamp.version}"
                    ),
                )

            if strategy == ConflictResolutionStrategy.MERGE:
                # Merge strategy: increment but mark as merge-resolved
                new_stamp = current.increment(user_id)
                self._version_stamps[resource_id] = new_stamp
                return ConflictDetectionResult(
                    detected=True,
                    resource_id=resource_id,
                    strategy=strategy,
                    resolved=True,
                    details=(
                        f"Conflict resolved (merge): "
                        f"version {conflict_version} -> {new_stamp.version}"
                    ),
                )

            # strategy == REJECT
            raise VersionConflictError(
                resource_id=resource_id,
                expected_version=expected_version,
                actual_version=conflict_version,
            )

    def detect_conflict(
        self,
        resource_id: str,
        expected_version: int,
    ) -> bool:
        """Check whether a conflict exists without resolving it.

        Args:
            resource_id: The resource to check.
            expected_version: The version the caller expects.

        Returns:
            ``True`` if a conflict is detected.

        Raises:
            ValueError: If arguments are invalid.
        """
        if not resource_id or not resource_id.strip():
            raise ValueError("resource_id must be a non-empty string")
        if expected_version < 1:
            raise ValueError("expected_version must be >= 1")

        with self._version_stamps_lock:
            current = self._version_stamps.get(resource_id)
        if current is None:
            return False
        return current.version != expected_version
