"""UserService — business logic for ``users`` + multi-department assignment (U3).

This module is the single owner of user CRUD operations that span the
``users`` and ``user_departments`` tables. Web UI routes
(``/api/v1/admin/users/*``) and the CLI ``agentkit admin user`` sub-app
both call into :class:`UserService` rather than touching the tables
directly, keeping the validation rules (duplicate-username, department
existence) in one place.

The service is a module-level singleton (see :func:`get_user_service`)
so tests can inject a custom instance via :func:`set_user_service`.

Password hashing uses bcrypt (cost factor 12) via
:func:`agentkit.server.auth.password.hash_password`. Password resets
also revoke all of the user's active sessions via :class:`SessionService`.
"""

from __future__ import annotations

import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import aiosqlite

from agentkit.server.auth.models import user_row_to_dict
from agentkit.server.auth.password import hash_password
from agentkit.server.auth.providers.local import LocalAuthProvider
from agentkit.server.auth.session_service import (
    REVOKE_REASON_PASSWORD_CHANGED,
    get_session_service,
)

logger = logging.getLogger(__name__)


def _now_iso() -> str:
    """Return current UTC time as ISO 8601 string."""
    return datetime.now(timezone.utc).isoformat()


class UserService:
    """CRUD + multi-department assignment operations for users.

    All async methods take ``db_path: Path`` as the first argument
    (after ``self``). Each method opens its own short-lived
    :class:`aiosqlite.Connection` — there is no shared connection state,
    which keeps the service safe to call from any async context.

    The :meth:`create_user` method delegates the actual user-row insert
    to :class:`LocalAuthProvider` so that the password-hashing and
    IntegrityError-handling logic stays in one place. The other methods
    (list/get/update/delete/reset_password/department assignment) operate
    directly on the DB.
    """

    # ------------------------------------------------------------------
    # User CRUD
    # ------------------------------------------------------------------

    async def create_user(
        self,
        db_path: Path,
        username: str,
        email: str,
        password: str,
        role: str = "member",
        department_ids: list[str] | None = None,
        created_by: str | None = None,
    ) -> dict[str, Any]:
        """Create a new user, optionally assigning to departments.

        Args:
            db_path: Path to the auth SQLite DB.
            username: Unique username.
            email: Unique email address.
            password: Plain-text password (bcrypt-hashed before storage).
            role: Role name (``member`` / ``operator`` / ``admin``).
                Defaults to ``member``.
            department_ids: Optional list of department ids to assign
                the user to. Each id must exist in the ``departments``
                table; duplicate or non-existent ids raise ``ValueError``.
            created_by: Optional user id of the admin who created this
                user (audit trail).

        Returns:
            The newly-created user dict, including a ``departments``
            list (each item is ``{id, name}``).

        Raises:
            ValueError: If a user with the same username or email
                already exists, or if any of the ``department_ids``
                does not exist or is already assigned.
        """
        provider = LocalAuthProvider(db_path=db_path)
        user = await provider.create_user(
            username=username,
            email=email,
            password=password,
            role=role,
            created_by=created_by,
        )
        user_id = user["id"]

        if department_ids:
            for dept_id in department_ids:
                # assign_department validates department existence and
                # duplicate-assignment; raise on the first failure.
                await self.assign_department(db_path, user_id, dept_id)

        # Re-fetch with departments populated so the caller sees the
        # full picture (matches the shape returned by get_user).
        result = await self.get_user(db_path, user_id)
        assert result is not None  # we just created it
        return result

    async def list_users(
        self,
        db_path: Path,
        department_id: str | None = None,
        include_inactive: bool = True,
    ) -> list[dict[str, Any]]:
        """List users, optionally filtered by department.

        Args:
            db_path: Path to the auth SQLite DB.
            department_id: When provided, only users assigned to this
                department are returned (via ``user_departments`` join).
            include_inactive: When ``False``, only users with
                ``is_active=1`` are returned.

        Returns:
            List of user dicts, each including a ``departments`` list
            (each item is ``{id, name}``). Ordered by ``created_at``.
        """
        async with aiosqlite.connect(str(db_path)) as db:
            db.row_factory = aiosqlite.Row
            if department_id is not None:
                # JOIN through user_departments to filter. DISTINCT
                # avoids duplicates when a user is in the department
                # (the join would otherwise produce one row per
                # user_department pair, but we filter to a single
                # department here so each user appears at most once).
                if include_inactive:
                    sql = (
                        "SELECT DISTINCT u.id, u.username, u.email, u.password_hash, "
                        "u.role, u.is_active, u.is_terminal_authorized, "
                        "u.is_server_terminal_authorized, u.created_at, u.updated_at, "
                        "u.last_login_at, u.created_by "
                        "FROM users u "
                        "INNER JOIN user_departments ud ON ud.user_id = u.id "
                        "WHERE ud.department_id = ? "
                        "ORDER BY u.created_at ASC"
                    )
                    args: tuple[Any, ...] = (department_id,)
                else:
                    sql = (
                        "SELECT DISTINCT u.id, u.username, u.email, u.password_hash, "
                        "u.role, u.is_active, u.is_terminal_authorized, "
                        "u.is_server_terminal_authorized, u.created_at, u.updated_at, "
                        "u.last_login_at, u.created_by "
                        "FROM users u "
                        "INNER JOIN user_departments ud ON ud.user_id = u.id "
                        "WHERE ud.department_id = ? AND u.is_active = 1 "
                        "ORDER BY u.created_at ASC"
                    )
                    args = (department_id,)
            elif include_inactive:
                sql = (
                    "SELECT id, username, email, password_hash, role, is_active, "
                    "is_terminal_authorized, is_server_terminal_authorized, "
                    "created_at, updated_at, last_login_at, created_by "
                    "FROM users ORDER BY created_at ASC"
                )
                args = ()
            else:
                sql = (
                    "SELECT id, username, email, password_hash, role, is_active, "
                    "is_terminal_authorized, is_server_terminal_authorized, "
                    "created_at, updated_at, last_login_at, created_by "
                    "FROM users WHERE is_active = 1 ORDER BY created_at ASC"
                )
                args = ()
            cursor = await db.execute(sql, args)
            rows = await cursor.fetchall()

            users = [user_row_to_dict(row) for row in rows]
            # Batch-fetch departments for each user in the same
            # connection to avoid N+1 round-trips.
            for user in users:
                user["departments"] = await self._fetch_departments(db, user["id"])
        return users

    async def get_user(
        self,
        db_path: Path,
        user_id: str,
    ) -> dict[str, Any] | None:
        """Return a single user by id, or ``None`` if not found.

        The returned dict includes a ``departments`` list (each item is
        ``{id, name}``).
        """
        async with aiosqlite.connect(str(db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT id, username, email, password_hash, role, is_active, "
                "is_terminal_authorized, is_server_terminal_authorized, "
                "created_at, updated_at, last_login_at, created_by "
                "FROM users WHERE id = ?",
                (user_id,),
            )
            row = await cursor.fetchone()
            if row is None:
                return None
            user = user_row_to_dict(row)
            user["departments"] = await self._fetch_departments(db, user_id)
        return user

    async def update_user(
        self,
        db_path: Path,
        user_id: str,
        role: str | None = None,
        is_active: bool | None = None,
        is_terminal_authorized: bool | None = None,
        is_server_terminal_authorized: bool | None = None,
    ) -> dict[str, Any]:
        """Partially update a user.

        Only the provided fields are updated. ``password_hash`` is not
        touched here — use :meth:`reset_password` for that.

        Args:
            db_path: Path to the auth SQLite DB.
            user_id: User id to update.
            role: New role, or ``None`` to skip.
            is_active: New active flag, or ``None`` to skip.
            is_terminal_authorized: New terminal-authorized flag, or
                ``None`` to skip.
            is_server_terminal_authorized: New server-terminal-authorized
                flag, or ``None`` to skip.

        Returns:
            The updated user dict (including ``departments``).

        Raises:
            ValueError: If the user does not exist.
        """
        existing = await self.get_user(db_path, user_id)
        if existing is None:
            raise ValueError(f"User {user_id!r} not found")

        # Build the SET clause dynamically based on which fields were
        # provided. This avoids overwriting columns with NULL when the
        # caller only wants to update a subset.
        set_parts: list[str] = []
        args: list[Any] = []
        if role is not None:
            set_parts.append("role = ?")
            args.append(role)
        if is_active is not None:
            set_parts.append("is_active = ?")
            args.append(1 if is_active else 0)
        if is_terminal_authorized is not None:
            set_parts.append("is_terminal_authorized = ?")
            args.append(1 if is_terminal_authorized else 0)
        if is_server_terminal_authorized is not None:
            set_parts.append("is_server_terminal_authorized = ?")
            args.append(1 if is_server_terminal_authorized else 0)

        if set_parts:
            set_parts.append("updated_at = ?")
            args.append(_now_iso())
            args.append(user_id)
            sql = f"UPDATE users SET {', '.join(set_parts)} WHERE id = ?"
            async with aiosqlite.connect(str(db_path)) as db:
                await db.execute(sql, tuple(args))
                await db.commit()

        updated = await self.get_user(db_path, user_id)
        assert updated is not None  # we checked existence above
        return updated

    async def delete_user(
        self,
        db_path: Path,
        user_id: str,
    ) -> bool:
        """Soft-delete a user (set ``is_active = 0``).

        The row is NOT actually deleted — this preserves audit trails
        and foreign-key references. Use :meth:`update_user` with
        ``is_active=True`` to re-enable.

        Args:
            db_path: Path to the auth SQLite DB.
            user_id: User id to soft-delete.

        Returns:
            ``True`` if the user was updated, ``False`` if the user did
            not exist (or was already inactive).
        """
        async with aiosqlite.connect(str(db_path)) as db:
            cursor = await db.execute(
                "UPDATE users SET is_active = 0, updated_at = ? WHERE id = ? AND is_active = 1",
                (_now_iso(), user_id),
            )
            await db.commit()
            return cursor.rowcount > 0

    async def reset_password(
        self,
        db_path: Path,
        user_id: str,
        new_password: str,
    ) -> bool:
        """Reset a user's password and revoke all their sessions.

        The new password is bcrypt-hashed (cost factor 12) before
        storage. All active sessions for the user are then revoked
        (via :class:`SessionService`) so that any stolen refresh tokens
        become invalid immediately.

        Args:
            db_path: Path to the auth SQLite DB.
            user_id: User id whose password should be reset.
            new_password: New plain-text password.

        Returns:
            ``True`` if the password was updated, ``False`` if the user
            did not exist.
        """
        # Use the shared hash_password helper so the cost factor (12)
        # is configured in one place (auth.password).
        password_hash = hash_password(new_password)
        async with aiosqlite.connect(str(db_path)) as db:
            cursor = await db.execute(
                "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
                (password_hash, _now_iso(), user_id),
            )
            await db.commit()
            updated = cursor.rowcount > 0

        if not updated:
            return False

        # Revoke all active sessions for this user. We use the
        # process-wide SessionService singleton (which reads the same
        # auth DB) so that the revocation takes effect immediately for
        # any subsequent request that checks session validity.
        try:
            session_svc = get_session_service()
            await session_svc.revoke_all_for_user(user_id, reason=REVOKE_REASON_PASSWORD_CHANGED)
        except Exception:  # noqa: BLE001 — never block password reset on session revocation
            logger.exception(
                "Failed to revoke sessions for user %s after password reset",
                user_id,
            )

        logger.info("Reset password for user %s and revoked active sessions", user_id)
        return True

    # ------------------------------------------------------------------
    # Department assignment
    # ------------------------------------------------------------------

    async def assign_department(
        self,
        db_path: Path,
        user_id: str,
        department_id: str,
    ) -> dict[str, Any]:
        """Assign a user to a department.

        Args:
            db_path: Path to the auth SQLite DB.
            user_id: User id to assign.
            department_id: Department id to assign the user to.

        Returns:
            Dict with ``{user_id, department_id, created_at}``.

        Raises:
            ValueError: If the department does not exist, or if the
                user is already assigned to this department (duplicate
                ``user_departments`` row).
        """
        # Verify the department exists. We don't verify the user exists
        # here — the INSERT below will fail with a row-constraint
        # violation if the user_id is bogus, and the caller is expected
        # to have created/fetched the user before calling this method.
        async with aiosqlite.connect(str(db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT id FROM departments WHERE id = ?",
                (department_id,),
            )
            dept_row = await cursor.fetchone()
            if dept_row is None:
                raise ValueError(f"Department {department_id!r} not found")

            now = _now_iso()
            try:
                await db.execute(
                    "INSERT INTO user_departments (user_id, department_id, created_at) "
                    "VALUES (?, ?, ?)",
                    (user_id, department_id, now),
                )
                await db.commit()
            except aiosqlite.IntegrityError as exc:
                # The composite PK (user_id, department_id) is the only
                # UNIQUE constraint on this table, so an IntegrityError
                # here means the assignment already exists.
                raise ValueError(
                    f"User {user_id!r} is already assigned to department {department_id!r}"
                ) from exc

        return {
            "user_id": user_id,
            "department_id": department_id,
            "created_at": now,
        }

    async def remove_department(
        self,
        db_path: Path,
        user_id: str,
        department_id: str,
    ) -> bool:
        """Remove a user's department assignment.

        Args:
            db_path: Path to the auth SQLite DB.
            user_id: User id.
            department_id: Department id to remove.

        Returns:
            ``True`` if a row was deleted, ``False`` if the assignment
            did not exist.
        """
        async with aiosqlite.connect(str(db_path)) as db:
            cursor = await db.execute(
                "DELETE FROM user_departments WHERE user_id = ? AND department_id = ?",
                (user_id, department_id),
            )
            await db.commit()
            return cursor.rowcount > 0

    async def list_user_departments(
        self,
        db_path: Path,
        user_id: str,
    ) -> list[dict[str, Any]]:
        """Return the list of departments a user is assigned to.

        Each item is ``{id, name, is_active}`` — the ``is_active`` flag
        reflects the department's active state (not the user's).
        """
        async with aiosqlite.connect(str(db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT d.id, d.name, d.is_active "
                "FROM departments d "
                "INNER JOIN user_departments ud ON ud.department_id = d.id "
                "WHERE ud.user_id = ? "
                "ORDER BY d.name ASC",
                (user_id,),
            )
            rows = await cursor.fetchall()
        return [
            {
                "id": row["id"],
                "name": row["name"],
                "is_active": bool(row["is_active"]),
            }
            for row in rows
        ]

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    async def _fetch_departments(
        self,
        db: aiosqlite.Connection,
        user_id: str,
    ) -> list[dict[str, Any]]:
        """Fetch the departments for a user (helper for list/get).

        Reuses the caller's open connection to avoid an extra round-trip.
        Returns a list of ``{id, name}`` dicts.
        """
        db.row_factory = aiosqlite.Row
        cursor = await db.execute(
            "SELECT d.id, d.name "
            "FROM departments d "
            "INNER JOIN user_departments ud ON ud.department_id = d.id "
            "WHERE ud.user_id = ? "
            "ORDER BY d.name ASC",
            (user_id,),
        )
        rows = await cursor.fetchall()
        return [{"id": row["id"], "name": row["name"]} for row in rows]


# ---------------------------------------------------------------------------
# Module-level singleton (overridable in tests via set_user_service)
# ---------------------------------------------------------------------------


_user_service: UserService | None = None


def get_user_service() -> UserService:
    """Return the process-wide :class:`UserService` (lazy singleton)."""
    global _user_service
    if _user_service is None:
        _user_service = UserService()
    return _user_service


def set_user_service(service: UserService | None) -> None:
    """Inject a custom :class:`UserService` (used by tests)."""
    global _user_service
    _user_service = service
