package autoimport

import (
	"cmp"
	"context"
	"maps"
	"slices"
	"strings"
	"sync"
	"time"

	"github.com/microsoft/typescript-go/internal/ast"
	"github.com/microsoft/typescript-go/internal/binder"
	"github.com/microsoft/typescript-go/internal/checker"
	"github.com/microsoft/typescript-go/internal/collections"
	"github.com/microsoft/typescript-go/internal/compiler"
	"github.com/microsoft/typescript-go/internal/core"
	"github.com/microsoft/typescript-go/internal/ls/lsconv"
	"github.com/microsoft/typescript-go/internal/ls/lsutil"
	"github.com/microsoft/typescript-go/internal/lsp/lsproto"
	"github.com/microsoft/typescript-go/internal/module"
	"github.com/microsoft/typescript-go/internal/packagejson"
	"github.com/microsoft/typescript-go/internal/project/dirty"
	"github.com/microsoft/typescript-go/internal/project/logging"
	"github.com/microsoft/typescript-go/internal/symlinks"
	"github.com/microsoft/typescript-go/internal/tspath"
	"github.com/microsoft/typescript-go/internal/vfs"
	"github.com/microsoft/typescript-go/internal/vfs/vfsmatch"
)

var knownRecursiveSearchPackages = collections.NewSetFromItems(
	"@material-ui/core",
	"@material-ui/icons",
	"@sap/cds",
	"@testing-library/react-native",
	"ajv",
	"asap",
	"async",
	"aws-sdk",
	"braintree-web",
	"core-js",
	"core-js-pure",
	"crypto-js",
	"cypress-mochawesome-reporter",
	"dd-trace",
	"dumi",
	"dva",
	"egg-mock",
	"electron-log",
	"es-abstract",
	"es6-promise",
	"eslint-config-taro",
	"expo",
	"expo-router",
	"flow-remove-types",
	"gatsby",
	"glamor",
	"gluegun",
	"graphology-indices",
	"graphology-traversal",
	"graphology-utils",
	"jest-expo",
	"lodash",
	"lodash-es",
	"moment",
	"mz",
	"next",
	"pdfjs-dist",
	"protobufjs",
	"react-app-polyfill",
	"react-dev-utils",
	"react-devtools-inline",
	"recast",
	"semver",
	"stylelint-config-html",
	"umi",
	"web3-provider-engine",
	"webpack",
)

type newProgramStructure int

const (
	newProgramStructureFalse newProgramStructure = iota
	newProgramStructureSameFileNames
	newProgramStructureDifferentFileNames
)

// bucketBuildPreferences holds user preferences that affect how a bucket is
// built. When any of these change between builds, the bucket must be rebuilt.
// Adding a new preference here automatically integrates it into the rebuild
// checks via Equal.
type bucketBuildPreferences struct {
	fileExcludePatterns                 []string
	autoImportEntrypointDirectorySearch core.Tristate
}

func bucketBuildPreferencesFromUserPreferences(prefs lsutil.UserPreferences) bucketBuildPreferences {
	return bucketBuildPreferences{
		fileExcludePatterns:                 prefs.AutoImportFileExcludePatterns,
		autoImportEntrypointDirectorySearch: prefs.AutoImportEntrypointDirectorySearch,
	}
}

func (p bucketBuildPreferences) Equal(other bucketBuildPreferences) bool {
	return core.UnorderedEqual(p.fileExcludePatterns, other.fileExcludePatterns) &&
		p.autoImportEntrypointDirectorySearch == other.autoImportEntrypointDirectorySearch
}

func (p bucketBuildPreferences) Clone() bucketBuildPreferences {
	p.fileExcludePatterns = slices.Clone(p.fileExcludePatterns)
	return p
}

// BucketState represents the dirty state of a bucket.
// In general, a bucket can be used for an auto-imports request if it is clean
// or if the only edited file is the one that was requested for auto-imports.
// Most edits within a file will not change the imports available to that file.
// However, one exception causes the bucket to be rebuilt after a change to a
// single file: local files are newly added to the project by a manual import.
// This can only happen after a full (non-clone) program update. When this
// happens, the `newProgramStructure` flag is set until the next time the bucket
// is rebuilt, when this condition will be checked.
type BucketState struct {
	// dirtyFile is the file that was edited last, if any. It does not necessarily
	// indicate that no other files have been edited, so it should be ignored if
	// `multipleFilesDirty` is set. It should not be used for node_modules buckets,
	// which rely on `dirtyPackages` instead.
	dirtyFile           tspath.Path
	multipleFilesDirty  bool
	newProgramStructure newProgramStructure
	// buildPreferences holds the user preferences that were in effect when
	// the bucket was built. If changed, the bucket should be rebuilt.
	buildPreferences bucketBuildPreferences
	// dirtyPackages is the set of package names that need to be re-indexed.
	// This is used for granular updates: when a file in a local workspace package
	// changes, only that package needs to be re-extracted rather than rebuilding
	// the entire node_modules bucket.
	// If nil, no granular updates are pending.
	// If set but multipleFilesDirty is true, the entire bucket needs to be rebuilt.
	dirtyPackages *collections.Set[string]
	// recursiveSearchPackages tracks which packages were recursively directory-searched
	// when the bucket was built. nil means all non-exports packages were searched
	// (e.g. when the autoImportEntrypointDirectorySearch preference is enabled).
	// A non-nil set lists only the specific packages that were searched.
	// Used for rebuild detection: a rebuild is triggered when target packages are
	// not a subset of the currently searched packages.
	recursiveSearchPackages *collections.Set[string]
}

func (b BucketState) Clone() BucketState {
	b.buildPreferences = b.buildPreferences.Clone()
	b.dirtyPackages = b.dirtyPackages.Clone()
	b.recursiveSearchPackages = b.recursiveSearchPackages.Clone()
	return b
}

func (b BucketState) Dirty() bool {
	return b.multipleFilesDirty || b.dirtyFile != "" || b.newProgramStructure > 0 || b.dirtyPackages.Len() > 0
}

func (b BucketState) DirtyFile() tspath.Path {
	if b.multipleFilesDirty {
		return ""
	}
	return b.dirtyFile
}

func (b BucketState) DirtyPackages() *collections.Set[string] {
	if b.multipleFilesDirty {
		return nil
	}
	return b.dirtyPackages
}

func (b BucketState) RecursiveSearchPackages() *collections.Set[string] {
	return b.recursiveSearchPackages
}

func (b BucketState) possiblyNeedsRebuildForFile(file tspath.Path, preferences lsutil.UserPreferences) bool {
	return b.newProgramStructure > 0 ||
		b.hasDirtyFileBesides(file) ||
		!b.buildPreferences.Equal(bucketBuildPreferencesFromUserPreferences(preferences)) ||
		b.dirtyPackages.Len() > 0
}

func (b BucketState) hasDirtyFileBesides(file tspath.Path) bool {
	return b.multipleFilesDirty || b.dirtyFile != "" && b.dirtyFile != file
}

// recursiveSearchSubset reports whether target is a subset of current.
// nil represents "all packages" — a superset of every concrete set.
// Returns true if the current set already covers everything the target needs,
// meaning no rebuild is required for recursive search purposes.
func recursiveSearchSubset(target, current *collections.Set[string]) bool {
	if target == nil {
		// Target wants all packages searched — only satisfied if current is also all.
		return current == nil
	}
	if current == nil {
		// Current searched all packages, so any concrete target is satisfied.
		return true
	}
	return target.IsSubsetOf(current)
}

type RegistryBucket struct {
	state BucketState

	// Paths maps file paths to package names. For project buckets, the package name
	// is always empty string. For node_modules buckets, this enables reverse lookup
	// from path to package for granular updates. Only paths for local workspace
	// packages (symlinked and within the workspace root) have entries here, since
	// their realpaths are outside node_modules and need reverse lookup for dirty
	// detection.
	//
	// Paths is considered immutable after the bucket is finalized.
	// It should be fully replaced rather than mutated while changing a bucket.
	Paths map[tspath.Path]string
	// PackageFiles maps package names to their file paths and file names.
	// All package directory names in node_modules are keys; indexed packages have
	// non-nil maps with path→fileName entries, unindexed packages have nil maps.
	// This enables efficient removal of a package's files during granular updates
	// without iterating through all entries. Only defined for node_modules buckets.
	//
	// PackageFiles is considered immutable after the bucket is finalized.
	// It should be fully replaced rather than mutated while changing a bucket.
	PackageFiles map[string]map[tspath.Path]string
	// ResolvedPackageNames is only defined for project buckets. It is the set of
	// package names that were resolved from imports in the project's program files.
	// This is passed to node_modules buckets so they include packages that are
	// directly imported even if not listed in package.json dependencies.
	//
	// ResolvedPackageNames is considered immutable after the bucket is finalized.
	// It should be fully replaced rather than mutated while changing a bucket.
	ResolvedPackageNames *collections.Set[string]
	// DependencyNames is only defined for node_modules buckets. It is the set of
	// package names that will be included in the bucket if present in the directory,
	// computed from package.json dependencies plus resolved package names from
	// active programs. If nil, all packages are included because at least one open
	// file has access to this node_modules directory without being filtered by a
	// package.json.
	//
	// DependencyNames is considered immutable after the bucket is finalized.
	// It should be fully replaced rather than mutated while changing a bucket.
	DependencyNames *collections.Set[string]
	// AmbientModuleNames is only defined for node_modules buckets. It is the set of
	// ambient module names found while extracting exports in the bucket.
	//
	// AmbientModuleNames is considered immutable after the bucket is finalized.
	// It should be fully replaced rather than mutated while changing a bucket.
	AmbientModuleNames map[string][]string
	// Index is considered immutable after the bucket is finalized.
	// It should be cloned and replaced rather than mutated while changing a bucket.
	Index *Index[*Export]
}

