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

// Package mcp implements the MCP surface for cooked-mcp.
package mcp

import (
	"context"
	"fmt"
	"reflect"
	"strings"

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

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

const (
	serverName                 = "Cooked"
	defaultSaveRecipePortions  = 1
	maxShoppingListTextItems   = 30
	readToolName               = "read"
	extractIntoRecipeToolName  = "extract_into_recipe"
	saveRecipeToolName         = "save_recipe"
	deleteRecipeToolName       = "delete_recipe"
	changeShoppingListToolName = "change_shopping_list"
	resolveRecipeIDToolName    = "resolve_recipe_id"
)

// Backend provides the Cooked operations exposed as MCP tools.
type Backend interface {
	ReadShoppingList(ctx context.Context) (cooked.ShoppingList, error)
	ListRecipes(ctx context.Context, page, limit int) ([]cooked.RecipeCard, error)
	SearchRecipes(ctx context.Context, query string, page int) ([]cooked.RecipeCard, error)
	ReadRecipeMetadata(ctx context.Context, recipeID string) (cooked.RecipeMetadata, error)
	ReadRecipeContent(ctx context.Context, recipeID string) (cooked.RecipeContent, error)
	PreviewRecipeText(ctx context.Context, title, text string) (cooked.RecipeTextPreview, error)
	SavePreparedRecipe(ctx context.Context, title, markdown string, portions float64) (string, error)
	SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions float64) (string, error)
	UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions float64) error
	ImportRecipeURL(ctx context.Context, recipeURL string) (cooked.RecipeURLImport, error)
	DeleteRecipe(ctx context.Context, recipeID string) error
	ClearShoppingList(ctx context.Context) error
	AddShoppingListIngredients(ctx context.Context, ingredients, recipeID string) (cooked.AddShoppingListResult, error)
	RemoveShoppingListProductGroups(ctx context.Context, productGroupIDs []string) error
	ReplaceShoppingListSelection(ctx context.Context, productGroupIDs []string) error
	UpdateShoppingListProductGroup(
		ctx context.Context,
		productGroupID string,
		update cooked.ShoppingListProductGroupUpdate,
	) error
}

// Server exposes Cooked as an MCP server.
type Server struct {
	backend Backend
	sdk     *sdk.Server
}

// NewServer returns an MCP server for a Cooked backend.
func NewServer(backend Backend, version string) *Server {
	server := &Server{backend: backend}
	server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
	sdk.AddTool(server.sdk, readTool(), server.callReadTool)
	sdk.AddTool(server.sdk, extractIntoRecipeTool(), server.callExtractIntoRecipeTool)
	sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
	sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool)
	sdk.AddTool(server.sdk, changeShoppingListTool(), server.callChangeShoppingListTool)
	sdk.AddTool(server.sdk, resolveRecipeIDTool(), server.callResolveRecipeIDTool)

	return server
}

// RunStdio serves MCP over the official SDK stdio transport.
func (s *Server) RunStdio(ctx context.Context) error {
	return s.sdk.Run(ctx, &sdk.StdioTransport{})
}

// CallReadTool invokes the read tool without asserting MCP wire presentation.
func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
	_, output, err := s.callReadTool(ctx, nil, arguments)
	if err != nil {
		return ReadOutput{}, err
	}

	return output, nil
}

func (s *Server) callReadTool(
	ctx context.Context,
	_ *sdk.CallToolRequest,
	arguments ReadArguments,
) (*sdk.CallToolResult, ReadOutput, error) {
	switch arguments.Target {
	case "shopping_list":
		return s.callShoppingListReadTool(ctx)
	case "recipe":
		return s.callRecipeReadTool(ctx, arguments.RecipeID)
	case "recipes":
		return s.callRecipesReadTool(ctx, arguments)
	case "example_recipes":
		return callExampleRecipesReadTool()
	default:
		return nil, ReadOutput{}, fmt.Errorf(
			"unsupported read target %q (supported: shopping_list, recipes, recipe, example_recipes)",
			arguments.Target,
		)
	}
}

