// This file implements the search tools (US-019): grep (search file contents by
// regexp with optional glob filtering), find (locate files by name glob), and ls
// (list a directory, distinguishing files from directories). All three resolve
// paths against a Root with the same boundary guard as the other tools and skip
// paths ignored by the workspace .gitignore. They are read-only → parallel.
package agenttool

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"

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

// searchMaxResults caps the number of matches/entries any single search returns
// so a broad query cannot flood the model's context.
const searchMaxResults = 1000

// resolveWithin resolves p against root and verifies it stays within it. It is
// the single workspace-boundary policy shared by every file tool: the search
// tools call it directly, and ReadTool/WriteTool/EditTool.resolvePath delegate
// to it, so the path-traversal guard lives in exactly one place.
func resolveWithin(root, p string) (string, error) {
	if root == "" {
		wd, err := os.Getwd()
		if err != nil {
			return "", fmt.Errorf("cannot determine working directory: %w", err)
		}
		root = wd
	}
	absRoot, err := filepath.Abs(root)
	if err != nil {
		return "", fmt.Errorf("invalid root: %w", err)
	}
	var full string
	if filepath.IsAbs(p) {
		full = filepath.Clean(p)
	} else {
		full = filepath.Join(absRoot, p)
	}
	rel, err := filepath.Rel(absRoot, full)
	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
		return "", fmt.Errorf("path %q is outside the workspace root", p)
	}
	return full, nil
}

// resolveWithinAny resolves p against the first root that contains it, trying
// roots in order. It exists so the file tools can additionally permit trusted
// out-of-workspace roots (the skills directory) whose absolute SKILL.md paths
// pigo itself advertises in the system prompt: without this, the workspace guard
// would reject the very paths the model is instructed to read or author. Empty
// roots are skipped; if none contain p, the standard workspace-escape error is
// returned.
func resolveWithinAny(roots []string, p string) (string, error) {
	var lastErr error
	for _, root := range roots {
		if root == "" {
			continue
		}
		full, err := resolveWithin(root, p)
		if err == nil {
			return full, nil
		}
		lastErr = err
	}
	if lastErr != nil {
		return "", lastErr
	}
	// No usable roots supplied: fall back to the default (cwd) policy.
	return resolveWithin("", p)
}

// gitignore is a minimal .gitignore matcher. It supports the common subset:
// blank lines and #-comments are skipped; a leading "/" anchors to the root;
// a trailing "/" matches directories only; "!" negation re-includes; and plain
// patterns match by base name or path via filepath.Match. It is intentionally
// not a full gitignore implementation (no "**" spanning, no nested .gitignore).
type gitignore struct {
	rules []ignoreRule
	// hasSegmentRule is true when at least one rule matches by path segment
	// (non-anchored, no "/"). Only then does ignored() need to split relPath into
	// segments, so the common all-anchored case skips the split entirely.
	hasSegmentRule bool
}

type ignoreRule struct {
	pattern  string
	negate   bool
	dirOnly  bool
	anchored bool
	// matchFull is precomputed at load time: an anchored pattern, or one that
	// contains a "/", matches against the full relative path; otherwise the rule
	// matches by base name or any single path segment. Hoisting this out of the
	// per-file loop avoids a strings.Contains scan for every file × rule.
	matchFull bool
}

// loadGitignore reads root/.gitignore. A missing file yields an empty matcher
// (matches nothing), never an error.
func loadGitignore(root string) *gitignore {
	gi := &gitignore{}
	data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
	if err != nil {
		return gi
	}
	sc := bufio.NewScanner(strings.NewReader(string(data)))
	for sc.Scan() {
		line := strings.TrimRight(sc.Text(), " ")
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		r := ignoreRule{}
		if strings.HasPrefix(line, "!") {
			r.negate = true
			line = line[1:]
		}
		if strings.HasSuffix(line, "/") {
			r.dirOnly = true
			line = strings.TrimSuffix(line, "/")
		}
		if strings.HasPrefix(line, "/") {
			r.anchored = true
			line = strings.TrimPrefix(line, "/")
		}
		if line == "" {
			continue
		}
		r.pattern = line
		r.matchFull = r.anchored || strings.Contains(line, "/")
		if !r.matchFull {
			gi.hasSegmentRule = true
		}
		gi.rules = append(gi.rules, r)
	}
	return gi
}

