# src/chitai/services/calibre.py

"""
Read a Calibre library.

This knows about `metadata.db` and the tree beside it, and nothing about `Book`,
`BookService` or a database session — it is a file-format reader, and it is testable
without Postgres or an app. Interpretation belongs to whoever imports what it returns:
identifiers come back exactly as Calibre wrote them, not folded onto Chitai's schemes.

Things about Calibre that are load-bearing here:

- **Never query the views.** `meta` and the `tag_browser_*` family call SQLite functions
  Calibre registers from Python at connection time, so `SELECT * FROM meta` fails with
  `no such function: sortconcat`. Only base tables are touched below.
- **An unknown date is a sentinel, not a null** — `0101-01-01`, Calibre's
  `UNDEFINED_DATE`. It parses fine as a date, so nothing complains; it just makes every
  book without a publication date look like it was published in the year 101.
- **`data.name` is not the title.** It is the on-disk stem, truncated to Calibre's
  filename limit and sanitised, so the file is `The Project Gutenberg eBook #33283_
  Calcul - Silvanus Phillips Thompson.pdf` for a book titled `The Project Gutenberg
  eBook #33283: Calculus Made Easy, 2nd Edition`. Names locate files; the database
  carries the metadata.
- **`authors.name` escapes a comma as `|`**, which Calibre reverses on read
  (`AuthorsTable.unserialize` in its `db/tables.py`).
- **`series_index` defaults to 1.0 whether or not the book is in a series**, so a
  position is only meaningful alongside a series.
- **`books_pages_link` is recent and often empty.** It is treated as optional both ways:
  the table may not exist, and where it does the rows are frequently `pages = 0` with
  `needs_scan = 1`.

Nothing walks the tree: every file is located through `books.path`, which is why
`.caltrash` — where Calibre keeps deleted books, still on disk — cannot be picked up by
accident.
"""

from __future__ import annotations

import asyncio
import shutil
import sqlite3
import tempfile
import zipfile
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import date, datetime
from html.parser import HTMLParser
from pathlib import Path, PurePosixPath


METADATA_DB = "metadata.db"
COVER_FILENAME = "cover.jpg"

# Calibre writes `0101-01-01` for "no date". Any year this early is that sentinel rather
# than a publication date somebody meant.
EARLIEST_REAL_YEAR = 1000

# The sidecars a WAL-mode database keeps beside itself. Copied along with it so the
# snapshot can be recovered, since Calibre may be running while this reads.
_DATABASE_SIDECARS = ("-wal", "-shm")


class CalibreLibraryError(Exception):
    """The library cannot be read at all — wrong directory, or no catalogue in it."""


# How far down an archive to look for `metadata.db`. Zipping a Calibre library gives
# either the directory itself or its contents, and a file manager may add a wrapper
# folder on top, so two levels of nesting is normal and more is somebody's backup tree.
_ARCHIVE_SEARCH_DEPTH = 3

# Extraction is refused unless the destination has the uncompressed size plus this
# much headroom. Filling the disk would take the whole application down, not just the
# import.
_DISK_HEADROOM = 256 * 1024 * 1024  # 256 MiB


