// This file implements the edit tool (US-017): exact string replacement within
// a file. old_string must match exactly; if it is not unique (and replace_all
// is false) the edit is rejected. A unified-style diff of the change is returned
// for the UI to render. Paths resolve against a Root with the same traversal
// guard as the read/write tools.
package agenttool

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"strings"

	"github.com/smallnest/pigo/internal/agentcore"
)

// EditTool performs exact string replacements in files under Root.
type EditTool struct {
	// Root bounds all edits; a path resolving outside Root is rejected. Empty
	// Root defaults to the current working directory.
	Root string
	// ExtraRoots are additional trusted directories an edit may target even though
	// they lie outside Root. It exists for the skills directory so the model can
	// modify existing skills that live outside the workspace.
	ExtraRoots []string
	// Snap, when non-nil, records the file's prior content before it is edited so
	// the /rewind command can roll the change back. It is shared with the write tool.
	Snap *FileSnapshotRecorder
}

// editToolArgs is the decoded argument shape for EditTool.
type editToolArgs struct {
	Path       string `json:"path"`
	OldString  string `json:"old_string"`
	NewString  string `json:"new_string"`
	ReplaceAll bool   `json:"replace_all,omitempty"`
}

// Name implements AgentTool.
func (t *EditTool) Name() string { return "edit" }

// Description implements AgentTool.
func (t *EditTool) Description() string {
	return "Replace an exact string in a file. old_string must be unique unless " +
		"replace_all is set. Returns a diff of the change."
}

// Schema implements AgentTool.
func (t *EditTool) Schema() json.RawMessage {
	return json.RawMessage(`{
  "type": "object",
  "properties": {
    "path":        {"type": "string", "description": "File path to edit, relative to the workspace root."},
    "old_string":  {"type": "string", "description": "Exact text to replace."},
    "new_string":  {"type": "string", "description": "Replacement text."},
    "replace_all": {"type": "boolean", "description": "Replace every occurrence instead of requiring a unique match."}
  },
  "required": ["path", "old_string", "new_string"],
  "additionalProperties": false
}`)
}

// ExecutionMode implements AgentTool. Edits mutate the filesystem → sequential.
func (t *EditTool) ExecutionMode() agentcore.ToolExecutionMode {
	return agentcore.ToolExecutionSequential
}

// resolvePath resolves p against Root (or any ExtraRoots) via the shared
// resolveWithin boundary policy, so every file tool enforces the same
// workspace-escape guard while edits can also reach trusted extra roots.
func (t *EditTool) resolvePath(p string) (string, error) {
	if len(t.ExtraRoots) == 0 {
		return resolveWithin(t.Root, p)
	}
	return resolveWithinAny(append([]string{t.Root}, t.ExtraRoots...), p)
}

// Execute implements AgentTool. Edit failures (no match, non-unique match,
// missing file, out-of-root) are encoded as error results.
func (t *EditTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
	a, bad := decodeArgs[editToolArgs](args, "edit")
	if bad != nil {
		return *bad, nil
	}
	if a.Path == "" {
		return errorResult("edit: path is required"), nil
	}
	if a.OldString == a.NewString {
		return errorResult("edit: old_string and new_string are identical; nothing to change"), nil
	}
	full, err := t.resolvePath(a.Path)
	if err != nil {
		return errorResult("edit: " + err.Error()), nil
	}
	data, err := os.ReadFile(full)
	if err != nil {
		if os.IsNotExist(err) {
			return errorResult(fmt.Sprintf("edit: file %q does not exist", a.Path)), nil
		}
		return errorResult(fmt.Sprintf("edit: cannot read %q: %v", a.Path, err)), nil
	}
	original := string(data)

	count := strings.Count(original, a.OldString)
	if count == 0 {
		return errorResult(fmt.Sprintf("edit: old_string not found in %q", a.Path)), nil
	}
	if count > 1 && !a.ReplaceAll {
		return errorResult(fmt.Sprintf("edit: old_string is not unique in %q (%d matches); provide more context or set replace_all", a.Path, count)), nil
	}

	var updated string
	if a.ReplaceAll {
		updated = strings.ReplaceAll(original, a.OldString, a.NewString)
	} else {
		updated = strings.Replace(original, a.OldString, a.NewString, 1)
	}

	// Snapshot the prior state before mutating so /rewind can restore it.
	t.Snap.Record(full)
	if err := os.WriteFile(full, []byte(updated), filePerm); err != nil {
		return errorResult(fmt.Sprintf("edit: cannot write %q: %v", a.Path, err)), nil
	}

	diff := unifiedDiff(a.Path, original, updated)
	replaced := 1
	if a.ReplaceAll {
		replaced = count
	}
	msg := fmt.Sprintf("Edited %s (%d replacement(s))\n%s", a.Path, replaced, diff)
	return agentcore.AgentToolResult{
		Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
		Details: map[string]any{"path": a.Path, "replacements": replaced, "diff": diff},
	}, nil
}

