import random

import pytest
from fastapi.testclient import TestClient

from mealie.schema.household.group_shopping_list import (
    ShoppingListAddRecipeParamsBulk,
    ShoppingListItemOut,
    ShoppingListItemUpdate,
    ShoppingListItemUpdateBulk,
    ShoppingListOut,
)
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientFood
from tests import utils
from tests.utils import api_routes
from tests.utils.assertion_helpers import assert_deserialize
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser


def test_shopping_lists_get_all(api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]):
    response = api_client.get(api_routes.households_shopping_lists, headers=unique_user.token)
    assert response.status_code == 200
    all_lists = response.json()["items"]

    assert len(all_lists) == len(shopping_lists)

    known_ids = [str(model.id) for model in shopping_lists]

    for list_ in all_lists:
        assert list_["id"] in known_ids


def test_shopping_lists_create_one(api_client: TestClient, unique_user: TestUser):
    payload = {
        "name": random_string(10),
    }

    response = api_client.post(api_routes.households_shopping_lists, json=payload, headers=unique_user.token)
    response_list = utils.assert_deserialize(response, 201)

    assert response_list["name"] == payload["name"]
    assert response_list["groupId"] == str(unique_user.group_id)
    assert response_list["householdId"] == str(unique_user.household_id)
    assert response_list["userId"] == str(unique_user.user_id)


def test_shopping_lists_get_one(api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]):
    shopping_list = shopping_lists[0]

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(shopping_list.id),
        headers=unique_user.token,
    )
    assert response.status_code == 200

    response_list = response.json()

    assert response_list["id"] == str(shopping_list.id)
    assert response_list["name"] == shopping_list.name
    assert response_list["groupId"] == str(shopping_list.group_id)
    assert response_list["householdId"] == str(unique_user.household_id)
    assert response_list["userId"] == str(shopping_list.user_id)


def test_shopping_lists_update_one(
    api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]
):
    sample_list = random.choice(shopping_lists)

    payload = {
        "name": random_string(10),
        "id": str(sample_list.id),
        "groupId": str(sample_list.group_id),
        "householdId": str(sample_list.household_id),
        "userId": str(sample_list.user_id),
        "listItems": [],
    }

    response = api_client.put(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        json=payload,
        headers=unique_user.token,
    )
    assert response.status_code == 200

    response_list = response.json()

    assert response_list["id"] == str(sample_list.id)
    assert response_list["name"] == payload["name"]
    assert response_list["groupId"] == str(sample_list.group_id)
    assert response_list["householdId"] == str(sample_list.household_id)
    assert response_list["userId"] == str(sample_list.user_id)


def test_shopping_lists_delete_one(
    api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]
):
    sample_list = random.choice(shopping_lists)

    response = api_client.delete(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    assert response.status_code == 200

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    assert response.status_code == 404


def test_shopping_lists_add_recipe(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
    recipe_ingredient_only: Recipe,
):
    sample_list = random.choice(shopping_lists)
    recipe = recipe_ingredient_only

    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id),
        headers=unique_user.token,
    )
    assert response.status_code == 200

    # get list and verify items against ingredients
    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)
    assert len(as_json["listItems"]) == len(recipe.recipe_ingredient)

    known_ingredients = {ingredient.note: ingredient for ingredient in recipe.recipe_ingredient}
    for item in as_json["listItems"]:
        assert item["note"] in known_ingredients

        ingredient = known_ingredients[item["note"]]
        assert item["quantity"] == (ingredient.quantity or 0)

    # check recipe reference was added with quantity 1
    refs = as_json["recipeReferences"]
    assert len(refs) == 1
    assert refs[0]["recipeId"] == str(recipe.id)
    assert refs[0]["recipeQuantity"] == 1

    # add the recipe again and check the resulting items
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id),
        headers=unique_user.token,
    )
    assert response.status_code == 200

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)
    assert len(as_json["listItems"]) == len(recipe.recipe_ingredient)

    for item in as_json["listItems"]:
        assert item["note"] in known_ingredients

        ingredient = known_ingredients[item["note"]]
        assert item["quantity"] == (ingredient.quantity or 0) * 2

    refs = as_json["recipeReferences"]
    assert len(refs) == 1
    assert refs[0]["recipeId"] == str(recipe.id)
    assert refs[0]["recipeQuantity"] == 2