func newRegistryBucket() *RegistryBucket {
	return &RegistryBucket{
		state: BucketState{
			multipleFilesDirty:  true,
			newProgramStructure: newProgramStructureDifferentFileNames,
		},
	}
}

func (b *RegistryBucket) Clone() *RegistryBucket {
	return &RegistryBucket{
		state:                b.state.Clone(),
		Paths:                b.Paths,
		PackageFiles:         b.PackageFiles,
		ResolvedPackageNames: b.ResolvedPackageNames,
		DependencyNames:      b.DependencyNames,
		AmbientModuleNames:   b.AmbientModuleNames,
		Index:                b.Index,
	}
}

// markProjectFileDirty should only be called within a Change call on the dirty map.
// Buckets are considered immutable once in a finalized registry. Should only
// be used for project buckets.
func (b *RegistryBucket) markProjectFileDirty(file tspath.Path) {
	if b.state.hasDirtyFileBesides(file) {
		b.state.multipleFilesDirty = true
	} else {
		b.state.dirtyFile = file
	}
}

// markNodeModulesDirty should only be called within a Change call on the dirty map.
// Buckets are considered immutable once in a finalized registry. If packageName is
// non-empty, that package is marked for granular update. Otherwise, the entire bucket
// is marked dirty.
func (b *RegistryBucket) markNodeModulesDirty(packageName string) {
	if b.state.multipleFilesDirty {
		return
	}
	if packageName == "" {
		b.state.multipleFilesDirty = true
		return
	}
	// Track the package for granular updates
	if b.state.dirtyPackages == nil {
		b.state.dirtyPackages = &collections.Set[string]{}
	}
	b.state.dirtyPackages.Add(packageName)
}

type directory struct {
	name           string
	packageJson    *packagejson.InfoCacheEntry
	hasNodeModules bool
}

func (d *directory) Clone() *directory {
	return &directory{
		name:           d.name,
		packageJson:    d.packageJson,
		hasNodeModules: d.hasNodeModules,
	}
}

type Registry struct {
	toPath          func(fileName string) tspath.Path
	userPreferences lsutil.UserPreferences

	// exports      map[tspath.Path][]*RawExport
	directories map[tspath.Path]*directory

	nodeModules        map[tspath.Path]*RegistryBucket
	projects           map[tspath.Path]*RegistryBucket
	uniquePackageCount int

	// entrypoints maps from file path to the resolved entrypoints for that file, shared across all node_modules buckets.
	entrypoints map[tspath.Path][]*module.ResolvedEntrypoint

	// specifierCache maps from importing file to target file to specifier.
	specifierCache map[tspath.Path]*collections.SyncMap[tspath.Path, string]
}

func NewRegistry(toPath func(fileName string) tspath.Path, preferences lsutil.UserPreferences) *Registry {
	return &Registry{
		toPath:          toPath,
		userPreferences: preferences,
		directories:     make(map[tspath.Path]*directory),
	}
}

func (r *Registry) IsPreparedForImportingFile(fileName string, projectPath tspath.Path, preferences lsutil.UserPreferences) bool {
	if r == nil {
		return false
	}
	projectBucket, ok := r.projects[projectPath]
	if !ok {
		return false
	}
	path := r.toPath(fileName)
	if projectBucket.state.possiblyNeedsRebuildForFile(path, preferences) {
		return false
	}

	dirPath := path.GetDirectoryPath()
	for {
		if dirBucket, ok := r.nodeModules[dirPath]; ok {
			if dirBucket.state.possiblyNeedsRebuildForFile(path, preferences) {
				return false
			}
		}
		parent := dirPath.GetDirectoryPath()
		if parent == dirPath {
			break
		}
		dirPath = parent
	}
	return true
}

func (r *Registry) NodeModulesDirectories() map[tspath.Path]string {
	dirs := make(map[tspath.Path]string)
	for dirPath, dir := range r.directories {
		if dir.hasNodeModules {
			dirs[tspath.Path(tspath.CombinePaths(string(dirPath), "node_modules"))] = tspath.CombinePaths(dir.name, "node_modules")
		}
	}
	return dirs
}

func (r *Registry) Clone(ctx context.Context, change RegistryChange, host RegistryCloneHost, logger *logging.LogTree) (*Registry, error) {
	start := time.Now()
	if logger != nil {
		logger = logger.Fork("Building autoimport registry")
	}
	builder := newRegistryBuilder(r, host)
	if change.UserPreferences != nil {
		builder.userPreferences = *change.UserPreferences
		if !core.UnorderedEqual(builder.userPreferences.AutoImportSpecifierExcludeRegexes, r.userPreferences.AutoImportSpecifierExcludeRegexes) {
			builder.specifierCache.Clear()
		}
	}
	builder.updateBucketAndDirectoryExistence(change, logger)
	builder.markBucketsDirty(change, logger)
	if change.RequestedFile != "" {
		builder.updateIndexes(ctx, change, logger)
	}
	if logger != nil {
		logger.Logf("Built autoimport registry in %v", time.Since(start))
	}
	registry := builder.Build()
	return registry, nil
}

type BucketStats struct {
	Path            tspath.Path
	ExportCount     int
	FileCount       int
	State           BucketState
	DependencyNames *collections.Set[string]
	PackageNames    *collections.Set[string]
}

type CacheStats struct {
	ProjectBuckets     []BucketStats
	NodeModulesBuckets []BucketStats
	UniquePackageCount int
}

func (r *Registry) GetCacheStats() *CacheStats {
	stats := &CacheStats{
		UniquePackageCount: r.uniquePackageCount,
	}

	for path, bucket := range r.projects {
		exportCount := 0
		if bucket.Index != nil {
			exportCount = len(bucket.Index.entries)
		}
		stats.ProjectBuckets = append(stats.ProjectBuckets, BucketStats{
			Path:            path,
			ExportCount:     exportCount,
			FileCount:       len(bucket.Paths),
			State:           bucket.state,
			DependencyNames: bucket.DependencyNames,
			PackageNames:    nil,
		})
	}

	for path, bucket := range r.nodeModules {
		exportCount := 0
		if bucket.Index != nil {
			exportCount = len(bucket.Index.entries)
		}
		// Derive PackageNames from PackageFiles keys
		var packageNames *collections.Set[string]
		fileCount := 0
		if bucket.PackageFiles != nil {
			packageNames = collections.NewSetWithSizeHint[string](len(bucket.PackageFiles))
			for name, paths := range bucket.PackageFiles {
				packageNames.Add(name)
				fileCount += len(paths)
			}
		}
		stats.NodeModulesBuckets = append(stats.NodeModulesBuckets, BucketStats{
			Path:            path,
			ExportCount:     exportCount,
			FileCount:       fileCount,
			State:           bucket.state,
			DependencyNames: bucket.DependencyNames,
			PackageNames:    packageNames,
		})
	}

	slices.SortFunc(stats.ProjectBuckets, func(a, b BucketStats) int {
		return cmp.Compare(a.Path, b.Path)
	})
	slices.SortFunc(stats.NodeModulesBuckets, func(a, b BucketStats) int {
		return cmp.Compare(a.Path, b.Path)
	})

	return stats
}

type RegistryChange struct {
	RequestedFile tspath.Path
	OpenFiles     map[tspath.Path]string
	Changed       collections.Set[lsproto.DocumentUri]
	Created       collections.Set[lsproto.DocumentUri]
	Deleted       collections.Set[lsproto.DocumentUri]
	// RebuiltPrograms maps from project path to:
	//   - true: the program was rebuilt with a different set of file names
	//   - false: the program was rebuilt but the set of file names is unchanged
	RebuiltPrograms map[tspath.Path]bool
	UserPreferences *lsutil.UserPreferences
}

type RegistryCloneHost interface {
	module.ResolutionHost
	FS() vfs.FS
	GetDefaultProject(path tspath.Path) (tspath.Path, *compiler.Program)
	GetProgramForProject(projectPath tspath.Path) *compiler.Program
	GetPackageJson(fileName string) *packagejson.InfoCacheEntry
	GetSourceFile(fileName string, path tspath.Path) *ast.SourceFile
	Dispose()
}

