package scanner

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync/atomic"

	"github.com/smart-mcp-proxy/mcpproxy-go/internal/dockernaming"
	"github.com/smart-mcp-proxy/mcpproxy-go/internal/shellwrap"
	"go.uber.org/zap"
)

// SourceResolver automatically determines the source directory for scanning
// a server. It resolves source based on server type:
//   - Docker-isolated stdio servers: extracts changed files from running container
//   - HTTP/SSE servers: no source needed (scanners use mcp_connection)
//   - Local stdio servers: uses working_dir or command directory
type SourceResolver struct {
	logger *zap.Logger

	// fetchPackageSource, when true, allows the resolver to fetch the PUBLISHED
	// source of a package-runner server (npx/uvx) — without executing it — as a
	// last resort before falling back to a tool-definitions-only scan. This is a
	// facet of the opt-in deep-scan layer (Spec 077 US3): the server layer keeps
	// it false unless security.deep_scan.enabled is true, and even then honors
	// security.deep_scan.fetch_package_source (default true) so air-gapped
	// deployments can forbid the network egress. With deep scan off it is always
	// false, so no published-package-source fetch happens by default.
	fetchPackageSource bool

	// resolveCalls / resolveFullSourceCalls count invocations of Resolve and
	// ResolveFullSource. They exist so tests can assert the Spec 077 US3
	// invariant that neither runs while the opt-in deep-scan layer is off (no
	// Docker lookup / extraction / package fetch by default). Atomic so the
	// Pass-2 goroutine's ResolveFullSource increment is race-free.
	resolveCalls           atomic.Int64
	resolveFullSourceCalls atomic.Int64
}

// NewSourceResolver creates a new SourceResolver
func NewSourceResolver(logger *zap.Logger) *SourceResolver {
	return &SourceResolver{logger: logger, fetchPackageSource: true}
}

// SetFetchPackageSource toggles the published-package-source fetch fallback.
func (r *SourceResolver) SetFetchPackageSource(enabled bool) {
	r.fetchPackageSource = enabled
}

// dockerCmd builds an exec.Cmd that invokes the resolved `docker` binary.
//
// The binary is looked up via shellwrap.ResolveDockerPath rather than relying
// on $PATH directly. mcpproxy is frequently launched from a GUI bundle or a
// PKInstallSandbox where $PATH does not include /usr/local/bin or
// /opt/homebrew/bin, so a bare "docker" exec would silently fail and the
// caller would see "no Docker container found" with no signal as to why. The
// shellwrap helper probes well-known install locations (Docker Desktop bundle
// binary, ~/.docker/bin, OrbStack, Homebrew, snap) and falls back to a login
// shell — the same resolution the rest of the scanner already uses for the
// image probe (see internal/security/scanner/docker.go).
//
// The minimal env is intentionally NOT applied here: source extraction runs
// against user-trusted local containers (already running with the user's own
// docker daemon) and `docker cp` of UTF-8 paths needs LANG/LC_* to round-trip
// correctly. The narrower secret-leak concern handled in docker.go applies to
// scanner containers we spawn, not to read-only `ps`/`diff`/`cp`/`exec` calls
// against an existing container.
func (r *SourceResolver) dockerCmd(ctx context.Context, args ...string) *exec.Cmd {
	dockerBin, err := shellwrap.ResolveDockerPath(r.logger)
	if err != nil || dockerBin == "" {
		dockerBin = "docker"
		if r.logger != nil {
			r.logger.Debug("source resolver: falling back to bare 'docker' lookup",
				zap.Error(err))
		}
	}
	return exec.CommandContext(ctx, dockerBin, args...)
}

// ServerInfo contains the information needed to resolve a server's source
type ServerInfo struct {
	Name       string // Server name
	Protocol   string // "stdio", "http", "sse", "streamable-http"
	Command    string // Command used to start the server (stdio only)
	Args       []string
	WorkingDir string            // Configured working directory
	URL        string            // Server URL (HTTP/SSE only)
	Env        map[string]string // Environment variables
}

// ResolvedSource contains the resolved source information for scanning
type ResolvedSource struct {
	SourceDir      string   // Host directory containing source files
	ContainerID    string   // Docker container ID (if applicable)
	ContainerImage string   // Docker image reference (for "container_image" input)
	ServerURL      string   // URL for mcp_connection input (HTTP/SSE servers)
	Method         string   // How source was resolved: "docker_extract", "container_image", "working_dir", "local_path", "url", "manual"
	Cleanup        func()   // Cleanup function (removes temp dirs)
	Files          []string // List of files found in source dir (capped)
	TotalFiles     int      // Total file count
	TotalSize      int64    // Total size in bytes
}

// Resolve determines the source directory for scanning a server.
// It tries these strategies in order:
//  1. Find running Docker container for the server (mcpproxy-<name>-*)
//  2. Use working_dir from server config
//  3. Use directory containing the server command
//  4. For HTTP servers, return URL for mcp_connection scanners
func (r *SourceResolver) Resolve(ctx context.Context, info ServerInfo) (*ResolvedSource, error) {
	r.resolveCalls.Add(1)
	// HTTP/SSE servers: scanners connect via URL
	if info.Protocol == "http" || info.Protocol == "sse" || info.Protocol == "streamable-http" {
		if info.URL != "" {
			return &ResolvedSource{
				ServerURL: info.URL,
				Method:    "url",
				Cleanup:   func() {},
			}, nil
		}
		return nil, fmt.Errorf("HTTP server %s has no URL configured", info.Name)
	}

	// User-managed Docker-image servers (e.g. `docker run -i --rm mcp/fetch`).
	// These are NOT mcpproxy-managed containers (which use uvx/npx commands wrapped
	// in `mcpproxy-<name>-*` containers and are handled below). Here the user wired
	// `docker run <image>` directly as the server command, so there is no source
	// tree to extract and no managed container to diff — the scan target is the
	// image itself. Surface it so scanners that accept a "container_image" input
	// (Trivy) can scan the image instead of falling back to an empty source dir
	// (which produced source_method=tool_definitions_only and zero scanning).
	if image := dockerImageFromCommand(info); image != "" {
		r.logger.Info("Resolved source as Docker image reference",
			zap.String("server", info.Name),
			zap.String("image", image),
		)
		return &ResolvedSource{
			ContainerImage: image,
			Method:         "container_image",
			Cleanup:        func() {},
		}, nil
	}

	// Stdio servers: try Docker container first
	containerID, err := r.findServerContainer(ctx, info.Name)
	if err == nil && containerID != "" {
		sourceDir, cleanup, err := r.extractFromContainer(ctx, containerID, info)
		if err == nil {
			r.logger.Info("Resolved source from Docker container",
				zap.String("server", info.Name),
				zap.String("container", containerID),
				zap.String("source_dir", sourceDir),
			)
			return &ResolvedSource{
				SourceDir:   sourceDir,
				ContainerID: containerID,
				Method:      "docker_extract",
				Cleanup:     cleanup,
			}, nil
		}
		r.logger.Warn("Failed to extract from container, trying fallback",
			zap.String("server", info.Name),
			zap.Error(err),
		)
	} else if err != nil {
		// Surface the docker-ps failure so users can see why source extraction
		// fell back to working_dir or tool_definitions_only. The most common
		// cause in production has been mcpproxy launched from a sandboxed PATH
		// where the bare "docker" binary couldn't be found — silently swallowing
		// that produced the misleading "Local (no Docker)" badge in the UI even
		// when the server WAS running in a container (see #420 for the related
		// image-probe fix).
		r.logger.Warn("Docker container lookup failed, will fall back to non-Docker source resolution",
			zap.String("server", info.Name),
			zap.Error(err),
		)
	}

	// For package-runner commands (npx, uvx, pipx, bunx, pnpm dlx, yarn dlx),
	// ALWAYS try the package cache FIRST before arg scanning. This prevents
	// a server like `npx @modelcontextprotocol/server-filesystem /tmp/data`
	// from picking up the user's data dir (`/tmp/data`) as the server
	// source — the arg is the filesystem server's allowed root, not code.
	if info.Command != "" && isPackageRunnerCommand(info.Command, info.Args) {
		if resolved, err := r.resolveFromPackageCache(ctx, info); err == nil {
			return resolved, nil
		} else {
			r.logger.Debug("Package cache lookup failed, falling through",
				zap.String("server", info.Name),
				zap.String("command", info.Command),
				zap.Error(err),
			)
		}
	}

	// Fallback: use working_dir
	if info.WorkingDir != "" {
		if stat, err := os.Stat(info.WorkingDir); err == nil && stat.IsDir() {
			r.logger.Info("Resolved source from working_dir",
				zap.String("server", info.Name),
				zap.String("working_dir", info.WorkingDir),
			)
			return &ResolvedSource{
				SourceDir: info.WorkingDir,
				Method:    "working_dir",
				Cleanup:   func() {},
			}, nil
		}
	}

	// Fallback: use directory of the command itself.
	// When we fall back to this arg-scan heuristic we require the candidate
	// directory to look like source code. Otherwise, a generic "path argument"
	// (e.g. a data directory passed to a filesystem server) would be
	// misclassified as code and fed to scanners.
	if info.Command != "" {
		for _, arg := range info.Args {
			if strings.HasPrefix(arg, "-") {
				continue
			}
			absPath := arg
			if !filepath.IsAbs(arg) && info.WorkingDir != "" {
				absPath = filepath.Join(info.WorkingDir, arg)
			}
			stat, err := os.Stat(absPath)
			if err != nil {
				continue
			}
			dir := absPath
			if !stat.IsDir() {
				// For a concrete file arg (e.g. `python server.py`), the parent
				// directory is the source tree by convention. If the file is
				// a source file (.py/.js/.ts/.go/.rs) we accept it directly to
				// preserve the existing behavior for interpreter servers.
				if isSourceFile(absPath) {
					return &ResolvedSource{
						SourceDir: filepath.Dir(absPath),
						Method:    "working_dir",
						Cleanup:   func() {},
					}, nil
				}
				dir = filepath.Dir(absPath)
			}
			if !dirLooksLikeSource(dir) {
				r.logger.Debug("Arg path does not look like source tree, skipping",
					zap.String("server", info.Name),
					zap.String("arg", arg),
					zap.String("path", dir),
				)
				continue
			}
			r.logger.Info("Resolved source from command argument",
				zap.String("server", info.Name),
				zap.String("path", dir),
			)
			return &ResolvedSource{
				SourceDir: dir,
				Method:    "working_dir",
				Cleanup:   func() {},
			}, nil
		}
	}

	// Last resort: try the package cache for any other command (e.g. the user
	// has an absolute path to node_modules that we didn't match above).
	if info.Command != "" && !isPackageRunnerCommand(info.Command, info.Args) {
		if resolved, err := r.resolveFromPackageCache(ctx, info); err == nil {
			return resolved, nil
		}
	}

	// Final fallback for package-runner servers (npx/uvx): fetch the PUBLISHED
	// package source without executing it. This is the primary scan target —
	// a quarantined-on-add server is never run locally, so the local cache above
	// always misses (MCP-2206). Fetching real source lets the AI + supply-chain
	// scanners run instead of degrading to tool_definitions_only.
	if r.fetchPackageSource && info.Command != "" && isPackageRunnerCommand(info.Command, info.Args) {
		if resolved, err := r.resolveFromPackageFetch(ctx, info); err == nil {
			return resolved, nil
		} else {
			r.logger.Debug("Published package fetch failed, falling back to tool definitions only",
				zap.String("server", info.Name),
				zap.String("command", info.Command),
				zap.Error(err),
			)
		}
	}

	return nil, fmt.Errorf("could not resolve source for server %s: no Docker container found, no working_dir configured, and no local file paths in command args", info.Name)
}

