// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
//
// SPDX-License-Identifier: LicenseRef-MutuaL-1.2

package cooked

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"net/url"
	"testing"
)

func TestReadShoppingListACIDAuthenticationLogin3StoresCookies(t *testing.T) {
	var sawSessionCookie bool
	client, closeServer := newTestClient(t, shoppingListTestHandler(t, &sawSessionCookie))
	defer closeServer()

	shoppingList, err := client.ReadShoppingList(context.Background())
	if err != nil {
		t.Fatalf("ReadShoppingList() error = %v", err)
	}

	if !sawSessionCookie {
		t.Fatal("shopping-list request did not receive session cookie")
	}
	if len(shoppingList.Aisles) != 1 || shoppingList.Aisles[0].ProductGroups[0].Name != "Pasta" {
		t.Fatalf("shopping list = %#v, want one Pasta item", shoppingList)
	}
}

func TestListRecipesACIDRecipesRead1ListsSavedRecipes(t *testing.T) {
	client, closeServer := newTestClient(t, listRecipesTestHandler(t))
	defer closeServer()

	recipes, err := client.ListRecipes(context.Background(), 2, 5)
	if err != nil {
		t.Fatalf("ListRecipes() error = %v", err)
	}

	if len(recipes) != 1 {
		t.Fatalf("recipes length = %d, want 1", len(recipes))
	}
	if recipes[0].ID != "recipe-1" || recipes[0].Title != "Pasta" {
		t.Fatalf("recipe = %#v, want recipe-1 Pasta", recipes[0])
	}
}

func TestSearchRecipesACIDRecipesRead2SearchesSavedRecipes(t *testing.T) {
	client, closeServer := newTestClient(t, searchRecipesTestHandler(t))
	defer closeServer()

	recipes, err := client.SearchRecipes(context.Background(), "pasta & tomato", 3)
	if err != nil {
		t.Fatalf("SearchRecipes() error = %v", err)
	}

	if len(recipes) != 1 {
		t.Fatalf("recipes length = %d, want 1", len(recipes))
	}
	if recipes[0].ID != "recipe-1" || recipes[0].Title != "Pasta" {
		t.Fatalf("recipe = %#v, want recipe-1 Pasta", recipes[0])
	}
}

func TestReadRecipeMetadataACIDRecipesRead6ReadsMetadata(t *testing.T) {
	client, closeServer := newTestClient(t, recipeMetadataTestHandler(t))
	defer closeServer()

	metadata, err := client.ReadRecipeMetadata(context.Background(), "recipe/with space")
	if err != nil {
		t.Fatalf("ReadRecipeMetadata() error = %v", err)
	}

	if metadata.Title != "Pasta with Tomato Sauce" {
		t.Fatalf("metadata title = %q, want Pasta with Tomato Sauce", metadata.Title)
	}
	if len(metadata.ImageURLs) != 1 || metadata.ImageURLs[0] != "https://example.invalid/pasta.jpg" {
		t.Fatalf("metadata image URLs = %#v, want one pasta image", metadata.ImageURLs)
	}
	if metadata.Owner != "returned-user" || !metadata.EditPermission {
		t.Fatalf("metadata owner/edit = %q/%v, want returned-user/true", metadata.Owner, metadata.EditPermission)
	}
}

func TestReadRecipeContentACIDRecipesRead7And8ReadsContentAndPortions(t *testing.T) {
	client, closeServer := newTestClient(t, recipeContentTestHandler(t))
	defer closeServer()

	content, err := client.ReadRecipeContent(context.Background(), "recipe/with space")
	if err != nil {
		t.Fatalf("ReadRecipeContent() error = %v", err)
	}

	if content.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." {
		t.Fatalf("content = %q, want recipe markdown", content.Content)
	}
	if content.Portions != 2 {
		t.Fatalf("portions = %d, want 2", content.Portions)
	}
}

