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

package mcp

import (
	"context"
	"encoding/json"
	"slices"
	"strings"
	"testing"

	sdk "github.com/modelcontextprotocol/go-sdk/mcp"

	"git.secluded.site/cooked-mcp/internal/cooked"
)

func TestCallToolACIDToolsSurface1ReadsShoppingList(t *testing.T) {
	backend := &fakeBackend{shoppingList: cooked.ShoppingList{
		Aisles: []cooked.Aisle{{
			ID:   "pantry",
			Name: "Pantry",
			ProductGroups: []cooked.ProductGroup{{
				ID:       "pasta",
				Name:     "Pasta",
				Quantity: "200g",
			}},
		}},
	}}
	server := NewServer(backend, "test")

	output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "shopping_list"})
	if err != nil {
		t.Fatalf("CallReadTool() error = %v", err)
	}

	if len(output.Aisles) != 1 {
		t.Fatalf("structured aisles = %d, want 1", len(output.Aisles))
	}
	if output.Aisles[0].ProductGroups[0].Name != "Pasta" {
		t.Fatalf("first item = %q, want Pasta", output.Aisles[0].ProductGroups[0].Name)
	}
}

func TestCallToolACIDRecipesRead1ListsRecipes(t *testing.T) {
	backend := &fakeBackend{
		recipes: []cooked.RecipeCard{
			{ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
		},
	}
	server := NewServer(backend, "test")

	output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Page: 2, Limit: 5})
	if err != nil {
		t.Fatalf("CallReadTool() error = %v", err)
	}

	if backend.page != 2 {
		t.Fatalf("backend page = %d, want 2", backend.page)
	}
	if backend.limit != 5 {
		t.Fatalf("backend limit = %d, want 5", backend.limit)
	}
	if len(output.Recipes) != 1 {
		t.Fatalf("structured recipes = %d, want 1", len(output.Recipes))
	}
	if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
		t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
	}
}

func TestCallToolACIDRecipesPagination4DefaultsAndCapsRecipeLimit(t *testing.T) {
	backend := &fakeBackend{}
	server := NewServer(backend, "test")

	_, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Limit: 99})
	if err != nil {
		t.Fatalf("CallReadTool() error = %v", err)
	}

	if backend.page != 1 {
		t.Fatalf("backend page = %d, want default page 1", backend.page)
	}
	if backend.limit != 30 {
		t.Fatalf("backend limit = %d, want capped limit 30", backend.limit)
	}
}

func TestCallToolACIDRecipesRead2SearchesRecipes(t *testing.T) {
	backend := &fakeBackend{searchRecipes: []cooked.RecipeCard{
		{ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
		{ID: "recipe-2", Title: "Tomato Pasta", ThumbnailURL: "https://example.invalid/thumb2.jpg"},
	}}
	server := NewServer(backend, "test")

	output, err := server.CallReadTool(context.Background(), ReadArguments{
		Target: "recipes",
		Query:  " pasta ",
		Page:   2,
		Limit:  1,
	})
	if err != nil {
		t.Fatalf("CallReadTool() error = %v", err)
	}

	if backend.searchQuery != "pasta" {
		t.Fatalf("backend search query = %q, want pasta", backend.searchQuery)
	}
	if backend.searchPage != 2 {
		t.Fatalf("backend search page = %d, want 2", backend.searchPage)
	}
	if len(output.Recipes) != 1 {
		t.Fatalf("structured recipes = %d, want truncated result length 1", len(output.Recipes))
	}
	if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
		t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
	}
}

func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
	backend := &fakeBackend{
		recipeMetadata: cooked.RecipeMetadata{
			Title:          "Pasta",
			ImageURLs:      []string{"https://example.invalid/pasta.jpg"},
			Owner:          "returned-user",
			EditPermission: true,
		},
		recipeContent: cooked.RecipeContent{
			Content:  "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.",
			Portions: 2,
		},
	}
	server := NewServer(backend, "test")

	result, output, err := server.callReadTool(
		context.Background(),
		nil,
		ReadArguments{Target: "recipe", RecipeID: "recipe-1"},
	)
	if err != nil {
		t.Fatalf("callReadTool() error = %v", err)
	}

	requireSingleRecipeBackendCalls(t, backend)
	requireSingleRecipeOutput(t, output)
	requireNoImageLeak(t, result, output)
}

