package tools

import (
	"context"
	_ "embed"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"charm.land/fantasy"
	"github.com/charmbracelet/crush/internal/csync"
	"github.com/charmbracelet/crush/internal/diff"
	"github.com/charmbracelet/crush/internal/filepathext"
	"github.com/charmbracelet/crush/internal/filetracker"
	"github.com/charmbracelet/crush/internal/fsext"
	"github.com/charmbracelet/crush/internal/history"

	"github.com/charmbracelet/crush/internal/lsp"
	"github.com/charmbracelet/crush/internal/permission"
)

type EditParams struct {
	FilePath   string `json:"file_path" description:"The absolute path to the file to modify"`
	OldString  string `json:"old_string" description:"The text to replace"`
	NewString  string `json:"new_string" description:"The text to replace it with"`
	ReplaceAll bool   `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)"`
}

type EditPermissionsParams struct {
	FilePath   string `json:"file_path"`
	OldContent string `json:"old_content,omitempty"`
	NewContent string `json:"new_content,omitempty"`
}

type EditResponseMetadata struct {
	Additions  int    `json:"additions"`
	Removals   int    `json:"removals"`
	OldContent string `json:"old_content,omitempty"`
	NewContent string `json:"new_content,omitempty"`
}

const EditToolName = "edit"

const (
	errOldStringNotFound     = "old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"
	errOldStringMultipleHits = "old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"
)

var (
	viewLinePrefixRE     = regexp.MustCompile(`^\s*\d+\|\s?`)
	collapseBlankLinesRE = regexp.MustCompile(`\n{3,}`)
	markdownCodeFenceRE  = regexp.MustCompile("(?s)^\\s*```[^\\n]*\\n(.*)\\n```\\s*$")
)

//go:embed edit.md
var editDescription []byte

type editContext struct {
	ctx         context.Context
	permissions permission.Service
	files       history.Service
	workingDir  string
}

func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
	return fantasy.NewAgentTool(
		EditToolName,
		string(editDescription),
		func(ctx context.Context, params EditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
			if params.FilePath == "" {
				return fantasy.NewTextErrorResponse("file_path is required"), nil
			}

			params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)

			var response fantasy.ToolResponse
			var err error

			editCtx := editContext{ctx, permissions, files, workingDir}

			if params.OldString == "" {
				response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
			} else if params.NewString == "" {
				response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
			} else {
				response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
			}

			if err != nil {
				return response, err
			}
			if response.IsError {
				// Return early if there was an error during content replacement
				// This prevents unnecessary LSP diagnostics processing
				return response, nil
			}

			notifyLSPs(ctx, lspClients, params.FilePath)

			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
			text += getDiagnostics(params.FilePath, lspClients)
			response.Content = text
			return response, nil
		})
}

func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
	fileInfo, err := os.Stat(filePath)
	if err == nil {
		if fileInfo.IsDir() {
			return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
		}
		return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
	} else if !os.IsNotExist(err) {
		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
	}

	dir := filepath.Dir(filePath)
	if err = os.MkdirAll(dir, 0o755); err != nil {
		return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
	}

	sessionID := GetSessionFromContext(edit.ctx)
	if sessionID == "" {
		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
	}

	_, additions, removals := diff.GenerateDiff(
		"",
		content,
		strings.TrimPrefix(filePath, edit.workingDir),
	)
	p, err := edit.permissions.Request(edit.ctx,
		permission.CreatePermissionRequest{
			SessionID:   sessionID,
			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
			ToolCallID:  call.ID,
			ToolName:    EditToolName,
			Action:      "write",
			Description: fmt.Sprintf("Create file %s", filePath),
			Params: EditPermissionsParams{
				FilePath:   filePath,
				OldContent: "",
				NewContent: content,
			},
		},
	)
	if err != nil {
		return fantasy.ToolResponse{}, err
	}
	if !p {
		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
	}

	err = os.WriteFile(filePath, []byte(content), 0o644)
	if err != nil {
		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
	}

	// File can't be in the history so we create a new file history
	_, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
	if err != nil {
		// Log error but don't fail the operation
		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
	}

	// Add the new content to the file history
	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
	if err != nil {
		// Log error but don't fail the operation
		slog.Error("Error creating file history version", "error", err)
	}

	filetracker.RecordWrite(filePath)
	filetracker.RecordRead(filePath)

	return fantasy.WithResponseMetadata(
		fantasy.NewTextResponse("File created: "+filePath),
		EditResponseMetadata{
			OldContent: "",
			NewContent: content,
			Additions:  additions,
			Removals:   removals,
		},
	), nil
}

