// Package shellwrap provides platform-level helpers for wrapping commands in
// the user's login shell and for resolving tool binaries (e.g. docker) with
// PATH caching.
//
// It exists so that both the upstream proxy code (internal/upstream/core) and
// the security scanner (internal/security/scanner) can share a single,
// well-tested implementation of shell quoting + login-shell wrapping instead
// of each rolling their own.
package shellwrap

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"runtime"
	"strings"
	"sync"
	"time"

	"go.uber.org/zap"
)

const (
	osWindows = "windows"
	// defaultUnixShell is used when $SHELL is unset on a Unix-like system.
	defaultUnixShell = "/bin/bash"
	// defaultWindowsShell is used when neither $ComSpec nor $SHELL is set.
	defaultWindowsShell = "cmd"
)

// Shellescape escapes a single argument for safe inclusion in a shell command
// string. On Unix it uses POSIX single-quoting; on Windows it performs a
// best-effort cmd.exe quoting.
//
// This mirrors the implementation in internal/upstream/core so both code paths
// can converge on one function.
func Shellescape(s string) string {
	if s == "" {
		if runtime.GOOS == osWindows {
			return `""`
		}
		return "''"
	}

	if runtime.GOOS == osWindows {
		// Windows cmd.exe special characters.
		if !strings.ContainsAny(s, " \t\n\r\"&|<>()^%") {
			return s
		}
		// cmd.exe does not use backslash as an escape character. If the
		// caller supplied embedded double quotes we strip them — this is
		// the same behaviour the upstream helper has used since PR #195.
		cleaned := strings.Trim(s, `"`)
		return `"` + cleaned + `"`
	}

	// Unix shell special characters: if none present, return as-is.
	if !strings.ContainsAny(s, " \t\n\r\"'\\$`;&|<>(){}[]?*~") {
		return s
	}
	// Use single quotes and escape any embedded single quotes.
	return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}

// isBashLikeShell mirrors the detection logic in connection_stdio.go so that
// Git Bash / MSYS on Windows uses the Unix-style -l -c flags.
func isBashLikeShell(shell string) bool {
	lower := strings.ToLower(shell)
	return strings.Contains(lower, "bash") || strings.Contains(lower, "sh")
}

// resolveLoginShell returns the user's preferred login shell, respecting the
// $SHELL environment variable and falling back to platform defaults.
func resolveLoginShell() string {
	shell := os.Getenv("SHELL")
	if shell != "" {
		return shell
	}
	if runtime.GOOS == osWindows {
		if cs := os.Getenv("ComSpec"); cs != "" {
			return cs
		}
		return defaultWindowsShell
	}
	return defaultUnixShell
}

// WrapWithUserShell wraps a command and its arguments in the user's login
// shell so the child process inherits the interactive PATH (important when
// mcpproxy is launched from a GUI / LaunchAgent on macOS).
//
// It returns the shell to exec and the shell arguments (e.g. ["-l", "-c",
// "docker run ..."] on Unix, ["/c", "docker run ..."] on Windows cmd).
//
// logger may be nil; when non-nil a debug line is emitted mirroring the
// existing upstream/core helper.
func WrapWithUserShell(logger *zap.Logger, command string, args []string) (shell string, shellArgs []string) {
	shell = resolveLoginShell()

	parts := make([]string, 0, len(args)+1)
	parts = append(parts, Shellescape(command))
	for _, a := range args {
		parts = append(parts, Shellescape(a))
	}
	commandString := strings.Join(parts, " ")

	if logger != nil {
		// Redact secret env values (`-e KEY=VALUE`) before logging — docker-command
		// upstreams inject Slack/Jira tokens here and debug logs are written to disk.
		logger.Debug("shellwrap: wrapping command with user login shell",
			zap.String("original_command", command),
			zap.Strings("original_args", RedactDockerArgs(args)),
			zap.String("shell", shell),
			zap.String("wrapped_command", RedactDockerCommandString(commandString)))
	}

	isBash := isBashLikeShell(shell)
	if runtime.GOOS == osWindows && !isBash {
		// Windows cmd.exe: /c to execute a command string.
		return shell, []string{"/c", commandString}
	}
	// Unix shells and Git Bash on Windows: -l for login env, -c for command.
	return shell, []string{"-l", "-c", commandString}
}

