package secureenv

import (
	"net/url"
	"os"
	"runtime"
	"strings"

	"github.com/smart-mcp-proxy/mcpproxy-go/internal/shellwrap"
)

// loginShellPATHFn captures the user's interactive login-shell $PATH.
// Overridable in tests. Default delegates to shellwrap.LoginShellPATH which
// caches the result for the process lifetime via sync.Once.
var loginShellPATHFn = func() string { return shellwrap.LoginShellPATH(nil) }

const (
	osWindows = "windows"
	osDarwin  = "darwin"
)

// EnvConfig represents environment configuration for secure filtering
type EnvConfig struct {
	InheritSystemSafe bool              `json:"inherit_system_safe"`
	AllowedSystemVars []string          `json:"allowed_system_vars"`
	CustomVars        map[string]string `json:"custom_vars"`
	EnhancePath       bool              `json:"enhance_path"` // Enable PATH enhancement for Launchd scenarios
	// ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy
	// environment variables to spawned upstream servers (MCP-2769). It is OFF by
	// default and deliberately kept out of the AllowedSystemVars default list:
	// proxy URLs frequently carry credentials (http://user:pass@proxy), so
	// forwarding them to every stdio upstream is a credential-leak risk. When
	// enabled, values are forwarded with their userinfo (credentials) redacted.
	ForwardProxyEnv bool `json:"forward_proxy_env,omitempty"`
}

// PathDiscovery contains auto-discovered paths for common tools
type PathDiscovery struct {
	HomePath        string
	BrewPaths       []string
	NodePaths       []string
	PythonPaths     []string
	RustPaths       []string
	GoPaths         []string
	ChocoPaths      []string
	ScoopPaths      []string
	SystemPaths     []string
	DiscoveredPaths []string
	AvailableTools  map[string]string
}

// DefaultEnvConfig returns default environment configuration with safe system variables
func DefaultEnvConfig() *EnvConfig {
	allowedVars := []string{
		"PATH",     // Essential for finding executables
		"HOME",     // User directory path (Unix)
		"TMPDIR",   // Temporary directory (Unix)
		"TEMP",     // Temporary directory (Windows)
		"TMP",      // Temporary directory (Windows)
		"SHELL",    // Default shell
		"TERM",     // Terminal type
		"LANG",     // Language settings
		"USER",     // Current user (Unix)
		"USERNAME", // Current user (Windows)
	}

	// Add Windows-specific variables
	if runtime.GOOS == osWindows {
		allowedVars = append(allowedVars,
			"USERPROFILE",  // User profile directory
			"APPDATA",      // Application data directory
			"LOCALAPPDATA", // Local application data directory
			"PROGRAMFILES", // Program files directory
			"SYSTEMROOT",   // System root directory
			"COMSPEC",      // Command interpreter
		)
	}

	// Add Unix-specific variables
	if runtime.GOOS != osWindows {
		allowedVars = append(allowedVars,
			"XDG_CONFIG_HOME", // XDG config directory
			"XDG_DATA_HOME",   // XDG data directory
			"XDG_CACHE_HOME",  // XDG cache directory
			"XDG_RUNTIME_DIR", // XDG runtime directory
		)
	}

	// Add locale-related variables
	localeVars := []string{
		"LC_ALL", "LC_CTYPE", "LC_NUMERIC", "LC_TIME", "LC_COLLATE",
		"LC_MONETARY", "LC_MESSAGES", "LC_PAPER", "LC_NAME", "LC_ADDRESS",
		"LC_TELEPHONE", "LC_MEASUREMENT", "LC_IDENTIFICATION",
	}
	allowedVars = append(allowedVars, localeVars...)

	// Add container / tool-home passthrough variables (MCP-2751). These are NOT
	// secrets; they mirror the curated set hydrated by shellwrap.HydrateFromLoginShell
	// so the now-present vars survive this allow-list filter and reach upstream
	// stdio/docker spawns. Proxy vars (HTTP_PROXY etc.) are intentionally excluded
	// — proxy forwarding is a separate opt-in concern.
	allowedVars = append(allowedVars,
		"DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY",
		"NVM_DIR", "ASDF_DIR", "PYENV_ROOT", "VOLTA_HOME", "HOMEBREW_PREFIX", "COLIMA_HOME",
	)

	return &EnvConfig{
		InheritSystemSafe: true,
		AllowedSystemVars: allowedVars,
		CustomVars:        make(map[string]string),
	}
}

