// 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"
	"strconv"
	"strings"

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

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

const (
	serverName                 = "Cooked"
	maxShoppingListTextItems   = 30
	readToolName               = "read"
	previewRecipeTextToolName  = "preview_recipe_text"
	saveRecipeToolName         = "save_recipe"
	deleteRecipeToolName       = "delete_recipe"
	changeShoppingListToolName = "change_shopping_list"
)

// 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, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
	sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
	sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool)
	sdk.AddTool(server.sdk, changeShoppingListTool(), server.callChangeShoppingListTool)

	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)
	default:
		return nil, ReadOutput{}, fmt.Errorf(
			"unsupported read target %q (supported: shopping_list, recipes, recipe)",
			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")
	}

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

	recipe := RecipeDetail{
		ID:             recipeID,
		Title:          metadata.Title,
		Owner:          metadata.Owner,
		EditPermission: metadata.EditPermission,
		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) callPreviewRecipeTextTool(
	ctx context.Context,
	_ *sdk.CallToolRequest,
	arguments PreviewRecipeTextArguments,
) (*sdk.CallToolResult, PreviewRecipeTextOutput, error) {
	title := strings.TrimSpace(arguments.Title)
	if title == "" {
		return nil, PreviewRecipeTextOutput{}, fmt.Errorf("title is required")
	}
	if strings.TrimSpace(arguments.Text) == "" {
		return nil, PreviewRecipeTextOutput{}, fmt.Errorf("text is required")
	}

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

	output := PreviewRecipeTextOutput{
		Title:    preview.Title,
		Markdown: preview.Markdown,
		Portions: preview.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")
	}
	if err := validateSaveRecipeContent("prepared", arguments.Markdown, arguments.Portions); err != nil {
		return nil, SaveRecipeOutput{}, err
	}

	return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.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) callDraftSaveRecipeTool(
	ctx context.Context,
	arguments SaveRecipeArguments,
) (*sdk.CallToolResult, SaveRecipeOutput, error) {
	draftID := strings.TrimSpace(arguments.DraftID)
	if draftID == "" {
		return nil, SaveRecipeOutput{}, fmt.Errorf("draft_id is required when source is draft")
	}
	if err := validateSaveRecipeContent("draft", arguments.Markdown, arguments.Portions); err != nil {
		return nil, SaveRecipeOutput{}, err
	}

	recipeID, err := s.backend.SaveRecipeDraft(ctx, draftID, arguments.Markdown, arguments.Portions)
	if err != nil {
		return nil, SaveRecipeOutput{}, err
	}
	if strings.TrimSpace(recipeID) == "" {
		return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe draft response missing recipe ID")
	}

	return newRecipeSaveResult(recipeID)
}

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 := validateSaveRecipeContent("existing", arguments.Markdown, arguments.Portions); err != nil {
		return nil, SaveRecipeOutput{}, err
	}

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

	return newRecipeSaveResult(recipeID)
}

func validateSaveRecipeContent(source, markdown string, portions float64) error {
	if strings.TrimSpace(markdown) == "" {
		return fmt.Errorf("markdown is required when source is %s", source)
	}
	if portions < 1 {
		return fmt.Errorf("portions is required when source is %s", source)
	}

	return 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 (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 := 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")
	}

	result, err := s.backend.AddShoppingListIngredients(
		ctx,
		ingredients,
		strings.TrimSpace(arguments.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 := normalizeProductGroupIDs(arguments.ProductGroupIDs)
	if len(ids) == 0 {
		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}
	text := fmt.Sprintf("Removed %d shopping-list product groups.", len(ids))

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

func (s *Server) replaceShoppingListSelection(
	ctx context.Context,
	arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
	if len(ids) == 0 {
		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
			"product_group_ids is required when action is replace_selection",
		)
	}

	return s.setShoppingListSelection(ctx, ids)
}

func (s *Server) addShoppingListSelection(
	ctx context.Context,
	arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
	if len(ids) == 0 {
		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),
	)
}

func (s *Server) removeShoppingListSelection(
	ctx context.Context,
	arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
	if len(ids) == 0 {
		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),
	)
}

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

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

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

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",
		)
	}

	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 newToolResult[Output any](text string, output Output) *sdk.CallToolResult {
	return &sdk.CallToolResult{
		Content:           []sdk.Content{&sdk.TextContent{Text: text}},
		StructuredContent: output,
	}
}

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, target recipes to list or search saved recipe IDs and titles, or target recipe with recipe_id to read a single recipe. Recipe results omit images and thumbnails.",
		Annotations: &sdk.ToolAnnotations{
			Title:         "Read Cooked data",
			ReadOnlyHint:  true,
			OpenWorldHint: &openWorld,
		},
	}
}

