package runtimeconfig

import (
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
)

// RuntimeResolver resolves runtime binaries from PATH plus well-known install
// locations used by supported package managers and installer scripts.
type RuntimeResolver struct {
	LookPath          func(string) (string, error)
	Stat              func(string) (os.FileInfo, error)
	UserHomeDir       func() (string, error)
	Glob              func(string) ([]string, error)
	Getenv            func(string) string
	Setenv            func(string, string) error
	RegistryPath      func() string
	GOOS              string
	PathListSeparator string
}

// DefaultRuntimeResolver returns a RuntimeResolver backed by the current process environment.
func DefaultRuntimeResolver() RuntimeResolver {
	return RuntimeResolver{
		LookPath:          exec.LookPath,
		Stat:              os.Stat,
		UserHomeDir:       os.UserHomeDir,
		Glob:              filepath.Glob,
		Getenv:            os.Getenv,
		Setenv:            os.Setenv,
		RegistryPath:      windowsRegistryPath,
		GOOS:              runtime.GOOS,
		PathListSeparator: string(os.PathListSeparator),
	}
}

// ResolveBinary resolves a runtime binary from PATH or fallback candidates.
// When PATH points at a Homebrew shim and an AgentTeam-managed candidate exists
// (for example ~/.opencode/bin or a Node manager bin), prefer the candidate so
// a runtime installed/upgraded by AgentTeam is the one that later version detection
// and process launches see.
func (r RuntimeResolver) ResolveBinary(binary string, candidates []string) (string, error) {
	r = r.withDefaults()
	if path, err := r.LookPath(binary); err == nil {
		if preferred, ok := r.preferredRuntimeCandidate(path, candidates); ok {
			return preferred, nil
		}
		return path, nil
	}
	if path, ok := r.lookPathInRuntimePath(binary, ShellPathForRuntimeResolution()); ok {
		if preferred, ok := r.preferredRuntimeCandidate(path, candidates); ok {
			return preferred, nil
		}
		return path, nil
	}
	if path, ok := r.lookPathInRuntimePath(binary, r.RegistryPath()); ok {
		if preferred, ok := r.preferredRuntimeCandidate(path, candidates); ok {
			return preferred, nil
		}
		return path, nil
	}
	if candidate, ok := r.firstExistingRuntimeCandidate(candidates); ok {
		return candidate, nil
	}
	return "", exec.ErrNotFound
}

func (r RuntimeResolver) lookPathInRuntimePath(binary, pathValue string) (string, bool) {
	binary = strings.TrimSpace(binary)
	if binary == "" || strings.TrimSpace(pathValue) == "" {
		return "", false
	}
	for _, dir := range splitRuntimePathList(pathValue, r.PathListSeparator) {
		dir = strings.TrimSpace(dir)
		if dir == "" {
			continue
		}
		for _, name := range r.pathBinaryNames(binary) {
			path := filepath.Join(dir, name)
			if runtimeBinaryExecutable(r, path) {
				return path, true
			}
		}
	}
	return "", false
}

func (r RuntimeResolver) pathBinaryNames(binary string) []string {
	if r.GOOS != "windows" || filepath.Ext(binary) != "" {
		return []string{binary}
	}

	extensions := strings.Split(r.Getenv("PATHEXT"), ";")
	if len(extensions) == 0 || strings.TrimSpace(strings.Join(extensions, "")) == "" {
		extensions = []string{".COM", ".EXE", ".BAT", ".CMD"}
	}

	names := []string{binary}
	for _, ext := range extensions {
		ext = strings.TrimSpace(ext)
		if ext == "" {
			continue
		}
		if !strings.HasPrefix(ext, ".") {
			ext = "." + ext
		}
		names = append(names, binary+ext)
	}
	return dedupeRuntimePaths(names)
}

func runtimeBinaryExecutable(r RuntimeResolver, path string) bool {
	info, err := r.Stat(path)
	if err != nil || info.IsDir() {
		return false
	}
	if r.GOOS == "windows" {
		return true
	}
	return info.Mode().Perm()&0o111 != 0
}

func (r RuntimeResolver) preferredRuntimeCandidate(path string, candidates []string) (string, bool) {
	if !runtimePathLooksHomebrew(path) {
		return "", false
	}
	for _, candidate := range candidates {
		candidate = strings.TrimSpace(candidate)
		if candidate == "" || sameRuntimePath(candidate, path, r.GOOS) || runtimePathLooksHomebrew(candidate) {
			continue
		}
		if runtimeBinaryExecutable(r, candidate) {
			return candidate, true
		}
	}
	return "", false
}

func (r RuntimeResolver) firstExistingRuntimeCandidate(candidates []string) (string, bool) {
	for _, candidate := range candidates {
		candidate = strings.TrimSpace(candidate)
		if candidate == "" {
			continue
		}
		if runtimeBinaryExecutable(r, candidate) {
			return candidate, true
		}
	}
	return "", false
}

