"""Tests for importing a Calibre library through BookService."""

from pathlib import Path

import pytest

from chitai.config import settings
from chitai.database import models as m
from chitai.services import BookService
from chitai.services.calibre import CalibreLibrary

from tests.calibre_fixtures import CalibreFixture


DATA_FILES = Path("tests/data_files")
EPUB = DATA_FILES / "Metamorphosis - Franz Kafka.epub"
OTHER_EPUB = DATA_FILES / "The Art of War - Sun Tzu.epub"
PDF = DATA_FILES / "Calculus Made Easy - Silvanus Thompson.pdf"


@pytest.fixture(name="calibre_root")
def fx_calibre_root(tmp_path: Path) -> Path:
    """Three books: one plain, one in two formats, one Chitai cannot use."""
    fixture = CalibreFixture(tmp_path / "source")
    fixture.add_pages_table()

    fixture.add_book(
        1,
        "The Metamorphosis",
        authors=["Franz Kafka"],
        pubdate="1915-10-15 00:00:00+00:00",
        tags=["Fiction", "Absurdist"],
        publisher="Kurt Wolff Verlag",
        languages=["deu"],
        comment="<p>He wakes up <i>changed</i>.</p>",
        identifiers={"isbn": "978-0-486-29030-0", "amazon": "B01N5IB20Q"},
        uuid="11111111-2222-3333-4444-555555555555",
        pages=201,
        cover=True,
        formats={"EPUB": EPUB},
    )

    fixture.add_book(
        2,
        "The Art of War",
        authors=["Sun Tzu"],
        series="Classics",
        series_index=3.0,
        formats={"EPUB": OTHER_EPUB, "PDF": PDF},
    )

    fixture.add_book(3, "Metadata Only", authors=["Nobody"])

    return fixture.commit()


async def library_of(root: Path) -> CalibreLibrary:
    source = CalibreLibrary(root)
    await source.open()
    return source