func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
	shoppingList, err := s.backend.ReadShoppingList(ctx)
	if err != nil {
		return nil, ReadOutput{}, err
	}

	output := ReadOutput{
		Aisles:              shoppingList.Aisles,
		ShoppingListRecipes: shoppingList.Recipes,
	}

	return newToolResult(formatShoppingList(shoppingList), output), output, nil
}

func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
	recipeID := strings.TrimSpace(rawRecipeID)
	if recipeID == "" {
		return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
	}
	if err := validateRecipeID(recipeID); err != nil {
		return nil, ReadOutput{}, err
	}

	metadata, content, err := s.readRecipeParts(ctx, recipeID)
	if err != nil {
		return nil, ReadOutput{}, err
	}

	recipe := RecipeDetail{
		ID:       recipeID,
		Title:    metadata.Title,
		Owner:    metadata.Owner,
		Content:  content.Content,
		Portions: content.Portions,
	}
	output := ReadOutput{Recipe: &recipe}

	return newToolResult(formatRecipe(recipe), output), output, nil
}

func (s *Server) readRecipeParts(
	ctx context.Context,
	recipeID string,
) (cooked.RecipeMetadata, cooked.RecipeContent, error) {
	metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
	if err != nil {
		return cooked.RecipeMetadata{}, cooked.RecipeContent{}, err
	}
	content, err := s.backend.ReadRecipeContent(ctx, recipeID)
	if err != nil {
		return cooked.RecipeMetadata{}, cooked.RecipeContent{}, err
	}

	return metadata, content, nil
}

func (s *Server) callRecipesReadTool(
	ctx context.Context,
	arguments ReadArguments,
) (*sdk.CallToolResult, ReadOutput, error) {
	page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
	query := strings.TrimSpace(arguments.Query)
	recipes, paginationHint, err := s.readRecipeCards(ctx, query, page, limit)
	if err != nil {
		return nil, ReadOutput{}, err
	}

	output := ReadOutput{Recipes: recipeSummaries(recipes)}
	text := formatRecipes(recipes)
	if paginationHint != "" {
		text += "\n\n" + paginationHint
	}

	return newToolResult(text, output), output, nil
}

func (s *Server) readRecipeCards(
	ctx context.Context,
	query string,
	page, limit int,
) ([]cooked.RecipeCard, string, error) {
	if query == "" {
		recipes, err := s.backend.ListRecipes(ctx, page, limit)
		if err != nil {
			return nil, "", err
		}
		if len(recipes) == limit {
			return recipes, formatRecipeNextPageHint(query, page+1, limit), nil
		}

		return recipes, "", nil
	}

	recipes, err := s.backend.SearchRecipes(ctx, query, page)
	if err != nil {
		return nil, "", err
	}
	if len(recipes) == limit {
		return recipes, formatRecipeNextPageHint(query, page+1, limit), nil
	}
	if len(recipes) > limit {
		available := len(recipes)
		recipes = recipes[:limit]
		return recipes, formatRecipeSearchLimitHint(query, page, page+1, available), nil
	}

	return recipes, "", nil
}

func (s *Server) callExtractIntoRecipeTool(
	ctx context.Context,
	_ *sdk.CallToolRequest,
	arguments ExtractIntoRecipeArguments,
) (*sdk.CallToolResult, ExtractIntoRecipeOutput, error) {
	title := strings.TrimSpace(arguments.Title)
	if title == "" {
		return nil, ExtractIntoRecipeOutput{}, fmt.Errorf("title is required")
	}
	if strings.TrimSpace(arguments.Text) == "" {
		return nil, ExtractIntoRecipeOutput{}, fmt.Errorf("text is required")
	}

	extraction, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
	if err != nil {
		return nil, ExtractIntoRecipeOutput{}, err
	}

	output := ExtractIntoRecipeOutput{
		Title:    extraction.Title,
		Markdown: extraction.Markdown,
		Portions: extraction.Portions,
	}

	return newToolResult(formatRecipeTextPreview(output), output), output, nil
}

