// Package environment probes the local developer environment at startup and
// renders a small, stable model-facing summary.
package environment

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"sync"
	"time"

	"reasonix/internal/proc"
	"reasonix/internal/secrets"
	"reasonix/internal/shellparse"
)

const ProbeTimeout = 2 * time.Second

const probeWaitDelay = time.Second

const probeCacheTTL = 5 * time.Minute

const maxRenderedTools = 24

var probeTimeout = ProbeTimeout

type probeCacheEntry struct {
	storedAt time.Time
	results  []ProbeResult
}

type probeInflight struct {
	done    chan struct{}
	results []ProbeResult
}

var (
	probeCacheMu       sync.Mutex
	probeCache         = map[string]probeCacheEntry{}
	probeInflightCalls = map[string]*probeInflight{}
	probeNow           = time.Now
)

type ProbeResult struct {
	Command string
	Binary  string
	Output  string
	Found   bool
	Error   string
}

type ProbeOptions struct {
	Overrides map[string]string
	DenyRoots []string
	// SnapshotDir, when set, persists probe results across process restarts
	// (one snapshot per fingerprint under SnapshotDir/environment). A snapshot
	// younger than probeSnapshotTTL is served without re-probing, and a
	// refresh merges transient failures against the previous snapshot — both
	// keep the rendered environment section byte-stable so the cached
	// system-prompt prefix survives rebuilds. Empty disables persistence.
	// The directory is host state, never model-visible, so it stays out of
	// the probe fingerprint.
	SnapshotDir string
}

func DefaultProbes() []string {
	return []string{
		"go version",
		"python3 --version",
		"python --version",
		"node --version",
		"npm --version",
		"rustc --version",
		"cargo --version",
		"git version",
		"make --version",
		"rg --version",
		"docker --version",
	}
}

func RunProbes(ctx context.Context, commands []string) []ProbeResult {
	return RunProbesWithOverrides(ctx, commands, nil)
}

func RunProbesWithOverrides(ctx context.Context, commands []string, overrides map[string]string) []ProbeResult {
	return RunProbesWithOptions(ctx, commands, ProbeOptions{Overrides: overrides})
}

func RunProbesWithOptions(ctx context.Context, commands []string, opts ProbeOptions) []ProbeResult {
	key := probeFingerprint(commands, opts)
	now := probeNow()
	if results, ok := cachedProbeResults(key, now); ok {
		return results
	}
	if call, ok := beginProbe(key); ok {
		<-call.done
		return cloneProbeResults(call.results)
	}
	// A fresh persisted snapshot substitutes for a live run entirely: rebuilds
	// and app relaunches within the TTL render the exact bytes the sessions on
	// this machine were recorded with, so the provider prefix cache survives.
	snapshot, hasSnapshot := loadProbeSnapshot(opts.SnapshotDir, key)
	if hasSnapshot && now.Sub(snapshot.StoredAt) < probeSnapshotTTL {
		finishProbe(key, snapshot.Results, now)
		return cloneProbeResults(snapshot.Results)
	}
	results := runProbesUncached(ctx, commands, opts)
	if hasSnapshot {
		// Even an expired snapshot anchors the flap merge: transient failures
		// (timeout, nonzero exit) keep the previous successful observation so
		// a slow tool cannot rewrite the prompt prefix.
		results = mergeProbeSnapshot(snapshot.Results, results)
	}
	saveProbeSnapshot(opts.SnapshotDir, key, results, now)
	finishProbe(key, results, probeNow())
	return cloneProbeResults(results)
}

func runProbesUncached(ctx context.Context, commands []string, opts ProbeOptions) []ProbeResult {
	results := make([]ProbeResult, len(commands))
	var wg sync.WaitGroup
	for i, command := range commands {
		wg.Add(1)
		go func(i int, command string) {
			defer wg.Done()
			results[i] = runOne(ctx, command, opts)
		}(i, command)
	}
	wg.Wait()
	sortResults(results)
	return results
}

func cachedProbeResults(key string, now time.Time) ([]ProbeResult, bool) {
	probeCacheMu.Lock()
	defer probeCacheMu.Unlock()
	entry, ok := probeCache[key]
	if !ok || now.Sub(entry.storedAt) >= probeCacheTTL {
		if ok {
			delete(probeCache, key)
		}
		return nil, false
	}
	return cloneProbeResults(entry.results), true
}

func beginProbe(key string) (*probeInflight, bool) {
	probeCacheMu.Lock()
	defer probeCacheMu.Unlock()
	if call, ok := probeInflightCalls[key]; ok {
		return call, true
	}
	probeInflightCalls[key] = &probeInflight{done: make(chan struct{})}
	return nil, false
}

