"""Team collaboration domain models for CleverAgents.

Multi-user session management, role-based access control, concurrent
session tracking, and optimistic-locking conflict resolution for
shared project access.

## Collaboration Model

Teams are collections of users who share access to projects.  Each
member has a ``TeamRole`` that determines which ``TeamPermission``
actions they may perform.  Active sessions are tracked in a
``SessionRegistry`` so that concurrent edits can be detected and
resolved via configurable ``ConflictResolutionStrategy`` policies.

## Role Hierarchy

| Role    | Permissions                           |
|---------|---------------------------------------|
| owner   | read, write, admin, manage_members    |
| admin   | read, write, admin, manage_members    |
| member  | read, write                           |
| viewer  | read                                  |

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

from __future__ import annotations

import threading
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, field_validator
from ulid import ULID

__all__ = [
    "TEAM_ROLE_PERMISSIONS",
    "ConflictDetectionResult",
    "ConflictResolutionStrategy",
    "SessionRegistry",
    "TeamCollaborationError",
    "TeamMember",
    "TeamMemberExistsError",
    "TeamMemberNotFoundError",
    "TeamPermission",
    "TeamRole",
    "UserSession",
    "VersionConflictError",
    "VersionStamp",
]


# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------


class TeamRole(StrEnum):
    """Role of a user within a collaboration team.

    Determines the set of ``TeamPermission`` actions the user may
    perform on shared resources.
    """

    OWNER = "owner"
    ADMIN = "admin"
    MEMBER = "member"
    VIEWER = "viewer"


class TeamPermission(StrEnum):
    """Actions gated by team role membership."""

    READ = "read"
    WRITE = "write"
    ADMIN = "admin"
    MANAGE_MEMBERS = "manage_members"


class ConflictResolutionStrategy(StrEnum):
    """Strategy for resolving concurrent edit conflicts.

    - ``last_writer_wins``: The most recent write silently overwrites.
    - ``reject``: The conflicting write is rejected with an error.
    - ``merge``: Both changes are merged (requires caller support).
    """

    LAST_WRITER_WINS = "last_writer_wins"
    REJECT = "reject"
    MERGE = "merge"


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

TEAM_ROLE_PERMISSIONS: dict[TeamRole, frozenset[TeamPermission]] = {
    TeamRole.OWNER: frozenset(TeamPermission),
    TeamRole.ADMIN: frozenset(
        {
            TeamPermission.READ,
            TeamPermission.WRITE,
            TeamPermission.ADMIN,
            TeamPermission.MANAGE_MEMBERS,
        }
    ),
    TeamRole.MEMBER: frozenset(
        {
            TeamPermission.READ,
            TeamPermission.WRITE,
        }
    ),
    TeamRole.VIEWER: frozenset(
        {
            TeamPermission.READ,
        }
    ),
}
"""Mapping of each team role to its set of allowed permissions.

