from datetime import date, timezone
from datetime import datetime as dt

import pytest
from conftest import DB, Response

from hircine.db.models import (
    Artist,
    Circle,
    Comic,
    ComicArtist,
    ComicTag,
    Namespace,
    Tag,
    World,
)
from hircine.enums import Category, Censorship, Direction, Language, Layout, Rating

full_comic_fragment = """
    fragment FullComic on FullComic {
        id
        title
        category
        censorship
        createdAt
        date
        direction
        language
        layout
        originalTitle
        url
        rating
        pageCount
        updatedAt
        organized
        bookmarked
        archive {
            __typename
            id
        }
        artists {
            __typename
            id
        }
        characters {
            __typename
            id
        }
        circles {
            __typename
            id
        }
        cover {
            __typename
            id
        }
        pages {
            __typename
            id
        }
        tags {
            __typename
            id
            name
        }
        worlds {
            __typename
            id
        }
    }
"""

comic_fragment = """
    fragment Comic on Comic {
        id
        title
        category
        censorship
        date
        language
        originalTitle
        rating
        pageCount
        organized
        bookmarked
        artists {
            __typename
            id
        }
        characters {
            __typename
            id
        }
        circles {
            __typename
            id
        }
        cover {
            __typename
            id
        }
        tags {
            __typename
            id
            name
        }
        worlds {
            __typename
            id
        }
    }
"""


@pytest.fixture
def query_comic(execute_id):
    query = """
    query comic($id: Int!) {
        comic(id: $id) {
            __typename
            ... on FullComic {
                ...FullComic
            }
            ... on Error {
                message
            }
            ... on IDNotFoundError {
                id
            }
        }
    }
    """

    return execute_id(full_comic_fragment + query)


@pytest.fixture
def query_comics(execute):
    query = """
    query comics {
        comics {
            __typename
            count
            edges {
                ...Comic
            }
        }
    }
    """

    return execute(comic_fragment + query)


@pytest.fixture
def add_comic(execute_add):
    mutation = """
    mutation addComic($input: AddComicInput!) {
        addComic(input: $input) {
            __typename
            ... on AddComicSuccess {
                id
                archivePagesRemaining
            }
            ... on Error {
                message
            }
            ... on IDNotFoundError {
                id
            }
            ... on PageClaimedError {
                comicId
                id
            }
            ... on PageRemoteError {
                archiveId
                id
            }
            ... on InvalidParameterError {
                parameter
            }
        }
    }
    """

    return execute_add(mutation)


@pytest.fixture
def delete_comics(execute_delete):
    mutation = """
    mutation deleteComics($ids: [Int!]!) {
        deleteComics(ids: $ids) {
            __typename
            ... on Success {
                message
            }
            ... on Error {
                message
            }
            ... on IDNotFoundError {
                id
            }
        }
    }
    """

    return execute_delete(mutation)


@pytest.fixture
def update_comics(execute_update):
    mutation = """
    mutation updateComics($ids: [Int!]!, $input: UpdateComicInput!) {
        updateComics(ids: $ids, input: $input) {
            __typename
            ... on Success {
                message
            }
            ... on Error {
                message
            }
            ... on PageRemoteError {
                id
                archiveId
            }
            ... on PageClaimedError {
                id
                comicId
            }
            ... on IDNotFoundError {
                id
            }
            ... on InvalidParameterError {
                parameter
            }
        }
    }
    """  # noqa: E501

    return execute_update(mutation)


@pytest.fixture
def upsert_comics(execute_update):
    mutation = """
    mutation upsertComics($ids: [Int!]!, $input: UpsertComicInput!) {
        upsertComics(ids: $ids, input: $input) {
            __typename
            ... on Success {
                message
            }
            ... on Error {
                message
            }
            ... on InvalidParameterError {
                parameter
            }
        }
    }
    """  # noqa: E501

    return execute_update(mutation)


def assert_association_matches(obj, model, typename):
    assert obj["__typename"] == typename
    assert obj["id"] == model.id


def assert_associations_match(objlist, modellist, typename, sortkey):
    assert isinstance((objlist), list)
    assert len(objlist) == len(modellist)
    model = iter(sorted(modellist, key=sortkey))
    for obj in objlist:
        assert_association_matches(obj, next(model), typename)