// isPackageRunnerCommand returns true for commands that execute a package from
// a remote registry rather than running local source code. For these, the
// server source lives in the package manager's cache, not in any positional
// argument.
//
// Classification is ARGUMENT-AWARE for subcommand-style runners (MCP-2445):
// npx/uvx/bunx always run a remote package by name, but pnpm/yarn/bun/pipx are
// general multi-command tools — they are runners ONLY when their ephemeral-run
// keyword (`dlx`/`x`/`run`) is actually present. Classifying `pnpm start` or
// `bun server.ts` as a runner by name alone would route a local invocation into
// the package cache/fetch path and scan the wrong token. We decide this up front
// rather than relying on a later parser failure.
func isPackageRunnerCommand(command string, args []string) bool {
	base := strings.ToLower(filepath.Base(command))
	switch base {
	case "npx", "uvx", "bunx":
		return true
	case "pipx", "pnpm", "yarn", "bun":
		// Runner only when the ephemeral-run keyword yields a resolvable package.
		return runnerPackageSpec(base, args) != ""
	}
	return false
}

// dockerRunValueFlags lists the `docker run` / `podman run` flags that consume
// the following argument as their value. Used by dockerImageFromCommand to skip
// past flag values when locating the positional image reference. Flags not in
// this set (and not containing "=") are treated as boolean (e.g. -i, -t, --rm),
// consuming no extra token. Attached forms ("--flag=value", "-eFOO=bar") carry
// their value inline and are detected by the "=" check, so they need no entry.
var dockerRunValueFlags = map[string]bool{
	// Short forms
	"-e": true, "-v": true, "-p": true, "-u": true, "-w": true, "-l": true, "-m": true,
	// Long forms
	"--env": true, "--volume": true, "--publish": true, "--name": true,
	"--workdir": true, "--entrypoint": true, "--network": true, "--net": true,
	"--user": true, "--label": true, "--mount": true, "--env-file": true,
	"--add-host": true, "--device": true, "--expose": true, "--hostname": true,
	"--memory": true, "--memory-swap": true, "--cpus": true, "--cpu-shares": true,
	"--cpuset-cpus": true, "--platform": true, "--pull": true, "--restart": true,
	"--log-driver": true, "--log-opt": true, "--cap-add": true, "--cap-drop": true,
	"--security-opt": true, "--tmpfs": true, "--ulimit": true, "--gpus": true,
	"--dns": true, "--link": true, "--volumes-from": true, "--sysctl": true,
	"--shm-size": true, "--stop-signal": true, "--stop-timeout": true,
	"--pid": true, "--ipc": true, "--uts": true, "--userns": true,
	"--cgroupns": true, "--group-add": true, "--runtime": true,
	"--health-cmd": true, "--health-interval": true, "--health-timeout": true,
	"--health-retries": true, "--health-start-period": true,
}

// dockerImageFromCommand returns the Docker/Podman image reference for a server
// whose command is a literal `docker run <image>` (or `podman run <image>`, or
// `docker container run <image>`). It returns "" when the command is not a
// docker/podman run invocation or no positional image argument can be found.
//
// This handles the common case where a user wires an MCP server as
// `command: "docker", args: ["run", "-i", "--rm", "mcp/fetch"]`. Such servers
// are not mcpproxy-managed containers, so the running-container resolver finds
// nothing; without this the scan degraded to tool-definitions-only and Trivy
// scanned an empty directory.
//
// Flag handling follows docker's CLI grammar: flags containing "=" carry their
// value inline; flags listed in dockerRunValueFlags consume the next token;
// every other flag (and combined short booleans like -it) consumes no value.
// The first non-flag token after `run` is the image; anything after it belongs
// to the containerized command and is ignored.
func dockerImageFromCommand(info ServerInfo) string {
	base := strings.ToLower(filepath.Base(info.Command))
	if base != "docker" && base != "podman" {
		return ""
	}

	args := info.Args
	// Skip an optional "container" management subcommand: `docker container run`.
	if len(args) > 0 && args[0] == "container" {
		args = args[1:]
	}
	if len(args) == 0 || args[0] != "run" {
		return ""
	}
	args = args[1:]

	for i := 0; i < len(args); i++ {
		arg := args[i]
		if strings.HasPrefix(arg, "-") {
			if strings.Contains(arg, "=") {
				continue // --flag=value / -eFOO=bar — value is inline
			}
			if dockerRunValueFlags[arg] {
				i++ // consume the flag's value token
			}
			continue
		}
		// First positional argument after `run` is the image reference.
		return arg
	}
	return ""
}

// isSourceFile returns true if the path looks like a source-code file by
// extension. Used so interpreter servers (`python server.py`) still resolve
// cleanly to the script's parent directory.
func isSourceFile(path string) bool {
	ext := strings.ToLower(filepath.Ext(path))
	switch ext {
	case ".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".go", ".rs", ".rb", ".php", ".sh":
		return true
	}
	return false
}

// dirLooksLikeSource heuristically decides whether a directory is a source
// tree vs. e.g. a data directory passed as a filesystem-server root.
// A directory qualifies if it contains a known manifest file or at least one
// source file within two directory levels.
func dirLooksLikeSource(dir string) bool {
	markers := []string{
		"package.json", "pyproject.toml", "setup.py", "Cargo.toml",
		"go.mod", "composer.json", "Gemfile",
	}
	for _, m := range markers {
		if _, err := os.Stat(filepath.Join(dir, m)); err == nil {
			return true
		}
	}
	// Walk up to depth 2 looking for at least one source file.
	found := false
	const maxDepth = 2
	_ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil || info == nil {
			return nil //nolint:nilerr // ignore walk errors, treat as "no source"
		}
		rel, rerr := filepath.Rel(dir, path)
		if rerr != nil {
			return nil
		}
		depth := 0
		if rel != "." {
			depth = strings.Count(rel, string(filepath.Separator)) + 1
		}
		if info.IsDir() {
			if depth > maxDepth {
				return filepath.SkipDir
			}
			// Skip noisy dirs
			name := info.Name()
			if name == ".git" || name == "node_modules" || name == "__pycache__" || name == ".venv" {
				return filepath.SkipDir
			}
			return nil
		}
		if depth > maxDepth {
			return nil
		}
		if isSourceFile(path) {
			found = true
			return filepath.SkipAll
		}
		return nil
	})
	return found
}

