# src/chitai/controllers/book.py

# TODO: Make the endpoints more consistent (i.e sometimes book_id is a path parameter, other times it a query param)

# Standard library
from typing import Annotated

# Third-party libraries
from litestar import Controller, get, post, put, patch, delete
from litestar.di import Provide
from litestar.params import Dependency, Body
from litestar.enums import RequestEncodingType
from litestar.response import File, Stream
from litestar.exceptions import HTTPException
from litestar.status_codes import (
    HTTP_200_OK,
    HTTP_204_NO_CONTENT,
    HTTP_400_BAD_REQUEST,
    HTTP_409_CONFLICT,
)
from litestar.datastructures import UploadFile
from advanced_alchemy.service.pagination import OffsetPagination
from advanced_alchemy.filters import CollectionFilter
from advanced_alchemy.service import FilterTypeT

# Local imports
from chitai.services import dependencies as deps
from chitai import schemas as s
from chitai.database import models as m
from chitai.services import BookService, BookProgressService
from chitai.services.book import DuplicateFilesError


def _duplicate_conflict(exc: DuplicateFilesError) -> HTTPException:
    """
    Turn refused files into a 409 the caller can act on.

    The files ride along in `extra` so the client can name them and offer to send them
    again with `allow_duplicates`, rather than being told only that something clashed.
    """
    return HTTPException(
        status_code=HTTP_409_CONFLICT,
        detail="These files are already in the library",
        extra=[
            s.DuplicateFileRead.model_validate(duplicate).model_dump(mode="json")
            for duplicate in exc.duplicates
        ],
    )