func requireSingleRecipeBackendCalls(t *testing.T, backend *fakeBackend) {
	t.Helper()

	if backend.metadataRecipeID != "recipe-1" || backend.contentRecipeID != "recipe-1" {
		t.Fatalf(
			"backend recipe IDs = metadata %q content %q, want recipe-1",
			backend.metadataRecipeID,
			backend.contentRecipeID,
		)
	}
	if backend.metadataCalls != 1 || backend.contentCalls != 1 {
		t.Fatalf("backend calls = metadata %d content %d, want 1/1", backend.metadataCalls, backend.contentCalls)
	}
}

func requireSingleRecipeOutput(t *testing.T, output ReadOutput) {
	t.Helper()

	if output.Recipe == nil {
		t.Fatal("structured recipe is nil")
	}
	if output.Recipe.ID != "recipe-1" || output.Recipe.Title != "Pasta" {
		t.Fatalf("structured recipe identity = %#v, want recipe-1 Pasta", output.Recipe)
	}
	if output.Recipe.Owner != "returned-user" || !output.Recipe.EditPermission {
		t.Fatalf(
			"structured recipe owner/edit = %q/%v, want returned-user/true",
			output.Recipe.Owner,
			output.Recipe.EditPermission,
		)
	}
	if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 2 {
		t.Fatalf(
			"structured recipe content/portions = %q/%d, want markdown/2",
			output.Recipe.Content,
			output.Recipe.Portions,
		)
	}
}

func requireNoImageLeak(t *testing.T, result *sdk.CallToolResult, output ReadOutput) {
	t.Helper()

	encodedOutput, err := json.Marshal(output)
	if err != nil {
		t.Fatalf("marshal output: %v", err)
	}
	if strings.Contains(string(encodedOutput), "image") ||
		strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") {
		t.Fatalf("structured output leaked image data: %s", encodedOutput)
	}
	if len(result.Content) != 1 {
		t.Fatalf("text content length = %d, want 1", len(result.Content))
	}
	textContent, ok := result.Content[0].(*sdk.TextContent)
	if !ok {
		t.Fatalf("content type = %T, want *sdk.TextContent", result.Content[0])
	}
	if strings.Contains(textContent.Text, "https://example.invalid/pasta.jpg") {
		t.Fatalf("text output leaked image URL: %s", textContent.Text)
	}
}

func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T) {
	backend := &fakeBackend{}
	server := NewServer(backend, "test")

	_, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipe", RecipeID: "   "})
	if err == nil {
		t.Fatal("CallReadTool() error = nil, want missing recipe_id error")
	}

	if backend.metadataCalls != 0 || backend.contentCalls != 0 {
		t.Fatalf("backend calls = metadata %d content %d, want none", backend.metadataCalls, backend.contentCalls)
	}
}

func TestCallToolACIDRecipesPreviewText1PreviewsRawText(t *testing.T) {
	backend := &fakeBackend{preview: cooked.RecipeTextPreview{
		Title:    "Pasta with Tomato Sauce",
		Markdown: "# Pasta\n\n1. Boil pasta.",
		Portions: 2,
	}}
	server := NewServer(backend, "test")

	_, output, err := server.callPreviewRecipeTextTool(
		context.Background(),
		nil,
		PreviewRecipeTextArguments{Title: " Pasta ", Text: "  Boil pasta.\n"},
	)
	if err != nil {
		t.Fatalf("callPreviewRecipeTextTool() error = %v", err)
	}

	if backend.previewCalls != 1 {
		t.Fatalf("preview calls = %d, want 1", backend.previewCalls)
	}
	if backend.previewTitle != "Pasta" {
		t.Fatalf("backend preview title = %q, want trimmed Pasta", backend.previewTitle)
	}
	if backend.previewText != "  Boil pasta.\n" {
		t.Fatalf("backend preview text = %q, want raw text preserved", backend.previewText)
	}
	if output.Title != "Pasta with Tomato Sauce" || output.Markdown != "# Pasta\n\n1. Boil pasta." ||
		output.Portions != 2 {
		t.Fatalf("preview output = %#v, want title/markdown/portions", output)
	}
}

func TestCallToolACIDToolsPreviewRecipeTextTool1RequiresTitle(t *testing.T) {
	backend := &fakeBackend{}
	server := NewServer(backend, "test")

	_, _, err := server.callPreviewRecipeTextTool(
		context.Background(),
		nil,
		PreviewRecipeTextArguments{Title: "   ", Text: "Boil pasta."},
	)
	if err == nil {
		t.Fatal("callPreviewRecipeTextTool() error = nil, want missing title error")
	}
	if backend.previewCalls != 0 {
		t.Fatalf("preview calls = %d, want none", backend.previewCalls)
	}
}