// Manager handles secure environment variable filtering
type Manager struct {
	config        *EnvConfig
	pathDiscovery *PathDiscovery
}

// NewManager creates a new secure environment manager
func NewManager(config *EnvConfig) *Manager {
	if config == nil {
		config = DefaultEnvConfig()
	}

	manager := &Manager{
		config: config,
	}

	// Perform path discovery for robust PATH handling
	manager.pathDiscovery = manager.discoverPaths()

	return manager
}

// discoverPaths automatically discovers common tool installation paths
func (m *Manager) discoverPaths() *PathDiscovery {
	discovery := &PathDiscovery{
		AvailableTools: make(map[string]string),
	}

	// Get user home directory
	if homeDir, err := os.UserHomeDir(); err == nil {
		discovery.HomePath = homeDir
	}

	// Discover platform-specific paths
	if runtime.GOOS == osWindows {
		discovery.DiscoveredPaths = m.discoverWindowsPaths()
	} else {
		discovery.DiscoveredPaths = m.discoverUnixPaths()
	}

	return discovery
}

// discoverUnixPaths discovers common Unix/macOS tool paths that actually exist.
func (m *Manager) discoverUnixPaths() []string {
	// Filter the platform candidate list to only paths that actually exist.
	var existingPaths []string
	for _, path := range unixCandidatePaths() {
		if _, err := os.Stat(path); err == nil {
			existingPaths = append(existingPaths, path)
		}
	}

	return existingPaths
}

// unixCandidatePaths returns the ordered list of well-known Unix/macOS tool
// directories to probe for existence. Split out (pure, no I/O) so platform-
// specific inclusion can be unit-tested without depending on what is installed
// on the test host.
func unixCandidatePaths() []string {
	commonPaths := []string{
		"/usr/local/bin",    // Homebrew, Docker Desktop symlink, etc.
		"/usr/bin",          // System binaries
		"/bin",              // Core system binaries
		"/opt/homebrew/bin", // Apple Silicon Homebrew
		"/usr/local/sbin",   // System admin binaries
		"/usr/sbin",         // System admin binaries
		"/sbin",             // System admin binaries
	}

	// macOS (#696): Docker Desktop installed the default way (without the
	// optional, admin-gated "install CLI tools" step) leaves the docker CLI
	// only inside the app bundle, which is not on any standard PATH dir. Adding
	// it (defense in depth for the absolute-path spawn fix) lets nested/child
	// docker resolution work for isolated servers. Existence is filtered by the
	// caller, so this is a no-op on hosts without Docker Desktop.
	if runtime.GOOS == "darwin" {
		commonPaths = append(commonPaths, "/Applications/Docker.app/Contents/Resources/bin")
	}

	// Add user-specific paths
	if homeDir, err := os.UserHomeDir(); err == nil {
		commonPaths = append(commonPaths,
			homeDir+"/.local/bin", // Local user binaries
			homeDir+"/.npm/bin",   // NPM global binaries
			homeDir+"/.yarn/bin",  // Yarn global binaries
			homeDir+"/.cargo/bin", // Rust cargo binaries
			homeDir+"/go/bin",     // Go binaries
		)
	}

	return commonPaths
}