// findServerContainer finds the running Docker container for a server.
// MCPProxy names containers as: mcpproxy-<sanitized-server-name>-<suffix>.
// The sanitization MUST match the one used to name the container at launch
// (internal/upstream/core), hence the shared dockernaming package — official
// registry names like "com.pulsemcp/google-flights" keep their dots and would
// otherwise never match (MCP-2123).
func (r *SourceResolver) findServerContainer(ctx context.Context, serverName string) (string, error) {
	// Use docker ps with filter to find matching containers
	cmd := r.dockerCmd(ctx, "ps",
		"--filter", fmt.Sprintf("name=mcpproxy-%s-", dockernaming.SanitizeServerName(serverName)),
		"--format", "{{.ID}}",
		"--no-trunc",
	)
	var stdout bytes.Buffer
	cmd.Stdout = &stdout
	if err := cmd.Run(); err != nil {
		return "", fmt.Errorf("docker ps failed: %w", err)
	}

	lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
	if len(lines) == 0 || lines[0] == "" {
		return "", fmt.Errorf("no running container found for server %s", serverName)
	}

	// Return first match
	return lines[0], nil
}

// extractFromContainer extracts changed files from a running container.
// Two resolution strategies are combined:
//
//  1. For package-runner servers (npx, uvx) the target package is located
//     directly via `docker exec` and copied out. This is necessary because the
//     target lives inside a Docker volume mount (e.g. /root/.npm for the
//     shared npx cache) and volume contents never appear in `docker diff`.
//
//  2. `docker diff` is used to find any additional user-added app source in
//     the container's writable layer (e.g. /app, /src) and copy those too.
//
// The npx diff path is filtered by target package name so sibling packages
// hoisted into the same shared cache cannot leak into the scan.
func (r *SourceResolver) extractFromContainer(ctx context.Context, containerID string, info ServerInfo) (string, func(), error) {
	serverName := info.Name
	// Create temp directory for extracted source. Keep the pattern a constant:
	// os.MkdirTemp's random suffix already guarantees uniqueness, so embedding the
	// (user-controlled) server name added nothing but a go/path-injection taint
	// (MCP-2155) and a slash-rejection bug for official-registry names like
	// "com.pulsemcp/google-flights" (MCP-2123). Dropping it fixes both.
	tempDir, err := os.MkdirTemp("", "mcpproxy-scan-")
	if err != nil {
		return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
	}
	cleanup := func() { os.RemoveAll(tempDir) }

	extracted := false

	// Strategy 1: direct target package lookup for npx/uvx servers.
	if targetDir := r.findContainerTargetDir(ctx, containerID, info); targetDir != "" {
		destDir := filepath.Join(tempDir, "target")
		_ = os.MkdirAll(destDir, 0755)
		cpCmd := r.dockerCmd(ctx, "cp", containerID+":"+targetDir+"/.", destDir)
		if err := cpCmd.Run(); err == nil {
			r.logger.Info("Extracted target package from container",
				zap.String("server", serverName),
				zap.String("target_dir", targetDir),
			)
			extracted = true
		} else {
			r.logger.Debug("docker cp of target package failed",
				zap.String("server", serverName),
				zap.String("target_dir", targetDir),
				zap.Error(err),
			)
		}
	}

	// Strategy 2: docker diff for any user-added app source.
	cmd := r.dockerCmd(ctx, "diff", containerID)
	var stdout bytes.Buffer
	cmd.Stdout = &stdout
	diffErr := cmd.Run()

	var appDirs []string
	if diffErr == nil {
		// Identify app-relevant directories from the diff, scoped to the target npx package.
		// If the server is not an npx command, targetNpxPkg is empty and no npx-cache paths
		// are accepted at all (they would otherwise pollute the scan with sibling packages).
		targetNpxPkg := npxTargetPackage(info)
		appDirs = r.findAppDirectories(stdout.String(), targetNpxPkg)
	} else if !extracted {
		cleanup()
		return "", nil, fmt.Errorf("docker diff failed: %w", diffErr)
	}

	if len(appDirs) == 0 && !extracted {
		// Fallback: try UV git checkouts directly, then common app dirs
		// Do NOT copy /root entirely — it may contain 10K+ dependency files
		r.logger.Info("No specific app directories found in docker diff, trying direct paths",
			zap.String("container", containerID),
		)
		// Try UV git checkouts first (uvx --from pkg@git+URL)
		uvCheckoutCmd := r.dockerCmd(ctx, "exec", containerID, "find", "/root/.cache/uv/git-v0/checkouts", "-maxdepth", "2", "-mindepth", "2", "-type", "d")
		var uvOut bytes.Buffer
		uvCheckoutCmd.Stdout = &uvOut
		if uvCheckoutCmd.Run() == nil {
			for _, dir := range strings.Split(strings.TrimSpace(uvOut.String()), "\n") {
				if dir == "" {
					continue
				}
				destDir := filepath.Join(tempDir, "source")
				os.MkdirAll(destDir, 0755)
				cpCmd := r.dockerCmd(ctx, "cp", containerID+":"+dir+"/.", destDir)
				if cpCmd.Run() == nil {
					r.logger.Info("Extracted UV git checkout", zap.String("dir", dir))
					return tempDir, cleanup, nil
				}
			}
		}
		// Try common app dirs (NOT /root — too broad)
		for _, dir := range []string{"/app", "/src", "/opt/app"} {
			cpCmd := r.dockerCmd(ctx, "cp", containerID+":"+dir+"/.", filepath.Join(tempDir, filepath.Base(dir)))
			if cpCmd.Run() == nil {
				return tempDir, cleanup, nil
			}
		}
		cleanup()
		return "", nil, fmt.Errorf("no extractable source found in container %s", containerID)
	}

	// Extract each app directory
	for _, dir := range appDirs {
		destDir := filepath.Join(tempDir, filepath.Base(dir))
		os.MkdirAll(destDir, 0755)
		cpCmd := r.dockerCmd(ctx, "cp", containerID+":"+dir+"/.", destDir)
		if err := cpCmd.Run(); err != nil {
			r.logger.Debug("Failed to copy directory from container",
				zap.String("dir", dir),
				zap.Error(err),
			)
		}
	}

	return tempDir, cleanup, nil
}

// findAppDirectories analyzes docker diff output to find app-relevant directories.
// It looks for directories where packages were installed (node_modules, site-packages, etc.)
// and any user-added source files. When targetNpxPkg is non-empty, paths in an
// npx cache that belong to a different package are filtered out — this prevents
// sibling packages (hoisted into the same /.npm/_npx/<hash>/node_modules) from
// leaking into scans of a specific target package.
func (r *SourceResolver) findAppDirectories(diffOutput, targetNpxPkg string) []string {
	seen := make(map[string]bool)
	var dirs []string

	for _, line := range strings.Split(diffOutput, "\n") {
		line = strings.TrimSpace(line)
		if len(line) < 3 {
			continue
		}
		action := line[0] // A=added, C=changed, D=deleted
		path := line[2:]

		if action == 'D' {
			continue // Skip deleted files
		}

		// Skip OS-level directories (not app code)
		if r.isSystemPath(path) {
			continue
		}

		// Find the top-level app directory
		dir := r.extractAppRoot(path, targetNpxPkg)
		if dir != "" && !seen[dir] {
			seen[dir] = true
			dirs = append(dirs, dir)
		}
	}

	return dirs
}

// isSystemPath returns true for OS-level paths or dependency dirs that aren't app source
func (r *SourceResolver) isSystemPath(path string) bool {
	systemPrefixes := []string{
		"/etc/", "/var/", "/tmp/", "/proc/", "/sys/", "/dev/",
		"/usr/lib/", "/usr/bin/", "/usr/sbin/",
		"/lib/", "/bin/", "/sbin/",
	}
	for _, prefix := range systemPrefixes {
		if strings.HasPrefix(path, prefix) {
			return true
		}
	}
	// Skip dependency directories (too large, not user code)
	if strings.Contains(path, "/site-packages/") ||
		strings.Contains(path, "/dist-packages/") {
		return true
	}
	// Skip standalone node_modules (but NOT inside npx cache which is the server itself)
	if strings.Contains(path, "/node_modules/") && !strings.Contains(path, "/_npx/") {
		return true
	}
	// Skip UV/pip dependency archives (keep git checkouts which are actual source)
	if strings.Contains(path, "/.cache/uv/archive-v0/") ||
		strings.Contains(path, "/.cache/pip/") {
		return true
	}
	return false
}