def assert_comic_item_matches(data, comic):
    assert data["id"] == comic.id
    assert data["title"] == comic.title
    assert data["originalTitle"] == comic.original_title
    assert date.fromisoformat(data["date"]) == comic.date
    assert Rating[data["rating"]] == comic.rating
    assert Language[data["language"]] == comic.language
    assert data["pageCount"] == comic.page_count
    assert data["organized"] == comic.organized
    assert data["bookmarked"] == comic.bookmarked

    if data["category"]:
        assert Category[data["category"]] == comic.category
    else:
        assert comic.category is None

    if data["censorship"]:
        assert Censorship[data["censorship"]] == comic.censorship
    else:
        assert comic.censorship is None

    assert_association_matches(data["cover"], comic.cover, "Image")
    assert_associations_match(
        data["artists"], comic.artists, "Artist", lambda a: a.name
    )
    assert_associations_match(
        data["characters"], comic.characters, "Character", lambda c: c.name
    )
    assert_associations_match(
        data["circles"], comic.circles, "Circle", lambda c: c.name
    )
    assert_associations_match(data["tags"], comic.tags, "ComicTag", lambda t: t.name)
    assert_associations_match(data["worlds"], comic.worlds, "World", lambda w: w.name)


def assert_comic_matches(data, comic):
    assert_comic_item_matches(data, comic)
    assert dt.fromisoformat(data["createdAt"]) == comic.created_at
    assert dt.fromisoformat(data["updatedAt"]) == comic.updated_at
    assert Direction[data["direction"]] == comic.direction
    assert Layout[data["layout"]] == comic.layout
    assert data["url"] == comic.url

    assert_association_matches(data["archive"], comic.archive, "Archive")
    assert_associations_match(data["pages"], comic.pages, "Page", lambda p: p.index)


@pytest.mark.anyio
async def test_query_comic(query_comic, gen_comic):
    comic = await DB.add(next(gen_comic))

    response = Response(await query_comic(comic.id))
    response.assert_is("FullComic")

    assert_comic_matches(response.data, comic)


@pytest.mark.anyio
async def test_query_comic_sorts_pages(query_comic, gen_jumbled_archive):
    archive = next(gen_jumbled_archive)

    comic = await DB.add(
        Comic(
            id=1,
            title="A Jumbled Mess",
            archive=archive,
            pages=archive.pages,
            cover=archive.cover,
        )
    )

    response = Response(await query_comic(comic.id))
    response.assert_is("FullComic")

    assert_associations_match(response.pages, comic.pages, "Page", lambda p: p.index)


@pytest.mark.anyio
async def test_query_comic_fails_not_found(query_comic):
    response = Response(await query_comic(1))
    response.assert_is("IDNotFoundError")
    assert response.id == 1
    assert response.message == "Comic ID not found: '1'"


@pytest.mark.anyio
async def test_query_comics(query_comics, gen_comic):
    comics = await DB.add_all(*gen_comic)

    response = Response(await query_comics())
    response.assert_is("ComicFilterResult")

    assert response.count == len(comics)
    assert isinstance((response.edges), list)
    assert len(response.edges) == len(comics)

    edge = iter(response.edges)
    for comic in sorted(comics, key=lambda c: c.title):
        assert_comic_item_matches(next(edge), comic)


@pytest.mark.anyio
async def test_add_comic(add_comic, gen_archive):
    archive = next(gen_archive)
    await DB.add(archive)

    before = dt.now(timezone.utc).replace(microsecond=0)

    response = Response(
        await add_comic(
            {
                "title": "The Comically Bad Comic",
                "archive": {"id": archive.id},
                "pages": {"ids": [p.id for p in archive.pages]},
                "cover": {"id": archive.pages[0].id},
            }
        )
    )
    response.assert_is("AddComicSuccess")
    assert response.archivePagesRemaining is False

    after = dt.now(timezone.utc).replace(microsecond=0)

    comic = await DB.get(Comic, response.id, full=True)
    assert comic is not None
    assert comic.title == "The Comically Bad Comic"

    assert comic.archive.id == archive.id
    assert comic.archive.organized is True

    assert set([page.id for page in comic.pages]) == set(
        [page.id for page in archive.pages]
    )

    assert comic.cover.id == archive.cover.id

    assert comic.category is None
    assert comic.censorship is None
    assert comic.created_at >= before
    assert comic.created_at <= after
    assert comic.date is None
    assert comic.language is None
    assert comic.layout == Layout.SINGLE
    assert comic.original_title is None
    assert comic.url is None
    assert comic.rating is None

    assert comic.artists == []
    assert comic.characters == []
    assert comic.circles == []
    assert comic.tags == []
    assert comic.worlds == []


@pytest.mark.anyio
async def test_add_comic_pages_remaining(add_comic, gen_archive):
    archive = next(gen_archive)
    await DB.add(archive)

    response = Response(
        await add_comic(
            {
                "title": "The Unfinished Comic",
                "archive": {"id": archive.id},
                "pages": {"ids": [p.id for p in archive.pages][:2]},
                "cover": {"id": archive.pages[0].id},
            }
        )
    )
    response.assert_is("AddComicSuccess")
    assert response.archivePagesRemaining is True

    comic = await DB.get(Comic, response.id, full=True)
    assert comic.archive.organized is False