def test_shopping_lists_add_recipes(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
    recipes_ingredient_only: list[Recipe],
):
    sample_list = random.choice(shopping_lists)
    recipes = recipes_ingredient_only
    all_ingredients = [ingredient for recipe in recipes for ingredient in recipe.recipe_ingredient]

    recipes_post_data = utils.jsonify(
        [ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump() for recipe in recipes]
    )
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(sample_list.id),
        json=recipes_post_data,
        headers=unique_user.token,
    )
    assert response.status_code == 200

    # get list and verify items against ingredients
    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)
    assert len(as_json["listItems"]) == len(all_ingredients)

    known_ingredients = {ingredient.note: ingredient for ingredient in all_ingredients}
    for item in as_json["listItems"]:
        assert item["note"] in known_ingredients

        ingredient = known_ingredients[item["note"]]
        assert item["quantity"] == (ingredient.quantity or 0)

    # check recipe reference was added with quantity 1
    refs = as_json["recipeReferences"]
    assert len(refs) == len(recipes)
    refs_by_id = {ref["recipeId"]: ref for ref in refs}
    for recipe in recipes:
        assert str(recipe.id) in refs_by_id
        assert refs_by_id[str(recipe.id)]["recipeQuantity"] == 1

    # add the recipes again and check the resulting items
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(sample_list.id),
        json=recipes_post_data,
        headers=unique_user.token,
    )
    assert response.status_code == 200

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)
    assert len(as_json["listItems"]) == len(all_ingredients)

    for item in as_json["listItems"]:
        assert item["note"] in known_ingredients

        ingredient = known_ingredients[item["note"]]
        assert item["quantity"] == (ingredient.quantity or 0) * 2

    refs = as_json["recipeReferences"]
    assert len(refs) == len(recipes)
    refs_by_id = {ref["recipeId"]: ref for ref in refs}
    for recipe in recipes:
        assert str(recipe.id) in refs_by_id
        assert refs_by_id[str(recipe.id)]["recipeQuantity"] == 2


def test_shopping_lists_add_nested_recipe_ingredients(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
):
    """Test that adding a recipe with nested recipe ingredients flattens all ingredients (a -> b -> c)."""
    shopping_list = random.choice(shopping_lists)
    database = unique_user.repos

    # Create three food items for the base recipes
    food_c = database.ingredient_foods.create(
        SaveIngredientFood(
            name=random_string(10),
            group_id=unique_user.group_id,
        )
    )

    food_b = database.ingredient_foods.create(
        SaveIngredientFood(
            name=random_string(10),
            group_id=unique_user.group_id,
        )
    )

    food_a = database.ingredient_foods.create(
        SaveIngredientFood(
            name=random_string(10),
            group_id=unique_user.group_id,
        )
    )

    # Create recipe_c with a single food ingredient (base recipe)
    recipe_c: Recipe = database.recipes.create(
        Recipe(
            name=random_string(10),
            user_id=unique_user.user_id,
            group_id=unique_user.group_id,
            recipe_ingredient=[
                RecipeIngredient(note=f"ingredient from recipe c - {food_c.name}", food=food_c, quantity=1),
            ],
        )
    )

    # Create recipe_b with its own food ingredient and a reference to recipe_c (c -> b)
    recipe_b: Recipe = database.recipes.create(
        Recipe(
            name=random_string(10),
            user_id=unique_user.user_id,
            group_id=unique_user.group_id,
            recipe_ingredient=[
                RecipeIngredient(note=f"ingredient from recipe b - {food_b.name}", food=food_b, quantity=2),
                RecipeIngredient(note="nested recipe c", referenced_recipe=recipe_c),
            ],
        )
    )

    # Create recipe_a with its own food ingredient and a reference to recipe_b (b -> a, creating chain c -> b -> a)
    recipe_a: Recipe = database.recipes.create(
        Recipe(
            name=random_string(10),
            user_id=unique_user.user_id,
            group_id=unique_user.group_id,
            recipe_ingredient=[
                RecipeIngredient(note=f"ingredient from recipe a - {food_a.name}", food=food_a, quantity=3),
                RecipeIngredient(note="nested recipe b", referenced_recipe=recipe_b),
            ],
        )
    )

    # Add recipe_a to the shopping list
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(shopping_list.id),
        json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe_a.id).model_dump()]),
        headers=unique_user.token,
    )
    assert response.status_code == 200

    # Get the shopping list and verify all ingredients from a, b, and c are flattened
    response = api_client.get(
        api_routes.households_shopping_lists_item_id(shopping_list.id),
        headers=unique_user.token,
    )
    shopping_list_data = utils.assert_deserialize(response, 200)

    # Should have 3 items: one from recipe_a, one from recipe_b, and one from recipe_c
    assert len(shopping_list_data["listItems"]) == 3

    # Verify each ingredient is present with the correct quantity
    found_ingredients = {
        food_a.name: False,
        food_b.name: False,
        food_c.name: False,
    }

    for item in shopping_list_data["listItems"]:
        if food_a.name in item["note"]:
            assert item["quantity"] == 3
            found_ingredients[food_a.name] = True
        elif food_b.name in item["note"]:
            assert item["quantity"] == 2
            found_ingredients[food_b.name] = True
        elif food_c.name in item["note"]:
            assert item["quantity"] == 1
            found_ingredients[food_c.name] = True

    # Ensure all ingredients were found
    assert all(found_ingredients.values()), f"Missing ingredients: {found_ingredients}"

    # Verify recipe reference
    refs = shopping_list_data["recipeReferences"]
    assert len(refs) == 1
    assert refs[0]["recipeId"] == str(recipe_a.id)
    assert refs[0]["recipeQuantity"] == 1