// extractAppRoot extracts the top-level application directory from a path.
// Identifies actual server source vs dependency code for various package managers.
// targetNpxPkg, when non-empty, restricts npx cache matches to a specific package
// (e.g. "@modelcontextprotocol/server-everything") so unrelated sibling packages
// hoisted into the same /.npm/_npx/<hash>/node_modules bucket are excluded.
func (r *SourceResolver) extractAppRoot(path, targetNpxPkg string) string {
	// UV git checkouts: /root/.cache/uv/git-v0/checkouts/<hash>/<rev>/ → extract that specific checkout
	// This is the ACTUAL source code of a git-installed package (e.g., uvx --from pkg@git+URL)
	if strings.Contains(path, "/.cache/uv/git-v0/checkouts/") {
		// Extract: /root/.cache/uv/git-v0/checkouts/<hash>/<rev>
		parts := strings.Split(path, "/")
		for i, p := range parts {
			if p == "checkouts" && i+2 < len(parts) {
				return strings.Join(parts[:i+3], "/")
			}
		}
	}

	// npm npx cache: /root/.npm/_npx/<hash>/node_modules/<pkg> → extract the specific package
	// directory. Without this isolation, the shared bucket directory is returned and
	// `docker cp` copies ALL peer packages, causing findings to reference unrelated
	// code (e.g. scanning everything-server surfaces @just-every/mcp-screenshot-website-fast).
	if strings.Contains(path, "/.npm/_npx/") && strings.Contains(path, "/node_modules/") {
		return extractNpxPackageDir(path, targetNpxPkg)
	}

	// Common app directories
	appRoots := []string{"/app", "/src", "/opt/app", "/home"}
	for _, root := range appRoots {
		if strings.HasPrefix(path, root+"/") || path == root {
			return root
		}
	}

	// Root-level user files. Deliberately reject hidden (dot) directories —
	// those are package manager caches, config, and volume mounts (.npm, .cache,
	// .local, .config, .venv, ...) which are either already handled by the
	// specific matchers above, or would pull in tens of thousands of unrelated
	// files (e.g. /root/.npm is the shared Docker volume containing every
	// package ever used by any container that mounts it).
	if strings.HasPrefix(path, "/root/") {
		parts := strings.SplitN(path[6:], "/", 2) // after "/root/"
		if len(parts) > 0 && parts[0] != "" && !strings.HasPrefix(parts[0], ".") {
			return "/root/" + parts[0]
		}
	}

	return ""
}

// extractNpxPackageDir returns the directory of the specific package a file
// belongs to inside an npx cache. Scoped packages (@scope/name) consume two
// path segments; unscoped packages consume one. The caller MUST provide the
// target package name; when targetPkg is empty the function returns "" to
// avoid arbitrarily picking a package from a shared bucket (which is exactly
// the sibling-leak bug this helper exists to prevent). npx hoists ALL
// transitive dependencies into the same /_npx/<hash>/node_modules/ bucket
// alongside the requested package, so without a known target we cannot safely
// attribute a path to "the server's own code".
func extractNpxPackageDir(path, targetPkg string) string {
	if targetPkg == "" {
		return ""
	}
	const marker = "/node_modules/"
	idx := strings.Index(path, marker)
	if idx == -1 {
		return ""
	}
	rest := path[idx+len(marker):]
	if rest == "" {
		return ""
	}
	parts := strings.SplitN(rest, "/", 3)
	var pkgName, pkgDir string
	if strings.HasPrefix(parts[0], "@") {
		// Scoped package requires two segments: @scope/name
		if len(parts) < 2 || parts[1] == "" {
			return ""
		}
		pkgName = parts[0] + "/" + parts[1]
		pkgDir = path[:idx+len(marker)] + parts[0] + "/" + parts[1]
	} else {
		if parts[0] == "" {
			return ""
		}
		pkgName = parts[0]
		pkgDir = path[:idx+len(marker)] + parts[0]
	}
	if pkgName != targetPkg {
		return ""
	}
	return pkgDir
}

// npxTargetPackage returns the canonical package name (with version specifier
// stripped) that an npx-based server launches. Returns "" if the command is
// not npx or no package name can be determined.
func npxTargetPackage(info ServerInfo) string {
	if info.Command == "" || filepath.Base(info.Command) != "npx" {
		return ""
	}
	name, _ := parsePackageSpec(runnerPackageSpec("npx", info.Args))
	return name
}

// uvxTargetPackage returns the Python package name a uvx-based server launches.
// Supports `uvx <pkg>`, `uvx --from <pkg> <cmd>`, and `uvx <pkg>@<version>`.
// Git URLs (git+https://...) are reduced to the repo name. Returns "" if the
// command is not uvx or no package name can be determined.
func uvxTargetPackage(info ServerInfo) string {
	if info.Command == "" || filepath.Base(info.Command) != "uvx" {
		return ""
	}
	// runnerPackageSpec skips `--with <dep>` / `-p <python>` values so the target
	// is not shadowed by an extra dependency or python version (MCP-2445).
	raw := runnerPackageSpec("uvx", info.Args)
	if raw == "" {
		return ""
	}
	// Git URL: extract the repo name.
	if strings.HasPrefix(raw, "git+") {
		url := strings.TrimPrefix(raw, "git+")
		url = strings.TrimSuffix(url, ".git")
		if idx := strings.LastIndex(url, "/"); idx != -1 && idx+1 < len(url) {
			return url[idx+1:]
		}
		return ""
	}
	// Strip version specifier: pkg@1.0 or pkg==1.0.
	name, _ := parsePackageSpec(raw)
	return name
}

// npxContainerLocateScript builds a /bin/sh script that locates the npx cache
// directory for pkg under npxGlobRoot (`<root>/<hash>/node_modules/<pkg>`). When
// version is non-empty it PREFERS a bucket whose package.json declares that exact
// version (honoring a version pin across a multi-version cache), and otherwise
// echoes the first matching directory — preserving the previous newest/first
// behavior. The package name and version are single-quote escaped for safe
// embedding. Factored out so the shell logic is unit-testable on the host.
func npxContainerLocateScript(npxGlobRoot, pkg, version string) string {
	pkgEsc := strings.ReplaceAll(pkg, "'", `'\''`)
	if version != "" {
		// Pinned: return ONLY a bucket whose package.json declares that exact
		// version. A pin-miss returns nothing — never a mismatched version, which
		// would scan the wrong code and report false coverage (MCP-2445).
		//
		// The version is compared as a LITERAL shell string ([ "$v" = '...' ]),
		// NOT as a regex: a pin like "1.0.0-alpha.1" must not match a cached
		// "1.0.0-alpha-1" ('.' is not a wildcard) — MCP-2445 round-3. We extract
		// the package.json "version" value with a grep whose pattern matches only
		// the KEY (the value is captured as "[^\"]*", never the pin), pull the
		// value out with sed, then compare it literally.
		verEsc := strings.ReplaceAll(version, "'", `'\''`)
		return "for d in " + npxGlobRoot + "/*/node_modules/'" + pkgEsc + "'; do " +
			"[ -d \"$d\" ] || continue; " +
			"v=$(grep -o '\"version\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' \"$d/package.json\" 2>/dev/null | head -1 | sed 's/.*\"\\([^\"]*\\)\"$/\\1/'); " +
			"if [ \"$v\" = '" + verEsc + "' ]; then printf '%s\\n' \"$d\"; exit 0; fi; " +
			"done"
	}
	// Unpinned: first matching bucket.
	return "for d in " + npxGlobRoot + "/*/node_modules/'" + pkgEsc + "'; do " +
		"[ -d \"$d\" ] || continue; printf '%s\\n' \"$d\"; exit 0; done"
}

// findContainerTargetDir locates the target package's directory inside a
// running Docker container using `docker exec`. This is the resolution of
// choice for package-runner servers (npx, uvx) whose target lives inside a
// mounted cache volume — volume contents never appear in `docker diff`, so
// the diff-based scanners would otherwise either miss the target entirely or
// (worse) fall back to copying the whole volume and dragging sibling packages
// into the scan. Returns "" if the server is not a package-runner or the
// target cannot be located.
func (r *SourceResolver) findContainerTargetDir(ctx context.Context, containerID string, info ServerInfo) string {
	if pkg := npxTargetPackage(info); pkg != "" {
		// Glob for the package under every npx cache bucket inside the container.
		// When the spec carries an exact version pin, prefer the bucket whose
		// package.json declares that version over the first match (MCP-2445).
		_, wantVer := parsePackageSpec(runnerPackageSpec("npx", info.Args))
		script := npxContainerLocateScript("/root/.npm/_npx", pkg, wantVer)
		if out, ok := r.dockerExecCapture(ctx, containerID, script); ok {
			if path := strings.TrimSpace(out); path != "" {
				return path
			}
		}
		return ""
	}
	if pkg := uvxTargetPackage(info); pkg != "" {
		// uv/uvx install locations (from most- to least-specific):
		//   1. /root/.local/share/uv/tools/<pkg>/                              (persistent `uv tool install`)
		//   2. /root/.cache/uv/archive-v0/<hash>/lib/pythonX.Y/site-packages/<pkg>/  (ephemeral uvx env)
		//   3. /usr/local/lib/pythonX.Y/site-packages/<pkg>/                   (system pip install)
		//
		// Wheel-normalised names use underscores, PEP 503 names use hyphens, so
		// try both variants. We search in priority order and stop at the first hit.
		escaped := strings.ReplaceAll(pkg, "'", `'\''`)
		lower := strings.ToLower(escaped)
		underscore := strings.ReplaceAll(lower, "-", "_")
		hyphen := strings.ReplaceAll(lower, "_", "-")
		// Build a deduped list of candidate leaf names.
		seen := map[string]bool{}
		var names []string
		for _, n := range []string{escaped, lower, underscore, hyphen} {
			if n != "" && !seen[n] {
				seen[n] = true
				names = append(names, n)
			}
		}
		var globs []string
		for _, n := range names {
			globs = append(globs,
				"/root/.local/share/uv/tools/'"+n+"'",
				"/root/.cache/uv/archive-v0/*/lib/python*/site-packages/'"+n+"'",
				"/usr/local/lib/python*/site-packages/'"+n+"'",
				"/usr/lib/python*/site-packages/'"+n+"'",
				"/usr/lib/python*/dist-packages/'"+n+"'",
			)
		}
		script := "for p in " + strings.Join(globs, " ") + "; do for d in $p; do [ -d \"$d\" ] && { echo \"$d\"; exit 0; }; done; done; exit 1"
		if out, ok := r.dockerExecCapture(ctx, containerID, script); ok {
			if path := strings.TrimSpace(out); path != "" {
				return path
			}
		}
	}
	return ""
}

