"""DocumentService — single business-logic layer for document operations.

Agent tools (U6) and REST routes (U7) are thin wrappers over this service.
The service dispatches to format-specific renderers (U2-U5) and persists
metadata via the db module (U1).
"""

from __future__ import annotations

import logging
import os
import uuid
from pathlib import Path

from agentkit.documents.db import (
    DEFAULT_DOC_DB_PATH,
    get_conversation_documents,
    get_document_by_id,
    insert_document,
)
from agentkit.documents.models import DocumentMeta, _now_iso

logger = logging.getLogger(__name__)

_PROJECT_ROOT = Path(__file__).parents[3]
DEFAULT_UPLOAD_DIR = Path(
    os.environ.get("AGENTKIT_UPLOAD_DIR", _PROJECT_ROOT / "data" / "uploads")
)

# Format → file extension mapping
_FORMAT_EXT = {"word": ".docx", "excel": ".xlsx", "pdf": ".pdf"}


def _sanitize_filename(name: str) -> str:
    """Remove path separators and keep only safe characters.

    Mirrors ``server/routes/chat.py::_sanitize_filename`` so document
    filenames are sanitized consistently with chat uploads.
    """
    name = name.replace("\\", "_").replace("/", "_")
    return "".join(c for c in name if c.isalnum() or c in "._-").strip(".")


class DocumentService:
    """Create, query, and manage generated documents.

    The service is format-agnostic at this layer — it handles storage and
    metadata. Format-specific rendering is delegated to renderer modules
    (registered via :meth:`register_renderer`), which keeps the service
    extensible without coupling it to a specific library.
    """

    def __init__(
        self,
        upload_dir: str | Path | None = None,
        db_path: str | Path | None = None,
    ) -> None:
        self.upload_dir = Path(upload_dir) if upload_dir else DEFAULT_UPLOAD_DIR
        self.db_path = Path(db_path) if db_path else DEFAULT_DOC_DB_PATH
        # Renderers are registered by format key: {"word": WordRenderer, ...}
        # U2-U5 populate this dict. U1 leaves it empty.
        self._renderers: dict[str, object] = {}

    def register_renderer(self, format_key: str, renderer: object) -> None:
        """Register a renderer for a format key (e.g. "word", "excel", "pdf")."""
        self._renderers[format_key] = renderer

    def _ensure_upload_dir(self) -> Path:
        self.upload_dir.mkdir(parents=True, exist_ok=True)
        return self.upload_dir

    async def create_document(
        self,
        format: str,
        content: str,
        conversation_id: str,
        filename: str | None = None,
        template_path: str | Path | None = None,
        template_data: dict | None = None,
    ) -> DocumentMeta:
        """Create a document from Markdown content or a template.

        Args:
            format: "word" | "excel" | "pdf".
            content: Markdown-formatted content (ignored if template_path is given).
            conversation_id: Conversation to associate the document with.
            filename: Display filename. If None, a default is generated.
            template_path: Path to a .docx template (Word only, U5).
            template_data: Data dict for Jinja2 template填充 (U5).

        Returns:
            DocumentMeta for the created document.

        Raises:
            ValueError: If format is unsupported or no renderer is registered.
        """
        if format not in _FORMAT_EXT:
            raise ValueError(f"Unsupported format: {format}. Use one of: {list(_FORMAT_EXT)}")

        renderer = self._renderers.get(format)
        if renderer is None:
            raise ValueError(f"No renderer registered for format: {format}")

        ext = _FORMAT_EXT[format]
        doc_id = uuid.uuid4().hex
        stored_name = f"{doc_id}{ext}"
        display_name = _sanitize_filename(filename) if filename else f"document-{doc_id[:8]}{ext}"
        if not display_name.endswith(ext):
            display_name += ext

        upload_dir = self._ensure_upload_dir()
        output_path = upload_dir / stored_name

        # Dispatch to renderer (U2-U5 implement the renderers)
        if template_path is not None and format == "word":
            # Template filling path (U5) — TemplateRenderer handles Jinja2/docxtpl.
            # Lazy import avoids coupling the service to a specific renderer and
            # ensures template filling works even if only WordRenderer was registered.
            from agentkit.documents.renderers.template_renderer import TemplateRenderer

            await self._render_template(
                TemplateRenderer(), template_path, template_data or {}, output_path
            )
        else:
            # Markdown → format rendering path (U2-U4)
            await self._render_content(renderer, content, output_path)

        size = output_path.stat().st_size
        meta = DocumentMeta(
            id=doc_id,
            filename=display_name,
            stored_name=stored_name,
            format=format,
            size=size,
            conversation_id=conversation_id,
            created_at=_now_iso(),
        )
        await insert_document(meta, self.db_path)
        logger.info(f"Created document {doc_id} ({format}, {size} bytes) for conv {conversation_id}")
        return meta

    async def _render_content(self, renderer: object, content: str, output_path: Path) -> None:
        """Call renderer.render(markdown_content, output_path).

        Renderers may be sync or async. We support both by checking
        for a coroutine result.
        """
        import inspect

        result = renderer.render(content, output_path)
        if inspect.isawaitable(result):
            await result

    async def _render_template(
        self, renderer: object, template_path: str | Path, data: dict, output_path: Path
    ) -> None:
        """Call renderer.render_template(template_path, data, output_path)."""
        import inspect

        result = renderer.render_template(template_path, data, output_path)
        if inspect.isawaitable(result):
            await result

    async def get_conversation_documents(self, conversation_id: str) -> list[DocumentMeta]:
        """Return all documents for a conversation, newest first."""
        return await get_conversation_documents(conversation_id, self.db_path)

    async def get_document(self, doc_id: str) -> DocumentMeta | None:
        """Return a single document by id, or None."""
        return await get_document_by_id(doc_id, self.db_path)

    def get_download_path(self, doc_id: str) -> Path | None:
        """Return the on-disk path for a document id.

        Note: This is a sync method because it only checks the filesystem.
        The caller should have already verified the document exists via
        :meth:`get_document` if metadata is needed.
        """
        # Try each known extension — the stored_name uses the format's ext.
        for ext in _FORMAT_EXT.values():
            path = self.upload_dir / f"{doc_id}{ext}"
            if path.exists():
                return path
        return None