@pytest.mark.parametrize("is_private_household", [True, False])
@pytest.mark.parametrize("household_lock_recipe_edits", [True, False])
def test_shopping_lists_add_cross_household_recipe(
    api_client: TestClient,
    unique_user: TestUser,
    h2_user: TestUser,
    shopping_lists: list[ShoppingListOut],
    is_private_household: bool,
    household_lock_recipe_edits: bool,
):
    sample_list = random.choice(shopping_lists)
    item_name = random_string(10)

    # set up household
    household = h2_user.repos.households.get_one(h2_user.household_id)
    assert household and household.preferences
    household.preferences.private_household = is_private_household
    household.preferences.lock_recipe_edits_from_other_households = household_lock_recipe_edits
    h2_user.repos.household_preferences.update(household.id, household.preferences)

    # set up recipe
    recipe = h2_user.repos.recipes.create(
        Recipe(
            user_id=h2_user.user_id,
            group_id=h2_user.group_id,
            name=random_string(10),
            recipe_ingredient=[{"note": item_name, "quantity": 1}],
        )
    )

    # add recipe once
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id),
        headers=unique_user.token,
    )
    assert response.status_code == 200
    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)
    assert len(as_json["listItems"]) == 1
    assert as_json["listItems"][0]["note"] == item_name
    assert as_json["listItems"][0]["quantity"] == 1

    # add recipe again
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id),
        headers=unique_user.token,
    )
    assert response.status_code == 200
    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)
    assert len(as_json["listItems"]) == 1
    assert as_json["listItems"][0]["note"] == item_name
    assert as_json["listItems"][0]["quantity"] == 2


def test_shopping_lists_add_one_with_zero_quantity(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
):
    shopping_list = random.choice(shopping_lists)

    # build a recipe that has some ingredients with a null quantity
    response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token)
    recipe_slug = utils.assert_deserialize(response, 201)

    response = api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token)
    recipe_data = utils.assert_deserialize(response, 200)

    ingredient_1 = {"quantity": random_int(1, 10), "note": random_string()}
    ingredient_2 = {"quantity": random_int(1, 10), "note": random_string()}
    ingredient_3_null_qty = {"quantity": None, "note": random_string()}

    recipe_data["recipeIngredient"] = [
        ingredient_1,
        ingredient_2,
        ingredient_3_null_qty,
    ]
    response = api_client.put(
        f"{api_routes.recipes}/{recipe_slug}",
        json=recipe_data,
        headers=unique_user.token,
    )
    utils.assert_deserialize(response, 200)

    recipe = Recipe.model_validate_json(
        api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content
    )
    assert recipe.id
    assert len(recipe.recipe_ingredient) == 3

    # add the recipe to the list and make sure there are three list items
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(shopping_list.id),
        json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]),
        headers=unique_user.token,
    )

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(shopping_list.id),
        headers=unique_user.token,
    )
    shopping_list_out = ShoppingListOut.model_validate(utils.assert_deserialize(response, 200))

    assert len(shopping_list_out.list_items) == 3

    found = False
    for item in shopping_list_out.list_items:
        if item.note != ingredient_3_null_qty["note"]:
            continue

        found = True
        assert item.quantity == 0

    assert found


def test_shopping_lists_add_custom_recipe_items(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
    recipe_ingredient_only: Recipe,
):
    sample_list = random.choice(shopping_lists)
    recipe = recipe_ingredient_only

    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(sample_list.id),
        json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]),
        headers=unique_user.token,
    )
    assert response.status_code == 200

    custom_items = random.sample(recipe_ingredient_only.recipe_ingredient, k=3)
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(sample_list.id),
        json=utils.jsonify(
            [ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id, recipe_ingredients=custom_items).model_dump()]
        ),
        headers=unique_user.token,
    )
    assert response.status_code == 200

    # get list and verify items against ingredients
    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)
    assert len(as_json["listItems"]) == len(recipe.recipe_ingredient)

    known_ingredients = {ingredient.note: ingredient for ingredient in recipe.recipe_ingredient}
    custom_ingredients = [ingredient.note for ingredient in custom_items]
    for item in as_json["listItems"]:
        assert item["note"] in known_ingredients

        ingredient = known_ingredients[item["note"]]
        if item["note"] in custom_ingredients:
            assert item["quantity"] == (ingredient.quantity * 2 if ingredient.quantity else 0)

        else:
            assert item["quantity"] == (ingredient.quantity or 0)

    # check recipe reference was added with quantity 2
    refs = as_json["recipeReferences"]
    assert len(refs) == 1
    assert refs[0]["recipeId"] == str(recipe.id)
    assert refs[0]["recipeQuantity"] == 2