func TestCallToolACIDToolsPreviewRecipeTextTool2RequiresText(t *testing.T) {
	backend := &fakeBackend{}
	server := NewServer(backend, "test")

	_, _, err := server.callPreviewRecipeTextTool(
		context.Background(),
		nil,
		PreviewRecipeTextArguments{Title: "Pasta", Text: "   "},
	)
	if err == nil {
		t.Fatal("callPreviewRecipeTextTool() error = nil, want missing text error")
	}
	if backend.previewCalls != 0 {
		t.Fatalf("preview calls = %d, want none", backend.previewCalls)
	}
}

func TestCallToolACIDRecipesSave2And9SavesPreparedRecipe(t *testing.T) {
	backend := &fakeBackend{saveRecipeID: "recipe-1"}
	server := NewServer(backend, "test")

	_, output, err := server.callSaveRecipeTool(
		context.Background(),
		nil,
		SaveRecipeArguments{
			Source:   " prepared ",
			Title:    " Pasta ",
			Markdown: "  # Pasta\n\n1. Boil pasta.\n",
			Portions: 2,
		},
	)
	if err != nil {
		t.Fatalf("callSaveRecipeTool() error = %v", err)
	}

	if backend.saveCalls != 1 {
		t.Fatalf("save calls = %d, want 1", backend.saveCalls)
	}
	if backend.saveTitle != "Pasta" {
		t.Fatalf("backend save title = %q, want trimmed Pasta", backend.saveTitle)
	}
	if backend.saveMarkdown != "  # Pasta\n\n1. Boil pasta.\n" {
		t.Fatalf("backend save markdown = %q, want raw markdown preserved", backend.saveMarkdown)
	}
	if backend.savePortions != 2 {
		t.Fatalf("backend save portions = %d, want 2", backend.savePortions)
	}
	if output.RecipeID != "recipe-1" {
		t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
	}
}

func TestCallToolACIDRecipesSave2_1To2_3RequiresPreparedFields(t *testing.T) {
	tests := []struct {
		name      string
		arguments SaveRecipeArguments
	}{
		{
			name:      "title",
			arguments: SaveRecipeArguments{Source: "prepared", Title: "   ", Markdown: "# Pasta", Portions: 2},
		},
		{
			name:      "markdown",
			arguments: SaveRecipeArguments{Source: "prepared", Title: "Pasta", Markdown: "   ", Portions: 2},
		},
		{
			name:      "portions",
			arguments: SaveRecipeArguments{Source: "prepared", Title: "Pasta", Markdown: "# Pasta", Portions: 0},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			backend := &fakeBackend{}
			server := NewServer(backend, "test")

			_, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
			if err == nil {
				t.Fatal("callSaveRecipeTool() error = nil, want missing prepared field error")
			}
			if backend.saveCalls != 0 {
				t.Fatalf("save calls = %d, want none", backend.saveCalls)
			}
		})
	}
}

func TestCallToolACIDRecipesSave10RejectsUnsupportedSourceBeforeCallingCooked(t *testing.T) {
	backend := &fakeBackend{}
	server := NewServer(backend, "test")

	_, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "url"})
	if err == nil {
		t.Fatal("callSaveRecipeTool() error = nil, want unsupported source error")
	}
	if backend.saveCalls != 0 {
		t.Fatalf("save calls = %d, want none", backend.saveCalls)
	}
}

func TestCallToolACIDRecipesSave1And11SavesRawTextRecipe(t *testing.T) {
	backend := &fakeBackend{
		preview: cooked.RecipeTextPreview{
			Title:    "Pasta with Tomato Sauce",
			Markdown: "# Pasta\n\n1. Boil pasta.",
			Portions: 2,
		},
		saveRecipeID: "recipe-1",
	}
	server := NewServer(backend, "test")

	_, output, err := server.callSaveRecipeTool(
		context.Background(),
		nil,
		SaveRecipeArguments{Source: " raw_text ", Title: " Pasta ", Text: "  Boil pasta.\n"},
	)
	if err != nil {
		t.Fatalf("callSaveRecipeTool() error = %v", err)
	}

	if backend.previewCalls != 1 || backend.saveCalls != 1 {
		t.Fatalf("backend calls = preview %d save %d, want 1/1", backend.previewCalls, backend.saveCalls)
	}
	if backend.previewTitle != "Pasta" {
		t.Fatalf("preview title = %q, want trimmed Pasta", backend.previewTitle)
	}
	if backend.previewText != "  Boil pasta.\n" {
		t.Fatalf("preview text = %q, want raw text preserved", backend.previewText)
	}
	if backend.saveTitle != "Pasta with Tomato Sauce" {
		t.Fatalf("save title = %q, want preview title", backend.saveTitle)
	}
	if backend.saveMarkdown != "# Pasta\n\n1. Boil pasta." {
		t.Fatalf("save markdown = %q, want preview markdown", backend.saveMarkdown)
	}
	if backend.savePortions != 2 {
		t.Fatalf("save portions = %d, want preview portions 2", backend.savePortions)
	}
	if output.RecipeID != "recipe-1" {
		t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
	}
}