type registryBuilder struct {
	host RegistryCloneHost
	base *Registry

	userPreferences lsutil.UserPreferences
	directories     *dirty.Map[tspath.Path, *directory]
	nodeModules     *dirty.Map[tspath.Path, *RegistryBucket]
	projects        *dirty.Map[tspath.Path, *RegistryBucket]
	specifierCache  *dirty.MapBuilder[tspath.Path, *collections.SyncMap[tspath.Path, string], *collections.SyncMap[tspath.Path, string]]
	resolverOptions module.ResolverOptions

	uniquePackageCount int
	entrypoints        *dirty.MapBuilder[tspath.Path, []*module.ResolvedEntrypoint, []*module.ResolvedEntrypoint]
}

func newRegistryBuilder(registry *Registry, host RegistryCloneHost) *registryBuilder {
	return &registryBuilder{
		host: host,
		base: registry,

		userPreferences:    registry.userPreferences,
		directories:        dirty.NewMap(registry.directories),
		nodeModules:        dirty.NewMap(registry.nodeModules),
		projects:           dirty.NewMap(registry.projects),
		specifierCache:     dirty.NewMapBuilder(registry.specifierCache, core.Identity, core.Identity),
		uniquePackageCount: registry.uniquePackageCount,
		entrypoints:        dirty.NewMapBuilder(registry.entrypoints, core.Identity, core.Identity),
	}
}

func (b *registryBuilder) Build() *Registry {
	return &Registry{
		toPath:             b.base.toPath,
		userPreferences:    b.userPreferences,
		directories:        core.FirstResult(b.directories.Finalize()),
		nodeModules:        core.FirstResult(b.nodeModules.Finalize()),
		projects:           core.FirstResult(b.projects.Finalize()),
		specifierCache:     core.FirstResult(b.specifierCache.Build()),
		uniquePackageCount: b.uniquePackageCount,
		entrypoints:        b.entrypoints.Build(),
	}
}

func (b *registryBuilder) updateBucketAndDirectoryExistence(change RegistryChange, logger *logging.LogTree) {
	start := time.Now()
	neededProjects := make(map[tspath.Path]struct{})
	neededDirectories := make(map[tspath.Path]string)
	for path, fileName := range change.OpenFiles {
		neededProjects[core.FirstResult(b.host.GetDefaultProject(path))] = struct{}{}
		if tspath.IsDynamicFileName(fileName) {
			continue
		}
		dir := fileName
		dirPath := path
		for {
			dir = tspath.GetDirectoryPath(dir)
			lastDirPath := dirPath
			dirPath = dirPath.GetDirectoryPath()
			if dirPath == lastDirPath {
				break
			}
			if _, ok := neededDirectories[dirPath]; ok {
				break
			}
			neededDirectories[dirPath] = dir
		}

		if !b.specifierCache.Has(path) {
			b.specifierCache.Set(path, &collections.SyncMap[tspath.Path, string]{})
		}
	}

	if change.RequestedFile != "" {
		neededProjects[core.FirstResult(b.host.GetDefaultProject(change.RequestedFile))] = struct{}{}
		if !b.specifierCache.Has(change.RequestedFile) {
			b.specifierCache.Set(change.RequestedFile, &collections.SyncMap[tspath.Path, string]{})
		}
	}

	for path := range b.base.specifierCache {
		if _, ok := change.OpenFiles[path]; !ok && path != change.RequestedFile {
			b.specifierCache.Delete(path)
		}
	}

	var addedProjects, removedProjects []tspath.Path
	core.DiffMapsFunc(
		b.base.projects,
		neededProjects,
		func(_ *RegistryBucket, _ struct{}) bool {
			panic("never called because onChanged is nil")
		},
		func(projectPath tspath.Path, _ struct{}) {
			// Need and don't have
			b.projects.Add(projectPath, newRegistryBucket())
			addedProjects = append(addedProjects, projectPath)
		},
		func(projectPath tspath.Path, _ *RegistryBucket) {
			// Have and don't need
			b.projects.Delete(projectPath)
			removedProjects = append(removedProjects, projectPath)
		},
		nil,
	)
	if logger != nil {
		for _, projectPath := range addedProjects {
			logger.Logf("Added project: %s", projectPath)
		}
		for _, projectPath := range removedProjects {
			logger.Logf("Removed project: %s", projectPath)
		}
	}

	updateDirectory := func(dirPath tspath.Path, dirName string, packageJsonChanged bool) {
		packageJsonFileName := tspath.CombinePaths(dirName, "package.json")
		hasNodeModules := b.host.FS().DirectoryExists(tspath.CombinePaths(dirName, "node_modules"))
		if entry, ok := b.directories.Get(dirPath); ok {
			entry.ChangeIf(func(dir *directory) bool {
				return packageJsonChanged || dir.hasNodeModules != hasNodeModules
			}, func(dir *directory) {
				dir.packageJson = b.host.GetPackageJson(packageJsonFileName)
				dir.hasNodeModules = hasNodeModules
			})
		} else {
			b.directories.Add(dirPath, &directory{
				name:           dirName,
				packageJson:    b.host.GetPackageJson(packageJsonFileName),
				hasNodeModules: hasNodeModules,
			})
		}

		if hasNodeModules {
			if _, ok := b.nodeModules.Get(dirPath); !ok {
				b.nodeModules.Add(dirPath, newRegistryBucket())
			}
		} else {
			b.nodeModules.TryDelete(dirPath)
		}
	}

	var addedNodeModulesDirs, removedNodeModulesDirs []tspath.Path
	packageJsonChanged := func(dirName string) bool {
		uri := lsconv.FileNameToDocumentURI(tspath.CombinePaths(dirName, "package.json"))
		return change.Changed.Has(uri) || change.Deleted.Has(uri) || change.Created.Has(uri)
	}
	core.DiffMapsFunc(
		b.base.directories,
		neededDirectories,
		func(dir *directory, dirName string) bool {
			return !packageJsonChanged(dirName) && dir.hasNodeModules == b.host.FS().DirectoryExists(tspath.CombinePaths(dirName, "node_modules"))
		},
		func(dirPath tspath.Path, dirName string) {
			// Need and don't have
			hadNodeModules := b.base.nodeModules[dirPath] != nil
			updateDirectory(dirPath, dirName, false)
			if logger != nil {
				logger.Logf("Added directory: %s", dirPath)
			}
			if _, hasNow := b.nodeModules.Get(dirPath); hasNow && !hadNodeModules {
				addedNodeModulesDirs = append(addedNodeModulesDirs, dirPath)
			}
		},
		func(dirPath tspath.Path, dir *directory) {
			// Have and don't need
			hadNodeModules := b.base.nodeModules[dirPath] != nil
			b.directories.Delete(dirPath)
			b.nodeModules.TryDelete(dirPath)
			if logger != nil {
				logger.Logf("Removed directory: %s", dirPath)
			}
			if hadNodeModules {
				removedNodeModulesDirs = append(removedNodeModulesDirs, dirPath)
			}
		},
		func(dirPath tspath.Path, dir *directory, dirName string) {
			updateDirectory(dirPath, dirName, packageJsonChanged(dirName))
			if logger != nil {
				logger.Logf("Changed directory: %s", dirPath)
			}
		},
	)

	if logger != nil {
		for _, dirPath := range addedNodeModulesDirs {
			logger.Logf("Added node_modules bucket: %s", dirPath)
		}
		for _, dirPath := range removedNodeModulesDirs {
			logger.Logf("Removed node_modules bucket: %s", dirPath)
		}
		logger.Logf("Updated buckets and directories in %v", time.Since(start))
	}
}