def test_shopping_list_ref_removes_itself(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_list: ShoppingListOut,
    recipe_ingredient_only: Recipe,
):
    # add a recipe to a list, then check off all recipe items and make sure the recipe ref is deleted
    recipe = recipe_ingredient_only
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(shopping_list.id),
        json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]),
        headers=unique_user.token,
    )
    utils.assert_deserialize(response, 200)

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(shopping_list.id),
        headers=unique_user.token,
    )
    shopping_list_json = utils.assert_deserialize(response, 200)
    assert len(shopping_list_json["listItems"]) == len(recipe.recipe_ingredient)
    assert len(shopping_list_json["recipeReferences"]) == 1

    for item in shopping_list_json["listItems"]:
        item["checked"] = True

    response = api_client.put(
        api_routes.households_shopping_items,
        json=shopping_list_json["listItems"],
        headers=unique_user.token,
    )
    utils.assert_deserialize(response, 200)

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(shopping_list.id),
        headers=unique_user.token,
    )
    shopping_list_json = utils.assert_deserialize(response, 200)
    assert len(shopping_list_json["recipeReferences"]) == 0


def test_shopping_lists_add_recipe_with_merge(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
):
    shopping_list = random.choice(shopping_lists)

    # build a recipe that has some ingredients more than once
    response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token)
    recipe_slug = utils.assert_deserialize(response, 201)

    response = api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token)
    recipe_data = utils.assert_deserialize(response, 200)

    ingredient_1 = {"quantity": random_int(1, 10), "note": random_string()}
    ingredient_2 = {"quantity": random_int(1, 10), "note": random_string()}
    ingredient_duplicate_1 = {"quantity": random_int(1, 10), "note": random_string()}
    ingredient_duplicate_2 = {
        "quantity": random_int(1, 10),
        "note": ingredient_duplicate_1["note"],
    }

    recipe_data["recipeIngredient"] = [
        ingredient_1,
        ingredient_2,
        ingredient_duplicate_1,
        ingredient_duplicate_2,
    ]
    response = api_client.put(
        f"{api_routes.recipes}/{recipe_slug}",
        json=recipe_data,
        headers=unique_user.token,
    )
    utils.assert_deserialize(response, 200)

    recipe = Recipe.model_validate_json(
        api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content
    )
    assert recipe.id
    assert len(recipe.recipe_ingredient) == 4

    # add the recipe to the list and make sure there are only three list items, and their quantities/refs are correct
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(shopping_list.id),
        json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]),
        headers=unique_user.token,
    )

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(shopping_list.id),
        headers=unique_user.token,
    )
    shopping_list_out = ShoppingListOut.model_validate(utils.assert_deserialize(response, 200))

    assert len(shopping_list_out.list_items) == 3

    found_item_1 = False
    found_item_2 = False
    found_duplicate_item = False
    for list_item in shopping_list_out.list_items:
        assert len(list_item.recipe_references) == 1

        ref = list_item.recipe_references[0]
        assert ref.recipe_scale == 1
        assert ref.recipe_quantity == list_item.quantity

        if list_item.note == ingredient_1["note"]:
            assert list_item.quantity == ingredient_1["quantity"]
            found_item_1 = True

        elif list_item.note == ingredient_2["note"]:
            assert list_item.quantity == ingredient_2["quantity"]
            found_item_2 = True

        elif list_item.note == ingredient_duplicate_1["note"]:
            combined_quantity = ingredient_duplicate_1["quantity"] + ingredient_duplicate_2["quantity"]  # type: ignore
            assert list_item.quantity == combined_quantity
            found_duplicate_item = True

    assert all([found_item_1, found_item_2, found_duplicate_item])


