"""KbService — admin-driven KB document/source management (U6).

This module wraps the existing in-memory :class:`KnowledgeSourceStore`
from :mod:`agentkit.server.routes.kb_management` so admin routes can
manage documents and sources through a service layer (mirroring
:class:`DepartmentService` and :class:`SkillService`).

Department filtering
--------------------
When listing documents, the service filters by department visibility:
- documents whose ``department_id`` is in the caller's department set, OR
- documents whose ``department_id`` is ``None`` (global documents).

Admin callers (``department_ids=None``) bypass filtering and see all
documents.

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

from __future__ import annotations

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

from agentkit.server.routes.kb_management import (
    KnowledgeSource,
    KnowledgeSourceStore,
    UploadedDocument,
    get_source_store,
)

logger = logging.getLogger(__name__)


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


class KbService:
    """Wraps :class:`KnowledgeSourceStore` with admin-friendly operations.

    The service does NOT own the store — it delegates to the
    module-level singleton from :mod:`kb_management`. This keeps a
    single source of truth for KB state (the existing routes still
    work) while giving admin routes a clean service boundary.

    Department filtering is applied at the service layer so admin
    routes can pass ``department_ids`` directly from the
    :class:`DepartmentContext` dependency.
    """

    def __init__(self, store: KnowledgeSourceStore | None = None) -> None:
        self._store = store

    def _resolve_store(self) -> KnowledgeSourceStore:
        """Return the injected store, or the module singleton."""
        if self._store is not None:
            return self._store
        return get_source_store()

    # ------------------------------------------------------------------
    # Document operations
    # ------------------------------------------------------------------

    def list_documents(
        self,
        department_ids: list[str] | None = None,
        source_id: str | None = None,
    ) -> list[dict[str, Any]]:
        """List documents, optionally filtered by source and department.

        Args:
            department_ids: Caller's department ids. ``None`` means admin
                (no filtering — return all documents). An empty list means
                a user with no departments (return only global documents).
            source_id: Optional source id filter.

        Returns:
            List of document dicts (JSON-safe).
        """
        store = self._resolve_store()
        documents = store.list_documents(source_id=source_id)

        # Admin bypass: department_ids=None means "see everything".
        if department_ids is None:
            return [self._doc_to_dict(d) for d in documents]

        # Non-admin: visible = (docs in user's departments) ∪ (global docs).
        dept_set = set(department_ids)
        visible = [d for d in documents if d.department_id is None or d.department_id in dept_set]
        return [self._doc_to_dict(d) for d in visible]

    def upload_document(
        self,
        filename: str,
        content: bytes,
        source_id: str = "",
        department_id: str | None = None,
    ) -> dict[str, Any]:
        """Upload a document with optional department binding.

        Args:
            filename: Original filename.
            content: File content bytes (used to estimate chunk count).
            source_id: Optional source id. Defaults to ``"local"``.
            department_id: Optional department id to bind the document to.
                ``None`` means the document is global (visible to all).

        Returns:
            The uploaded document dict.
        """
        store = self._resolve_store()
        effective_source_id = source_id or "local"

        # Ensure a local source exists for default uploads.
        if effective_source_id == "local":
            local_sources = [s for s in store.list_sources() if s.type == "local"]
            if not local_sources:
                store.add_source("本地文档", "local", {})

        # Estimate chunks from content length (rough approximation,
        # mirrors the existing /kb-management/documents/upload route).
        chunks = max(1, len(content) // 500)

        doc = UploadedDocument(
            document_id=str(uuid.uuid4()),
            filename=filename,
            source_id=effective_source_id,
            chunks=chunks,
            status="indexed",
            department_id=department_id,
        )
        store.add_document(doc)
        return self._doc_to_dict(doc)

    def delete_document(self, document_id: str) -> bool:
        """Delete a document by id. Returns ``True`` if deleted."""
        store = self._resolve_store()
        return store.delete_document(document_id)

    # ------------------------------------------------------------------
    # Source operations
    # ------------------------------------------------------------------

    def list_sources(self) -> list[dict[str, Any]]:
        """List all KB sources."""
        store = self._resolve_store()
        return [self._source_to_dict(s) for s in store.list_sources()]

    def get_source(self, source_id: str) -> dict[str, Any] | None:
        """Return a single source by id, or ``None`` if not found."""
        store = self._resolve_store()
        source = store.get_source(source_id)
        return self._source_to_dict(source) if source else None

    def sync_source(self, source_id: str) -> dict[str, Any]:
        """Trigger a source sync (stub — marks status as ``syncing``).

        Raises:
            ValueError: If the source does not exist.
        """
        store = self._resolve_store()
        source = store.get_source(source_id)
        if source is None:
            raise ValueError(f"KB source {source_id!r} not found")
        # Stub: mark status as syncing. A real implementation would
        # kick off an async sync job and return a job id.
        source.status = "syncing"
        source.last_synced = _now_iso()
        return {
            "source_id": source_id,
            "status": "syncing",
            "message": "Sync started",
        }

    def rebuild_index(self, source_id: str) -> dict[str, Any]:
        """Rebuild the index for a source (stub — marks status).

        Raises:
            ValueError: If the source does not exist.
        """
        store = self._resolve_store()
        source = store.get_source(source_id)
        if source is None:
            raise ValueError(f"KB source {source_id!r} not found")
        # Stub: mark status as rebuilding.
        source.status = "rebuilding"
        return {
            "source_id": source_id,
            "status": "rebuilding",
            "message": "Index rebuild started",
        }

    # ------------------------------------------------------------------
    # Serialization helpers
    # ------------------------------------------------------------------

    def _doc_to_dict(self, doc: UploadedDocument) -> dict[str, Any]:
        """Convert an :class:`UploadedDocument` to a JSON-safe dict."""
        return {
            "document_id": doc.document_id,
            "filename": doc.filename,
            "source_id": doc.source_id,
            "chunks": doc.chunks,
            "status": doc.status,
            "created_at": doc.created_at,
            "department_id": doc.department_id,
        }

    def _source_to_dict(self, source: KnowledgeSource) -> dict[str, Any]:
        """Convert a :class:`KnowledgeSource` to a JSON-safe dict."""
        return {
            "id": source.id,
            "name": source.name,
            "type": source.type,
            "status": source.status,
            "document_count": source.document_count,
            "last_synced": source.last_synced,
            "department_id": source.department_id,
        }


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


_kb_service: KbService | None = None


def get_kb_service() -> KbService:
    """Return the process-wide :class:`KbService` (lazy singleton)."""
    global _kb_service
    if _kb_service is None:
        _kb_service = KbService()
    return _kb_service


def set_kb_service(service: KbService | None) -> None:
    """Inject a custom :class:`KbService` (used by tests)."""
    global _kb_service
    _kb_service = service