func (s *Server) callSaveRecipeTool(
	ctx context.Context,
	_ *sdk.CallToolRequest,
	arguments SaveRecipeArguments,
) (*sdk.CallToolResult, SaveRecipeOutput, error) {
	source := strings.TrimSpace(arguments.Source)
	switch source {
	case "raw_text":
		return s.callRawTextSaveRecipeTool(ctx, arguments)
	case "prepared":
		return s.callPreparedSaveRecipeTool(ctx, arguments)
	case "url":
		return s.callURLSaveRecipeTool(ctx, arguments)
	case "draft":
		return s.callDraftSaveRecipeTool(ctx, arguments)
	case "existing":
		return s.callExistingSaveRecipeTool(ctx, arguments)
	case "":
		return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
	default:
		return nil, SaveRecipeOutput{}, fmt.Errorf(
			"unsupported save_recipe source %q (supported: raw_text, prepared, url, draft, existing)",
			source,
		)
	}
}

func (s *Server) callRawTextSaveRecipeTool(
	ctx context.Context,
	arguments SaveRecipeArguments,
) (*sdk.CallToolResult, SaveRecipeOutput, error) {
	title := strings.TrimSpace(arguments.Title)
	if title == "" {
		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is raw_text")
	}
	if strings.TrimSpace(arguments.Text) == "" {
		return nil, SaveRecipeOutput{}, fmt.Errorf("text is required when source is raw_text")
	}

	preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
	if err != nil {
		return nil, SaveRecipeOutput{}, err
	}
	saveTitle := strings.TrimSpace(preview.Title)
	if saveTitle == "" {
		saveTitle = title
	}
	if strings.TrimSpace(preview.Markdown) == "" {
		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing markdown")
	}
	if preview.Portions < 1 {
		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing portions")
	}

	return s.savePreparedRecipe(ctx, saveTitle, preview.Markdown, preview.Portions)
}

func (s *Server) callPreparedSaveRecipeTool(
	ctx context.Context,
	arguments SaveRecipeArguments,
) (*sdk.CallToolResult, SaveRecipeOutput, error) {
	title := strings.TrimSpace(arguments.Title)
	if title == "" {
		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
	}
	portions, err := normalizeSaveRecipeContent("prepared", arguments.Markdown, arguments.Portions)
	if err != nil {
		return nil, SaveRecipeOutput{}, err
	}

	return s.savePreparedRecipe(ctx, title, arguments.Markdown, portions)
}

func (s *Server) callURLSaveRecipeTool(
	ctx context.Context,
	arguments SaveRecipeArguments,
) (*sdk.CallToolResult, SaveRecipeOutput, error) {
	recipeURL := strings.TrimSpace(arguments.URL)
	if recipeURL == "" {
		return nil, SaveRecipeOutput{}, fmt.Errorf("url is required when source is url")
	}

	imported, err := s.backend.ImportRecipeURL(ctx, recipeURL)
	if err != nil {
		return nil, SaveRecipeOutput{}, err
	}

	recipeID := strings.TrimSpace(imported.RecipeID)
	if recipeID != "" {
		return newRecipeSaveResult(recipeID)
	}

	draftID := strings.TrimSpace(imported.DraftID)
	if draftID == "" {
		return nil, SaveRecipeOutput{}, fmt.Errorf("import recipe URL response missing recipe or draft ID")
	}

	metadata, content, err := s.readRecipeParts(ctx, draftID)
	if err != nil {
		return nil, SaveRecipeOutput{}, err
	}

	output := SaveRecipeOutput{
		DraftID:  draftID,
		Title:    metadata.Title,
		Markdown: content.Content,
		Portions: content.Portions,
	}

	return newToolResult(formatRecipeURLImportDraft(output), output), output, nil
}