def test_shopping_lists_add_recipes_with_merge(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
):
    shopping_list = random.choice(shopping_lists)

    # build two recipes that share an ingredient
    recipes: list[Recipe] = []
    common_note = random_string()
    for _ in range(2):
        response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token)
        recipe_slug = utils.assert_deserialize(response, 201)

        response = api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token)
        recipe_data = utils.assert_deserialize(response, 200)

        ingredient_unique = {"quantity": 1, "note": random_string()}
        ingredient_duplicate = {"quantity": 1, "note": common_note}

        recipe_data["recipeIngredient"] = [ingredient_unique, ingredient_duplicate]
        response = api_client.put(
            f"{api_routes.recipes}/{recipe_slug}",
            json=recipe_data,
            headers=unique_user.token,
        )
        utils.assert_deserialize(response, 200)

        recipe = Recipe.model_validate_json(
            api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content
        )
        assert recipe.id
        assert len(recipe.recipe_ingredient) == 2
        recipes.append(recipe)

    # add the recipes to the list and make sure there are only five list items, and their quantities/refs are correct
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(shopping_list.id),
        json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump() for recipe in recipes]),
        headers=unique_user.token,
    )

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(shopping_list.id),
        headers=unique_user.token,
    )
    shopping_list_out = ShoppingListOut.model_validate(utils.assert_deserialize(response, 200))

    assert len(shopping_list_out.list_items) == 3

    found_recipe_1_item = False
    found_recipe_2_item = False
    found_duplicate_item = False
    for list_item in shopping_list_out.list_items:
        if list_item.note == common_note:
            assert list_item.quantity == 2
            assert len(list_item.recipe_references) == 2
            assert {ref.recipe_id for ref in list_item.recipe_references} == {recipe.id for recipe in recipes}
            found_duplicate_item = True

        else:
            assert len(list_item.recipe_references) == 1
            assert list_item.quantity == 1
            if list_item.note == recipes[0].recipe_ingredient[0].note:
                assert list_item.recipe_references[0].recipe_id == recipes[0].id
                found_recipe_1_item = True
            elif list_item.note == recipes[1].recipe_ingredient[0].note:
                assert list_item.recipe_references[0].recipe_id == recipes[1].id
                found_recipe_2_item = True
            else:
                raise Exception("Unexpected item")

        for ref in list_item.recipe_references:
            assert ref.recipe_scale == 1
            assert ref.recipe_quantity == 1

    assert all([found_recipe_1_item, found_recipe_2_item, found_duplicate_item])


def test_shopping_list_add_recipe_scale(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
    recipe_ingredient_only: Recipe,
):
    sample_list = random.choice(shopping_lists)
    recipe = recipe_ingredient_only

    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(sample_list.id),
        json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]),
        headers=unique_user.token,
    )

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)

    assert len(as_json["recipeReferences"]) == 1
    assert as_json["recipeReferences"][0]["recipeQuantity"] == 1

    for item in as_json["listItems"]:
        assert item["quantity"] == 1
        refs = item["recipeReferences"]

        # only one reference per item
        assert len(refs) == 1

        # base recipe quantity is 1
        assert refs[0]["recipeQuantity"] == 1

        # scale was unspecified, which defaults to 1
        assert refs[0]["recipeScale"] == 1

    recipe_scale = round(random.uniform(1, 10), 5)
    payload = utils.jsonify(
        [ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id, recipe_increment_quantity=recipe_scale).model_dump()]
    )

    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(sample_list.id),
        headers=unique_user.token,
        json=payload,
    )

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)

    assert len(as_json["recipeReferences"]) == 1
    assert as_json["recipeReferences"][0]["recipeQuantity"] == 1 + recipe_scale

    for item in as_json["listItems"]:
        assert item["quantity"] == 1 + recipe_scale
        refs = item["recipeReferences"]

        assert len(refs) == 1
        assert refs[0]["recipeQuantity"] == 1
        assert refs[0]["recipeScale"] == 1 + recipe_scale


def test_shopping_lists_remove_recipe(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
    recipe_ingredient_only: Recipe,
):
    sample_list = random.choice(shopping_lists)
    recipe = recipe_ingredient_only

    # add two instances of the recipe
    payload = utils.jsonify(
        [ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id, recipe_increment_quantity=2).model_dump()]
    )
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(sample_list.id),
        json=payload,
        headers=unique_user.token,
    )
    assert response.status_code == 200

    # remove one instance of the recipe
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe_recipe_id_delete(sample_list.id, recipe.id),
        headers=unique_user.token,
    )
    assert response.status_code == 200

    # get list and verify items against ingredients
    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)
    assert len(as_json["listItems"]) == len(recipe.recipe_ingredient)

    known_ingredients = {ingredient.note: ingredient for ingredient in recipe.recipe_ingredient}
    for item in as_json["listItems"]:
        assert item["note"] in known_ingredients

        ingredient = known_ingredients[item["note"]]
        assert item["quantity"] == (ingredient.quantity or 0)

    # check recipe reference was reduced to 1
    refs = as_json["recipeReferences"]
    assert len(refs) == 1
    assert refs[0]["recipeId"] == str(recipe.id)
    assert refs[0]["recipeQuantity"] == 1

    # remove the recipe again and check if the list is empty
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe_recipe_id_delete(sample_list.id, recipe.id),
        headers=unique_user.token,
    )
    assert response.status_code == 200

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)
    assert len(as_json["listItems"]) == 0
    assert len(as_json["recipeReferences"]) == 0


