"""DepartmentService — business logic for ``departments`` and bindings (U2).

This module is the single owner of the ``departments``,
``department_skill_bindings``, and ``department_kb_bindings`` tables.
Web UI routes (``/api/v1/admin/departments/*``) and the CLI
``agentkit admin department`` sub-app both call into
:class:`DepartmentService` rather than touching the tables directly,
keeping the validation rules (duplicate-name, has-users guard) in one
place.

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

from __future__ import annotations

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

import aiosqlite

from agentkit.server.auth.models import department_row_to_dict

logger = logging.getLogger(__name__)


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


def _new_id() -> str:
    """Return a new UUID4 string."""
    return str(uuid.uuid4())


class DepartmentService:
    """CRUD + skill/KB binding operations for departments.

    All methods are async and 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.
    """

    # ------------------------------------------------------------------
    # Department CRUD
    # ------------------------------------------------------------------

    async def create_department(
        self,
        db_path: Path,
        name: str,
        description: str = "",
    ) -> dict[str, Any]:
        """Create a new department.

        Args:
            db_path: Path to the auth SQLite DB.
            name: Department name (must be unique).
            description: Optional description (defaults to empty string).

        Returns:
            The newly-created department as a dict.

        Raises:
            ValueError: If a department with the same name already exists.
        """
        dept_id = _new_id()
        now = _now_iso()
        try:
            async with aiosqlite.connect(str(db_path)) as db:
                await db.execute(
                    "INSERT INTO departments (id, name, description, is_active, created_at) "
                    "VALUES (?, ?, ?, ?, ?)",
                    (dept_id, name, description or None, 1, now),
                )
                await db.commit()
        except aiosqlite.IntegrityError as exc:
            # The only UNIQUE constraint on departments is `name`.
            raise ValueError(f"Department with name {name!r} already exists") from exc

        created = await self.get_department(db_path, dept_id)
        assert created is not None  # we just inserted it
        return created

    async def list_departments(
        self,
        db_path: Path,
        include_inactive: bool = True,
    ) -> list[dict[str, Any]]:
        """List all departments, ordered by ``created_at``.

        Args:
            db_path: Path to the auth SQLite DB.
            include_inactive: When ``False``, only departments with
                ``is_active=1`` are returned.

        Returns:
            List of department dicts (newest last).
        """
        async with aiosqlite.connect(str(db_path)) as db:
            db.row_factory = aiosqlite.Row
            if include_inactive:
                cursor = await db.execute("SELECT * FROM departments ORDER BY created_at ASC")
            else:
                cursor = await db.execute(
                    "SELECT * FROM departments WHERE is_active = 1 ORDER BY created_at ASC"
                )
            rows = await cursor.fetchall()
        return [department_row_to_dict(row) for row in rows]

    async def get_department(
        self,
        db_path: Path,
        department_id: str,
    ) -> dict[str, Any] | None:
        """Return a single department by id, or ``None`` if not found."""
        async with aiosqlite.connect(str(db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT * FROM departments WHERE id = ?",
                (department_id,),
            )
            row = await cursor.fetchone()
        return department_row_to_dict(row) if row else None

    async def update_department(
        self,
        db_path: Path,
        department_id: str,
        name: str | None = None,
        description: str | None = None,
    ) -> dict[str, Any]:
        """Partially update a department.

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

        Args:
            db_path: Path to the auth SQLite DB.
            department_id: Department id to update.
            name: New name (must remain unique), or ``None`` to skip.
            description: New description, or ``None`` to skip.

        Returns:
            The updated department dict.

        Raises:
            ValueError: If the department does not exist, or if the new
                name collides with an existing department.
        """
        existing = await self.get_department(db_path, department_id)
        if existing is None:
            raise ValueError(f"Department {department_id!r} not found")

        new_name = name if name is not None else existing["name"]
        # Treat empty-string description as "clear" (None) to keep the
        # column nullable; otherwise preserve the existing value.
        if description is not None:
            new_description: str | None = description or None
        else:
            new_description = existing["description"]

        try:
            async with aiosqlite.connect(str(db_path)) as db:
                await db.execute(
                    "UPDATE departments SET name = ?, description = ? WHERE id = ?",
                    (new_name, new_description, department_id),
                )
                await db.commit()
        except aiosqlite.IntegrityError as exc:
            raise ValueError(f"Department with name {new_name!r} already exists") from exc

        updated = await self.get_department(db_path, department_id)
        assert updated is not None
        return updated

    async def set_department_active(
        self,
        db_path: Path,
        department_id: str,
        is_active: bool,
    ) -> dict[str, Any]:
        """Toggle ``is_active`` on a department.

        Raises:
            ValueError: If the department does not exist.
        """
        existing = await self.get_department(db_path, department_id)
        if existing is None:
            raise ValueError(f"Department {department_id!r} not found")

        async with aiosqlite.connect(str(db_path)) as db:
            await db.execute(
                "UPDATE departments SET is_active = ? WHERE id = ?",
                (1 if is_active else 0, department_id),
            )
            await db.commit()

        updated = await self.get_department(db_path, department_id)
        assert updated is not None
        return updated

    async def delete_department(
        self,
        db_path: Path,
        department_id: str,
    ) -> bool:
        """Delete a department.

        Refuses to delete a department that still has users assigned
        (rows in ``user_departments``). The caller must remove the
        user-department associations first.

        Args:
            db_path: Path to the auth SQLite DB.
            department_id: Department id to delete.

        Returns:
            ``True`` if the department was deleted, ``False`` if it did
            not exist.

        Raises:
            ValueError: If the department has users assigned.
        """
        existing = await self.get_department(db_path, department_id)
        if existing is None:
            return False

        user_count = await self.count_department_users(db_path, department_id)
        if user_count > 0:
            raise ValueError("Department has users, remove them first")

        async with aiosqlite.connect(str(db_path)) as db:
            # Cascade-clear bindings so the department row can be deleted
            # without leaving orphan binding rows. (user_departments was
            # already checked to be empty above.)
            await db.execute(
                "DELETE FROM department_skill_bindings WHERE department_id = ?",
                (department_id,),
            )
            await db.execute(
                "DELETE FROM department_kb_bindings WHERE department_id = ?",
                (department_id,),
            )
            await db.execute(
                "DELETE FROM department_quotas WHERE department_id = ?",
                (department_id,),
            )
            await db.execute(
                "DELETE FROM departments WHERE id = ?",
                (department_id,),
            )
            await db.commit()
        return True

    # ------------------------------------------------------------------
    # Skill bindings
    # ------------------------------------------------------------------

    async def bind_skill(
        self,
        db_path: Path,
        department_id: str,
        skill_name: str,
    ) -> dict[str, Any]:
        """Bind a skill to a department.

        Raises:
            ValueError: If the binding already exists, or if the
                department does not exist.
        """
        existing = await self.get_department(db_path, department_id)
        if existing is None:
            raise ValueError(f"Department {department_id!r} not found")

        binding_id = _new_id()
        now = _now_iso()
        try:
            async with aiosqlite.connect(str(db_path)) as db:
                await db.execute(
                    "INSERT INTO department_skill_bindings "
                    "(id, department_id, skill_name, created_at) "
                    "VALUES (?, ?, ?, ?)",
                    (binding_id, department_id, skill_name, now),
                )
                await db.commit()
        except aiosqlite.IntegrityError as exc:
            # UNIQUE (department_id, skill_name) violation.
            raise ValueError(
                f"Skill {skill_name!r} already bound to department {department_id!r}"
            ) from exc

        return {
            "id": binding_id,
            "department_id": department_id,
            "skill_name": skill_name,
            "created_at": now,
        }

    async def unbind_skill(
        self,
        db_path: Path,
        department_id: str,
        skill_name: str,
    ) -> bool:
        """Remove a skill binding. Returns ``True`` if a row was deleted."""
        async with aiosqlite.connect(str(db_path)) as db:
            cursor = await db.execute(
                "DELETE FROM department_skill_bindings WHERE department_id = ? AND skill_name = ?",
                (department_id, skill_name),
            )
            await db.commit()
            return cursor.rowcount > 0

    async def list_department_skills(
        self,
        db_path: Path,
        department_id: str,
    ) -> list[str]:
        """Return the list of skill names bound to the department."""
        async with aiosqlite.connect(str(db_path)) as db:
            cursor = await db.execute(
                "SELECT skill_name FROM department_skill_bindings "
                "WHERE department_id = ? ORDER BY skill_name ASC",
                (department_id,),
            )
            rows = await cursor.fetchall()
        return [row[0] for row in rows]

    # ------------------------------------------------------------------
    # KB bindings
    # ------------------------------------------------------------------

    async def bind_kb(
        self,
        db_path: Path,
        department_id: str,
        kb_source_id: str,
    ) -> dict[str, Any]:
        """Bind a KB source to a department.

        Raises:
            ValueError: If the binding already exists, or if the
                department does not exist.
        """
        existing = await self.get_department(db_path, department_id)
        if existing is None:
            raise ValueError(f"Department {department_id!r} not found")

        binding_id = _new_id()
        now = _now_iso()
        try:
            async with aiosqlite.connect(str(db_path)) as db:
                await db.execute(
                    "INSERT INTO department_kb_bindings "
                    "(id, department_id, kb_source_id, created_at) "
                    "VALUES (?, ?, ?, ?)",
                    (binding_id, department_id, kb_source_id, now),
                )
                await db.commit()
        except aiosqlite.IntegrityError as exc:
            raise ValueError(
                f"KB source {kb_source_id!r} already bound to department {department_id!r}"
            ) from exc

        return {
            "id": binding_id,
            "department_id": department_id,
            "kb_source_id": kb_source_id,
            "created_at": now,
        }

    async def unbind_kb(
        self,
        db_path: Path,
        department_id: str,
        kb_source_id: str,
    ) -> bool:
        """Remove a KB binding. Returns ``True`` if a row was deleted."""
        async with aiosqlite.connect(str(db_path)) as db:
            cursor = await db.execute(
                "DELETE FROM department_kb_bindings WHERE department_id = ? AND kb_source_id = ?",
                (department_id, kb_source_id),
            )
            await db.commit()
            return cursor.rowcount > 0

    async def list_department_kbs(
        self,
        db_path: Path,
        department_id: str,
    ) -> list[str]:
        """Return the list of KB source ids bound to the department."""
        async with aiosqlite.connect(str(db_path)) as db:
            cursor = await db.execute(
                "SELECT kb_source_id FROM department_kb_bindings "
                "WHERE department_id = ? ORDER BY kb_source_id ASC",
                (department_id,),
            )
            rows = await cursor.fetchall()
        return [row[0] for row in rows]

    # ------------------------------------------------------------------
    # User count
    # ------------------------------------------------------------------

    async def count_department_users(
        self,
        db_path: Path,
        department_id: str,
    ) -> int:
        """Return the number of users assigned to the department."""
        async with aiosqlite.connect(str(db_path)) as db:
            cursor = await db.execute(
                "SELECT COUNT(*) FROM user_departments WHERE department_id = ?",
                (department_id,),
            )
            row = await cursor.fetchone()
        return int(row[0]) if row else 0


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


_department_service: DepartmentService | None = None


def get_department_service() -> DepartmentService:
    """Return the process-wide :class:`DepartmentService` (lazy singleton)."""
    global _department_service
    if _department_service is None:
        _department_service = DepartmentService()
    return _department_service


def set_department_service(service: DepartmentService | None) -> None:
    """Inject a custom :class:`DepartmentService` (used by tests)."""
    global _department_service
    _department_service = service