func TestPreviewRecipeTextACIDRecipesPreviewText1To4PreviewsWithoutSaving(t *testing.T) {
	var previewCalls int
	client, closeServer := newTestClient(t, previewRecipeTextTestHandler(t, &previewCalls))
	defer closeServer()

	preview, err := client.PreviewRecipeText(context.Background(), "Pasta", "Boil pasta.")
	if err != nil {
		t.Fatalf("PreviewRecipeText() error = %v", err)
	}

	if previewCalls != 1 {
		t.Fatalf("preview calls = %d, want 1", previewCalls)
	}
	if preview.Title != "Pasta with Tomato Sauce" {
		t.Fatalf("preview title = %q, want Pasta with Tomato Sauce", preview.Title)
	}
	if preview.Markdown != "# Pasta\n\n1. Boil pasta." {
		t.Fatalf("preview markdown = %q, want recipe markdown", preview.Markdown)
	}
	if preview.Portions != 2 {
		t.Fatalf("preview portions = %d, want 2", preview.Portions)
	}
}

func TestSavePreparedRecipeACIDRecipesSave2And9And13SavesMarkdownAsDescription(t *testing.T) {
	var saveCalls int
	client, closeServer := newTestClient(t, savePreparedRecipeTestHandler(t, &saveCalls))
	defer closeServer()

	recipeID, err := client.SavePreparedRecipe(context.Background(), "Pasta", "# Pasta\n\n1. Boil pasta.", 2)
	if err != nil {
		t.Fatalf("SavePreparedRecipe() error = %v", err)
	}

	if saveCalls != 1 {
		t.Fatalf("save calls = %d, want 1", saveCalls)
	}
	if recipeID != "recipe-1" {
		t.Fatalf("recipe ID = %q, want recipe-1", recipeID)
	}
}

func TestUpdateRecipeContentACIDRecipesSave8And9And13UpdatesExistingRecipe(t *testing.T) {
	var updateCalls int
	client, closeServer := newTestClient(t, updateRecipeContentTestHandler(t, &updateCalls))
	defer closeServer()

	err := client.UpdateRecipeContent(context.Background(), "recipe/with space", "# Pasta\n\n1. Boil pasta.", 4)
	if err != nil {
		t.Fatalf("UpdateRecipeContent() error = %v", err)
	}

	if updateCalls != 1 {
		t.Fatalf("update calls = %d, want 1", updateCalls)
	}
}

func TestDeleteRecipeDeletesSavedRecipe(t *testing.T) {
	var deleteCalls int
	client, closeServer := newTestClient(t, deleteRecipeTestHandler(t, &deleteCalls))
	defer closeServer()

	err := client.DeleteRecipe(context.Background(), "recipe/with space")
	if err != nil {
		t.Fatalf("DeleteRecipe() error = %v", err)
	}

	if deleteCalls != 1 {
		t.Fatalf("delete calls = %d, want 1", deleteCalls)
	}
}

func TestClearShoppingListACIDShoppingListClear1ClearsAllItems(t *testing.T) {
	var clearCalls int
	client, closeServer := newTestClient(t, clearShoppingListTestHandler(t, &clearCalls))
	defer closeServer()

	err := client.ClearShoppingList(context.Background())
	if err != nil {
		t.Fatalf("ClearShoppingList() error = %v", err)
	}

	if clearCalls != 1 {
		t.Fatalf("clear calls = %d, want 1", clearCalls)
	}
}

func TestAddShoppingListIngredientsACIDShoppingListAdd1To4AddsIngredients(t *testing.T) {
	var addCalls int
	client, closeServer := newTestClient(t, addShoppingListIngredientsTestHandler(t, &addCalls))
	defer closeServer()

	result, err := client.AddShoppingListIngredients(
		context.Background(),
		"200g pasta\n1 cup tomato sauce",
		"recipe-1",
	)
	if err != nil {
		t.Fatalf("AddShoppingListIngredients() error = %v", err)
	}

	if addCalls != 1 {
		t.Fatalf("add calls = %d, want 1", addCalls)
	}
	if result.AddedCount != 2 {
		t.Fatalf("added count = %d, want 2", result.AddedCount)
	}
	if len(result.Ingredients) != 2 || result.Ingredients[0] != "200g pasta" {
		t.Fatalf("added ingredients = %#v, want two added ingredients", result.Ingredients)
	}
}

