package builtin

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"golang.org/x/text/transform"

	fileenc "reasonix/internal/fileutil/encoding"
	"reasonix/internal/proc"
	"reasonix/internal/sandbox"
	"reasonix/internal/secrets"
	"reasonix/internal/sessiontemp"
	"reasonix/internal/tool"
)

const (
	grepMaxMatches     = 200
	grepDefaultTimeout = 30 * time.Second
	grepMaxTimeout     = 300 * time.Second
)

// grepTimeout clamps a caller-supplied second count to a sane bound; 0 (omitted)
// falls back to the default so a pathological walk can't hang for minutes.
func grepTimeout(sec int) time.Duration {
	switch {
	case sec <= 0:
		return grepDefaultTimeout
	case time.Duration(sec)*time.Second > grepMaxTimeout:
		return grepMaxTimeout
	default:
		return time.Duration(sec) * time.Second
	}
}

func formatGrep(ctx context.Context, out []string, truncated bool, to time.Duration) string {
	timedOut := ctx.Err() == context.DeadlineExceeded
	if len(out) == 0 {
		if timedOut {
			return fmt.Sprintf("(no matches; timed out after %s — narrow the path/pattern or raise timeout_seconds)", to)
		}
		return "(no matches)"
	}
	res := strings.Join(out, "\n")
	switch {
	case truncated:
		res += fmt.Sprintf("\n... (truncated at %d matches)", grepMaxMatches)
	case timedOut:
		res += fmt.Sprintf("\n... (timed out after %s; results incomplete — narrow the path/pattern or raise timeout_seconds)", to)
	}
	return res
}

func init() { tool.RegisterBuiltin(grepTool{}) }

// grepTool searches files by regex. workDir, when non-empty, is the directory a
// relative path resolves against (see resolveIn). rg, when non-empty, is a
// ripgrep binary the search delegates to instead of the native Go scanner.
// forbidRoots lists directories the tool may not search inside.
// sb is the OS sandbox spec for the ripgrep subprocess, making forbid-read
// directories invisible to ripgrep instead of checking them in-process.
type grepTool struct {
	workDir     string
	paths       *PathResolver
	rg          string
	forbidRoots []string
	sb          sandbox.Spec
	sessionTemp *sessiontemp.Manager
}

func (grepTool) Name() string { return "grep" }

func (g grepTool) Description() string {
	if g.rg != "" {
		return "Search for a regular expression in a file, or recursively under a directory — ripgrep-backed, so it honors .gitignore. Returns matching lines as path:line:text, capped at 200 matches. Independent searches with no data dependency should be issued in the same round."
	}
	return "Search for a regular expression in a file, or recursively under a directory (skips hidden files and files matched by .gitignore). Returns matching lines as path:line:text, capped at 200 matches. Independent searches with no data dependency should be issued in the same round."
}

func (grepTool) Schema() json.RawMessage {
	return json.RawMessage(`{"type":"object","properties":{"pattern":{"type":"string","description":"Regular expression (RE2 syntax)"},"path":{"type":"string","description":"File or directory to search (default \".\")"},"timeout_seconds":{"type":"integer","description":"Abort and return partial matches after this many seconds (default 30, max 300). Raise it for a large tree; lower it for a quick probe.","minimum":1}},"required":["pattern"]}`)
}

func (grepTool) ReadOnly() bool { return true }

// SnipHint keeps a long head of matches and a short tail: the first matches are
// the ones the model usually acts on, the tail just confirms scope.
func (grepTool) SnipHint() tool.SnipHint {
	return tool.SnipHint{Head: 80, Tail: 8, HeadChars: 10000, TailChars: 1000}
}

func (g grepTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
	var p struct {
		Pattern        string `json:"pattern"`
		Path           string `json:"path"`
		TimeoutSeconds int    `json:"timeout_seconds"`
	}
	if err := json.Unmarshal(args, &p); err != nil {
		return "", fmt.Errorf("invalid args: %w", err)
	}
	if p.Pattern == "" {
		return "", fmt.Errorf("pattern is required")
	}
	if p.Path == "" {
		p.Path = "."
	}
	rp := resolveReadablePath(g.workDir, p.Path, g.paths)
	p.Path = rp.Path

	to := grepTimeout(p.TimeoutSeconds)
	ctx, cancel := context.WithTimeout(ctx, to)
	defer cancel()

	info, err := os.Stat(p.Path)
	if err != nil {
		if rp.External {
			return "", fmt.Errorf("grep %s: %s", rp.DisplayPath, rp.ErrorText(err))
		}
		return "", fmt.Errorf("grep %s: %w", rp.DisplayPath, err)
	}
	if confineRead(g.forbidRoots, p.Path) {
		if info.IsDir() {
			return formatGrep(ctx, nil, false, to), nil
		}
		err := &os.PathError{Op: "stat", Path: p.Path, Err: os.ErrNotExist}
		if rp.External {
			return "", fmt.Errorf("grep %s: %s", rp.DisplayPath, rp.ErrorText(err))
		}
		return "", err
	}

	if g.rg != "" {
		out, wrapped, err := g.runRipgrep(ctx, p.Pattern, p.Path, to, rp)
		if len(g.forbidRoots) == 0 || wrapped {
			return out, err
		}
		// Without an OS sandbox, ripgrep can walk into forbid-read roots. Fall
		// back to the native scanner, which prunes those roots in-process.
	}

	return g.runNative(ctx, p.Pattern, p.Path, info, to, rp)
}