func (b *registryBuilder) markBucketsDirty(change RegistryChange, logger *logging.LogTree) {
	// Mark new program structures
	for projectPath, newFileNames := range change.RebuiltPrograms {
		if bucket, ok := b.projects.Get(projectPath); ok {
			bucket.Change(func(bucket *RegistryBucket) {
				bucket.state.newProgramStructure = core.IfElse(newFileNames, newProgramStructureDifferentFileNames, newProgramStructureSameFileNames)
			})
		}
	}

	// Mark files dirty, bailing out if all buckets already have multiple files dirty
	cleanNodeModulesBuckets := make(map[tspath.Path]struct{})
	cleanProjectBuckets := make(map[tspath.Path]struct{})
	b.nodeModules.Range(func(entry *dirty.MapEntry[tspath.Path, *RegistryBucket]) bool {
		if !entry.Value().state.multipleFilesDirty {
			cleanNodeModulesBuckets[entry.Key()] = struct{}{}
		}
		return true
	})
	b.projects.Range(func(entry *dirty.MapEntry[tspath.Path, *RegistryBucket]) bool {
		if !entry.Value().state.multipleFilesDirty {
			cleanProjectBuckets[entry.Key()] = struct{}{}
		}
		return true
	})

	markFilesDirty := func(uris map[lsproto.DocumentUri]struct{}) {
		if len(cleanNodeModulesBuckets) == 0 && len(cleanProjectBuckets) == 0 {
			return
		}
		for uri := range uris {
			path := b.base.toPath(uri.FileName())
			if len(cleanNodeModulesBuckets) > 0 {
				// For node_modules, mark the bucket dirty if anything changes in the directory.
				// The path could be either a symlink path (containing /node_modules/) or a realpath
				// (for symlinked project references). Both are recorded in Paths for granular updates.
				if nodeModulesIndex := strings.Index(string(path), "/node_modules/"); nodeModulesIndex != -1 {
					dirPath := path[:nodeModulesIndex]
					if _, ok := cleanNodeModulesBuckets[dirPath]; ok {
						entry := core.FirstResult(b.nodeModules.Get(dirPath))
						// Look up the package name for granular updates
						packageName := entry.Value().Paths[path]
						entry.Change(func(bucket *RegistryBucket) { bucket.markNodeModulesDirty(packageName) })
						if !entry.Value().state.multipleFilesDirty {
							delete(cleanNodeModulesBuckets, dirPath)
						}
					}
				} else {
					// Check if this path (possibly a realpath of a workspace package) is in any bucket's Paths.
					// This handles local workspace packages where the realpath doesn't contain /node_modules/.
					for bucketDirPath := range cleanNodeModulesBuckets {
						entry := core.FirstResult(b.nodeModules.Get(bucketDirPath))
						if packageName, ok := entry.Value().Paths[path]; ok {
							// Use the package name for granular updates
							entry.Change(func(bucket *RegistryBucket) { bucket.markNodeModulesDirty(packageName) })
							if !entry.Value().state.multipleFilesDirty {
								delete(cleanNodeModulesBuckets, bucketDirPath)
							}
						}
					}
				}
			}

			// For projects, mark the bucket dirty if the bucket contains the file directly.
			// Any other significant change, like a created failed lookup location, is
			// handled by newProgramStructure.
			for projectDirPath := range cleanProjectBuckets {
				entry, _ := b.projects.Get(projectDirPath)
				if _, ok := entry.Value().Paths[path]; ok {
					// Project buckets don't use package-based granular updates
					entry.Change(func(bucket *RegistryBucket) { bucket.markProjectFileDirty(path) })
					if !entry.Value().state.multipleFilesDirty {
						delete(cleanProjectBuckets, projectDirPath)
					}
				}
			}
		}
	}

	markFilesDirty(change.Created.Keys())
	markFilesDirty(change.Deleted.Keys())
	markFilesDirty(change.Changed.Keys())
}

