# src/chitai/controllers/library.py

# Standard library
import asyncio
import shutil
import tempfile
from pathlib import Path
from typing import Annotated

# Third-party libraries
import aiofiles
from aiofiles import os as aios
from litestar import Controller, post, get, delete
from litestar.enums import RequestEncodingType
from litestar.params import Body, Dependency
from litestar.exceptions import HTTPException
from litestar.status_codes import HTTP_200_OK, HTTP_202_ACCEPTED
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination
from advanced_alchemy.service import FilterTypeT

# Local imports
from chitai.database import models as m
from chitai.services import LibraryService
from chitai.schemas.library import (
    CalibreArchiveUpload,
    CalibreImportRead,
    LibraryCreate,
    LibraryRead,
)
from chitai.services.calibre import CalibreLibraryError, extract_calibre_archive
from chitai.services.calibre_import import registry
from chitai.services.utils import DirectoryDoesNotExist


# How much of an uploaded archive is held in memory at a time on its way to disk.
UPLOAD_CHUNK_SIZE = 262144  # 256 KiB


class LibraryController(Controller):
    """Controller for managing library operations."""

    path = "/libraries"
    dependencies = create_service_dependencies(
        LibraryService,
        key="library_service",
        filters={"id_filter": int, "pagination_type": "limit_offset"},
    )

    @post()
    async def create_library(
        self, library_service: LibraryService, data: LibraryCreate
    ) -> LibraryRead:
        """
        Create a new library.

        Creates a new library entry in the system using the provided data. If the library is read-only
        the target root path must exist.

        Request Body:
            data: Data used to create the new library.

        Injected Dependencies:
            library_service: The service used to manage library creation and persistence.

        Returns:
            The created library.
        """
        try:
            library = await library_service.create(data)
            return library_service.to_schema(library, schema_type=LibraryRead)
        except DirectoryDoesNotExist as exc:
            raise HTTPException(status_code=400, detail=str(exc))

    @get()
    async def list_libraries(
        self,
        library_service: LibraryService,
        filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)],
    ) -> OffsetPagination[LibraryRead]:
        """
        List all libraries.

        Retrieves a paginated list of all libraries, optionally filtered by parameters.

        Query Parameters:
            filters: Filtering by ID and pagination parameters.

        Injected Dependencies:
            library_service: The service used to query and return library data.
        """
        results, total = await library_service.list_and_count(
            *filters, load=[m.Library.books]
        )
        return library_service.to_schema(
            results, total, filters, schema_type=LibraryRead
        )

    @post(
        path="{library_id:int}/imports/calibre/upload",
        status_code=HTTP_202_ACCEPTED,
        request_max_body_size=None,
    )
    async def upload_calibre_import(
        self,
        library_service: LibraryService,
        library_id: int,
        data: Annotated[
            CalibreArchiveUpload, Body(media_type=RequestEncodingType.MULTI_PART)
        ],
    ) -> CalibreImportRead:
        """
        Import a zipped Calibre library that was uploaded rather than named on disk.

        For the case where the library is not on the server: zip the Calibre folder and
        send it. Unpacked into a temp directory the job owns and deletes when it ends —
        by which time the books worth keeping have been copied into the library proper.

        The server-folder route stays the one for a very large library. This one has to
        carry the whole archive over HTTP first.

        Path Parameters:
            library_id: The library to import into.

        Request Body:
            data: The `.zip` holding the Calibre library.

        Returns:
            The job, already running.

        Raises:
            HTTPException: 400 if the archive is not a zip, holds no `metadata.db`,
                names an entry outside itself, or would not fit on disk; 409 if an
                import into this library is already running.
        """
        library = await self._importable(library_service, library_id)

        if (running := registry.running_for(library_id)) is not None:
            raise HTTPException(
                status_code=409,
                detail="An import into this library is already running",
                extra={"job_id": running.id},
            )

        workspace = Path(await asyncio.to_thread(tempfile.mkdtemp))

        try:
            archive = workspace / "upload.zip"
            await data.archive.seek(0)

            async with aiofiles.open(archive, "wb") as destination:
                while chunk := await data.archive.read(UPLOAD_CHUNK_SIZE):
                    await destination.write(chunk)

            unpacked = workspace / "library"
            unpacked.mkdir()

            catalogue = await extract_calibre_archive(archive, unpacked)

            # The archive itself is dead weight once unpacked, and the library it
            # unpacked to can be large.
            await aios.remove(archive)
        except CalibreLibraryError as exc:
            await asyncio.to_thread(shutil.rmtree, workspace, True)
            raise HTTPException(status_code=400, detail=str(exc))
        except Exception:
            await asyncio.to_thread(shutil.rmtree, workspace, True)
            raise

        job = registry.start(
            library,
            catalogue,
            workspace=workspace,
            label=data.archive.filename or "uploaded archive",
            allow_duplicates=data.allow_duplicates,
        )

        return CalibreImportRead.model_validate(job)

    @get(path="imports/{job_id:str}")
    async def get_import(self, job_id: str) -> CalibreImportRead:
        """
        Report on an import.

        Polled by the client while a run is going. Jobs are held in memory, so this is
        answered by the process that started it — see `services/calibre_import.py`.

        Path Parameters:
            job_id: The job to report on.

        Raises:
            HTTPException: 404 if this process holds no such job.
        """
        if (job := registry.get(job_id)) is None:
            raise HTTPException(status_code=404, detail="No such import")

        return CalibreImportRead.model_validate(job)

    @delete(path="imports/{job_id:str}", status_code=HTTP_200_OK)
    async def cancel_import(self, job_id: str) -> CalibreImportRead:
        """
        Ask an import to stop after the book it is on.

        Deliberately not an abort: a book abandoned mid-copy would leave files on disk
        with no row describing them. Whatever it has imported stays imported.

        Path Parameters:
            job_id: The job to stop.

        Raises:
            HTTPException: 404 if this process holds no such job.
        """
        if (job := registry.cancel(job_id)) is None:
            raise HTTPException(status_code=404, detail="No such import")

        return CalibreImportRead.model_validate(job)

    @staticmethod
    async def _importable(
        library_service: LibraryService, library_id: int
    ) -> m.Library:
        """
        The library, if it can be imported into at all.

        Raises:
            HTTPException: 404 if there is no such library, 400 if it is read-only —
                a read-only library points at a tree Chitai does not own, so copying
                books into it would write into somebody else's directory.
        """
        library = await library_service.get_one_or_none(m.Library.id == library_id)

        if library is None:
            raise HTTPException(status_code=404, detail="No such library")

        if library.read_only:
            raise HTTPException(
                status_code=400,
                detail="This library is read-only, so nothing can be imported into it",
            )

        return library
