package runtimeconfig

import (
	"errors"
	"os"
	"os/exec"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/require"
)

func emptyRuntimeResolverEnv(string) string {
	return ""
}

func TestRuntimeResolverResolveBinaryUsesFallbackCandidate(t *testing.T) {
	t.Parallel()

	homeDir := t.TempDir()
	binDir := filepath.Join(homeDir, ".local", "bin")
	require.NoError(t, os.MkdirAll(binDir, 0o755))
	binaryPath := filepath.Join(binDir, "claude")
	require.NoError(t, os.WriteFile(binaryPath, []byte("stub"), 0o755))

	resolver := RuntimeResolver{
		LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
		Stat:     os.Stat,
		Getenv:   emptyRuntimeResolverEnv,
		UserHomeDir: func() (string, error) {
			return homeDir, nil
		},
		GOOS: "darwin",
	}

	path, err := resolver.ResolveBinary("claude", resolver.RuntimeCandidatePaths("claude"))
	require.NoError(t, err)
	require.Equal(t, binaryPath, path)
}

func TestRuntimeResolverFallbackCandidateMustBeExecutableOnUnix(t *testing.T) {
	binary := "nonexec-runtime"
	homeDir := t.TempDir()
	binDir := filepath.Join(homeDir, ".local", "bin")
	require.NoError(t, os.MkdirAll(binDir, 0o755))
	binaryPath := filepath.Join(binDir, binary)
	require.NoError(t, os.WriteFile(binaryPath, []byte("stub"), 0o644))

	resolver := RuntimeResolver{
		LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
		Stat:     os.Stat,
		Getenv:   emptyRuntimeResolverEnv,
		UserHomeDir: func() (string, error) {
			return homeDir, nil
		},
		GOOS: "darwin",
	}

	_, err := resolver.ResolveBinary(binary, []string{binaryPath})
	require.ErrorIs(t, err, exec.ErrNotFound)
}

func TestRuntimeResolverResolveBinaryUsesOpenCodeInstallDirCandidate(t *testing.T) {
	t.Parallel()

	homeDir := t.TempDir()
	binDir := filepath.Join(homeDir, ".opencode", "bin")
	require.NoError(t, os.MkdirAll(binDir, 0o755))
	binaryPath := filepath.Join(binDir, "opencode")
	require.NoError(t, os.WriteFile(binaryPath, []byte("stub"), 0o755))

	resolver := RuntimeResolver{
		LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
		Stat:     os.Stat,
		Getenv:   emptyRuntimeResolverEnv,
		UserHomeDir: func() (string, error) {
			return homeDir, nil
		},
		GOOS: "darwin",
	}

	path, err := resolver.ResolveBinary("opencode", resolver.RuntimeCandidatePaths("opencode"))
	require.NoError(t, err)
	require.Equal(t, binaryPath, path)
}

func TestRuntimeResolverPrefersAgentTeamCandidateOverHomebrewPath(t *testing.T) {
	t.Parallel()

	homeDir := t.TempDir()
	agentTeamBinDir := filepath.Join(homeDir, ".opencode", "bin")
	require.NoError(t, os.MkdirAll(agentTeamBinDir, 0o755))
	agentTeamPath := filepath.Join(agentTeamBinDir, "opencode")
	require.NoError(t, os.WriteFile(agentTeamPath, []byte("stub"), 0o755))

	homebrewPath := filepath.Join(t.TempDir(), "homebrew", "bin", "opencode")
	require.NoError(t, os.MkdirAll(filepath.Dir(homebrewPath), 0o755))
	require.NoError(t, os.WriteFile(homebrewPath, []byte("stub"), 0o755))

	resolver := RuntimeResolver{
		LookPath: func(string) (string, error) { return homebrewPath, nil },
		Stat:     os.Stat,
		Getenv:   emptyRuntimeResolverEnv,
		UserHomeDir: func() (string, error) {
			return homeDir, nil
		},
		GOOS: "darwin",
	}

	path, err := resolver.ResolveBinary("opencode", resolver.RuntimeCandidatePaths("opencode"))
	require.NoError(t, err)
	require.Equal(t, agentTeamPath, path)
}