func (b *registryBuilder) updateIndexes(ctx context.Context, change RegistryChange, logger *logging.LogTree) {
	type nodeModulesBucketTask struct {
		entry           *dirty.MapEntry[tspath.Path, *RegistryBucket]
		dependencyNames *collections.Set[string]
		dirName         string
		dirPath         tspath.Path

		// For granular updates.
		isUpdate       bool
		existingBucket *RegistryBucket
		dirtyPackages  *collections.Set[string]

		// Filled by discovery.
		packageNames          *collections.Set[string]
		directoryPackageNames *collections.Set[string]
		discovered            []*discoveredPackage
	}

	projectPath, _ := b.host.GetDefaultProject(change.RequestedFile)
	if projectPath == "" {
		return
	}

	var wg sync.WaitGroup

	// Compute resolved package names and project reference output mappings for all projects upfront.
	// Resolved package names are needed to compute node_modules dependencies so packages that are
	// directly imported by programs are included even if not listed in package.json.
	// Project reference output mappings are needed to redirect extraction from output .d.ts files
	// to source files for packages that are project references.
	// We need all projects because a node_modules directory can be used by multiple projects.
	allResolvedPackageNames := make(map[tspath.Path]*collections.Set[string])
	projectReferenceOutputs := make(map[tspath.Path]string)
	// Compute which packages have implicit deep imports (subpath imports in packages
	// without exports). These packages need recursive directory search to discover
	// all auto-importable files, even when the preference is disabled.
	allDeepImportPackages := &collections.Set[string]{}
	b.projects.Range(func(entry *dirty.MapEntry[tspath.Path, *RegistryBucket]) bool {
		program := b.host.GetProgramForProject(entry.Key())
		if program != nil {
			allResolvedPackageNames[entry.Key()] = getResolvedPackageNames(ctx, program)
			addProjectReferenceOutputMappings(program, projectReferenceOutputs)
			for name := range program.DeepImportPackageNames().Keys() {
				allDeepImportPackages.Add(name)
			}
		}
		return true
	})

	fileExcludePatterns := b.userPreferences.ParsedAutoImportFileExcludePatterns(b.host.FS().UseCaseSensitiveFileNames())

	// Determine which packages need recursive directory search for this build.
	// nil means all packages (preference is enabled for all).
	var targetRecursivePackages *collections.Set[string]
	if !b.userPreferences.AutoImportEntrypointDirectorySearch.IsTrue() {
		targetRecursivePackages = allDeepImportPackages
	}

	// --- Collect node_modules tasks ---
	var nodeModulesTasks []*nodeModulesBucketTask
	tspath.ForEachAncestorDirectoryPath(change.RequestedFile, func(dirPath tspath.Path) (any, bool) {
		if nodeModulesBucket, ok := b.nodeModules.Get(dirPath); ok {
			dirName := core.FirstResult(b.directories.Get(dirPath)).Value().name
			dependencies := b.computeDependenciesForNodeModulesDirectory(change, allResolvedPackageNames, dirName, dirPath)
			bucketState := nodeModulesBucket.Value().state
			// !!! Optimization: handle different dependency set via granular updates
			needsFullRebuild := bucketState.multipleFilesDirty ||
				!nodeModulesBucket.Value().DependencyNames.Equals(dependencies) ||
				!bucketState.buildPreferences.Equal(bucketBuildPreferencesFromUserPreferences(b.userPreferences)) ||
				!recursiveSearchSubset(targetRecursivePackages, bucketState.recursiveSearchPackages)
			dirtyPackages := bucketState.DirtyPackages()
			canDoGranularUpdate := !needsFullRebuild && dirtyPackages.Len() > 0

			if needsFullRebuild {
				nodeModulesTasks = append(nodeModulesTasks, &nodeModulesBucketTask{
					entry:           nodeModulesBucket,
					dependencyNames: dependencies,
					dirName:         dirName,
					dirPath:         dirPath,
				})
			} else if canDoGranularUpdate {
				nodeModulesTasks = append(nodeModulesTasks, &nodeModulesBucketTask{
					entry:           nodeModulesBucket,
					dependencyNames: dependencies,
					dirName:         dirName,
					dirPath:         dirPath,
					isUpdate:        true,
					existingBucket:  nodeModulesBucket.Value(),
					dirtyPackages:   dirtyPackages,
				})
			}
		}
		return nil, false
	})

	var nodeModulesLogger *logging.LogTree
	if logger != nil && len(nodeModulesTasks) > 0 {
		nodeModulesLogger = logger.Fork("Building node_modules indexes")
	}

	// --- Phase 1: Discovery (parallel per bucket) ---
	// Resolve package.json and realpath for each package in each bucket.
	discoveryStart := time.Now()
	for _, task := range nodeModulesTasks {
		wg.Go(func() {
			if task.isUpdate {
				task.packageNames = task.dirtyPackages
			} else {
				task.directoryPackageNames = getPackageNamesInNodeModules(tspath.CombinePaths(task.dirName, "node_modules"), b.host.FS())
				task.packageNames = core.Coalesce(task.dependencyNames, task.directoryPackageNames)
			}
			task.discovered = b.discoverBucketPackages(task.packageNames, task.dirName, task.dirPath)
		})
	}
	wg.Wait()
	if nodeModulesLogger != nil {
		nodeModulesLogger.Logf("Discovered packages: %v", time.Since(discoveryStart))
	}

	// --- Phase 2: Extraction (parallel per unique realpath) ---
	// Extract from main packages first. If a main package has no TypeScript entrypoints,
	// we fall back to extracting from @types in a second pass. Packages with no main
	// package extract directly from @types in the primary pass.
	extractionStart := time.Now()
	seen := make(map[string]bool)
	extractionCache := make(map[string]*perPackageExtractionResult)
	var extractionMu sync.Mutex
	// Collect all packages that have an @types fallback. After the primary pass, we
	// filter to only those whose main extraction failed, then deduplicate by typesRealpath.
	var typesFallbackCandidates []*discoveredPackage
	for _, task := range nodeModulesTasks {
		for _, pkg := range task.discovered {
			if pkg.realpath != "" {
				if !seen[pkg.realpath] {
					seen[pkg.realpath] = true
					enableDirSearch := targetRecursivePackages == nil ||
						targetRecursivePackages.Has(pkg.packageName) ||
						knownRecursiveSearchPackages.Has(pkg.packageName)
					// Record actual directory-searched packages so the stored set
					// reflects reality for rebuild detection and stats.
					if enableDirSearch && targetRecursivePackages != nil {
						targetRecursivePackages.Add(pkg.packageName)
					}
					wg.Go(func() {
						if ctx.Err() != nil {
							return
						}
						result := b.extractPackage(ctx, pkg.packageJson, pkg.packageName, projectReferenceOutputs, fileExcludePatterns, enableDirSearch)
						if result != nil {
							extractionMu.Lock()
							extractionCache[pkg.realpath] = result
							extractionMu.Unlock()
						}
					})
				}
				if pkg.typesRealpath != "" {
					typesFallbackCandidates = append(typesFallbackCandidates, pkg)
				}
			} else if pkg.typesRealpath != "" {
				if !seen[pkg.typesRealpath] {
					seen[pkg.typesRealpath] = true
					// @types packages always get directory search
					if targetRecursivePackages != nil {
						targetRecursivePackages.Add(pkg.packageName)
					}
					wg.Go(func() {
						if ctx.Err() != nil {
							return
						}
						result := b.extractPackage(ctx, pkg.typesPackageJson, pkg.packageName, projectReferenceOutputs, fileExcludePatterns, true /*enableDirectorySearch*/)
						if result != nil {
							extractionMu.Lock()
							extractionCache[pkg.typesRealpath] = result
							extractionMu.Unlock()
						}
					})
				}
			}
		}
	}
	wg.Wait()

	// For packages whose main extraction yielded nothing, fall back to @types.
	for _, pkg := range typesFallbackCandidates {
		extractionMu.Lock()
		mainExtracted := extractionCache[pkg.realpath] != nil
		extractionMu.Unlock()
		if mainExtracted || seen[pkg.typesRealpath] {
			continue
		}
		seen[pkg.typesRealpath] = true
		// @types fallback packages always get directory search
		if targetRecursivePackages != nil {
			targetRecursivePackages.Add(pkg.packageName)
		}
		wg.Go(func() {
			if ctx.Err() != nil {
				return
			}
			result := b.extractPackage(ctx, pkg.typesPackageJson, pkg.packageName, projectReferenceOutputs, fileExcludePatterns, true /*enableDirectorySearch*/)
			if result != nil {
				extractionMu.Lock()
				extractionCache[pkg.typesRealpath] = result
				extractionMu.Unlock()
			}
		})
	}
	wg.Wait()
	if nodeModulesLogger != nil {
		nodeModulesLogger.Logf("Extracted exports: %v (%d packages)", time.Since(extractionStart), len(seen))
	}
	b.uniquePackageCount = len(seen)

	// --- Phase 3: Bucket building (parallel per bucket) ---
	// Each bucket installs the shared extraction results and builds its index.
	var allResults []*bucketBuildResult

	for _, task := range nodeModulesTasks {
		br := &bucketBuildResult{entry: task.entry}
		allResults = append(allResults, br)
		wg.Go(func() {
			if task.isUpdate {
				b.updateNodeModulesBucket(
					ctx, br, task.existingBucket, task.dirtyPackages, task.discovered, extractionCache,
					targetRecursivePackages, nodeModulesLogger.Fork(task.dirName),
				)
			} else {
				b.buildNodeModulesBucket(
					ctx, br, task.dependencyNames, task.dirPath, task.discovered, task.directoryPackageNames, extractionCache,
					targetRecursivePackages, nodeModulesLogger.Fork(task.dirName),
				)
			}
		})
	}

	// Project bucket (not part of the three-phase pipeline — no cross-bucket dedup needed).
	if project, hasProject := b.projects.Get(projectPath); hasProject {
		program := b.host.GetProgramForProject(projectPath)
		resolvedPackageNames := allResolvedPackageNames[projectPath]
		shouldRebuild := project.Value().state.hasDirtyFileBesides(change.RequestedFile) ||
			!project.Value().state.buildPreferences.Equal(bucketBuildPreferencesFromUserPreferences(b.userPreferences))
		if !shouldRebuild && project.Value().state.newProgramStructure > 0 {
			if !project.Value().ResolvedPackageNames.Equals(resolvedPackageNames) || hasNewNonNodeModulesFiles(program, project.Value()) {
				shouldRebuild = true
			} else {
				project.Change(func(b *RegistryBucket) { b.state.newProgramStructure = newProgramStructureFalse })
			}
		}
		if shouldRebuild {
			br := &bucketBuildResult{entry: project}
			allResults = append(allResults, br)
			wg.Go(func() {
				b.buildProjectBucket(
					ctx, br, projectPath, resolvedPackageNames,
					logger.Fork("Building project bucket "+string(projectPath)),
				)
			})
		}
	}

	wg.Wait()

	for _, br := range allResults {
		if br.err != nil {
			continue
		}
		for _, path := range br.removedEntrypointPaths {
			b.entrypoints.Delete(path)
		}
		for path, entries := range br.entrypoints {
			b.entrypoints.Set(path, entries)
		}
		br.entry.Replace(br.bucket)
	}

	// If we failed to resolve any alias exports by ending up at a non-relative module specifier
	// that didn't resolve to another package, it's probably an ambient module declared in another package.
	// We recorded these failures, along with the name of every ambient module declared elsewhere, so we
	// can do a second pass on the failed files, this time including the ambient modules declarations that
	// were missing the first time. Example: node_modules/fs-extra/index.d.ts is simply `export * from "fs"`,
	// but when trying to resolve the `export *`, we don't know where "fs" is declared. The aliasResolver
	// tries to find packages named "fs" on the file system, but after failing, records "fs" as a failure
	// for fs-extra/index.d.ts. Meanwhile, if we also processed node_modules/@types/node/fs.d.ts, we
	// recorded that file as declaring the ambient module "fs". In the second pass, we combine those two
	// files and reprocess fs-extra/index.d.ts, this time finding "fs" declared in @types/node.
	secondPassStart := time.Now()
	var secondPassFileCount int
	for _, br := range allResults {
		if br.err != nil {
			continue
		}
		if br.possibleFailedAmbientModuleLookupTargets == nil {
			continue
		}
		rootFiles := make(map[string]*ast.SourceFile)
		for target := range br.possibleFailedAmbientModuleLookupTargets.Keys() {
			for _, fileName := range b.resolveAmbientModuleName(target, br.entry.Key()) {
				if _, exists := rootFiles[fileName]; exists {
					continue
				}
				rootFiles[fileName] = b.host.GetSourceFile(fileName, b.base.toPath(fileName))
				secondPassFileCount++
			}
		}
		if len(rootFiles) > 0 {
			moduleResolver := module.NewResolverWithOptions(b.host, core.EmptyCompilerOptions, "", "", b.resolverOptions)
			aliasResolver := newAliasResolver(
				slices.Collect(maps.Values(rootFiles)),
				nil,
				b.host,
				moduleResolver,
				b.base.toPath,
				func(_ ast.HasFileName, _ string) {
					// no-op
				},
			)
			ch, _ := checker.NewChecker(aliasResolver, nil)
			br.possibleFailedAmbientModuleLookupSources.Range(func(path tspath.Path, source *failedAmbientModuleLookupSource) bool {
				sourceFile := aliasResolver.GetSourceFile(source.fileName)
				extractor := b.newExportExtractor(source.packageName, ch, moduleResolver, b.host.FS().Realpath)
				fileExports := extractor.extractFromFile(sourceFile)
				for _, exp := range fileExports {
					br.bucket.Index.insertAsWords(exp)
				}
				return true
			})
		}
	}

	if nodeModulesLogger != nil {
		if secondPassFileCount > 0 {
			nodeModulesLogger.Logf("%d files required second pass, took %v", secondPassFileCount, time.Since(secondPassStart))
		}
		nodeModulesLogger.Logf("Total: %v", time.Since(discoveryStart))
	}
}

func hasNewNonNodeModulesFiles(program *compiler.Program, bucket *RegistryBucket) bool {
	if bucket.state.newProgramStructure != newProgramStructureDifferentFileNames {
		return false
	}
	for _, file := range program.GetSourceFiles() {
		if strings.Contains(file.FileName(), "/node_modules/") || isIgnoredFile(program, file) {
			continue
		}
		if _, ok := bucket.Paths[file.Path()]; !ok {
			return true
		}
	}
	return false
}

func isIgnoredFile(program *compiler.Program, file *ast.SourceFile) bool {
	return program.IsSourceFileDefaultLibrary(file.Path()) || program.IsGlobalTypingsFile(file.FileName())
}

// hasSymlinkToNodeModules checks if a file's realpath has a symlink that points
// to a node_modules directory. This is used to skip files in the project bucket
// that would be duplicated by the node_modules bucket via their symlink.
func hasSymlinkToNodeModules(filePath tspath.Path, symlinkCache *symlinks.KnownSymlinks) bool {
	if symlinkCache == nil {
		return false
	}

	// First check if the file itself has a symlink to node_modules
	if filesByRealpath := symlinkCache.FilesByRealpath(); filesByRealpath != nil {
		if symlinkPaths, ok := filesByRealpath.Load(filePath); ok {
			found := false
			symlinkPaths.Range(func(symlinkPath string) bool {
				if strings.Contains(symlinkPath, "/node_modules/") {
					found = true
					return false // stop ranging
				}
				return true
			})
			if found {
				return true
			}
		}
	}

	// Fall back to checking ancestor directories
	directoriesByRealpath := symlinkCache.DirectoriesByRealpath()
	if directoriesByRealpath == nil {
		return false
	}
	found := false
	tspath.ForEachAncestorDirectoryPath(filePath, func(dirPath tspath.Path) (any, bool) {
		symlinkPaths, ok := directoriesByRealpath.Load(dirPath.EnsureTrailingDirectorySeparator())
		if !ok {
			return nil, false
		}
		// Check if any of the symlinks point to a node_modules directory
		symlinkPaths.Range(func(symlinkPath string) bool {
			if strings.Contains(symlinkPath, "/node_modules/") {
				found = true
				return false // stop ranging
			}
			return true
		})
		return nil, found // stop if we found a match
	})
	return found
}