def test_shopping_lists_remove_recipe_multiple_quantity(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
    recipe_ingredient_only: Recipe,
):
    sample_list = random.choice(shopping_lists)
    recipe = recipe_ingredient_only

    for _ in range(3):
        response = api_client.post(
            api_routes.households_shopping_lists_item_id_recipe(sample_list.id),
            json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]),
            headers=unique_user.token,
        )
        assert response.status_code == 200

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)

    assert len(as_json["listItems"]) == len(recipe.recipe_ingredient)

    known_ingredients = [ingredient.note for ingredient in recipe.recipe_ingredient]

    for item in as_json["listItems"]:
        assert item["note"] in known_ingredients

    # Remove Recipe
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe_recipe_id_delete(sample_list.id, recipe.id),
        headers=unique_user.token,
    )

    # Get List and Check for Ingredients
    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)

    # All Items Should Still Exists
    assert len(as_json["listItems"]) == len(recipe.recipe_ingredient)

    # Quantity Should Equal 2 Start with 3 remove 1)
    for item in as_json["listItems"]:
        assert item["quantity"] == 2.0

    refs = as_json["recipeReferences"]
    assert len(refs) == 1
    assert refs[0]["recipeId"] == str(recipe.id)


def test_shopping_list_remove_recipe_scale(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
    recipe_ingredient_only: Recipe,
):
    sample_list = random.choice(shopping_lists)
    recipe = recipe_ingredient_only

    recipe_initital_scale = 100
    payload = utils.jsonify(
        [
            ShoppingListAddRecipeParamsBulk(
                recipe_id=recipe.id, recipe_increment_quantity=recipe_initital_scale
            ).model_dump()
        ]
    )

    # first add a bunch of quantity to the list
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(sample_list.id),
        headers=unique_user.token,
        json=payload,
    )

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)

    assert len(as_json["recipeReferences"]) == 1
    assert as_json["recipeReferences"][0]["recipeQuantity"] == recipe_initital_scale

    for item in as_json["listItems"]:
        assert item["quantity"] == recipe_initital_scale
        refs = item["recipeReferences"]

        assert len(refs) == 1
        assert refs[0]["recipeQuantity"] == 1
        assert refs[0]["recipeScale"] == recipe_initital_scale

    recipe_decrement_scale = round(random.uniform(10, 90), 5)
    payload = {"recipeDecrementQuantity": recipe_decrement_scale}
    recipe_expected_scale = recipe_initital_scale - recipe_decrement_scale

    # remove some of the recipes
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe_recipe_id_delete(sample_list.id, recipe.id),
        headers=unique_user.token,
        json=payload,
    )

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)

    assert len(as_json["recipeReferences"]) == 1
    assert as_json["recipeReferences"][0]["recipeQuantity"] == recipe_expected_scale

    for item in as_json["listItems"]:
        assert item["quantity"] == recipe_expected_scale
        refs = item["recipeReferences"]

        assert len(refs) == 1
        assert refs[0]["recipeQuantity"] == 1
        assert refs[0]["recipeScale"] == recipe_expected_scale


def test_recipe_decrement_max(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
    recipe_ingredient_only: Recipe,
):
    sample_list = random.choice(shopping_lists)
    recipe = recipe_ingredient_only

    recipe_scale = 10
    payload = utils.jsonify(
        [ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id, recipe_increment_quantity=recipe_scale).model_dump()]
    )

    # first add a bunch of quantity to the list
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(sample_list.id),
        headers=unique_user.token,
        json=payload,
    )

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)

    assert len(as_json["recipeReferences"]) == 1
    assert as_json["recipeReferences"][0]["recipeQuantity"] == recipe_scale

    for item in as_json["listItems"]:
        assert item["quantity"] == recipe_scale
        refs = item["recipeReferences"]

        assert len(refs) == 1
        assert refs[0]["recipeQuantity"] == 1
        assert refs[0]["recipeScale"] == recipe_scale

    # next add a little bit more of one item
    item_additional_quantity = random_int(1, 10)
    item_json = random.choice(as_json["listItems"])
    item_json["quantity"] += item_additional_quantity

    response = api_client.put(
        api_routes.households_shopping_items_item_id(item_json["id"]),
        json=item_json,
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)
    item_json = as_json["updatedItems"][0]
    assert item_json["quantity"] == recipe_scale + item_additional_quantity

    # now remove way too many instances of the recipe
    payload = {"recipeDecrementQuantity": recipe_scale * 100}
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe_recipe_id_delete(sample_list.id, recipe.id),
        headers=unique_user.token,
        json=payload,
    )

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(sample_list.id),
        headers=unique_user.token,
    )
    as_json = utils.assert_deserialize(response, 200)

    # check that only the original recipe quantity and its reference were removed, not the additional quantity
    assert len(as_json["recipeReferences"]) == 0
    assert len(as_json["listItems"]) == 1

    item = as_json["listItems"][0]
    assert item["quantity"] == item_additional_quantity
    assert len(item["recipeReferences"]) == 0