// --- Docker path resolution ---------------------------------------------

// dockerPathNegativeTTL bounds how long a failed lookup is cached. We retry
// after this window so a transient failure (e.g. mcpproxy started from a
// PKInstallSandbox where /bin/sh -l is restricted, then later able to find
// docker once the install context drains) self-heals instead of poisoning
// the process for its entire lifetime. Successes are cached forever.
//
// var (not const) so tests can drop it to zero for retry-behavior coverage.
var dockerPathNegativeTTL = 60 * time.Second

var (
	dockerPathMu        sync.Mutex
	dockerPath          string
	dockerPathSource    string // which branch resolved docker (see DockerSource* enum)
	dockerPathErr       error
	dockerPathHasResult bool
	dockerPathExpires   time.Time // zero for cached success (never expires)
)

// DockerSource* are the coarse, fixed-enum labels describing HOW the docker
// CLI was resolved (or that it is absent). They are emitted in telemetry as the
// #696 fleet signal (docker-installed-but-not-on-PATH). They deliberately carry
// no path, host, or user information — only the resolution branch.
const (
	DockerSourcePath       = "path"        // found via exec.LookPath (ambient PATH)
	DockerSourceBundled    = "bundled"     // found at a well-known install location (e.g. Docker Desktop bundle)
	DockerSourceLoginShell = "login_shell" // recovered via the user's login-shell PATH
	DockerSourceAbsent     = "absent"      // not resolvable anywhere (#696 worst case)
)

// wellKnownDockerPathsFn returns docker install locations to probe directly
// when neither $PATH nor the user's login shell exposes a docker binary.
// Exposed as a package variable so tests can stub the list.
var wellKnownDockerPathsFn = defaultWellKnownDockerPaths

func defaultWellKnownDockerPaths() []string {
	switch runtime.GOOS {
	case "darwin":
		// Order matters: prefer the bundle binary (always present when
		// Docker Desktop is installed) over /usr/local/bin/docker which is
		// merely a symlink Docker Desktop creates and may be missing when
		// /usr/local/bin is not writable (Docker falls back to ~/.docker/bin
		// in that case — see docker/for-mac#6168).
		paths := []string{
			"/Applications/Docker.app/Contents/Resources/bin/docker", // canonical bundle binary
			"/usr/local/bin/docker",                                  // Docker Desktop symlink (when present)
			"/opt/homebrew/bin/docker",                               // Apple Silicon Homebrew
			"/opt/podman/bin/docker",                                 // Podman shim
		}
		if home, err := os.UserHomeDir(); err == nil && home != "" {
			paths = append(paths,
				home+"/.docker/bin/docker",   // Docker Desktop fallback when /usr/local/bin not writable
				home+"/.orbstack/bin/docker", // OrbStack
			)
		}
		return paths
	case "linux":
		return []string{
			"/usr/bin/docker",
			"/usr/local/bin/docker",
			"/snap/bin/docker",
		}
	}
	return nil
}

// probeWellKnownDocker returns the first existing executable from the
// well-known docker install locations, or "" if none qualify.
func probeWellKnownDocker(logger *zap.Logger) string {
	for _, candidate := range wellKnownDockerPathsFn() {
		info, err := os.Stat(candidate)
		if err != nil || info.IsDir() {
			continue
		}
		if info.Mode()&0o111 == 0 {
			continue
		}
		if logger != nil {
			logger.Debug("shellwrap: resolved docker via well-known path",
				zap.String("path", candidate))
		}
		return candidate
	}
	return ""
}