func finishProbe(key string, results []ProbeResult, now time.Time) {
	probeCacheMu.Lock()
	defer probeCacheMu.Unlock()
	cached := cloneProbeResults(results)
	probeCache[key] = probeCacheEntry{storedAt: now, results: cached}
	if call, ok := probeInflightCalls[key]; ok {
		call.results = cached
		delete(probeInflightCalls, key)
		close(call.done)
	}
}

func probeFingerprint(commands []string, opts ProbeOptions) string {
	var b strings.Builder
	b.WriteString("v1")
	for _, command := range commands {
		b.WriteByte('\x00')
		b.WriteString(strings.TrimSpace(command))
	}
	for _, name := range sortedMapKeys(opts.Overrides) {
		b.WriteByte('\x00')
		b.WriteString(name)
		b.WriteByte('=')
		b.WriteString(expandHome(opts.Overrides[name]))
	}
	for _, root := range normalizedDenyRoots(opts.DenyRoots) {
		b.WriteByte('\x00')
		b.WriteString("deny=")
		b.WriteString(root)
	}
	return b.String()
}

func cloneProbeResults(results []ProbeResult) []ProbeResult {
	if results == nil {
		return nil
	}
	return append([]ProbeResult(nil), results...)
}

func runOne(ctx context.Context, command string, opts ProbeOptions) ProbeResult {
	probe, err := shellparse.ParseStaticCommand(command, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
	if err != nil {
		return ProbeResult{Command: command, Binary: command, Error: "invalid command: " + err.Error()}
	}
	parts := probe.Argv
	if len(parts) == 0 {
		return ProbeResult{Command: command, Binary: command, Error: "empty command"}
	}
	res := ProbeResult{Command: command, Binary: parts[0]}
	var exe string
	if override := strings.TrimSpace(opts.Overrides[parts[0]]); override != "" {
		exe = expandHome(override)
		if !filepath.IsAbs(exe) {
			res.Error = "not trusted"
			return res
		}
		if !fileExecutable(exe) {
			res.Error = "not found"
			return res
		}
	} else {
		found, err := exec.LookPath(parts[0])
		if err != nil {
			res.Error = "not found"
			return res
		}
		exe = found
	}
	if blockedExecutable(exe, opts.DenyRoots) {
		res.Error = "not trusted"
		return res
	}
	cmdCtx, cancel := context.WithTimeout(ctx, probeTimeout)
	defer cancel()
	cmd := proc.CommandContext(cmdCtx, exe, parts[1:]...)
	// Always set the env explicitly: leaving cmd.Env nil would inherit the
	// full process environment and bypass [secrets] filter_subprocess_env for
	// probes that declare no extra variables of their own.
	cmd.Env = append(secrets.ProcessEnv(), probe.Env...)
	prepareProbeCommand(cmd)
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	if probe.MergeStderr {
		cmd.Stderr = &stdout
	} else {
		cmd.Stderr = &stderr
	}
	err = cmd.Run()
	out := strings.TrimSpace(stdout.String())
	if out == "" {
		out = strings.TrimSpace(stderr.String())
	}
	if err != nil {
		if cmdCtx.Err() == context.DeadlineExceeded {
			res.Error = "timeout"
			return res
		}
		if out == "" {
			res.Error = "exit " + err.Error()
			return res
		}
		res.Error = firstLine(out)
		return res
	}
	res.Found = true
	res.Output = firstLine(out)
	return res
}

func prepareProbeCommand(cmd *exec.Cmd) {
	proc.HideWindow(cmd)
	proc.SetProcessGroupKill(cmd)
	cmd.Cancel = func() error {
		proc.KillTree(cmd)
		return nil
	}
	cmd.WaitDelay = probeWaitDelay
}

func sortResults(results []ProbeResult) {
	sort.Slice(results, func(i, j int) bool {
		if results[i].Found != results[j].Found {
			return results[i].Found
		}
		return results[i].Binary < results[j].Binary
	})
}

func firstLine(s string) string {
	s = strings.TrimSpace(s)
	if before, _, ok := strings.Cut(s, "\n"); ok {
		return strings.TrimRight(before, "\r")
	}
	return s
}

func FormatSection(results []ProbeResult, osName, shellPath string, overrides map[string]string) string {
	if len(results) == 0 && len(overrides) == 0 && osName == "" && shellPath == "" {
		return ""
	}
	results = append([]ProbeResult(nil), results...)
	sortResults(results)
	var b strings.Builder
	b.WriteString("## Environment\n\n")
	if osName == "" {
		osName = runtime.GOOS + "/" + runtime.GOARCH
	}
	b.WriteString("- OS: " + osName + "\n")
	if shellPath != "" {
		b.WriteString("- Shell: " + redactHome(shellPath) + "\n")
	}
	if len(overrides) > 0 {
		b.WriteString("\nConfigured tools:\n")
		names := sortedMapKeys(overrides)
		for _, name := range limitStrings(names, maxRenderedTools) {
			fmt.Fprintf(&b, "- %s: %s\n", name, redactHome(overrides[name]))
		}
		if omitted := len(names) - maxRenderedTools; omitted > 0 {
			fmt.Fprintf(&b, "- ... %d more configured tools omitted\n", omitted)
		}
	}
	if len(results) > 0 {
		b.WriteString("\nDetected tools:\n")
		foundShown := 0
		foundTotal := 0
		for _, r := range results {
			if r.Found {
				foundTotal++
				if foundShown >= maxRenderedTools {
					continue
				}
				out := r.Output
				if out == "" {
					out = "available"
				}
				fmt.Fprintf(&b, "- %s: %s\n", r.Binary, out)
				foundShown++
			}
		}
		if omitted := foundTotal - foundShown; omitted > 0 {
			fmt.Fprintf(&b, "- ... %d more detected tools omitted\n", omitted)
		}
		b.WriteString("\nNot found or unavailable:\n")
		missingShown := 0
		missingTotal := 0
		for _, r := range results {
			if !r.Found {
				missingTotal++
				if missingShown >= maxRenderedTools {
					continue
				}
				reason := r.Error
				if reason == "" {
					reason = "not found"
				}
				fmt.Fprintf(&b, "- %s: %s\n", r.Binary, reason)
				missingShown++
			}
		}
		if omitted := missingTotal - missingShown; omitted > 0 {
			fmt.Fprintf(&b, "- ... %d more unavailable tools omitted\n", omitted)
		}
	}
	b.WriteString("\nUse detected tools when appropriate. Do not try unavailable tools unless the user installs or configures them.\n")
	return strings.TrimRight(b.String(), "\n")
}

func sortedMapKeys(m map[string]string) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		if strings.TrimSpace(k) != "" {
			keys = append(keys, k)
		}
	}
	sort.Strings(keys)
	return keys
}