func TestCallToolACIDRecipesSave1_1To1_2RequiresRawTextFields(t *testing.T) {
	tests := []struct {
		name      string
		arguments SaveRecipeArguments
	}{
		{name: "title", arguments: SaveRecipeArguments{Source: "raw_text", Title: "   ", Text: "Boil pasta."}},
		{name: "text", arguments: SaveRecipeArguments{Source: "raw_text", Title: "Pasta", Text: "   "}},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			backend := &fakeBackend{}
			server := NewServer(backend, "test")

			_, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
			if err == nil {
				t.Fatal("callSaveRecipeTool() error = nil, want missing raw text field error")
			}
			if backend.previewCalls != 0 || backend.saveCalls != 0 {
				t.Fatalf("backend calls = preview %d save %d, want none", backend.previewCalls, backend.saveCalls)
			}
		})
	}
}

func TestCallToolACIDRecipesSave8And9UpdatesExistingRecipe(t *testing.T) {
	backend := &fakeBackend{}
	server := NewServer(backend, "test")

	_, output, err := server.callSaveRecipeTool(
		context.Background(),
		nil,
		SaveRecipeArguments{
			Source:   " existing ",
			RecipeID: " recipe-1 ",
			Markdown: "  # Pasta\n\n1. Boil pasta.\n",
			Portions: 4,
		},
	)
	if err != nil {
		t.Fatalf("callSaveRecipeTool() error = %v", err)
	}

	if backend.updateCalls != 1 {
		t.Fatalf("update calls = %d, want 1", backend.updateCalls)
	}
	if backend.updateRecipeID != "recipe-1" {
		t.Fatalf("backend update recipe ID = %q, want recipe-1", backend.updateRecipeID)
	}
	if backend.updateMarkdown != "  # Pasta\n\n1. Boil pasta.\n" {
		t.Fatalf("backend update markdown = %q, want raw markdown preserved", backend.updateMarkdown)
	}
	if backend.updatePortions != 4 {
		t.Fatalf("backend update portions = %d, want 4", backend.updatePortions)
	}
	if output.RecipeID != "recipe-1" {
		t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
	}
}

func TestCallToolACIDRecipesSave8_1To8_3RequiresExistingFields(t *testing.T) {
	tests := []struct {
		name      string
		arguments SaveRecipeArguments
	}{
		{
			name:      "recipe ID",
			arguments: SaveRecipeArguments{Source: "existing", RecipeID: "   ", Markdown: "# Pasta", Portions: 4},
		},
		{
			name:      "markdown",
			arguments: SaveRecipeArguments{Source: "existing", RecipeID: "recipe-1", Markdown: "   ", Portions: 4},
		},
		{
			name:      "portions",
			arguments: SaveRecipeArguments{Source: "existing", RecipeID: "recipe-1", Markdown: "# Pasta", Portions: 0},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			backend := &fakeBackend{}
			server := NewServer(backend, "test")

			_, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
			if err == nil {
				t.Fatal("callSaveRecipeTool() error = nil, want missing existing field error")
			}
			if backend.updateCalls != 0 {
				t.Fatalf("update calls = %d, want none", backend.updateCalls)
			}
		})
	}
}

func TestCallToolACIDRecipesDelete1And2DeletesRecipe(t *testing.T) {
	backend := &fakeBackend{}
	server := NewServer(backend, "test")

	_, output, err := server.callDeleteRecipeTool(
		context.Background(),
		nil,
		DeleteRecipeArguments{RecipeID: " recipe-1 "},
	)
	if err != nil {
		t.Fatalf("callDeleteRecipeTool() error = %v", err)
	}

	if backend.deleteCalls != 1 {
		t.Fatalf("delete calls = %d, want 1", backend.deleteCalls)
	}
	if backend.deleteRecipeID != "recipe-1" {
		t.Fatalf("backend delete recipe ID = %q, want recipe-1", backend.deleteRecipeID)
	}
	if output.RecipeID != "recipe-1" {
		t.Fatalf("delete output recipe ID = %q, want recipe-1", output.RecipeID)
	}
}