// ResolveDockerPath returns the absolute path to the `docker` binary.
// Successful resolutions are cached for the process lifetime; failed
// resolutions are cached only for dockerPathNegativeTTL so a transient
// failure (e.g. PKInstallSandbox at process start) does not permanently
// disable docker discovery for the daemon.
//
// Resolution order:
//  1. exec.LookPath("docker") — cheap, works when mcpproxy was started from
//     a terminal or when launchd's PATH already contains docker.
//  2. Probe well-known install locations directly (Docker Desktop's bundle
//     binary, /usr/local/bin/docker symlink, Apple Silicon Homebrew,
//     ~/.docker/bin, OrbStack, snap, etc.). Avoids the fragile login-shell
//     dance when the binary is at a predictable path.
//  3. Last resort: ask the user's login shell `command -v docker` so we pick
//     up Colima or other non-standard installs only present in the
//     interactive PATH. Skipped on Windows.
func ResolveDockerPath(logger *zap.Logger) (string, error) {
	dockerPathMu.Lock()
	defer dockerPathMu.Unlock()
	return resolveDockerPathLocked(logger)
}

// resolveDockerPathLocked is the single cache-aware resolver. Callers MUST hold
// dockerPathMu. It is the one place the docker-path cache (path, err, expiry)
// AND the parallel dockerPathSource enum are written, so ResolveDockerPath and
// ResolveDockerSource can never diverge on the same cache state — including the
// MCP-2744 stat-probe override. (Earlier the two functions duplicated this
// logic and the source-tracking drifted between them.)
func resolveDockerPathLocked(logger *zap.Logger) (string, error) {
	// Honor cache: keep successful resolutions forever.
	if dockerPathHasResult && dockerPathErr == nil {
		return dockerPath, nil
	}

	// A cached negative within its TTL would normally short-circuit here. But
	// the well-known-path probe is a pure os.Stat — never sandbox- or
	// login-shell-restricted — so a negative cached because only the restricted
	// login-shell leg failed must NOT permanently shadow a docker binary that is
	// sitting at a well-known path right now (the spawn-vs-status divergence in
	// MCP-2744). Re-run the cheap probe before honoring a live negative; on
	// success, upgrade the cache to a permanent success and return it.
	if dockerPathHasResult && dockerPathErr != nil &&
		!dockerPathExpires.IsZero() && time.Now().Before(dockerPathExpires) {
		if p := probeWellKnownDocker(logger); p != "" {
			dockerPath = p
			// The override resolves via the well-known-path probe, so the
			// source is "bundled". Must be set here too, else a stale "absent"
			// from the prior failed resolution leaks into docker_cli_source
			// telemetry on the next ResolveDockerSource call (schema v5).
			dockerPathSource = DockerSourceBundled
			dockerPathErr = nil
			dockerPathExpires = time.Time{}
			return p, nil
		}
		return dockerPath, dockerPathErr
	}

	path, source, err := resolveDockerPathUncached(logger)
	dockerPath = path
	dockerPathSource = source
	dockerPathErr = err
	dockerPathHasResult = true
	if err != nil {
		dockerPathExpires = time.Now().Add(dockerPathNegativeTTL)
	} else {
		dockerPathExpires = time.Time{}
	}
	return path, err
}

// ResolveDockerSource returns the coarse, fixed-enum label describing how the
// docker CLI was resolved (DockerSourcePath / DockerSourceBundled /
// DockerSourceLoginShell), or DockerSourceAbsent when docker cannot be found.
// It drives the SAME cache path as ResolveDockerPath (via resolveDockerPathLocked),
// so the reported source always matches the resolution ResolveDockerPath would
// give for the current cache state — including the MCP-2744 stat-probe override
// during the negative-TTL window. Never returns the resolved path — only the
// branch — so callers (telemetry) cannot leak it.
func ResolveDockerSource(logger *zap.Logger) string {
	dockerPathMu.Lock()
	defer dockerPathMu.Unlock()
	if _, err := resolveDockerPathLocked(logger); err != nil {
		return DockerSourceAbsent
	}
	return sourceOrAbsent(dockerPathSource)
}

