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

package mcp

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

type fakeBackend struct {
	shoppingList     cooked.ShoppingList
	recipes          []cooked.RecipeCard
	searchRecipes    []cooked.RecipeCard
	recipeMetadata   cooked.RecipeMetadata
	recipeContent    cooked.RecipeContent
	preview          cooked.RecipeTextPreview
	saveRecipeID     string
	page             int
	limit            int
	searchQuery      string
	searchPage       int
	metadataRecipeID string
	contentRecipeID  string
	previewTitle     string
	previewText      string
	saveTitle        string
	saveMarkdown     string
	savePortions     int
	metadataCalls    int
	contentCalls     int
	previewCalls     int
	saveCalls        int
}

func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
	return f.shoppingList, nil
}

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

	return f.recipes, nil
}

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

	return f.searchRecipes, nil
}

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

	return f.recipeMetadata, nil
}

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

	return f.recipeContent, nil
}

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

	return f.preview, nil
}

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

	return "recipe-1", nil
}