func TestCallToolACIDToolsDeleteRecipeTool1RequiresRecipeID(t *testing.T) {
	backend := &fakeBackend{}
	server := NewServer(backend, "test")

	_, _, err := server.callDeleteRecipeTool(context.Background(), nil, DeleteRecipeArguments{RecipeID: "   "})
	if err == nil {
		t.Fatal("callDeleteRecipeTool() error = nil, want missing recipe_id error")
	}
	if backend.deleteCalls != 0 {
		t.Fatalf("delete calls = %d, want none", backend.deleteCalls)
	}
}

func TestDeleteRecipeToolACIDToolsAnnotations2To5MarksDestructiveOpenWorldTool(t *testing.T) {
	tool := deleteRecipeTool()
	if tool.Name != deleteRecipeToolName {
		t.Fatalf("tool name = %q, want %q", tool.Name, deleteRecipeToolName)
	}
	if tool.Annotations == nil {
		t.Fatal("tool annotations nil")
	}
	if tool.Annotations.ReadOnlyHint {
		t.Fatal("delete_recipe read-only hint = true, want false")
	}
	if tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
		t.Fatalf("delete_recipe destructive hint = %#v, want true", tool.Annotations.DestructiveHint)
	}
	if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
		t.Fatalf("delete_recipe open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
	}
	if tool.Annotations.Title == "" {
		t.Fatal("delete_recipe annotation title empty")
	}
}

func TestCallToolACIDShoppingListClear1And2ClearsShoppingList(t *testing.T) {
	backend := &fakeBackend{}
	server := NewServer(backend, "test")

	_, output, err := server.callChangeShoppingListTool(
		context.Background(),
		nil,
		ChangeShoppingListArguments{Action: " clear "},
	)
	if err != nil {
		t.Fatalf("callChangeShoppingListTool() error = %v", err)
	}

	if backend.clearShoppingListCalls != 1 {
		t.Fatalf("clear calls = %d, want 1", backend.clearShoppingListCalls)
	}
	if !output.Cleared {
		t.Fatalf("clear output cleared = %v, want true", output.Cleared)
	}
}

func TestCallToolACIDShoppingListActions2RejectsUnknownActionBeforeCallingCooked(t *testing.T) {
	tests := []struct {
		name      string
		arguments ChangeShoppingListArguments
	}{
		{name: "empty", arguments: ChangeShoppingListArguments{Action: "   "}},
		{name: "unknown", arguments: ChangeShoppingListArguments{Action: "dance"}},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			backend := &fakeBackend{}
			server := NewServer(backend, "test")

			_, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
			if err == nil {
				t.Fatal("callChangeShoppingListTool() error = nil, want action error")
			}
			if backend.clearShoppingListCalls != 0 {
				t.Fatalf("clear calls = %d, want none", backend.clearShoppingListCalls)
			}
		})
	}
}

func TestCallToolACIDShoppingListAdd1To4AddsIngredients(t *testing.T) {
	backend := &fakeBackend{addShoppingListResult: cooked.AddShoppingListResult{
		AddedCount:  2,
		Ingredients: []string{"200g pasta", "1 cup tomato sauce"},
	}}
	server := NewServer(backend, "test")

	_, output, err := server.callChangeShoppingListTool(
		context.Background(),
		nil,
		ChangeShoppingListArguments{
			Action:      " add ",
			Ingredients: "200g pasta\n1 cup tomato sauce",
			RecipeID:    " recipe-1 ",
		},
	)
	if err != nil {
		t.Fatalf("callChangeShoppingListTool() error = %v", err)
	}

	if backend.addShoppingListCalls != 1 {
		t.Fatalf("add calls = %d, want 1", backend.addShoppingListCalls)
	}
	if backend.addIngredients != "200g pasta\n1 cup tomato sauce" {
		t.Fatalf("backend ingredients = %q, want raw multiline ingredients", backend.addIngredients)
	}
	if backend.addRecipeID != "recipe-1" {
		t.Fatalf("backend recipe ID = %q, want recipe-1", backend.addRecipeID)
	}
	if output.AddedCount != 2 {
		t.Fatalf("output added count = %d, want 2", output.AddedCount)
	}
	if len(output.Ingredients) != 2 || output.Ingredients[0] != "200g pasta" {
		t.Fatalf("output ingredients = %#v, want added ingredients", output.Ingredients)
	}
}

func TestCallToolACIDToolsChangeShoppingListTool2RequiresIngredients(t *testing.T) {
	backend := &fakeBackend{}
	server := NewServer(backend, "test")

	_, _, err := server.callChangeShoppingListTool(
		context.Background(),
		nil,
		ChangeShoppingListArguments{Action: "add", Ingredients: "   "},
	)
	if err == nil {
		t.Fatal("callChangeShoppingListTool() error = nil, want missing ingredients error")
	}
	if backend.addShoppingListCalls != 0 {
		t.Fatalf("add calls = %d, want none", backend.addShoppingListCalls)
	}
}