def _archive_members(archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
    """
    The entries worth extracting, refusing any that would escape the destination.

    `ZipFile.extract` does sanitise names, but relying on that silently is how the next
    person to swap the extraction call reintroduces zip-slip. An archive naming
    `../../etc/anything` is malformed or hostile, and either way there is nothing to
    salvage by continuing.

    Raises:
        CalibreLibraryError: If any entry points outside the archive root.
    """
    members = []

    for member in archive.infolist():
        if member.is_dir():
            continue

        name = PurePosixPath(member.filename)

        if name.is_absolute() or ".." in name.parts:
            raise CalibreLibraryError(
                f"The archive contains an entry outside itself: {member.filename!r}"
            )

        members.append(member)

    return members


async def extract_calibre_archive(archive: Path, destination: Path) -> Path:
    """
    Unpack a zipped Calibre library and find the catalogue inside it.

    Args:
        archive: The `.zip` to unpack.
        destination: An empty directory to unpack into. The caller owns it and is
            responsible for removing it.

    Returns:
        The directory holding `metadata.db`, which is what `CalibreLibrary` takes.

    Raises:
        CalibreLibraryError: If the file is not a zip, names an entry outside itself,
            would not fit on disk, or holds no `metadata.db`.
    """
    return await asyncio.to_thread(_extract_calibre_archive, archive, destination)


def _extract_calibre_archive(archive: Path, destination: Path) -> Path:
    if not zipfile.is_zipfile(archive):
        raise CalibreLibraryError(
            "That is not a zip file. A Calibre library has to be zipped, not tarred."
        )

    with zipfile.ZipFile(archive) as opened:
        members = _archive_members(opened)

        if not any(
            PurePosixPath(member.filename).name == METADATA_DB for member in members
        ):
            raise CalibreLibraryError(
                f"The archive holds no {METADATA_DB}, so it is not a Calibre library"
            )

        # Checked before writing rather than discovered part-way through: a full disk
        # takes the whole application down, and the number is in the archive already.
        needed = sum(member.file_size for member in members)
        free = shutil.disk_usage(destination).free

        if needed + _DISK_HEADROOM > free:
            raise CalibreLibraryError(
                f"Unpacking needs {needed // (1024 * 1024)} MiB and only "
                f"{free // (1024 * 1024)} MiB is free"
            )

        opened.extractall(destination, members=members)

    return _find_catalogue(destination)


def _find_catalogue(root: Path) -> Path:
    """The shallowest directory under `root` holding a `metadata.db`."""
    candidates = sorted(
        (path.parent for path in root.rglob(METADATA_DB) if path.is_file()),
        key=lambda path: len(path.relative_to(root).parts),
    )

    for candidate in candidates:
        if len(candidate.relative_to(root).parts) <= _ARCHIVE_SEARCH_DEPTH:
            return candidate

    raise CalibreLibraryError(
        f"No {METADATA_DB} within {_ARCHIVE_SEARCH_DEPTH} levels of the archive root"
    )


@dataclass(frozen=True)
class CalibreFile:
    """One row of Calibre's `data` table: a book in one format."""

    path: Path
    """Absolute path, resolved against the library root. Not checked for existence."""

    format: str
    """As Calibre stores it, upper case: `EPUB`, `PDF`, `AZW3`."""

    size: int
    """`data.uncompressed_size` — Calibre's claim, not a fresh stat."""


@dataclass(frozen=True)
class CalibreBook:
    """One book, with everything Chitai has a column for and nothing it does not."""

    calibre_id: int
    uuid: str
    title: str
    authors: list[str] = field(default_factory=list)
    description: str | None = None
    published_date: date | None = None
    series: str | None = None
    series_position: str | None = None
    tags: list[str] = field(default_factory=list)
    publisher: str | None = None
    language: str | None = None

    identifiers: dict[str, str] = field(default_factory=dict)
    """Keyed by `identifiers.type` verbatim — `isbn`, `mobi-asin`, `amazon`."""

    pages: int | None = None
    cover: Path | None = None
    files: list[CalibreFile] = field(default_factory=list)


class CalibreLibrary:
    """
    A Calibre library on disk, opened for reading.

    The catalogue is **copied** before it is read. Calibre may be running and writing,
    and opening the live file either sees a torn state or needs to recover a write-ahead
    log, which read-only access cannot do. The copy is small — hundreds of kilobytes for
    a handful of books, single-digit megabytes for thousands — so this costs nothing and
    removes the question. The original is never opened by SQLite at all.
    """

    def __init__(self, root: Path | str) -> None:
        self.root = Path(root)
        self._connection: sqlite3.Connection | None = None
        self._workspace: Path | None = None

        # Every query runs in a worker thread, and `asyncio.to_thread` hands out
        # whichever one is free — so the connection outlives the thread that opened it
        # and `check_same_thread` has to be off. The lock is what makes that safe: it
        # keeps two queries off the connection at once, which is the thing that check
        # was standing in for.
        self._lock = asyncio.Lock()

    @property
    def database(self) -> Path:
        return self.root / METADATA_DB

    async def open(self) -> None:
        """
        Copy the catalogue aside and connect to the copy.

        Raises:
            CalibreLibraryError: If there is no `metadata.db` under the root.
        """
        if self._connection is not None:
            return

        if not await asyncio.to_thread(self.database.is_file):
            raise CalibreLibraryError(
                f"No {METADATA_DB} in '{self.root}' — that is not a Calibre library"
            )

        self._workspace = Path(await asyncio.to_thread(tempfile.mkdtemp))
        copy = self._workspace / METADATA_DB

        await asyncio.to_thread(self._copy_database, copy)

        # Read-write on our own copy, deliberately: that is what lets SQLite recover a
        # write-ahead log the source may have been mid-way through.
        self._connection = sqlite3.connect(str(copy), check_same_thread=False)

    def _copy_database(self, destination: Path) -> None:
        shutil.copy2(self.database, destination)

        for suffix in _DATABASE_SIDECARS:
            sidecar = self.database.with_name(self.database.name + suffix)
            if sidecar.is_file():
                shutil.copy2(sidecar, destination.with_name(destination.name + suffix))

    async def close(self) -> None:
        """
        Disconnect and remove the copy. Safe to call more than once.

        The copy is removed even if closing the connection fails — otherwise a failure
        here leaves a catalogue-sized file in the temp directory, and the caller that
        failed is exactly the one that will not come back to tidy up.
        """
        try:
            if self._connection is not None:
                async with self._lock:
                    self._connection.close()
                    self._connection = None
        finally:
            if self._workspace is not None:
                await asyncio.to_thread(shutil.rmtree, self._workspace, True)
                self._workspace = None

    async def __aenter__(self) -> CalibreLibrary:
        await self.open()
        return self

    async def __aexit__(self, *_exception: object) -> None:
        await self.close()

    async def count(self) -> int:
        """How many books the catalogue holds, without reading any of them."""
        rows = await self._in_thread(
            lambda: self._execute("SELECT count(*) FROM books")
        )
        return int(rows[0][0])

    async def books(self) -> list[CalibreBook]:
        """
        Read the whole catalogue.

        One query per table and the joining done in Python, rather than a per-book query
        across ten tables. Everything Chitai stores about a book is small, so a
        self-hosted catalogue fits in memory comfortably.

        Returns:
            Every book, in Calibre id order.
        """
        return await self._in_thread(self._read_books)

    async def _in_thread[T](self, work: Callable[[], T]) -> T:
        """Run one unit of SQLite work off the event loop, and only one at a time."""
        async with self._lock:
            return await asyncio.to_thread(work)

    def _execute(self, statement: str) -> list[tuple]:
        if self._connection is None:
            raise CalibreLibraryError("The library is not open")

        return self._connection.execute(statement).fetchall()

    def _has_table(self, name: str) -> bool:
        return bool(
            self._execute(
                f"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '{name}'"
            )
        )

    def _read_books(self) -> list[CalibreBook]:
        authors = self._grouped(
            "SELECT bal.book, a.name FROM books_authors_link bal "
            "JOIN authors a ON a.id = bal.author ORDER BY bal.id"
        )
        tags = self._grouped(
            "SELECT btl.book, t.name FROM books_tags_link btl "
            "JOIN tags t ON t.id = btl.tag ORDER BY t.name"
        )
        # Calibre's link tables are unique per book for these two, so the last write
        # wins and there is nothing to choose between.
        series = self._mapped(
            "SELECT bsl.book, s.name FROM books_series_link bsl "
            "JOIN series s ON s.id = bsl.series"
        )
        publishers = self._mapped(
            "SELECT bpl.book, p.name FROM books_publishers_link bpl "
            "JOIN publishers p ON p.id = bpl.publisher"
        )
        # A book can carry several languages; Chitai holds one, so the first wins.
        languages = self._grouped(
            "SELECT bll.book, l.lang_code FROM books_languages_link bll "
            "JOIN languages l ON l.id = bll.lang_code ORDER BY bll.item_order"
        )
        descriptions = self._mapped("SELECT book, text FROM comments")

        identifiers: dict[int, dict[str, str]] = defaultdict(dict)
        for book_id, name, value in self._execute(
            "SELECT book, type, val FROM identifiers"
        ):
            if name and value:
                identifiers[book_id][str(name)] = str(value)

        files: dict[int, list[tuple[str, str, int]]] = defaultdict(list)
        for book_id, format, name, size in self._execute(
            "SELECT book, format, name, uncompressed_size FROM data ORDER BY id"
        ):
            files[book_id].append((str(format), str(name), int(size or 0)))

        pages: dict[int, int] = {}
        if self._has_table("books_pages_link"):
            pages = {
                book_id: int(count)
                for book_id, count in self._execute(
                    "SELECT book, pages FROM books_pages_link WHERE pages > 0"
                )
            }

        books = []
        for row in self._execute(
            "SELECT id, title, pubdate, series_index, path, uuid, has_cover "
            "FROM books ORDER BY id"
        ):
            book_id, title, pubdate, series_index, path, uuid, has_cover = row
            directory = self.root / Path(str(path))
            in_series = series.get(book_id)

            books.append(
                CalibreBook(
                    calibre_id=book_id,
                    uuid=str(uuid or ""),
                    title=str(title or ""),
                    authors=[
                        unescape_author(name) for name in authors.get(book_id, [])
                    ],
                    description=strip_html(descriptions.get(book_id)),
                    published_date=parse_date(pubdate),
                    series=in_series,
                    # Meaningless without a series: Calibre defaults the index to 1.0 for
                    # every book, in a series or not.
                    series_position=(
                        format_series_index(series_index) if in_series else None
                    ),
                    tags=tags.get(book_id, []),
                    publisher=publishers.get(book_id),
                    language=next(iter(languages.get(book_id, [])), None),
                    identifiers=dict(identifiers.get(book_id, {})),
                    pages=pages.get(book_id),
                    cover=directory / COVER_FILENAME if has_cover else None,
                    files=[
                        CalibreFile(
                            path=directory / f"{name}.{format.lower()}",
                            format=format,
                            size=size,
                        )
                        for format, name, size in files.get(book_id, [])
                    ],
                )
            )

        return books

    def _grouped(self, statement: str) -> dict[int, list[str]]:
        """Run a `(book, value)` query into one list per book, keeping row order."""
        grouped: dict[int, list[str]] = defaultdict(list)

        for book_id, value in self._execute(statement):
            if value is not None:
                grouped[book_id].append(str(value))

        return grouped

    def _mapped(self, statement: str) -> dict[int, str]:
        """Run a `(book, value)` query into one value per book."""
        return {
            book_id: str(value)
            for book_id, value in self._execute(statement)
            if value is not None
        }


def unescape_author(name: str) -> str:
    """
    Undo Calibre's comma escaping.

    `authors.name` stores a comma as `|`, and Calibre reverses it on the way out. Left
    alone, `Doyle, Sir Arthur Conan` comes back as `Doyle| Sir Arthur Conan`.
    """
    return name.replace("|", ",").strip()


def parse_date(value: object) -> date | None:
    """
    Read one of Calibre's timestamps, discarding its "unknown" sentinel.

    Args:
        value: The stored column, which is text in practice but need not be.

    Returns:
        The date, or None for a null, an unparseable value, or Calibre's
        `0101-01-01` placeholder.
    """
    if value is None:
        return None

    if isinstance(value, datetime):
        parsed = value.date()
    elif isinstance(value, date):
        parsed = value
    else:
        text = str(value).strip()
        if not text:
            return None

        try:
            parsed = datetime.fromisoformat(text).date()
        except ValueError:
            try:
                parsed = date.fromisoformat(text[:10])
            except ValueError:
                return None

    return parsed if parsed.year >= EARLIEST_REAL_YEAR else None


def format_series_index(index: object) -> str | None:
    """
    Render `series_index` as the string `Book.series_position` holds.

    Calibre stores a REAL, so volume seven arrives as `7.0` — which would be stored
    verbatim and then compared as a string against the `7` everything else writes.
    Fractional positions are real and are kept: `1.5` is a novella between two novels.
    """
    if index is None:
        return None

    try:
        number = float(index)
    except (TypeError, ValueError):
        return None

    return str(int(number)) if number.is_integer() else f"{number:g}"


class _TextExtractor(HTMLParser):
    """Flatten markup to text, keeping the line breaks that carried meaning."""

    # Tags whose boundaries are a line break rather than nothing at all. Without these
    # a description of three paragraphs comes out as one run-on sentence.
    _BREAKS = frozenset(
        {
            "p",
            "br",
            "div",
            "li",
            "tr",
            "blockquote",
            "hr",
            "h1",
            "h2",
            "h3",
            "h4",
            "h5",
            "h6",
        }
    )

    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self._parts: list[str] = []

    def handle_data(self, data: str) -> None:
        self._parts.append(data)

    def handle_starttag(self, tag: str, _attrs: object) -> None:
        self._break(tag)

    def handle_endtag(self, tag: str) -> None:
        self._break(tag)

    def _break(self, tag: str) -> None:
        """
        End the current line, once.

        Both halves of `</p><p>` are a boundary, and the open tag of the very first
        block is not one at all — so emitting a newline per tag turns two paragraphs
        into two blank-line-separated ones with a leading gap. One break per boundary
        is what the plain text wants.
        """
        if tag in self._BREAKS and self._parts and not self._parts[-1].endswith("\n"):
            self._parts.append("\n")

    @property
    def text(self) -> str:
        lines = [line.strip() for line in "".join(self._parts).splitlines()]

        return "\n".join(line for line in lines if line).strip()


def strip_html(html: str | None) -> str | None:
    """
    Turn Calibre's `comments` into plain text.

    `comments.text` is always HTML, and `Book.description` is rendered as text — so the
    tags would show literally on the book page.

    Args:
        html: The stored comment, if there is one.

    Returns:
        The text, or None when there was nothing or nothing survived.
    """
    if not html:
        return None

    parser = _TextExtractor()
    parser.feed(html)
    parser.close()

    return parser.text or None