func runtimePathLooksHomebrew(path string) bool {
	path = strings.ToLower(filepath.ToSlash(filepath.Clean(strings.TrimSpace(path))))
	return strings.Contains(path, "/homebrew/") || strings.Contains(path, "/linuxbrew/")
}

// RuntimeCandidatePaths returns explicit binary paths that the daemon may need
// to probe when its PATH was inherited before an install completed.
func (r RuntimeResolver) RuntimeCandidatePaths(binary string) []string {
	r = r.withDefaults()
	binary = strings.TrimSpace(binary)
	if binary == "" {
		return nil
	}
	dirs := r.commonRuntimeDirs()
	var candidates []string
	for _, dir := range dirs {
		candidates = append(candidates, filepath.Join(dir, binary))
	}
	if r.GOOS == "windows" {
		for _, dir := range dirs {
			candidates = append(candidates, filepath.Join(dir, binary+".exe"))
			candidates = append(candidates, filepath.Join(dir, binary+".cmd"))
		}
	}
	return dedupeRuntimePaths(candidates)
}

// PrependBinaryDirToPATH prepends path's directory to PATH so later exec calls
// can use the runtime's configured binary name directly.
func (r RuntimeResolver) PrependBinaryDirToPATH(path string) error {
	return r.insertBinaryDirToPATH(path, true)
}

// AppendBinaryDirToPATH appends path's directory to PATH while preserving
// earlier explicit PATH entries ahead of fallback runtime directories.
func (r RuntimeResolver) AppendBinaryDirToPATH(path string) error {
	return r.insertBinaryDirToPATH(path, false)
}

func (r RuntimeResolver) insertBinaryDirToPATH(path string, prepend bool) error {
	r = r.withDefaults()
	dir := strings.TrimSpace(filepath.Dir(path))
	if dir == "" || dir == "." {
		return nil
	}
	current := r.Getenv("PATH")
	separator := r.PathListSeparator
	if separator == "" {
		separator = string(os.PathListSeparator)
	}
	for _, entry := range splitRuntimePathList(current, separator) {
		if sameRuntimePath(entry, dir, r.GOOS) {
			return nil
		}
	}
	if current == "" {
		return r.Setenv("PATH", dir)
	}
	if prepend {
		return r.Setenv("PATH", dir+separator+current)
	}
	return r.Setenv("PATH", current+separator+dir)
}

func (r RuntimeResolver) commonRuntimeDirs() []string {
	r = r.withDefaults()
	dirs := r.npmPrefixRuntimeDirs()
	homeDir := ""
	if value, err := r.UserHomeDir(); err == nil {
		homeDir = strings.TrimSpace(value)
	}
	if homeDir != "" {
		dirs = append(dirs,
			filepath.Join(homeDir, ".opencode", "bin"),
			filepath.Join(homeDir, ".local", "bin"),
			filepath.Join(homeDir, ".npm-global", "bin"),
			filepath.Join(homeDir, ".bun", "bin"),
			filepath.Join(runtimeRoot(r.Getenv("VOLTA_HOME"), filepath.Join(homeDir, ".volta")), "bin"),
			filepath.Join(runtimeRoot(r.Getenv("ASDF_DATA_DIR"), filepath.Join(homeDir, ".asdf")), "shims"),
			filepath.Join(runtimeRoot(r.Getenv("MISE_DATA_DIR"), filepath.Join(homeDir, ".local", "share", "mise")), "shims"),
			filepath.Join(runtimeRoot(r.Getenv("FNM_DIR"), filepath.Join(homeDir, ".local", "share", "fnm")), "aliases", "default", "bin"),
		)
		dirs = append(dirs, r.globRuntimeDirs(filepath.Join(runtimeRoot(r.Getenv("NVM_DIR"), filepath.Join(homeDir, ".nvm")), "versions", "node", "*", "bin"))...)
		dirs = append(dirs, r.globRuntimeDirs(filepath.Join(runtimeRoot(r.Getenv("FNM_DIR"), filepath.Join(homeDir, ".local", "share", "fnm")), "node-versions", "*", "installation", "bin"))...)
		dirs = append(dirs, r.globRuntimeDirs(filepath.Join(runtimeRoot(r.Getenv("ASDF_DATA_DIR"), filepath.Join(homeDir, ".asdf")), "installs", "nodejs", "*", "bin"))...)
		dirs = append(dirs, r.globRuntimeDirs(filepath.Join(runtimeRoot(r.Getenv("MISE_DATA_DIR"), filepath.Join(homeDir, ".local", "share", "mise")), "installs", "node", "*", "bin"))...)
		dirs = append(dirs,
			filepath.Join(homeDir, "bin"),
		)
		if r.GOOS == "windows" {
			dirs = append(dirs,
				filepath.Join(homeDir, "scoop", "shims"),
				filepath.Join(homeDir, "AppData", "Roaming", "npm"),
			)
		}
	}
	dirs = append(dirs, r.windowsNVMRuntimeDirs(homeDir)...)
	dirs = append(dirs, r.windowsChocolateyRuntimeDirs()...)
	switch r.GOOS {
	case "darwin":
		dirs = append(dirs, "/opt/homebrew/bin", "/usr/local/bin")
	case "linux":
		dirs = append(dirs, "/usr/local/bin", "/home/linuxbrew/.linuxbrew/bin")
	case "windows":
		dirs = append(dirs,
			filepath.Join(r.Getenv("ProgramFiles"), "nodejs"),
			filepath.Join(r.Getenv("ProgramFiles(x86)"), "nodejs"),
			filepath.Join(r.Getenv("LocalAppData"), "Programs", "nodejs"),
			filepath.Join(r.Getenv("LocalAppData"), "Microsoft", "WinGet", "Links"),
		)
	}
	return dedupeRuntimePaths(dirs)
}