// unifiedDiff produces a minimal line-based diff between old and new content.
// It is not a full unified-diff implementation (no hunk coalescing); it emits a
// header plus per-line -/+ markers, which is enough for a UI to render the
// change. Unchanged lines are shown with a leading space for context.
func unifiedDiff(path, oldContent, newContent string) string {
	oldLines := splitLinesKeep(oldContent)
	newLines := splitLinesKeep(newContent)

	var b strings.Builder
	fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", path, path)

	// Longest common subsequence over lines drives the -/+ markers.
	ops := diffLines(oldLines, newLines)
	for _, op := range ops {
		switch op.kind {
		case diffEqual:
			fmt.Fprintf(&b, " %s\n", op.text)
		case diffDelete:
			fmt.Fprintf(&b, "-%s\n", op.text)
		case diffInsert:
			fmt.Fprintf(&b, "+%s\n", op.text)
		}
	}
	return b.String()
}

// splitLinesKeep splits s into lines, dropping a single trailing newline so an
// empty final element is not produced for the common "ends with \n" case.
func splitLinesKeep(s string) []string {
	if s == "" {
		return nil
	}
	lines := strings.Split(s, "\n")
	if len(lines) > 0 && lines[len(lines)-1] == "" {
		lines = lines[:len(lines)-1]
	}
	return lines
}

type diffKind int

const (
	diffEqual diffKind = iota
	diffDelete
	diffInsert
)

type diffOp struct {
	kind diffKind
	text string
}

// diffLines computes a line diff via a standard LCS dynamic-programming table,
// then backtracks to emit equal/delete/insert ops in order.
func diffLines(a, b []string) []diffOp {
	n, m := len(a), len(b)
	// lcs[i][j] = length of LCS of a[i:] and b[j:].
	lcs := make([][]int, n+1)
	for i := range lcs {
		lcs[i] = make([]int, m+1)
	}
	for i := n - 1; i >= 0; i-- {
		for j := m - 1; j >= 0; j-- {
			if a[i] == b[j] {
				lcs[i][j] = lcs[i+1][j+1] + 1
			} else if lcs[i+1][j] >= lcs[i][j+1] {
				lcs[i][j] = lcs[i+1][j]
			} else {
				lcs[i][j] = lcs[i][j+1]
			}
		}
	}
	var ops []diffOp
	i, j := 0, 0
	for i < n && j < m {
		if a[i] == b[j] {
			ops = append(ops, diffOp{diffEqual, a[i]})
			i++
			j++
		} else if lcs[i+1][j] >= lcs[i][j+1] {
			ops = append(ops, diffOp{diffDelete, a[i]})
			i++
		} else {
			ops = append(ops, diffOp{diffInsert, b[j]})
			j++
		}
	}
	for ; i < n; i++ {
		ops = append(ops, diffOp{diffDelete, a[i]})
	}
	for ; j < m; j++ {
		ops = append(ops, diffOp{diffInsert, b[j]})
	}
	return ops
}