func TestRuntimeResolverResolveBinaryUsesFNMNodeVersionCandidate(t *testing.T) {
	t.Parallel()

	homeDir := t.TempDir()
	binDir := filepath.Join(homeDir, ".local", "share", "fnm", "node-versions", "v24.14.0", "installation", "bin")
	require.NoError(t, os.MkdirAll(binDir, 0o755))
	binaryPath := filepath.Join(binDir, "test-runtime")
	require.NoError(t, os.WriteFile(binaryPath, []byte("stub"), 0o755))

	resolver := RuntimeResolver{
		LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
		Stat:     os.Stat,
		Getenv:   emptyRuntimeResolverEnv,
		UserHomeDir: func() (string, error) {
			return homeDir, nil
		},
		GOOS: "darwin",
	}

	path, err := resolver.ResolveBinary("test-runtime", resolver.RuntimeCandidatePaths("test-runtime"))
	require.NoError(t, err)
	require.Equal(t, binaryPath, path)
}

func TestRuntimeResolverResolveBinaryUsesNodeManagerCandidates(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name string
		dir  string
	}{
		{name: "nvm", dir: filepath.Join(".nvm", "versions", "node", "v24.14.0", "bin")},
		{name: "volta", dir: filepath.Join(".volta", "bin")},
		{name: "asdf shims", dir: filepath.Join(".asdf", "shims")},
		{name: "asdf node install", dir: filepath.Join(".asdf", "installs", "nodejs", "24.14.0", "bin")},
		{name: "mise shims", dir: filepath.Join(".local", "share", "mise", "shims")},
		{name: "mise node install", dir: filepath.Join(".local", "share", "mise", "installs", "node", "24.14.0", "bin")},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			homeDir := t.TempDir()
			binDir := filepath.Join(homeDir, tt.dir)
			require.NoError(t, os.MkdirAll(binDir, 0o755))
			binaryPath := filepath.Join(binDir, "codex")
			require.NoError(t, os.WriteFile(binaryPath, []byte("stub"), 0o755))

			resolver := RuntimeResolver{
				LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
				Stat:     os.Stat,
				Getenv:   emptyRuntimeResolverEnv,
				UserHomeDir: func() (string, error) {
					return homeDir, nil
				},
				GOOS: "darwin",
			}

			path, err := resolver.ResolveBinary("codex", resolver.RuntimeCandidatePaths("codex"))
			require.NoError(t, err)
			require.Equal(t, binaryPath, path)
		})
	}
}

func TestRuntimeResolverUsesNodeManagerEnvDirs(t *testing.T) {
	t.Parallel()

	homeDir := t.TempDir()
	nvmDir := filepath.Join(homeDir, "custom-nvm")
	fnmDir := filepath.Join(homeDir, "custom-fnm")
	voltaDir := filepath.Join(homeDir, "custom-volta")
	asdfDir := filepath.Join(homeDir, "custom-asdf")
	miseDir := filepath.Join(homeDir, "custom-mise")
	nvmBinDir := filepath.Join(nvmDir, "versions", "node", "v24.14.0", "bin")
	fnmBinDir := filepath.Join(fnmDir, "node-versions", "v24.14.0", "installation", "bin")
	asdfBinDir := filepath.Join(asdfDir, "installs", "nodejs", "24.14.0", "bin")
	miseBinDir := filepath.Join(miseDir, "installs", "node", "24.14.0", "bin")
	for _, dir := range []string{nvmBinDir, fnmBinDir, asdfBinDir, miseBinDir} {
		require.NoError(t, os.MkdirAll(dir, 0o755))
	}

	resolver := RuntimeResolver{
		Getenv: func(key string) string {
			switch key {
			case "NVM_DIR":
				return nvmDir
			case "FNM_DIR":
				return fnmDir
			case "VOLTA_HOME":
				return voltaDir
			case "ASDF_DATA_DIR":
				return asdfDir
			case "MISE_DATA_DIR":
				return miseDir
			}
			return ""
		},
		UserHomeDir: func() (string, error) {
			return homeDir, nil
		},
		GOOS: "darwin",
	}

	candidates := resolver.RuntimeCandidatePaths("codex")
	require.Contains(t, candidates, filepath.Join(nvmBinDir, "codex"))
	require.Contains(t, candidates, filepath.Join(fnmDir, "aliases", "default", "bin", "codex"))
	require.Contains(t, candidates, filepath.Join(fnmBinDir, "codex"))
	require.Contains(t, candidates, filepath.Join(voltaDir, "bin", "codex"))
	require.Contains(t, candidates, filepath.Join(asdfDir, "shims", "codex"))
	require.Contains(t, candidates, filepath.Join(asdfBinDir, "codex"))
	require.Contains(t, candidates, filepath.Join(miseDir, "shims", "codex"))
	require.Contains(t, candidates, filepath.Join(miseBinDir, "codex"))
}