func TestRemoveShoppingListProductGroupsACIDShoppingListRemove1RemovesProductGroups(t *testing.T) {
	var removeCalls int
	client, closeServer := newTestClient(t, removeShoppingListProductGroupsTestHandler(t, &removeCalls))
	defer closeServer()

	err := client.RemoveShoppingListProductGroups(context.Background(), []string{"pasta", "tomato"})
	if err != nil {
		t.Fatalf("RemoveShoppingListProductGroups() error = %v", err)
	}

	if removeCalls != 1 {
		t.Fatalf("remove calls = %d, want 1", removeCalls)
	}
}

func TestReplaceShoppingListSelectionACIDShoppingListSelection1And8ReplacesSelectedSet(t *testing.T) {
	var replaceCalls int
	client, closeServer := newTestClient(t, replaceShoppingListSelectionTestHandler(t, &replaceCalls))
	defer closeServer()

	err := client.ReplaceShoppingListSelection(context.Background(), []string{"pasta", "tomato"})
	if err != nil {
		t.Fatalf("ReplaceShoppingListSelection() error = %v", err)
	}

	if replaceCalls != 1 {
		t.Fatalf("replace calls = %d, want 1", replaceCalls)
	}
}

func TestUpdateShoppingListProductGroupACIDShoppingListUpdateItem1UpdatesProductGroup(t *testing.T) {
	var updateCalls int
	client, closeServer := newTestClient(t, updateShoppingListProductGroupTestHandler(t, &updateCalls))
	defer closeServer()

	err := client.UpdateShoppingListProductGroup(
		context.Background(),
		"product/with space",
		ShoppingListProductGroupUpdate{
			Name:     "Pasta",
			Quantity: "250g",
			AisleID:  "pantry",
			Selected: false,
		},
	)
	if err != nil {
		t.Fatalf("UpdateShoppingListProductGroup() error = %v", err)
	}

	if updateCalls != 1 {
		t.Fatalf("update calls = %d, want 1", updateCalls)
	}
}

func TestUserPathEscapesAuthenticatedUsername(t *testing.T) {
	client := &Client{authenticatedUsername: "user/name"}

	got := client.userPath("/api/user/{username}/shopping-list")
	if got != "/api/user/user%2Fname/shopping-list" {
		t.Fatalf("userPath() = %q, want escaped username", got)
	}
}

func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) {
	baseURL, err := url.Parse("https://example.invalid")
	if err != nil {
		t.Fatalf("parse base URL: %v", err)
	}
	client := &Client{baseURL: baseURL}

	request, err := client.newRequest(
		context.Background(),
		http.MethodGet,
		"/api/user/user%2Fname/recipes?page=2&page-count=5",
		nil,
	)
	if err != nil {
		t.Fatalf("newRequest() error = %v", err)
	}

	if request.URL.String() != "https://example.invalid/api/user/user%2Fname/recipes?page=2&page-count=5" {
		t.Fatalf("request URL = %q, want escaped username preserved", request.URL.String())
	}
}

func newTestClient(t *testing.T, handler http.HandlerFunc) (*Client, func()) {
	t.Helper()

	server := httptest.NewServer(handler)
	baseURL, err := url.Parse(server.URL)
	if err != nil {
		server.Close()
		t.Fatalf("parse server URL: %v", err)
	}
	client, err := NewClient(baseURL, "configured-user", "configured-password")
	if err != nil {
		server.Close()
		t.Fatalf("NewClient() error = %v", err)
	}

	return client, server.Close
}

func shoppingListTestHandler(t *testing.T, sawSessionCookie *bool) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/public/login":
			requireLoginRequest(t, r)
			writeLoginResponse(t, w)
		case "/api/user/returned-user/shopping-list":
			requireSessionCookie(t, r)
			*sawSessionCookie = true
			writeShoppingListResponse(t, w)
		default:
			t.Fatalf("unexpected path %s", r.URL.Path)
		}
	}
}

func listRecipesTestHandler(t *testing.T) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/user/returned-user/recipes":
			requireQueryValue(t, r, "page", "2")
			requireQueryValue(t, r, "page-count", "5")
			writeRecipeListResponse(t, w)
		default:
			t.Fatalf("unexpected path %s", r.URL.Path)
		}
	}
}