func TestCallToolACIDShoppingListRemove1And2RemovesProductGroups(t *testing.T) {
	backend := &fakeBackend{}
	server := NewServer(backend, "test")

	_, output, err := server.callChangeShoppingListTool(
		context.Background(),
		nil,
		ChangeShoppingListArguments{
			Action:          " remove ",
			ProductGroupIDs: []string{" pasta ", "tomato", "   "},
		},
	)
	if err != nil {
		t.Fatalf("callChangeShoppingListTool() error = %v", err)
	}

	if backend.removeShoppingListCalls != 1 {
		t.Fatalf("remove calls = %d, want 1", backend.removeShoppingListCalls)
	}
	if len(backend.removeProductGroupIDs) != 2 || backend.removeProductGroupIDs[0] != "pasta" ||
		backend.removeProductGroupIDs[1] != "tomato" {
		t.Fatalf("backend remove IDs = %#v, want pasta and tomato", backend.removeProductGroupIDs)
	}
	if len(output.RemovedProductGroupIDs) != 2 || output.RemovedProductGroupIDs[0] != "pasta" ||
		output.RemovedProductGroupIDs[1] != "tomato" {
		t.Fatalf("output removed IDs = %#v, want pasta and tomato", output.RemovedProductGroupIDs)
	}
}

func TestCallToolACIDShoppingListSelection1And5And6And8ReplacesSelectedSet(t *testing.T) {
	backend := &fakeBackend{}
	server := NewServer(backend, "test")

	_, output, err := server.callChangeShoppingListTool(
		context.Background(),
		nil,
		ChangeShoppingListArguments{
			Action:          " replace_selection ",
			ProductGroupIDs: []string{" pasta ", "tomato", "   "},
		},
	)
	if err != nil {
		t.Fatalf("callChangeShoppingListTool() error = %v", err)
	}

	if backend.replaceSelectionCalls != 1 {
		t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
	}
	if len(backend.replaceSelectionProductGroupIDs) != 2 || backend.replaceSelectionProductGroupIDs[0] != "pasta" ||
		backend.replaceSelectionProductGroupIDs[1] != "tomato" {
		t.Fatalf("backend selected IDs = %#v, want pasta and tomato", backend.replaceSelectionProductGroupIDs)
	}
	if len(output.SelectedProductGroupIDs) != 2 || output.SelectedProductGroupIDs[0] != "pasta" ||
		output.SelectedProductGroupIDs[1] != "tomato" {
		t.Fatalf("output selected IDs = %#v, want pasta and tomato", output.SelectedProductGroupIDs)
	}
}

func TestCallToolACIDShoppingListSelection7RequiresReplaceSelectionProductGroupIDs(t *testing.T) {
	tests := []struct {
		name      string
		arguments ChangeShoppingListArguments
	}{
		{name: "nil", arguments: ChangeShoppingListArguments{Action: "replace_selection"}},
		{
			name:      "blank",
			arguments: ChangeShoppingListArguments{Action: "replace_selection", ProductGroupIDs: []string{"  "}},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			backend := &fakeBackend{}
			server := NewServer(backend, "test")

			_, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
			if err == nil {
				t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
			}
			if backend.replaceSelectionCalls != 0 {
				t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls)
			}
		})
	}
}

func TestCallToolACIDShoppingListSelection2And4And5And6And9AddsSelectedSet(t *testing.T) {
	backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
		ProductGroups: []cooked.ProductGroup{
			{ID: "pasta", Selected: true},
			{ID: "tomato"},
			{ID: "salt", Selected: true},
		},
	}}}}
	server := NewServer(backend, "test")

	_, output, err := server.callChangeShoppingListTool(
		context.Background(),
		nil,
		ChangeShoppingListArguments{
			Action:          " add_selection ",
			ProductGroupIDs: []string{" tomato ", "pasta", "   ", "pepper"},
		},
	)
	if err != nil {
		t.Fatalf("callChangeShoppingListTool() error = %v", err)
	}

	if backend.readShoppingListCalls != 1 {
		t.Fatalf("read shopping-list calls = %d, want 1", backend.readShoppingListCalls)
	}
	if backend.replaceSelectionCalls != 1 {
		t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
	}
	expectedIDs := []string{"pasta", "salt", "tomato", "pepper"}
	if !slices.Equal(backend.replaceSelectionProductGroupIDs, expectedIDs) {
		t.Fatalf("backend selected IDs = %#v, want %#v", backend.replaceSelectionProductGroupIDs, expectedIDs)
	}
	if !slices.Equal(output.SelectedProductGroupIDs, expectedIDs) {
		t.Fatalf("output selected IDs = %#v, want %#v", output.SelectedProductGroupIDs, expectedIDs)
	}
}

