package core

import (
	"context"
	"fmt"
	"os"
	"sort"
	"strings"
	"unicode"
	"unicode/utf8"

	"charm.land/fantasy"

	udiff "github.com/aymanbagabas/go-udiff"
)

// Edit represents a single replacement in a multi-edit operation.
type Edit struct {
	OldText string `json:"old_text"`
	NewText string `json:"new_text"`
}

// editArgs holds the arguments for the edit tool.
// Supports both single-edit mode (old_text/new_text) and multi-edit mode (edits array).
type editArgs struct {
	Path    string `json:"path"`
	OldText string `json:"old_text"` // Single-edit mode
	NewText string `json:"new_text"` // Single-edit mode
	Edits   []Edit `json:"edits"`    // Multi-edit mode
}

// replacement represents a normalized edit ready for processing.
type replacement struct {
	oldText     string // normalized old text for matching
	newText     string // normalized new text
	originalOld string // original old text for metadata
	originalNew string // original new text for metadata
	index       int    // index in the original edits array (for error messages)
}

// matchedReplacement represents a replacement with its match location.
type matchedReplacement struct {
	replacement
	start          int  // start index in normalized content
	end            int  // end index in normalized content
	usedFuzzyMatch bool // true if fuzzy matching was used
}

// NewEditTool creates the edit core tool.
func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
	cfg := ApplyOptions(opts)
	return &coreTool{
		info: fantasy.ToolInfo{
			Name:        "edit",
			Description: "Edit a file by replacing exact text. Supports single edit via old_text/new_text, or multiple edits via the edits array. All edits in the array are matched against the original file content (non-incremental) and must be non-overlapping.",
			Parameters: map[string]any{
				"path": map[string]any{
					"type":        "string",
					"description": "Path to the file to edit (relative or absolute)",
				},
				"old_text": map[string]any{
					"type":        "string",
					"description": "Exact text to find and replace (single-edit mode). Must not be used with 'edits' array.",
				},
				"new_text": map[string]any{
					"type":        "string",
					"description": "New text to replace the old text with (single-edit mode). Must not be used with 'edits' array.",
				},
				"edits": map[string]any{
					"type":        "array",
					"description": "Array of edits for multi-region replacement. Each edit must have unique, non-overlapping old_text. All matches are against the original file content.",
					"items": map[string]any{
						"type": "object",
						"properties": map[string]any{
							"old_text": map[string]any{
								"type":        "string",
								"description": "Exact text to find and replace for this edit",
							},
							"new_text": map[string]any{
								"type":        "string",
								"description": "New text for this edit",
							},
						},
						"required": []string{"old_text", "new_text"},
					},
				},
			},
			Required: []string{"path"},
		},
		handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
			return executeEdit(ctx, call, cfg.WorkDir)
		},
	}
}

func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
	var args editArgs
	if err := parseArgs(call.Input, &args); err != nil {
		return fantasy.NewTextErrorResponse("failed to parse arguments: " + err.Error()), nil
	}
	if args.Path == "" {
		return fantasy.NewTextErrorResponse("path parameter is required"), nil
	}

	absPath, err := resolvePathWithWorkDir(args.Path, workDir)
	if err != nil {
		return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
	}

	contentBytes, err := os.ReadFile(absPath)
	if err != nil {
		return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to read file: %v", err)), nil
	}

	content := string(contentBytes)

	// Normalize and validate input
	replacements, err := normalizeEditInput(args)
	if err != nil {
		return fantasy.NewTextErrorResponse(err.Error()), nil
	}

	// Apply all edits
	newContent, applied, err := applyEdits(content, replacements)
	if err != nil {
		return fantasy.NewTextErrorResponse(err.Error()), nil
	}

	// Write the file
	if err := os.WriteFile(absPath, []byte(newContent), 0644); err != nil {
		return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
	}

	// Generate diff
	normalizedContent := strings.ReplaceAll(content, "\r\n", "\n")
	diff := generateDiff(absPath, normalizedContent, newContent)

	// Build response with fuzzy match indication
	fuzzyCount := 0
	for _, m := range applied {
		if m.usedFuzzyMatch {
			fuzzyCount++
		}
	}

	var msg string
	if len(applied) == 1 {
		if fuzzyCount > 0 {
			msg = fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff)
		} else {
			msg = fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff)
		}
	} else {
		if fuzzyCount > 0 {
			msg = fmt.Sprintf("Applied %d edits (%d fuzzy) to %s\n%s", len(applied), fuzzyCount, args.Path, diff)
		} else {
			msg = fmt.Sprintf("Applied %d edits to %s\n%s", len(applied), args.Path, diff)
		}
	}

	resp := fantasy.NewTextResponse(msg)
	return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, applied)), nil
}