func previewRecipeTextTool() *sdk.Tool {
	openWorld := true
	return &sdk.Tool{
		Name:        previewRecipeTextToolName,
		Title:       "Preview recipe text",
		Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
		Annotations: &sdk.ToolAnnotations{
			Title:         "Preview recipe text",
			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 may return a draft_id for review instead of saving immediately.",
		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.",
		Annotations: &sdk.ToolAnnotations{
			Title:           "Delete recipe",
			DestructiveHint: &destructive,
			OpenWorldHint:   &openWorld,
		},
	}
}

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`. Simple read-output lines like `milk — 1` are normalized. Remove and clear are destructive.",
		Annotations: &sdk.ToolAnnotations{
			Title:           "Change shopping list",
			DestructiveHint: &destructive,
			OpenWorldHint:   &openWorld,
		},
	}
}

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 {
	normalized := make([]string, 0, len(ids))
	for _, id := range ids {
		id = strings.TrimSpace(id)
		if id != "" {
			normalized = append(normalized, id)
		}
	}

	return normalized
}

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 formatRecipes(recipes []cooked.RecipeCard) string {
	if len(recipes) == 0 {
		return "No saved recipes found."
	}

	var builder strings.Builder
	builder.WriteString("Saved recipes:\n")
	for _, recipe := range recipes {
		builder.WriteString("- ")
		builder.WriteString(recipe.Title)
		builder.WriteString(" (id: ")
		builder.WriteString(recipe.ID)
		builder.WriteString(")\n")
	}

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

func formatRecipeNextPageHint(query string, nextPage, limit int) string {
	if query == "" {
		return fmt.Sprintf(
			"More recipes may be available. Call read with target recipes, page %d, limit %d to continue.",
			nextPage,
			limit,
		)
	}

	return fmt.Sprintf(
		"More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to continue.",
		query,
		nextPage,
		limit,
	)
}

func formatRecipeSearchLimitHint(query string, page, nextPage, limit int) string {
	return fmt.Sprintf(
		"More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to include more results from this page before trying page %d.",
		query,
		page,
		limit,
		nextPage,
	)
}

func formatRecipe(recipe RecipeDetail) string {
	title := recipe.Title
	if title == "" {
		title = "(untitled recipe)"
	}

	var builder strings.Builder
	builder.WriteString("Recipe: ")
	builder.WriteString(title)
	builder.WriteString(" (id: ")
	builder.WriteString(recipe.ID)
	builder.WriteString(")\n")
	if recipe.Owner != "" {
		builder.WriteString("Owner: ")
		builder.WriteString(recipe.Owner)
		builder.WriteByte('\n')
	}
	fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
	fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(recipe.Portions))

	content := strings.TrimSpace(recipe.Content)
	if content == "" {
		builder.WriteString("\nNo recipe content returned.")
	} else {
		builder.WriteString("\n")
		builder.WriteString(content)
	}

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

func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
	title := preview.Title
	if title == "" {
		title = "(untitled preview)"
	}

	var builder strings.Builder
	builder.WriteString("Recipe text preview: ")
	builder.WriteString(title)
	builder.WriteByte('\n')
	fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(preview.Portions))

	markdown := strings.TrimSpace(preview.Markdown)
	if markdown == "" {
		builder.WriteString("\nNo preview markdown returned.")
	} else {
		builder.WriteString("\n")
		builder.WriteString(markdown)
	}

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

func formatPortions(portions float64) string { return strconv.FormatFloat(portions, 'f', -1, 64) }

func formatRecipeSave(output SaveRecipeOutput) string {
	return "Saved recipe (id: " + output.RecipeID + ")."
}

func formatRecipeURLImportDraft(output SaveRecipeOutput) string {
	title := output.Title
	if strings.TrimSpace(title) == "" {
		title = "(untitled draft)"
	}

	var builder strings.Builder
	builder.WriteString("Imported recipe URL as draft: ")
	builder.WriteString(title)
	builder.WriteString(" (draft_id: ")
	builder.WriteString(output.DraftID)
	builder.WriteString("). Review the markdown and portions before saving.")
	if output.Portions > 0 {
		fmt.Fprintf(&builder, "\nPortions: %s", formatPortions(output.Portions))
	}
	if strings.TrimSpace(output.Markdown) != "" {
		builder.WriteString("\n\n")
		builder.WriteString(output.Markdown)
	}

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

func formatRecipeDelete(output DeleteRecipeOutput) string {
	return "Deleted recipe (id: " + output.RecipeID + ")."
}

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
}

// ReadArguments contains read tool arguments.
type ReadArguments struct {
	Target   string `json:"target"              jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
	Query    string `json:"query,omitempty"     jsonschema:"Search query for target recipes. Omit to list saved recipes."`
	Page     int    `json:"page,omitempty"      jsonschema:"Page number for target recipes. Defaults to 1."`
	Limit    int    `json:"limit,omitempty"     jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
}

// ReadOutput is the structured output for the current read tool slice.
type ReadOutput struct {
	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
	Recipes             []RecipeSummary `json:"recipes,omitempty"`
	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
}

// RecipeSummary is the MCP-safe saved recipe summary.
type RecipeSummary struct {
	ID    string `json:"id"`
	Title string `json:"title"`
}

// RecipeDetail is the MCP-safe single recipe output.
type RecipeDetail struct {
	ID             string  `json:"id"`
	Title          string  `json:"title"`
	Owner          string  `json:"owner"`
	EditPermission bool    `json:"edit_permission"`
	Content        string  `json:"content"`
	Portions       float64 `json:"portions"`
}

// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
type PreviewRecipeTextArguments struct {
	Title string `json:"title" jsonschema:"Recipe title for the preview."`
	Text  string `json:"text"  jsonschema:"Raw recipe text to preview without saving."`
}

// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
type PreviewRecipeTextOutput struct {
	Title    string  `json:"title"`
	Markdown string  `json:"markdown"`
	Portions float64 `json:"portions"`
}

// SaveRecipeArguments contains save_recipe tool arguments.
type SaveRecipeArguments struct {
	Source   string  `json:"source"              jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."`
	RecipeID string  `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
	DraftID  string  `json:"draft_id,omitempty"  jsonschema:"Draft ID for reviewed URL import draft saves."`
	Title    string  `json:"title,omitempty"     jsonschema:"Recipe title for raw_text or prepared saves."`
	Text     string  `json:"text,omitempty"      jsonschema:"Raw recipe text for raw_text saves."`
	URL      string  `json:"url,omitempty"       jsonschema:"Recipe URL for url imports."`
	Markdown string  `json:"markdown,omitempty"  jsonschema:"Recipe markdown for prepared saves, reviewed URL import drafts, or existing recipe updates."`
	Portions float64 `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves, reviewed URL import drafts, or existing recipe updates."`
}