func TestCallToolACIDShoppingListSelection7RequiresAddSelectionProductGroupIDs(t *testing.T) {
	tests := []struct {
		name      string
		arguments ChangeShoppingListArguments
	}{
		{name: "nil", arguments: ChangeShoppingListArguments{Action: "add_selection"}},
		{
			name:      "blank",
			arguments: ChangeShoppingListArguments{Action: "add_selection", ProductGroupIDs: []string{"  "}},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			backend := &fakeBackend{}
			server := NewServer(backend, "test")

			_, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
			if err == nil {
				t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
			}
			if backend.readShoppingListCalls != 0 {
				t.Fatalf("read shopping-list calls = %d, want none", backend.readShoppingListCalls)
			}
			if backend.replaceSelectionCalls != 0 {
				t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls)
			}
		})
	}
}

func TestCallToolACIDShoppingListSelection3And4And5And6And10RemovesSelectedSet(t *testing.T) {
	backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
		ProductGroups: []cooked.ProductGroup{
			{ID: "pasta", Selected: true},
			{ID: "tomato", Selected: true},
			{ID: "salt", Selected: true},
			{ID: "pepper"},
		},
	}}}}
	server := NewServer(backend, "test")

	_, output, err := server.callChangeShoppingListTool(
		context.Background(),
		nil,
		ChangeShoppingListArguments{
			Action:          " remove_selection ",
			ProductGroupIDs: []string{" tomato ", "pepper", "   "},
		},
	)
	if err != nil {
		t.Fatalf("callChangeShoppingListTool() error = %v", err)
	}

	if backend.readShoppingListCalls != 1 {
		t.Fatalf("read shopping-list calls = %d, want 1", backend.readShoppingListCalls)
	}
	if backend.replaceSelectionCalls != 1 {
		t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
	}
	expectedIDs := []string{"pasta", "salt"}
	if !slices.Equal(backend.replaceSelectionProductGroupIDs, expectedIDs) {
		t.Fatalf("backend selected IDs = %#v, want %#v", backend.replaceSelectionProductGroupIDs, expectedIDs)
	}
	if !slices.Equal(output.SelectedProductGroupIDs, expectedIDs) {
		t.Fatalf("output selected IDs = %#v, want %#v", output.SelectedProductGroupIDs, expectedIDs)
	}
}

func TestCallToolACIDShoppingListSelection7RequiresRemoveSelectionProductGroupIDs(t *testing.T) {
	tests := []struct {
		name      string
		arguments ChangeShoppingListArguments
	}{
		{name: "nil", arguments: ChangeShoppingListArguments{Action: "remove_selection"}},
		{
			name:      "blank",
			arguments: ChangeShoppingListArguments{Action: "remove_selection", ProductGroupIDs: []string{"  "}},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			backend := &fakeBackend{}
			server := NewServer(backend, "test")

			_, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
			if err == nil {
				t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
			}
			if backend.readShoppingListCalls != 0 {
				t.Fatalf("read shopping-list calls = %d, want none", backend.readShoppingListCalls)
			}
			if backend.replaceSelectionCalls != 0 {
				t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls)
			}
		})
	}
}

func TestCallToolACIDShoppingListSafety1RequiresRemoveProductGroupIDs(t *testing.T) {
	tests := []struct {
		name      string
		arguments ChangeShoppingListArguments
	}{
		{name: "nil", arguments: ChangeShoppingListArguments{Action: "remove"}},
		{name: "blank", arguments: ChangeShoppingListArguments{Action: "remove", ProductGroupIDs: []string{"  "}}},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			backend := &fakeBackend{}
			server := NewServer(backend, "test")

			_, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
			if err == nil {
				t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
			}
			if backend.removeShoppingListCalls != 0 {
				t.Fatalf("remove calls = %d, want none", backend.removeShoppingListCalls)
			}
		})
	}
}

func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenWorldTool(t *testing.T) {
	tool := changeShoppingListTool()
	if tool.Name != changeShoppingListToolName {
		t.Fatalf("tool name = %q, want %q", tool.Name, changeShoppingListToolName)
	}
	if tool.Annotations == nil {
		t.Fatal("tool annotations nil")
	}
	if tool.Annotations.ReadOnlyHint {
		t.Fatal("change_shopping_list read-only hint = true, want false")
	}
	if tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
		t.Fatalf("change_shopping_list destructive hint = %#v, want true", tool.Annotations.DestructiveHint)
	}
	if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
		t.Fatalf("change_shopping_list open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
	}
	if tool.Annotations.Title == "" {
		t.Fatal("change_shopping_list annotation title empty")
	}
	if !strings.Contains(tool.Description, "clear") || !strings.Contains(tool.Description, "destructive") {
		t.Fatalf("change_shopping_list description = %q, want clear/destructive warning", tool.Description)
	}
}