// sourceOrAbsent normalizes an empty source string to DockerSourceAbsent so the
// enum is never blank on the wire.
func sourceOrAbsent(source string) string {
	if source == "" {
		return DockerSourceAbsent
	}
	return source
}

// resolveDockerPathUncached resolves docker and reports which branch found it.
// The returned source is one of DockerSourcePath / DockerSourceBundled /
// DockerSourceLoginShell on success, or DockerSourceAbsent on failure.
func resolveDockerPathUncached(logger *zap.Logger) (path, source string, err error) {
	// Fast path: ask Go's standard PATH lookup first.
	if p, lookErr := exec.LookPath("docker"); lookErr == nil && p != "" {
		if logger != nil {
			logger.Debug("shellwrap: resolved docker via PATH", zap.String("path", p))
		}
		return p, DockerSourcePath, nil
	}

	// Well-known path probe: covers PKG-installer / launchd / sandboxed
	// contexts where $SHELL=/bin/sh and the user's interactive PATH
	// customizations are unreachable, but Docker Desktop is installed at
	// a standard location. Cheap (just os.Stat) and reliable.
	if p := probeWellKnownDocker(logger); p != "" {
		return p, DockerSourceBundled, nil
	}

	// Slow path: shell out via the user's login shell. Only useful for
	// non-standard installs (Colima, custom prefixes); skipped on Windows.
	if runtime.GOOS == osWindows {
		return "", DockerSourceAbsent, fmt.Errorf("docker not found in PATH or well-known locations")
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	shell, shellArgs := WrapWithUserShell(logger, "command", []string{"-v", "docker"})
	cmd := exec.CommandContext(ctx, shell, shellArgs...)
	out, lookErr := cmd.Output()
	if lookErr != nil {
		return "", DockerSourceAbsent, fmt.Errorf("login-shell docker lookup failed: %w", lookErr)
	}
	resolved := strings.TrimSpace(string(out))
	if resolved == "" {
		return "", DockerSourceAbsent, fmt.Errorf("docker not found in login shell PATH")
	}
	if logger != nil {
		logger.Debug("shellwrap: resolved docker via login shell",
			zap.String("path", resolved))
	}
	return resolved, DockerSourceLoginShell, nil
}

// resetDockerPathCacheForTest is used by tests to clear the cache between
// scenarios. It is intentionally unexported and only referenced from
// shellwrap_test.go.
func resetDockerPathCacheForTest() {
	dockerPathMu.Lock()
	defer dockerPathMu.Unlock()
	dockerPath = ""
	dockerPathSource = ""
	dockerPathErr = nil
	dockerPathHasResult = false
	dockerPathExpires = time.Time{}
}

// --- Login-shell PATH capture --------------------------------------------

// LoginShellPATH returns the PATH value emitted by the user's login shell.
// It is a thin view over captureLoginShellEnv (hydrate.go), which sources the
// login shell exactly once per process and caches the full environment — so
// PATH capture and env hydration share a single shell fork.
//
// Why this exists: when mcpproxy runs as a macOS App Bundle or LaunchAgent,
// os.Getenv("PATH") is often `/usr/bin:/bin`. That is enough for Go's
// exec.LookPath to find a docker binary once shellwrap.ResolveDockerPath
// has cached its absolute path, but it is NOT enough for the docker CLI
// itself, which re-execs credential helpers like `docker-credential-desktop`
// via its own $PATH lookup. Those helpers typically live in
// /usr/local/bin or /opt/homebrew/bin — directories that only exist in
// the interactive login PATH.
//
// On Windows, this function returns "" (credential-helper PATH drift is
// not the same problem there, and interactive-shell PATH capture would
// require cmd.exe or PowerShell gymnastics we explicitly avoid).
//
// Callers should treat an empty return value as "no override available"
// and fall back to os.Getenv("PATH").
func LoginShellPATH(logger *zap.Logger) string {
	env := captureLoginShellEnv(logger)
	if env == nil {
		return ""
	}
	return env["PATH"]
}

// mergePathUnique joins two PATH-style strings into one, preserving the
// order of `primary` (highest priority) followed by any entries of
// `secondary` that were not already present. Empty segments are dropped.
func mergePathUnique(primary, secondary, sep string) string {
	if primary == "" {
		return secondary
	}
	if secondary == "" {
		return primary
	}
	seen := make(map[string]struct{}, 16)
	parts := make([]string, 0, 16)
	add := func(list string) {
		for _, p := range strings.Split(list, sep) {
			if p == "" {
				continue
			}
			if _, ok := seen[p]; ok {
				continue
			}
			seen[p] = struct{}{}
			parts = append(parts, p)
		}
	}
	add(primary)
	add(secondary)
	return strings.Join(parts, sep)
}

// resetLoginShellPathCacheForTest clears the shared login-shell capture cache.
// Only referenced from shellwrap_test.go.
func resetLoginShellPathCacheForTest() {
	resetLoginShellEnvCacheForTest()
}

// --- Minimal environment for scanner subprocesses ------------------------

// MinimalEnv returns a minimal, allow-listed environment suitable for
// subprocesses that must NOT inherit the user's ambient credentials (e.g.
// AWS_ACCESS_KEY_ID, GITHUB_TOKEN, etc). It includes PATH + HOME on Unix and
// PATH + USERPROFILE on Windows so that `docker` itself still functions.
//
// Callers that need TLS or Docker-specific variables (DOCKER_HOST,
// DOCKER_CONFIG, …) should append them explicitly.
//
// On Unix, PATH is built by merging the user's login-shell PATH
// (captured once via LoginShellPATH) with the process's ambient PATH.
// Login-shell entries come first so that docker's own credential-helper
// lookups can find binaries installed in /opt/homebrew/bin or
// /usr/local/bin even when mcpproxy was started from a LaunchAgent with
// a minimal inherited PATH. See issue #381.
func MinimalEnv() []string {
	return minimalEnvWithLogger(nil)
}

// MinimalEnvWithLogger is MinimalEnv with an optional logger used while
// capturing the login-shell PATH on the first call. Subsequent calls
// return the cached value without logging.
func MinimalEnvWithLogger(logger *zap.Logger) []string {
	return minimalEnvWithLogger(logger)
}

func minimalEnvWithLogger(logger *zap.Logger) []string {
	env := make([]string, 0, 8)
	if path := buildMinimalPath(logger); path != "" {
		env = append(env, "PATH="+path)
	}
	if runtime.GOOS == osWindows {
		if v := os.Getenv("USERPROFILE"); v != "" {
			env = append(env, "USERPROFILE="+v)
		}
		if v := os.Getenv("SystemRoot"); v != "" {
			env = append(env, "SystemRoot="+v)
		}
		if v := os.Getenv("ComSpec"); v != "" {
			env = append(env, "ComSpec="+v)
		}
	} else {
		if v := os.Getenv("HOME"); v != "" {
			env = append(env, "HOME="+v)
		}
	}
	return env
}

// buildMinimalPath returns the PATH value that MinimalEnv should set on
// child processes. On Unix it merges the login-shell PATH with ambient
// PATH so that docker credential helpers (e.g. docker-credential-desktop)
// installed in /opt/homebrew/bin or /usr/local/bin are resolvable — see
// issue #381. On Windows it returns the ambient PATH unchanged.
func buildMinimalPath(logger *zap.Logger) string {
	ambient := os.Getenv("PATH")
	if runtime.GOOS == osWindows {
		return ambient
	}
	login := LoginShellPATH(logger)
	if login == "" {
		return ambient
	}
	return mergePathUnique(login, ambient, string(os.PathListSeparator))
}