// SaveRecipeOutput is the structured output for save_recipe.
type SaveRecipeOutput struct {
	RecipeID string  `json:"recipe_id,omitempty"`
	DraftID  string  `json:"draft_id,omitempty"`
	Title    string  `json:"title,omitempty"`
	Markdown string  `json:"markdown,omitempty"`
	Portions float64 `json:"portions,omitempty"`
}

// DeleteRecipeArguments contains delete_recipe tool arguments.
type DeleteRecipeArguments struct {
	RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
}

// DeleteRecipeOutput is the structured output for delete_recipe.
type DeleteRecipeOutput struct {
	RecipeID string `json:"recipe_id"`
}

// ChangeShoppingListArguments contains change_shopping_list tool arguments.
type ChangeShoppingListArguments struct {
	Action          string   `json:"action"                      jsonschema:"Shopping-list action: add, remove, replace_selection, add_selection, remove_selection, update_item, or clear."`
	Ingredients     string   `json:"ingredients,omitempty"       jsonschema:"Newline-separated ingredients for action add. Put quantities before names, for example 1 milk."`
	RecipeID        string   `json:"recipe_id,omitempty"         jsonschema:"Optional saved recipe ID or import draft ID for action add."`
	ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
	ProductGroupID  string   `json:"product_group_id,omitempty"  jsonschema:"Product group ID for action update_item."`
	Name            *string  `json:"name,omitempty"              jsonschema:"Replacement product name for action update_item."`
	Quantity        *string  `json:"quantity,omitempty"          jsonschema:"Replacement quantity for action update_item."`
	AisleID         *string  `json:"aisle_id,omitempty"          jsonschema:"Replacement aisle ID for action update_item."`
	Selected        *bool    `json:"selected,omitempty"          jsonschema:"Replacement selected state for action update_item."`
}

// ChangeShoppingListOutput is the structured output for change_shopping_list.
type ChangeShoppingListOutput struct {
	Cleared                 bool     `json:"cleared,omitempty"`
	AddedCount              int      `json:"added_count,omitempty"`
	Ingredients             []string `json:"ingredients,omitempty"`
	RemovedProductGroupIDs  []string `json:"removed_product_group_ids,omitempty"`
	SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
	UpdatedProductGroupID   string   `json:"updated_product_group_id,omitempty"`
}