func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
	fileInfo, err := os.Stat(filePath)
	if err != nil {
		if os.IsNotExist(err) {
			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
		}
		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
	}

	if fileInfo.IsDir() {
		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
	}

	if filetracker.LastReadTime(filePath).IsZero() {
		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
	}

	modTime := fileInfo.ModTime()
	lastRead := filetracker.LastReadTime(filePath)
	if modTime.After(lastRead) {
		return fantasy.NewTextErrorResponse(
			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
			)), nil
	}

	content, err := os.ReadFile(filePath)
	if err != nil {
		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
	}

	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))

	var newContent string

	if replaceAll {
		// For replaceAll, try fuzzy match if exact match fails.
		replaced, found := replaceAllWithBestMatch(oldContent, oldString, "")
		if !found {
			return fantasy.NewTextErrorResponse(errOldStringNotFound), nil
		}
		newContent = replaced
	} else {
		// Try exact match first, then fuzzy match.
		matchedString, found, isMultiple := findBestMatch(oldContent, oldString)
		if !found {
			return fantasy.NewTextErrorResponse(errOldStringNotFound), nil
		}
		if isMultiple {
			return fantasy.NewTextErrorResponse(errOldStringMultipleHits), nil
		}

		index := strings.Index(oldContent, matchedString)
		newContent = oldContent[:index] + oldContent[index+len(matchedString):]
	}

	sessionID := GetSessionFromContext(edit.ctx)

	if sessionID == "" {
		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
	}

	_, additions, removals := diff.GenerateDiff(
		oldContent,
		newContent,
		strings.TrimPrefix(filePath, edit.workingDir),
	)

	p, err := edit.permissions.Request(edit.ctx,
		permission.CreatePermissionRequest{
			SessionID:   sessionID,
			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
			ToolCallID:  call.ID,
			ToolName:    EditToolName,
			Action:      "write",
			Description: fmt.Sprintf("Delete content from file %s", filePath),
			Params: EditPermissionsParams{
				FilePath:   filePath,
				OldContent: oldContent,
				NewContent: newContent,
			},
		},
	)
	if err != nil {
		return fantasy.ToolResponse{}, err
	}
	if !p {
		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
	}

	if isCrlf {
		newContent, _ = fsext.ToWindowsLineEndings(newContent)
	}

	err = os.WriteFile(filePath, []byte(newContent), 0o644)
	if err != nil {
		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
	}

	// Check if file exists in history
	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
	if err != nil {
		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
		if err != nil {
			// Log error but don't fail the operation
			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
		}
	}
	if file.Content != oldContent {
		// User manually changed the content; store an intermediate version
		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
		if err != nil {
			slog.Error("Error creating file history version", "error", err)
		}
	}
	// Store the new version
	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
	if err != nil {
		slog.Error("Error creating file history version", "error", err)
	}

	filetracker.RecordWrite(filePath)
	filetracker.RecordRead(filePath)

	return fantasy.WithResponseMetadata(
		fantasy.NewTextResponse("Content deleted from file: "+filePath),
		EditResponseMetadata{
			OldContent: oldContent,
			NewContent: newContent,
			Additions:  additions,
			Removals:   removals,
		},
	), nil
}

func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
	fileInfo, err := os.Stat(filePath)
	if err != nil {
		if os.IsNotExist(err) {
			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
		}
		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
	}

	if fileInfo.IsDir() {
		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
	}

	if filetracker.LastReadTime(filePath).IsZero() {
		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
	}

	modTime := fileInfo.ModTime()
	lastRead := filetracker.LastReadTime(filePath)
	if modTime.After(lastRead) {
		return fantasy.NewTextErrorResponse(
			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
			)), nil
	}

	content, err := os.ReadFile(filePath)
	if err != nil {
		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
	}

	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))

	var newContent string

	if replaceAll {
		// For replaceAll, try fuzzy match if exact match fails.
		replaced, found := replaceAllWithBestMatch(oldContent, oldString, newString)
		if !found {
			return fantasy.NewTextErrorResponse(errOldStringNotFound), nil
		}
		newContent = replaced
	} else {
		// Try exact match first, then fuzzy match.
		matchedString, found, isMultiple := findBestMatch(oldContent, oldString)
		if !found {
			return fantasy.NewTextErrorResponse(errOldStringNotFound), nil
		}
		if isMultiple {
			return fantasy.NewTextErrorResponse(errOldStringMultipleHits), nil
		}

		index := strings.Index(oldContent, matchedString)
		newContent = oldContent[:index] + newString + oldContent[index+len(matchedString):]
	}

	if oldContent == newContent {
		return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
	}
	sessionID := GetSessionFromContext(edit.ctx)

	if sessionID == "" {
		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
	}
	_, additions, removals := diff.GenerateDiff(
		oldContent,
		newContent,
		strings.TrimPrefix(filePath, edit.workingDir),
	)

	p, err := edit.permissions.Request(edit.ctx,
		permission.CreatePermissionRequest{
			SessionID:   sessionID,
			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
			ToolCallID:  call.ID,
			ToolName:    EditToolName,
			Action:      "write",
			Description: fmt.Sprintf("Replace content in file %s", filePath),
			Params: EditPermissionsParams{
				FilePath:   filePath,
				OldContent: oldContent,
				NewContent: newContent,
			},
		},
	)
	if err != nil {
		return fantasy.ToolResponse{}, err
	}
	if !p {
		return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
	}

	if isCrlf {
		newContent, _ = fsext.ToWindowsLineEndings(newContent)
	}

	err = os.WriteFile(filePath, []byte(newContent), 0o644)
	if err != nil {
		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
	}

	// Check if file exists in history
	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
	if err != nil {
		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
		if err != nil {
			// Log error but don't fail the operation
			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
		}
	}
	if file.Content != oldContent {
		// User manually changed the content; store an intermediate version
		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
		if err != nil {
			slog.Debug("Error creating file history version", "error", err)
		}
	}
	// Store the new version
	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
	if err != nil {
		slog.Error("Error creating file history version", "error", err)
	}

	filetracker.RecordWrite(filePath)
	filetracker.RecordRead(filePath)

	return fantasy.WithResponseMetadata(
		fantasy.NewTextResponse("Content replaced in file: "+filePath),
		EditResponseMetadata{
			OldContent: oldContent,
			NewContent: newContent,
			Additions:  additions,
			Removals:   removals,
		}), nil
}