class BookController(Controller):
    """Controller for managing book-related operations."""

    path = "/books"
    dependencies = {
        "books_service": Provide(deps.provide_book_service),
        "progress_service": Provide(deps.provide_progress_service),
        "library_service": Provide(deps.provide_library_service),
        "library": Provide(deps.get_library_by_id),
        "authors_filter": Provide(deps.provide_authors_filter, sync_to_thread=False),
        "publishers_filter": Provide(
            deps.provide_publishers_filter, sync_to_thread=False
        ),
        "tags_filter": Provide(deps.provide_tags_filter, sync_to_thread=False),
        "bookshelves_filter": Provide(
            deps.provide_bookshelves_filter, sync_to_thread=False
        ),
        "progress_filter": Provide(deps.provide_progress_filter, sync_to_thread=False),
        "libraries_filter": Provide(
            deps.provide_libraries_filter, sync_to_thread=False
        ),
        "book_filters": Provide(deps.provide_book_filters, sync_to_thread=False),
    } | deps.create_book_filter_dependencies(
        {
            "id_filter": int,
            "pagination_type": "limit_offset",
            "sort_field": "title",
            "search": ["title"],
            "search_ignore_case": True,
        }
    )

    @post(request_max_body_size=None)
    async def create_book(
        self,
        books_service: BookService,
        library: m.Library,
        data: Annotated[s.BookCreate, Body(media_type=RequestEncodingType.MULTI_PART)],
        allow_duplicates: bool = False,
    ) -> s.BookRead:
        """
        Create a new book with metadata and files.

        Accepts book metadata and associated files, processes them through the
        book service, and returns the created book details.

        Path Parameters:
            library_id: The ID of the library the book belongs to.

        Query Parameters:
            allow_duplicates: If True, store the files even if the library already
                holds them.

        Request Body:
            data: Book creation data including metadata and files.

        Injected Dependencies:
            books_service: The book service for database operations.
            library: The library the book belongs to.

        Returns:
            The created book as a BookRead schema.

        Raises:
            HTTPException: 409 if any of the files is already in the library.
        """

        try:
            result = await books_service.create_book(
                data, library, screen_duplicates=not allow_duplicates
            )
        except DuplicateFilesError as exc:
            raise _duplicate_conflict(exc)

        book = await books_service.get(result.id)
        return books_service.to_schema(book, schema_type=s.BookRead)

    @post("fromFiles", request_max_body_size=None)
    async def create_books_without_metadata(
        self,
        books_service: BookService,
        library: m.Library,
        data: Annotated[
            s.BooksCreateFromFiles, Body(media_type=RequestEncodingType.MULTI_PART)
        ],
        allow_duplicates: bool = False,
    ) -> s.BooksUploadResult:
        """
        Create multiple books from uploaded files.

        Groups files by directory and creates separate books for each group.
        Metadata is automatically extracted from the files.

        Files the library already holds are skipped rather than refused, and reported
        back so the caller can say which ones did not make it in and why.

        Query Parameters:
            allow_duplicates: If True, store every file, even one already held.

        Request Body:
            data: Container with list of uploaded files.

        Injected Dependencies:
            books_service: The book service for database operations.
            library: The library the books belong to.

        Returns:
            The books created, and the files skipped as duplicates.
        """
        try:
            result = await books_service.create_many_from_files(
                data, library, allow_duplicates=allow_duplicates
            )
        except ValueError:
            raise HTTPException(
                status_code=HTTP_400_BAD_REQUEST, detail="Must upload at least one file"
            )

        books = (
            await books_service.list(
                CollectionFilter("id", [book.id for book in result.books])
            )
            if result.books
            else []
        )

        return s.BooksUploadResult(
            created=[
                books_service.to_schema(book, schema_type=s.BookRead) for book in books
            ],
            skipped=[
                s.DuplicateFileRead.model_validate(duplicate)
                for duplicate in result.duplicates
            ],
            possible_duplicates=[
                s.PossibleDuplicateRead.model_validate(possible)
                for possible in result.possible_duplicates
            ],
        )

    @get(path="duplicate-books")
    async def list_duplicate_books(
        self, books_service: BookService, library: m.Library
    ) -> list[s.DuplicateBookGroupRead]:
        """
        Report books already in the library that look like copies of one another.

        The import-time check only ever sees what is arriving, so this is what covers
        a collection someone already has. Matching is on metadata and therefore a
        guess: a group is a question for the reader, not a verdict.

        Query Parameters:
            library_id: The library to review.

        Injected Dependencies:
            books_service: The book service for database operations.
            library: The library to review.

        Returns:
            One entry per group of two or more books. Empty when there is nothing to
            review, or when duplicate detection is switched off.
        """
        groups = await books_service.find_duplicate_book_groups(library)

        return [
            s.DuplicateBookGroupRead(
                books=[s.DuplicateBookRead.model_validate(book) for book in group]
            )
            for group in groups
        ]

    @post(path="merge")
    async def merge_books(
        self, books_service: BookService, library: m.Library, data: s.BookMerge
    ) -> s.BookRead:
        """
        Fold several books into one and delete the records folded in.

        The survivor keeps its id, so links and bookmarks still resolve. Files, reading
        progress, shelves, tags and unheld identifiers move onto it; metadata is only
        changed by what `metadata` names, because choosing between two titles is the
        reader's judgement rather than this endpoint's.

        Nothing is removed from disk — a wrong merge should cost metadata that can be
        retyped, not a book.

        Query Parameters:
            library_id: The library the books belong to.

        Request Body:
            data: The survivor, the books to fold in, and the resolved metadata.

        Injected Dependencies:
            books_service: The book service for database operations.
            library: The library the books belong to.

        Returns:
            The surviving book.

        Raises:
            HTTPException: 400 if fewer than two distinct books were named, one is
                unknown, or they do not all belong to one library.
        """
        try:
            book = await books_service.merge_books(
                data.survivor_id,
                data.merged_ids,
                library,
                metadata=data.metadata.model_dump(exclude_unset=True)
                if data.metadata
                else None,
            )
        except ValueError as exc:
            raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=str(exc))

        return books_service.to_schema(book, schema_type=s.BookRead)

    @post(path="duplicate-books/dismissals", status_code=HTTP_204_NO_CONTENT)
    async def dismiss_duplicate_books(
        self, books_service: BookService, data: s.DuplicateDismissal
    ) -> None:
        """
        Record that two books are not the same book.

        Without this the review screen proposes the same wrong pair forever, which is
        how a reader learns to stop looking at it.

        Request Body:
            data: The two book IDs. Order does not matter.

        Injected Dependencies:
            books_service: The book service for database operations.

        Raises:
            HTTPException: 400 if the two IDs are the same or either book is unknown.
        """
        try:
            await books_service.dismiss_duplicates(data.book_a_id, data.book_b_id)
        except ValueError as exc:
            raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=str(exc))

    @delete(path="duplicate-books/dismissals")
    async def restore_duplicate_books(
        self, books_service: BookService, book_a_id: int, book_b_id: int
    ) -> None:
        """
        Undo a dismissal, so the pair is proposed again.

        Query Parameters:
            book_a_id: One of the two books.
            book_b_id: The other. Order does not matter.

        Injected Dependencies:
            books_service: The book service for database operations.
        """
        await books_service.restore_duplicates(book_a_id, book_b_id)

    # A question, not a change: 200 rather than the 201 a POST would default to.
    @post(path="duplicate-files", status_code=HTTP_200_OK)
    async def check_duplicate_files(
        self,
        books_service: BookService,
        library: m.Library,
        data: list[s.FileFingerprint],
    ) -> list[s.DuplicateFileRead]:
        """
        Report which of the given files the library already holds.

        Lets a client ask before it uploads anything, which is the difference between
        re-sending a folder of books and re-sending twelve kilobytes of hashes.

        Query Parameters:
            library_id: The library to check against.

        Request Body:
            data: Hash and size for each file, optionally with the name to echo back.

        Injected Dependencies:
            books_service: The book service for database operations.
            library: The library to check against.

        Returns:
            One entry per submitted file that is already stored. Files that are not
            are absent.
        """
        matches = await books_service.find_duplicate_files(
            ((item.hash, item.size) for item in data), library
        )

        return [
            s.DuplicateFileRead(
                filename=item.filename or match.filename,
                hash=match.hash,
                size=match.size,
                library_id=match.library_id,
                book_id=match.book_id,
                book_title=match.book_title,
            )
            for item in data
            if (match := matches.get((item.hash, item.size))) is not None
        ]

    @get(path="/{book_id:int}")
    async def get_book_by_id(
        self, book_id: int, books_service: BookService, current_user: m.User
    ) -> s.BookRead:
        """
        Retrieve book details by ID.

        Path Parameters:
            book_id: The ID of the book to retrieve.

        Injected Dependencies:
            books_service: The book service for database operations.
            current_user: The authenticated user making the request.

        Returns:
            The book details as a BookRead schema.
        """
        book = await books_service.get(book_id)
        return books_service.to_schema(book, schema_type=s.BookRead)

    @get()
    async def list_books(
        self,
        books_service: BookService,
        book_filters: Annotated[
            list[FilterTypeT], Dependency(skip_validation=True)
        ] = [],
        filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
    ) -> OffsetPagination[s.BookRead]:
        """
        List books with filtering, pagination, and search.

        Supports filtering by library, tags, authors, shelves, and publishers.

        Query Parameters:
            library_id: The library to list books from.
            tags: Optional list of tag IDs to filter by.
            authors: Optional list of author IDs to filter by.
            shelves: Optional list of bookshelf IDs to filter by.
            publishers: Optional list of publisher IDs to filter by.
            id: Injected filter. Filter by book ID.
            sort_field: Injected filter. Field to sort by (default: title).
            search: Injected filter. Search on title field.
            search_ignore_case: Injected filter. Case-insensitive search.

        Injected Dependencies:
            books_service: The book service for database operations.
            current_user: The authenticated user making the request.
            filters: Pre-constructed filters from dependencies.

        Returns:
            Paginated list of books matching the criteria.
        """

        books, total = await books_service.list_and_count(*filters, *book_filters)
        return books_service.to_schema(books, total, filters, schema_type=s.BookRead)

    @patch(path="{book_id:int}")
    async def update_book_metadata(
        self,
        book_id: int,
        data: Annotated[
            s.BookMetadataUpdate, Body(media_type=RequestEncodingType.JSON)
        ],
        books_service: BookService,
        library: m.Library,
    ) -> s.BookRead:
        """
        Update a book's metadata and optionally reorganize files if necessary.

        Allows editing book metadata such as title, author, and series information.
        If metadata changes affect the file organization path, files are
        automatically reorganized.

        Path Parameters:
            book_id: The ID of the book to update.

        Request Body:
            data: Updated metadata for the book.

        Injected Dependencies:
            books_service: The book service for database operations.
            library: The library containing the book.

        Returns:
            The updated book as a BookRead schema.
        """
        await books_service.update_book(book_id, data, library)
        book = await books_service.get(book_id)
        return books_service.to_schema(book, schema_type=s.BookRead)

    @put(path="{book_id:int}/cover")
    async def update_cover(
        self,
        book_id: int,
        data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)],
        books_service: BookService,
        library: m.Library,
    ) -> s.BookRead:
        """
        Update's a book's cover image.

        Allows uploading a new cover image that replaces the current cover.

        Path Parameters:
            book_id: The ID of the book to update.

        Request Body:
            cover_image: The new cover image for the book.

        Injected Dependencies:
            books_service: The book service for database operations.
            library: The library containing the book.

        Returns:
            The updated book as a BookRead schema.
        """

        await books_service.update_book(book_id, {"cover_image": data}, library)
        updated_book = await books_service.get(book_id)
        return books_service.to_schema(updated_book, schema_type=s.BookRead)

    @get(path="download/{book_id:int}/{file_id:int}")
    async def get_file(
        self, book_id: int, file_id: int, books_service: BookService
    ) -> File:
        """
        Download a single file from a book.

        Path Parameters:
            book_id: The ID of the book containing the file.
            file_id: The ID of the file to download.

        Injected Dependencies:
            books_service: The book service for database operations.

        Returns:
            The file ready for download.
        """
        return await books_service.get_file(book_id, file_id)

    @get(path="download")
    async def get_files(
        self, book_ids: list[int], library_id: int, books_service: BookService
    ) -> Stream:
        """
        Download multiple books as a single ZIP file.

        Compresses all files from specified books into a single archive for download.

        Query Parameters:
            book_ids: List of book IDs to include in the download.
            library_id: The library containing the books.

        Injected Dependencies:
            books_service: The book service for database operations.

        Returns:
            A stream of the compressed ZIP file.
        """
        return Stream(
            books_service.get_files(book_ids, library_id),
            headers={
                "Content-Disposition": 'attachment; filename="download.zip"',
                "Content-Type": "application/zip",
            },
        )

    @post(path="/{book_id:int}/files", request_max_body_size=None)
    async def add_book_files(
        self,
        book_id: int,
        data: Annotated[
            list[UploadFile], Body(media_type=RequestEncodingType.MULTI_PART)
        ],
        library: m.Library,
        books_service: BookService,
        allow_duplicates: bool = False,
    ) -> s.BookRead:
        """
        Add files to an existing book.

        A file the book already carries is ignored, so re-sending one is harmless.

        Path Parameters:
            book_id: The ID of the book to modify

        Query Parameters:
            allow_duplicates: If True, store the files even if the library already
                holds them.

        Request Body:
            files: The files to add to the book

        Injected Dependencies:
            library: The library containing the book.
            books_service: The book service for database operations.

        Returns:
            The modified book

        Raises:
            HTTPException: 409 if a file is already stored under a different book.
        """

        try:
            await books_service.add_files(
                book_id, data, library, allow_duplicates=allow_duplicates
            )
        except DuplicateFilesError as exc:
            raise _duplicate_conflict(exc)

        book = await books_service.get(book_id)
        return books_service.to_schema(book, schema_type=s.BookRead)

    @delete(path="{book_id:int}/files")
    async def delete_book_files(
        self,
        library: m.Library,
        books_service: BookService,
        book_id: int,
        file_ids: list[int],
        delete_files: bool = False,
    ) -> None:
        """
        Delete specific files from a book.

        Removes the specified files from both the database and optionally
        remove them from the filesystem.

        Path Parameters:
            book_id: The ID of the book.

        Query Parameters:
            file_ids: List of file IDs to delete.
            delete_files: If True, also delete files from the filesystem.

        Injected Dependencies:
            books_service: The book service for database operations.
            library: The library containing the book.
        """

        if len(file_ids) == 0:
            raise HTTPException(
                status_code=HTTP_400_BAD_REQUEST,
                detail="At least one file to delete is required",
            )

        await books_service.remove_files(book_id, file_ids, delete_files, library)

    @delete(path="/")
    async def delete_books(
        self,
        books_service: BookService,
        library: m.Library,
        book_ids: list[int],
        delete_files: bool = False,
    ) -> None:
        """
        Delete multiple books by ID.

        Removes books from the database and optionally deletes associated files
        from the filesystem.

        Query Parameters:
            book_ids: List of book IDs to delete.
            delete_files: If True, also delete associated files from the filesystem.
            library_id: The library the given books belong to.

        Injected Dependencies:
            books_service: The book service for database operations.
            library: The library containing the books.

        """

        await books_service.delete_books(book_ids, library, delete_files=delete_files)

    @post(path="/progress/{book_id:int}")
    async def update_progress(
        self,
        book_id: int,
        current_user: m.User,
        data: s.BookProgressCreate,
        progress_service: BookProgressService,
    ) -> None:
        """
        Update or create reading progress for a book.

        Records the user's progress on a book, creating or updating the progress entry.

        Path Parameters:
            book_id: The ID of the book.

        Request Body:
            data: Progress data to update.

        Injected Dependencies:
            current_user: The authenticated user.
            progress_service: The progress service for database operations.

        """
        progress = await progress_service.get_one_or_none(
            m.BookProgress.user_id == current_user.id, m.BookProgress.book_id == book_id
        )
        dict_data = data.model_dump() | {"user_id": current_user.id, "book_id": book_id}
        await progress_service.upsert(dict_data, progress.id if progress else None)

    @post(path="progress")
    async def set_book_progress_batch(
        self,
        book_ids: list[int],
        data: Annotated[s.BookProgressCreate, Body()],
        current_user: m.User,
        progress_service: BookProgressService,
    ) -> None:
        """
        Marks all the user progress on the books matching the IDs as completed.

        Query Parameters:
            book_ids: List of book IDs to mark as completed.

        Injected Dependencies:
            current_user: The authenticated user.
            progress_service: The progress service for database operations.
        """

        # TODO Optimize this by performing a batch query and batch upsert
        for book_id in book_ids:
            existing_progress = await progress_service.get_one_or_none(
                m.BookProgress.user_id == current_user.id,
                m.BookProgress.book_id == book_id,
            )

            progress_data = data.model_dump() | {
                "user_id": current_user.id,
                "book_id": book_id,
            }

            await progress_service.upsert(
                progress_data, existing_progress.id if existing_progress else None
            )
