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

// 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)
	UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error
	DeleteRecipe(ctx context.Context, recipeID string) 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)

	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, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
	if err != nil {
		return nil, ReadOutput{}, err
	}
	content, err := s.backend.ReadRecipeContent(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) 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 "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 strings.TrimSpace(arguments.Markdown) == "" {
		return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is prepared")
	}
	if arguments.Portions < 1 {
		return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is prepared")
	}

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

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 strings.TrimSpace(arguments.Markdown) == "" {
		return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is existing")
	}
	if arguments.Portions < 1 {
		return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is existing")
	}

	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 (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 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, and source existing with recipe_id, markdown, and portions. Other source values are reserved for later slices.",
		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 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 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 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, and existing."`
	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
	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."`
	Markdown string `json:"markdown,omitempty"  jsonschema:"Recipe markdown for prepared saves or existing recipe updates."`
	Portions int    `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves or existing recipe updates."`
}

// SaveRecipeOutput is the structured output for save_recipe.
type SaveRecipeOutput struct {
	RecipeID string `json:"recipe_id,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"`
}