func limitStrings(in []string, limit int) []string {
	if len(in) <= limit {
		return in
	}
	return in[:limit]
}

func redactHome(path string) string {
	path = expandHome(path)
	if path == "" {
		return ""
	}
	home, err := os.UserHomeDir()
	if err != nil || home == "" {
		return filepath.ToSlash(filepath.Clean(path))
	}
	clean := filepath.Clean(path)
	home = filepath.Clean(home)
	if clean == home {
		return "~"
	}
	if strings.HasPrefix(clean, home+string(filepath.Separator)) {
		return filepath.ToSlash("~" + strings.TrimPrefix(clean, home))
	}
	return filepath.ToSlash(clean)
}

func expandHome(path string) string {
	path = strings.TrimSpace(path)
	if path == "" || path == "~" || !strings.HasPrefix(path, "~/") {
		return path
	}
	home, err := os.UserHomeDir()
	if err != nil || home == "" {
		return path
	}
	return filepath.Join(home, strings.TrimPrefix(path, "~/"))
}

func fileExecutable(path string) bool {
	fi, err := os.Stat(path)
	return err == nil && !fi.IsDir()
}

func blockedExecutable(path string, denyRoots []string) bool {
	abs, err := filepath.Abs(path)
	if err != nil {
		abs = filepath.Clean(path)
	}
	for _, root := range normalizedDenyRoots(denyRoots) {
		if pathWithin(abs, root) {
			return true
		}
	}
	return false
}

func normalizedDenyRoots(roots []string) []string {
	out := make([]string, 0, len(roots))
	seen := map[string]bool{}
	for _, root := range roots {
		root = strings.TrimSpace(root)
		if root == "" {
			continue
		}
		root = expandHome(root)
		abs, err := filepath.Abs(root)
		if err != nil {
			abs = filepath.Clean(root)
		}
		abs = filepath.Clean(abs)
		if !seen[abs] {
			seen[abs] = true
			out = append(out, abs)
		}
	}
	sort.Strings(out)
	return out
}

func pathWithin(path, root string) bool {
	path = filepath.Clean(path)
	root = filepath.Clean(root)
	if path == root {
		return true
	}
	rel, err := filepath.Rel(root, path)
	if err != nil {
		return false
	}
	return rel != "." && rel != "" && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
}