func (s *Server) callExistingSaveRecipeTool(
	ctx context.Context,
	arguments SaveRecipeArguments,
) (*sdk.CallToolResult, SaveRecipeOutput, error) {
	recipeID := strings.TrimSpace(arguments.RecipeID)
	if recipeID == "" {
		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing")
	}
	if err := validateRecipeID(recipeID); err != nil {
		return nil, SaveRecipeOutput{}, err
	}
	portions, err := normalizeSaveRecipeContent("existing", arguments.Markdown, arguments.Portions)
	if err != nil {
		return nil, SaveRecipeOutput{}, err
	}

	if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, portions); err != nil {
		return nil, SaveRecipeOutput{}, err
	}

	return newRecipeSaveResult(recipeID)
}

func normalizeSaveRecipeContent(source, markdown string, portions float64) (float64, error) {
	if strings.TrimSpace(markdown) == "" {
		return 0, fmt.Errorf("markdown is required when source is %s", source)
	}
	if portions == 0 {
		return defaultSaveRecipePortions, nil
	}
	if portions < 1 {
		return 0, fmt.Errorf("portions must be at least 1 when source is %s", source)
	}

	return portions, nil
}

func (s *Server) savePreparedRecipe(
	ctx context.Context,
	title, markdown string,
	portions float64,
) (*sdk.CallToolResult, SaveRecipeOutput, error) {
	recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions)
	if err != nil {
		return nil, SaveRecipeOutput{}, err
	}
	if strings.TrimSpace(recipeID) == "" {
		return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
	}

	return newRecipeSaveResult(recipeID)
}

func newRecipeSaveResult(recipeID string) (*sdk.CallToolResult, SaveRecipeOutput, error) {
	output := SaveRecipeOutput{RecipeID: recipeID}

	return newToolResult(formatRecipeSave(output), output), output, nil
}

func newRecipeOverwriteResult(recipeID string) (*sdk.CallToolResult, SaveRecipeOutput, error) {
	output := SaveRecipeOutput{RecipeID: recipeID}

	return newToolResult(formatRecipeOverwrite(output), output), output, nil
}

func (s *Server) callDeleteRecipeTool(
	ctx context.Context,
	_ *sdk.CallToolRequest,
	arguments DeleteRecipeArguments,
) (*sdk.CallToolResult, DeleteRecipeOutput, error) {
	recipeID := strings.TrimSpace(arguments.RecipeID)
	if recipeID == "" {
		return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
	}
	if err := validateRecipeID(recipeID); err != nil {
		return nil, DeleteRecipeOutput{}, err
	}

	if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
		return nil, DeleteRecipeOutput{}, err
	}

	output := DeleteRecipeOutput{RecipeID: recipeID}

	return newToolResult(formatRecipeDelete(output), output), output, nil
}

func (s *Server) callChangeShoppingListTool(
	ctx context.Context,
	_ *sdk.CallToolRequest,
	arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
	action := strings.TrimSpace(arguments.Action)
	switch action {
	case "add":
		return s.addShoppingListIngredients(ctx, arguments)
	case "clear":
		return s.clearShoppingList(ctx)
	case "remove":
		return s.removeShoppingListProductGroups(ctx, arguments)
	case "replace_selection":
		return s.replaceShoppingListSelection(ctx, arguments)
	case "add_selection":
		return s.addShoppingListSelection(ctx, arguments)
	case "remove_selection":
		return s.removeShoppingListSelection(ctx, arguments)
	case "update_item":
		return s.updateShoppingListProductGroup(ctx, arguments)
	case "":
		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
	default:
		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
			"unsupported change_shopping_list action %q (supported: add, update_item, replace_selection, add_selection, remove_selection, remove, clear)",
			action,
		)
	}
}

func (s *Server) addShoppingListIngredients(
	ctx context.Context,
	arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
	ingredients := normalizeShoppingListAddIngredients(arguments.Ingredients)
	if ingredients == "" {
		return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
	}
	recipeID := strings.TrimSpace(arguments.RecipeID)

	result, err := s.backend.AddShoppingListIngredients(
		ctx,
		ingredients,
		recipeID,
	)
	if err != nil {
		return nil, ChangeShoppingListOutput{}, err
	}

	output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
	text := fmt.Sprintf("Added %d shopping-list ingredients.", result.AddedCount)

	return newToolResult(text, output), output, nil
}

