import pytest
from httpx import AsyncClient
from pathlib import Path


@pytest.mark.parametrize(
    ("path_to_book", "library_id", "title", "authors"),
    [
        (
            Path("tests/data_files/Metamorphosis - Franz Kafka.epub"),
            1,
            "Metamorphosis",
            ["Franz Kafka"],
        ),
        (
            Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"),
            1,
            "Moby Dick; Or, The Whale",
            ["Herman Melville"],
        ),
        (
            Path(
                "tests/data_files/Relativity: The Special and General Theory - Albert Einstein.epub"
            ),
            2,
            "Relativity : the Special and General Theory",
            ["Albert Einstein"],
        ),
        (
            Path(
                "tests/data_files/On The Origin of Species By Means of Natural Selection - Charles Darwin.epub"
            ),
            2,
            "On the Origin of Species By Means of Natural Selection / Or, the Preservation of Favoured Races in the Struggle for Life",
            ["Charles Darwin"],
        ),
        (
            Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"),
            2,
            # The ", 2nd Edition" is split off into `edition`, not kept in the title.
            "The Project Gutenberg eBook #33283: Calculus Made Easy",
            ["Silvanus Phillips Thompson"],
        ),
    ],
)
async def test_upload_book_without_data(
    authenticated_client: AsyncClient,
    path_to_book: Path,
    library_id: int,
    title: str,
    authors: list[str],
) -> None:
    """Test uploading a book file. Book information should be extracted from file."""
    # Read file contents
    file_content = path_to_book.read_bytes()

    # Prepare multipart form data
    files = [("files", (path_to_book.name, file_content, "application/epub+zip"))]

    # The rest of the book data will be parsed from file
    data = {
        "library_id": library_id,
    }

    response = await authenticated_client.post(
        f"/books?library_id={library_id}",
        files=files,
        data=data,
    )

    assert response.status_code == 201
    book_data = response.json()

    assert book_data["id"] is not None
    assert book_data["title"] == title
    assert book_data["library_id"] == library_id

    # Check if authors were properly parsed
    book_authors = [author["name"] for author in book_data["authors"]]
    assert book_authors == authors


@pytest.mark.parametrize(
    ("path_to_book", "library_id", "title", "authors", "tags"),
    [
        (
            Path("tests/data_files/Metamorphosis - Franz Kafka.epub"),
            1,
            "The Metamorphosis",
            ["Franz Kafka"],
            ["Psychological fiction"],
        ),
        (
            Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"),
            1,
            "Moby Dick",
            ["Herman Melville"],
            ["Classic Literature", "Whaling"],
        ),
        (
            Path(
                "tests/data_files/Relativity: The Special and General Theory - Albert Einstein.epub"
            ),
            2,
            "Relativity: the Special and General Theory",
            ["Albert Einstein"],
            ["Physics", "Mathematics"],
        ),
        (
            Path(
                "tests/data_files/On The Origin of Species By Means of Natural Selection - Charles Darwin.epub"
            ),
            2,
            "On the Origin of Species By Means of Natural Selection",
            ["Charles Darwin"],
            ["Biology"],
        ),
        (
            Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"),
            2,
            "Calculus Made Easy",
            ["Silvanus Thompson"],
            ["Mathematics"],
        ),
    ],
)
async def test_upload_book_with_data(
    authenticated_client: AsyncClient,
    path_to_book: Path,
    library_id: int,
    title: str,
    authors: list[str],
    tags: list[str],
) -> None:
    """Test uploading a book file with some book information provided."""
    # Read file contents
    file_content = path_to_book.read_bytes()

    # Prepare multipart form data
    files = [("files", (path_to_book.name, file_content, "application/epub+zip"))]

    # The rest of the book data will be parsed from file
    data = {"library_id": library_id, "title": title, "authors": authors, "tags": tags}

    response = await authenticated_client.post(
        f"/books?library_id={library_id}",
        files=files,
        data=data,
    )

    assert response.status_code == 201
    book_data = response.json()

    assert book_data["id"] is not None
    assert book_data["title"] == title
    assert book_data["library_id"] == library_id

    book_authors = [author["name"] for author in book_data["authors"]]
    assert book_authors == authors

    book_tags = [tag["name"] for tag in book_data["tags"]]
    assert book_tags == tags