def test_recipe_manipulation_with_zero_quantities(
    api_client: TestClient,
    unique_user: TestUser,
    shopping_lists: list[ShoppingListOut],
):
    shopping_list = random.choice(shopping_lists)

    # create a recipe with one item that has a quantity of zero
    response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token)
    recipe_slug = utils.assert_deserialize(response, 201)

    response = api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token)
    recipe_data = utils.assert_deserialize(response, 200)

    note_with_zero_quantity = random_string()
    recipe_data["recipeIngredient"] = [
        {"quantity": random_int(1, 10), "note": random_string()},
        {"quantity": random_int(1, 10), "note": random_string()},
        {"quantity": random_int(1, 10), "note": random_string()},
        {"quantity": 0, "note": note_with_zero_quantity},
    ]

    response = api_client.put(
        f"{api_routes.recipes}/{recipe_slug}",
        json=recipe_data,
        headers=unique_user.token,
    )
    utils.assert_deserialize(response, 200)

    recipe = Recipe.model_validate_json(
        api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content
    )
    assert recipe.id
    assert len(recipe.recipe_ingredient) == 4

    # add the recipe to the list twice and make sure the quantity is still zero
    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(shopping_list.id),
        json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]),
        headers=unique_user.token,
    )
    utils.assert_deserialize(response, 200)

    response = api_client.post(
        api_routes.households_shopping_lists_item_id_recipe(shopping_list.id),
        json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]),
        headers=unique_user.token,
    )
    utils.assert_deserialize(response, 200)

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(shopping_list.id),
        headers=unique_user.token,
    )
    updated_list = ShoppingListOut.model_validate_json(response.content)
    assert len(updated_list.list_items) == 4

    found = False
    for item in updated_list.list_items:
        if item.note != note_with_zero_quantity:
            continue

        assert item.quantity == 0

        recipe_ref = item.recipe_references[0]
        assert recipe_ref.recipe_scale == 2

        found = True
        break

    if not found:
        raise Exception("Did not find item with no quantity in shopping list")

    # remove the recipe once and make sure the item is still on the list
    api_client.post(
        api_routes.households_shopping_lists_item_id_recipe_recipe_id_delete(shopping_list.id, recipe.id),
        headers=unique_user.token,
    )

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(shopping_list.id),
        headers=unique_user.token,
    )
    updated_list = ShoppingListOut.model_validate_json(response.content)
    assert len(updated_list.list_items) == 4

    found = False
    for item in updated_list.list_items:
        if item.note != note_with_zero_quantity:
            continue

        assert item.quantity == 0

        recipe_ref = item.recipe_references[0]
        assert recipe_ref.recipe_scale == 1

        found = True
        break

    if not found:
        raise Exception("Did not find item with no quantity in shopping list")

    # remove the recipe one more time and make sure the item is gone and the list is empty
    api_client.post(
        api_routes.households_shopping_lists_item_id_recipe_recipe_id_delete(shopping_list.id, recipe.id),
        headers=unique_user.token,
    )

    response = api_client.get(
        api_routes.households_shopping_lists_item_id(shopping_list.id),
        headers=unique_user.token,
    )
    updated_list = ShoppingListOut.model_validate_json(response.content)
    assert len(updated_list.list_items) == 0


def test_shopping_list_extras(
    api_client: TestClient,
    unique_user: TestUser,
):
    key_str_1 = random_string()
    val_str_1 = random_string()

    key_str_2 = random_string()
    val_str_2 = random_string()

    # create a list with extras
    new_list_data: dict = {"name": random_string()}
    new_list_data["extras"] = {key_str_1: val_str_1}

    response = api_client.post(api_routes.households_shopping_lists, json=new_list_data, headers=unique_user.token)
    list_as_json = utils.assert_deserialize(response, 201)

    # make sure the extra persists
    extras = list_as_json["extras"]
    assert key_str_1 in extras
    assert extras[key_str_1] == val_str_1

    # add more extras to the list
    list_as_json["extras"][key_str_2] = val_str_2

    response = api_client.put(
        api_routes.households_shopping_lists_item_id(list_as_json["id"]),
        json=list_as_json,
        headers=unique_user.token,
    )
    list_as_json = utils.assert_deserialize(response, 200)

    # make sure both the new extra and original extra persist
    extras = list_as_json["extras"]
    assert key_str_1 in extras
    assert key_str_2 in extras
    assert extras[key_str_1] == val_str_1
    assert extras[key_str_2] == val_str_2