// dockerExecCapture runs a shell command inside a container and returns its
// stdout. Returns ok=false if the command fails or exits non-zero.
func (r *SourceResolver) dockerExecCapture(ctx context.Context, containerID, script string) (string, bool) {
	cmd := r.dockerCmd(ctx, "exec", containerID, "sh", "-c", script)
	var stdout bytes.Buffer
	cmd.Stdout = &stdout
	if err := cmd.Run(); err != nil {
		return "", false
	}
	return stdout.String(), true
}

// ResolveFullSource resolves the FULL source directory for a server, including
// all dependencies (site-packages, node_modules, UV archives, etc.).
// This is used for Pass 2 (supply chain audit) to scan the complete filesystem.
func (r *SourceResolver) ResolveFullSource(ctx context.Context, info ServerInfo) (*ResolvedSource, error) {
	r.resolveFullSourceCalls.Add(1)
	// HTTP/SSE servers: no filesystem to scan
	if info.Protocol == "http" || info.Protocol == "sse" || info.Protocol == "streamable-http" {
		if info.URL != "" {
			return &ResolvedSource{
				ServerURL: info.URL,
				Method:    "url",
				Cleanup:   func() {},
			}, nil
		}
		return nil, fmt.Errorf("HTTP server %s has no URL configured", info.Name)
	}

	// User-managed Docker-image servers: scan the image, not a source tree.
	// Pass 2 (supply chain audit) benefits most here — Trivy image mode reports
	// CVEs in the image's OS packages and bundled dependencies.
	if image := dockerImageFromCommand(info); image != "" {
		r.logger.Info("Resolved full source as Docker image reference for Pass 2",
			zap.String("server", info.Name),
			zap.String("image", image),
		)
		return &ResolvedSource{
			ContainerImage: image,
			Method:         "container_image",
			Cleanup:        func() {},
		}, nil
	}

	// Stdio servers: try Docker container first — extract FULL container
	containerID, err := r.findServerContainer(ctx, info.Name)
	if err == nil && containerID != "" {
		sourceDir, cleanup, err := r.extractFullFromContainer(ctx, containerID, info)
		if err == nil {
			r.logger.Info("Resolved full source from Docker container for Pass 2",
				zap.String("server", info.Name),
				zap.String("container", containerID),
				zap.String("source_dir", sourceDir),
			)
			return &ResolvedSource{
				SourceDir:   sourceDir,
				ContainerID: containerID,
				Method:      "docker_extract",
				Cleanup:     cleanup,
			}, nil
		}
		r.logger.Warn("Failed to extract full source from container, trying fallback",
			zap.String("server", info.Name),
			zap.Error(err),
		)
	}

	// Fallback: use working_dir (same as Pass 1 — no container means no deps to scan)
	if info.WorkingDir != "" {
		if stat, err := os.Stat(info.WorkingDir); err == nil && stat.IsDir() {
			return &ResolvedSource{
				SourceDir: info.WorkingDir,
				Method:    "working_dir",
				Cleanup:   func() {},
			}, nil
		}
	}

	// Final fallback for package-runner servers: fetch the published package
	// source without executing it (MCP-2206). Same rationale as Pass 1 — without
	// a local container or cache, Pass 2 supply-chain scanning would otherwise
	// have nothing to analyze for npx/uvx servers.
	if r.fetchPackageSource && info.Command != "" && isPackageRunnerCommand(info.Command, info.Args) {
		if resolved, err := r.resolveFromPackageFetch(ctx, info); err == nil {
			return resolved, nil
		}
	}

	return nil, fmt.Errorf("could not resolve full source for server %s", info.Name)
}

// extractFullFromContainer extracts ALL changed files from a container
// that belong to the target server's source and its dependency trees. Used for
// Pass 2 supply chain audit. Unlike Pass 1, this intentionally INCLUDES
// installed dependencies (site-packages, node_modules) so CVE scanners can see
// the full supply chain — but it does NOT include unrelated system files such
// as the Python standard library, which would otherwise flood the scan with
// false positives (e.g. flagging shutil.py or tempfile.py as "malicious").
func (r *SourceResolver) extractFullFromContainer(ctx context.Context, containerID string, info ServerInfo) (string, func(), error) {
	serverName := info.Name
	// Keep the pattern a constant — os.MkdirTemp's random suffix guarantees
	// uniqueness; embedding the user-controlled server name only added a
	// go/path-injection taint (MCP-2155) and a slash-rejection bug (MCP-2123).
	tempDir, err := os.MkdirTemp("", "mcpproxy-scan-full-")
	if err != nil {
		return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
	}
	cleanup := func() { os.RemoveAll(tempDir) }

	extracted := false

	// Strategy 1: direct target package lookup for npx/uvx servers. The target
	// (and, because npm/uv hoist dependencies into the same tree, also its deps)
	// lives inside a Docker volume mount invisible to `docker diff`, so we must
	// locate it via `docker exec` instead.
	if targetDir := r.findContainerTargetDir(ctx, containerID, info); targetDir != "" {
		destDir := filepath.Join(tempDir, "target")
		_ = os.MkdirAll(destDir, 0755)
		cpCmd := r.dockerCmd(ctx, "cp", containerID+":"+targetDir+"/.", destDir)
		if err := cpCmd.Run(); err == nil {
			r.logger.Info("Extracted target package from container (Pass 2)",
				zap.String("server", serverName),
				zap.String("target_dir", targetDir),
			)
			extracted = true
		} else {
			r.logger.Debug("docker cp of target package failed (Pass 2)",
				zap.String("server", serverName),
				zap.String("target_dir", targetDir),
				zap.Error(err),
			)
		}
	}

	// Strategy 2: docker diff for additional dependency subtrees that the
	// server touched (e.g. anything at /app, /src, or installed site-packages).
	cmd := r.dockerCmd(ctx, "diff", containerID)
	var stdout bytes.Buffer
	cmd.Stdout = &stdout
	diffErr := cmd.Run()

	var dirs []string
	if diffErr == nil {
		dirs = r.findAllChangedDirectories(stdout.String(), npxTargetPackage(info))
	} else if !extracted {
		cleanup()
		return "", nil, fmt.Errorf("docker diff failed: %w", diffErr)
	}

	if len(dirs) == 0 && !extracted {
		// Narrow fallback: only try conventional app source dirs. Do NOT fall back
		// to /root or /usr — they contain the npm cache and Python stdlib which
		// would produce huge, noisy scans and cause stdlib false positives.
		for _, dir := range []string{"/app", "/src", "/opt/app"} {
			destDir := filepath.Join(tempDir, filepath.Base(dir))
			os.MkdirAll(destDir, 0755)
			cpCmd := r.dockerCmd(ctx, "cp", containerID+":"+dir+"/.", destDir)
			if cpCmd.Run() == nil {
				return tempDir, cleanup, nil
			}
		}
		cleanup()
		return "", nil, fmt.Errorf("no extractable source found in container %s", containerID)
	}

	// Extract each directory. Use a content hash of the path for the destination
	// name so that multiple site-packages / archive subtrees under the same parent
	// don't collide when filepath.Base returns the same leaf.
	for i, dir := range dirs {
		destName := fmt.Sprintf("%d-%s", i, filepath.Base(dir))
		destDir := filepath.Join(tempDir, destName)
		os.MkdirAll(destDir, 0755)
		cpCmd := r.dockerCmd(ctx, "cp", containerID+":"+dir+"/.", destDir)
		if err := cpCmd.Run(); err != nil {
			r.logger.Debug("Failed to copy directory from container (Pass 2)",
				zap.String("dir", dir),
				zap.Error(err),
			)
		}
	}

	return tempDir, cleanup, nil
}

// findAllChangedDirectories analyzes docker diff output and returns specific
// dependency/source subtrees that belong to the target server. It deliberately
// filters out unrelated system files (Python stdlib, /usr/lib, OS pseudo-fs)
// while still including installed dependencies for supply chain auditing.
func (r *SourceResolver) findAllChangedDirectories(diffOutput, targetNpxPkg string) []string {
	seen := make(map[string]bool)
	var dirs []string

	for _, line := range strings.Split(diffOutput, "\n") {
		line = strings.TrimSpace(line)
		if len(line) < 3 {
			continue
		}
		action := line[0]
		path := line[2:]

		if action == 'D' {
			continue
		}

		if r.isHardSystemPath(path) {
			continue
		}

		dir := r.extractPass2Dir(path, targetNpxPkg)
		if dir != "" && !seen[dir] {
			seen[dir] = true
			dirs = append(dirs, dir)
		}
	}

	return dirs
}