async def test_imports_a_catalogue(
    books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
    source = await library_of(calibre_root)
    try:
        result = await books_service.create_many_from_calibre(source, test_library)
    finally:
        await source.close()

    assert result.total == 3
    assert len(result.created) == 2

    # The book with no files is left out: a record with nothing to read, and a directory
    # to match, is worse than not importing it.
    assert [skipped.calibre_id for skipped in result.skipped] == [3]
    assert result.skipped[0].reason == "no files in the catalogue"
    assert result.failed == []

    book = await books_service.get(result.created[0])

    assert book.title == "The Metamorphosis"
    assert [author.name for author in book.authors] == ["Franz Kafka"]
    assert sorted(tag.name for tag in book.tags) == ["Absurdist", "Fiction"]
    assert book.publisher is not None and book.publisher.name == "Kurt Wolff Verlag"
    assert book.published_date is not None and book.published_date.year == 1915
    assert book.language == "deu"
    assert book.pages == 201
    assert book.cover_image is not None

    # The HTML is gone; `Book.description` is rendered as text.
    assert book.description == "He wakes up changed."


async def test_two_formats_are_one_book(
    books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
    source = await library_of(calibre_root)
    try:
        result = await books_service.create_many_from_calibre(source, test_library)
    finally:
        await source.close()

    book = await books_service.get(result.created[1])

    assert book.title == "The Art of War"
    assert sorted(Path(file.path).suffix for file in book.files) == [".epub", ".pdf"]

    # A REAL series index reaches the column as the string everything else writes.
    assert book.series is not None and book.series.title == "Classics"
    assert book.series_position == "3"


async def test_identifiers_are_folded_onto_chitai_schemes(
    books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
    """
    `amazon` becomes `asin`, a hyphenated ISBN survives, and the Calibre uuid is kept.

    The uuid is deliberately not stored under `uuid`, which duplicate matching ignores
    because an EPUB regenerates one per build. Calibre's is stable, so it is the durable
    link back to the row it came from.
    """
    source = await library_of(calibre_root)
    try:
        result = await books_service.create_many_from_calibre(source, test_library)
    finally:
        await source.close()

    book = await books_service.get(result.created[0])
    identifiers = {identifier.name: identifier.value for identifier in book.identifiers}

    assert identifiers["asin"] == "B01N5IB20Q"
    assert identifiers["isbn-13"] == "9780486290300"
    assert identifiers["calibre-uuid"] == "11111111-2222-3333-4444-555555555555"

    matching = {
        identifier.name: identifier.normalized_value for identifier in book.identifiers
    }

    # Stored under its own name, matched under one scheme for both ISBN forms.
    assert matching["isbn-13"] == "isbn:9780486290300"

    # And the uuid carries a real matching key, which is the whole reason it is not
    # filed under `uuid`.
    assert matching["calibre-uuid"] is not None


async def test_the_source_library_is_left_alone(
    books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
    """Files are copied. Moving them would leave `metadata.db` pointing at nothing."""
    before = {
        path: path.stat().st_mtime_ns
        for path in sorted(calibre_root.rglob("*"))
        if path.is_file()
    }

    source = await library_of(calibre_root)
    try:
        result = await books_service.create_many_from_calibre(source, test_library)
    finally:
        await source.close()

    after = {
        path: path.stat().st_mtime_ns
        for path in sorted(calibre_root.rglob("*"))
        if path.is_file()
    }

    assert after == before

    # And the copies are really there, under the library's own layout.
    for book_id in result.created:
        book = await books_service.get(book_id)
        for file in book.files:
            assert (Path(book.path or "") / file.path).is_file()


async def test_importing_twice_creates_nothing(
    books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
    """
    Re-running is safe with no bookkeeping: the bytes are recognised wherever they sit.

    This is what makes an interrupted import resumable by simply running it again.
    """
    for _ in range(2):
        source = await library_of(calibre_root)
        try:
            result = await books_service.create_many_from_calibre(source, test_library)
        finally:
            await source.close()

    assert result.created == []
    assert sorted(skipped.reason for skipped in result.skipped) == [
        "already stored",
        "already stored",
        "no files in the catalogue",
    ]

    held_by = [
        skipped.book_id
        for skipped in result.skipped
        if skipped.reason == "already stored"
    ]
    assert all(book_id is not None for book_id in held_by)


async def test_a_file_the_catalogue_lists_but_disk_does_not(
    books_service: BookService, test_library: m.Library, tmp_path: Path
) -> None:
    """Calibre keeps the row when a file is moved away behind its back."""
    fixture = CalibreFixture(tmp_path / "source")
    fixture.add_book(1, "Present", authors=["A"], formats={"EPUB": EPUB})
    fixture.add_book(2, "Absent", authors=["B"])
    fixture.add_missing_format(2, "EPUB", "Absent - B")
    root = fixture.commit()

    source = await library_of(root)
    try:
        result = await books_service.create_many_from_calibre(source, test_library)
    finally:
        await source.close()

    assert len(result.created) == 1
    assert [(s.calibre_id, s.reason) for s in result.skipped] == [
        (2, "no files on disk")
    ]


async def test_one_broken_book_does_not_stop_the_import(
    books_service: BookService, test_library: m.Library, tmp_path: Path
) -> None:
    """
    A failure is recorded and the run continues, leaving no files behind for it.

    An orphaned directory would make the next attempt reserve `title (2)` and look as
    though it had worked.
    """
    fixture = CalibreFixture(tmp_path / "source")
    fixture.add_book(1, "First", authors=["A"], formats={"EPUB": EPUB})
    fixture.add_book(2, "Doomed", authors=["B"], formats={"EPUB": OTHER_EPUB})
    fixture.add_book(3, "Third", authors=["C"], formats={"PDF": PDF})
    root = fixture.commit()

    original = books_service.create

    async def fail_on_the_second(data, *args, **kwargs):
        if isinstance(data, dict) and data.get("title") == "Doomed":
            raise RuntimeError("no room on the shelf")
        return await original(data, *args, **kwargs)

    books_service.create = fail_on_the_second  # type: ignore[method-assign]

    source = await library_of(root)
    try:
        result = await books_service.create_many_from_calibre(source, test_library)
    finally:
        await source.close()
        books_service.create = original  # type: ignore[method-assign]

    assert len(result.created) == 2
    assert len(result.failed) == 1
    assert result.failed[0].calibre_id == 2
    assert "no room on the shelf" in result.failed[0].reason

    # Nothing of the failed book was left in the library. Checked against the path the
    # template would have produced, rather than by walking the root — the Calibre source
    # sits under it in these tests, and its own files are meant to still be there.
    assert not (Path(test_library.root_path) / "B").exists()

    # And the books either side of it are where they should be.
    for book_id in result.created:
        book = await books_service.get(book_id)
        for file in book.files:
            assert (Path(book.path or "") / file.path).is_file()


async def test_a_cover_that_cannot_be_read_is_not_fatal(
    books_service: BookService, test_library: m.Library, tmp_path: Path
) -> None:
    """
    A truncated `cover.jpg` costs the cover, not the book.

    Real libraries hold them, from an interrupted download or a failed conversion, and
    the cover is the one thing in the directory that can be replaced from the book page.
    """
    fixture = CalibreFixture(tmp_path / "source")
    fixture.add_book(
        1, "Unreadable Cover", authors=["A"], corrupt_cover=True, formats={"EPUB": EPUB}
    )
    root = fixture.commit()

    source = await library_of(root)
    try:
        result = await books_service.create_many_from_calibre(source, test_library)
    finally:
        await source.close()

    assert result.failed == []
    assert len(result.created) == 1

    book = await books_service.get(result.created[0])
    assert book.cover_image is None
    assert len(book.files) == 1


async def test_shared_authors_and_tags_are_one_row_each(
    books_service: BookService, test_library: m.Library, tmp_path: Path, session
) -> None:
    """Two books by one author must not produce two `Author` rows."""
    fixture = CalibreFixture(tmp_path / "source")
    fixture.add_book(
        1, "One", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": EPUB}
    )
    fixture.add_book(
        2,
        "Two",
        authors=["Franz Kafka"],
        tags=["Fiction"],
        formats={"EPUB": OTHER_EPUB},
    )
    root = fixture.commit()

    source = await library_of(root)
    try:
        result = await books_service.create_many_from_calibre(source, test_library)
    finally:
        await source.close()

    assert len(result.created) == 2

    first, second = [await books_service.get(book_id) for book_id in result.created]

    assert first.authors[0].id == second.authors[0].id
    assert first.tags[0].id == second.tags[0].id


async def test_a_second_copy_is_reported_not_refused(
    books_service: BookService, test_library: m.Library, tmp_path: Path
) -> None:
    """
    Two catalogue rows for one book, with different bytes, both import.

    File-level dedupe cannot see it — the archives differ — so book-level detection
    reports the pair and leaves the decision to the reader.
    """
    padded = tmp_path / "padded.epub"
    padded.write_bytes(EPUB.read_bytes() + b"\0" * 64)

    fixture = CalibreFixture(tmp_path / "source")
    fixture.add_book(
        1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB}
    )
    fixture.add_book(
        2, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": padded}
    )
    root = fixture.commit()

    source = await library_of(root)
    try:
        result = await books_service.create_many_from_calibre(source, test_library)
    finally:
        await source.close()

    assert len(result.created) == 2
    assert len(result.possible_duplicates) == 1
    assert result.possible_duplicates[0].candidates[0].book_id == result.created[0]


async def test_allow_duplicates_stores_the_same_bytes_again(
    books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
    for allow in (False, True):
        source = await library_of(calibre_root)
        try:
            result = await books_service.create_many_from_calibre(
                source, test_library, allow_duplicates=allow
            )
        finally:
            await source.close()

    assert len(result.created) == 2


async def test_duplicate_scope_off_imports_everything(
    books_service: BookService,
    test_library: m.Library,
    calibre_root: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(settings, "duplicate_scope", "off")

    for _ in range(2):
        source = await library_of(calibre_root)
        try:
            result = await books_service.create_many_from_calibre(source, test_library)
        finally:
            await source.close()

    assert len(result.created) == 2


async def test_progress_is_reported_per_book(
    books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
    """The import is long enough that its progress is the only thing worth watching."""
    seen = []

    source = await library_of(calibre_root)
    try:
        await books_service.create_many_from_calibre(
            source, test_library, on_progress=seen.append
        )
    finally:
        await source.close()

    assert [progress.processed for progress in seen] == [1, 2, 3]
    assert all(progress.total == 3 for progress in seen)
    assert [progress.outcome for progress in seen] == ["created", "created", "skipped"]
    assert seen[0].title == "The Metamorphosis"