func TestRuntimeResolverResolveBinaryUsesNPMConfigPrefixCandidate(t *testing.T) {
	t.Parallel()

	prefixDir := t.TempDir()
	binDir := filepath.Join(prefixDir, "bin")
	require.NoError(t, os.MkdirAll(binDir, 0o755))
	binaryPath := filepath.Join(binDir, "test-runtime")
	require.NoError(t, os.WriteFile(binaryPath, []byte("stub"), 0o755))

	resolver := RuntimeResolver{
		LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
		Stat:     os.Stat,
		Getenv: func(key string) string {
			if key == "NPM_CONFIG_PREFIX" {
				return prefixDir
			}
			return ""
		},
		UserHomeDir: func() (string, error) {
			return t.TempDir(), nil
		},
		GOOS: "darwin",
	}

	path, err := resolver.ResolveBinary("test-runtime", resolver.RuntimeCandidatePaths("test-runtime"))
	require.NoError(t, err)
	require.Equal(t, binaryPath, path)
}

func TestRuntimeResolverResolveBinaryUsesWindowsNVMVersionCandidate(t *testing.T) {
	t.Parallel()

	homeDir := t.TempDir()
	binDir := filepath.Join(homeDir, "AppData", "Roaming", "nvm", "v23.9.0")
	require.NoError(t, os.MkdirAll(binDir, 0o755))
	binaryPath := filepath.Join(binDir, "codex.cmd")
	require.NoError(t, os.WriteFile(binaryPath, []byte("stub"), 0o755))

	resolver := RuntimeResolver{
		LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
		Stat:     os.Stat,
		Getenv:   emptyRuntimeResolverEnv,
		UserHomeDir: func() (string, error) {
			return homeDir, nil
		},
		GOOS: "windows",
	}

	path, err := resolver.ResolveBinary("codex", resolver.RuntimeCandidatePaths("codex"))
	require.NoError(t, err)
	require.Equal(t, binaryPath, path)
}

func TestRuntimeResolverResolveBinaryUsesWindowsChocolateyCandidate(t *testing.T) {
	t.Parallel()

	chocolateyInstall := filepath.Join(t.TempDir(), "chocolatey")
	binDir := filepath.Join(chocolateyInstall, "bin")
	require.NoError(t, os.MkdirAll(binDir, 0o755))
	binaryPath := filepath.Join(binDir, "opencode.exe")
	require.NoError(t, os.WriteFile(binaryPath, []byte("stub"), 0o644))

	resolver := RuntimeResolver{
		LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
		Stat:     os.Stat,
		Getenv: func(key string) string {
			if key == "ChocolateyInstall" {
				return chocolateyInstall
			}
			return ""
		},
		UserHomeDir: func() (string, error) {
			return t.TempDir(), nil
		},
		GOOS: "windows",
	}

	path, err := resolver.ResolveBinary("opencode", resolver.RuntimeCandidatePaths("opencode"))
	require.NoError(t, err)
	require.Equal(t, binaryPath, path)
}

func TestRuntimeResolverResolveBinaryUsesWindowsRegistryPath(t *testing.T) {
	t.Parallel()

	runtimeDir := t.TempDir()
	binaryPath := filepath.Join(runtimeDir, "opencode.exe")
	require.NoError(t, os.WriteFile(binaryPath, []byte("stub"), 0o644))

	resolver := RuntimeResolver{
		LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
		Stat:     os.Stat,
		Getenv: func(key string) string {
			if key == "PATHEXT" {
				return ".exe;.cmd"
			}
			return ""
		},
		RegistryPath: func() string { return runtimeDir },
		UserHomeDir: func() (string, error) {
			return t.TempDir(), nil
		},
		GOOS:              "windows",
		PathListSeparator: ";",
	}

	path, err := resolver.ResolveBinary("opencode", resolver.RuntimeCandidatePaths("opencode"))
	require.NoError(t, err)
	require.Equal(t, binaryPath, path)
}

func TestRuntimeResolverPrependBinaryDirToPATH(t *testing.T) {
	t.Parallel()

	var pathValue string
	resolver := RuntimeResolver{
		Getenv: func(key string) string {
			if key == "PATH" {
				return pathValue
			}
			return ""
		},
		Setenv: func(key, value string) error {
			require.Equal(t, "PATH", key)
			pathValue = value
			return nil
		},
		GOOS:              "linux",
		PathListSeparator: ":",
	}

	pathValue = "/usr/bin:/bin"
	require.NoError(t, resolver.PrependBinaryDirToPATH("/opt/runtime/bin/claude"))
	require.Equal(t, "/opt/runtime/bin:/usr/bin:/bin", pathValue)

	require.NoError(t, resolver.PrependBinaryDirToPATH("/opt/runtime/bin/claude"))
	require.Equal(t, "/opt/runtime/bin:/usr/bin:/bin", pathValue)
}

