# src/chitai/services/book.py

# Standard library
from __future__ import annotations
from collections import defaultdict

from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, field, replace
from io import BytesIO, RawIOBase
from itertools import combinations
from pathlib import Path
import uuid
import zipfile
from typing import TYPE_CHECKING, Any, AsyncIterator, TypeVar

# Third-party libraries
from advanced_alchemy.extensions.litestar import service
from advanced_alchemy.service import (
    SQLAlchemyAsyncRepositoryService,
    ModelDictT,
    is_dict,
    schema_dump,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.filters import CollectionFilter
from sqlalchemy import and_, delete, inspect, or_, select, tuple_, update
from sqlalchemy.orm import joinedload, selectinload
from litestar.response import File
from litestar.datastructures import UploadFile
import aiofiles
from aiofiles import os as aios
from PIL import Image

# Local imports
from chitai.config import DuplicateScope, settings
from chitai.database.models import (
    Book,
    Author,
    BookAuthorLink,
    BookListLink,
    BookProgress,
    BookTagLink,
    DuplicateDismissal,
    KosyncProgress,
    Tag,
    Publisher,
    BookSeries,
    FileMetadata,
    Identifier,
    Library,
)
from chitai.schemas.book import BooksCreateFromFiles
from chitai.services.calibre import CalibreBook, CalibreLibrary
from chitai.services.filesystem_library import BookPathGenerator
from chitai.services.matching import (
    normalize_author,
    normalize_identifier,
    normalize_title,
)
from chitai.services.metadata_extractor import Extractor as MetadataExtractor
from chitai.services.metadata_extractor import parse_identifier
from chitai.services.utils import (
    cleanup_empty_parent_directories,
    copy_file,
    delete_file,
    fingerprint_file,
    fingerprint_upload,
    guess_content_type,
    move_dir_contents,
    move_file,
    save_image,
    StreamingHasher,
)


# An incoming file, either uploaded or already sitting in the consume directory.
FileT = TypeVar("FileT", UploadFile, Path)

# A file's identity for duplicate detection: its hash and its byte size.
#
# The hash on its own is not proof. It is KOReader's partial MD5, which samples
# twelve 1 KiB windows, so two different files can produce the same one — EPUBs are
# especially prone at offset 0, where the `mimetype` entry and `META-INF/container.xml`
# are often byte-identical across everything a given tool produced. Pairing it with the
# size makes a false match require both.
Fingerprint = tuple[str, int]

# How much of a file is held in memory at a time while it is written to disk.
CHUNK_SIZE = 262144  # 256 KiB

# What Calibre's own `books.uuid` is stored as. Not `uuid`: that name is reserved for
# the per-build identifier an EPUB carries, which duplicate matching ignores.
CALIBRE_UUID = "calibre-uuid"


@dataclass(frozen=True)
class DuplicateFile:
    """
    An incoming file that was not stored because the library already holds its bytes.

    `book_id` and `book_title` are `None` when the match was another file in the same
    import rather than something already in the database — there is no row to name yet.
    """

    filename: str
    hash: str
    size: int
    library_id: int
    book_id: int | None = None
    book_title: str | None = None


# Why two books look like the same book. Both are reported when both apply.
MATCHED_ON_IDENTIFIER = "identifier"
MATCHED_ON_TITLE_AUTHOR = "title-author"


@dataclass(frozen=True)
class DuplicateBook:
    """
    A book already in the library that may be the same book as another one.

    Unlike `DuplicateFile` this is a guess, not a fact: the evidence is metadata two
    editions of one work legitimately share, and so do a work and its translation. It
    is reported, never acted on.
    """

    book_id: int
    title: str
    authors: list[str]
    library_id: int
    matched_on: list[str]
    cover_image: str | None = None


@dataclass(frozen=True)
class PossibleDuplicate:
    """A book that was imported, together with what it might be a second copy of."""

    book_id: int
    title: str
    candidates: list[DuplicateBook]


@dataclass
class ImportResult:
    """What an import produced: the books created, and the files left out of them."""

    books: list[Book] = field(default_factory=list)
    duplicates: list[DuplicateFile] = field(default_factory=list)

    # Books that were created — nothing here was refused — and look like something the
    # library already holds. The reader decides; see `find_duplicate_books`.
    possible_duplicates: list[PossibleDuplicate] = field(default_factory=list)


@dataclass(frozen=True)
class UnimportedBook:
    """A book in a source catalogue that did not become a Chitai book, and why."""

    calibre_id: int
    title: str
    reason: str

    # The book already holding its files, when that is why it was left out.
    book_id: int | None = None


@dataclass(frozen=True)
class CalibreImportProgress:
    """One book's outcome, as it happens.

    Reported per book rather than at the end: an import of a real library takes long
    enough that its progress is the only thing worth looking at while it runs.
    """

    processed: int
    total: int
    title: str
    outcome: str
    """`created`, `skipped` or `failed`."""

    detail: str | None = None


@dataclass
class CalibreImportResult:
    """What importing a Calibre library produced.

    Holds ids rather than `Book` objects: a real catalogue runs to thousands of books,
    and nothing downstream needs them all in memory at once.
    """

    total: int = 0
    created: list[int] = field(default_factory=list)
    skipped: list[UnimportedBook] = field(default_factory=list)
    failed: list[UnimportedBook] = field(default_factory=list)

    # Individual files left out of books that were otherwise imported.
    duplicate_files: list[DuplicateFile] = field(default_factory=list)

    possible_duplicates: list[PossibleDuplicate] = field(default_factory=list)

    # The run gave up before reaching the end of the catalogue, on request. What it did
    # get through is complete; `processed` is simply short of `total`.
    stopped: bool = False


class DuplicateFilesError(Exception):
    """Raised when an import would store files that are already in the library."""

    def __init__(self, duplicates: list[DuplicateFile]) -> None:
        self.duplicates = duplicates
        super().__init__(f"{len(duplicates)} file(s) are already in the library")


def _submitted_name(file: UploadFile | Path) -> str:
    """The name an incoming file is tracked under while it is being screened."""
    return file.filename if isinstance(file, UploadFile) else file.name


def _unused_path(path: Path) -> Path:
    """
    The given path, or the first free `name (n).ext` beside it.

    Filenames are generated from metadata, so two books that share an author and a
    title produce the same one. Opening it for writing would replace the bytes another
    `FileMetadata` row describes, leaving that row quietly lying about its own file.

    Args:
        path: Where the file would go.

    Returns:
        A path that nothing occupies.
    """
    candidate = path
    suffix = 1

    while candidate.exists():
        suffix += 1
        candidate = path.with_name(f"{path.stem} ({suffix}){path.suffix}")

    return candidate


def _title_keys(title: str, authors: set[str]) -> set[str]:
    """
    Every normalized title one book could reasonably be filed under.

    Ebook files are overwhelmingly named `Title - Author.epub`, and wherever nothing
    inside the file overrode that name, the author ended up in the title column. So
    one copy of a book is stored as "Building Microservices" and another as
    "Building Microservices Sam Newman", and comparing titles alone never puts the
    two together — which is most of what this feature is for.

    Both directions are generated, the author stripped off and the author added on,
    so the comparison works whichever form each copy happens to hold. This stays an
    exact match on an indexed column: no similarity score, nothing to tune.

    Args:
        title: The book's normalized title.
        authors: Its normalized author keys.

    Returns:
        The title keys, or an empty set when there is no title to match on.
    """
    if not title:
        return set()

    keys = {title}

    for author in authors:
        if title.endswith(f" {author}"):
            # A title that is only its author's name is not a title once stripped.
            if stripped := title[: -len(author)].strip():
                keys.add(stripped)
        else:
            keys.add(f"{title} {author}")

    return keys


def _matching_keys(data: Any) -> tuple[set[str], set[str], set[str]]:
    """
    The keys a book is compared on: its title, its authors, and its identifiers.

    Accepts a `Book` — whose keys the model validators have already computed — or the
    metadata dict an import is holding before any row exists.

    Args:
        data: A `Book`, or a dict with `title`, `authors` and `identifiers`.

    Returns:
        The set of title keys (empty if there is nothing to match on), the set of
        author keys, and the set of identifier keys.
    """
    if isinstance(data, Book):
        authors = {
            author.normalized_name for author in data.authors if author.normalized_name
        }
        return (
            _title_keys(data.normalized_title, authors),
            authors,
            {
                identifier.normalized_value
                for identifier in data.identifiers
                if identifier.normalized_value
            },
        )

    authors = set()
    for author in data.get("authors") or []:
        key = (
            author.normalized_name
            if isinstance(author, Author)
            else normalize_author(author)
        )
        if key:
            authors.add(key)

    # Identifiers arrive as a `{name: value}` dict from the API and the extractors, and
    # as `Identifier` rows once `_preprocess_book_data` has been over them.
    raw = data.get("identifiers") or {}
    pairs = (
        raw.items()
        if isinstance(raw, dict)
        else ((identifier.name, identifier.value) for identifier in raw)
    )

    identifiers = {
        key for name, value in pairs if (key := normalize_identifier(name, value))
    }

    title = normalize_title(data.get("title"))

    return _title_keys(title, authors), authors, identifiers


def _series_position(data: Any) -> tuple[str, str | None]:
    """The series a book belongs to and where it sits in it, both normalized."""
    if isinstance(data, Book):
        series = data.series.title if data.series else None
        position = data.series_position
    else:
        series = data.get("series")
        series = series.title if isinstance(series, BookSeries) else series
        position = data.get("series_position")

    return normalize_title(series), (position or "").strip() or None


def _is_different_volume(
    left: tuple[str, str | None], right: tuple[str, str | None]
) -> bool:
    """
    Whether two books are numbered entries of one series, and not the same entry.

    A trilogy shares an author, and often most of its title. Book two is emphatically
    not a second copy of book one, and the position is the library saying so outright.

    Args:
        left: One book's `(series key, position)`, from `_series_position`.
        right: The other book's.

    Returns:
        True when both name the same series at different positions.
    """
    (left_series, left_position), (right_series, right_position) = left, right

    if not left_series or left_series != right_series:
        return False

    if left_position is None or right_position is None:
        return False

    try:
        return float(left_position) != float(right_position)
    except ValueError:
        return left_position.casefold() != right_position.casefold()


class _DisjointSet:
    """
    Union-find over book ids.

    Books are bucketed by each key they carry, so one book reaches a group through its
    ISBN and another through its title. Merging the buckets is what puts A, B and C in
    one group when A matches B on an ISBN and B matches C on its title.
    """

    def __init__(self) -> None:
        self._parent: dict[int, int] = {}

    def find(self, item: int) -> int:
        root = self._parent.setdefault(item, item)

        while root != self._parent[root]:
            root = self._parent[root]

        while item != root:  # Flatten the path behind us.
            self._parent[item], item = root, self._parent[item]

        return root

    def union(self, first: int, second: int) -> None:
        left, right = self.find(first), self.find(second)
        if left != right:
            self._parent[right] = left

    def groups(self) -> list[list[int]]:
        """Every set of two or more members, in a stable order."""
        grouped: dict[int, list[int]] = defaultdict(list)
        for item in sorted(self._parent):
            grouped[self.find(item)].append(item)

        return sorted(
            (members for members in grouped.values() if len(members) > 1),
            key=lambda members: members[0],
        )


class _ZipStream(RawIOBase):
    """
    A write-only sink that hands whatever `ZipFile` writes back to the caller.

    `ZipFile` wants a file object, but a download handler wants chunks it can yield.
    This collects the bytes `ZipFile` produces so the generator driving it can drain
    them as they appear, instead of building the whole archive first. Reporting
    itself as unseekable makes `ZipFile` emit data descriptors rather than seeking
    back to patch entry headers.
    """

    def __init__(self) -> None:
        self._chunks: list[bytes] = []
        self._position = 0

    def writable(self) -> bool:
        return True

    def seekable(self) -> bool:
        return False

    def tell(self) -> int:
        return self._position

    def write(self, data: Any) -> int:
        chunk = bytes(data)
        self._chunks.append(chunk)
        self._position += len(chunk)
        return len(chunk)

    def drain(self) -> bytes:
        """Take everything written since the last drain."""
        data = b"".join(self._chunks)
        self._chunks.clear()
        return data


class BookService(SQLAlchemyAsyncRepositoryService[Book]):
    """Book service for managing book operations."""

    class Repo(SQLAlchemyAsyncRepository[Book]):
        """Book repository."""

        model_type = Book

    repository_type = Repo

    async def create_book(
        self,
        data: ModelDictT[Book],
        library: Library,
        *,
        screen_duplicates: bool = True,
        fingerprints: dict[str, Fingerprint] | None = None,
        **kwargs,
    ) -> Book:
        """
        Create a new book entity.

        Orchestrates the full book creation pipeline: extracting metadata from files,
        saving cover images, and storing book files to the filesystem.

        Args:
            data: Book data as a dictionary or model.
            library: The library the book belongs to.
            screen_duplicates: Refuse the book if any of its files is already stored.
            fingerprints: Pre-computed `(hash, size)` per submitted filename, so files
                that have already been screened are not read a second time.
            **kwargs: Additional keyword arguments passed to parent create.

        Returns:
            The created Book entity.

        Raises:
            DuplicateFilesError: If any file is already stored. Creating one book is a
                deliberate act naming specific files, so this is all-or-nothing:
                dropping one of them silently would be worse than refusing the lot.
        """
        data = schema_dump(data)

        files = data.get("files") or []
        if screen_duplicates and files:
            fingerprints = await self._fingerprint_uploads(files)
            known = await self.find_duplicate_files(fingerprints.values(), library)
            _, rejected = self._screen_for_duplicates(
                files, fingerprints, known, library
            )

            if rejected:
                raise DuplicateFilesError([duplicate for _, duplicate in rejected])

        await self._parse_metadata_from_files(data)

        await self._save_cover_image(data)
        await self._save_book_files(library, data, fingerprints)

        return await super().create(data, **kwargs)

    async def find_duplicate_files(
        self, fingerprints: Iterable[Fingerprint], library: Library
    ) -> dict[Fingerprint, DuplicateFile]:
        """
        Look up which of the given fingerprints are already stored.

        A row only counts while its bytes are still on disk. The hash lives in the
        database and the file does not, so a file removed behind the app's back would
        otherwise go on refusing its own replacement — telling the reader the library
        holds something it cannot open.

        Args:
            fingerprints: The `(hash, size)` pairs to look for.
            library: The library being imported into. The search is scoped to it
                unless `duplicate_scope` widens or disables it.

        Returns:
            A mapping from each matched fingerprint to the book already holding it.
            Fingerprints with no match are absent.
        """
        wanted = set(fingerprints)

        if not wanted or settings.duplicate_scope == DuplicateScope.OFF:
            return {}

        statement = (
            select(
                FileMetadata.hash,
                FileMetadata.size,
                FileMetadata.path,
                Book.id,
                Book.title,
                Book.library_id,
                Book.path,
            )
            .join(Book, FileMetadata.book_id == Book.id)
            .where(tuple_(FileMetadata.hash, FileMetadata.size).in_(wanted))
            # Databases predating duplicate detection can hold the same file twice;
            # order so that the book reported for it is at least a stable one.
            .order_by(Book.id)
        )

        if settings.duplicate_scope == DuplicateScope.LIBRARY:
            statement = statement.where(Book.library_id == library.id)

        rows = await self.repository.session.execute(statement)

        matches: dict[Fingerprint, DuplicateFile] = {}
        for file_hash, size, path, book_id, title, library_id, book_path in rows:
            if (file_hash, size) in matches:
                continue

            if not book_path or not await aios.path.isfile(Path(book_path) / path):
                continue

            matches[(file_hash, size)] = DuplicateFile(
                filename=Path(path).name,
                hash=file_hash,
                size=size,
                library_id=library_id,
                book_id=book_id,
                book_title=title,
            )

        return matches

    # Everything a candidate has to be able to answer for: what it matched on, and
    # enough about itself to be shown to a reader.
    _MATCH_LOADS = (
        selectinload(Book.identifiers),
        selectinload(Book.author_links).joinedload(BookAuthorLink.author),
        joinedload(Book.series),
    )

    async def find_duplicate_books(
        self,
        data: ModelDictT[Book] | dict[str, Any],
        library: Library,
        exclude_book_id: int | None = None,
    ) -> list[DuplicateBook]:
        """
        Look for books the library already holds that may be the same book as this one.

        Two signals, either of which is enough: a shared identifier, or the same
        normalized title credited to at least one of the same authors. Both are things
        a second edition, a re-scan and a re-zipped EPUB carry when their bytes have
        nothing in common, which is exactly the case file-level dedupe cannot see.

        Nothing here refuses anything. The evidence is metadata a work shares with its
        own translation and with its own second edition, so a wrong answer would cost a
        reader a book they meant to keep.

        Args:
            data: The incoming book, as a `Book` or as the metadata dict an import is
                holding before the row exists.
            library: The library being imported into. The search is scoped to it unless
                `duplicate_scope` widens or disables it.
            exclude_book_id: A book to leave out — its own row, once it has one.

        Returns:
            One entry per candidate, in id order, saying what it matched on. Empty when
            the incoming book carries nothing to match on: no identifier, and either no
            title or no authors.
        """
        if settings.duplicate_scope == DuplicateScope.OFF:
            return []

        titles, authors, identifiers = _matching_keys(data)

        conditions = []
        if identifiers:
            conditions.append(
                Book.identifiers.any(Identifier.normalized_value.in_(identifiers))
            )

        # A shared author is required for a title match, never optional: without it
        # every book the extractors gave up on and called "Unknown" is a duplicate of
        # every other one.
        if titles and authors:
            conditions.append(
                and_(
                    Book.normalized_title.in_(titles),
                    Book.authors.any(Author.normalized_name.in_(authors)),
                )
            )

        if not conditions:
            return []

        statement = (
            select(Book)
            .where(or_(*conditions))
            .options(*self._MATCH_LOADS)
            .order_by(Book.id)
        )

        if exclude_book_id is not None:
            statement = statement.where(Book.id != exclude_book_id)

        if settings.duplicate_scope == DuplicateScope.LIBRARY:
            statement = statement.where(Book.library_id == library.id)

        candidates = (
            (await self.repository.session.execute(statement)).unique().scalars().all()
        )

        series = _series_position(data)
        matches = []

        for candidate in candidates:
            if _is_different_volume(series, _series_position(candidate)):
                continue

            (
                candidate_titles,
                candidate_authors,
                candidate_identifiers,
            ) = _matching_keys(candidate)

            matched_on = []
            if identifiers & candidate_identifiers:
                matched_on.append(MATCHED_ON_IDENTIFIER)
            if titles & candidate_titles and authors & candidate_authors:
                matched_on.append(MATCHED_ON_TITLE_AUTHOR)

            if matched_on:
                matches.append(self._describe_candidate(candidate, matched_on))

        return matches

    async def merge_books(
        self,
        survivor_id: int,
        merged_ids: list[int],
        library: Library,
        metadata: ModelDictT[Book] | dict[str, Any] | None = None,
    ) -> Book:
        """
        Fold several books into one, and delete the records that were folded in.

        The survivor keeps its id, so every link, bookmark and shelf entry pointing at
        it still resolves. Everything the others carried moves onto it: their files,
        their reading progress where it is further along, their shelves, tags and any
        identifier under a name the survivor lacks.

        **Metadata is not merged automatically.** The survivor's own columns are kept
        unless `metadata` says otherwise, because guessing which of two titles is the
        better one is exactly the judgement the caller is making. The review screen
        sends what the reader chose; a scripted merge that sends nothing gets the
        survivor's metadata verbatim, which is at least predictable.

        Nothing is deleted from disk. Files move into the survivor's directory and the
        emptied directories are pruned, but no bytes and no cover are removed — there
        is no undo, so a wrong merge should cost metadata that can be retyped rather
        than a book that cannot be got back.

        Args:
            survivor_id: The book to keep.
            merged_ids: The books to fold into it and then delete.
            library: The library they belong to.
            metadata: Field values to write onto the survivor, as the reader resolved
                them.

        Returns:
            The surviving book.

        Raises:
            ValueError: If fewer than two distinct books were named, one of them does
                not exist, or they do not all belong to one library.
        """
        merged_ids = [
            book_id for book_id in dict.fromkeys(merged_ids) if book_id != survivor_id
        ]

        if not merged_ids:
            raise ValueError("A merge needs at least two different books")

        books = await self.list(Book.id.in_([survivor_id, *merged_ids]))
        by_id = {book.id: book for book in books}

        if len(by_id) != len(merged_ids) + 1:
            raise ValueError("No such book")

        # Refused rather than handled: a cross-library merge moves files between two
        # configured root paths, which is a surprise nobody asked this endpoint for.
        if {book.library_id for book in books} != {library.id}:
            raise ValueError("Every book in a merge must belong to the same library")

        survivor = by_id[survivor_id]
        losers = [by_id[book_id] for book_id in merged_ids]

        await self._absorb_files(survivor, losers, library)
        await self._absorb_rows(survivor_id, merged_ids)

        # Read before the rows go: expiring the session afterwards would send an
        # attribute access looking for a book that no longer exists.
        survivor_path = survivor.path
        emptied = [loser.path for loser in losers if loser.path]

        session = self.repository.session
        await session.flush()

        # The losers' rows are gone from here on; every child table either moved above
        # or is removed by the FK cascade.
        await self._sync(delete(Book).where(Book.id.in_(merged_ids)))

        if metadata:
            # Refreshed first, and explicitly. `synchronize_session="fetch"` keeps an
            # object's own columns honest but says nothing about a *collection* that
            # gained a row, so the survivor still believes it holds the tags and
            # identifiers it started with. `update_book` reconciles those collections,
            # and against a stale one it builds a second link row that collides with
            # the one just moved onto it.
            await session.refresh(
                survivor,
                ["files", "author_links", "tag_links", "identifiers", "list_links"],
            )
            await self.update_book(survivor_id, metadata, library)

        for path in emptied:
            if path != survivor_path:
                cleanup_empty_parent_directories(Path(path), Path(library.root_path))

        # Re-read rather than trust the identity map: the survivor's collections were
        # repointed by statements the ORM did not run through its own bookkeeping.
        return await self.get(
            survivor_id, execution_options={"populate_existing": True}
        )

    async def _sync(self, statement):
        """
        Run a bulk update or delete and keep the session's loaded objects honest.

        `synchronize_session="fetch"` is what lets these statements run at all here:
        without it the identity map goes on serving the values the rows held before,
        and every caller still holding a book gets stale answers. The blunt
        alternative, expiring the whole session, punishes callers that did nothing
        wrong by making their next attribute access do IO.
        """
        return await self.repository.session.execute(
            statement.execution_options(synchronize_session="fetch")
        )

    async def _absorb_files(
        self, survivor: Book, losers: list[Book], library: Library
    ) -> None:
        """
        Move the merged books' files into the survivor's directory.

        `FileMetadata.path` is a bare filename resolved against `book.path`, so moving
        the row without moving the bytes leaves it describing a file that is not there.

        A row whose file has already gone missing is repointed anyway rather than
        dropped: it is the only remaining record that the book had that format, and
        `add_files` knows how to put the bytes back into a row that has lost them.
        """
        if survivor.path is None:
            path_gen = BookPathGenerator(library.root_path)
            survivor.path = str(
                await self._reserve_book_path(
                    path_gen.generate_path(survivor.to_dict()), survivor.id
                )
            )

        destination = Path(survivor.path)
        destination.mkdir(parents=True, exist_ok=True)

        for loser in losers:
            for file in loser.files:
                if loser.path:
                    source = Path(loser.path) / file.path
                    if await aios.path.isfile(source):
                        target = _unused_path(destination / Path(file.path).name)
                        await move_file(source, target)
                        file.path = target.name

                file.book_id = survivor.id

    async def _absorb_rows(self, survivor_id: int, merged_ids: list[int]) -> None:
        """
        Repoint everything hanging off the merged books onto the survivor.

        Core statements rather than the ORM: `Book.files` and the link collections are
        `delete-orphan`, so reassigning them through loaded objects invites SQLAlchemy
        to delete the very rows being moved. Whatever is not moved here is removed by
        the `ondelete="cascade"` on its foreign key when the book row goes.

        Args:
            survivor_id: The book everything is moving onto.
            merged_ids: The books being emptied.
        """
        session = self.repository.session

        # Reading progress is per user, and the furthest one is the true answer for a
        # reader who has been through the EPUB and not the PDF.
        rows = (
            (
                await session.execute(
                    select(BookProgress)
                    .where(BookProgress.book_id.in_([survivor_id, *merged_ids]))
                    .order_by(BookProgress.percentage.desc())
                )
            )
            .scalars()
            .all()
        )

        furthest: dict[int, BookProgress] = {}
        for progress in rows:
            furthest.setdefault(progress.user_id, progress)

        # Every id, not just the merged ones: the row being beaten is often the
        # survivor's own, and leaving it behind gives one user two progress rows.
        keep = {progress.id for progress in furthest.values()}
        await self._sync(
            delete(BookProgress).where(
                BookProgress.book_id.in_([survivor_id, *merged_ids]),
                BookProgress.id.notin_(keep),
            )
        )
        await self._sync(
            update(BookProgress)
            .where(BookProgress.id.in_(keep), BookProgress.book_id.in_(merged_ids))
            .values(book_id=survivor_id)
        )

        # Keyed by the KOReader document hash, which follows the file, so a device
        # carries on syncing without noticing anything happened.
        await self._sync(
            update(KosyncProgress)
            .where(KosyncProgress.book_id.in_(merged_ids))
            .values(book_id=survivor_id)
        )

        # Collections the survivor may already be in. The unique constraint on each
        # would refuse a second link, so drop those before repointing the rest.
        for model, column in (
            (BookListLink, BookListLink.list_id),
            (BookTagLink, BookTagLink.tag_id),
        ):
            held = select(column).where(model.book_id == survivor_id)
            await self._sync(
                delete(model).where(model.book_id.in_(merged_ids), column.in_(held))
            )
            await self._sync(
                update(model)
                .where(model.book_id.in_(merged_ids))
                .values(book_id=survivor_id)
            )

        # Identifiers are unique per name, so only names the survivor lacks can move,
        # and only one of them however many books offered it.
        held_names = select(Identifier.name).where(Identifier.book_id == survivor_id)
        incoming = (
            (
                await session.execute(
                    select(Identifier)
                    .where(
                        Identifier.book_id.in_(merged_ids),
                        Identifier.name.notin_(held_names),
                    )
                    .order_by(Identifier.book_id, Identifier.id)
                )
            )
            .scalars()
            .all()
        )

        taken: set[str] = set()
        for identifier in incoming:
            if identifier.name in taken:
                continue
            taken.add(identifier.name)
            identifier.book_id = survivor_id

        await self._absorb_dismissals(survivor_id, merged_ids)

    async def _absorb_dismissals(self, survivor_id: int, merged_ids: list[int]) -> None:
        """
        Carry over "not a duplicate" verdicts, without inventing new ones.

        A pair between two books being merged into each other stops meaning anything
        and is dropped; a pair with some third book still holds, and would otherwise be
        forgotten the moment its book was deleted.
        """
        session = self.repository.session
        merged = set(merged_ids)

        rows = (
            (
                await session.execute(
                    select(DuplicateDismissal).where(
                        or_(
                            DuplicateDismissal.book_a_id.in_(merged_ids),
                            DuplicateDismissal.book_b_id.in_(merged_ids),
                        )
                    )
                )
            )
            .scalars()
            .all()
        )

        existing = await self._dismissed_pairs()
        doomed: list[int] = []

        for row in rows:
            first = survivor_id if row.book_a_id in merged else row.book_a_id
            second = survivor_id if row.book_b_id in merged else row.book_b_id

            pair = DuplicateDismissal.pair(first, second)

            if pair[0] == pair[1] or pair in existing:
                doomed.append(row.id)
                continue

            existing.add(pair)
            row.book_a_id, row.book_b_id = pair

        if doomed:
            await self._sync(
                delete(DuplicateDismissal).where(DuplicateDismissal.id.in_(doomed))
            )

    async def _load_for_matching(self, book_id: int) -> Book:
        """
        Fetch a book with everything the comparison reads already loaded.

        A book handed back by `create` carries its relationships only as far as the
        caller happened to populate them, and touching an unloaded one from async code
        raises rather than lazy-loading. Asking for them outright is the whole fix.
        """
        statement = select(Book).where(Book.id == book_id).options(*self._MATCH_LOADS)

        return (await self.repository.session.execute(statement)).unique().scalar_one()

    async def find_duplicate_book_groups(
        self, library: Library
    ) -> list[list[DuplicateBook]]:
        """
        Group everything already stored that looks like more than one copy of one book.

        `find_duplicate_books` only ever runs at import, so it says nothing about the
        library someone already has. This is the pass over it, and the reason the
        feature is worth having at all for an existing collection.

        Books are bucketed by each key they carry and the buckets are then merged, so a
        group holds everything transitively connected: A and B sharing an ISBN, B and C
        sharing a title and an author, all three in one group. Pairs a reader has
        dismissed are never merged, so disagreeing with one pairing does not silently
        break a group that stands on other evidence.

        Args:
            library: The library to review. Scoped by `duplicate_scope`, exactly as the
                import-time check is.

        Returns:
            Groups of two or more, in a stable order. Empty when detection is off.
        """
        if settings.duplicate_scope == DuplicateScope.OFF:
            return []

        statement = select(Book).options(*self._MATCH_LOADS).order_by(Book.id)

        if settings.duplicate_scope == DuplicateScope.LIBRARY:
            statement = statement.where(Book.library_id == library.id)

        books = {
            book.id: book
            for book in (await self.repository.session.execute(statement))
            .unique()
            .scalars()
            .all()
        }

        buckets: dict[tuple[str, str], list[int]] = defaultdict(list)
        for book_id, book in books.items():
            titles, authors, identifiers = _matching_keys(book)

            for identifier in identifiers:
                buckets[(MATCHED_ON_IDENTIFIER, identifier)].append(book_id)

            # One bucket per (title key, author) pair. A book sits in several, so two
            # copies meet as long as they agree on any one of them.
            for title in titles:
                for author in authors:
                    buckets[(MATCHED_ON_TITLE_AUTHOR, f"{title}\x00{author}")].append(
                        book_id
                    )

        dismissed = await self._dismissed_pairs()
        series = {book_id: _series_position(book) for book_id, book in books.items()}

        pairings = _DisjointSet()
        matched_on: dict[int, set[str]] = defaultdict(set)

        for (reason, _), members in buckets.items():
            # Pairwise rather than wholesale: a dismissal and the series check both
            # speak about two specific books, not about the bucket they landed in.
            for left, right in combinations(members, 2):
                if DuplicateDismissal.pair(left, right) in dismissed:
                    continue
                if _is_different_volume(series[left], series[right]):
                    continue

                pairings.union(left, right)
                matched_on[left].add(reason)
                matched_on[right].add(reason)

        return [
            [
                self._describe_candidate(books[book_id], sorted(matched_on[book_id]))
                for book_id in group
            ]
            for group in pairings.groups()
        ]

    async def dismiss_duplicates(self, book_a_id: int, book_b_id: int) -> None:
        """
        Record that two books are not the same book, and stop proposing them.

        Args:
            book_a_id: One of the books.
            book_b_id: The other. Order does not matter; the pair is stored ordered.

        Raises:
            ValueError: If the two ids are the same, or either book does not exist.
        """
        pair = DuplicateDismissal.pair(book_a_id, book_b_id)

        if pair[0] == pair[1]:
            raise ValueError("A book cannot be dismissed against itself")

        found = (
            (
                await self.repository.session.execute(
                    select(Book.id).where(Book.id.in_(pair))
                )
            )
            .scalars()
            .all()
        )

        if len(set(found)) != 2:
            raise ValueError("No such book")

        if pair in await self._dismissed_pairs():
            return

        self.repository.session.add(
            DuplicateDismissal(book_a_id=pair[0], book_b_id=pair[1])
        )
        await self.repository.session.flush()

    async def restore_duplicates(self, book_a_id: int, book_b_id: int) -> None:
        """
        Undo a dismissal, so the pair is proposed again.

        Args:
            book_a_id: One of the books.
            book_b_id: The other.
        """
        book_a_id, book_b_id = DuplicateDismissal.pair(book_a_id, book_b_id)

        await self.repository.session.execute(
            delete(DuplicateDismissal).where(
                DuplicateDismissal.book_a_id == book_a_id,
                DuplicateDismissal.book_b_id == book_b_id,
            )
        )

    async def _dismissed_pairs(self) -> set[tuple[int, int]]:
        """Every pair a reader has said is not a duplicate."""
        rows = await self.repository.session.execute(
            select(DuplicateDismissal.book_a_id, DuplicateDismissal.book_b_id)
        )

        return set(rows.all())

    @staticmethod
    def _describe_candidate(book: Book, matched_on: list[str]) -> DuplicateBook:
        """Enough of a book to show a reader who has to decide whether it is the same."""
        return DuplicateBook(
            book_id=book.id,
            title=book.title,
            authors=[author.name for author in book.authors],
            library_id=book.library_id,
            matched_on=matched_on,
            cover_image=book.cover_image,
        )

    async def _reserve_book_path(
        self, parent: Path, book_id: int | None = None
    ) -> Path:
        """
        Find a directory for a book that no other book is already using.

        The path is generated from metadata alone, so two books that share an author
        and title land on the same one — an `allow_duplicates` copy, or simply two
        editions. Letting them share it is not a cosmetic problem: `book.path` is what
        deletes, moves and file lookups act on, so one book's files overwrite the
        other's and deleting either takes both.

        Args:
            parent: The path the template generated.
            book_id: The book being placed, if it already exists. Its own directory is
                not a collision with itself.

        Returns:
            `parent`, or the first free `parent (n)` beside it.
        """
        candidate = parent
        suffix = 1

        while await self._path_belongs_to_another_book(candidate, book_id):
            suffix += 1
            candidate = parent.with_name(f"{parent.name} ({suffix})")

        return candidate

    async def _path_belongs_to_another_book(
        self, path: Path, book_id: int | None
    ) -> bool:
        """Whether a book other than `book_id` already claims this directory."""
        statement = select(Book.id).where(Book.path == str(path)).limit(1)

        if book_id is not None:
            statement = statement.where(Book.id != book_id)

        return (await self.repository.session.execute(statement)).first() is not None

    async def _fingerprint_uploads(
        self, files: Sequence[UploadFile]
    ) -> dict[str, Fingerprint]:
        """
        Fingerprint uploaded files, keyed by the name each was submitted under.

        Names carry the browser's relative path, so they are unique within a request
        the same way the files themselves are — two entries under one name would
        already be overwriting each other on disk.
        """
        return {file.filename: await fingerprint_upload(file) for file in files}

    def _screen_for_duplicates(
        self,
        files: Sequence[FileT],
        fingerprints: dict[str, Fingerprint],
        known: dict[Fingerprint, DuplicateFile],
        library: Library,
    ) -> tuple[list[FileT], list[tuple[FileT, DuplicateFile]]]:
        """
        Split incoming files into the ones to store and the ones already held.

        `known` is extended as it goes: an accepted file is recorded, so a second copy
        of it later in the same import is refused too. The database cannot answer for
        rows that do not exist yet.

        Args:
            files: The incoming files, in the order they should be considered.
            fingerprints: `(hash, size)` per submitted name, covering every file.
            known: Fingerprints already accounted for, from `find_duplicate_files`.
            library: The library being imported into.

        Returns:
            The files to store, and the refused ones paired with a record of where
            their bytes already live.
        """
        if settings.duplicate_scope == DuplicateScope.OFF:
            return list(files), []

        accepted: list[FileT] = []
        rejected: list[tuple[FileT, DuplicateFile]] = []

        for file in files:
            name = _submitted_name(file)
            fingerprint = fingerprints[name]

            if (match := known.get(fingerprint)) is not None:
                rejected.append((file, replace(match, filename=name)))
                continue

            accepted.append(file)
            known[fingerprint] = DuplicateFile(
                filename=name,
                hash=fingerprint[0],
                size=fingerprint[1],
                library_id=library.id,
            )

        return accepted, rejected

    async def create_many_from_files(
        self,
        data: BooksCreateFromFiles,
        library: Library,
        allow_duplicates: bool = False,
        **kwargs,
    ) -> ImportResult:
        """
        Create multiple books from uploaded files.

        Groups files by their parent directory to organize books. Files in the root
        directory are treated as separate individual books.

        Files the library already holds are skipped rather than refused: re-dropping a
        folder to pick up the few books that are new to it is the thing this endpoint
        exists for, so one already-stored file must not cost the rest of the import.

        Args:
            data: Container with list of uploaded files.
            library: The library the books belong to.
            allow_duplicates: Store every file, even one already held.
            **kwargs: Additional keyword arguments passed to create.

        Returns:
            The books created, and the files skipped along the way.
        """

        if not data.files:
            raise ValueError("Must upload at least one file")

        # Group the files that make up a single book.
        #
        # `file.filename` carries the browser's webkitRelativePath, whose first
        # component is whichever folder the user picked. Depth is therefore relative
        # to the selection, not to the library: picking a book's own folder and
        # picking the shelf above it submit the same files at different depths.
        #
        # Inside a subdirectory the parent directory is unambiguously the book. Sitting
        # directly in the selected folder it is genuinely ambiguous — "Fluent Python.epub"
        # alongside "Fluent Python.pdf" is one book in two formats, while "a.epub"
        # alongside "b.epub" is two books — so the filename stem breaks the tie.
        books: dict[tuple[Path, str], list[UploadFile]] = defaultdict(list)
        for file in data.files:
            filepath = Path(file.filename)
            if len(filepath.parent.parts) > 1:
                books[(filepath.parent, "")].append(file)
            else:
                books[(filepath.parent, filepath.stem)].append(file)

        # Hashed once for the whole request. Screening has to happen before anything is
        # written, and `_save_book_files` then reuses these rather than reading every
        # file a second time on its way to disk.
        fingerprints = await self._fingerprint_uploads(data.files)
        known = (
            {}
            if allow_duplicates
            else await self.find_duplicate_files(fingerprints.values(), library)
        )

        result = ImportResult()
        for files in books.values():
            if allow_duplicates:
                accepted, rejected = list(files), []
            else:
                accepted, rejected = self._screen_for_duplicates(
                    files, fingerprints, known, library
                )

            result.duplicates.extend(duplicate for _, duplicate in rejected)

            # Nothing new in this folder. A book record with no files, and a directory
            # to match, is worse than not importing it at all.
            if not accepted:
                continue

            book = await self.create_book(
                {"files": accepted, "library_id": library.id},
                library,
                screen_duplicates=False,
                fingerprints=fingerprints,
                **kwargs,
            )
            result.books.append(book)
            await self._record_possible_duplicates(
                result.possible_duplicates, book, library
            )

        return result

    async def _record_possible_duplicates(
        self, into: list[PossibleDuplicate], book: Book, library: Library
    ) -> None:
        """
        Note anything the library already holds that this book might be a copy of.

        Run after the book is created, not instead of creating it: a metadata match is
        a guess, and the cost of acting on a wrong one is refusing a legitimate second
        edition or a translation. The row is flushed by now, so the book before it in
        the same import is a candidate too.

        Args:
            into: The collection to append to, in place. Takes the list rather than the
                whole result so that every ingest path can use it whatever its own
                result type is.
            book: The book that was just created.
            library: The library it went into.
        """
        candidates = await self.find_duplicate_books(
            await self._load_for_matching(book.id), library, exclude_book_id=book.id
        )

        if candidates:
            into.append(
                PossibleDuplicate(
                    book_id=book.id, title=book.title, candidates=candidates
                )
            )

    async def create_many_from_existing_files(
        self,
        file_paths: list[Path],
        consume_path: Path,
        library: Library,
        allow_duplicates: bool = False,
        **kwargs,
    ) -> ImportResult:
        """
        Import files that are already on disk, from the consume directory.

        Files the library already holds are moved aside rather than imported — see
        `_quarantine_duplicate`, since there is nobody to ask what to do with them.

        Args:
            file_paths: Absolute paths of the files to import.
            consume_path: The library's consume directory, which the paths sit under.
            library: The library the books belong to.
            allow_duplicates: Import every file, even one already held.
            **kwargs: Additional keyword arguments passed to create.

        Returns:
            The books created, and the files moved aside as duplicates.
        """

        # Group up files if they are in the same leaf directory

        result = ImportResult()
        file_groups: dict[Path, list[Path]] = defaultdict(list)

        for file_path in file_paths:
            rel_path = file_path.relative_to(consume_path)
            parent_rel = rel_path.parent

            # Files at root level should each be in their own group
            # Use the file's relative path as the group key instead of parent
            if parent_rel == Path("."):
                group_key = rel_path  # Use the file itself as the key
            else:
                group_key = parent_rel  # Use parent directory as key

            # Add to appropriate group
            file_groups[group_key].append(file_path)

        # For each grouping
        for group, files in file_groups.items():
            # Fingerprinted before anything moves, since the paths are about to change.
            fingerprints = {file.name: await fingerprint_file(file) for file in files}

            if allow_duplicates:
                accepted, rejected = list(files), []
            else:
                known = await self.find_duplicate_files(fingerprints.values(), library)
                accepted, rejected = self._screen_for_duplicates(
                    files, fingerprints, known, library
                )

            for file, duplicate in rejected:
                await self._quarantine_duplicate(file, consume_path, library)
                result.duplicates.append(duplicate)

            if not accepted:
                cleanup_empty_parent_directories(consume_path / group, consume_path)
                continue

            data: dict[str, Any] = {"files": accepted}
            await self._parse_metadata_from_files(data, root_path=consume_path)
            await self._save_cover_image(data)

            # Get info from files
            path_gen = BookPathGenerator(library.root_path)
            parent = await self._reserve_book_path(path_gen.generate_path(data))
            data["path"] = str(parent)
            data["library_id"] = library.id
            file_metadata = []

            for file in accepted:
                file_hash, file_size = fingerprints[file.name]

                filename = path_gen.generate_filename(data, Path(file.name))

                file_metadata.append(
                    FileMetadata(
                        path=str(filename),
                        size=file_size,
                        hash=file_hash,
                        content_type=guess_content_type(file),
                    )
                )

            data["files"] = file_metadata

            # Move files to appropriate directory. A single file is moved by name
            # rather than by group: the group key is a directory for files that
            # arrived in one and the file's own relative path for files dropped at the
            # top level, and moving the directory would nest the file one level below
            # where its `FileMetadata.path` says it is.
            if len(accepted) > 1:
                await move_dir_contents(consume_path / group, parent)
            else:
                await move_file(accepted[0], parent / accepted[0].name)

            cleanup_empty_parent_directories(consume_path / group, consume_path)

            book = await super().create(data)
            result.books.append(book)
            await self._record_possible_duplicates(
                result.possible_duplicates, book, library
            )

            await self.repository.session.commit()

        return result

    async def create_many_from_calibre(
        self,
        source: CalibreLibrary,
        library: Library,
        allow_duplicates: bool = False,
        on_progress: Callable[[CalibreImportProgress], None] | None = None,
        should_stop: Callable[[], bool] | None = None,
    ) -> CalibreImportResult:
        """
        Import a Calibre library, **copying** its files rather than taking them.

        Calibre's catalogue is curated and its filenames are truncated, so the metadata
        is taken from `metadata.db` and the extractors are not run at all — the one
        ingest path here that trusts what it is given. The title is stored verbatim for
        the same reason: no edition is split out of it and no subtitle is guessed, since
        the field is one somebody maintained by hand.

        Files are **copied**, never moved. `metadata.db` would go on pointing at files
        that were gone, which quietly ruins a library the reader still uses; the source
        tree is left byte-for-byte alone.

        Re-running is safe and needs no bookkeeping: the same bytes are recognised
        wherever they sit, so a second pass over one library skips all of it.

        Args:
            source: An open `CalibreLibrary`.
            library: The Chitai library to import into.
            allow_duplicates: Import books whose files are already stored.
            on_progress: Called once per book, as it is decided.
            should_stop: Asked before each book whether to give up. Checked between
                books rather than during one, so a cancelled import leaves whole books
                behind and never half of one.

        Returns:
            What was created, left out and flagged. A cancelled run returns what it got
            through — every book in `created` is committed and complete.
        """
        books = await source.books()
        result = CalibreImportResult(total=len(books))

        for processed, entry in enumerate(books, start=1):
            if should_stop is not None and should_stop():
                result.stopped = True
                break

            try:
                outcome, detail = await self._import_calibre_book(
                    entry, library, result, allow_duplicates
                )
            except Exception as exc:
                # One book must never cost the rest of the import. The session is left
                # unusable by a failed flush, so it is rolled back before the next book
                # touches it.
                await self.repository.session.rollback()

                outcome, detail = "failed", f"{type(exc).__name__}: {exc}"
                result.failed.append(
                    UnimportedBook(
                        calibre_id=entry.calibre_id, title=entry.title, reason=detail
                    )
                )

            if on_progress is not None:
                on_progress(
                    CalibreImportProgress(
                        processed=processed,
                        total=result.total,
                        title=entry.title,
                        outcome=outcome,
                        detail=detail,
                    )
                )

        return result

    async def _import_calibre_book(
        self,
        entry: CalibreBook,
        library: Library,
        result: CalibreImportResult,
        allow_duplicates: bool,
    ) -> tuple[str, str | None]:
        """
        Import one Calibre book, or say why it was left out.

        Args:
            entry: The book as Calibre describes it.
            library: The library to import into.
            result: The import being assembled, appended to in place.
            allow_duplicates: Store files the library already holds.

        Returns:
            The outcome — `created` or `skipped` — and a detail for the progress report.

        Raises:
            Exception: Anything the write path raises, after the files this book had
                already copied are removed again. An orphaned directory would make the
                next attempt reserve `title (2)` and look like it had succeeded.
        """
        # What the catalogue claims and what is on disk can disagree: Calibre keeps the
        # row when a file is moved away behind its back.
        present = [
            file.path for file in entry.files if await aios.path.isfile(file.path)
        ]

        if not present:
            reason = "no files on disk" if entry.files else "no files in the catalogue"
            result.skipped.append(
                UnimportedBook(
                    calibre_id=entry.calibre_id, title=entry.title, reason=reason
                )
            )
            return "skipped", reason

        fingerprints = {path.name: await fingerprint_file(path) for path in present}

        if allow_duplicates:
            accepted, rejected = present, []
        else:
            known = await self.find_duplicate_files(fingerprints.values(), library)
            accepted, rejected = self._screen_for_duplicates(
                present, fingerprints, known, library
            )

        if not accepted:
            held_by = next(
                (duplicate.book_id for _, duplicate in rejected if duplicate.book_id),
                None,
            )
            result.skipped.append(
                UnimportedBook(
                    calibre_id=entry.calibre_id,
                    title=entry.title,
                    reason="already stored",
                    book_id=held_by,
                )
            )
            return "skipped", "already stored"

        # Files this book already has under another name are reported but do not stop
        # the formats that are new from being imported.
        result.duplicate_files.extend(duplicate for _, duplicate in rejected)

        data = self._calibre_metadata(entry, library)

        path_gen = BookPathGenerator(library.root_path)
        parent = await self._reserve_book_path(path_gen.generate_path(data))
        data["path"] = str(parent)

        copied: list[Path] = []
        try:
            await self._attach_calibre_cover(entry, data)

            file_metadata = []
            for path in accepted:
                filename = path_gen.generate_filename(data, Path(path.name))
                destination = _unused_path(parent / filename)

                await copy_file(path, destination)
                copied.append(destination)

                file_hash, file_size = fingerprints[path.name]
                file_metadata.append(
                    FileMetadata(
                        path=destination.name,
                        size=file_size,
                        hash=file_hash,
                        content_type=guess_content_type(path),
                    )
                )

            data["files"] = file_metadata

            book = await self.create(data)
            result.created.append(book.id)

            await self._record_possible_duplicates(
                result.possible_duplicates, book, library
            )

            await self.repository.session.commit()
        except Exception:
            for path in copied:
                await delete_file(path)
            cleanup_empty_parent_directories(parent, Path(library.root_path))
            raise

        return "created", None

    def _calibre_metadata(self, entry: CalibreBook, library: Library) -> dict[str, Any]:
        """
        Turn one `CalibreBook` into the payload `create` takes.

        Args:
            entry: The book as Calibre describes it.
            library: The library it is going into.

        Returns:
            The metadata dict, with no files or path in it yet.
        """
        # Folded onto Chitai's schemes here rather than in the reader, which reports what
        # Calibre wrote. `parse_identifier` maps `amazon` and `mobi-asin` both onto
        # `asin`, and a book carrying both would otherwise breach the unique
        # `(name, book_id)` constraint — so the first one wins, as it does everywhere
        # else identifiers are merged.
        identifiers: dict[str, str] = {}
        for name, value in entry.identifiers.items():
            if parsed := parse_identifier(value, scheme=name):
                identifiers.setdefault(*parsed)

        # Deliberately not passed through `parse_identifier`, which would recognise the
        # shape and file it under `uuid` — a name `normalize_identifier` refuses, since
        # an EPUB's uuid is regenerated per build. Calibre's is stable for the life of
        # the row, so it is the one durable link back to the source and worth matching
        # on when the same library is imported twice.
        if entry.uuid:
            identifiers.setdefault(CALIBRE_UUID, entry.uuid)

        return {
            "library_id": library.id,
            "title": entry.title or "Unknown",
            "authors": list(entry.authors),
            "description": entry.description,
            "published_date": entry.published_date,
            "series": entry.series,
            "series_position": entry.series_position,
            "tags": list(entry.tags),
            "publisher": entry.publisher,
            "language": entry.language,
            "identifiers": identifiers,
            "pages": entry.pages,
        }

    async def _attach_calibre_cover(self, entry: CalibreBook, data: dict) -> None:
        """
        Put Calibre's `cover.jpg` on the payload, for `_save_cover_image` to convert.

        Taken from the file Calibre already rendered rather than extracted again from
        the book: it is the cover the reader chose, and opening every EPUB and rendering
        the first page of every PDF is the slowest thing an import could do.

        Args:
            entry: The book as Calibre describes it.
            data: The payload, modified in place.
        """
        if entry.cover is None or not await aios.path.isfile(entry.cover):
            return

        try:
            # Copied out of the context manager: the image is saved later, by which time
            # the file this was opened from is closed.
            with Image.open(entry.cover) as cover:
                data["cover_image"] = cover.copy()
        except OSError:
            # A truncated or malformed cover.jpg is not a reason to refuse the book. It
            # is the least important thing in the directory, and one can be added later
            # from the book page; the files cannot.
            data.pop("cover_image", None)
            return

        await self._save_cover_image(data)

    @staticmethod
    async def _quarantine_duplicate(
        file: Path, consume_path: Path, library: Library
    ) -> None:
        """
        Move a file the library already holds out of the consume directory.

        Nothing is deleted — the watcher has nobody to ask, and the file is the user's.
        It cannot stay where it is either: `watchfiles` only reports additions, so a
        file left behind is never looked at again and only accumulates.

        Args:
            file: The refused file.
            consume_path: The library's consume directory, which the file sits under.
            library: The library it was offered to.
        """
        destination = (
            Path(settings.duplicate_path)
            / library.slug
            / file.relative_to(consume_path)
        )

        await move_file(file, destination)

    async def delete_books(
        self,
        book_ids: list[int],
        library: Library,
        delete_files: bool = False,
        **kwargs,
    ) -> None:
        """
        Delete books and optionally delete their associated files.

        Removes book records from the database and optionally deletes files from the
        filesystem, including cover images and book files.

        Args:
            book_ids: List of book IDs to delete.
            library: The library containing the books.
            delete_files: If True, also delete associated files from the filesystem.
            **kwargs: Additional keyword arguments.
        """
        books = await self.list(
            CollectionFilter(field_name="id", values=book_ids),
            Book.library_id == library.id,
        )

        for book in books:
            await self.remove_files(
                book.id,
                [file.id for file in book.files],
                library=library,
                delete_files=delete_files,
            )

            if book.cover_image:
                await delete_file(book.cover_image)

        await super().delete_many(book_ids)

    async def get_file(self, book_id: int, file_id: int) -> File:
        """
        Retrieve a file for download.

        Args:
            book_id: The ID of the book containing the file.
            file_id: The ID of the file to retrieve.

        Returns:
            A File object ready for download.

        Raises:
            ValueError: If the file is missing or not found for the given book.
        """
        book = await self.get(book_id)
        if book.path is None:
            raise ValueError("Cannot download file: book has no path")

        for file in book.files:
            if file.id != file_id:
                continue

            path = Path(book.path) / Path(file.path)
            if not await aios.path.isfile(path):
                raise ValueError("The file is missing")

            # Rows written before `guess_content_type` existed can hold null here, and
            # so can `guess_content_type` itself. Litestar fills a null in from the
            # filename, falling back to `application/octet-stream` on the response.
            return File(
                path, media_type=file.content_type or guess_content_type(file.path)
            )

        raise ValueError("No such file for the given book")

    async def get_files(
        self, book_ids: list[int], library_id: int
    ) -> AsyncIterator[bytes]:
        """
        Get all selected book files as a compressed zip file.

        Streams the zip file in chunks to avoid loading the entire archive into memory.
        Each book gets its own directory in the archive, so two books that happen to
        share a filename do not overwrite one another. Files that have a row but no
        longer exist on disk are skipped.

        Args:
            book_ids: List of book IDs to include in the zip.
            library_id: The ID of the library containing the books.

        Yields:
            Chunks of compressed zip file data.
        """
        books = await self.list(Book.id.in_(book_ids), Book.library_id == library_id)

        stream = _ZipStream()
        chunk_size = 32768  # 32 KiB

        with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as zip_file:
            for book in books:
                # Books without files have no path, and `file.path` is stored relative
                # to it, so there is nothing to resolve against.
                if book.path is None:
                    continue

                book_path = Path(book.path)
                for file in book.files:
                    path = book_path / file.path
                    if not await aios.path.isfile(path):
                        continue

                    # Namespaced by the book's own directory to avoid collisions.
                    info = zipfile.ZipInfo.from_file(
                        path, arcname=f"{book_path.name}/{Path(file.path).name}"
                    )
                    info.compress_type = zipfile.ZIP_DEFLATED

                    with zip_file.open(info, "w") as entry:
                        async with aiofiles.open(path, "rb") as source:
                            while content := await source.read(chunk_size):
                                entry.write(content)
                                if chunk := stream.drain():
                                    yield chunk

                    if chunk := stream.drain():
                        yield chunk

        # The central directory, written when ZipFile closes.
        if chunk := stream.drain():
            yield chunk

    async def update_book(
        self, book_id: int, update_data: ModelDictT[Book], library: Library
    ) -> Book:
        """
        Update a book's metadata and files.

        Handles cover image updates, path reorganization based on updated metadata,
        and cleanup of empty directories. Ensures related entities (authors, series, etc.)
        remain unique in the database.

        Args:
            book_id: The ID of the book to update.
            update_data: The updated book data.
            library: The library containing the book.

        Returns:
            The updated Book entity.
        """
        book = await self.get(book_id)

        if not is_dict(update_data):
            data = service.schema_dump(update_data, exclude_unset=True)
        else:
            data = update_data

        data["id"] = book_id

        if data.get("cover_image", None):
            # Delete the existing cover image
            if book.cover_image:
                await delete_file(book.cover_image)
            await self._save_cover_image(data)

        # TODO: extract out into its own function _update_book_path
        # Check if file path must be updated (only for books with files)
        if book.path is not None:
            path_gen = BookPathGenerator(library.root_path)
            # Reserved rather than taken: an edit that renames this book onto another
            # book's path would otherwise move its files in on top of theirs.
            updated_path = await self._reserve_book_path(
                path_gen.generate_path(book.to_dict() | data), book_id
            )
            if str(updated_path) != book.path:
                # TODO: Move only the files associated with the book instead of the whole directory
                await move_dir_contents(book.path, updated_path)
                data["path"] = str(updated_path)
                cleanup_empty_parent_directories(
                    Path(book.path), Path(library.root_path)
                )

        return await super().update(
            data, item_id=book_id, execution_options={"populate_existing": True}
        )

    async def add_files(
        self,
        book_id: int,
        files: list[UploadFile],
        library: Library,
        allow_duplicates: bool = False,
    ) -> None:
        """
        Add additional files to an existing book.

        Args:
            book_id: The ID of the book.
            files: List of files to add.
            library: The library containing the book.
            allow_duplicates: Store every file, even one already held.

        Raises:
            DuplicateFilesError: If a file is already stored under a different book.
        """
        book = await self.get(book_id)
        fingerprints = await self._fingerprint_uploads(files)

        if not allow_duplicates:
            # A file this book already carries is not a conflict — adding it again asks
            # for a state that already holds, so there is nothing to do. Unless its
            # bytes have gone missing, in which case that state does not hold: the
            # upload fills the existing row back in rather than earning the book a
            # second row for the same file.
            own = {(file.hash, file.size): file for file in book.files}
            remaining = []

            for file in files:
                stored = own.get(fingerprints[file.filename])
                if stored is None:
                    remaining.append(file)
                else:
                    await self._restore_file(book, stored, file)

            files = remaining

            known = await self.find_duplicate_files(
                (fingerprints[file.filename] for file in files), library
            )
            files, rejected = self._screen_for_duplicates(
                files, fingerprints, known, library
            )

            if rejected:
                raise DuplicateFilesError([duplicate for _, duplicate in rejected])

        if not files:
            return

        data = book.to_dict()
        data["files"] = files
        new_files = await self._save_book_files(library, data, fingerprints)
        book.files.extend(new_files)
        await self.update_book(
            book.id, {"files": [file for file in book.files]}, library
        )

    @staticmethod
    async def _restore_file(
        book: Book, stored: FileMetadata, upload: UploadFile
    ) -> None:
        """
        Put one of a book's own files back on disk, if it is no longer there.

        Args:
            book: The book the file belongs to.
            stored: The row saying where the file should be.
            upload: The uploaded copy of it.
        """
        if not book.path:
            return

        path = Path(book.path) / stored.path
        if await aios.path.isfile(path):
            return

        path.parent.mkdir(parents=True, exist_ok=True)
        await upload.seek(0)

        async with aiofiles.open(path, "wb") as dest:
            while chunk := await upload.read(CHUNK_SIZE):
                await dest.write(chunk)

    async def remove_files(
        self, book_id: int, file_ids: list[int], delete_files: bool, library: Library
    ) -> None:
        """
        Remove files from an existing book.

        Optionally deletes the files from the filesystem and cleans up empty directories.

        Args:
            book_id: The ID of the book.
            file_ids: List of file IDs to remove.
            delete_files: If True, also delete the files from the filesystem.
            library: The library containing the book.
        """
        book = await self.get_one(Book.id == book_id, Book.library_id == library.id)

        if delete_files and book.path is not None:
            # TODO: Extract this out into its own function
            for file in (file for file in book.files if file.id in file_ids):
                full_path = Path(book.path) / Path(file.path)
                if await aios.path.isfile(full_path):
                    await aios.remove(full_path)

                cleanup_empty_parent_directories(
                    Path(book.path), Path(library.root_path)
                )

        book.files = [file for file in book.files if file.id not in file_ids]

        await super().update(book.files, book.id)

    async def to_model_on_create(self, data: ModelDictT[Book]) -> ModelDictT[Book]:
        """
        Hook called during model creation to populate unique relationships.

        Args:
            data: The book data to transform.

        Returns:
            The transformed data with unique relationship entities.
        """
        data = service.schema_dump(data)
        self._preprocess_book_data(data)
        return await self._populate_with_unique_relationships(data)

    async def to_model_on_update(self, data: ModelDictT[Book]) -> ModelDictT[Book]:
        """
        Hook called during model update to populate unique relationships.

        Args:
            data: The book data to transform.

        Returns:
            The transformed data with unique relationship entities.
        """
        data = service.schema_dump(data)
        self._preprocess_book_data(data)
        model_data = await self._populate_with_unique_relationships(data)

        return model_data

    def _preprocess_book_data(self, data: dict) -> dict:
        """Transform API input format to model format."""
        if not isinstance(data, dict):
            return data

        # Transform dict identifiers to list of Identifier objects
        if "identifiers" in data and isinstance(data["identifiers"], dict):
            data["identifiers"] = [
                Identifier(name=key, value=val)
                for key, val in data["identifiers"].items()
            ]

        return data

    async def _populate_with_unique_relationships(
        self, data: ModelDictT[Book]
    ) -> ModelDictT[Book]:
        """
        Ensure relationship entities (authors, series, tags, etc.) are unique in the database.

        Fetches or creates unique instances of related entities to prevent duplicates.

        Args:
            data: Book data containing relationship fields.

        Returns:
            The data with relationship entities replaced by unique instances.
        """
        if not isinstance(data, dict):
            return data

        if data.get("id", None):
            model_data = await super().get(data["id"])

            # Update all scalar fields dynamically
            mapper = inspect(Book)
            for column in mapper.columns:
                if column.name in data:
                    setattr(model_data, column.name, data[column.name])
        else:
            model_data = await super().to_model(data)

        if "authors" in data:
            authors = [
                await Author.as_unique_async(self.repository.session, name=author)
                for author in data["authors"]
            ]
            self._sync_link_collection(
                model_data.author_links,
                authors,
                "author",
                lambda author: BookAuthorLink(author=author),
            )

        if "series" in data:
            if data["series"]:
                model_data.series = await BookSeries.as_unique_async(
                    self.repository.session, title=data["series"]
                )
            else:
                model_data.series = None

        if "publisher" in data:
            if data["publisher"]:
                model_data.publisher = await Publisher.as_unique_async(
                    self.repository.session, name=data["publisher"]
                )
            else:
                model_data.publisher = None

        if "tags" in data:
            tags = [
                await Tag.as_unique_async(self.repository.session, name=tag)
                for tag in data["tags"]
            ]
            self._sync_link_collection(
                model_data.tag_links,
                tags,
                "tag",
                lambda tag: BookTagLink(tag=tag),
            )

        if "identifiers" in data:
            self._sync_identifiers(model_data, data["identifiers"])

        if "files" in data:
            model_data.files = data["files"]

        return model_data

    @staticmethod
    def _sync_link_collection(
        links: list[Any],
        targets: list[Any],
        attr: str,
        creator: Callable[[Any], Any],
    ) -> None:
        """
        Reconcile an association-proxy link collection against the desired targets.

        Assigning through the association proxy runs its ``creator`` for every target,
        so a target that is already attached gets a brand new link row while the old
        one is orphaned. SQLAlchemy flushes a mapper's INSERTs before its DELETEs, so
        the new row hits the ``(book_id, tag_id)`` / ``(book_id, author_id)`` unique
        constraint while its predecessor is still in the table. Reusing the existing
        link keeps that from happening, and holds on to its id.

        Args:
            links: The link collection to reconcile in place.
            targets: The entities the collection should end up pointing at.
            attr: Name of the attribute on a link that holds the target.
            creator: Builds a new link for a target that is not attached yet.
        """
        existing = {getattr(link, attr): link for link in links}
        reconciled: list[Any] = []
        seen: set[Any] = set()

        for target in targets:
            if target in seen:
                continue
            seen.add(target)
            reconciled.append(existing.get(target) or creator(target))

        # Slice assignment so ordering_list renumbers `position` from the new order.
        links[:] = reconciled

    @staticmethod
    def _sync_identifiers(book: Book, identifiers: Any) -> None:
        """
        Reconcile a book's identifiers against the incoming ones, keyed by name.

        Same hazard as `_sync_link_collection`: `Identifier` is unique on
        ``(name, book_id)``, so replacing the collection wholesale re-inserts a name
        the book already carries. Existing rows are updated in place instead.

        Args:
            book: The book whose identifiers are being reconciled.
            identifiers: The incoming `Identifier` instances.
        """
        if not all(isinstance(identifier, Identifier) for identifier in identifiers):
            book.identifiers = identifiers
            return

        existing = {identifier.name: identifier for identifier in book.identifiers}
        reconciled: list[Identifier] = []
        seen: set[str] = set()

        for incoming in identifiers:
            if incoming.name in seen:
                continue
            seen.add(incoming.name)

            current = existing.get(incoming.name)
            if current is None:
                reconciled.append(incoming)
            else:
                current.value = incoming.value
                reconciled.append(current)

        book.identifiers[:] = reconciled

    async def _save_book_files(
        self,
        library: Library,
        data: dict,
        fingerprints: dict[str, Fingerprint] | None = None,
    ) -> list[FileMetadata]:
        """
        Save uploaded book files to the filesystem.

        Uses the library's path template to organize files and extracts file metadata
        (size, content type, etc.) for database storage.

        Args:
            library: The library containing the book.
            data: Book data with files to save.
            fingerprints: `(hash, size)` per submitted filename, for files that have
                already been hashed by duplicate screening. Anything not covered here
                is hashed on its way to disk instead.

        Returns:
            The data with file paths and metadata populated.
        """
        # Use the library template path with the book's data to generate a filepath
        path_gen = BookPathGenerator(library.root_path)

        # A book that already has a directory keeps it. `add_files` has to land beside
        # the files that are already there, which is not necessarily where the template
        # points now — the directory may have been renamed, or moved aside for a book
        # that generated the same one.
        if data.get("path"):
            parent = Path(data["path"])
        else:
            parent = await self._reserve_book_path(path_gen.generate_path(data))
            data["path"] = str(parent)

        file_metadata = []
        for file in data.pop("files", []):
            if TYPE_CHECKING:
                file: UploadFile

            # TODO filename should be generated with generate_path function
            filename = path_gen.generate_filename(data, Path(file.filename).name)

            # Store files in the correct directory and return the file's metadata
            await file.seek(0)
            parent.mkdir(parents=True, exist_ok=True)

            path = _unused_path(parent / filename)
            filename = path.name

            # Screening has usually hashed the file already; hashing it again on the
            # way past would read every byte a second time for an answer we hold.
            fingerprint = fingerprints.get(file.filename) if fingerprints else None

            hasher = StreamingHasher()
            async with aiofiles.open(path, "wb") as dest:
                # Read spooled file and save it to the local filesystem
                while chunk := await file.read(CHUNK_SIZE):
                    await dest.write(chunk)
                    if fingerprint is None:
                        hasher.update(chunk)

            stats = await aios.stat(path)
            file_size = stats.st_size

            file_metadata.append(
                FileMetadata(
                    path=str(filename),
                    size=file_size,
                    hash=fingerprint[0] if fingerprint else hasher.hexdigest(),
                    # The name outranks what the browser said: it posts
                    # `application/octet-stream` for every format it does not know,
                    # which is most of them.
                    content_type=guess_content_type(file, fallback=file.content_type),
                )
            )

        data["files"] = file_metadata
        return data["files"]

    async def _parse_metadata_from_files(
        self, data: dict, root_path: Path | None = None
    ) -> dict:
        """
        Extract metadata (title, author, etc.) from book files.

        Fills in missing book metadata fields using extracted information from files.

        Args:
            data: Book data to populate with extracted metadata.

        Returns:
            The data with extracted metadata populated in empty fields.
        """
        extracted_metadata = await MetadataExtractor.extract_metadata(
            data["files"], root_path
        )

        # Add missing fields and update empty (falsey) fields with extracted metadata
        for attr in extracted_metadata.keys():
            if not data.get(attr, None):
                data[attr] = extracted_metadata[attr]
        return data

    async def _save_cover_image(self, data: dict) -> dict:
        """
        Save the book's cover image to the filesystem.

        Converts uploaded images to WebP format for consistent storage and assigns
        a unique UUID-based filename.

        Args:
            data: Book data potentially containing a cover_image field.

        Returns:
            The data with cover_image path populated or removed if not provided.
        """
        if (image := data.pop("cover_image", None)) is None:
            return data

        if isinstance(image, UploadFile):
            content = await image.read()
            image = Image.open(BytesIO(content))

        filepath = Path(settings.book_cover_path)
        filename = Path(f"{uuid.uuid4()}.webp")  # Random filename

        await save_image(image, filepath / filename)

        data["cover_image"] = str(filepath / filename)
        return data