type failedAmbientModuleLookupSource struct {
	mu          sync.Mutex
	fileName    string
	packageName string
}

type bucketBuildResult struct {
	entry *dirty.MapEntry[tspath.Path, *RegistryBucket]
	err   error

	bucket *RegistryBucket
	// entrypoints are the resolved entrypoints from this bucket's packages,
	// to be merged into the registry-level entrypoints map.
	entrypoints map[tspath.Path][]*module.ResolvedEntrypoint
	// removedEntrypointPaths lists paths whose entrypoints should be removed from
	// the registry-level map before merging new entrypoints. Used for granular updates.
	removedEntrypointPaths []tspath.Path
	// File path to filename and package name
	possibleFailedAmbientModuleLookupSources *collections.SyncMap[tspath.Path, *failedAmbientModuleLookupSource]
	// Likely ambient module name
	possibleFailedAmbientModuleLookupTargets *collections.SyncSet[string]
}

func (b *registryBuilder) buildProjectBucket(
	ctx context.Context,
	result *bucketBuildResult,
	projectPath tspath.Path,
	resolvedPackageNames *collections.Set[string],
	logger *logging.LogTree,
) {
	if ctx.Err() != nil {
		result.err = ctx.Err()
		return
	}

	start := time.Now()
	var mu sync.Mutex
	fileExcludePatterns := b.userPreferences.ParsedAutoImportFileExcludePatterns(b.host.FS().UseCaseSensitiveFileNames())
	result.bucket = &RegistryBucket{}
	moduleResolver := module.NewResolverWithOptions(b.host, core.EmptyCompilerOptions, "", "", b.resolverOptions)
	program := b.host.GetProgramForProject(projectPath)
	symlinkCache := program.GetSymlinkCache()
	getChecker, closePool, checkerCount := createCheckerPool(program)
	defer closePool()
	exports := make(map[tspath.Path][]*Export)
	var wg sync.WaitGroup
	var skippedFileCount int
	var combinedStats extractorStats

	for _, file := range program.GetSourceFiles() {
		if isIgnoredFile(program, file) {
			continue
		}
		if fileExcludePatterns != nil && fileExcludePatterns.MatchString(file.FileName()) {
			skippedFileCount++
			continue
		}
		// Skip all node_modules files - they are always handled by node_modules buckets.
		// This simplifies the logic and ensures exports are indexed consistently.
		if strings.Contains(file.FileName(), "/node_modules/") {
			continue
		}
		// Skip files that are realpaths of symlinks in node_modules.
		// These files will be indexed via their symlinked path in node_modules buckets.
		if hasSymlinkToNodeModules(file.Path(), symlinkCache) {
			continue
		}
		wg.Go(func() {
			if ctx.Err() == nil {
				checker, done := getChecker()
				defer done()
				extractor := b.newExportExtractor("", checker, moduleResolver, nil)
				fileExports := extractor.extractFromFile(file)
				mu.Lock()
				exports[file.Path()] = fileExports
				mu.Unlock()
				stats := extractor.Stats()
				combinedStats.exports.Add(stats.exports.Load())
				combinedStats.usedChecker.Add(stats.usedChecker.Load())
			}
		})
	}

	wg.Wait()

	indexStart := time.Now()
	idx := &Index[*Export]{}
	paths := make(map[tspath.Path]string, len(exports))
	for path, fileExports := range exports {
		paths[path] = "" // Empty string for project buckets
		for _, exp := range fileExports {
			idx.insertAsWords(exp)
		}
	}

	result.bucket.Paths = paths
	result.bucket.Index = idx
	result.bucket.ResolvedPackageNames = resolvedPackageNames
	result.bucket.state.buildPreferences = bucketBuildPreferencesFromUserPreferences(b.userPreferences)

	if logger != nil {
		logger.Logf("Extracted exports: %v (%d exports, %d used checker, %d created checkers)", indexStart.Sub(start), combinedStats.exports.Load(), combinedStats.usedChecker.Load(), checkerCount())
		if skippedFileCount > 0 {
			logger.Logf("Skipped %d files due to exclude patterns", skippedFileCount)
		}
		logger.Logf("Built index: %v", time.Since(indexStart))
		logger.Logf("Bucket total: %v", time.Since(start))
	}
}

func (b *registryBuilder) computeDependenciesForNodeModulesDirectory(change RegistryChange, allResolvedPackageNames map[tspath.Path]*collections.Set[string], dirName string, dirPath tspath.Path) *collections.Set[string] {
	// If any open files are in scope of this directory but not in scope of any package.json,
	// we need to add all packages in this node_modules directory.
	for path := range change.OpenFiles {
		if dirPath.ContainsPath(path) && b.getNearestAncestorDirectoryWithPackageJson(path) == nil {
			return nil
		}
	}

	// Get all package.jsons that have this node_modules directory in their spine
	dependencies := &collections.Set[string]{}
	b.directories.Range(func(entry *dirty.MapEntry[tspath.Path, *directory]) bool {
		if entry.Value().packageJson.Exists() && dirPath.ContainsPath(entry.Key()) {
			addPackageJsonDependencies(entry.Value().packageJson.Contents, dependencies)
		}
		return true
	})

	// Add packages that are directly imported by programs but not listed in package.json.
	// This ensures node_modules files are always in node_modules buckets.
	// Include packages from all projects that have this node_modules directory in their spine.
	for _, resolvedPackageNames := range allResolvedPackageNames {
		for name := range resolvedPackageNames.Keys() {
			dependencies.Add(name)
		}
	}

	return dependencies
}

// discoveredPackage represents a package found during the discovery phase.
// It holds the resolved package.json and realpath for deduplication.
// When both a real package and a corresponding @types package exist (e.g., react + @types/react),
// both are stored so extraction can fall back to the @types package if the real package has no
// TypeScript entrypoints.
type discoveredPackage struct {
	packageName      string
	packageJson      *packagejson.InfoCacheEntry
	realpath         string
	typesPackageJson *packagejson.InfoCacheEntry
	typesRealpath    string
	dirPath          tspath.Path // bucket directory path (used as extraction context)
	isLocal          bool        // true if realpath is within the workspace root
}

// perPackageExtractionResult holds the extraction output for one physical package.
// Produced once per unique realpath during the extraction phase, then installed
// into every bucket that needs it during the bucket-building phase.
type perPackageExtractionResult struct {
	packageFiles                     map[tspath.Path]string
	entrypoints                      []*module.ResolvedEntrypoint
	exports                          map[tspath.Path][]*Export
	ambientModules                   map[string][]string
	statsExports                     int
	statsUsedChecker                 int
	skippedEntrypoints               int
	isSymlinked                      bool
	failedAmbientModuleLookupSources map[tspath.Path]*failedAmbientModuleLookupSource
	failedAmbientModuleLookupTargets *collections.Set[string]
}

// packageExtractionResult holds the results of extracting exports from a set of packages.
type packageExtractionResult struct {
	exports                                  map[tspath.Path][]*Export
	packageFiles                             map[string]map[tspath.Path]string
	ambientModuleNames                       map[string][]string
	entrypoints                              [][]*module.ResolvedEntrypoint
	workspacePackages                        *collections.Set[string]
	possibleFailedAmbientModuleLookupSources *collections.SyncMap[tspath.Path, *failedAmbientModuleLookupSource]
	possibleFailedAmbientModuleLookupTargets *collections.SyncSet[string]
	stats                                    extractorStats
	skippedEntrypointsCount                  int
}