// isHardSystemPath returns true for paths that are never useful for scanning.
// This includes OS pseudo-filesystems (proc, sys, dev), system config, and the
// Python standard library (which lives under /usr/local/lib/pythonX/ directly
// but NOT in site-packages/dist-packages — those are installed dependencies
// and ARE worth scanning for supply chain audit).
func (r *SourceResolver) isHardSystemPath(path string) bool {
	hardSystemPrefixes := []string{
		"/proc/", "/sys/", "/dev/",
		"/etc/", "/var/run/", "/var/lock/",
		"/usr/lib/", "/usr/bin/", "/usr/sbin/",
		"/lib/", "/bin/", "/sbin/",
	}
	for _, prefix := range hardSystemPrefixes {
		if strings.HasPrefix(path, prefix) {
			return true
		}
	}
	// Python stdlib: anything under /.../lib/pythonX.Y/ that is NOT inside
	// site-packages or dist-packages is stdlib shipped with the base image.
	// Scanning it produces false positives (shutil flagged as "shell command
	// execution", tempfile flagged as "obfuscated payload", etc.).
	if isPythonStdlibPath(path) {
		return true
	}
	return false
}

// isPythonStdlibPath reports whether a path is inside the Python standard
// library tree (not in site-packages/dist-packages).
func isPythonStdlibPath(path string) bool {
	if !strings.Contains(path, "/python") {
		return false
	}
	// Look for a segment like "pythonX" or "pythonX.Y" preceded by "lib/".
	parts := strings.Split(path, "/")
	for i := 1; i < len(parts); i++ {
		if parts[i-1] != "lib" {
			continue
		}
		seg := parts[i]
		if !strings.HasPrefix(seg, "python") {
			continue
		}
		rest := seg[len("python"):]
		if rest == "" {
			continue
		}
		// Expect a digit after "python" (python3, python3.11, python313, ...)
		if rest[0] < '0' || rest[0] > '9' {
			continue
		}
		// Everything after .../lib/pythonX[.Y]/ is stdlib unless it then
		// descends into site-packages or dist-packages.
		tail := strings.Join(parts[i+1:], "/")
		if tail == "" {
			return true
		}
		if strings.HasPrefix(tail, "site-packages/") || tail == "site-packages" ||
			strings.HasPrefix(tail, "dist-packages/") || tail == "dist-packages" {
			return false
		}
		return true
	}
	return false
}

// extractPass2Dir returns the most specific dependency/source subtree a path
// belongs to, for Pass 2 supply chain scanning. Returns "" when the path does
// not belong to any recognised app or dependency location — in particular it
// NEVER returns broad roots like /usr or /root, which would cause `docker cp`
// to copy thousands of unrelated files (including Python stdlib) into the scan.
func (r *SourceResolver) extractPass2Dir(path, targetNpxPkg string) string {
	// Application source dirs — narrow, safe to copy whole.
	for _, d := range []string{"/app", "/src", "/opt/app"} {
		if strings.HasPrefix(path, d+"/") || path == d {
			return d
		}
	}

	// Python installed deps: return the full site-packages / dist-packages dir.
	// This is the root of the supply chain for uvx/pip-installed servers.
	for _, marker := range []string{"/site-packages/", "/dist-packages/"} {
		if idx := strings.Index(path, marker); idx != -1 {
			return path[:idx+len(marker)-1] // drop trailing slash
		}
	}

	// npm npx cache: isolate to the specific target package (prevents sibling
	// packages hoisted into the same npx bucket from leaking into the scan).
	if strings.Contains(path, "/.npm/_npx/") && strings.Contains(path, "/node_modules/") {
		return extractNpxPackageDir(path, targetNpxPkg)
	}

	// Regular node_modules (npm install, not npx): return the node_modules root
	// — supply chain audit wants to see all npm dependencies together.
	if strings.Contains(path, "/node_modules/") {
		idx := strings.Index(path, "/node_modules/")
		return path[:idx+len("/node_modules")]
	}

	// UV git checkouts — actual source code of a git-installed package.
	if strings.Contains(path, "/.cache/uv/git-v0/checkouts/") {
		parts := strings.Split(path, "/")
		for i, p := range parts {
			if p == "checkouts" && i+2 < len(parts) {
				return strings.Join(parts[:i+3], "/")
			}
		}
	}

	// UV archive cache (wheel cache): /.cache/uv/archive-v0/<hash>/<pkg>/
	if idx := strings.Index(path, "/.cache/uv/archive-v0/"); idx != -1 {
		rest := path[idx+len("/.cache/uv/archive-v0/"):]
		parts := strings.SplitN(rest, "/", 2)
		if parts[0] != "" {
			return path[:idx+len("/.cache/uv/archive-v0/")] + parts[0]
		}
	}

	return ""
}

// CollectFileList walks a directory and returns a list of files (relative paths).
// Caps at MaxScannedFiles entries. Also returns total count and size.
func CollectFileList(dir string) (files []string, totalFiles int, totalSize int64) {
	filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return nil // skip errors
		}
		if info.IsDir() {
			// Skip common uninteresting directories
			name := info.Name()
			if name == ".git" || name == "__pycache__" || name == ".npm" || name == "node_modules" {
				return filepath.SkipDir
			}
			return nil
		}
		totalFiles++
		totalSize += info.Size()
		if len(files) < MaxScannedFiles {
			rel, _ := filepath.Rel(dir, path)
			if rel == "" {
				rel = path
			}
			files = append(files, rel)
		}
		return nil
	})
	return
}

// EnrichWithFileList populates the Files, TotalFiles, TotalSize fields on a ResolvedSource
func (r *SourceResolver) EnrichWithFileList(resolved *ResolvedSource) {
	if resolved.SourceDir == "" {
		return
	}
	resolved.Files, resolved.TotalFiles, resolved.TotalSize = CollectFileList(resolved.SourceDir)
}

// resolveFromPackageCache attempts to find server source in local package manager caches.
// Supports npx (npm), uvx (uv/pip), and pipx.
func (r *SourceResolver) resolveFromPackageCache(ctx context.Context, info ServerInfo) (*ResolvedSource, error) {
	cmdBase := filepath.Base(info.Command)

	switch cmdBase {
	case "npx":
		return r.resolveNpxCache(info)
	case "uvx":
		return r.resolveUvxCache(info)
	}

	return nil, fmt.Errorf("unsupported command %q for package cache resolution", cmdBase)
}

// resolveNpxCache finds an npx package's source in ~/.npm/_npx/*/node_modules/<package>/
func (r *SourceResolver) resolveNpxCache(info ServerInfo) (*ResolvedSource, error) {
	// Extract the target package spec (handles `npx -p <pkg>`, `pnpm dlx <pkg>`,
	// version pins, etc.) then split name from any exact version pin.
	spec := runnerPackageSpec(strings.ToLower(filepath.Base(info.Command)), info.Args)
	if spec == "" {
		return nil, fmt.Errorf("no package name found in npx args")
	}
	pkgName, wantVersion := parsePackageSpec(spec)
	if pkgName == "" {
		return nil, fmt.Errorf("no package name found in npx args")
	}

	// Find npm cache directory
	homeDir, err := os.UserHomeDir()
	if err != nil {
		return nil, fmt.Errorf("cannot determine home directory: %w", err)
	}

	npxCacheDir := filepath.Join(homeDir, ".npm", "_npx")
	if _, err := os.Stat(npxCacheDir); os.IsNotExist(err) {
		return nil, fmt.Errorf("npx cache directory not found: %s", npxCacheDir)
	}

	// Search for the package in npx cache: ~/.npm/_npx/<hash>/node_modules/<package>/
	//
	// The SAME package name can appear under multiple npx cache hashes. One may
	// hold the real installed source (package.json + dist/lib/*.js) while another
	// holds only a tools.json stub that mcpproxy itself wrote into the cache when
	// it dumped tool definitions. The stub's mtime is frequently NEWER than the
	// real source (it was just written), so a naive newest-mtime pick selects the
	// stub and the scan reports a false "1 file" coverage (MCP-2397). We therefore
	// prefer candidates that look like real package source, and only fall back to
	// the newest-mtime tiebreak among candidates of the same class. When the spec
	// carries an exact version pin, a candidate whose package.json declares that
	// version is preferred over a newer-but-mismatched install (MCP-2445).
	var bestMatch string
	var bestModTime int64
	var bestReal bool
	var bestVerMatch bool

	entries, err := os.ReadDir(npxCacheDir)
	if err != nil {
		return nil, fmt.Errorf("cannot read npx cache: %w", err)
	}

	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}
		candidatePath := filepath.Join(npxCacheDir, entry.Name(), "node_modules", pkgName)
		stat, err := os.Stat(candidatePath)
		if err != nil || !stat.IsDir() {
			continue
		}
		real := npxCacheLooksReal(candidatePath)
		verMatch := npxCacheVersionMatches(candidatePath, wantVersion)
		modTime := stat.ModTime().Unix()

		// Selection order (most to least significant):
		//  1. any real-source candidate beats any stub;
		//  2. a version-pin match beats a mismatch;
		//  3. within the same class, the newest mtime wins.
		better := false
		switch {
		case bestMatch == "":
			better = true
		case real != bestReal:
			better = real
		case verMatch != bestVerMatch:
			better = verMatch
		default:
			better = modTime > bestModTime
		}
		if better {
			bestMatch = candidatePath
			bestModTime = modTime
			bestReal = real
			bestVerMatch = verMatch
		}
	}

	if bestMatch == "" {
		return nil, fmt.Errorf("package %q not found in npx cache (%s)", pkgName, npxCacheDir)
	}

	if wantVersion != "" && !bestVerMatch {
		// An exact version was pinned but no cached candidate declares it. Do NOT
		// substitute a different cached version — scanning the wrong code reports
		// false coverage (MCP-2445). Defer to the published-source fetch fallback,
		// which fetches the PINNED version, or degrade to tool_definitions_only.
		r.logger.Warn("npx cache has the package but not the pinned version; deferring to published-source fetch",
			zap.String("server", info.Name),
			zap.String("package", pkgName),
			zap.String("pinned_version", wantVersion),
		)
		return nil, fmt.Errorf("npx cache for %q has no entry matching pinned version %q (avoiding mismatched-version scan)", pkgName, wantVersion)
	}

	if !bestReal {
		// Every candidate was a bare stub (e.g. a lone tools.json mcpproxy wrote
		// into the cache) — no real installed source exists locally. Returning the
		// stub here would report false "1 file" coverage AND, post-MCP-2206, would
		// short-circuit the caller's published-source fetch fallback in Resolve(),
		// which can fetch the REAL package source without executing it. So we treat
		// stub-only as "not found" and let that fallback (or a tool_definitions_only
		// degrade when fetch is disabled) take over instead.
		r.logger.Warn("npx cache held only stub directories (no real package source); deferring to published-source fetch fallback",
			zap.String("server", info.Name),
			zap.String("package", pkgName),
			zap.String("stub_path", bestMatch),
		)
		return nil, fmt.Errorf("npx cache for %q contained only stub directories (no real source) in %s", pkgName, npxCacheDir)
	}

	r.logger.Info("Resolved source from npx cache",
		zap.String("server", info.Name),
		zap.String("package", pkgName),
		zap.String("path", bestMatch),
	)

	return &ResolvedSource{
		SourceDir: bestMatch,
		Method:    "npx_cache",
		Cleanup:   func() {},
	}, nil
}