// findBestMatch attempts to find a match for oldString in content. If an exact
// match is found, it returns the oldString unchanged. Otherwise, it tries
// several normalization strategies to find a fuzzy match.
//
// Returns: (matchedString, found, isMultiple)
//   - matchedString: the actual string found in content that should be used
//   - found: whether any match was found
//   - isMultiple: whether multiple matches were found (ambiguous)
func findBestMatch(content, oldString string) (string, bool, bool) {
	oldString = normalizeOldStringForMatching(oldString)

	// Strategy 1: Exact match.
	index := strings.Index(content, oldString)
	if index != -1 {
		lastIndex := strings.LastIndex(content, oldString)
		return oldString, true, index != lastIndex
	}

	// Strategy 2: Try trimming surrounding blank lines.
	trimmedSurrounding := trimSurroundingBlankLines(oldString)
	if trimmedSurrounding != "" && trimmedSurrounding != oldString {
		index := strings.Index(content, trimmedSurrounding)
		if index != -1 {
			lastIndex := strings.LastIndex(content, trimmedSurrounding)
			return trimmedSurrounding, true, index != lastIndex
		}
	}

	// Strategy 3: Try trimming trailing whitespace from each line of oldString.
	trimmedLines := trimTrailingWhitespacePerLine(oldString)
	if trimmedLines != oldString {
		index := strings.Index(content, trimmedLines)
		if index != -1 {
			lastIndex := strings.LastIndex(content, trimmedLines)
			return trimmedLines, true, index != lastIndex
		}
	}

	// Strategy 4: Try with/without trailing newline.
	if strings.HasSuffix(oldString, "\n") {
		withoutTrailing := strings.TrimSuffix(oldString, "\n")
		index := strings.Index(content, withoutTrailing)
		if index != -1 {
			lastIndex := strings.LastIndex(content, withoutTrailing)
			return withoutTrailing, true, index != lastIndex
		}
	} else {
		withTrailing := oldString + "\n"
		index := strings.Index(content, withTrailing)
		if index != -1 {
			lastIndex := strings.LastIndex(content, withTrailing)
			return withTrailing, true, index != lastIndex
		}
	}

	// Strategy 5: Try matching with flexible blank lines (collapse multiple
	// blank lines to single).
	collapsedOld := collapseBlankLines(oldString)
	if collapsedOld != oldString {
		index := strings.Index(content, collapsedOld)
		if index != -1 {
			lastIndex := strings.LastIndex(content, collapsedOld)
			return collapsedOld, true, index != lastIndex
		}
	}

	// Strategy 6: Try normalizing indentation (find content with same structure
	// but different leading whitespace).
	matched, found, isMultiple := tryNormalizeIndentation(content, oldString)
	if found {
		return matched, true, isMultiple
	}

	if collapsedOld != oldString {
		matched, found, isMultiple := tryNormalizeIndentation(content, collapsedOld)
		if found {
			return matched, true, isMultiple
		}
	}

	return "", false, false
}

func normalizeOldStringForMatching(oldString string) string {
	oldString, _ = fsext.ToUnixLineEndings(oldString)
	oldString = stripZeroWidthCharacters(oldString)
	oldString = stripMarkdownCodeFences(oldString)
	oldString = stripViewLineNumbers(oldString)
	return oldString
}