// ignored reports whether relPath (slash-separated, relative to root) is ignored.
// isDir refines dir-only rules. Later rules win, so a negation can re-include.
//
// The relPath is split into segments at most once per call (only when a
// segment-matching rule exists), rather than re-splitting inside the rule loop:
// this keeps the per-file cost O(rules) instead of O(rules × pathSegments),
// which matters because ignored() is called for every entry of a WalkDir.
func (g *gitignore) ignored(relPath string, isDir bool) bool {
	relPath = filepath.ToSlash(relPath)
	base := relPath
	if i := strings.LastIndex(relPath, "/"); i >= 0 {
		base = relPath[i+1:]
	}
	var segs []string
	if g.hasSegmentRule {
		segs = strings.Split(relPath, "/")
	}
	result := false
	for _, r := range g.rules {
		if r.dirOnly && !isDir {
			continue
		}
		var match bool
		if r.matchFull {
			match, _ = filepath.Match(r.pattern, relPath)
		} else {
			match, _ = filepath.Match(r.pattern, base)
			if !match {
				// A non-anchored pattern also matches any path component,
				// so an ignored directory hides everything beneath it.
				for _, seg := range segs {
					if ok, _ := filepath.Match(r.pattern, seg); ok {
						match = true
						break
					}
				}
			}
		}
		if match {
			result = !r.negate
		}
	}
	return result
}

// GrepTool searches file contents by regexp under Root, honoring .gitignore.
type GrepTool struct {
	// Root bounds the search; empty defaults to the current working directory.
	Root string
}

type grepToolArgs struct {
	// Pattern is the regexp to search for (Go regexp syntax).
	Pattern string `json:"pattern"`
	// Path optionally scopes the search to a subdirectory (relative to Root).
	Path string `json:"path,omitempty"`
	// Glob optionally filters files by base-name glob (e.g. "*.go").
	Glob string `json:"glob,omitempty"`
}

func (t *GrepTool) Name() string { return "grep" }
func (t *GrepTool) Description() string {
	return "Search file contents by regular expression under the workspace, " +
		"optionally filtering files by glob. Skips .gitignore'd paths."
}
func (t *GrepTool) ExecutionMode() agentcore.ToolExecutionMode {
	return agentcore.ToolExecutionParallel
}
func (t *GrepTool) Schema() json.RawMessage {
	return json.RawMessage(`{
  "type": "object",
  "properties": {
    "pattern": {"type": "string", "description": "Regular expression to search for."},
    "path":    {"type": "string", "description": "Subdirectory to scope the search to (relative to the workspace root)."},
    "glob":    {"type": "string", "description": "Filter files by base-name glob, e.g. *.go."}
  },
  "required": ["pattern"],
  "additionalProperties": false
}`)
}

func (t *GrepTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
	a, bad := decodeArgs[grepToolArgs](args, "grep")
	if bad != nil {
		return *bad, nil
	}
	if a.Pattern == "" {
		return errorResult("grep: pattern is required"), nil
	}
	re, err := regexp.Compile(a.Pattern)
	if err != nil {
		return errorResult(fmt.Sprintf("grep: invalid pattern: %v", err)), nil
	}
	root, err := resolveWithin(t.Root, "")
	if err != nil {
		return errorResult("grep: " + err.Error()), nil
	}
	start := root
	if a.Path != "" {
		if start, err = resolveWithin(t.Root, a.Path); err != nil {
			return errorResult("grep: " + err.Error()), nil
		}
	}
	gi := loadGitignore(root)

	var matches []string
	count := 0
	walkErr := filepath.WalkDir(start, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return nil // skip unreadable entries
		}
		rel, _ := filepath.Rel(root, path)
		if rel == "." {
			return nil
		}
		if rel == ".git" || strings.HasPrefix(rel, ".git"+string(filepath.Separator)) {
			if d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		if gi.ignored(rel, d.IsDir()) {
			if d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		if d.IsDir() {
			return nil
		}
		if a.Glob != "" {
			if ok, _ := filepath.Match(a.Glob, d.Name()); !ok {
				return nil
			}
		}
		f, err := os.Open(path)
		if err != nil {
			return nil
		}
		defer f.Close()
		sc := bufio.NewScanner(f)
		sc.Buffer(make([]byte, 0, scanBufInit), grepScanBufMax)
		lineNo := 0
		for sc.Scan() {
			lineNo++
			line := sc.Text()
			if re.MatchString(line) {
				matches = append(matches, fmt.Sprintf("%s:%d:%s", rel, lineNo, line))
				count++
				if count >= searchMaxResults {
					return filepath.SkipAll
				}
			}
		}
		return nil
	})
	if walkErr != nil {
		return errorResult(fmt.Sprintf("grep: %v", walkErr)), nil
	}

	msg := fmt.Sprintf("%d match(es) for %q", len(matches), a.Pattern)
	if len(matches) > 0 {
		msg += "\n" + strings.Join(matches, "\n")
	}
	if count >= searchMaxResults {
		msg += fmt.Sprintf("\n[truncated at %d matches]", searchMaxResults)
	}
	return agentcore.AgentToolResult{
		Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
		Details: map[string]any{"matches": len(matches)},
	}, nil
}

// FindTool locates files by base-name glob under Root, honoring .gitignore.
type FindTool struct {
	// Root bounds the search; empty defaults to the current working directory.
	Root string
}