func (r RuntimeResolver) windowsChocolateyRuntimeDirs() []string {
	if r.GOOS != "windows" {
		return nil
	}

	var roots []string
	if install := strings.TrimSpace(r.Getenv("ChocolateyInstall")); install != "" {
		roots = append(roots, install)
	}
	if programData := strings.TrimSpace(r.Getenv("ProgramData")); programData != "" {
		roots = append(roots, filepath.Join(programData, "chocolatey"))
	}
	roots = append(roots, `C:\ProgramData\chocolatey`)

	dirs := make([]string, 0, len(roots))
	for _, root := range dedupeRuntimePaths(roots) {
		dirs = append(dirs, filepath.Join(root, "bin"))
	}
	return dirs
}

func (r RuntimeResolver) windowsNVMRuntimeDirs(homeDir string) []string {
	if r.GOOS != "windows" {
		return nil
	}

	var dirs []string
	if symlink := strings.TrimSpace(r.Getenv("NVM_SYMLINK")); symlink != "" {
		dirs = append(dirs, symlink)
	}

	var roots []string
	if nvmHome := strings.TrimSpace(r.Getenv("NVM_HOME")); nvmHome != "" {
		roots = append(roots, nvmHome)
	}
	if appData := strings.TrimSpace(r.Getenv("APPDATA")); appData != "" {
		roots = append(roots, filepath.Join(appData, "nvm"))
	}
	if strings.TrimSpace(homeDir) != "" {
		roots = append(roots, filepath.Join(homeDir, "AppData", "Roaming", "nvm"))
	}

	for _, root := range dedupeRuntimePaths(roots) {
		dirs = append(dirs, r.globRuntimeDirs(filepath.Join(root, "v*"))...)
	}
	return dirs
}

func (r RuntimeResolver) globRuntimeDirs(pattern string) []string {
	matches, err := r.Glob(pattern)
	if err != nil {
		return nil
	}
	sort.Strings(matches)
	return matches
}

func (r RuntimeResolver) npmPrefixRuntimeDirs() []string {
	prefix := strings.TrimSpace(r.Getenv("NPM_CONFIG_PREFIX"))
	if prefix == "" {
		return nil
	}
	if r.GOOS == "windows" {
		return []string{prefix, filepath.Join(prefix, "bin")}
	}
	return []string{filepath.Join(prefix, "bin"), prefix}
}

func runtimeRoot(value, fallback string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return fallback
	}
	return value
}

func (r RuntimeResolver) withDefaults() RuntimeResolver {
	defaults := DefaultRuntimeResolver()
	if r.LookPath == nil {
		r.LookPath = defaults.LookPath
	}
	if r.Stat == nil {
		r.Stat = defaults.Stat
	}
	if r.UserHomeDir == nil {
		r.UserHomeDir = defaults.UserHomeDir
	}
	if r.Glob == nil {
		r.Glob = defaults.Glob
	}
	if r.Getenv == nil {
		r.Getenv = defaults.Getenv
	}
	if r.Setenv == nil {
		r.Setenv = defaults.Setenv
	}
	if r.RegistryPath == nil {
		r.RegistryPath = defaults.RegistryPath
	}
	if r.GOOS == "" {
		r.GOOS = defaults.GOOS
	}
	if r.PathListSeparator == "" {
		r.PathListSeparator = defaults.PathListSeparator
	}
	return r
}

func dedupeRuntimePaths(values []string) []string {
	seen := make(map[string]struct{}, len(values))
	out := make([]string, 0, len(values))
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value == "" {
			continue
		}
		key := filepath.Clean(value)
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}
		out = append(out, value)
	}
	return out
}

func splitRuntimePathList(value string, separator string) []string {
	if value == "" {
		return nil
	}
	if separator == "" || separator == string(os.PathListSeparator) {
		return filepath.SplitList(value)
	}
	return strings.Split(value, separator)
}

func sameRuntimePath(a, b string, goos string) bool {
	a = filepath.Clean(strings.TrimSpace(a))
	b = filepath.Clean(strings.TrimSpace(b))
	if goos == "windows" {
		return strings.EqualFold(a, b)
	}
	return a == b
}