func searchRecipesTestHandler(t *testing.T) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/user/returned-user/recipes/search":
			requireMethod(t, r, http.MethodGet, "search")
			requireQueryValue(t, r, "q", "pasta & tomato")
			requireQueryValue(t, r, "page", "3")
			writeRecipeListResponse(t, w)
		default:
			t.Fatalf("unexpected path %s", r.URL.Path)
		}
	}
}

func recipeMetadataTestHandler(t *testing.T) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.EscapedPath() {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/recipe/recipe%2Fwith%20space/metadata":
			requireMethod(t, r, http.MethodGet, "metadata")
			requireSessionCookie(t, r)
			writeRecipeMetadataResponse(t, w)
		default:
			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
		}
	}
}

func recipeContentTestHandler(t *testing.T) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.EscapedPath() {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/recipe/recipe%2Fwith%20space/content":
			requireMethod(t, r, http.MethodGet, "content")
			requireSessionCookie(t, r)
			writeJSON(t, w, RecipeContent{Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.", Portions: 2})
		default:
			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
		}
	}
}

func previewRecipeTextTestHandler(t *testing.T, previewCalls *int) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/new/from-text/preview":
			requireMethod(t, r, http.MethodPost, "preview")
			requireSessionCookie(t, r)
			requirePreviewRecipeTextRequest(t, r)
			(*previewCalls)++
			writeJSON(t, w, RecipeTextPreview{
				Title:    "Pasta with Tomato Sauce",
				Markdown: "# Pasta\n\n1. Boil pasta.",
				Portions: 2,
			})
		default:
			t.Fatalf("unexpected path %s", r.URL.Path)
		}
	}
}

func savePreparedRecipeTestHandler(t *testing.T, saveCalls *int) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/recipe/import/save":
			requireMethod(t, r, http.MethodPost, "save prepared recipe")
			requireSessionCookie(t, r)
			requireSavePreparedRecipeRequest(t, r)
			(*saveCalls)++
			writeJSON(t, w, saveRecipeResponse{RecipeID: "recipe-1"})
		default:
			t.Fatalf("unexpected path %s", r.URL.Path)
		}
	}
}

func updateRecipeContentTestHandler(t *testing.T, updateCalls *int) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.EscapedPath() {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/recipe/recipe%2Fwith%20space/content":
			requireMethod(t, r, http.MethodPost, "update recipe content")
			requireSessionCookie(t, r)
			requireUpdateRecipeContentRequest(t, r)
			(*updateCalls)++
			w.WriteHeader(http.StatusNoContent)
		default:
			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
		}
	}
}

func deleteRecipeTestHandler(t *testing.T, deleteCalls *int) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.EscapedPath() {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/recipe/recipe%2Fwith%20space":
			requireMethod(t, r, http.MethodDelete, "delete recipe")
			requireSessionCookie(t, r)
			(*deleteCalls)++
			w.WriteHeader(http.StatusNoContent)
		default:
			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
		}
	}
}

func clearShoppingListTestHandler(t *testing.T, clearCalls *int) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.EscapedPath() {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/user/returned-user/shopping-list":
			requireMethod(t, r, http.MethodDelete, "clear shopping list")
			requireSessionCookie(t, r)
			(*clearCalls)++
			w.WriteHeader(http.StatusNoContent)
		default:
			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
		}
	}
}

func addShoppingListIngredientsTestHandler(t *testing.T, addCalls *int) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.EscapedPath() {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/user/shopping-list":
			requireMethod(t, r, http.MethodPut, "add shopping-list ingredients")
			requireSessionCookie(t, r)
			requireAddShoppingListIngredientsRequest(t, r)
			(*addCalls)++
			writeAddShoppingListIngredientsResponse(t, w)
		default:
			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
		}
	}
}

func requireAddShoppingListIngredientsRequest(t *testing.T, r *http.Request) {
	t.Helper()

	var request struct {
		Ingredients string `json:"ingredients"`
		RecipeID    string `json:"recipe-id"`
	}
	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
		t.Fatalf("decode add shopping-list ingredients request: %v", err)
	}
	if request.Ingredients != "200g pasta\n1 cup tomato sauce" {
		t.Fatalf("ingredients = %q, want newline-separated ingredients", request.Ingredients)
	}
	if request.RecipeID != "recipe-1" {
		t.Fatalf("recipe ID = %q, want recipe-1", request.RecipeID)
	}
}