@pytest.mark.anyio
async def test_add_comic_fails_archive_not_found(add_comic, gen_archive):
    archive = next(gen_archive)
    await DB.add(archive)

    response = Response(
        await add_comic(
            {
                "title": "Voidful Comic",
                "archive": {"id": 10},
                "pages": {"ids": [p.id for p in archive.pages]},
                "cover": {"id": archive.pages[0].id},
            }
        )
    )
    response.assert_is("IDNotFoundError")
    assert response.id == 10
    assert response.message == "Archive ID not found: '10'"


@pytest.mark.anyio
async def test_add_comic_fails_page_not_found(add_comic, gen_archive):
    archive = next(gen_archive)
    await DB.add(archive)

    response = Response(
        await add_comic(
            {
                "title": "Pageless Comic",
                "archive": {"id": archive.id},
                "pages": {"ids": [10]},
                "cover": {"id": archive.pages[0].id},
            }
        )
    )
    response.assert_is("IDNotFoundError")
    assert response.id == 10
    assert response.message == "Page ID not found: '10'"


@pytest.mark.anyio
async def test_add_comic_fails_page_claimed(add_comic, gen_archive):
    other_archive = next(gen_archive)
    other_comic = await DB.add(
        Comic(
            title="Lawful Comic",
            archive=other_archive,
            cover=other_archive.cover,
            pages=other_archive.pages,
        )
    )

    claimed_page = other_comic.pages[0]

    archive = next(gen_archive)
    await DB.add(archive)

    response = Response(
        await add_comic(
            {
                "title": "Comic of Attempted Burglary",
                "archive": {"id": archive.id},
                "pages": {"ids": [claimed_page.id]},
                "cover": {"id": archive.pages[0].id},
            }
        )
    )

    response.assert_is("PageClaimedError")
    assert response.id == claimed_page.id
    assert response.comicId == other_comic.id
    assert (
        response.message
        == f"Page ID {claimed_page.id} is already claimed by comic ID {other_comic.id}"
    )


@pytest.mark.anyio
async def test_add_comic_fails_empty_parameter(add_comic, gen_archive):
    archive = next(gen_archive)
    await DB.add(archive)

    response = Response(
        await add_comic(
            {
                "title": "",
                "archive": {"id": archive.id},
                "pages": {"ids": [p.id for p in archive.pages]},
                "cover": {"id": archive.pages[0].id},
            }
        )
    )
    response.assert_is("InvalidParameterError")
    assert response.parameter == "title"


@pytest.mark.anyio
async def test_add_comic_fails_page_remote(add_comic, gen_archive):
    other_archive = await DB.add(next(gen_archive))
    other_page = other_archive.pages[0]

    archive = await DB.add(next(gen_archive))

    response = Response(
        await add_comic(
            {
                "title": "Comic of Multiple Archives",
                "archive": {"id": archive.id},
                "pages": {"ids": [other_page.id]},
                "cover": {"id": archive.pages[0].id},
            }
        )
    )

    response.assert_is("PageRemoteError")
    assert response.id == other_page.id
    assert response.archiveId == other_archive.id
    assert (
        response.message
        == f"Page ID {other_page.id} comes from remote archive ID {other_archive.id}"
    )


@pytest.mark.anyio
async def test_add_comic_fails_cover_remote(add_comic, gen_archive):
    other_archive = await DB.add(next(gen_archive))
    other_page = other_archive.pages[0]

    archive = await DB.add(next(gen_archive))

    response = Response(
        await add_comic(
            {
                "title": "Comic of Multiple Archives",
                "archive": {"id": archive.id},
                "pages": {"ids": [p.id for p in archive.pages]},
                "cover": {"id": other_page.id},
            }
        )
    )

    response.assert_is("PageRemoteError")
    assert response.id == other_page.id
    assert response.archiveId == other_archive.id
    assert (
        response.message
        == f"Page ID {other_page.id} comes from remote archive ID {other_archive.id}"
    )


@pytest.mark.anyio
async def test_delete_comic(delete_comics, gen_comic):
    comic = await DB.add(next(gen_comic))

    response = Response(await delete_comics(comic.id))
    response.assert_is("DeleteSuccess")

    comic = await DB.get(Comic, comic.id)
    assert comic is None


@pytest.mark.anyio
async def test_delete_comic_not_found(delete_comics):
    response = Response(await delete_comics(1))

    response.assert_is("IDNotFoundError")
    assert response.id == 1
    assert response.message == "Comic ID not found: '1'"


def assert_assocs_match(assocs, collection, name_only=False):
    assert set([o.name for o in assocs]) == set([o.name for o in collection])
    assert set([o.id for o in assocs]) == set([o.id for o in collection])