// npxCacheVersionMatches reports whether the package.json at dir declares exactly
// the given version. Returns false when version is empty or the file is missing /
// unparseable. Used to prefer a version-pinned install over a newer-but-
// mismatched one in a multi-version npx cache.
func npxCacheVersionMatches(dir, version string) bool {
	if version == "" {
		return false
	}
	data, err := os.ReadFile(filepath.Join(dir, "package.json"))
	if err != nil {
		return false
	}
	var pj struct {
		Version string `json:"version"`
	}
	if err := json.Unmarshal(data, &pj); err != nil {
		return false
	}
	return pj.Version == version
}

// npxCacheLooksReal reports whether an npx cache package directory holds the
// real installed package rather than a bare stub. Every npm package ships a
// package.json, so its presence is the canonical marker of real source. A stub
// directory mcpproxy creates just to hold a dumped tools.json has none. As a
// secondary signal we also accept a directory that contains a conventional
// build/source subdirectory (dist/lib/build/src) or any JavaScript/TypeScript
// source file, in case an unusual package omits package.json at the cache root.
func npxCacheLooksReal(dir string) bool {
	if _, err := os.Stat(filepath.Join(dir, "package.json")); err == nil {
		return true
	}
	for _, sub := range []string{"dist", "lib", "build", "src"} {
		if stat, err := os.Stat(filepath.Join(dir, sub)); err == nil && stat.IsDir() {
			return true
		}
	}
	entries, err := os.ReadDir(dir)
	if err != nil {
		return false
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		switch strings.ToLower(filepath.Ext(e.Name())) {
		case ".js", ".mjs", ".cjs", ".ts":
			return true
		}
	}
	return false
}

// resolveUvxCache finds a uvx package's source in ~/.cache/uv/ or ~/.local/share/uv/tools/
func (r *SourceResolver) resolveUvxCache(info ServerInfo) (*ResolvedSource, error) {
	// Extract the target package spec. runnerPackageSpec handles `uvx <pkg>`,
	// `uvx --from <pkg> <cmd>`, `uvx git+<url>`, and crucially skips `--with <dep>`
	// / `-p <python>` values so the additional dependency or python version is not
	// mistaken for the target (MCP-2445).
	pkgName := runnerPackageSpec(strings.ToLower(filepath.Base(info.Command)), info.Args)
	if pkgName == "" {
		return nil, fmt.Errorf("no package name found in uvx args")
	}

	// Check if it's a git URL: git+https://github.com/...
	isGitURL := strings.HasPrefix(pkgName, "git+")

	// Exact version pin (if any). Only meaningful for registry packages — git
	// specs pin via the URL ref, not a PEP 440 version.
	wantVersion := ""
	if !isGitURL {
		_, wantVersion = parsePackageSpec(pkgName)
	}

	homeDir, err := os.UserHomeDir()
	if err != nil {
		return nil, fmt.Errorf("cannot determine home directory: %w", err)
	}

	// Strategy 1: UV git checkouts (for git+URL packages)
	// The cache structure is: ~/.cache/uv/git-v0/checkouts/<hash>/<rev>/
	// We must match the checkout to the actual git URL by checking for repo-specific files,
	// not just picking the newest subdirectory (which could be from a different package).
	if isGitURL {
		gitCheckoutsDir := filepath.Join(homeDir, ".cache", "uv", "git-v0", "checkouts")
		// Extract repo name from git URL for matching: git+https://github.com/org/repo → repo
		repoName := ""
		gitURL := strings.TrimPrefix(pkgName, "git+")
		if parts := strings.Split(strings.TrimSuffix(gitURL, ".git"), "/"); len(parts) > 0 {
			repoName = parts[len(parts)-1]
		}
		if dir, err := r.findGitCheckoutByRepo(gitCheckoutsDir, repoName, pkgName); err == nil {
			r.logger.Info("Resolved source from UV git checkout",
				zap.String("server", info.Name),
				zap.String("repo", repoName),
				zap.String("path", dir),
			)
			return &ResolvedSource{
				SourceDir: dir,
				Method:    "uvx_cache",
				Cleanup:   func() {},
			}, nil
		}
	}

	// Strategy 2: UV tools directory (for regular packages). Reduce the spec to a
	// bare distribution name: strip a git+ URL to its repo, otherwise strip the
	// `@version` form AND any PEP 440 specifier (`==`, `>=`, extras …) — the latter
	// was previously missed, so a `pkg==1.0` spec produced a bogus `tools/pkg==1.0`
	// path that never matched.
	cleanPkg := pkgName
	if strings.HasPrefix(cleanPkg, "git+") {
		// Extract package name from URL: git+https://github.com/org/repo → repo
		parts := strings.Split(cleanPkg, "/")
		if len(parts) > 0 {
			cleanPkg = parts[len(parts)-1]
		}
	} else {
		if idx := strings.LastIndex(cleanPkg, "@"); idx > 0 {
			cleanPkg = cleanPkg[:idx]
		}
		cleanPkg = stripPkgVersion(cleanPkg)
	}

	toolsDir := filepath.Join(homeDir, ".local", "share", "uv", "tools", cleanPkg)
	if stat, err := os.Stat(toolsDir); err == nil && stat.IsDir() {
		// When a version is pinned, only accept the tools dir if it actually holds
		// that version — otherwise fall through so the published-source fetch gets
		// the PINNED version instead of scanning whatever was `uv tool install`-ed
		// (MCP-2445: never substitute a mismatched version).
		if wantVersion == "" || uvDirHasVersion(toolsDir, stripPkgVersion(cleanPkg), wantVersion) {
			r.logger.Info("Resolved source from UV tools directory",
				zap.String("server", info.Name),
				zap.String("package", cleanPkg),
				zap.String("path", toolsDir),
			)
			return &ResolvedSource{
				SourceDir: toolsDir,
				Method:    "uvx_cache",
				Cleanup:   func() {},
			}, nil
		}
		r.logger.Warn("UV tools dir present but not the pinned version; deferring to archive/published-source fetch",
			zap.String("server", info.Name),
			zap.String("package", cleanPkg),
			zap.String("pinned_version", wantVersion),
		)
	}

	// Strategy 3: ephemeral uvx archive cache (~/.cache/uv/archive-v0/<hash>/).
	// `uvx <pkg>` (the common case) never populates the persistent tools dir
	// above — it unpacks the published wheel into a content-addressed archive
	// entry keyed by an opaque hash, not by package name. So a server that HAS
	// been run locally still missed both strategies above and fell through to a
	// tool-definitions-only scan (or, post-MCP-2206, a redundant network fetch).
	// The container resolver (findContainerTargetDir) already searches this
	// cache; the host resolver now does too. Found by globbing every archive
	// entry and matching the wheel's `.dist-info` (robust to dist-name vs
	// import-name differences). git+URL packages live in git-v0 (Strategy 1),
	// not archive-v0, so they are skipped here.
	if !isGitURL {
		archiveRoot := filepath.Join(homeDir, ".cache", "uv", "archive-v0")
		// Honor an exact version pin: select the archive entry whose .dist-info
		// declares the pinned version; a pin-miss resolves nothing (MCP-2445).
		if dir, ok := findUvxArchiveDir(archiveRoot, stripPkgVersion(cleanPkg), wantVersion); ok {
			r.logger.Info("Resolved source from UV archive cache",
				zap.String("server", info.Name),
				zap.String("package", cleanPkg),
				zap.String("path", dir),
			)
			return &ResolvedSource{
				SourceDir: dir,
				Method:    "uvx_cache",
				Cleanup:   func() {},
			}, nil
		}
	}

	return nil, fmt.Errorf("package %q not found in UV cache", pkgName)
}