func writeAddShoppingListIngredientsResponse(t *testing.T, w http.ResponseWriter) {
	t.Helper()

	w.Header().Set("Content-Type", "application/json")
	if _, err := w.Write([]byte(`{
		"success": true,
		"added-count": 2,
		"ingredients": ["200g pasta", "1 cup tomato sauce"]
	}`)); err != nil {
		t.Fatalf("write add shopping-list ingredients response: %v", err)
	}
}

func removeShoppingListProductGroupsTestHandler(t *testing.T, removeCalls *int) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.EscapedPath() {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/user/returned-user/shopping-list/product-groups":
			requireMethod(t, r, http.MethodDelete, "remove shopping-list product groups")
			requireSessionCookie(t, r)
			requireRemoveShoppingListProductGroupsRequest(t, r)
			(*removeCalls)++
			w.WriteHeader(http.StatusNoContent)
		default:
			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
		}
	}
}

func replaceShoppingListSelectionTestHandler(t *testing.T, replaceCalls *int) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.EscapedPath() {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/user/returned-user/shopping-list/product-groups/selection":
			requireMethod(t, r, http.MethodPut, "replace shopping-list selection")
			requireSessionCookie(t, r)
			requireReplaceShoppingListSelectionRequest(t, r)
			(*replaceCalls)++
			w.WriteHeader(http.StatusNoContent)
		default:
			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
		}
	}
}

func updateShoppingListProductGroupTestHandler(t *testing.T, updateCalls *int) http.HandlerFunc {
	t.Helper()

	return func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.EscapedPath() {
		case "/api/public/login":
			writeLoginResponse(t, w)
		case "/api/user/returned-user/shopping-list/product-groups/product%2Fwith%20space":
			requireMethod(t, r, http.MethodPut, "update shopping-list product group")
			requireSessionCookie(t, r)
			requireUpdateShoppingListProductGroupRequest(t, r)
			(*updateCalls)++
			w.WriteHeader(http.StatusNoContent)
		default:
			t.Fatalf("unexpected path %s", r.URL.EscapedPath())
		}
	}
}

func requireRemoveShoppingListProductGroupsRequest(t *testing.T, r *http.Request) {
	t.Helper()

	var request struct {
		IDs []string `json:"ids"`
	}
	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
		t.Fatalf("decode remove shopping-list product groups request: %v", err)
	}
	if len(request.IDs) != 2 || request.IDs[0] != "pasta" || request.IDs[1] != "tomato" {
		t.Fatalf("remove IDs = %#v, want pasta and tomato", request.IDs)
	}
}

func requireReplaceShoppingListSelectionRequest(t *testing.T, r *http.Request) {
	t.Helper()

	var request struct {
		Selected []string `json:"selected"`
	}
	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
		t.Fatalf("decode replace shopping-list selection request: %v", err)
	}
	if len(request.Selected) != 2 || request.Selected[0] != "pasta" || request.Selected[1] != "tomato" {
		t.Fatalf("selected IDs = %#v, want pasta and tomato", request.Selected)
	}
}

func requireUpdateShoppingListProductGroupRequest(t *testing.T, r *http.Request) {
	t.Helper()

	var request struct {
		Name     string `json:"name"`
		Quantity string `json:"quantity"`
		AisleID  string `json:"aisle-id"`
		Selected *bool  `json:"selected"`
	}
	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
		t.Fatalf("decode update shopping-list product group request: %v", err)
	}
	if request.Name != "Pasta" || request.Quantity != "250g" || request.AisleID != "pantry" {
		t.Fatalf("update request = %#v, want configured name, quantity, and aisle", request)
	}
	if request.Selected == nil {
		t.Fatal("update request selected missing, want explicit false")
	}
	if *request.Selected {
		t.Fatal("update request selected = true, want false")
	}
}

func requirePreviewRecipeTextRequest(t *testing.T, r *http.Request) {
	t.Helper()

	var request previewRecipeTextRequest
	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
		t.Fatalf("decode preview request: %v", err)
	}
	if request.RecipeName != "Pasta" || request.RecipeText != "Boil pasta." {
		t.Fatalf("preview request = %#v, want configured title/text", request)
	}
}