OWNER and ADMIN currently grant identical permissions.  The semantic
distinction mirrors the ``PermissionRole`` design --- OWNER denotes the
team/project creator while ADMIN is a delegated role.
"""


# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------


class TeamCollaborationError(Exception):
    """Base error for team collaboration operations."""


class TeamMemberNotFoundError(TeamCollaborationError):
    """Raised when a team member is not found."""


class TeamMemberExistsError(TeamCollaborationError):
    """Raised when attempting to add a member who already exists."""


class VersionConflictError(TeamCollaborationError):
    """Raised when an optimistic-locking version conflict is detected.

    Attributes:
        resource_id: The resource that was modified concurrently.
        expected_version: The version the caller expected.
        actual_version: The current version in the registry.
    """

    def __init__(
        self,
        resource_id: str,
        expected_version: int,
        actual_version: int,
    ) -> None:
        if not resource_id or not resource_id.strip():
            raise ValueError("resource_id must be a non-empty string")
        if expected_version < 0:
            raise ValueError("expected_version must be >= 0")
        if actual_version < 0:
            raise ValueError("actual_version must be >= 0")
        self.resource_id = resource_id
        self.expected_version = expected_version
        self.actual_version = actual_version
        super().__init__(
            f"Version conflict on resource '{resource_id}': "
            f"expected version {expected_version}, "
            f"actual version {actual_version}"
        )


# ---------------------------------------------------------------------------
# TeamMember
# ---------------------------------------------------------------------------


class TeamMember(BaseModel):
    """A user participating in a collaboration team.

    Each member has a unique ``user_id``, a display name, an email
    address, and a ``TeamRole`` controlling their permissions.
    """

    user_id: str = Field(
        ...,
        min_length=1,
        description="Unique identifier for the team member.",
    )
    display_name: str = Field(
        ...,
        min_length=1,
        description="Human-readable display name.",
    )
    email: str = Field(
        ...,
        min_length=3,
        description="Email address of the team member.",
    )
    role: TeamRole = Field(
        ...,
        description="Team role determining permissions.",
    )
    joined_at: datetime = Field(
        default_factory=lambda: datetime.now(tz=UTC),
        description="When the member joined the team.",
    )

    @field_validator("email")
    @classmethod
    def validate_email_format(cls: type[TeamMember], v: str) -> str:
        """Basic email format validation (must contain '@')."""
        if "@" not in v:
            raise ValueError("email must contain '@'")
        return v

    def has_permission(self, permission: TeamPermission) -> bool:
        """Check whether this member's role grants *permission*.

        Args:
            permission: The permission to check.

        Returns:
            ``True`` if the member's role includes the permission.
        """
        allowed = TEAM_ROLE_PERMISSIONS.get(self.role, frozenset())
        return permission in allowed

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# UserSession
# ---------------------------------------------------------------------------


class UserSession(BaseModel):
    """An active user session for concurrent project access.

    Tracks which user is connected, which project they are working on,
    and session lifecycle timestamps.
    """

    session_id: str = Field(
        ...,
        min_length=1,
        description="Unique session identifier (ULID).",
    )
    user_id: str = Field(
        ...,
        min_length=1,
        description="Identifier of the connected user.",
    )
    display_name: str = Field(
        ...,
        min_length=1,
        description="Display name of the connected user.",
    )
    project_id: str = Field(
        ...,
        min_length=1,
        description="Project the session is scoped to.",
    )
    started_at: datetime = Field(
        default_factory=lambda: datetime.now(tz=UTC),
        description="When the session was established.",
    )
    last_active: datetime = Field(
        default_factory=lambda: datetime.now(tz=UTC),
        description="When the session was last active.",
    )
    is_active: bool = Field(
        default=True,
        description="Whether the session is currently active.",
    )
    metadata: dict[str, Any] = Field(
        default_factory=dict,
        description="Optional session metadata.",
    )

    def deactivate(self) -> None:
        """Mark the session as inactive."""
        self.is_active = False

    def touch(self) -> None:
        """Update the last-active timestamp to now."""
        self.last_active = datetime.now(tz=UTC)

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# VersionStamp
# ---------------------------------------------------------------------------


class VersionStamp(BaseModel):
    """Optimistic-locking version stamp for a shared resource.

    Each write increments the ``version`` counter.  Callers present
    their last-known version when writing; a mismatch indicates a
    concurrent modification.
    """

    resource_id: str = Field(
        ...,
        min_length=1,
        description="Identifier of the versioned resource.",
    )
    version: int = Field(
        default=1,
        ge=1,
        description="Monotonically increasing version counter.",
    )
    updated_by: str = Field(
        ...,
        min_length=1,
        description="User who last modified the resource.",
    )
    updated_at: datetime = Field(
        default_factory=lambda: datetime.now(tz=UTC),
        description="When the resource was last modified.",
    )

    def increment(self, updated_by: str) -> VersionStamp:
        """Return a new stamp with an incremented version.

        Args:
            updated_by: The user performing the update.

        Returns:
            A new ``VersionStamp`` with ``version + 1``.

        Raises:
            ValueError: If *updated_by* is empty.
        """
        if not updated_by or not updated_by.strip():
            raise ValueError("updated_by must be a non-empty string")
        return VersionStamp(
            resource_id=self.resource_id,
            version=self.version + 1,
            updated_by=updated_by,
            updated_at=datetime.now(tz=UTC),
        )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# ConflictDetectionResult
# ---------------------------------------------------------------------------


class ConflictDetectionResult(BaseModel):
    """Result of a conflict detection check.

    Encapsulates whether a conflict was detected and, if resolved,
    which strategy was applied and the outcome.
    """

    detected: bool = Field(
        ...,
        description="Whether a version conflict was detected.",
    )
    resource_id: str = Field(
        ...,
        min_length=1,
        description="Identifier of the checked resource.",
    )
    strategy: ConflictResolutionStrategy | None = Field(
        default=None,
        description="Strategy applied (None if no conflict).",
    )
    resolved: bool = Field(
        default=False,
        description="Whether the conflict was successfully resolved.",
    )
    details: str = Field(
        default="",
        description="Human-readable description of the outcome.",
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# SessionRegistry
# ---------------------------------------------------------------------------


class SessionRegistry:
    """Thread-safe registry of active user sessions.

    Tracks concurrent sessions and provides queries by user, project,
    or session ID.  All mutating operations are guarded by a
    ``threading.Lock`` for safe concurrent access.
    """

    def __init__(self) -> None:
        self._sessions: dict[str, UserSession] = {}
        self._lock = threading.Lock()

    def register(self, session: UserSession) -> None:
        """Register a new user session.

        Args:
            session: The session to register.

        Raises:
            ValueError: If a session with the same ID already exists.
        """
        if not session.session_id or not session.session_id.strip():
            raise ValueError("session_id must be a non-empty string")
        with self._lock:
            if session.session_id in self._sessions:
                raise ValueError(
                    f"Session '{session.session_id}' is already registered"
                )
            self._sessions[session.session_id] = session

    def unregister(self, session_id: str) -> bool:
        """Remove a session from the registry.

        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")
        with self._lock:
            if session_id in self._sessions:
                self._sessions[session_id].deactivate()
                del self._sessions[session_id]
                return True
            return False

    def get(self, session_id: str) -> UserSession | None:
        """Retrieve a session by ID.

        Args:
            session_id: The session to look up.

        Returns:
            The session, or ``None`` if not found.
        """
        if not session_id or not session_id.strip():
            raise ValueError("session_id must be a non-empty string")
        with self._lock:
            return self._sessions.get(session_id)

    def get_active_sessions(self) -> list[UserSession]:
        """Return all currently active sessions.

        Returns:
            A list of active ``UserSession`` instances.
        """
        with self._lock:
            return [s for s in self._sessions.values() if s.is_active]

    def get_sessions_for_user(self, user_id: str) -> list[UserSession]:
        """Return all sessions belonging to a specific user.

        Args:
            user_id: The user whose sessions to retrieve.

        Returns:
            A list of sessions for the given user.
        """
        if not user_id or not user_id.strip():
            raise ValueError("user_id must be a non-empty string")
        with self._lock:
            return [
                s
                for s in self._sessions.values()
                if s.user_id == user_id and s.is_active
            ]

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

        Args:
            project_id: The project to query.

        Returns:
            A list of active sessions for the given project.
        """
        if not project_id or not project_id.strip():
            raise ValueError("project_id must be a non-empty string")
        with self._lock:
            return [
                s
                for s in self._sessions.values()
                if s.project_id == project_id and s.is_active
            ]

    @property
    def active_count(self) -> int:
        """Return the number of active sessions."""
        with self._lock:
            return sum(1 for s in self._sessions.values() if s.is_active)

    def clear(self) -> int:
        """Remove all sessions.

        Returns:
            The number of sessions that were removed.
        """
        with self._lock:
            count = len(self._sessions)
            for session in self._sessions.values():
                session.deactivate()
            self._sessions.clear()
            return count

    def generate_session_id(self) -> str:
        """Generate a new unique session ID (ULID).

        Returns:
            A ULID string suitable for ``UserSession.session_id``.
        """
        return str(ULID())