type findToolArgs struct {
	// Glob is the base-name glob to match (e.g. "*.go").
	Glob string `json:"glob"`
	// Path optionally scopes the search to a subdirectory (relative to Root).
	Path string `json:"path,omitempty"`
}

func (t *FindTool) Name() string { return "find" }
func (t *FindTool) Description() string {
	return "Find files by base-name glob under the workspace. Skips .gitignore'd paths."
}
func (t *FindTool) ExecutionMode() agentcore.ToolExecutionMode {
	return agentcore.ToolExecutionParallel
}
func (t *FindTool) Schema() json.RawMessage {
	return json.RawMessage(`{
  "type": "object",
  "properties": {
    "glob": {"type": "string", "description": "Base-name glob to match, e.g. *.go."},
    "path": {"type": "string", "description": "Subdirectory to scope the search to (relative to the workspace root)."}
  },
  "required": ["glob"],
  "additionalProperties": false
}`)
}

func (t *FindTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
	a, bad := decodeArgs[findToolArgs](args, "find")
	if bad != nil {
		return *bad, nil
	}
	if a.Glob == "" {
		return errorResult("find: glob is required"), nil
	}
	root, err := resolveWithin(t.Root, "")
	if err != nil {
		return errorResult("find: " + err.Error()), nil
	}
	start := root
	if a.Path != "" {
		if start, err = resolveWithin(t.Root, a.Path); err != nil {
			return errorResult("find: " + err.Error()), nil
		}
	}
	gi := loadGitignore(root)

	var found []string
	walkErr := filepath.WalkDir(start, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return nil
		}
		rel, _ := filepath.Rel(root, path)
		if rel == "." {
			return nil
		}
		if rel == ".git" || strings.HasPrefix(rel, ".git"+string(filepath.Separator)) {
			if d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		if gi.ignored(rel, d.IsDir()) {
			if d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		if d.IsDir() {
			return nil
		}
		if ok, _ := filepath.Match(a.Glob, d.Name()); ok {
			found = append(found, rel)
			if len(found) >= searchMaxResults {
				return filepath.SkipAll
			}
		}
		return nil
	})
	if walkErr != nil {
		return errorResult(fmt.Sprintf("find: %v", walkErr)), nil
	}
	sort.Strings(found)

	msg := fmt.Sprintf("%d file(s) matching %q", len(found), a.Glob)
	if len(found) > 0 {
		msg += "\n" + strings.Join(found, "\n")
	}
	return agentcore.AgentToolResult{
		Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
		Details: map[string]any{"count": len(found)},
	}, nil
}

// LsTool lists the entries of a directory, distinguishing files from directories.
type LsTool struct {
	// Root bounds the listing; empty defaults to the current working directory.
	Root string
}

type lsToolArgs struct {
	// Path is the directory to list, relative to Root (empty = Root itself).
	Path string `json:"path,omitempty"`
}

func (t *LsTool) Name() string { return "ls" }
func (t *LsTool) Description() string {
	return "List a directory's entries, marking directories with a trailing slash."
}
func (t *LsTool) ExecutionMode() agentcore.ToolExecutionMode { return agentcore.ToolExecutionParallel }
func (t *LsTool) Schema() json.RawMessage {
	return json.RawMessage(`{
  "type": "object",
  "properties": {
    "path": {"type": "string", "description": "Directory to list, relative to the workspace root."}
  },
  "additionalProperties": false
}`)
}

func (t *LsTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
	a, bad := decodeArgs[lsToolArgs](args, "ls")
	if bad != nil {
		return *bad, nil
	}
	full, err := resolveWithin(t.Root, a.Path)
	if err != nil {
		return errorResult("ls: " + err.Error()), nil
	}
	info, err := os.Stat(full)
	if err != nil {
		if os.IsNotExist(err) {
			return errorResult(fmt.Sprintf("ls: %q does not exist", a.Path)), nil
		}
		return errorResult(fmt.Sprintf("ls: cannot stat %q: %v", a.Path, err)), nil
	}
	if !info.IsDir() {
		return errorResult(fmt.Sprintf("ls: %q is not a directory", a.Path)), nil
	}
	entries, err := os.ReadDir(full)
	if err != nil {
		return errorResult(fmt.Sprintf("ls: cannot read %q: %v", a.Path, err)), nil
	}

	var dirs, files []string
	for _, e := range entries {
		if e.IsDir() {
			dirs = append(dirs, e.Name()+"/")
		} else {
			files = append(files, e.Name())
		}
	}
	sort.Strings(dirs)
	sort.Strings(files)
	lines := append(dirs, files...)

	label := a.Path
	if label == "" {
		label = "."
	}
	msg := fmt.Sprintf("%s (%d dir(s), %d file(s))", label, len(dirs), len(files))
	if len(lines) > 0 {
		msg += "\n" + strings.Join(lines, "\n")
	}
	return agentcore.AgentToolResult{
		Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
		Details: map[string]any{"dirs": len(dirs), "files": len(files)},
	}, nil
}