type fakeBackend struct {
	shoppingList                     cooked.ShoppingList
	recipes                          []cooked.RecipeCard
	searchRecipes                    []cooked.RecipeCard
	recipeMetadata                   cooked.RecipeMetadata
	recipeContent                    cooked.RecipeContent
	preview                          cooked.RecipeTextPreview
	saveRecipeID                     string
	page                             int
	limit                            int
	searchQuery                      string
	searchPage                       int
	metadataRecipeID                 string
	contentRecipeID                  string
	previewTitle                     string
	previewText                      string
	saveTitle                        string
	saveMarkdown                     string
	savePortions                     int
	updateRecipeID                   string
	updateMarkdown                   string
	updatePortions                   int
	deleteRecipeID                   string
	addShoppingListResult            cooked.AddShoppingListResult
	addIngredients                   string
	addRecipeID                      string
	removeProductGroupIDs            []string
	replaceSelectionProductGroupIDs  []string
	updateShoppingListProductGroupID string
	updateShoppingListProductGroup   cooked.ShoppingListProductGroupUpdate
	metadataCalls                    int
	readShoppingListCalls            int
	contentCalls                     int
	previewCalls                     int
	saveCalls                        int
	updateCalls                      int
	deleteCalls                      int
	clearShoppingListCalls           int
	addShoppingListCalls             int
	removeShoppingListCalls          int
	replaceSelectionCalls            int
	updateShoppingListCalls          int
}

func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
	f.readShoppingListCalls++

	return f.shoppingList, nil
}

func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.RecipeCard, error) {
	f.page = page
	f.limit = limit

	return f.recipes, nil
}

func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ([]cooked.RecipeCard, error) {
	f.searchQuery = query
	f.searchPage = page

	return f.searchRecipes, nil
}

func (f *fakeBackend) ReadRecipeMetadata(_ context.Context, recipeID string) (cooked.RecipeMetadata, error) {
	f.metadataRecipeID = recipeID
	f.metadataCalls++

	return f.recipeMetadata, nil
}

func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (cooked.RecipeContent, error) {
	f.contentRecipeID = recipeID
	f.contentCalls++

	return f.recipeContent, nil
}

func (f *fakeBackend) PreviewRecipeText(_ context.Context, title, text string) (cooked.RecipeTextPreview, error) {
	f.previewTitle = title
	f.previewText = text
	f.previewCalls++

	return f.preview, nil
}

func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown string, portions int) (string, error) {
	f.saveTitle = title
	f.saveMarkdown = markdown
	f.savePortions = portions
	f.saveCalls++
	if f.saveRecipeID != "" {
		return f.saveRecipeID, nil
	}

	return "recipe-1", nil
}

func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions int) error {
	f.updateRecipeID = recipeID
	f.updateMarkdown = markdown
	f.updatePortions = portions
	f.updateCalls++

	return nil
}

func (f *fakeBackend) DeleteRecipe(_ context.Context, recipeID string) error {
	f.deleteRecipeID = recipeID
	f.deleteCalls++

	return nil
}

func (f *fakeBackend) ClearShoppingList(context.Context) error {
	f.clearShoppingListCalls++

	return nil
}

func (f *fakeBackend) AddShoppingListIngredients(
	_ context.Context,
	ingredients, recipeID string,
) (cooked.AddShoppingListResult, error) {
	f.addIngredients = ingredients
	f.addRecipeID = recipeID
	f.addShoppingListCalls++

	return f.addShoppingListResult, nil
}

func (f *fakeBackend) RemoveShoppingListProductGroups(_ context.Context, ids []string) error {
	f.removeProductGroupIDs = ids
	f.removeShoppingListCalls++

	return nil
}

func (f *fakeBackend) ReplaceShoppingListSelection(_ context.Context, ids []string) error {
	f.replaceSelectionProductGroupIDs = ids
	f.replaceSelectionCalls++

	return nil
}

func (f *fakeBackend) UpdateShoppingListProductGroup(
	_ context.Context,
	productGroupID string,
	update cooked.ShoppingListProductGroupUpdate,
) error {
	f.updateShoppingListProductGroupID = productGroupID
	f.updateShoppingListProductGroup = update
	f.updateShoppingListCalls++

	return nil
}
