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

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

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

const (
	serverName                 = "Cooked"
	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 int) (string, error)
	SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions int) (string, error)
	UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) 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 in this slice", 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 &sdk.CallToolResult{
		Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
	}, 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 &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, 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)
	recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
	if err != nil {
		return nil, ReadOutput{}, err
	}

	output := ReadOutput{Recipes: recipeSummaries(recipes)}

	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
}

func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
	if query == "" {
		return s.backend.ListRecipes(ctx, page, limit)
	}

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

	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 &sdk.CallToolResult{
		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeTextPreview(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 in this slice", 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), SaveRecipeOutput{RecipeID: recipeID}, nil
	}

	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 &sdk.CallToolResult{
		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeURLImportDraft(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), SaveRecipeOutput{RecipeID: recipeID}, 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 := 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), SaveRecipeOutput{RecipeID: recipeID}, nil
}

func validateSaveRecipeContent(source, markdown string, portions int) 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 int,
) (*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), SaveRecipeOutput{RecipeID: recipeID}, nil
}

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

	return &sdk.CallToolResult{
		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
	}
}

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 &sdk.CallToolResult{
		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeDelete(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 in this slice",
			action,
		)
	}
}

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

	result, err := s.backend.AddShoppingListIngredients(
		ctx,
		arguments.Ingredients,
		strings.TrimSpace(arguments.RecipeID),
	)
	if err != nil {
		return nil, ChangeShoppingListOutput{}, err
	}

	output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}

	return &sdk.CallToolResult{
		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
			"Added %d shopping-list ingredients.",
			result.AddedCount,
		)}},
	}, 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 &sdk.CallToolResult{
		Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
	}, 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}

	return &sdk.CallToolResult{
		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
			"Removed %d shopping-list product groups.",
			len(ids),
		)}},
	}, 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}

	return &sdk.CallToolResult{
		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
			"Selected %d shopping-list product groups.",
			len(ids),
		)}},
	}, 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}

	return &sdk.CallToolResult{
		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
			"Updated shopping-list product group (id: %s).",
			productGroupID,
		)}},
	}, 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, 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. This slice supports source raw_text with title and text, source prepared with title, markdown, and portions, source url with url, source draft with draft_id, markdown, and portions, and source existing with recipe_id, markdown, and portions.",
		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. This slice supports action add with newline-separated ingredients and optional recipe_id, action remove, replace_selection, add_selection, or remove_selection with product_group_ids, action update_item with product_group_id and optional name, quantity, aisle_id, or selected, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.",
		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 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."
	}

	var builder strings.Builder
	builder.WriteString("Shopping list:\n")
	for _, aisle := range shoppingList.Aisles {
		builder.WriteString("- ")
		builder.WriteString(aisle.Name)
		builder.WriteString(":")
		if len(aisle.ProductGroups) == 0 {
			builder.WriteString(" no items\n")
			continue
		}
		builder.WriteByte('\n')

		for _, product := range aisle.ProductGroups {
			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')
		}
	}

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

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 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: %d\n", 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: %d\n", 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 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: %d", 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       int    `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 int    `json:"portions"`
}

// SaveRecipeArguments contains save_recipe tool arguments.
type SaveRecipeArguments struct {
	Source   string `json:"source"              jsonschema:"Recipe save source. Supported now: raw_text, prepared, url, draft, and 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 int    `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 int    `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. Supported now: add, remove, replace_selection, add_selection, remove_selection, update_item, and clear."`
	Ingredients     string   `json:"ingredients,omitempty"       jsonschema:"Newline-separated ingredients for action add."`
	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"`
}