func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
	if err := s.backend.ClearShoppingList(ctx); err != nil {
		return nil, ChangeShoppingListOutput{}, err
	}

	output := ChangeShoppingListOutput{Cleared: true}

	return newToolResult("Cleared shopping list.", output), output, nil
}

func (s *Server) removeShoppingListProductGroups(
	ctx context.Context,
	arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
	ids, skippedIDs := normalizeProductGroupIDs(arguments.ProductGroupIDs)
	if len(ids) == 0 {
		if len(skippedIDs) > 0 {
			result, output := skippedProductGroupIDsResult(skippedIDs)

			return result, output, nil
		}

		return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
	}

	if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
		return nil, ChangeShoppingListOutput{}, err
	}

	output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids, SkippedProductGroupIDs: skippedIDs}
	text := fmt.Sprintf("Removed %d shopping-list product groups.", len(ids))
	text = appendSkippedProductGroupIDs(text, skippedIDs)

	return newToolResult(text, output), output, nil
}

func (s *Server) replaceShoppingListSelection(
	ctx context.Context,
	arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
	ids, skippedIDs := normalizeProductGroupIDs(arguments.ProductGroupIDs)
	if len(ids) == 0 {
		if len(skippedIDs) > 0 {
			result, output := skippedProductGroupIDsResult(skippedIDs)

			return result, output, nil
		}

		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
			"product_group_ids is required when action is replace_selection",
		)
	}

	return s.setShoppingListSelection(ctx, ids, skippedIDs)
}

func (s *Server) addShoppingListSelection(
	ctx context.Context,
	arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
	ids, skippedIDs := normalizeProductGroupIDs(arguments.ProductGroupIDs)
	if len(ids) == 0 {
		if len(skippedIDs) > 0 {
			result, output := skippedProductGroupIDsResult(skippedIDs)

			return result, output, nil
		}

		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
			"product_group_ids is required when action is add_selection",
		)
	}

	shoppingList, err := s.backend.ReadShoppingList(ctx)
	if err != nil {
		return nil, ChangeShoppingListOutput{}, err
	}

	return s.setShoppingListSelection(
		ctx,
		appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
		skippedIDs,
	)
}

func (s *Server) removeShoppingListSelection(
	ctx context.Context,
	arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
	ids, skippedIDs := normalizeProductGroupIDs(arguments.ProductGroupIDs)
	if len(ids) == 0 {
		if len(skippedIDs) > 0 {
			result, output := skippedProductGroupIDsResult(skippedIDs)

			return result, output, nil
		}

		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
			"product_group_ids is required when action is remove_selection",
		)
	}

	shoppingList, err := s.backend.ReadShoppingList(ctx)
	if err != nil {
		return nil, ChangeShoppingListOutput{}, err
	}

	return s.setShoppingListSelection(
		ctx,
		subtractProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
		skippedIDs,
	)
}

func (s *Server) setShoppingListSelection(
	ctx context.Context,
	ids []string,
	skippedIDs []string,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
	if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
		return nil, ChangeShoppingListOutput{}, err
	}

	output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids, SkippedProductGroupIDs: skippedIDs}
	text := fmt.Sprintf("Selected %d shopping-list product groups.", len(ids))
	text = appendSkippedProductGroupIDs(text, skippedIDs)

	return newToolResult(text, output), output, nil
}

func skippedProductGroupIDsResult(skippedIDs []string) (*sdk.CallToolResult, ChangeShoppingListOutput) {
	output := ChangeShoppingListOutput{SkippedProductGroupIDs: skippedIDs}
	text := appendSkippedProductGroupIDs("No valid shopping-list product group IDs were provided.", skippedIDs)

	return newToolResult(text, output), output
}