@pytest.mark.parametrize(
    ("path_to_book", "library_id"),
    [
        (Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"), 1),
        (Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"), 2),
    ],
)
async def test_get_book_file(
    authenticated_client: AsyncClient, path_to_book: Path, library_id: int
) -> None:
    """Test uploading a book then downloading the book file."""

    # Read file contents
    file_content = path_to_book.read_bytes()

    # Prepare multipart form data
    files = [("files", (path_to_book.name, file_content, "application/epub+zip"))]

    # The rest of the book data will be parsed from file
    data = {
        "library_id": library_id,
    }

    create_response = await authenticated_client.post(
        f"/books?library_id={library_id}",
        files=files,
        data=data,
    )

    assert create_response.status_code == 201
    book_data = create_response.json()

    # Retrieve the book file
    book_id = book_data["id"]
    file_id = book_data["files"][0]["id"]
    file_response = await authenticated_client.get(
        f"/books/download/{book_id}/{file_id}"
    )

    assert file_response.status_code == 200

    downloaded_content = file_response.content

    assert len(downloaded_content) == len(file_content)
    assert downloaded_content == file_content


async def test_list_books_by_id(populated_authenticated_client: AsyncClient) -> None:
    """
    `?ids=` has to reach the database as integers.

    advanced_alchemy's stock id filter annotates the parameter as `list[str]` whatever
    the configured id type, so the ids arrived as strings and Postgres refused to
    compare a bigint primary key against them. Nothing called it until a screen needed
    to fetch a handful of books by id.
    """
    response = await populated_authenticated_client.get("/books?ids=1&ids=2&pageSize=10")

    assert response.status_code == 200
    assert sorted(book["id"] for book in response.json()["items"]) == [1, 2]


async def test_get_book_by_id(populated_authenticated_client: AsyncClient) -> None:
    """Test retrieving a specific book by ID."""

    # Retrieve the book
    response = await populated_authenticated_client.get(f"/books/1")

    assert response.status_code == 200
    book_data = response.json()
    assert book_data["id"] == 1
    assert book_data["title"] == "The Adventures of Sherlock Holmes"
    assert len(book_data["authors"]) == 1
    assert book_data["authors"][0]["name"] == "Arthur Conan Doyle"
    assert len(book_data["tags"]) == 1
    assert book_data["tags"][0]["name"] == "Mystery"


async def test_list_books(populated_authenticated_client: AsyncClient) -> None:
    """Test listing books with pagination and filters."""
    response = await populated_authenticated_client.get(
        "/books?library_id=1&pageSize=10&page=1"
    )

    assert response.status_code == 200
    data = response.json()

    assert "items" in data  # Pagination structure
    assert isinstance(data.get("items"), list)
    assert data["total"] == 3  # There should be 3 books in this library


async def test_list_books_with_tag_filter(
    populated_authenticated_client: AsyncClient,
) -> None:
    """Test listing books filtered by tags."""

    response = await populated_authenticated_client.get("/books?library_id=1&tags=1")

    assert response.status_code == 200

    result = response.json()
    assert len(result["items"]) == 2  # Two books with the "Mystery" tag


async def test_list_books_with_author_filter(
    populated_authenticated_client: AsyncClient,
) -> None:
    """Test listing books filtered by authors."""
    response = await populated_authenticated_client.get("/books?library_id=1&authors=1")

    assert response.status_code == 200

    result = response.json()
    assert len(result["items"]) == 1  # One book by "Arthur Conan Doyle"


async def test_list_books_with_search(
    populated_authenticated_client: AsyncClient,
) -> None:
    """Test listing books with title search."""
    response = await populated_authenticated_client.get(
        "/books?library_id=1&searchString=frankenstein"
    )

    assert response.status_code == 200

    result = response.json()
    assert len(result["items"]) == 1  # One matching book


async def test_delete_book_metadata_only(
    populated_authenticated_client: AsyncClient,
) -> None:
    """Test deleting a book record without deleting files."""

    # Delete book without deleting files
    response = await populated_authenticated_client.delete(
        f"/books?book_ids=3&delete_files=false&library_id=1"
    )

    assert response.status_code == 204

    # Verify book is deleted
    get_response = await populated_authenticated_client.get(f"/books/3")
    assert get_response.status_code == 404


async def test_delete_book_with_files(
    populated_authenticated_client: AsyncClient,
) -> None:
    """Test deleting a book and its associated files."""

    # Delete book and files
    response = await populated_authenticated_client.delete(
        f"/books?book_ids=3&delete_files=true&library_id=1"
    )

    assert response.status_code == 204


async def test_delete_specific_book_files(
    populated_authenticated_client: AsyncClient,
) -> None:
    """Test deleting specific files from a book."""

    # Delete specific file
    response = await populated_authenticated_client.delete(
        f"/books/1/files?file_ids=1",
    )

    assert response.status_code == 204


async def test_update_reading_progress(
    populated_authenticated_client: AsyncClient,
) -> None:
    """Test updating reading progress for a book."""

    # Update progress
    progress_data = {
        "percentage": 0.5,
    }

    response = await populated_authenticated_client.post(
        f"/books/progress/1",
        json=progress_data,
    )

    assert response.status_code == 201

    # Get the book and check if the progress is correct
    response = await populated_authenticated_client.get("/books/1")

    assert response.status_code == 200

    book = response.json()
    assert book["progress"]["percentage"] == 0.5


async def test_create_multiple_books_from_directory(
    authenticated_client: AsyncClient,
) -> None:
    """Test creating multiple books from a directory of files."""
    path1 = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
    path2 = Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub")

    files = [
        ("files", (path1.name, path1.read_bytes(), "application/epub+zip")),
        ("files", (path2.name, path2.read_bytes(), "application/epub+zip")),
    ]

    response = await authenticated_client.post(
        "/books/fromFiles?library_id=1",
        files=files,
        data={"library_id": 1},
    )

    assert response.status_code == 201
    data = response.json()
    assert len(data["created"]) == 2
    assert data["skipped"] == []


async def test_create_books_from_parent_directory_keeps_embedded_title(
    authenticated_client: AsyncClient,
) -> None:
    """A folder name in the upload path must not override the file's own metadata.

    The browser sends webkitRelativePath, so picking the shelf above a book's folder
    submits one more path component than picking the folder itself. That extra level
    used to make the directory name win over the title inside the EPUB.
    """
    source = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
    files = [
        (
            "files",
            (
                "Shelf/Metamorphosis - Franz Kafka/Metamorphosis.epub",
                source.read_bytes(),
                "application/epub+zip",
            ),
        )
    ]

    response = await authenticated_client.post(
        "/books/fromFiles?library_id=1", files=files, data={"library_id": 1}
    )

    assert response.status_code == 201

    books = response.json()["created"]
    assert len(books) == 1
    assert books[0]["title"] == "Metamorphosis"


async def test_create_books_groups_formats_within_one_folder(
    authenticated_client: AsyncClient,
) -> None:
    """Picking a book's own folder yields one book with both formats, not two books."""
    epub = Path("tests/data_files/Metamorphosis - Franz Kafka.epub").read_bytes()
    pdf = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf").read_bytes()

    files = [
        ("files", ("Metamorphosis/Metamorphosis.epub", epub, "application/epub+zip")),
        ("files", ("Metamorphosis/Metamorphosis.pdf", pdf, "application/pdf")),
    ]

    response = await authenticated_client.post(
        "/books/fromFiles?library_id=1", files=files, data={"library_id": 1}
    )

    assert response.status_code == 201

    books = response.json()["created"]
    assert len(books) == 1
    assert len(books[0]["files"]) == 2


class TestDuplicateHandling:
    """A file the library already holds must not be stored a second time."""

    epub_path = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")

    def upload(self, name: str | None = None) -> list[tuple[str, tuple]]:
        return [
            (
                "files",
                (
                    name or self.epub_path.name,
                    self.epub_path.read_bytes(),
                    "application/epub+zip",
                ),
            )
        ]

    async def test_bulk_upload_reports_skipped_files(
        self, authenticated_client: AsyncClient
    ) -> None:
        """Re-dropping a folder must import what is new and name what was not."""
        first = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload()
        )
        assert first.status_code == 201
        created = first.json()["created"][0]

        second = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload()
        )

        assert second.status_code == 201
        result = second.json()
        assert result["created"] == []
        assert len(result["skipped"]) == 1

        skipped = result["skipped"][0]
        assert skipped["filename"] == self.epub_path.name
        assert skipped["book_id"] == created["id"]
        assert skipped["book_title"] == created["title"]

    async def test_bulk_upload_can_be_forced(
        self, authenticated_client: AsyncClient
    ) -> None:
        await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload()
        )

        response = await authenticated_client.post(
            "/books/fromFiles?library_id=1&allow_duplicates=true", files=self.upload()
        )

        assert response.status_code == 201
        assert len(response.json()["created"]) == 1
        assert response.json()["skipped"] == []

    async def test_single_book_create_conflicts(
        self, authenticated_client: AsyncClient
    ) -> None:
        """Naming files deliberately earns a refusal rather than a silent drop."""
        await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload()
        )

        response = await authenticated_client.post(
            "/books?library_id=1", files=self.upload(), data={"library_id": 1}
        )

        assert response.status_code == 409
        assert response.json()["extra"][0]["filename"] == self.epub_path.name

        forced = await authenticated_client.post(
            "/books?library_id=1&allow_duplicates=true",
            files=self.upload(),
            data={"library_id": 1},
        )
        assert forced.status_code == 201

    async def test_adding_another_books_file_conflicts(
        self, authenticated_client: AsyncClient
    ) -> None:
        created = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload()
        )
        book_id = created.json()["created"][0]["id"]

        other = await authenticated_client.post(
            "/books?library_id=1",
            files=[
                (
                    "files",
                    (
                        "war.epub",
                        Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(),
                        "application/epub+zip",
                    ),
                )
            ],
            data={"library_id": 1},
        )
        other_id = other.json()["id"]

        response = await authenticated_client.post(
            f"/books/{book_id}/files",
            files=[
                (
                    "files",
                    (
                        "war.epub",
                        Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(),
                        "application/epub+zip",
                    ),
                )
            ],
        )

        assert response.status_code == 409
        assert response.json()["extra"][0]["book_id"] == other_id

    async def test_resending_a_books_own_file_changes_nothing(
        self, authenticated_client: AsyncClient
    ) -> None:
        created = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload()
        )
        book_id = created.json()["created"][0]["id"]

        response = await authenticated_client.post(
            f"/books/{book_id}/files", files=self.upload()
        )

        assert response.status_code == 201
        assert len(response.json()["files"]) == 1

    async def test_duplicates_can_be_checked_before_uploading(
        self, authenticated_client: AsyncClient
    ) -> None:
        """The pre-flight check answers from hashes alone, with no file sent."""
        created = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload()
        )
        book = created.json()["created"][0]
        stored = book["files"][0]

        response = await authenticated_client.post(
            "/books/duplicate-files?library_id=1",
            json=[
                {
                    "hash": stored["hash"],
                    "size": stored["size"],
                    "filename": "local-copy.epub",
                },
                {"hash": stored["hash"], "size": stored["size"] + 1},
                {"hash": "0" * 32, "size": 1234},
            ],
        )

        assert response.status_code == 200

        matches = response.json()
        assert len(matches) == 1
        assert matches[0]["filename"] == "local-copy.epub"
        assert matches[0]["book_id"] == book["id"]