// normalizeEditInput validates and normalizes the edit input.
// Returns error if both single-edit and multi-edit modes are used.
func normalizeEditInput(args editArgs) ([]replacement, error) {
	singleMode := args.OldText != "" || args.NewText != ""
	multiMode := len(args.Edits) > 0

	if singleMode && multiMode {
		return nil, fmt.Errorf("cannot use old_text/new_text together with edits array")
	}

	if !singleMode && !multiMode {
		return nil, fmt.Errorf("must provide either old_text/new_text or edits array")
	}

	if singleMode {
		if args.OldText == "" {
			return nil, fmt.Errorf("old_text is required when using single-edit mode")
		}
		if args.NewText == "" {
			return nil, fmt.Errorf("new_text is required when using single-edit mode")
		}
		return []replacement{{
			oldText:     strings.ReplaceAll(args.OldText, "\r\n", "\n"),
			newText:     strings.ReplaceAll(args.NewText, "\r\n", "\n"),
			originalOld: args.OldText,
			originalNew: args.NewText,
			index:       0,
		}}, nil
	}

	// Multi-edit mode
	var reps []replacement
	for i, edit := range args.Edits {
		if edit.OldText == "" {
			return nil, fmt.Errorf("edits[%d].old_text is required", i)
		}
		reps = append(reps, replacement{
			oldText:     strings.ReplaceAll(edit.OldText, "\r\n", "\n"),
			newText:     strings.ReplaceAll(edit.NewText, "\r\n", "\n"),
			originalOld: edit.OldText,
			originalNew: edit.NewText,
			index:       i,
		})
	}
	return reps, nil
}

// applyEdits applies multiple replacements to the content.
// All matches are against the original content (non-incremental).
// Returns the new content, the applied matches, and any error.
func applyEdits(content string, edits []replacement) (string, []matchedReplacement, error) {
	normalizedContent := strings.ReplaceAll(content, "\r\n", "\n")

	// Find all matches
	var matched []matchedReplacement
	for _, edit := range edits {
		m, err := findMatch(normalizedContent, edit)
		if err != nil {
			return "", nil, err
		}
		matched = append(matched, *m)
	}

	// Sort by position
	sort.Slice(matched, func(i, j int) bool {
		return matched[i].start < matched[j].start
	})

	// Check for overlaps
	for i := 1; i < len(matched); i++ {
		if matched[i-1].end > matched[i].start {
			return "", nil, fmt.Errorf("edits[%d] and edits[%d] overlap; merge them into a single edit",
				matched[i-1].index, matched[i].index)
		}
	}

	// Apply edits in reverse order (end to start) to maintain stable offsets
	result := normalizedContent
	for i := len(matched) - 1; i >= 0; i-- {
		m := matched[i]
		result = result[:m.start] + m.newText + result[m.end:]
	}

	return result, matched, nil
}

// findMatch finds a unique match for the edit in the content.
// Returns error if not found or ambiguous.
func findMatch(content string, edit replacement) (*matchedReplacement, error) {
	// Try exact match first
	count := strings.Count(content, edit.oldText)

	if count == 0 {
		// Try fuzzy match
		idx, matchLen := fuzzyMatch(content, edit.oldText)
		if idx < 0 {
			return nil, fmt.Errorf("edits[%d]: could not find old_text in file. The text must match exactly (including whitespace)", edit.index)
		}
		// Use the matched text from content for the replacement
		matchedText := content[idx : idx+matchLen]
		return &matchedReplacement{
			replacement: replacement{
				oldText:     matchedText,
				newText:     edit.newText,
				originalOld: edit.originalOld,
				originalNew: edit.originalNew,
				index:       edit.index,
			},
			start:          idx,
			end:            idx + matchLen,
			usedFuzzyMatch: true,
		}, nil
	}

	if count > 1 {
		return nil, fmt.Errorf("found %d matches for edits[%d].old_text; each old_text must be unique, provide more context to identify the correct match", count, edit.index)
	}

	// Single exact match
	idx := strings.Index(content, edit.oldText)
	return &matchedReplacement{
		replacement: edit,
		start:       idx,
		end:         idx + len(edit.oldText),
	}, nil
}