func (s *Server) updateShoppingListProductGroup(
	ctx context.Context,
	arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
	productGroupID := strings.TrimSpace(arguments.ProductGroupID)
	if productGroupID == "" {
		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
			"product_group_id is required when action is update_item",
		)
	}
	if err := validateProductGroupID(productGroupID); err != nil {
		return nil, ChangeShoppingListOutput{}, err
	}

	shoppingList, err := s.backend.ReadShoppingList(ctx)
	if err != nil {
		return nil, ChangeShoppingListOutput{}, err
	}

	product, aisleID, ok := findShoppingListProductGroup(shoppingList, productGroupID)
	if !ok {
		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
			"product_group_id %q was not found in the current shopping list",
			productGroupID,
		)
	}

	update := cooked.ShoppingListProductGroupUpdate{
		Name:     product.Name,
		Quantity: product.Quantity,
		AisleID:  aisleID,
		Selected: product.Selected,
	}
	if arguments.Name != nil {
		update.Name = *arguments.Name
	}
	if arguments.Quantity != nil {
		update.Quantity = *arguments.Quantity
	}
	if arguments.AisleID != nil {
		update.AisleID = strings.TrimSpace(*arguments.AisleID)
	}
	if arguments.Selected != nil {
		update.Selected = *arguments.Selected
	}

	if err := s.backend.UpdateShoppingListProductGroup(ctx, productGroupID, update); err != nil {
		return nil, ChangeShoppingListOutput{}, err
	}

	output := ChangeShoppingListOutput{UpdatedProductGroupID: productGroupID}
	text := fmt.Sprintf("Updated shopping-list product group (id: %s).", productGroupID)

	return newToolResult(text, output), output, nil
}

func readTool() *sdk.Tool {
	openWorld := true
	return &sdk.Tool{
		Name:        readToolName,
		Title:       "Read Cooked data",
		Description: "Read Cooked data. Use target shopping_list for the current shopping list, recipes to list or search saved recipe IDs and titles, recipe with recipe_id to read a single recipe, or example_recipes for Cooked recipe markdown examples. You MUST check the examples before saving anything because Cooked uses a particular Markdown dialect. Recipe results omit images and thumbnails.",
		InputSchema: readInputSchema(),
		Annotations: &sdk.ToolAnnotations{
			Title:         "Read Cooked data",
			ReadOnlyHint:  true,
			OpenWorldHint: &openWorld,
		},
	}
}

func extractIntoRecipeTool() *sdk.Tool {
	openWorld := true
	return &sdk.Tool{
		Name:        extractIntoRecipeToolName,
		Title:       "Extract raw recipe text into Cooked recipe",
		Description: "Extract raw recipe text into Cooked recipe markdown and portions without saving or updating a recipe. Extraction rewrites ingredients and steps and infers portions; review the result before saving. If the extracted result is incorrect, save the correct version with source=prepared and your own markdown and portions.",
		Annotations: &sdk.ToolAnnotations{
			Title:         "Extract raw recipe text into Cooked recipe",
			ReadOnlyHint:  true,
			OpenWorldHint: &openWorld,
		},
	}
}

func saveRecipeTool() *sdk.Tool {
	openWorld := true
	return &sdk.Tool{
		Name:        saveRecipeToolName,
		Title:       "Save recipe",
		Description: "Save a recipe from raw text, prepared markdown, a URL import, a reviewed URL-import draft, or an existing recipe update. URL imports will return a draft_id for review instead of saving immediately.",
		InputSchema: saveRecipeInputSchema(),
		Annotations: &sdk.ToolAnnotations{
			Title:         "Save recipe",
			OpenWorldHint: &openWorld,
		},
	}
}

func deleteRecipeTool() *sdk.Tool {
	destructive := true
	openWorld := true
	return &sdk.Tool{
		Name:        deleteRecipeToolName,
		Title:       "Delete recipe",
		Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
		InputSchema: deleteRecipeInputSchema(),
		Annotations: &sdk.ToolAnnotations{
			Title:           "Delete recipe",
			DestructiveHint: &destructive,
			OpenWorldHint:   &openWorld,
		},
	}
}