func stripZeroWidthCharacters(s string) string {
	s = strings.ReplaceAll(s, "\ufeff", "")
	s = strings.ReplaceAll(s, "\u200b", "")
	s = strings.ReplaceAll(s, "\u200c", "")
	s = strings.ReplaceAll(s, "\u200d", "")
	s = strings.ReplaceAll(s, "\u2060", "")
	return s
}

func stripMarkdownCodeFences(s string) string {
	m := markdownCodeFenceRE.FindStringSubmatch(s)
	if len(m) != 2 {
		return s
	}
	return m[1]
}

func stripViewLineNumbers(s string) string {
	lines := strings.Split(s, "\n")
	if len(lines) < 2 {
		return s
	}

	var withPrefix int
	for _, line := range lines {
		if viewLinePrefixRE.MatchString(line) {
			withPrefix++
		}
	}

	if withPrefix < (len(lines)+1)/2 {
		return s
	}

	for i, line := range lines {
		lines[i] = viewLinePrefixRE.ReplaceAllString(line, "")
	}

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

func trimSurroundingBlankLines(s string) string {
	lines := strings.Split(s, "\n")
	start := 0
	for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
		start++
	}

	end := len(lines)
	for end > start && strings.TrimSpace(lines[end-1]) == "" {
		end--
	}

	return strings.Join(lines[start:end], "\n")
}

// replaceAllWithBestMatch replaces all occurrences of oldString in content
// with newString, using fuzzy matching strategies if an exact match fails.
func replaceAllWithBestMatch(content, oldString, newString string) (string, bool) {
	oldString = normalizeOldStringForMatching(oldString)
	if oldString == "" {
		return "", false
	}

	if strings.Contains(content, oldString) {
		return strings.ReplaceAll(content, oldString, newString), true
	}

	newContent, ok := tryReplaceAllWithFlexibleMultilineRegexp(content, oldString, newString)
	if ok {
		return newContent, true
	}

	collapsedOld := collapseBlankLines(oldString)
	if collapsedOld != oldString {
		newContent, ok := tryReplaceAllWithFlexibleMultilineRegexp(content, collapsedOld, newString)
		if ok {
			return newContent, true
		}
	}

	matchedString, found, _ := findBestMatch(content, oldString)
	if !found || matchedString == "" {
		return "", false
	}
	return strings.ReplaceAll(content, matchedString, newString), true
}

func tryReplaceAllWithFlexibleMultilineRegexp(content, oldString, newString string) (string, bool) {
	re := buildFlexibleMultilineRegexp(oldString)
	if re == nil {
		return "", false
	}

	if !re.MatchString(content) {
		return "", false
	}

	newContent := re.ReplaceAllStringFunc(content, func(string) string {
		return newString
	})
	return newContent, true
}

func buildFlexibleMultilineRegexp(oldString string) *regexp.Regexp {
	oldString = normalizeOldStringForMatching(oldString)
	lines := strings.Split(oldString, "\n")
	if len(lines) > 0 && lines[len(lines)-1] == "" {
		lines = lines[:len(lines)-1]
	}
	if len(lines) < 2 {
		return nil
	}

	patternParts := make([]string, 0, len(lines))
	for _, line := range lines {
		trimmedLeft := strings.TrimLeft(line, " \t")
		trimmed := strings.TrimRight(trimmedLeft, " \t")
		if trimmed == "" {
			patternParts = append(patternParts, `^[ \t]*$`)
			continue
		}
		escaped := regexp.QuoteMeta(trimmed)
		patternParts = append(patternParts, `^[ \t]*`+escaped+`[ \t]*$`)
	}

	pattern := "(?m)" + strings.Join(patternParts, "\n")
	re, err := regexp.Compile(pattern)
	if err != nil {
		return nil
	}
	return re
}

// trimTrailingWhitespacePerLine removes trailing spaces/tabs from each line.
func trimTrailingWhitespacePerLine(s string) string {
	lines := strings.Split(s, "\n")
	for i, line := range lines {
		lines[i] = strings.TrimRight(line, " \t")
	}
	return strings.Join(lines, "\n")
}

// collapseBlankLines replaces multiple consecutive blank lines with a single
// blank line.
func collapseBlankLines(s string) string {
	return collapseBlankLinesRE.ReplaceAllString(s, "\n\n")
}

// tryNormalizeIndentation attempts to find a match by adjusting indentation.
// It extracts the "shape" of the code (non-whitespace content per line) and
// looks for that pattern in the content with potentially different
// indentation.
func tryNormalizeIndentation(content, oldString string) (string, bool, bool) {
	re := buildFlexibleMultilineRegexp(oldString)
	if re == nil {
		return "", false, false
	}

	matches := re.FindAllStringIndex(content, 2)
	if len(matches) == 0 {
		return "", false, false
	}
	if len(matches) > 1 {
		return content[matches[0][0]:matches[0][1]], true, true
	}
	return content[matches[0][0]:matches[0][1]], true, false
}