func TestRuntimeResolverAppendBinaryDirToPATH(t *testing.T) {
	t.Parallel()

	var pathValue string
	resolver := RuntimeResolver{
		Getenv: func(key string) string {
			if key == "PATH" {
				return pathValue
			}
			return ""
		},
		Setenv: func(key, value string) error {
			require.Equal(t, "PATH", key)
			pathValue = value
			return nil
		},
		GOOS:              "linux",
		PathListSeparator: ":",
	}

	pathValue = "/usr/bin:/bin"
	require.NoError(t, resolver.AppendBinaryDirToPATH("/opt/runtime/bin/codex"))
	require.Equal(t, "/usr/bin:/bin:/opt/runtime/bin", pathValue)

	require.NoError(t, resolver.AppendBinaryDirToPATH("/opt/runtime/bin/codex"))
	require.Equal(t, "/usr/bin:/bin:/opt/runtime/bin", pathValue)
}

func TestRuntimeResolverWindowsCandidates(t *testing.T) {
	t.Parallel()

	resolver := RuntimeResolver{
		GOOS: "windows",
		Getenv: func(key string) string {
			switch key {
			case "ProgramFiles":
				return `C:\Program Files`
			case "ProgramFiles(x86)":
				return `C:\Program Files (x86)`
			case "LocalAppData":
				return `C:\Users\demo\AppData\Local`
			case "ProgramData":
				return `C:\ProgramData`
			case "ChocolateyInstall":
				return `D:\Tools\chocolatey`
			}
			return ""
		},
		UserHomeDir: func() (string, error) {
			return `C:\Users\demo`, nil
		},
	}

	require.Contains(t, resolver.RuntimeCandidatePaths("codex"), filepath.Join(`C:\Users\demo`, "AppData", "Roaming", "npm", "codex.cmd"))
	require.Contains(t, resolver.RuntimeCandidatePaths("codex"), filepath.Join(`C:\Users\demo`, "scoop", "shims", "codex.exe"))
	require.Contains(t, resolver.RuntimeCandidatePaths("codex"), filepath.Join(`D:\Tools\chocolatey`, "bin", "codex.exe"))
	require.Contains(t, resolver.RuntimeCandidatePaths("codex"), filepath.Join(`C:\ProgramData`, "chocolatey", "bin", "codex.exe"))
}

func TestRuntimeResolverWindowsNVMEnvCandidates(t *testing.T) {
	t.Parallel()

	nvmHome := filepath.Join(t.TempDir(), "nvm")
	nvmSymlink := filepath.Join(t.TempDir(), "nodejs")
	appData := filepath.Join(t.TempDir(), "Roaming")
	nvmHomeVersion := filepath.Join(nvmHome, "v23.9.0")
	appDataVersion := filepath.Join(appData, "nvm", "v24.0.0")

	resolver := RuntimeResolver{
		Getenv: func(key string) string {
			switch key {
			case "NVM_HOME":
				return nvmHome
			case "NVM_SYMLINK":
				return nvmSymlink
			case "APPDATA":
				return appData
			}
			return ""
		},
		UserHomeDir: func() (string, error) {
			return "", errors.New("no home")
		},
		Glob: func(pattern string) ([]string, error) {
			switch pattern {
			case filepath.Join(nvmHome, "v*"):
				return []string{nvmHomeVersion}, nil
			case filepath.Join(appData, "nvm", "v*"):
				return []string{appDataVersion}, nil
			default:
				return nil, nil
			}
		},
		GOOS: "windows",
	}

	candidates := resolver.RuntimeCandidatePaths("codex")
	require.Contains(t, candidates, filepath.Join(nvmSymlink, "codex.cmd"))
	require.Contains(t, candidates, filepath.Join(nvmHomeVersion, "codex.cmd"))
	require.Contains(t, candidates, filepath.Join(appDataVersion, "codex.cmd"))
}

func TestRuntimeResolverFallsBackWithoutHome(t *testing.T) {
	t.Parallel()

	resolver := RuntimeResolver{
		LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
		Stat:     func(string) (os.FileInfo, error) { return nil, os.ErrNotExist },
		UserHomeDir: func() (string, error) {
			return "", errors.New("no home")
		},
		GOOS: "linux",
	}

	_, err := resolver.ResolveBinary("opencode", resolver.RuntimeCandidatePaths("opencode"))
	require.ErrorIs(t, err, exec.ErrNotFound)
}