// discoverWindowsPaths discovers common Windows tool paths
func (m *Manager) discoverWindowsPaths() []string {
	// CRITICAL FIX for Issue #302:
	// Try to read PATH from Windows registry first.
	// This is necessary because when mcpproxy is launched via installer/service,
	// it doesn't inherit the user's PATH environment variable.
	// The registry is the source of truth for Windows PATH configuration.
	if registryPaths := discoverWindowsPathsFromRegistry(); len(registryPaths) > 0 {
		return registryPaths
	}

	// Fallback to hardcoded discovery if registry read fails
	// This expanded list includes more common tool locations
	homeDir, _ := os.UserHomeDir()

	commonPaths := []string{
		// System paths
		`C:\Windows\System32`,
		`C:\Windows`,

		// Common development tools
		`C:\Program Files\Docker\Docker\resources\bin`,
		`C:\Program Files\Git\bin`,
		`C:\Program Files\Git\cmd`,
		`C:\Program Files\nodejs`,
		`C:\Program Files\Go\bin`,

		// User-specific tool paths (if homeDir is available)
	}

	if homeDir != "" {
		commonPaths = append(commonPaths,
			homeDir+`\.cargo\bin`,          // Rust tools (cargo, uv)
			homeDir+`\.local\bin`,          // Python user scripts
			homeDir+`\go\bin`,              // Go binaries
			homeDir+`\AppData\Roaming\npm`, // npm globals
			homeDir+`\scoop\shims`,         // Scoop packages
			homeDir+`\AppData\Local\Programs\Python\Python313\Scripts`, // Python 3.13
			homeDir+`\AppData\Local\Programs\Python\Python312\Scripts`, // Python 3.12
			homeDir+`\AppData\Local\Programs\Python\Python311\Scripts`, // Python 3.11
			homeDir+`\AppData\Local\Programs\Python\Python310\Scripts`, // Python 3.10
		)
	}

	// Filter to only include paths that actually exist
	var existingPaths []string
	for _, path := range commonPaths {
		if _, err := os.Stat(path); err == nil {
			existingPaths = append(existingPaths, path)
		}
	}

	return existingPaths
}

// BuildSecureEnvironment builds a secure environment variable list
func (m *Manager) BuildSecureEnvironment() []string {
	var envVars []string

	// Add safe system environment variables if enabled
	if m.config.InheritSystemSafe {
		envVars = append(envVars, m.getFilteredSystemEnv()...)
	}

	// Add custom environment variables from config
	for k, v := range m.config.CustomVars {
		envVars = append(envVars, k+"="+v)
	}

	// Ensure PATH is comprehensive by checking and enhancing it, but only if inheritance is enabled
	if m.config.InheritSystemSafe {
		envVars = m.ensureComprehensivePath(envVars)
	}

	// Forward ambient proxy variables only when explicitly opted in (MCP-2769),
	// with credentials redacted. Done last so an explicitly-configured proxy
	// value (custom/server env) always wins over the ambient one.
	if m.config.ForwardProxyEnv {
		envVars = appendForwardedProxyEnv(envVars)
	}

	return envVars
}

// proxyEnvGroups lists the proxy environment variables that ForwardProxyEnv
// forwards, grouped by logical variable. HTTP clients treat the upper- and
// lower-case spellings as aliases, so each group is handled atomically: if
// either spelling is already set (via custom/server env), neither is forwarded
// from the ambient environment.
var proxyEnvGroups = [][]string{
	{"HTTP_PROXY", "http_proxy"},
	{"HTTPS_PROXY", "https_proxy"},
	{"ALL_PROXY", "all_proxy"},
	{"NO_PROXY", "no_proxy"},
	{"FTP_PROXY", "ftp_proxy"},
}

// appendForwardedProxyEnv appends the ambient proxy variables to envVars with
// credentials redacted. An already-present spelling (case-insensitive within a
// group) suppresses forwarding of the whole group so explicit configuration is
// never overridden.
func appendForwardedProxyEnv(envVars []string) []string {
	present := make(map[string]struct{}, len(envVars))
	for _, ev := range envVars {
		if i := strings.IndexByte(ev, '='); i > 0 {
			present[ev[:i]] = struct{}{}
		}
	}

	for _, group := range proxyEnvGroups {
		if anyProxyKeyPresent(present, group) {
			continue
		}
		for _, key := range group {
			if val, ok := os.LookupEnv(key); ok && val != "" {
				envVars = append(envVars, key+"="+redactProxyCredentials(val))
			}
		}
	}
	return envVars
}