func requireSavePreparedRecipeRequest(t *testing.T, r *http.Request) {
	t.Helper()

	var request savePreparedRecipeRequest
	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
		t.Fatalf("decode save prepared recipe request: %v", err)
	}
	if request.Title != "Pasta" {
		t.Fatalf("save title = %q, want Pasta", request.Title)
	}
	if request.Description != "# Pasta\n\n1. Boil pasta." {
		t.Fatalf("save description = %q, want markdown", request.Description)
	}
	if request.Portions != 2 {
		t.Fatalf("save portions = %d, want 2", request.Portions)
	}
}

func requireUpdateRecipeContentRequest(t *testing.T, r *http.Request) {
	t.Helper()

	var request updateRecipeContentRequest
	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
		t.Fatalf("decode update recipe content request: %v", err)
	}
	if request.Description != "# Pasta\n\n1. Boil pasta." {
		t.Fatalf("update description = %q, want markdown", request.Description)
	}
	if request.Portions != 4 {
		t.Fatalf("update portions = %d, want 4", request.Portions)
	}
}

func requireLoginRequest(t *testing.T, r *http.Request) {
	t.Helper()
	requireMethod(t, r, http.MethodPost, "login")

	var request loginRequest
	if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
		t.Fatalf("decode login request: %v", err)
	}
	if request.Username != "configured-user" || request.Password != "configured-password" {
		t.Fatalf("login request = %#v, want configured credentials", request)
	}
}

func requireMethod(t *testing.T, r *http.Request, want string, label string) {
	t.Helper()

	if r.Method != want {
		t.Fatalf("%s method = %s, want %s", label, r.Method, want)
	}
}

func requireSessionCookie(t *testing.T, r *http.Request) {
	t.Helper()

	cookie, err := r.Cookie("cooked_session")
	if err != nil {
		t.Fatalf("missing session cookie: %v", err)
	}
	if cookie.Value != "session-value" {
		t.Fatalf("session cookie = %q, want session-value", cookie.Value)
	}
}

func requireQueryValue(t *testing.T, r *http.Request, name string, want string) {
	t.Helper()

	if got := r.URL.Query().Get(name); got != want {
		t.Fatalf("%s query = %q, want %q", name, got, want)
	}
}

func writeLoginResponse(t *testing.T, w http.ResponseWriter) {
	t.Helper()

	http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"})
	writeJSON(t, w, loginResponse{Username: "returned-user"})
}

func writeShoppingListResponse(t *testing.T, w http.ResponseWriter) {
	t.Helper()

	writeJSON(t, w, shoppingListResponse{
		ShoppingList: ShoppingList{
			Aisles: []Aisle{{
				ID:   "pantry",
				Name: "Pantry",
				ProductGroups: []ProductGroup{{
					ID:       "pasta",
					Name:     "Pasta",
					Quantity: "200g",
				}},
			}},
		},
		Recipes: []string{"recipe-id"},
	})
}

func writeRecipeListResponse(t *testing.T, w http.ResponseWriter) {
	t.Helper()

	writeJSON(t, w, recipeListResponse{
		Recipes: []RecipeCard{{
			ID:           "recipe-1",
			Title:        "Pasta",
			ThumbnailURL: "https://example.invalid/thumb.jpg",
		}},
	})
}

func writeRecipeMetadataResponse(t *testing.T, w http.ResponseWriter) {
	t.Helper()

	w.Header().Set("Content-Type", "application/json")
	if _, err := w.Write([]byte(`{
		"title": "Pasta with Tomato Sauce",
		"image-urls": ["https://example.invalid/pasta.jpg"],
		"owner": "returned-user",
		"edit-permission": true
	}`)); err != nil {
		t.Fatalf("write metadata response: %v", err)
	}
}

func writeJSON[
	T loginResponse | shoppingListResponse | recipeListResponse | RecipeContent | RecipeTextPreview | saveRecipeResponse,
](
	t *testing.T,
	w http.ResponseWriter,
	value T,
) {
	t.Helper()

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(value); err != nil {
		t.Fatalf("write JSON: %v", err)
	}
}