// discoverBucketPackages resolves the package.json and realpath for each package name
// in a node_modules directory. This is the discovery phase of the three-phase extraction pipeline.
func (b *registryBuilder) discoverBucketPackages(
	packageNames *collections.Set[string],
	dirName string,
	dirPath tspath.Path,
) []*discoveredPackage {
	result := make([]*discoveredPackage, 0, packageNames.Len())
	for packageName := range packageNames.Keys() {
		typesPackageName := module.GetTypesPackageName(packageName)
		packageJson := b.host.GetPackageJson(tspath.CombinePaths(dirName, "node_modules", packageName, "package.json"))
		var typesPackageJson *packagejson.InfoCacheEntry
		if packageName != typesPackageName {
			typesJson := b.host.GetPackageJson(tspath.CombinePaths(dirName, "node_modules", typesPackageName, "package.json"))
			if typesJson.DirectoryExists {
				typesPackageJson = typesJson
			}
		}
		var realpath string
		if packageJson.DirectoryExists {
			realpath = b.host.FS().Realpath(packageJson.PackageDirectory)
		}
		var typesRealpath string
		if typesPackageJson != nil {
			typesRealpath = b.host.FS().Realpath(typesPackageJson.PackageDirectory)
		}
		isLocal := realpath != "" &&
			!strings.Contains(realpath, "/node_modules/") &&
			tspath.ContainsPath(
				b.host.GetCurrentDirectory(),
				realpath,
				tspath.ComparePathsOptions{UseCaseSensitiveFileNames: b.host.FS().UseCaseSensitiveFileNames()},
			)
		result = append(result, &discoveredPackage{
			packageName:      packageName,
			packageJson:      packageJson,
			realpath:         realpath,
			typesPackageJson: typesPackageJson,
			typesRealpath:    typesRealpath,
			dirPath:          dirPath,
			isLocal:          isLocal,
		})
	}
	return result
}

// extractPackage extracts exports from a single package.json.
// This runs once per unique realpath during the extraction phase.
// Returns nil if the package has no extractable entrypoints.
func (b *registryBuilder) extractPackage(
	ctx context.Context,
	packageJson *packagejson.InfoCacheEntry,
	packageName string,
	projectReferenceOutputs map[tspath.Path]string,
	fileExcludePatterns *vfsmatch.SpecMatcher,
	enableDirectorySearch bool,
) *perPackageExtractionResult {
	if packageJson == nil || !packageJson.DirectoryExists {
		return nil
	}
	toRealpath, toSymlink := getPackageRealpathFuncs(b.host.FS(), packageJson.PackageDirectory)
	resolver := getModuleResolver(b.host, toRealpath, b.resolverOptions)
	packageEntrypoints := resolver.GetEntrypointsFromPackageJsonInfo(packageJson, packageName, enableDirectorySearch)
	if packageEntrypoints == nil {
		return nil
	}

	var skippedEntrypoints int
	if fileExcludePatterns != nil {
		count := len(packageEntrypoints)
		packageEntrypoints = slices.DeleteFunc(packageEntrypoints, func(entrypoint *module.ResolvedEntrypoint) bool {
			return fileExcludePatterns.MatchString(entrypoint.ResolvedFileName)
		})
		skippedEntrypoints = count - len(packageEntrypoints)
	}
	if len(packageEntrypoints) == 0 {
		return nil
	}

	result := &perPackageExtractionResult{
		packageFiles:                     make(map[tspath.Path]string),
		entrypoints:                      packageEntrypoints,
		exports:                          make(map[tspath.Path][]*Export),
		ambientModules:                   make(map[string][]string),
		skippedEntrypoints:               skippedEntrypoints,
		failedAmbientModuleLookupSources: make(map[tspath.Path]*failedAmbientModuleLookupSource),
		failedAmbientModuleLookupTargets: &collections.Set[string]{},
	}

	// Resolve entrypoint source files and build the alias resolver.
	seenFiles := collections.NewSetWithSizeHint[tspath.Path](len(packageEntrypoints))
	rootFiles := make([]*ast.SourceFile, len(packageEntrypoints))
	symlinks := make(map[tspath.Path]pathAndFileName)
	var wg sync.WaitGroup
	for i, entrypoint := range packageEntrypoints {
		fileName := entrypoint.SymlinkOrRealpath()
		realpathFileName := entrypoint.ResolvedFileName
		realpathPath := b.base.toPath(realpathFileName)

		if inputFileName, ok := projectReferenceOutputs[realpathPath]; ok {
			fileName = toSymlink(inputFileName)
			realpathFileName = inputFileName
			realpathPath = b.base.toPath(realpathFileName)
		}

		if !seenFiles.AddIfAbsent(realpathPath) {
			continue
		}
		if fileName != realpathFileName {
			symlinkPath := b.base.toPath(fileName)
			symlinks[realpathPath] = pathAndFileName{path: symlinkPath, fileName: fileName}
			result.isSymlinked = true
		}
		wg.Go(func() {
			file := b.host.GetSourceFile(realpathFileName, realpathPath)
			if file != nil {
				binder.BindSourceFile(file)
			}
			rootFiles[i] = file
		})
	}
	wg.Wait()
	rootFiles = slices.DeleteFunc(rootFiles, func(f *ast.SourceFile) bool { return f == nil })

	aliasResolver := newAliasResolver(rootFiles, symlinks, b.host, resolver, b.base.toPath, func(source ast.HasFileName, moduleName string) {
		result.failedAmbientModuleLookupTargets.Add(moduleName)
		if _, exists := result.failedAmbientModuleLookupSources[source.Path()]; !exists {
			result.failedAmbientModuleLookupSources[source.Path()] = &failedAmbientModuleLookupSource{
				fileName: source.FileName(),
			}
		}
	})

	ch, _ := checker.NewChecker(aliasResolver, nil)
	extractor := b.newExportExtractor(packageName, ch, resolver, toRealpath)

	var nonModuleFiles collections.Set[tspath.Path]
	for _, entrypoint := range aliasResolver.rootFiles {
		if ctx.Err() != nil {
			return nil
		}
		fileExports := extractor.extractFromFile(entrypoint)
		for _, name := range entrypoint.AmbientModuleNames {
			result.ambientModules[name] = append(result.ambientModules[name], entrypoint.FileName())
		}
		result.packageFiles[entrypoint.Path()] = entrypoint.FileName()
		symlink, hasSymlink := aliasResolver.symlinks[entrypoint.Path()]
		if hasSymlink {
			result.packageFiles[symlink.path] = symlink.fileName
		}

		hasExports := len(fileExports) > 0 && entrypoint.ExternalModuleIndicator != nil
		if source, ok := result.failedAmbientModuleLookupSources[entrypoint.Path()]; !ok {
			result.exports[entrypoint.Path()] = fileExports
		} else {
			source.packageName = packageName
			hasExports = entrypoint.ExternalModuleIndicator != nil
		}

		if !hasExports {
			nonModuleFiles.Add(entrypoint.Path())
			if hasSymlink {
				nonModuleFiles.Add(symlink.path)
			}
		}
	}

	// Discard entrypoints for non-module files and empty modules.
	result.entrypoints = slices.DeleteFunc(result.entrypoints, func(ep *module.ResolvedEntrypoint) bool {
		return nonModuleFiles.Has(b.base.toPath(ep.ResolvedFileName))
	})

	stats := extractor.Stats()
	result.statsExports = int(stats.exports.Load())
	result.statsUsedChecker = int(stats.usedChecker.Load())
	return result
}

// installExtractions aggregates pre-extracted per-package results into a single
// packageExtractionResult for one bucket. This is the install phase of the three-phase pipeline.
func installExtractions(
	discovered []*discoveredPackage,
	extractionCache map[string]*perPackageExtractionResult,
) *packageExtractionResult {
	result := &packageExtractionResult{
		exports:                                  make(map[tspath.Path][]*Export),
		packageFiles:                             make(map[string]map[tspath.Path]string),
		ambientModuleNames:                       make(map[string][]string),
		workspacePackages:                        &collections.Set[string]{},
		possibleFailedAmbientModuleLookupSources: &collections.SyncMap[tspath.Path, *failedAmbientModuleLookupSource]{},
		possibleFailedAmbientModuleLookupTargets: &collections.SyncSet[string]{},
	}

	for _, pkg := range discovered {
		extraction := extractionCache[pkg.realpath]
		if extraction == nil {
			extraction = extractionCache[pkg.typesRealpath]
		}
		if extraction == nil {
			continue
		}
		maps.Copy(result.exports, extraction.exports)
		if result.packageFiles[pkg.packageName] == nil {
			result.packageFiles[pkg.packageName] = make(map[tspath.Path]string, len(extraction.packageFiles))
		}
		maps.Copy(result.packageFiles[pkg.packageName], extraction.packageFiles)
		for name, fileNames := range extraction.ambientModules {
			result.ambientModuleNames[name] = append(result.ambientModuleNames[name], fileNames...)
		}
		if extraction.entrypoints != nil {
			result.entrypoints = append(result.entrypoints, extraction.entrypoints)
		}
		for path, source := range extraction.failedAmbientModuleLookupSources {
			result.possibleFailedAmbientModuleLookupSources.LoadOrStore(path, source)
		}
		for target := range extraction.failedAmbientModuleLookupTargets.Keys() {
			result.possibleFailedAmbientModuleLookupTargets.Add(target)
		}
		if extraction.isSymlinked && pkg.isLocal {
			result.workspacePackages.Add(pkg.packageName)
		}
		result.stats.exports.Add(int32(extraction.statsExports))
		result.stats.usedChecker.Add(int32(extraction.statsUsedChecker))
		result.skippedEntrypointsCount += extraction.skippedEntrypoints
	}

	return result
}