// editDiffMeta builds the structured metadata attached to edit tool responses.
func editDiffMeta(path string, applied []matchedReplacement) map[string]any {
	var diffBlocks []map[string]any
	totalAdditions, totalDeletions := 0, 0

	for _, m := range applied {
		diffBlocks = append(diffBlocks, map[string]any{
			"old_text": m.originalOld,
			"new_text": m.originalNew,
		})
		totalAdditions += strings.Count(m.originalNew, "\n") + 1
		totalDeletions += strings.Count(m.originalOld, "\n") + 1
	}

	return map[string]any{
		"file_diffs": []map[string]any{{
			"path":        path,
			"additions":   totalAdditions,
			"deletions":   totalDeletions,
			"diff_blocks": diffBlocks,
		}},
	}
}

// fuzzyMatch tries to find old_text with relaxed matching:
//   - Strips trailing whitespace per line
//   - Normalizes unicode quotes to ASCII
//   - Normalizes unicode dashes/spaces
//
// Returns (index, matchLength) in the original content, or (-1, 0) if not
// found or ambiguous (multiple matches).
func fuzzyMatch(content, search string) (int, int) {
	normContent, contentMap := normalizeWithMap(content)
	normSearch := normalizeForFuzzy(search)

	if normSearch == "" {
		return -1, 0
	}

	idx := strings.Index(normContent, normSearch)
	if idx < 0 {
		return -1, 0
	}

	// Reject ambiguous matches — if there are multiple fuzzy matches
	// we can't safely pick one.
	if strings.Count(normContent, normSearch) > 1 {
		return -1, 0
	}

	// Map normalized byte positions back to original byte positions.
	origStart := contentMap[idx]
	endNorm := idx + len(normSearch)
	var origEnd int
	if endNorm >= len(normContent) {
		origEnd = len(content)
	} else {
		origEnd = contentMap[endNorm]
	}

	return origStart, origEnd - origStart
}

// normalizeWithMap normalizes s for fuzzy matching and returns both the
// normalized string and a byte-position mapping where mapping[i] is the
// original byte position corresponding to normalized byte position i.
//
// Normalization: trim trailing whitespace per line, replace unicode
// quotes/dashes/spaces with their ASCII equivalents.
func normalizeWithMap(s string) (string, []int) {
	var result []byte
	var mapping []int // mapping[i] = original byte position for result byte i

	lines := strings.Split(s, "\n")
	origPos := 0
	for li, line := range lines {
		if li > 0 {
			result = append(result, '\n')
			mapping = append(mapping, origPos)
			origPos++ // skip \n in original
		}

		trimmed := strings.TrimRightFunc(line, unicode.IsSpace)

		for j := 0; j < len(trimmed); {
			r, size := utf8.DecodeRuneInString(trimmed[j:])
			repl := normalizeRune(r)
			for k := 0; k < len(repl); k++ {
				mapping = append(mapping, origPos+j)
			}
			result = append(result, repl...)
			j += size
		}

		origPos += len(line) // advance past full original line including trailing ws
	}

	return string(result), mapping
}

// normalizeRune maps unicode quotes, dashes, and non-breaking spaces to
// their ASCII equivalents. Returns the original rune as a string for all
// other characters.
func normalizeRune(r rune) string {
	switch r {
	case '\u201c', '\u201d': // left/right double quote
		return "\""
	case '\u2018', '\u2019': // left/right single quote
		return "'"
	case '\u2013', '\u2014': // en dash, em dash
		return "-"
	case '\u00a0': // non-breaking space
		return " "
	default:
		return string(r)
	}
}

// normalizeForFuzzy normalizes s for fuzzy matching (without position mapping).
// Used for the search string where position mapping is not needed.
func normalizeForFuzzy(s string) string {
	norm, _ := normalizeWithMap(s)
	return norm
}

// generateDiff creates a unified diff showing the change between old and new
// file contents. Uses the go-udiff library for correct diff computation.
func generateDiff(path, old, new string) string {
	return udiff.Unified(path, path, old, new)
}