def test_modify_shopping_list_items_updates_shopping_list(
    api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]
):
    shopping_list = random.choice(shopping_lists)
    last_update_at = shopping_list.updated_at
    assert last_update_at

    # Create
    new_item_data = {"note": random_string(), "shopping_list_id": str(shopping_list.id)}
    response = api_client.post(api_routes.households_shopping_items, json=new_item_data, headers=unique_user.token)
    data = assert_deserialize(response, 201)
    updated_list = ShoppingListOut.model_validate_json(
        api_client.get(
            api_routes.households_shopping_lists_item_id(shopping_list.id), headers=unique_user.token
        ).content
    )
    assert updated_list and updated_list.updated_at
    assert updated_list.updated_at > last_update_at
    last_update_at = updated_list.updated_at

    list_item_id = data["createdItems"][0]["id"]
    list_item = ShoppingListItemOut.model_validate_json(
        api_client.get(api_routes.households_shopping_items_item_id(list_item_id), headers=unique_user.token).content
    )
    assert list_item

    # Update
    list_item.note = random_string()
    response = api_client.put(
        api_routes.households_shopping_items_item_id(list_item_id),
        json=utils.jsonify(list_item.cast(ShoppingListItemUpdate).model_dump()),
        headers=unique_user.token,
    )
    assert response.status_code == 200
    updated_list = ShoppingListOut.model_validate_json(
        api_client.get(
            api_routes.households_shopping_lists_item_id(shopping_list.id), headers=unique_user.token
        ).content
    )
    assert updated_list and updated_list.updated_at
    assert updated_list.updated_at > last_update_at
    last_update_at = updated_list.updated_at

    # Delete
    response = api_client.delete(
        api_routes.households_shopping_items_item_id(list_item_id),
        headers=unique_user.token,
    )
    assert response.status_code == 200
    updated_list = ShoppingListOut.model_validate_json(
        api_client.get(
            api_routes.households_shopping_lists_item_id(shopping_list.id), headers=unique_user.token
        ).content
    )
    assert updated_list and updated_list.updated_at
    assert updated_list.updated_at > last_update_at


def test_bulk_modify_shopping_list_items_updates_shopping_list(
    api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]
):
    shopping_list = random.choice(shopping_lists)
    last_update_at = shopping_list.updated_at
    assert last_update_at

    # Create
    new_item_data = [
        {"note": random_string(), "shopping_list_id": str(shopping_list.id)} for _ in range(random_int(3, 5))
    ]
    response = api_client.post(
        api_routes.households_shopping_items_create_bulk,
        json=new_item_data,
        headers=unique_user.token,
    )
    data = assert_deserialize(response, 201)
    updated_list = ShoppingListOut.model_validate_json(
        api_client.get(
            api_routes.households_shopping_lists_item_id(shopping_list.id), headers=unique_user.token
        ).content
    )
    assert updated_list and updated_list.updated_at
    assert updated_list.updated_at > last_update_at
    last_update_at = updated_list.updated_at

    # Update
    list_item_ids = [item["id"] for item in data["createdItems"]]
    list_items: list[ShoppingListItemOut] = []
    for list_item_id in list_item_ids:
        list_item = ShoppingListItemOut.model_validate_json(
            api_client.get(
                api_routes.households_shopping_items_item_id(list_item_id), headers=unique_user.token
            ).content
        )
        assert list_item
        assert list_item
        list_item.note = random_string()
        list_items.append(list_item)

    payload = [utils.jsonify(list_item.cast(ShoppingListItemUpdateBulk).model_dump()) for list_item in list_items]
    response = api_client.put(api_routes.households_shopping_items, json=payload, headers=unique_user.token)
    assert response.status_code == 200
    updated_list = ShoppingListOut.model_validate_json(
        api_client.get(
            api_routes.households_shopping_lists_item_id(shopping_list.id), headers=unique_user.token
        ).content
    )
    assert updated_list and updated_list.updated_at
    assert updated_list.updated_at > last_update_at
    last_update_at = updated_list.updated_at

    # Delete
    response = api_client.delete(
        api_routes.households_shopping_items,
        params={"ids": [str(list_item.id) for list_item in list_items]},
        headers=unique_user.token,
    )
    assert response.status_code == 200
    updated_list = ShoppingListOut.model_validate_json(
        api_client.get(
            api_routes.households_shopping_lists_item_id(shopping_list.id), headers=unique_user.token
        ).content
    )
    assert updated_list and updated_list.updated_at
    assert updated_list.updated_at > last_update_at