func (g grepTool) runNative(ctx context.Context, pattern, path string, info os.FileInfo, to time.Duration, rp ResolvedPath) (string, error) {
	re, err := regexp.Compile(pattern)
	if err != nil {
		return "", fmt.Errorf("invalid pattern: %w", err)
	}

	var out []string
	truncated := false

	// Reused across the serial walk so each file doesn't re-allocate ~72 KiB.
	peekBuf := make([]byte, 8*1024)
	scanBuf := make([]byte, 0, 64*1024)

	// searchFile returns io.EOF as a sentinel once the cap is reached.
	searchFile := func(file string) error {
		if confineRead(g.forbidRoots, file) {
			return nil
		}
		f, err := os.Open(file)
		if err != nil {
			return nil // skip unreadable files
		}
		defer f.Close()

		// Peek the first 8 KiB to reject binaries cheaply without reading
		// the entire file into memory. Check BOM first (UTF-16 files have
		// 0x00 for ASCII), then NUL.
		n, _ := io.ReadFull(f, peekBuf)
		peek := peekBuf[:n]

		bomKind := fileenc.DetectQuick(peek)
		if bomKind != fileenc.UTF16LE && bomKind != fileenc.UTF16BE && bomKind != fileenc.UTF8BOM {
			if bytes.IndexByte(peek, 0) >= 0 {
				return nil // binary, skip
			}
		}

		// Detect encoding from the peek alone — sufficient for the
		// UTF-8 vs GB18030 distinction (utf8.Valid on 8 KiB is reliable).
		// Then stream the rest through a decoder so the 200-match cap can
		// stop reading early instead of buffering the entire file.
		enc, _ := fileenc.Detect(peek)

		var src io.Reader
		if enc == fileenc.UTF16LE || enc == fileenc.UTF16BE {
			// UTF-16 needs full-file decode (multi-byte units span the
			// whole stream). These files are rare in grep targets.
			rest, err := io.ReadAll(f)
			if err != nil {
				return nil
			}
			all := append(peek, rest...)
			src = bytes.NewReader(fileenc.Decode(all, enc))
		} else {
			// Non-BOM path: stream through the decoder so the scanner can
			// stop as soon as the cap is reached without buffering the file.
			dec := fileenc.Decoder(enc)
			if dec != nil {
				src = transform.NewReader(io.MultiReader(bytes.NewReader(peek), f), dec)
			} else {
				// UTF-8 or LossyUTF8 — no transformation needed.
				src = io.MultiReader(bytes.NewReader(peek), f)
			}
		}

		sc := bufio.NewScanner(src)
		sc.Buffer(scanBuf, 1024*1024)
		ln := 0
		for sc.Scan() {
			ln++
			line := sc.Text()
			if strings.IndexByte(line, 0) >= 0 {
				return nil // looks binary, skip the file
			}
			if re.MatchString(line) {
				out = append(out, fmt.Sprintf("%s:%d:%s", rp.DisplayFor(file), ln, line))
				if len(out) >= grepMaxMatches {
					truncated = true
					return io.EOF
				}
			}
		}
		return nil
	}

	if info.IsDir() {
		ig := newWalkIgnorer(path, g.forbidRoots)
		_ = filepath.WalkDir(path, func(path string, d os.DirEntry, err error) error {
			if ctx.Err() != nil {
				return ctx.Err() // abort promptly on cancel — a huge tree is interruptible
			}
			if err != nil {
				return nil
			}
			if d.IsDir() {
				if ig.skip(path, d.Name(), true) {
					return filepath.SkipDir
				}
				ig.enter(path)
				return nil
			}
			if ig.skip(path, d.Name(), false) {
				return nil
			}
			if errors.Is(searchFile(path), io.EOF) {
				return filepath.SkipAll
			}
			return nil
		})
	} else {
		_ = searchFile(path)
	}

	return formatGrep(ctx, out, truncated, to), nil
}