func (b *registryBuilder) buildNodeModulesBucket(
	ctx context.Context,
	result *bucketBuildResult,
	dependencies *collections.Set[string],
	dirPath tspath.Path,
	discovered []*discoveredPackage,
	directoryPackageNames *collections.Set[string],
	extractionCache map[string]*perPackageExtractionResult,
	recursiveSearchPackages *collections.Set[string],
	logger *logging.LogTree,
) {
	if ctx.Err() != nil {
		result.err = ctx.Err()
		return
	}

	extraction := installExtractions(discovered, extractionCache)

	indexStart := time.Now()
	// Build PackageFiles with all directory package names; indexed packages have
	// non-nil maps, unindexed packages have nil maps.
	allPackageFiles := make(map[string]map[tspath.Path]string, directoryPackageNames.Len())
	for pkgName := range directoryPackageNames.Keys() {
		allPackageFiles[pkgName] = extraction.packageFiles[pkgName]
	}

	// Build Paths as reverse mapping from path to package name.
	// Only include paths for local workspace packages (eligible for granular updates).
	paths := make(map[tspath.Path]string)
	for pkgName := range extraction.workspacePackages.Keys() {
		if files, ok := extraction.packageFiles[pkgName]; ok {
			for path := range files {
				paths[path] = pkgName
			}
		}
	}

	result.bucket = &RegistryBucket{
		Index:              &Index[*Export]{},
		DependencyNames:    dependencies,
		PackageFiles:       allPackageFiles,
		AmbientModuleNames: extraction.ambientModuleNames,
		Paths:              paths,
		state: BucketState{
			buildPreferences:        bucketBuildPreferencesFromUserPreferences(b.userPreferences),
			recursiveSearchPackages: recursiveSearchPackages.Clone(),
		},
	}
	result.entrypoints = make(map[tspath.Path][]*module.ResolvedEntrypoint, len(extraction.exports))
	result.possibleFailedAmbientModuleLookupSources = extraction.possibleFailedAmbientModuleLookupSources
	result.possibleFailedAmbientModuleLookupTargets = extraction.possibleFailedAmbientModuleLookupTargets
	for _, fileExports := range extraction.exports {
		for _, exp := range fileExports {
			result.bucket.Index.insertAsWords(exp)
		}
	}
	for _, entrypointSet := range extraction.entrypoints {
		for _, entrypoint := range entrypointSet {
			path := b.base.toPath(entrypoint.ResolvedFileName)
			result.entrypoints[path] = append(result.entrypoints[path], entrypoint)
		}
	}

	// Compute old entrypoint paths to remove from the registry-level map.
	// For a full rebuild, all entrypoints belonging to the old bucket's packages must be removed.
	if oldEntry, ok := b.nodeModules.Get(dirPath); ok {
		oldBucket := oldEntry.Value()
		for _, files := range oldBucket.PackageFiles {
			for path := range files {
				if _, ok := b.base.entrypoints[path]; ok {
					result.removedEntrypointPaths = append(result.removedEntrypointPaths, path)
				}
			}
		}
	}

	if logger != nil {
		logger.Logf("Installed %d exports (%d used checker)", extraction.stats.exports.Load(), extraction.stats.usedChecker.Load())
		if extraction.skippedEntrypointsCount > 0 {
			logger.Logf("Skipped %d entrypoints due to exclude patterns", extraction.skippedEntrypointsCount)
		}
		logger.Logf("Built index: %v", time.Since(indexStart))
	}

	result.err = ctx.Err()
}

// updateNodeModulesBucket performs a granular update of the node_modules bucket,
// re-extracting only the dirty packages and merging with the existing bucket.
func (b *registryBuilder) updateNodeModulesBucket(
	ctx context.Context,
	result *bucketBuildResult,
	existingBucket *RegistryBucket,
	dirtyPackages *collections.Set[string],
	discovered []*discoveredPackage,
	extractionCache map[string]*perPackageExtractionResult,
	recursiveSearchPackages *collections.Set[string],
	logger *logging.LogTree,
) {
	if ctx.Err() != nil {
		result.err = ctx.Err()
		return
	}

	start := time.Now()
	extraction := installExtractions(discovered, extractionCache)

	indexStart := time.Now()

	// Clone the existing index, excluding exports from dirty packages
	newIndex := existingBucket.Index.Clone(func(exp *Export) bool {
		return !dirtyPackages.Has(exp.PackageName)
	})

	// Clone PackageFiles, removing dirty packages
	newPackageFiles := maps.Clone(existingBucket.PackageFiles)
	for pkgName := range dirtyPackages.Keys() {
		delete(newPackageFiles, pkgName)
	}
	// Add newly extracted package files
	maps.Copy(newPackageFiles, extraction.packageFiles)

	// Clone Paths, removing dirty package paths
	newPaths := make(map[tspath.Path]string, len(existingBucket.Paths))
	for path, pkgName := range existingBucket.Paths {
		if dirtyPackages.Has(pkgName) {
			continue
		}
		newPaths[path] = pkgName
	}
	// Add paths for newly extracted workspace packages
	for pkgName := range extraction.workspacePackages.Keys() {
		if files, ok := extraction.packageFiles[pkgName]; ok {
			for path := range files {
				newPaths[path] = pkgName
			}
		}
	}

	// Clone AmbientModuleNames, removing dirty package entries
	newAmbientModuleNames := make(map[string][]string, len(existingBucket.AmbientModuleNames))
	for moduleName, fileNames := range existingBucket.AmbientModuleNames {
		// Filter out files from dirty packages
		var filtered []string
		for _, fileName := range fileNames {
			path := b.base.toPath(fileName)
			if pkgName, ok := existingBucket.Paths[path]; ok && dirtyPackages.Has(pkgName) {
				continue
			}
			filtered = append(filtered, fileName)
		}
		if len(filtered) > 0 {
			newAmbientModuleNames[moduleName] = filtered
		}
	}
	// Add newly extracted ambient module names
	for moduleName, fileNames := range extraction.ambientModuleNames {
		newAmbientModuleNames[moduleName] = append(newAmbientModuleNames[moduleName], fileNames...)
	}

	// Collect entrypoint paths that need to be removed from the registry-level map
	// (paths belonging to dirty packages)
	var removedEntrypointPaths []tspath.Path
	for path := range b.base.entrypoints {
		if pkgName, ok := existingBucket.Paths[path]; ok && dirtyPackages.Has(pkgName) {
			removedEntrypointPaths = append(removedEntrypointPaths, path)
		}
	}
	// Build new entrypoints from extraction
	newEntrypoints := make(map[tspath.Path][]*module.ResolvedEntrypoint)
	for _, entrypointSet := range extraction.entrypoints {
		for _, entrypoint := range entrypointSet {
			path := b.base.toPath(entrypoint.ResolvedFileName)
			newEntrypoints[path] = append(newEntrypoints[path], entrypoint)
		}
	}

	// Insert newly extracted exports into the index
	for _, fileExports := range extraction.exports {
		for _, exp := range fileExports {
			newIndex.insertAsWords(exp)
		}
	}

	result.bucket = &RegistryBucket{
		Index:              newIndex,
		DependencyNames:    existingBucket.DependencyNames,
		PackageFiles:       newPackageFiles,
		AmbientModuleNames: newAmbientModuleNames,
		Paths:              newPaths,
		state: BucketState{
			buildPreferences:        bucketBuildPreferencesFromUserPreferences(b.userPreferences),
			recursiveSearchPackages: recursiveSearchPackages.Clone(),
		},
	}
	result.entrypoints = newEntrypoints
	result.removedEntrypointPaths = removedEntrypointPaths
	result.possibleFailedAmbientModuleLookupSources = extraction.possibleFailedAmbientModuleLookupSources
	result.possibleFailedAmbientModuleLookupTargets = extraction.possibleFailedAmbientModuleLookupTargets

	if logger != nil {
		logger.Logf("Granular update of %d packages: %v (%d exports)", dirtyPackages.Len(), indexStart.Sub(start), extraction.stats.exports.Load())
		logger.Logf("Built index: %v", time.Since(indexStart))
	}

	result.err = ctx.Err()
}

func (b *registryBuilder) getNearestAncestorDirectoryWithPackageJson(filePath tspath.Path) *directory {
	return core.FirstResult(tspath.ForEachAncestorDirectoryPath(filePath.GetDirectoryPath(), func(dirPath tspath.Path) (result *directory, stop bool) {
		if dirEntry, ok := b.directories.Get(dirPath); ok && dirEntry.Value().packageJson.Exists() {
			return dirEntry.Value(), true
		}
		return nil, false
	}))
}

func (b *registryBuilder) resolveAmbientModuleName(moduleName string, fromPath tspath.Path) []string {
	return core.FirstResult(tspath.ForEachAncestorDirectoryPath(fromPath, func(dirPath tspath.Path) (result []string, stop bool) {
		if bucket, ok := b.nodeModules.Get(dirPath); ok {
			if fileNames, ok := bucket.Value().AmbientModuleNames[moduleName]; ok {
				return fileNames, true
			}
		}
		return nil, false
	}))
}