func readInputSchema() *jsonschema.Schema {
	schema := inputSchemaFor[ReadArguments]("read input schema")
	setPropertyPattern(schema, "recipe_id", uuidPattern)

	return schema
}

func saveRecipeInputSchema() *jsonschema.Schema {
	schema := inputSchemaFor[SaveRecipeArguments]("save_recipe input schema")
	setPropertyPattern(schema, "recipe_id", uuidPattern)

	return schema
}

func deleteRecipeInputSchema() *jsonschema.Schema {
	schema := inputSchemaFor[DeleteRecipeArguments]("delete_recipe input schema")
	setPropertyPattern(schema, "recipe_id", uuidPattern)

	return schema
}

func changeShoppingListTool() *sdk.Tool {
	destructive := true
	openWorld := true
	return &sdk.Tool{
		Name:        changeShoppingListToolName,
		Title:       "Change shopping list",
		Description: "Change the Cooked shopping list. Supports add, update_item, replace_selection, add_selection, remove_selection, remove, and clear. Add accepts newline-separated ingredients and optional recipe_id; put quantities before names, such as `1 milk`. Remove and clear are destructive.",
		InputSchema: changeShoppingListInputSchema(),
		Annotations: &sdk.ToolAnnotations{
			Title:           "Change shopping list",
			DestructiveHint: &destructive,
			OpenWorldHint:   &openWorld,
		},
	}
}

func changeShoppingListInputSchema() *jsonschema.Schema {
	schema, err := jsonschema.For[ChangeShoppingListArguments](&jsonschema.ForOptions{
		TypeSchemas: map[reflect.Type]*jsonschema.Schema{
			reflect.TypeFor[ProductGroupIDs](): {
				Type:  "array",
				Items: &jsonschema.Schema{Type: "string"},
			},
		},
	})
	if err != nil {
		panic(fmt.Errorf("generate change_shopping_list input schema: %w", err))
	}
	setPropertyPattern(schema, "product_group_id", uuidPattern)

	return schema
}

func inputSchemaFor[T any](name string) *jsonschema.Schema {
	schema, err := jsonschema.For[T](nil)
	if err != nil {
		panic(fmt.Errorf("generate %s: %w", name, err))
	}

	return schema
}

func setPropertyPattern(schema *jsonschema.Schema, propertyName, pattern string) {
	property, ok := schema.Properties[propertyName]
	if !ok {
		panic(fmt.Errorf("%s schema property missing", propertyName))
	}
	property.Pattern = pattern
}

func normalizeRecipePage(page, limit int) (int, int) {
	if page < 1 {
		page = 1
	}
	if limit < 1 {
		limit = 10
	}
	if limit > 30 {
		limit = 30
	}

	return page, limit
}

func normalizeProductGroupIDs(ids []string) ([]string, []string) {
	normalized := make([]string, 0, len(ids))
	skipped := []string{}
	for _, id := range ids {
		id = strings.TrimSpace(id)
		if id == "" {
			continue
		}
		if uuidRegex.MatchString(id) {
			normalized = append(normalized, id)
		} else {
			skipped = append(skipped, id)
		}
	}

	return normalized, skipped
}

func appendSkippedProductGroupIDs(text string, skippedIDs []string) string {
	if len(skippedIDs) == 0 {
		return text
	}

	return fmt.Sprintf("%s Skipped malformed product_group_ids: %s.", text, strings.Join(skippedIDs, ", "))
}

func normalizeShoppingListAddIngredients(raw string) string {
	var lines []string
	for line := range strings.SplitSeq(raw, "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}

		lines = append(lines, normalizeShoppingListAddIngredientLine(line))
	}

	return strings.Join(lines, "\n")
}

func normalizeShoppingListAddIngredientLine(line string) string {
	const separator = " — "

	index := strings.LastIndex(line, separator)
	if index < 0 {
		return line
	}

	name := strings.TrimSpace(line[:index])
	quantity := strings.TrimSpace(line[index+len(separator):])
	if name == "" || quantity == "" || !startsWithDigit(quantity) {
		return line
	}

	return quantity + " " + name
}