@pytest.mark.asyncio
class TestDuplicateBooks:
    """A second copy of a book is imported and reported, never refused."""

    epub_path = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")

    def upload(self, name: str, pad: bool = False) -> list[tuple[str, tuple]]:
        """
        The fixture, optionally padded so it is a different file and the same book.

        Padding the archive changes its size and its sampled hash without disturbing
        the metadata, which is exactly the case the file-level check cannot see.
        """
        data = self.epub_path.read_bytes() + (b"\0" * 64 if pad else b"")

        return [("files", (name, data, "application/epub+zip"))]

    async def test_a_second_edition_is_created_and_reported(
        self, authenticated_client: AsyncClient
    ) -> None:
        first = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload("first.epub")
        )
        original = first.json()["created"][0]

        second = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
        )

        assert second.status_code == 201
        body = second.json()

        # Created, not skipped: a metadata match is a guess, and refusing a legitimate
        # second edition costs more than a note does.
        assert len(body["created"]) == 1
        assert body["skipped"] == []

        assert len(body["possible_duplicates"]) == 1
        possible = body["possible_duplicates"][0]
        assert possible["book_id"] == body["created"][0]["id"]

        candidate = possible["candidates"][0]
        assert candidate["book_id"] == original["id"]
        assert candidate["title"] == original["title"]
        assert candidate["authors"] == ["Franz Kafka"]
        assert "title-author" in candidate["matched_on"]

    async def test_the_review_screen_groups_them(
        self, authenticated_client: AsyncClient
    ) -> None:
        first = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload("first.epub")
        )
        second = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
        )

        book_ids = sorted(
            [first.json()["created"][0]["id"], second.json()["created"][0]["id"]]
        )

        response = await authenticated_client.get("/books/duplicate-books?library_id=1")

        assert response.status_code == 200
        groups = response.json()
        assert len(groups) == 1
        assert [book["book_id"] for book in groups[0]["books"]] == book_ids

    async def test_a_dismissed_group_stays_dismissed(
        self, authenticated_client: AsyncClient
    ) -> None:
        first = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload("first.epub")
        )
        second = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
        )

        pair = {
            "book_a_id": first.json()["created"][0]["id"],
            "book_b_id": second.json()["created"][0]["id"],
        }

        dismissed = await authenticated_client.post(
            "/books/duplicate-books/dismissals", json=pair
        )
        assert dismissed.status_code == 204

        response = await authenticated_client.get("/books/duplicate-books?library_id=1")
        assert response.json() == []

        restored = await authenticated_client.delete(
            "/books/duplicate-books/dismissals"
            f"?book_a_id={pair['book_b_id']}&book_b_id={pair['book_a_id']}"
        )
        assert restored.status_code == 204

        response = await authenticated_client.get("/books/duplicate-books?library_id=1")
        assert len(response.json()) == 1

    async def test_two_books_merge_into_one(
        self, authenticated_client: AsyncClient
    ) -> None:
        """The survivor keeps its id and gains the other's file; the other is gone."""
        first = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload("first.epub")
        )
        second = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
        )
        keep = first.json()["created"][0]
        fold = second.json()["created"][0]

        response = await authenticated_client.post(
            "/books/merge?library_id=1",
            json={
                "survivor_id": keep["id"],
                "merged_ids": [fold["id"]],
                "metadata": {"title": "Metamorphosis", "edition": 2},
            },
        )

        assert response.status_code == 201
        merged = response.json()

        assert merged["id"] == keep["id"]
        assert merged["edition"] == 2
        assert len(merged["files"]) == 2

        # The folded record is gone, and the group it formed with it.
        assert (await authenticated_client.get(f"/books/{fold['id']}")).status_code == 404
        groups = await authenticated_client.get("/books/duplicate-books?library_id=1")
        assert groups.json() == []

    async def test_merging_an_unknown_book_is_refused(
        self, authenticated_client: AsyncClient
    ) -> None:
        created = await authenticated_client.post(
            "/books/fromFiles?library_id=1", files=self.upload("first.epub")
        )
        keep = created.json()["created"][0]["id"]

        response = await authenticated_client.post(
            "/books/merge?library_id=1",
            json={"survivor_id": keep, "merged_ids": [9999]},
        )

        assert response.status_code == 400

    async def test_dismissing_an_unknown_book_is_refused(
        self, authenticated_client: AsyncClient
    ) -> None:
        response = await authenticated_client.post(
            "/books/duplicate-books/dismissals",
            json={"book_a_id": 1, "book_b_id": 9999},
        )

        assert response.status_code == 400