func anyProxyKeyPresent(present map[string]struct{}, group []string) bool {
	for _, key := range group {
		if _, ok := present[key]; ok {
			return true
		}
	}
	return false
}

// redactProxyCredentials strips any userinfo (user:password) from a proxy URL
// so credentials are never forwarded to upstream servers, while preserving the
// proxy host/port so the proxy remains functional. Non-URL values (e.g. the
// host list in NO_PROXY) and unparseable values are returned unchanged.
func redactProxyCredentials(value string) string {
	// Surrounding whitespace would make url.Parse error (leading space) or
	// otherwise fall through, forwarding a credentialed value verbatim. Trim it
	// first; whitespace is never meaningful in a proxy URL.
	value = strings.TrimSpace(value)
	if value == "" {
		return value
	}
	// Scheme-qualified values (http://user:pass@host) parse with Userinfo set.
	if u, err := url.Parse(value); err == nil && u.User != nil {
		u.User = nil
		return u.String()
	}
	// Schemeless values (user:pass@host:8080) are misread by url.Parse — it
	// treats "user" as the scheme, leaving Userinfo nil, so the value would be
	// forwarded with credentials intact. Re-parse with a dummy scheme so the
	// userinfo is recognized as part of the authority, then strip the scheme we
	// added. The "://" guard avoids touching already-schemed values, and
	// requiring an "@" keeps non-URL values (e.g. NO_PROXY host lists) untouched.
	// An "@" that lives in a path rather than the authority (e.g. host/path@x)
	// leaves Userinfo nil under url.Parse, so it is correctly left intact.
	if !strings.Contains(value, "://") && strings.Contains(value, "@") {
		if u, err := url.Parse("http://" + value); err == nil && u.User != nil {
			u.User = nil
			return strings.TrimPrefix(u.String(), "http://")
		}
	}
	return value
}

// ensureComprehensivePath ensures PATH includes all discovered tool paths
func (m *Manager) ensureComprehensivePath(envVars []string) []string {
	// Find existing PATH in environment
	var existingPath string
	var pathIndex = -1

	for i, envVar := range envVars {
		if strings.HasPrefix(envVar, "PATH=") {
			existingPath = strings.TrimPrefix(envVar, "PATH=")
			pathIndex = i
			break
		}
	}

	// Build enhanced PATH
	enhancedPath := m.buildEnhancedPath(existingPath)

	// Replace or add PATH
	pathVar := "PATH=" + enhancedPath
	if pathIndex >= 0 {
		envVars[pathIndex] = pathVar
	} else {
		envVars = append(envVars, pathVar)
	}

	return envVars
}