func startsWithDigit(value string) bool {
	return value[0] >= '0' && value[0] <= '9'
}

func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
	var ids []string
	for _, aisle := range shoppingList.Aisles {
		for _, product := range aisle.ProductGroups {
			if product.Selected {
				ids = append(ids, product.ID)
			}
		}
	}

	return ids
}

func appendMissingProductGroupIDs(existing, additions []string) []string {
	seen := make(map[string]struct{}, len(existing)+len(additions))
	result := make([]string, 0, len(existing)+len(additions))

	for _, id := range existing {
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		result = append(result, id)
	}

	for _, id := range additions {
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		result = append(result, id)
	}

	return result
}

func subtractProductGroupIDs(existing, removals []string) []string {
	removed := make(map[string]struct{}, len(removals))
	for _, id := range removals {
		removed[id] = struct{}{}
	}

	seen := make(map[string]struct{}, len(existing))
	result := make([]string, 0, len(existing))
	for _, id := range existing {
		if _, ok := removed[id]; ok {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		result = append(result, id)
	}

	return result
}

func findShoppingListProductGroup(
	shoppingList cooked.ShoppingList,
	productGroupID string,
) (cooked.ProductGroup, string, bool) {
	for _, aisle := range shoppingList.Aisles {
		for _, product := range aisle.ProductGroups {
			if product.ID == productGroupID {
				return product, aisle.ID, true
			}
		}
	}

	return cooked.ProductGroup{}, "", false
}

func formatShoppingList(shoppingList cooked.ShoppingList) string {
	if len(shoppingList.Aisles) == 0 {
		return "Shopping list is empty."
	}

	totalItems := shoppingListProductGroupCount(shoppingList)
	var builder strings.Builder
	if totalItems > maxShoppingListTextItems {
		fmt.Fprintf(&builder, "Shopping list (showing first %d of %d items):\n",
			maxShoppingListTextItems,
			totalItems)
	} else {
		builder.WriteString("Shopping list:\n")
	}

	shownItems := 0
	for _, aisle := range shoppingList.Aisles {
		if shownItems >= maxShoppingListTextItems {
			break
		}
		shownItems = writeShoppingListAisle(&builder, aisle, shownItems)
	}
	if totalItems > maxShoppingListTextItems {
		fmt.Fprintf(
			&builder,
			"Additional shopping-list items are available in structured_content from this tool response: %d.\n",
			totalItems-maxShoppingListTextItems,
		)
	}

	return strings.TrimRight(builder.String(), "\n")
}

func writeShoppingListAisle(builder *strings.Builder, aisle cooked.Aisle, shownItems int) int {
	builder.WriteString("- ")
	builder.WriteString(aisle.Name)
	builder.WriteString(":")
	if len(aisle.ProductGroups) == 0 {
		builder.WriteString(" no items\n")

		return shownItems
	}
	builder.WriteByte('\n')

	for _, product := range aisle.ProductGroups {
		if shownItems >= maxShoppingListTextItems {
			break
		}

		writeShoppingListProductGroup(builder, product)
		shownItems++
	}

	return shownItems
}

func writeShoppingListProductGroup(builder *strings.Builder, product cooked.ProductGroup) {
	builder.WriteString("  - ")
	builder.WriteString(product.Name)
	if product.Quantity != "" {
		builder.WriteString(" — ")
		builder.WriteString(product.Quantity)
	}
	builder.WriteString(" (id: ")
	builder.WriteString(product.ID)
	builder.WriteString(")")
	if product.Selected {
		builder.WriteString(" selected")
	}
	builder.WriteByte('\n')
}

func shoppingListProductGroupCount(shoppingList cooked.ShoppingList) int {
	count := 0
	for _, aisle := range shoppingList.Aisles {
		count += len(aisle.ProductGroups)
	}

	return count
}

func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
	summaries := make([]RecipeSummary, 0, len(recipes))
	for _, recipe := range recipes {
		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
	}

	return summaries
}