# NOTE: the multi-book ZIP download is covered at the service level, in
# tests/unit/test_services/test_book_service.py. Driving `/books/download` through
# AsyncTestClient hangs in fixture teardown: it is the only `Stream` endpoint in the
# app, and the test transport never sends the `http.disconnect` that Litestar's
# streaming response waits on, so the app's lifespan shutdown never completes.


# async def test_delete_book_metadata(authenticated_client: AsyncClient) -> None:
#     raise NotImplementedError()


# async def test_delete_book_metadata_and_files(
#     authenticated_client: AsyncClient,
# ) -> None:
#     raise NotImplementedError()


# async def test_edit_book_metadata(authenticated_client: AsyncClient) -> None:
#     raise NotImplementedError()

import pytest
import aiofiles
from httpx import AsyncClient
from pathlib import Path
from litestar.status_codes import HTTP_400_BAD_REQUEST

import pytest
from httpx import AsyncClient
from datetime import date


class TestMetadataUpdates:
    @pytest.mark.parametrize(
        ("updated_field", "new_value"),
        [
            ("title", "New Title"),
            ("subtitle", "Updated Subtitle"),
            ("pages", 256),
            ("description", "An updated description"),
            ("edition", 2),
            ("language", "pl"),
            ("published_date", "1910-04-05"),
            ("series_position", "3"),
        ],
    )
    async def test_update_single_scalar_field(
        self,
        populated_authenticated_client: AsyncClient,
        updated_field: str,
        new_value: str,
    ) -> None:
        """Test updating a single field without affecting others."""

        # Get original book state
        original_response = await populated_authenticated_client.get("/books/1")
        assert original_response.status_code == 200
        original_data = original_response.json()

        # Update field
        response = await populated_authenticated_client.patch(
            "/books/1",
            json={updated_field: str(new_value)},
        )
        assert response.status_code == 200

        updated_data = response.json()

        # Verify updated field
        assert updated_data[updated_field] == new_value

        # Verify all other fields remain unchanged
        for field, original_value in original_data.items():
            if field != updated_field and field not in ["updated_at", "created_at"]:
                assert updated_data[field] == original_value, (
                    f"Field {field} was unexpectedly changed"
                )

    @pytest.mark.parametrize(
        ("updated_field", "new_values", "assertion_func"),
        [
            (
                "authors",  # Update with new authors
                ["New Author 1", "New Author 2"],
                lambda data: {a["name"] for a in data["authors"]}
                == {"New Author 1", "New Author 2"},
            ),
            (
                "authors",  # Clear authors
                [],
                lambda data: data["authors"] == [],
            ),
            (
                "tags",  # Update with new tags
                ["Tag 1", "Tag 2", "Tag 3"],
                lambda data: {t["name"] for t in data["tags"]}
                == {"Tag 1", "Tag 2", "Tag 3"},
            ),
            (
                "tags",  # Clear tags
                [],
                lambda data: data["tags"] == [],
            ),
            (
                "publisher",  # Update with new publisher
                "Updated Publisher",
                lambda data: data["publisher"]["name"] == "Updated Publisher",
            ),
            (
                "publisher",  # Clear publisher
                None,
                lambda data: data["publisher"] is None,
            ),
            (
                "identifiers",  # Update with new identifiers
                {"isbn-13": "978-1234567890", "doi": "10.example/id"},
                lambda data: data["identifiers"]
                == {"isbn-13": "978-1234567890", "doi": "10.example/id"},
            ),
            (
                "identifiers",  # Clear identifiers
                {},
                lambda data: data["identifiers"] == {},
            ),
            (
                "series",  # Update with new series
                "Updated Series",
                lambda data: data["series"]["title"] == "Updated Series",
            ),
            (
                "series",  # Clear series
                None,
                lambda data: data["series"] is None,
            ),
        ],
    )
    async def test_update_relationship_fields(
        self,
        populated_authenticated_client: AsyncClient,
        updated_field: str,
        new_values: list[str],
        assertion_func,
    ) -> None:
        """Test updating relationship fields (authors, tags, etc.)"""

        # Get original book state
        original_response = await populated_authenticated_client.get("/books/1")
        assert original_response.status_code == 200
        original_data = original_response.json()

        response = await populated_authenticated_client.patch(
            "/books/1",
            json={updated_field: new_values},
        )
        assert response.status_code == 200

        # Verify updated field
        updated_data = response.json()
        assert assertion_func(updated_data)

        # Verify all other fields remain unchanged
        for field, original_value in original_data.items():
            if field != updated_field and field not in ["updated_at", "created_at"]:
                assert updated_data[field] == original_value, (
                    f"Field {field} was unexpectedly changed"
                )

    async def test_update_with_invalid_pages_value(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test updating with invalid pages value (non-integer)."""
        data = {
            "pages": "not_a_number",
        }

        response = await populated_authenticated_client.patch(
            "/books/1",
            files=data,
        )
        assert response.status_code == 400

    async def test_update_with_invalid_edition_value(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test updating with invalid edition value (non-integer)."""
        data = {
            "edition": "invalid_edition",
        }

        response = await populated_authenticated_client.patch(
            "/books/1",
            files=data,
        )
        assert response.status_code == 400

    async def test_update_with_invalid_date_format(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test updating with invalid published_date format."""
        data = {
            "published_date": "invalid-date",
        }

        response = await populated_authenticated_client.patch(
            "/books/1",
            files=data,
        )
        assert response.status_code == 400

    async def test_update_with_invalid_identifiers_json(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test updating with invalid JSON in identifiers."""
        data = {
            "identifiers": "not valid json",
        }

        response = await populated_authenticated_client.patch(
            "/books/1",
            files=data,
        )
        assert response.status_code == 400

    async def test_update_multiple_fields_at_once(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test updating multiple fields simultaneously."""
        data = {
            "title": "New Title",
            "description": "New description",
            "publisher": "New Publisher",
            "pages": 430,
        }

        response = await populated_authenticated_client.patch(
            "/books/1",
            json=data,
        )
        assert response.status_code == 200

        book_data = response.json()
        assert book_data["title"] == "New Title"
        assert book_data["description"] == "New description"
        assert book_data["publisher"]["name"] == "New Publisher"
        assert book_data["pages"] == 430

    async def test_update_nonexistent_book(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test updating a nonexistent book returns 404."""
        data = {
            "title": "New Title",
        }

        response = await populated_authenticated_client.patch(
            "/books/99999",
            json=data,
        )
        assert response.status_code == 404
        assert "book does not exist" in response.text

    @pytest.mark.parametrize(
        ("updated_field"),
        [
            ("subtitle"),
            ("pages"),
            ("description"),
            ("edition"),
            ("language"),
            ("published_date"),
            ("series_position"),
        ],
    )
    async def test_update_clears_optional_field(
        self, populated_authenticated_client: AsyncClient, updated_field: str
    ) -> None:
        """Test that optional fields can be cleared by passing None or empty values."""

        data = {updated_field: None}

        response = await populated_authenticated_client.patch(
            "/books/1",
            json=data,
        )
        assert response.status_code == 200

        result = response.json()

        assert result[updated_field] == None

    @pytest.mark.parametrize(
        ("updated_field"),
        [
            ("title"),
        ],
    )
    async def test_update_clears_required_field(
        self, populated_authenticated_client: AsyncClient, updated_field: str
    ) -> None:
        """Test that optional fields can be cleared by passing None or empty values."""

        data = {updated_field: None}

        response = await populated_authenticated_client.patch(
            "/books/1",
            json=data,
        )
        assert response.status_code == 400

    async def test_update_cover_successfully(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test successfully updating a book's cover image."""
        path = Path("tests/data_files/cover.jpg")
        file_content = path.read_bytes()

        files = {"cover_image": (path.name, file_content, "image/jpeg")}

        # Get original book state
        original_response = await populated_authenticated_client.get("/books/1")
        assert original_response.status_code == 200
        original_data = original_response.json()

        response = await populated_authenticated_client.put(
            "/books/1/cover",
            files=files,
        )
        assert response.status_code == 200

        book_data = response.json()
        assert book_data["id"] == 1

        assert book_data["cover_image"] is not None
        assert book_data["cover_image"] != original_data["cover_image"]


class TestFileManagement:
    async def test_add_multiple_files_to_book(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test adding multiple files to a book in a single request."""
        path1 = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
        path2 = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")

        file1_content = path1.read_bytes()
        file2_content = path2.read_bytes()

        files = [
            ("files", (path1.name, file1_content, "application/epub+zip")),
            ("files", (path2.name, file2_content, "application/pdf")),
        ]

        response = await populated_authenticated_client.post(
            "/books/1/files",
            files=files,
        )
        assert response.status_code == 201

        book_data = response.json()
        assert len(book_data["files"]) >= 2

    async def test_add_files_to_nonexistent_book(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test adding files to a nonexistent book returns 404."""
        path = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
        file_content = path.read_bytes()
        files = [("files", (path.name, file_content, "application/epub+zip"))]

        response = await populated_authenticated_client.post(
            "/books/99999/files?library_id=1",
            files=files,
        )
        assert response.status_code == 404

    async def test_remove_multiple_files_at_once(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test removing multiple files from a book at once."""
        # First add multiple files
        path1 = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
        path2 = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")

        files = [
            ("files", (path1.name, path1.read_bytes(), "application/epub+zip")),
            ("files", (path2.name, path2.read_bytes(), "application/pdf")),
        ]

        add_response = await populated_authenticated_client.post(
            "/books/1/files",
            files=files,
        )
        assert add_response.status_code == 201

        book_data = add_response.json()
        file_ids = [f["id"] for f in book_data["files"]]

        # Remove multiple files
        response = await populated_authenticated_client.delete(
            f"/books/1/files?file_ids={file_ids[0]}&file_ids={file_ids[1]}&delete_files=false",
        )
        assert response.status_code == 204

        # Verify files were removed
        book = await populated_authenticated_client.get("/books/1")
        book_data = book.json()
        assert len(book_data["files"]) < len(file_ids)

    async def test_remove_zero_files_raises_error(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test error when trying to remove zero files."""
        response = await populated_authenticated_client.delete(
            "/books/1/files?delete_files=false",
        )
        assert response.status_code == HTTP_400_BAD_REQUEST
        assert "missing required query parameter 'file_ids'" in response.text.lower()

    async def test_remove_files_from_nonexistent_book(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test removing files from a nonexistent book returns 404."""
        response = await populated_authenticated_client.delete(
            "/books/99999/files?file_ids=1&delete_files=false&library_id=1",
        )
        assert response.status_code == 404

    async def test_remove_nonexistent_file_id_from_book(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test removing a file_id that doesn't exist on that book."""
        # Try to remove a file that doesn't belong to this book
        response = await populated_authenticated_client.delete(
            "/books/1/files?file_ids=99999&delete_files=false",
        )
        # Should succeed (idempotent) or return 404, depending on implementation
        assert response.status_code in [204, 404]

    async def test_remove_file_with_delete_files_false_keeps_filesystem_file(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test that removing with delete_files=false keeps file on disk."""
        # Get the book to retrieve file info
        book_response = await populated_authenticated_client.get("/books/1")
        book_data = book_response.json()

        if not book_data["files"]:
            pytest.skip("Book has no files")

        file_id = book_data["files"][0]["id"]
        filename = book_data["files"][0].get("path")

        # Remove file without deleting from filesystem
        response = await populated_authenticated_client.delete(
            f"/books/1/files?file_ids={file_id}&delete_files=false",
        )
        assert response.status_code == 204

    async def test_remove_file_with_delete_files_true_removes_filesystem_file(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test that removing with delete_files=true deletes from filesystem."""
        # Add a new file first
        path = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
        file_content = path.read_bytes()
        files = [("files", (path.name, file_content, "application/pdf"))]

        add_response = await populated_authenticated_client.post(
            "/books/1/files",
            files=files,
        )
        assert add_response.status_code == 201

        book_data = add_response.json()
        file_id = book_data["files"][-1]["id"]
        file_path = book_data["files"][-1].get("path")

        # Remove file with deletion from filesystem
        response = await populated_authenticated_client.delete(
            f"/books/1/files?file_ids={file_id}&delete_files=true",
        )
        assert response.status_code == 204

    async def test_remove_file_idempotent(
        self, populated_authenticated_client: AsyncClient
    ) -> None:
        """Test that removing the same file twice doesn't error on second attempt."""
        book_response = await populated_authenticated_client.get("/books/1")
        book_data = book_response.json()

        if not book_data["files"]:
            pytest.skip("Book has no files")

        file_id = book_data["files"][0]["id"]

        # Remove file first time
        response1 = await populated_authenticated_client.delete(
            f"/books/1/files?file_ids={file_id}&delete_files=false",
        )
        assert response1.status_code == 204

        # Try to remove same file again
        response2 = await populated_authenticated_client.delete(
            f"/books/1/files?file_ids={file_id}&delete_files=false",
        )
        # Should succeed (idempotent)
        assert response2.status_code == 204


@pytest.mark.asyncio
class TestUnnameableFormats:
    """
    A file whose format nothing can name must still round-trip.

    `mimetypes.guess_type` answers None for `.mobi`, `.azw`, `.fb2` and `.lit`, which
    is most of what a library imported from elsewhere carries alongside its EPUBs.
    `FileMetadataRead.content_type` used to be a required string, so such a book was
    created and then failed serialisation on its way back out — a 500 on a book the
    reader can otherwise download.
    """

    def upload(self, name: str) -> list[tuple[str, tuple]]:
        # `application/octet-stream` is what a browser posts for these, and it is not
        # an answer — the extension is what names the format.
        return [("files", (name, b"BOOKMOBI\x00 payload", "application/octet-stream"))]

    async def test_a_mobi_is_named_from_its_extension(
        self, authenticated_client: AsyncClient
    ) -> None:
        response = await authenticated_client.post(
            "/books?library_id=1", files=self.upload("Dune.mobi"), data={"library_id": 1}
        )

        assert response.status_code == 201
        book = response.json()
        assert book["files"][0]["content_type"] == "application/x-mobipocket-ebook"

        detail = await authenticated_client.get(f"/books/{book['id']}")
        assert detail.status_code == 200

    async def test_an_unknown_extension_stores_no_content_type(
        self, authenticated_client: AsyncClient
    ) -> None:
        """Null, not a placeholder — and the book still serialises either way."""
        response = await authenticated_client.post(
            "/books?library_id=1",
            files=self.upload("Notes.xyzzy"),
            data={"library_id": 1},
        )

        assert response.status_code == 201
        book = response.json()
        assert book["files"][0]["content_type"] is None

        detail = await authenticated_client.get(f"/books/{book['id']}")
        assert detail.status_code == 200
        assert detail.json()["files"][0]["content_type"] is None

    async def test_the_file_downloads(
        self, authenticated_client: AsyncClient
    ) -> None:
        """Litestar supplies its own media type when the row carries none."""
        created = await authenticated_client.post(
            "/books?library_id=1",
            files=self.upload("Notes.xyzzy"),
            data={"library_id": 1},
        )
        book = created.json()

        response = await authenticated_client.get(
            f"/books/download/{book['id']}/{book['files'][0]['id']}"
        )

        assert response.status_code == 200
        assert response.headers["content-type"] == "application/octet-stream"

    async def test_the_opds_feed_survives_a_null_content_type(
        self, authenticated_client: AsyncClient
    ) -> None:
        """
        The one place the type has to be a string.

        `Link.type` is required, so a null fails the whole feed rather than one entry.
        OPDS clients speak Basic, not the JWT the rest of the API uses.
        """
        await authenticated_client.post(
            "/books?library_id=1",
            files=self.upload("Notes.xyzzy"),
            data={"library_id": 1},
        )

        feed = await authenticated_client.get(
            "/opds/acquisition?feed_id=all&feed_title=All+Books",
            auth=("user1@example.com", "password123"),
        )

        assert feed.status_code == 200
        assert 'type="application/octet-stream"' in feed.text