// stripPkgVersion trims a PEP 508 version specifier and/or extras from a
// package spec, leaving the bare distribution name. Handles `pkg==1.0`,
// `pkg>=1.0`, `pkg~=1.0`, `pkg!=1.0`, `pkg[extra]`, and a trailing space/marker.
// (The `@version` form is already stripped earlier by the caller.)
func stripPkgVersion(spec string) string {
	cut := len(spec)
	for _, c := range []string{"==", ">=", "<=", "~=", "!=", ">", "<", "[", " ", ";"} {
		if idx := strings.Index(spec, c); idx >= 0 && idx < cut {
			cut = idx
		}
	}
	return strings.TrimSpace(spec[:cut])
}

// normalizeDistName applies PEP 503 / wheel normalization to a Python
// distribution name: lowercase, with runs of "-", "_" and "." collapsed to a
// single "_". This matches the form uv writes for `.dist-info` directory names
// (e.g. "My.Cool-Server" → "my_cool_server"), so a config's free-form package
// spec can be matched against on-disk wheels.
func normalizeDistName(name string) string {
	n := strings.ToLower(name)
	n = strings.NewReplacer("-", "_", ".", "_").Replace(n)
	for strings.Contains(n, "__") {
		n = strings.ReplaceAll(n, "__", "_")
	}
	return strings.Trim(n, "_")
}

// findUvxArchiveDir locates the unpacked wheel for distribution pkg inside the
// uv ephemeral archive cache. Each archive entry (~/.cache/uv/archive-v0/<hash>)
// holds exactly one unpacked wheel, content-addressed by an opaque hash — so the
// package is found by scanning entries and matching the wheel's `.dist-info`
// directory rather than by constructing a name-based path. Both the flat layout
// (<hash>/<dist>-<ver>.dist-info) and the venv-style layout
// (<hash>/lib/python*/site-packages/<dist>-<ver>.dist-info) are supported. When
// multiple entries match (e.g. several cached versions) a wantVersion match wins
// first; otherwise the most recently modified entry wins, consistent with
// resolveNpxCache. wantVersion may be "" (no pin). Returns the archive entry root
// (the <hash> dir) and ok=true on a hit.
func findUvxArchiveDir(archiveRoot, pkg, wantVersion string) (string, bool) {
	norm := normalizeDistName(pkg)
	if norm == "" {
		return "", false
	}
	entries, err := os.ReadDir(archiveRoot)
	if err != nil {
		return "", false
	}

	var best string
	var bestMod int64
	var bestVerMatch bool
	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}
		entryPath := filepath.Join(archiveRoot, entry.Name())
		// Check the flat layout, then any venv-style site-packages dirs.
		matchDirs := []string{entryPath}
		if sp, _ := filepath.Glob(filepath.Join(entryPath, "lib", "python*", "site-packages")); len(sp) > 0 {
			matchDirs = append(matchDirs, sp...)
		}
		matched := false
		verMatch := false
		for _, d := range matchDirs {
			nm, vm := distInfoMatch(d, norm, wantVersion)
			if nm {
				matched = true
				if vm {
					verMatch = true
				}
			}
		}
		if !matched {
			continue
		}
		modTime := int64(0)
		if fi, err := entry.Info(); err == nil {
			modTime = fi.ModTime().Unix()
		}
		// Selection: a version-pin match beats a mismatch; then newest mtime wins.
		better := false
		switch {
		case best == "":
			better = true
		case verMatch != bestVerMatch:
			better = verMatch
		default:
			better = modTime > bestMod
		}
		if better {
			best = entryPath
			bestMod = modTime
			bestVerMatch = verMatch
		}
	}
	if best == "" {
		return "", false
	}
	if wantVersion != "" && !bestVerMatch {
		// Pinned version requested but no archive entry declares it. Do not
		// substitute a different cached version (false coverage) — report not found
		// so the caller fetches the pinned version or degrades (MCP-2445).
		return "", false
	}
	return best, true
}

// uvDirHasVersion reports whether a uv venv-style directory (a `uv tool install`
// tools dir) holds distribution pkg at exactly version. It looks for a matching
// `<dist>-<version>.dist-info` under any `lib/python*/site-packages`. Used to keep
// a version pin honest against the persistent tools dir.
func uvDirHasVersion(root, pkg, version string) bool {
	if version == "" {
		return false
	}
	norm := normalizeDistName(pkg)
	if norm == "" {
		return false
	}
	sps, _ := filepath.Glob(filepath.Join(root, "lib", "python*", "site-packages"))
	for _, d := range sps {
		if _, vm := distInfoMatch(d, norm, version); vm {
			return true
		}
	}
	return false
}

// distInfoMatch reports whether dir directly contains a `<name>-<version>.dist-info`
// directory whose normalized distribution name equals norm (nameMatch), and — when
// wantVersion is non-empty — whether that directory's version equals wantVersion
// (versionMatch). A wheel's `.dist-info` name is
// `{normalized-distribution}-{version}.dist-info`, and the normalized distribution
// name contains no "-" (those become "_"), so the FIRST "-" cleanly separates name
// from version.
func distInfoMatch(dir, norm, wantVersion string) (nameMatch, versionMatch bool) {
	es, err := os.ReadDir(dir)
	if err != nil {
		return false, false
	}
	for _, e := range es {
		if !e.IsDir() {
			continue
		}
		name := e.Name()
		if !strings.HasSuffix(strings.ToLower(name), ".dist-info") {
			continue
		}
		base := name[:len(name)-len(".dist-info")]
		idx := strings.Index(base, "-")
		if idx <= 0 {
			continue
		}
		if normalizeDistName(base[:idx]) != norm {
			continue
		}
		nameMatch = true
		if wantVersion != "" && base[idx+1:] == wantVersion {
			// Exact version match — best possible, return immediately.
			return true, true
		}
	}
	return nameMatch, versionMatch
}

// findGitCheckoutByRepo searches UV git checkouts for a directory that matches the given repo.
// It walks checkouts/<hash>/<rev>/ directories and checks for repo-identifying markers
// (pyproject.toml with matching name, or .git/config with matching URL).
func (r *SourceResolver) findGitCheckoutByRepo(checkoutsDir, repoName, gitURL string) (string, error) {
	if _, err := os.Stat(checkoutsDir); os.IsNotExist(err) {
		return "", err
	}

	gitURL = strings.TrimPrefix(gitURL, "git+")

	hashDirs, err := os.ReadDir(checkoutsDir)
	if err != nil {
		return "", err
	}

	var bestPath string
	var bestModTime int64

	for _, hashDir := range hashDirs {
		if !hashDir.IsDir() {
			continue
		}
		hashPath := filepath.Join(checkoutsDir, hashDir.Name())
		revDirs, err := os.ReadDir(hashPath)
		if err != nil {
			continue
		}
		for _, revDir := range revDirs {
			if !revDir.IsDir() {
				continue
			}
			candidate := filepath.Join(hashPath, revDir.Name())

			// Check pyproject.toml for package name match
			pyproject, err := os.ReadFile(filepath.Join(candidate, "pyproject.toml"))
			if err == nil {
				content := string(pyproject)
				// Match repo name in project name (handles hyphens/underscores)
				normalizedRepo := strings.ReplaceAll(strings.ToLower(repoName), "-", "[_-]")
				if matched, _ := filepath.Match("*"+strings.ToLower(repoName)+"*", strings.ToLower(content)); matched ||
					strings.Contains(strings.ToLower(content), strings.ToLower(repoName)) ||
					strings.Contains(strings.ToLower(content), strings.ReplaceAll(strings.ToLower(repoName), "-", "_")) {
					_ = normalizedRepo
					info, err := revDir.Info()
					if err == nil && info.ModTime().Unix() > bestModTime {
						bestModTime = info.ModTime().Unix()
						bestPath = candidate
					}
					continue
				}
			}

			// Fallback: check .git/config for URL match
			gitConfig, err := os.ReadFile(filepath.Join(candidate, ".git", "config"))
			if err == nil && strings.Contains(string(gitConfig), gitURL) {
				info, err := revDir.Info()
				if err == nil && info.ModTime().Unix() > bestModTime {
					bestModTime = info.ModTime().Unix()
					bestPath = candidate
				}
			}
		}
	}

	if bestPath == "" {
		return "", fmt.Errorf("no git checkout found matching repo %q", repoName)
	}
	return bestPath, nil
}