// buildEnhancedPath builds a comprehensive PATH by combining (in priority
// order) the user's interactive login-shell PATH, statically-discovered
// well-known tool directories, and the existing PATH inherited from the
// parent process.
//
// Enhancement is gated on EnvConfig.EnhancePath being explicitly opted in
// (true today only for stdio upstream servers — see core/client.go) and on
// the existing PATH NOT already containing a comprehensive tool directory.
// Issue #439: the previous gate `len(pathParts) <= 2` blocked enhancement
// for the launchd-handed `/usr/bin:/bin:/usr/sbin:/sbin` (4 entries), which
// is exactly the bad PATH that triggers the bug. We drop that gate and
// instead rely on login-shell capture + static discovery to enrich PATH
// whenever it lacks /usr/local/bin or /opt/homebrew/bin.
func (m *Manager) buildEnhancedPath(existingPath string) string {
	sep := string(os.PathListSeparator)

	if existingPath == "" {
		return strings.Join(m.pathDiscovery.DiscoveredPaths, sep)
	}

	pathParts := strings.Split(existingPath, sep)

	// If PATH already contains a common tool directory the user is fine —
	// don't pollute their carefully-set PATH with login-shell capture.
	commonToolDirs := []string{"/usr/local/bin", "/opt/homebrew/bin"}
	if runtime.GOOS == osWindows {
		commonToolDirs = []string{`C:\Program Files\Docker\Docker\resources\bin`}
	}
	for _, toolDir := range commonToolDirs {
		for _, pathPart := range pathParts {
			if pathPart == toolDir {
				return existingPath
			}
		}
	}

	if !m.config.EnhancePath {
		return existingPath
	}

	// Compose: login-shell PATH first (highest priority — captures the
	// user's actual interactive PATH including mise/asdf/Colima/custom
	// shims), then statically-discovered well-known directories (deterministic
	// floor when login-shell capture is empty / contaminated / unavailable),
	// then the existing PATH (preserves anything the operator deliberately
	// set on the daemon).
	enhancedParts := make([]string, 0, len(m.pathDiscovery.DiscoveredPaths)+len(pathParts)+8)
	seen := make(map[string]struct{}, len(enhancedParts))
	add := func(p string) {
		if p == "" {
			return
		}
		if _, ok := seen[p]; ok {
			return
		}
		seen[p] = struct{}{}
		enhancedParts = append(enhancedParts, p)
	}

	if loginShellPATHFn != nil {
		for _, p := range strings.Split(loginShellPATHFn(), sep) {
			add(p)
		}
	}
	for _, p := range m.pathDiscovery.DiscoveredPaths {
		add(p)
	}
	for _, p := range pathParts {
		add(p)
	}

	return strings.Join(enhancedParts, sep)
}

// getFilteredSystemEnv retrieves allowed environment variables from the system
func (m *Manager) getFilteredSystemEnv() []string {
	systemEnv := os.Environ()
	var filtered []string

	for _, envVar := range systemEnv {
		if m.isEnvVarAllowed(envVar) {
			filtered = append(filtered, envVar)
		}
	}

	return filtered
}

// isEnvVarAllowed checks if an environment variable is in the allowed list
func (m *Manager) isEnvVarAllowed(envVar string) bool {
	key := strings.Split(envVar, "=")[0]
	return m.isKeyAllowed(key)
}

// GetSystemEnvVar safely gets a system environment variable.
func (m *Manager) GetSystemEnvVar(key string) (string, bool) {
	if !m.isKeyAllowed(key) {
		return "", false
	}

	value := os.Getenv(key)
	return value, value != ""
}

// isKeyAllowed checks if a key is in the allowed list
func (m *Manager) isKeyAllowed(key string) bool {
	// Always allow custom variables defined in config
	if _, exists := m.config.CustomVars[key]; exists {
		return true
	}

	// Check against the list of allowed system variables
	for _, allowedKey := range m.config.AllowedSystemVars {
		if strings.HasSuffix(allowedKey, "*") {
			// Handle wildcard matching (e.g., "LC_*")
			prefix := strings.TrimSuffix(allowedKey, "*")
			if strings.HasPrefix(key, prefix) {
				return true
			}
		} else if strings.EqualFold(allowedKey, key) {
			// Handle exact matching
			return true
		}
	}
	return false
}

// ValidateConfig checks if the environment configuration is valid
func (m *Manager) ValidateConfig() error {
	return nil
}

// GetFilteredEnvCount returns the number of filtered and total system environment variables
func (m *Manager) GetFilteredEnvCount() (filteredCount, totalCount int) {
	systemEnv := os.Environ()
	filteredEnv := m.getFilteredSystemEnv()
	return len(filteredEnv), len(systemEnv)
}

// GetPathDiscovery returns the discovered path information
func (m *Manager) GetPathDiscovery() *PathDiscovery {
	return m.pathDiscovery
}