// runRipgrep delegates the search to ripgrep, which already emits
// path:line:text with these flags and honors .gitignore. Output is streamed and
// capped at grepMaxMatches so a flood of hits can't blow up memory.
// The ripgrep subprocess is wrapped in the OS sandbox so forbid-read
// directories are invisible to it.
func (g grepTool) runRipgrep(ctx context.Context, pattern, path string, to time.Duration, rp ResolvedPath) (string, bool, error) {
	// Build the ripgrep argv and wrap it in the OS sandbox so forbid-read
	// directories are invisible to the ripgrep subprocess.
	args := []string{
		g.rg,
		"--no-heading", "--line-number", "--with-filename", "--color", "never",
	}
	if secrets.ProtectSensitiveFiles() {
		// Mirror sensitiveReadPath for the subprocess: ripgrep cannot call
		// back into confineRead, so the denylist rides along as glob excludes.
		args = append(args,
			"--glob", "!.env",
			"--glob", "!.git-credentials",
			"--glob", "!.netrc",
			"--glob", "!*.pem",
			"--glob", "!*.key",
			"--glob", "!*.p12",
			"--glob", "!*.pfx",
			"--glob", "!.ssh/**",
		)
	}
	args = append(args, "--regexp", pattern, "--", path)

	var lease *sessiontemp.Lease
	sessionDir := ""
	if m := g.sessionTempManager(ctx); m != nil {
		l, err := m.Acquire()
		if err != nil {
			return "", false, fmt.Errorf("session temporary directory: %w", err)
		}
		lease = l
		sessionDir = l.Dir()
		defer lease.Release()
	}
	prepared := sandbox.PrepareArgs(g.sb, args, sessionDir)
	argv, wrapped := prepared.Argv, prepared.Wrapped
	if len(g.forbidRoots) > 0 && !wrapped {
		return "", wrapped, nil
	}

	cmd := proc.CommandContext(ctx, argv[0], argv[1:]...)
	cmd.Env = applyEnvOverrides(secrets.ProcessEnv(), prepared.EnvOverrides)
	proc.HideWindow(cmd)
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return "", wrapped, err
	}
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	if err := cmd.Start(); err != nil {
		return "", wrapped, fmt.Errorf("ripgrep: %w", err)
	}

	var out []string
	truncated := false
	sc := bufio.NewScanner(stdout)
	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	for sc.Scan() {
		out = append(out, displayRipgrepLine(sc.Text(), rp))
		if len(out) >= grepMaxMatches {
			truncated = true
			break
		}
	}
	if truncated {
		_ = cmd.Process.Kill()
	}
	_, _ = io.Copy(io.Discard, stdout) // drain to EOF so Wait neither blocks nor races the reader
	_ = cmd.Wait()

	if len(out) == 0 && ctx.Err() != context.DeadlineExceeded {
		// ripgrep exits 1 with no output for "no matches"; a real failure (bad
		// pattern, unreadable path) writes a message to stderr.
		if msg := strings.TrimSpace(stderr.String()); msg != "" {
			if rp.External {
				msg = rp.ErrorText(fmt.Errorf("%s", msg))
			}
			return "", wrapped, fmt.Errorf("ripgrep: %s", msg)
		}
	}
	return formatGrep(ctx, out, truncated, to), wrapped, nil
}

func (g grepTool) sessionTempManager(ctx context.Context) *sessiontemp.Manager {
	if m := sessiontemp.FromContext(ctx); m != nil {
		return m
	}
	return g.sessionTemp
}

func displayRipgrepLine(line string, rp ResolvedPath) string {
	if !rp.External || !strings.HasPrefix(line, rp.Root) {
		return line
	}
	for i := len(rp.Root); i < len(line); i++ {
		if line[i] != ':' || i+1 >= len(line) || line[i+1] < '0' || line[i+1] > '9' {
			continue
		}
		j := i + 1
		for j < len(line) && line[j] >= '0' && line[j] <= '9' {
			j++
		}
		if j >= len(line) || line[j] != ':' {
			continue
		}
		return rp.DisplayFor(line[:i]) + line[i:]
	}
	return line
}

// SearchSpec configures the grep tool's engine. A non-empty RgPath makes grep
// delegate to that ripgrep binary; empty uses the native Go scanner.
type SearchSpec struct {
	RgPath string
}

// ResolveSearch picks the grep engine from config. "native" forces the Go
// scanner; "rg" requires ripgrep (warns and falls back to native if absent);
// "auto"/"" uses ripgrep when found, else native. rgPath overrides the PATH
// lookup. warn (may be nil) receives the fall-back notice for engine="rg".
func ResolveSearch(engine, rgPath string, warn io.Writer) SearchSpec {
	find := func() string {
		if rgPath != "" {
			if fi, err := os.Stat(rgPath); err == nil && !fi.IsDir() {
				return rgPath
			}
			return ""
		}
		if p, err := exec.LookPath("rg"); err == nil {
			return p
		}
		return ""
	}
	switch strings.ToLower(strings.TrimSpace(engine)) {
	case "native":
		return SearchSpec{}
	case "rg":
		if p := find(); p != "" {
			return SearchSpec{RgPath: p}
		}
		if warn != nil {
			fmt.Fprintln(warn, `warning: [tools.search] engine="rg" but ripgrep (rg) was not found; using the native search engine`)
		}
		return SearchSpec{}
	default: // "auto", ""
		return SearchSpec{RgPath: find()}
	}
}

// ConfineSearch returns the grep built-in bound to a resolved search engine,
// os sandbox spec for the ripgrep subprocess, and forbid-read roots for the
// native scanner, overriding the native instance registered at init.
// Session-private temporary directories are bound via BindSessionTemp or
// Workspace.SessionTemp.
func ConfineSearch(spec SearchSpec, sb sandbox.Spec, forbidRoots []string) tool.Tool {
	return grepTool{rg: spec.RgPath, sb: sb, forbidRoots: forbidRoots}
}