@pytest.mark.anyio
async def test_update_comic(update_comics, gen_comic):
    original_comic = await DB.add(next(gen_comic))

    artists = await DB.add_all(Artist(name="arty"), Artist(name="farty"))
    circles = await DB.add_all(Circle(name="round"), Circle(name="oval"))
    worlds = await DB.add_all(World(name="animal world"), World(name="no spiders"))

    namespace = await DB.add(Namespace(name="emus"))
    tag = await DB.add(Tag(name="creepy"))
    ct = ComicTag(namespace=namespace, tag=tag)

    new_tags = [ct] + original_comic.tags
    new_pages = [p.id for p in original_comic.pages[:2]]

    input = {
        "title": "Saucy Savannah Adventures (now in Italian)",
        "url": "file:///home/savannah/avventura",
        "originalTitle": original_comic.title,
        "cover": {"id": original_comic.pages[1].id},
        "pages": {"ids": new_pages},
        "favourite": False,
        "organized": True,
        "bookmarked": True,
        "artists": {"ids": [a.id for a in artists]},
        "circles": {"ids": [c.id for c in circles]},
        "worlds": {"ids": [w.id for w in worlds]},
        "tags": {"ids": [ct.id for ct in new_tags]},
        "date": "2010-07-06",
        "direction": "LEFT_TO_RIGHT",
        "language": "IT",
        "layout": "DOUBLE_OFFSET",
        "rating": "EXPLICIT",
        "censorship": "BAR",
    }
    response = Response(await update_comics(original_comic.id, input))
    response.assert_is("UpdateSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)
    assert comic is not None
    assert comic.title == "Saucy Savannah Adventures (now in Italian)"
    assert comic.original_title == original_comic.title
    assert comic.cover.id == original_comic.pages[1].image.id
    assert comic.url == "file:///home/savannah/avventura"
    assert comic.favourite is False
    assert comic.organized is True
    assert comic.bookmarked is True

    assert set([p.id for p in comic.pages]) == set(new_pages)

    assert_assocs_match(comic.artists, artists)
    assert_assocs_match(comic.circles, circles)
    assert_assocs_match(comic.characters, original_comic.characters)
    assert_assocs_match(comic.worlds, worlds)
    assert_assocs_match(comic.tags, new_tags)

    assert comic.date == date(2010, 7, 6)
    assert comic.direction == Direction.LEFT_TO_RIGHT
    assert comic.layout == Layout.DOUBLE_OFFSET
    assert comic.rating == Rating.EXPLICIT
    assert comic.language == Language.IT
    assert comic.censorship == Censorship.BAR


@pytest.mark.anyio
async def test_update_comic_clears_associations(update_comics, gen_comic):
    original_comic = await DB.add(next(gen_comic))

    empty = {"ids": []}

    input = {
        "artists": empty,
        "circles": empty,
        "worlds": empty,
        "tags": empty,
    }
    response = Response(await update_comics(original_comic.id, input))
    response.assert_is("UpdateSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)
    assert comic is not None

    assert comic.artists == []
    assert comic.circles == []
    assert comic.worlds == []
    assert comic.tags == []


@pytest.mark.anyio
async def test_update_comic_clears_enums(update_comics, gen_comic):
    original_comic = await DB.add(next(gen_comic))

    input = {
        "category": None,
        "censorship": None,
        "rating": None,
    }
    response = Response(await update_comics(original_comic.id, input))
    response.assert_is("UpdateSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)
    assert comic is not None

    assert comic.rating is None
    assert comic.category is None
    assert comic.censorship is None


@pytest.mark.parametrize(
    "empty",
    [
        None,
        "",
    ],
    ids=[
        "with None",
        "with empty string",
    ],
)
@pytest.mark.anyio
async def test_update_comic_clears_string_fields(update_comics, gen_comic, empty):
    original_comic = await DB.add(next(gen_comic))

    input = {
        "originalTitle": empty,
        "url": empty,
        "date": None,
    }
    response = Response(await update_comics(original_comic.id, input))
    response.assert_is("UpdateSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)
    assert comic is not None

    assert comic.original_title is None
    assert comic.date is None
    assert comic.url is None


@pytest.mark.anyio
async def test_update_comic_fails_comic_not_found(update_comics):
    response = Response(await update_comics(1, {"title": "This Will Not Happen"}))
    response.assert_is("IDNotFoundError")
    assert response.id == 1
    assert response.message == "Comic ID not found: '1'"


@pytest.mark.parametrize(
    "parameter,empty",
    [
        ("title", ""),
        ("title", None),
        ("direction", None),
        ("layout", None),
    ],
    ids=[
        "title (empty string)",
        "title (none)",
        "direction",
        "layout",
    ],
)
@pytest.mark.anyio
async def test_update_comic_fails_empty_parameter(
    update_comics, gen_archive, parameter, empty
):
    archive = next(gen_archive)
    comic = await DB.add(
        Comic(
            title="Dusty Old Comic",
            archive=archive,
            cover=archive.cover,
            pages=archive.pages,
        )
    )

    response = Response(await update_comics(comic.id, {parameter: empty}))
    response.assert_is("InvalidParameterError")
    assert response.parameter == parameter
    assert response.message == f"Invalid parameter '{parameter}': cannot be empty"


@pytest.mark.anyio
async def test_update_comic_fails_namespace_not_found(update_comics, gen_archive):
    archive = next(gen_archive)
    comic = await DB.add(
        Comic(
            title="Dusty Old Comic",
            archive=archive,
            cover=archive.cover,
            pages=archive.pages,
        )
    )

    tag = await DB.add(Tag(name="shiny"))

    response = Response(
        await update_comics(comic.id, {"tags": {"ids": [f"1:{tag.id}"]}})
    )
    response.assert_is("IDNotFoundError")
    assert response.id == 1
    assert response.message == "Namespace ID not found: '1'"


@pytest.mark.anyio
async def test_update_comic_fails_tag_not_found(update_comics, gen_archive):
    archive = next(gen_archive)
    comic = await DB.add(
        Comic(
            title="Dusty Old Comic",
            archive=archive,
            cover=archive.cover,
            pages=archive.pages,
        )
    )

    namespace = await DB.add(Namespace(name="height"))

    response = Response(
        await update_comics(comic.id, {"tags": {"ids": [f"{namespace.id}:1"]}})
    )
    response.assert_is("IDNotFoundError")
    assert response.id == 1
    assert response.message == "Tag ID not found: '1'"


@pytest.mark.parametrize(
    "input",
    [
        "",
        ":1",
        "1:",
        "a:b",
        "tag",
    ],
    ids=[
        "empty",
        "namespacing missing",
        "tag missing",
        "no numeric ids",
        "wrong format",
    ],
)
@pytest.mark.anyio
async def test_update_comic_fails_invalid_tag(update_comics, gen_archive, input):
    archive = next(gen_archive)
    comic = await DB.add(
        Comic(
            title="Dusty Old Comic",
            archive=archive,
            cover=archive.cover,
            pages=archive.pages,
        )
    )

    response = Response(await update_comics(comic.id, {"tags": {"ids": [input]}}))
    response.assert_is("InvalidParameterError")
    assert response.parameter == "id"

    msg = "Invalid parameter 'id': ComicTag ID must be specified as <namespace_id>:<tag_id>"  # noqa: E501
    assert response.message == msg


@pytest.mark.parametrize(
    "name,key,id",
    [
        ("Artist", "artists", 1),
        ("Character", "characters", 1),
        ("Circle", "circles", 1),
        ("World", "worlds", 1),
    ],
    ids=[
        "artist",
        "character",
        "circle",
        "world",
    ],
)
@pytest.mark.anyio
async def test_update_comic_fails_assoc_not_found(
    update_comics, gen_archive, name, key, id
):
    archive = next(gen_archive)
    comic = await DB.add(
        Comic(
            title="Dusty Old Comic",
            archive=archive,
            cover=archive.cover,
            pages=archive.pages,
        )
    )

    response = Response(await update_comics(comic.id, {key: {"ids": [id]}}))
    response.assert_is("IDNotFoundError")
    assert response.id == id
    assert response.message == f"{name} ID not found: '{id}'"


@pytest.mark.parametrize(
    "option",
    [
        None,
        {},
        {"onMissing": "IGNORE"},
    ],
    ids=[
        "default option",
        "empty option",
        "explicit option",
    ],
)
@pytest.mark.anyio
async def test_upsert_comic_ignores_with(upsert_comics, gen_comic, option):
    original_comic = await DB.add(next(gen_comic))

    new_artists = await DB.add_all(Artist(name="arty"), Artist(name="farty"))

    input = {
        "artists": {
            "names": [a.name for a in new_artists] + ["newy"],
            "options": option,
        },
    }
    response = Response(await upsert_comics(original_comic.id, input))
    response.assert_is("UpsertSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)
    assert comic is not None

    assert_assocs_match(comic.artists, original_comic.artists + list(new_artists))


@pytest.mark.parametrize(
    "option",
    [
        None,
        {},
        {"onMissing": "IGNORE"},
    ],
    ids=[
        "default option",
        "empty option",
        "explicit option",
    ],
)
@pytest.mark.anyio
async def test_upsert_comic_ignores_missing_tags_with(upsert_comics, gen_comic, option):
    original_comic = await DB.add(next(gen_comic))

    tags = await DB.add_all(
        ComicTag(
            comic_id=original_comic.id,
            namespace=Namespace(name="foo"),
            tag=Tag(name="bar"),
        )
    )

    input = {
        "tags": {"names": ["foo:bar", "baz:qux"], "options": option},
    }
    response = Response(await upsert_comics(original_comic.id, input))
    response.assert_is("UpsertSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)
    assert comic is not None

    assert_assocs_match(comic.tags, original_comic.tags + list(tags))


@pytest.mark.parametrize(
    "option",
    [
        None,
        {},
        {"onMissing": "IGNORE"},
        {"onMissing": "CREATE"},
    ],
    ids=[
        "default option",
        "empty option",
        "IGNORE",
        "CREATE",
    ],
)
@pytest.mark.anyio
async def test_upsert_comic_skips_existing_tags(upsert_comics, gen_comic, option):
    comic = await DB.add(next(gen_comic))

    ctag = comic.tags[0]
    names = [f"{ctag.namespace.name}:{ctag.tag.name}"]

    input = {
        "tags": {"names": names, "options": option},
    }
    response = Response(await upsert_comics(comic.id, input))
    response.assert_is("UpsertSuccess")

    comic = await DB.get(Comic, comic.id, full=True)
    assert comic is not None

    assert_assocs_match(comic.tags, comic.tags)


@pytest.mark.parametrize(
    "valid",
    [
        True,
        False,
    ],
    ids=[
        "valid combination",
        "invalid combination",
    ],
)
@pytest.mark.anyio
async def test_upsert_comic_ignore_missing_handles_resident(
    upsert_comics, gen_comic, valid
):
    original_comic = await DB.add(next(gen_comic))

    namespace = await DB.add(Namespace(name="foo"))
    tag = Tag(name="bar")
    if valid:
        tag.namespaces = [namespace]

    tag = await DB.add(tag)
    ctag = ComicTag(namespace=namespace, tag=tag)

    expected_tags = original_comic.tags

    if valid:
        expected_tags.append(ctag)

    input = {
        "tags": {"names": ["foo:bar"], "options": {"onMissing": "IGNORE"}},
    }
    response = Response(await upsert_comics(original_comic.id, input))
    response.assert_is("UpsertSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)
    assert comic is not None

    assert_assocs_match(comic.tags, expected_tags)


@pytest.mark.anyio
async def test_upsert_comic_tags_uses_existing(upsert_comics, empty_comic):
    original_comic = await DB.add(empty_comic)

    await DB.add_all(Namespace(name="foo"))
    await DB.add_all(Tag(name="bar"))

    tag_names = ["foo:bar"]

    response = Response(
        await upsert_comics(
            original_comic.id,
            {"tags": {"names": tag_names, "options": {"onMissing": "CREATE"}}},
        )
    )
    response.assert_is("UpsertSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)
    assert comic is not None

    assert set(tag_names) == set(
        [f"{t.namespace.name}:{t.tag.name}" for t in comic.tags]
    )


@pytest.mark.parametrize(
    "key,items",
    [
        ("artists", ["arty", "farty"]),
        ("tags", ["alien:medium", "human:tiny"]),
        ("artists", ["arty", "arty"]),
        ("tags", ["foo:good", "bar:good"]),
        ("tags", ["foo:good", "foo:bad"]),
        ("artists", []),
    ],
    ids=[
        "artists",
        "tags",
        "artists (duplicate)",
        "tags (duplicate)",
        "namespace (duplicate)",
        "artists (empty)",
    ],
)
@pytest.mark.anyio
async def test_upsert_comic_creates(upsert_comics, empty_comic, key, items):
    original_comic = await DB.add(empty_comic)

    input = {
        key: {"names": items, "options": {"onMissing": "CREATE"}},
    }
    response = Response(await upsert_comics(original_comic.id, input))
    response.assert_is("UpsertSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)
    assert comic is not None

    assert set(items) == set([o.name for o in getattr(comic, key)])


@pytest.mark.anyio
async def test_upsert_comic_fails_creating_empty_assoc_name(upsert_comics, gen_comic):
    comic = await DB.add(next(gen_comic))

    input = {
        "artists": {"names": ""},
    }
    response = Response(await upsert_comics(comic.id, input))
    response.assert_is("InvalidParameterError")
    assert response.parameter == "Artist.name"


@pytest.mark.anyio
async def test_upsert_comic_does_not_replace(upsert_comics, gen_comic):
    original_comic = await DB.add(next(gen_comic))
    original_artists = set([a.name for a in original_comic.artists])

    input = {
        "artists": {"names": []},
    }
    response = Response(await upsert_comics(original_comic.id, input))
    response.assert_is("UpsertSuccess")

    comic = await DB.get(Comic, original_comic.id)
    artists = set([a.name for a in comic.artists])

    assert artists == original_artists


@pytest.mark.parametrize(
    "input",
    [
        "",
        ":tiny",
        "human:",
        "medium",
    ],
    ids=[
        "empty",
        "namespace missing",
        "tag missing",
        "wrong format",
    ],
)
@pytest.mark.anyio
async def test_upsert_comic_fails_creating_invalid_tag(upsert_comics, gen_comic, input):
    comic = await DB.add(next(gen_comic))

    input = {
        "tags": {"names": [input]},
    }
    response = Response(await upsert_comics(comic.id, input))
    response.assert_is("InvalidParameterError")
    assert response.parameter == "name"
    msg = (
        "Invalid parameter 'name': ComicTag name must be specified as <namespace>:<tag>"
    )
    assert response.message == msg


@pytest.mark.parametrize(
    "options",
    [
        None,
        {},
        {"mode": "REPLACE"},
    ],
    ids=[
        "by default (none)",
        "by default (empty record)",
        "when defined explicitly",
    ],
)
@pytest.mark.anyio
async def test_update_comic_replaces_assocs(update_comics, gen_comic, options):
    original_comic = await DB.add(next(gen_comic))
    new_artist = await DB.add(Artist(name="max"))

    input = {
        "artists": {"ids": [new_artist.id]},
    }
    response = Response(await update_comics(original_comic.id, input))
    response.assert_is("UpdateSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)

    assert_assocs_match(comic.artists, [new_artist])


@pytest.mark.anyio
async def test_update_comic_adds_assocs(update_comics, gen_comic):
    original_comic = await DB.add(next(gen_comic))
    new_artist = await DB.add(Artist(name="max"))
    added_artists = original_comic.artists + [new_artist]

    input = {
        "artists": {"ids": [new_artist.id], "options": {"mode": "ADD"}},
    }
    response = Response(await update_comics(original_comic.id, input))
    response.assert_is("UpdateSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)

    assert_assocs_match(comic.artists, added_artists)


@pytest.mark.anyio
async def test_update_comic_adds_existing_assocs(update_comics, gen_comic):
    original_comic = await DB.add(next(gen_comic))
    artists = original_comic.artists

    input = {
        "artists": {
            "ids": [artist.id for artist in artists],
            "options": {"mode": "ADD"},
        },
    }
    response = Response(await update_comics(original_comic.id, input))
    response.assert_is("UpdateSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)

    assert_assocs_match(comic.artists, artists)


@pytest.mark.anyio
async def test_update_comic_adds_tags(update_comics, gen_comic):
    original_comic = await DB.add(next(gen_comic))
    new_namespace = await DB.add(Namespace(name="new"))
    new_tag = await DB.add(Tag(name="new"))
    added_tags = original_comic.tags + [
        ComicTag(comic_id=original_comic.id, tag=new_tag, namespace=new_namespace)
    ]

    input = {
        "tags": {
            "ids": [f"{new_namespace.id}:{new_tag.id}"],
            "options": {"mode": "ADD"},
        },
    }
    response = Response(await update_comics(original_comic.id, input))
    response.assert_is("UpdateSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)
    assert_assocs_match(comic.tags, added_tags)


@pytest.mark.anyio
async def test_update_comic_adds_existing_tags(update_comics, gen_comic):
    original_comic = await DB.add(next(gen_comic))
    tags = original_comic.tags

    input = {
        "tags": {
            "ids": [f"{tag.namespace.id}:{tag.tag.id}" for tag in tags],
            "options": {"mode": "ADD"},
        },
    }
    response = Response(await update_comics(original_comic.id, input))
    response.assert_is("UpdateSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)
    assert_assocs_match(comic.tags, tags)


@pytest.mark.anyio
async def test_update_comic_removes_assocs(update_comics, empty_comic):
    original_comic = empty_comic
    removed_artist = Artist(id=1, name="sam")
    remaining_artist = Artist(id=2, name="max")
    original_comic.artists = [removed_artist, remaining_artist]
    original_comic = await DB.add(original_comic)

    input = {
        "artists": {"ids": [removed_artist.id], "options": {"mode": "REMOVE"}},
    }
    response = Response(await update_comics(original_comic.id, input))
    response.assert_is("UpdateSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)

    assert_assocs_match(comic.artists, [remaining_artist])


@pytest.mark.anyio
async def test_update_comic_removes_tags(update_comics, empty_comic):
    original_comic = empty_comic
    removed_tag = ComicTag(
        comic_id=original_comic.id,
        tag=Tag(id=1, name="gone"),
        namespace=Namespace(id=1, name="all"),
    )
    remaining_tag = ComicTag(
        comic_id=original_comic.id,
        tag=Tag(id=2, name="there"),
        namespace=Namespace(id=2, name="still"),
    )
    original_comic.tags = [removed_tag, remaining_tag]
    original_comic = await DB.add(original_comic)

    input = {
        "tags": {"ids": ["1:1"], "options": {"mode": "REMOVE"}},
    }
    response = Response(await update_comics(original_comic.id, input))
    response.assert_is("UpdateSuccess")

    comic = await DB.get(Comic, original_comic.id, full=True)

    assert_assocs_match(comic.tags, [remaining_tag])


@pytest.mark.parametrize(
    "rows,input",
    [
        ([], {"title": "Updated Comic"}),
        (
            [Artist(id=1, name="artist")],
            {"artists": {"ids": [1]}},
        ),
        (
            [Artist(id=1, name="artist"), ComicArtist(artist_id=1, comic_id=100)],
            {"title": "Updated Comic", "artists": {"ids": [1]}},
        ),
        (
            [
                Namespace(id=1, name="ns"),
                Tag(id=1, name="artist"),
            ],
            {"tags": {"ids": ["1:1"]}},
        ),
        (
            [
                Namespace(id=1, name="ns"),
                Tag(id=1, name="artist"),
                ComicTag(namespace_id=1, tag_id=1, comic_id=100),
            ],
            {"title": "Updated Comic", "tags": {"ids": ["1:1"]}},
        ),
    ],
    ids=[
        "with scalar",
        "with assoc",
        "with scalar and existing assoc",
        "with tag",
        "with scalar and existing tag",
    ],
)
@pytest.mark.anyio
async def test_update_comic_changes_updated_at(update_comics, empty_comic, rows, input):
    original_comic = empty_comic
    original_comic.updated_at = dt(2023, 1, 1, tzinfo=timezone.utc)
    original_comic = await DB.add(original_comic)

    await DB.add_all(*rows)

    response = Response(await update_comics(original_comic.id, input))
    response.assert_is("UpdateSuccess")

    comic = await DB.get(Comic, original_comic.id)
    assert comic.updated_at > original_comic.updated_at


@pytest.mark.anyio
async def test_update_comic_cover_fails_page_not_found(update_comics, gen_comic):
    comic = await DB.add(next(gen_comic))

    response = Response(await update_comics(comic.id, {"cover": {"id": 100}}))
    response.assert_is("IDNotFoundError")
    assert response.id == 100
    assert response.message == "Page ID not found: '100'"


@pytest.mark.anyio
async def test_update_comic_cover_fails_page_remote(
    update_comics, gen_comic, gen_archive
):
    comic = await DB.add(next(gen_comic))
    other_archive = await DB.add(next(gen_archive))
    remote_id = other_archive.pages[0].id

    response = Response(await update_comics(comic.id, {"cover": {"id": remote_id}}))
    response.assert_is("PageRemoteError")
    assert response.id == remote_id
    assert response.archiveId == other_archive.id
    assert (
        response.message
        == f"Page ID {remote_id} comes from remote archive ID {other_archive.id}"
    )


@pytest.mark.anyio
async def test_update_comic_pages_fails_page_not_found(update_comics, gen_comic):
    comic = await DB.add(next(gen_comic))

    response = Response(await update_comics(comic.id, {"pages": {"ids": 100}}))
    response.assert_is("IDNotFoundError")
    assert response.id == 100
    assert response.message == "Page ID not found: '100'"


@pytest.mark.anyio
async def test_update_comic_pages_fails_page_remote(
    update_comics, gen_comic, gen_archive
):
    comic = await DB.add(next(gen_comic))
    other_archive = await DB.add(next(gen_archive))
    remote_id = other_archive.pages[0].id

    response = Response(await update_comics(comic.id, {"pages": {"ids": [remote_id]}}))
    response.assert_is("PageRemoteError")
    assert response.id == remote_id
    assert response.archiveId == other_archive.id
    assert (
        response.message
        == f"Page ID {remote_id} comes from remote archive ID {other_archive.id}"
    )


@pytest.mark.anyio
async def test_update_comic_pages_fails_page_claimed(update_comics, gen_archive):
    archive = await DB.add(next(gen_archive))

    comic = await DB.add(
        Comic(
            id=1,
            title="A Very Good Comic",
            archive=archive,
            cover=archive.pages[0].image,
            pages=[archive.pages[0], archive.pages[1]],
        )
    )

    claiming = await DB.add(
        Comic(
            id=2,
            title="A Very Claiming Comic",
            archive=archive,
            cover=archive.pages[2].image,
            pages=[archive.pages[2], archive.pages[3]],
        )
    )

    claimed_id = claiming.pages[0].id

    response = Response(await update_comics(comic.id, {"pages": {"ids": [claimed_id]}}))
    response.assert_is("PageClaimedError")
    assert response.id == claimed_id
    assert response.comicId == claiming.id
    assert (
        response.message
        == f"Page ID {claimed_id} is already claimed by comic ID {claiming.id}"
    )


@pytest.mark.parametrize(
    "mode",
    [
        ("REPLACE"),
        ("REMOVE"),
    ],
)
@pytest.mark.anyio
async def test_update_comic_pages_fails_empty(update_comics, gen_comic, mode):
    comic = await DB.add(next(gen_comic))

    ids = [] if mode == "REPLACE" else [p.id for p in comic.pages]

    response = Response(
        await update_comics(
            comic.id, {"pages": {"ids": ids, "options": {"mode": mode}}}
        )
    )
    response.assert_is("InvalidParameterError")
    assert response.parameter == "pages"
    assert response.message == "Invalid parameter 'pages': cannot be empty"