package autoimport_test

import (
	"context"
	"fmt"
	"testing"

	"github.com/microsoft/typescript-go/internal/bundled"
	"github.com/microsoft/typescript-go/internal/collections"
	"github.com/microsoft/typescript-go/internal/core"
	"github.com/microsoft/typescript-go/internal/ls"
	"github.com/microsoft/typescript-go/internal/ls/autoimport"
	"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/project"
	"github.com/microsoft/typescript-go/internal/testutil/autoimporttestutil"
	"github.com/microsoft/typescript-go/internal/testutil/projecttestutil"
	"github.com/microsoft/typescript-go/internal/tspath"
	"github.com/microsoft/typescript-go/internal/vfs/vfstest"
	"gotest.tools/v3/assert"
)

func TestRegistryLifecycle(t *testing.T) {
	t.Parallel()
	t.Run("builds project and node_modules buckets", func(t *testing.T) {
		t.Parallel()
		fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
		session := fixture.Session()
		project := fixture.SingleProject()
		mainFile := project.File(0)
		session.DidOpenFile(context.Background(), mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)

		stats := autoImportStats(t, session)
		projectBucket := singleBucket(t, stats.ProjectBuckets)
		nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, true, projectBucket.State.Dirty())
		assert.Equal(t, 0, projectBucket.FileCount)
		assert.Equal(t, true, nodeModulesBucket.State.Dirty())
		assert.Equal(t, 0, nodeModulesBucket.FileCount)

		_, err := session.GetCurrentLanguageServiceWithAutoImports(context.Background(), mainFile.URI())
		assert.NilError(t, err)

		stats = autoImportStats(t, session)
		projectBucket = singleBucket(t, stats.ProjectBuckets)
		nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, false, projectBucket.State.Dirty())
		assert.Assert(t, projectBucket.ExportCount > 0)
		assert.Equal(t, false, nodeModulesBucket.State.Dirty())
		assert.Assert(t, nodeModulesBucket.ExportCount > 0)
	})

	t.Run("bucket does not rebuild on same-file change", func(t *testing.T) {
		t.Parallel()
		fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 2)
		session := fixture.Session()
		utils := fixture.Utils()
		project := fixture.SingleProject()
		mainFile := project.File(0)
		secondaryFile := project.File(1)
		session.DidOpenFile(context.Background(), mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
		session.DidOpenFile(context.Background(), secondaryFile.URI(), 1, secondaryFile.Content(), lsproto.LanguageKindTypeScript)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(context.Background(), mainFile.URI())
		assert.NilError(t, err)

		updatedContent := mainFile.Content() + "// change\n"
		session.DidChangeFile(context.Background(), mainFile.URI(), 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
			{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: updatedContent}},
		})

		_, err = session.GetLanguageService(context.Background(), mainFile.URI())
		assert.NilError(t, err)

		stats := autoImportStats(t, session)
		projectBucket := singleBucket(t, stats.ProjectBuckets)
		nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, projectBucket.State.Dirty(), true)
		assert.Equal(t, projectBucket.State.DirtyFile(), utils.ToPath(mainFile.FileName()))
		assert.Equal(t, nodeModulesBucket.State.Dirty(), false)
		assert.Equal(t, nodeModulesBucket.State.DirtyFile(), tspath.Path(""))

		// Bucket should not recompute when requesting same file changed
		_, err = session.GetCurrentLanguageServiceWithAutoImports(context.Background(), mainFile.URI())
		assert.NilError(t, err)
		stats = autoImportStats(t, session)
		projectBucket = singleBucket(t, stats.ProjectBuckets)
		assert.Equal(t, projectBucket.State.Dirty(), true)
		assert.Equal(t, projectBucket.State.DirtyFile(), utils.ToPath(mainFile.FileName()))

		// Bucket should recompute when other file has changed
		session.DidChangeFile(context.Background(), secondaryFile.URI(), 1, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
			{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: "// new content"}},
		})
		_, err = session.GetCurrentLanguageServiceWithAutoImports(context.Background(), mainFile.URI())
		assert.NilError(t, err)
		stats = autoImportStats(t, session)
		projectBucket = singleBucket(t, stats.ProjectBuckets)
		assert.Equal(t, projectBucket.State.Dirty(), false)
	})

	t.Run("bucket updates on same-file change when new files added to the program", func(t *testing.T) {
		t.Parallel()
		projectRoot := "/home/src/explicit-files-project"
		files := map[string]any{
			projectRoot + "/tsconfig.json": `{
				"compilerOptions": {
					"module": "esnext",
					"target": "esnext",
					"strict": true
				},
				"files": ["index.ts"]
			}`,
			projectRoot + "/index.ts": "",
			projectRoot + "/utils.ts": `export const foo = 1;
export const bar = 2;`,
		}
		session, _ := projecttestutil.Setup(files)
		t.Cleanup(session.Close)

		ctx := context.Background()
		indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")

		// Open the index.ts file
		session.DidOpenFile(ctx, indexURI, 1, "", lsproto.LanguageKindTypeScript)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
		assert.NilError(t, err)
		stats := autoImportStats(t, session)
		projectBucket := singleBucket(t, stats.ProjectBuckets)
		assert.Equal(t, 1, projectBucket.FileCount)

		// Edit index.ts to import foo from utils.ts
		newContent := `import { foo } from "./utils";`
		session.DidChangeFile(ctx, indexURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
			{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: newContent}},
		})

		// Bucket should be rebuilt because new files were added
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
		assert.NilError(t, err)
		stats = autoImportStats(t, session)
		projectBucket = singleBucket(t, stats.ProjectBuckets)
		assert.Equal(t, 2, projectBucket.FileCount)
	})

	t.Run("package.json dependency changes invalidate node_modules buckets", func(t *testing.T) {
		t.Parallel()
		fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
		session := fixture.Session()
		sessionUtils := fixture.Utils()
		project := fixture.SingleProject()
		mainFile := project.File(0)
		nodePackage := project.NodeModules()[0]
		packageJSON := project.PackageJSONFile()
		ctx := context.Background()

		session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
		assert.NilError(t, err)
		stats := autoImportStats(t, session)
		nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, nodeModulesBucket.State.Dirty(), false)

		fs := sessionUtils.FS()
		updatePackageJSON := func(content string) {
			assert.NilError(t, fs.WriteFile(packageJSON.FileName(), content))
			session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
				{Type: lsproto.FileChangeTypeChanged, Uri: packageJSON.URI()},
			})
		}

		sameDepsContent := fmt.Sprintf("{\n  \"name\": \"local-project-stable\",\n  \"dependencies\": {\n    \"%s\": \"*\"\n  }\n}\n", nodePackage.Name)
		updatePackageJSON(sameDepsContent)
		_, err = session.GetLanguageService(ctx, mainFile.URI())
		assert.NilError(t, err)
		stats = autoImportStats(t, session)
		nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, nodeModulesBucket.State.Dirty(), false)

		differentDepsContent := fmt.Sprintf("{\n  \"name\": \"local-project-stable\",\n  \"dependencies\": {\n    \"%s\": \"*\",\n    \"newpkg\": \"*\"\n  }\n}\n", nodePackage.Name)
		updatePackageJSON(differentDepsContent)
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
		assert.NilError(t, err)
		stats = autoImportStats(t, session)
		assert.Check(t, singleBucket(t, stats.NodeModulesBuckets).DependencyNames.Has("newpkg"))
	})

	t.Run("node_modules buckets get deleted when no open files can reference them", func(t *testing.T) {
		t.Parallel()
		fixture := autoimporttestutil.SetupMonorepoLifecycleSession(t, autoimporttestutil.MonorepoSetupConfig{
			Root: monorepoProjectRoot,
			MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{
				Name:            "monorepo",
				NodeModuleNames: []string{"pkg-root"},
			},
			Packages: []autoimporttestutil.MonorepoPackageConfig{
				{FileCount: 1, MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{Name: "package-a", NodeModuleNames: []string{"pkg-a"}}},
				{FileCount: 1, MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{Name: "package-b", NodeModuleNames: []string{"pkg-b"}}},
			},
		})
		session := fixture.Session()
		monorepo := fixture.Monorepo()
		pkgA := monorepo.Package(0)
		pkgB := monorepo.Package(1)
		fileA := pkgA.File(0)
		fileB := pkgB.File(0)
		ctx := context.Background()

		// Open file in package-a, should create buckets for root and package-a node_modules
		session.DidOpenFile(ctx, fileA.URI(), 1, fileA.Content(), lsproto.LanguageKindTypeScript)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, fileA.URI())
		assert.NilError(t, err)

		// Open file in package-b, should also create buckets for package-b
		session.DidOpenFile(ctx, fileB.URI(), 1, fileB.Content(), lsproto.LanguageKindTypeScript)
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, fileB.URI())
		assert.NilError(t, err)
		stats := autoImportStats(t, session)
		assert.Equal(t, len(stats.NodeModulesBuckets), 3)
		assert.Equal(t, len(stats.ProjectBuckets), 2)

		// Close file in package-a, package-a's node_modules bucket and project bucket should be removed
		session.DidCloseFile(ctx, fileA.URI())
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, fileB.URI())
		assert.NilError(t, err)
		stats = autoImportStats(t, session)
		assert.Equal(t, len(stats.NodeModulesBuckets), 2)
		assert.Equal(t, len(stats.ProjectBuckets), 1)
	})

	t.Run("deleting node_modules leaves the registry prepared for importing", func(t *testing.T) {
		t.Parallel()
		fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
		session := fixture.Session()
		sessionUtils := fixture.Utils()
		project := fixture.SingleProject()
		mainFile := project.File(0)
		ctx := context.Background()

		preferences := lsutil.NewDefaultUserPreferences()
		preferences.IncludeCompletionsForModuleExports = core.TSTrue
		preferences.IncludeCompletionsForImportStatements = core.TSTrue

		// Build auto-imports once so both buckets are clean and prepared.
		session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
		assert.NilError(t, err)

		snapshot := session.Snapshot()
		defaultProject := snapshot.GetDefaultProject(mainFile.URI())
		assert.Assert(t, defaultProject != nil)
		projectPath := defaultProject.ConfigFilePath()
		assert.Assert(t, snapshot.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, preferences))
		assert.Equal(t, len(autoImportStats(t, session).NodeModulesBuckets), 1)

		// Simulate the user deleting node_modules: remove the directory from disk
		// and notify the session of the deletion, which marks the node_modules
		// bucket dirty.
		nodeModulesDir := tspath.CombinePaths(project.Root(), "node_modules")
		assert.NilError(t, sessionUtils.FS().Remove(nodeModulesDir))
		session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
			{Type: lsproto.FileChangeTypeDeleted, Uri: lsconv.FileNameToDocumentURI(nodeModulesDir)},
		})

		// Re-preparing auto-imports must succeed and leave the registry prepared.
		// Before the fix, the node_modules bucket's rebuild bailed out with
		// vfs.ErrNotExist, leaving the stale dirty bucket in place so the registry
		// reported "needs rebuild" forever (which crashed the request handler).
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
		assert.NilError(t, err)

		snapshot = session.Snapshot()
		assert.Assert(t, snapshot.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, preferences),
			"registry should be prepared after node_modules is deleted")
		// The node_modules bucket should be removed entirely, not left behind as an
		// empty bucket.
		assert.Equal(t, len(autoImportStats(t, session).NodeModulesBuckets), 0)
	})

	t.Run("deleting node_modules alongside a package.json change removes the bucket", func(t *testing.T) {
		t.Parallel()
		fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
		session := fixture.Session()
		sessionUtils := fixture.Utils()
		project := fixture.SingleProject()
		mainFile := project.File(0)
		packageJSON := project.PackageJSONFile()
		ctx := context.Background()

		preferences := lsutil.NewDefaultUserPreferences()
		preferences.IncludeCompletionsForModuleExports = core.TSTrue
		preferences.IncludeCompletionsForImportStatements = core.TSTrue

		session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
		assert.NilError(t, err)

		snapshot := session.Snapshot()
		defaultProject := snapshot.GetDefaultProject(mainFile.URI())
		assert.Assert(t, defaultProject != nil)
		projectPath := defaultProject.ConfigFilePath()
		assert.Equal(t, len(autoImportStats(t, session).NodeModulesBuckets), 1)

		// In a single changeset, edit package.json AND delete node_modules. The
		// package.json change must not prevent the now-missing node_modules bucket
		// from being removed.
		assert.NilError(t, sessionUtils.FS().WriteFile(packageJSON.FileName(), `{"name": "app", "dependencies": {}}`))
		nodeModulesDir := tspath.CombinePaths(project.Root(), "node_modules")
		assert.NilError(t, sessionUtils.FS().Remove(nodeModulesDir))
		session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
			{Type: lsproto.FileChangeTypeChanged, Uri: packageJSON.URI()},
			{Type: lsproto.FileChangeTypeDeleted, Uri: lsconv.FileNameToDocumentURI(nodeModulesDir)},
		})

		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
		assert.NilError(t, err)

		snapshot = session.Snapshot()
		assert.Assert(t, snapshot.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, preferences))
		assert.Equal(t, len(autoImportStats(t, session).NodeModulesBuckets), 0)
	})

	t.Run("deleting a package directory inside node_modules invalidates the bucket", func(t *testing.T) {
		t.Parallel()
		fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
		session := fixture.Session()
		sessionUtils := fixture.Utils()
		project := fixture.SingleProject()
		mainFile := project.File(0)
		nodePackage := project.NodeModules()[0]
		ctx := context.Background()

		session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
		assert.NilError(t, err)
		assert.Assert(t, singleBucket(t, autoImportStats(t, session).NodeModulesBuckets).ExportCount > 0)

		// Delete just the package directory, leaving node_modules itself in place. The
		// package's files are read transiently by the registry, so they are never tracked
		// in diskFiles/diskDirectories; only the directory deletion event is reported, and
		// it must survive snapshotfs filtering to invalidate the bucket.
		assert.NilError(t, sessionUtils.FS().Remove(nodePackage.Directory))
		session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
			{Type: lsproto.FileChangeTypeDeleted, Uri: lsconv.FileNameToDocumentURI(nodePackage.Directory)},
		})

		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
		assert.NilError(t, err)
		assert.Equal(t, singleBucket(t, autoImportStats(t, session).NodeModulesBuckets).ExportCount, 0)
	})

	t.Run("node_modules bucket dependency selection changes with open files", func(t *testing.T) {
		t.Parallel()
		monorepoRoot := "/home/src/monorepo"
		packageADir := tspath.CombinePaths(monorepoRoot, "packages", "a")
		monorepoIndex := tspath.CombinePaths(monorepoRoot, "index.js")
		packageAIndex := tspath.CombinePaths(packageADir, "index.js")

		fixture := autoimporttestutil.SetupMonorepoLifecycleSession(t, autoimporttestutil.MonorepoSetupConfig{
			Root: monorepoRoot,
			MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{
				Name:            "monorepo",
				NodeModuleNames: []string{"pkg1", "pkg2", "pkg3"},
				DependencyNames: []string{"pkg1"},
			},
			Packages: []autoimporttestutil.MonorepoPackageConfig{
				{
					FileCount: 0,
					MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{
						Name:            "a",
						DependencyNames: []string{"pkg1", "pkg2"},
					},
				},
			},
			ExtraFiles: []autoimporttestutil.TextFileSpec{
				{Path: monorepoIndex, Content: "export const monorepoIndex = 1;\n"},
				{Path: packageAIndex, Content: "export const pkgA = 2;\n"},
			},
		})
		session := fixture.Session()
		monorepoHandle := fixture.ExtraFile(monorepoIndex)
		packageAHandle := fixture.ExtraFile(packageAIndex)

		ctx := context.Background()

		// Open monorepo root file: expect dependencies restricted to pkg1
		session.DidOpenFile(ctx, monorepoHandle.URI(), 1, monorepoHandle.Content(), lsproto.LanguageKindJavaScript)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, monorepoHandle.URI())
		assert.NilError(t, err)
		stats := autoImportStats(t, session)
		assert.Assert(t, singleBucket(t, stats.NodeModulesBuckets).DependencyNames.Equals(collections.NewSetFromItems("pkg1")))

		// Open package-a file: pkg2 should be added to existing bucket
		session.DidOpenFile(ctx, packageAHandle.URI(), 1, packageAHandle.Content(), lsproto.LanguageKindJavaScript)
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, packageAHandle.URI())
		assert.NilError(t, err)
		stats = autoImportStats(t, session)
		assert.Assert(t, singleBucket(t, stats.NodeModulesBuckets).DependencyNames.Equals(collections.NewSetFromItems("pkg1", "pkg2")))

		// Close package-a file; only monorepo bucket should remain
		session.DidCloseFile(ctx, packageAHandle.URI())
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, monorepoHandle.URI())
		assert.NilError(t, err)
		stats = autoImportStats(t, session)
		assert.Assert(t, singleBucket(t, stats.NodeModulesBuckets).DependencyNames.Equals(collections.NewSetFromItems("pkg1")))

		// Close monorepo file; no node_modules buckets should remain
		session.DidCloseFile(ctx, monorepoHandle.URI())
		session.DidOpenFile(ctx, "untitled:Untitled-1", 0, "", lsproto.LanguageKindTypeScript)
		_, err = session.GetLanguageService(ctx, "untitled:Untitled-1")
		assert.NilError(t, err)
		stats = autoImportStats(t, session)
		assert.Equal(t, len(stats.NodeModulesBuckets), 0)
	})

	t.Run("node_modules bucket includes resolved packages from all projects", func(t *testing.T) {
		// This test verifies that when multiple projects share a node_modules directory,
		// the node_modules bucket includes resolved package names from ALL projects,
		// not just the currently requested file's project.
		//
		// Scenario:
		// - Two separate projects (project-a and project-b) share a root node_modules
		// - pkg-listed is in both package.json dependencies
		// - pkg-unlisted is NOT in any package.json, but project-a imports it directly
		// - When requesting completions for project-b, pkg-unlisted should still be in
		//   the node_modules bucket because project-a's resolved packages are included.
		t.Parallel()
		monorepoRoot := "/home/src/cross-project-deps"
		packageADir := tspath.CombinePaths(monorepoRoot, "packages", "a")
		packageBDir := tspath.CombinePaths(monorepoRoot, "packages", "b")
		packageAIndex := tspath.CombinePaths(packageADir, "index.ts")
		packageBIndex := tspath.CombinePaths(packageBDir, "index.ts")

		fixture := autoimporttestutil.SetupMonorepoLifecycleSession(t, autoimporttestutil.MonorepoSetupConfig{
			Root: monorepoRoot,
			MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{
				Name: "monorepo",
				// Both pkg-listed and pkg-unlisted exist in node_modules
				NodeModuleNames: []string{"pkg-listed", "pkg-unlisted"},
				// But only pkg-listed is in the root package.json dependencies
				DependencyNames: []string{"pkg-listed"},
			},
			Packages: []autoimporttestutil.MonorepoPackageConfig{
				{
					FileCount: 0,
					MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{
						Name: "a",
						// package-a only lists pkg-listed in its package.json
						DependencyNames: []string{"pkg-listed"},
					},
				},
				{
					FileCount: 0,
					MonorepoPackageTemplate: autoimporttestutil.MonorepoPackageTemplate{
						Name: "b",
						// package-b also only lists pkg-listed in its package.json
						DependencyNames: []string{"pkg-listed"},
					},
				},
			},
			ExtraFiles: []autoimporttestutil.TextFileSpec{
				// project-a directly imports pkg-unlisted (not in package.json)
				{Path: packageAIndex, Content: "import { pkg_unlisted_value } from \"pkg-unlisted\";\nexport const a = pkg_unlisted_value;\n"},
				// project-b does not import pkg-unlisted
				{Path: packageBIndex, Content: "export const b = 1;\n"},
			},
		})
		session := fixture.Session()
		packageAHandle := fixture.ExtraFile(packageAIndex)
		packageBHandle := fixture.ExtraFile(packageBIndex)

		ctx := context.Background()

		// Open file in project-a (which imports pkg-unlisted)
		session.DidOpenFile(ctx, packageAHandle.URI(), 1, packageAHandle.Content(), lsproto.LanguageKindTypeScript)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, packageAHandle.URI())
		assert.NilError(t, err)

		// Open file in project-b (which does not import pkg-unlisted)
		session.DidOpenFile(ctx, packageBHandle.URI(), 1, packageBHandle.Content(), lsproto.LanguageKindTypeScript)
		// Request auto-imports for project-b
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, packageBHandle.URI())
		assert.NilError(t, err)

		// Verify that the node_modules bucket includes pkg-unlisted
		// even though we requested auto-imports for project-b which doesn't list it.
		// This is because project-a imports it directly, and the bucket should include
		// resolved packages from all projects that share the node_modules directory.
		stats := autoImportStats(t, session)
		nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
		assert.Assert(t, nodeModulesBucket.DependencyNames.Has("pkg-listed"), "pkg-listed should be in dependencies")
		assert.Assert(t, nodeModulesBucket.DependencyNames.Has("pkg-unlisted"), "pkg-unlisted should be in dependencies because project-a imports it")
	})

	t.Run("symlinked monorepo invalidates on source file change", func(t *testing.T) {
		// This test verifies that when a source file in a symlinked project reference
		// is modified, the node_modules bucket is properly invalidated.
		//
		// Scenario:
		// 1. project-a imports from project-b (symlinked into node_modules)
		// 2. project-b has projectBFunction export
		// 3. Open project-b's source file and delete the export
		// 4. Verify the node_modules bucket is invalidated and rebuilt
		//
		// We also include a regular (non-symlinked) package "other-pkg" to observe
		// whether a change to project-b triggers a full rebuild of the entire bucket
		// or a more granular update.
		t.Parallel()
		monorepoRoot := "/home/src/symlinked-monorepo-invalidation"
		projectADir := tspath.CombinePaths(monorepoRoot, "packages", "project-a")
		projectBDir := tspath.CombinePaths(monorepoRoot, "packages", "project-b")
		projectAIndex := tspath.CombinePaths(projectADir, "src", "index.ts")
		projectBSrcIndex := tspath.CombinePaths(projectBDir, "src", "index.ts")
		projectBDistIndex := tspath.CombinePaths(projectBDir, "dist", "index.d.ts")
		otherPkgDir := tspath.CombinePaths(projectADir, "node_modules", "other-pkg")

		files := map[string]any{
			// project-b: the library package
			tspath.CombinePaths(projectBDir, "tsconfig.json"): `{
				"compilerOptions": {
					"composite": true,
					"outDir": "./dist",
					"rootDir": "./src",
					"declaration": true,
					"module": "esnext",
					"strict": true
				},
				"include": ["src"]
			}`,
			tspath.CombinePaths(projectBDir, "package.json"): `{
				"name": "project-b",
				"version": "1.0.0",
				"main": "dist/index.js",
				"types": "dist/index.d.ts"
			}`,
			projectBSrcIndex: `export function projectBFunction(): string { return "hello"; }
export const projectBValue: number = 42;`,
			projectBDistIndex: `export declare function projectBFunction(): string;
export declare const projectBValue: number;`,
			// other-pkg: a regular (non-symlinked) package
			tspath.CombinePaths(otherPkgDir, "package.json"): `{
				"name": "other-pkg",
				"version": "1.0.0",
				"main": "index.js",
				"types": "index.d.ts"
			}`,
			tspath.CombinePaths(otherPkgDir, "index.d.ts"): `export declare function otherFunction(): void;
export declare const otherValue: string;`,
			// project-a: the consumer package
			tspath.CombinePaths(projectADir, "tsconfig.json"): `{
				"compilerOptions": {
					"module": "esnext",
					"strict": true,
					"outDir": "./dist",
					"rootDir": "./src"
				},
				"include": ["src"],
				"references": [{ "path": "../project-b" }]
			}`,
			tspath.CombinePaths(projectADir, "package.json"): `{
				"name": "project-a",
				"dependencies": { "project-b": "*", "other-pkg": "*" }
			}`,
			projectAIndex: `console.log("hello");
`,
			// Symlink: project-b is accessible via node_modules
			tspath.CombinePaths(projectADir, "node_modules", "project-b"): vfstest.Symlink(projectBDir),
		}

		session, _ := projecttestutil.Setup(files)
		t.Cleanup(session.Close)
		ctx := context.Background()

		// Open project-a's index file and get initial auto-imports
		projectAURI := lsconv.FileNameToDocumentURI(projectAIndex)
		projectAContent := files[projectAIndex].(string)
		session.DidOpenFile(ctx, projectAURI, 1, projectAContent, lsproto.LanguageKindTypeScript)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, projectAURI)
		assert.NilError(t, err)

		// Verify initial state: bucket is clean with files
		stats := autoImportStats(t, session)
		nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
		initialFileCount := nodeModulesBucket.FileCount
		assert.Equal(t, nodeModulesBucket.State.Dirty(), false, "bucket should be clean initially")
		assert.Assert(t, initialFileCount > 0, "bucket should have files initially")

		// Open project-b's source file
		projectBURI := lsconv.FileNameToDocumentURI(projectBSrcIndex)
		projectBContent := files[projectBSrcIndex].(string)
		session.DidOpenFile(ctx, projectBURI, 1, projectBContent, lsproto.LanguageKindTypeScript)

		// Modify the file (delete one export)
		newProjectBContent := `export const projectBValue: number = 42;`
		session.DidChangeFile(ctx, projectBURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
			{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: newProjectBContent}},
		})

		// Check that the node_modules bucket is now dirty
		_, err = session.GetLanguageService(ctx, projectAURI)
		assert.NilError(t, err)
		stats = autoImportStats(t, session)
		nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, nodeModulesBucket.State.Dirty(), true, "bucket should be dirty after source file change")

		// Verify that only project-b is marked for update, not other-pkg.
		// This tests that we correctly track which packages need granular updates.
		dirtyPackages := nodeModulesBucket.State.DirtyPackages()
		assert.Assert(t, dirtyPackages != nil, "dirty packages should be tracked")
		assert.Assert(t, dirtyPackages.Has("project-b"), "project-b should be in dirty packages")
		assert.Assert(t, !dirtyPackages.Has("other-pkg"), "other-pkg should NOT be in dirty packages")
		assert.Equal(t, dirtyPackages.Len(), 1, "only one package should be dirty")

		// Rebuild by requesting auto-imports again.
		// NOTE: Currently the entire bucket is rebuilt, not just the dirty packages.
		// The dirtyPackages tracking is in place for future granular update implementation.
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, projectAURI)
		assert.NilError(t, err)

		// Verify bucket is clean again after rebuild
		stats = autoImportStats(t, session)
		nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, nodeModulesBucket.State.Dirty(), false, "bucket should be clean after rebuild")
	})

	t.Run("pnpm-style symlinks only grant granular updates to workspace packages", func(t *testing.T) {
		// In pnpm, every package in node_modules is symlinked — registry packages
		// are symlinked into node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>.
		// Only local workspace packages (whose realpaths are outside node_modules
		// and within the workspace root) should be eligible for granular updates.
		// Registry packages should trigger full bucket rebuilds.
		t.Parallel()
		monorepoRoot := "/home/src/pnpm-monorepo"
		projectADir := tspath.CombinePaths(monorepoRoot, "packages", "project-a")
		projectBDir := tspath.CombinePaths(monorepoRoot, "packages", "project-b")
		projectAIndex := tspath.CombinePaths(projectADir, "src", "index.ts")
		projectBSrcIndex := tspath.CombinePaths(projectBDir, "src", "index.ts")
		projectBDistIndex := tspath.CombinePaths(projectBDir, "dist", "index.d.ts")

		// Simulated pnpm virtual store for a registry package (inside project-a's node_modules).
		// In real pnpm, this would be node_modules/.pnpm/other-pkg@1.0.0/node_modules/other-pkg,
		// but we use a simplified path to avoid creating nested node_modules buckets in tests.
		pnpmStoreDir := tspath.CombinePaths(projectADir, "node_modules", ".pnpm-store", "other-pkg@1.0.0")
		otherPkgIndex := tspath.CombinePaths(pnpmStoreDir, "index.d.ts")

		files := map[string]any{
			// project-b: a local workspace package
			tspath.CombinePaths(projectBDir, "tsconfig.json"): `{
				"compilerOptions": {
					"composite": true,
					"outDir": "./dist",
					"rootDir": "./src",
					"declaration": true,
					"module": "esnext",
					"strict": true
				},
				"include": ["src"]
			}`,
			tspath.CombinePaths(projectBDir, "package.json"): `{
				"name": "project-b",
				"version": "1.0.0",
				"main": "dist/index.js",
				"types": "dist/index.d.ts"
			}`,
			projectBSrcIndex: `export function projectBFunction(): string { return "hello"; }
export const projectBValue: number = 42;`,
			projectBDistIndex: `export declare function projectBFunction(): string;
export declare const projectBValue: number;`,
			// other-pkg: a registry package in pnpm's virtual store
			tspath.CombinePaths(pnpmStoreDir, "package.json"): `{
				"name": "other-pkg",
				"version": "1.0.0",
				"main": "index.js",
				"types": "index.d.ts"
			}`,
			otherPkgIndex: `export declare function otherFunction(): void;
export declare const otherValue: string;`,
			// project-a: the consumer package
			tspath.CombinePaths(projectADir, "tsconfig.json"): `{
				"compilerOptions": {
					"module": "esnext",
					"strict": true,
					"outDir": "./dist",
					"rootDir": "./src"
				},
				"include": ["src"],
				"references": [{ "path": "../project-b" }]
			}`,
			tspath.CombinePaths(projectADir, "package.json"): `{
				"name": "project-a",
				"dependencies": { "project-b": "*", "other-pkg": "*" }
			}`,
			projectAIndex: `console.log("hello");
`,
			// Symlink: local workspace package (realpath outside node_modules)
			tspath.CombinePaths(projectADir, "node_modules", "project-b"): vfstest.Symlink(projectBDir),
			// Symlink: pnpm-style registry package (realpath inside node_modules/.pnpm)
			tspath.CombinePaths(projectADir, "node_modules", "other-pkg"): vfstest.Symlink(pnpmStoreDir),
		}

		session, _ := projecttestutil.SetupWithOptions(files, &project.SessionOptions{
			CurrentDirectory:       monorepoRoot,
			DefaultLibraryPath:     bundled.LibPath(),
			PositionEncoding:       lsproto.PositionEncodingKindUTF8,
			WatchEnabled:           true,
			LoggingEnabled:         true,
			PushDiagnosticsEnabled: true,
		})
		t.Cleanup(session.Close)
		ctx := context.Background()

		// Open project-a's index file and build auto-imports
		projectAURI := lsconv.FileNameToDocumentURI(projectAIndex)
		projectAContent := files[projectAIndex].(string)
		session.DidOpenFile(ctx, projectAURI, 1, projectAContent, lsproto.LanguageKindTypeScript)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, projectAURI)
		assert.NilError(t, err)

		// Verify initial state: bucket is clean
		stats := autoImportStats(t, session)
		nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, nodeModulesBucket.State.Dirty(), false, "bucket should be clean initially")

		// Modify project-b's source file (local workspace package)
		projectBURI := lsconv.FileNameToDocumentURI(projectBSrcIndex)
		projectBContent := files[projectBSrcIndex].(string)
		session.DidOpenFile(ctx, projectBURI, 1, projectBContent, lsproto.LanguageKindTypeScript)
		session.DidChangeFile(ctx, projectBURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
			{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `export const projectBValue: number = 42;`}},
		})

		// project-b should get a granular update (tracked in dirtyPackages)
		_, err = session.GetLanguageService(ctx, projectAURI)
		assert.NilError(t, err)
		stats = autoImportStats(t, session)
		nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, nodeModulesBucket.State.Dirty(), true, "bucket should be dirty after workspace package change")
		dirtyPackages := nodeModulesBucket.State.DirtyPackages()
		assert.Assert(t, dirtyPackages != nil, "dirty packages should be tracked for workspace package")
		assert.Assert(t, dirtyPackages.Has("project-b"), "project-b should be in dirty packages")
		assert.Equal(t, dirtyPackages.Len(), 1, "only project-b should be dirty")

		// Rebuild to clear dirty state
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, projectAURI)
		assert.NilError(t, err)
		stats = autoImportStats(t, session)
		nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, nodeModulesBucket.State.Dirty(), false, "bucket should be clean after rebuild")

		// Now modify other-pkg (pnpm registry package, realpath inside node_modules/.pnpm)
		otherPkgURI := lsconv.FileNameToDocumentURI(otherPkgIndex)
		otherPkgContent := files[otherPkgIndex].(string)
		session.DidOpenFile(ctx, otherPkgURI, 1, otherPkgContent, lsproto.LanguageKindTypeScript)
		session.DidChangeFile(ctx, otherPkgURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
			{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `export declare function otherFunction(): void;`}},
		})

		// other-pkg should trigger a full rebuild (multipleFilesDirty), not a granular update.
		// Read stats from the request-time snapshot (held by ref via WithLanguageServiceAndSnapshot)
		// rather than session.Snapshot(), so a background auto-import warm task — which would
		// walk otherPkgURI's ancestor chain through project-a's node_modules and rebuild that
		// bucket — cannot race with the assertion by adopting a freshly-cleaned snapshot
		// before we read it.
		_, err = session.WithLanguageServiceAndSnapshot(
			ctx,
			projectAURI,
			func(_ *ls.LanguageService, snapshot *project.Snapshot) (func() error, error) {
				stats = snapshot.AutoImportRegistry().GetCacheStats()
				return nil, nil
			},
		)
		assert.NilError(t, err)
		nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, nodeModulesBucket.State.Dirty(), true, "bucket should be dirty after registry package change")
		dirtyPackages = nodeModulesBucket.State.DirtyPackages()
		// A full rebuild means dirtyPackages is nil (multipleFilesDirty takes precedence)
		// or dirtyPackages doesn't contain "other-pkg" as a granular entry
		if dirtyPackages != nil {
			assert.Assert(t, !dirtyPackages.Has("other-pkg"), "other-pkg should NOT be in dirty packages (should trigger full rebuild)")
		}
	})

	t.Run("changed fileExcludePatterns triggers bucket rebuild", func(t *testing.T) {
		t.Parallel()
		fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
		session := fixture.Session()
		project := fixture.SingleProject()
		mainFile := project.File(0)

		ctx := context.Background()

		// Open file and build auto-imports initially
		session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
		assert.NilError(t, err)

		// Verify buckets are clean after initial build
		stats := autoImportStats(t, session)
		projectBucket := singleBucket(t, stats.ProjectBuckets)
		nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, false, projectBucket.State.Dirty())
		assert.Equal(t, false, nodeModulesBucket.State.Dirty())

		// IsPreparedForImportingFile should return true with no exclude patterns
		snapshot := session.Snapshot()
		defaultProject := snapshot.GetDefaultProject(mainFile.URI())
		assert.Assert(t, defaultProject != nil)
		projectPath := defaultProject.ConfigFilePath()
		preferences := lsutil.NewDefaultUserPreferences()
		preferences.IncludeCompletionsForModuleExports = core.TSTrue
		preferences.IncludeCompletionsForImportStatements = core.TSTrue
		isPrepared := snapshot.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, preferences)
		assert.Assert(t, isPrepared)

		// Change the file exclude patterns preference
		newPreferences := lsutil.NewDefaultUserPreferences()
		newPreferences.IncludeCompletionsForModuleExports = core.TSTrue
		newPreferences.IncludeCompletionsForImportStatements = core.TSTrue
		newPreferences.AutoImportFileExcludePatterns = []string{"**/node_modules/**/*.d.ts"}
		session.Configure(newPreferences)

		// IsPreparedForImportingFile should return false since exclude patterns changed
		snapshot2 := session.Snapshot()
		isPrepared2 := snapshot2.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, newPreferences)
		assert.Assert(t, !isPrepared2)

		// After GetCurrentLanguageServiceWithAutoImports, buckets should be rebuilt
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
		assert.NilError(t, err)

		// IsPreparedForImportingFile should return true now that buckets are rebuilt
		snapshot3 := session.Snapshot()
		isPrepared3 := snapshot3.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, newPreferences)
		assert.Assert(t, isPrepared3, "IsPreparedForImportingFile should return true after bucket rebuild with new fileExcludePatterns")
	})

	t.Run("dedupes packages that resolve to same realpath across ancestor node_modules buckets", func(t *testing.T) {
		t.Parallel()

		repoRoot := "/home/src/autoimport-realpath-dedupe"
		appDir := tspath.CombinePaths(repoRoot, "apps", "web")
		sharedPkgDir := tspath.CombinePaths(repoRoot, "node_modules", "shared")
		appIndex := tspath.CombinePaths(appDir, "src", "index.ts")

		files := map[string]any{
			tspath.CombinePaths(repoRoot, "package.json"): `{
				"name": "repo-root",
				"private": true,
				"dependencies": { "shared": "*" }
			}`,
			tspath.CombinePaths(repoRoot, "tsconfig.json"): `{
				"compilerOptions": {
					"module": "esnext",
					"target": "esnext",
					"strict": true
				},
				"include": ["apps/**/*"]
			}`,
			tspath.CombinePaths(appDir, "package.json"): `{
				"name": "web",
				"private": true,
				"dependencies": { "shared": "*" }
			}`,
			tspath.CombinePaths(appDir, "tsconfig.json"): `{
				"compilerOptions": {
					"module": "esnext",
					"target": "esnext",
					"strict": true
				},
				"include": ["src"]
			}`,
			appIndex: "export const app = 1;\n",
			tspath.CombinePaths(sharedPkgDir, "package.json"): `{
				"name": "shared",
				"version": "1.0.0",
				"types": "index.d.ts"
			}`,
			tspath.CombinePaths(sharedPkgDir, "index.d.ts"):       "export declare const sharedValue: 1;\n",
			tspath.CombinePaths(appDir, "node_modules", "shared"): vfstest.Symlink(sharedPkgDir),
		}

		session, _ := projecttestutil.Setup(files)
		t.Cleanup(session.Close)

		ctx := context.Background()
		appURI := lsconv.FileNameToDocumentURI(appIndex)
		session.DidOpenFile(ctx, appURI, 1, files[appIndex].(string), lsproto.LanguageKindTypeScript)

		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, appURI)
		assert.NilError(t, err)

		stats := autoImportStats(t, session)
		assert.Equal(t, len(stats.NodeModulesBuckets), 2, "expected both app and repo node_modules buckets")
		assert.Equal(t, stats.UniquePackageCount, 1, "expected one unique package after realpath dedup")
	})
}

func TestHiddenDirectoriesInNodeModules(t *testing.T) {
	t.Parallel()
	t.Run("deep import through subdirectory package.json in hidden store", func(t *testing.T) {
		// Simulates a realistic scenario where:
		// 1. A package is symlinked from node_modules into a hidden store directory
		// 2. The user does a deep import like `import { debug } from "some-pkg/debug"`
		// 3. The package has NO "exports" field, so resolution uses the nested
		//    package.json at some-pkg/debug/package.json
		// 4. That nested package.json has no "name" or "version" (just {"main":"..."}),
		//    which is completely normal for subdirectory package.json files
		// 5. getPackageId uses the nested package.json (not the root), fails to get
		//    a name/version, so PackageId is empty
		// 6. collectPackageNames falls through to GetPackageNameFromDirectory, which
		//    extracts ".yarn-store" from the realpath after /node_modules/
		// See https://github.com/microsoft/typescript-go/issues/2780
		t.Parallel()
		projectRoot := "/home/src/fuse-project"
		storeDir := projectRoot + "/node_modules/.yarn-store"
		pkgStoreDir := storeDir + "/some-pkg-npm-1.0.0-abc123/package"

		files := map[string]any{
			projectRoot + "/tsconfig.json": `{
				"compilerOptions": {
					"module": "commonjs",
					"target": "es2020",
					"strict": true
				}
			}`,
			projectRoot + "/package.json": `{
				"name": "test-project",
				"dependencies": {
					"some-pkg": "*",
					"real-package": "*"
				}
			}`,
			// Deep import: "some-pkg/debug" — resolves through the subdirectory package.json
			projectRoot + "/index.ts": `import { debug } from "some-pkg/debug";`,

			// Real package that should be indexed normally
			projectRoot + "/node_modules/real-package/package.json": `{"name":"real-package","version":"1.0.0","types":"index.d.ts"}`,
			projectRoot + "/node_modules/real-package/index.d.ts":   "export declare const realExport: number;\n",

			// Symlink: node_modules/some-pkg -> .yarn-store/.../package/
			projectRoot + "/node_modules/some-pkg": vfstest.Symlink(pkgStoreDir),

			// Root package.json with name+version but NO "exports" field.
			// This is key: without exports, the resolver resolves deep imports
			// through the subdirectory package.json, not the root.
			pkgStoreDir + "/package.json": `{"name":"some-pkg","version":"1.0.0","types":"index.d.ts"}`,
			pkgStoreDir + "/index.d.ts":   "export declare const something: number;\n",
			// Subdirectory package.json for the deep import — no name or version,
			// just a main field. This is normal for packages that expose subpaths
			// without using the "exports" field.
			pkgStoreDir + "/debug/package.json": `{"main":"./debug.js","types":"./debug.d.ts"}`,
			pkgStoreDir + "/debug/debug.d.ts":   "export declare function debug(msg: string): void;\n",
			pkgStoreDir + "/debug/debug.js":     "exports.debug = function(msg) { console.log(msg); };\n",

			// Other content in the hidden store that should never be crawled
			storeDir + "/other-pkg-npm-2.0.0-def456/package/package.json": `{"name":"other-pkg","version":"1.0.0","types":"index.d.ts"}`,
			storeDir + "/other-pkg-npm-2.0.0-def456/package/index.d.ts":   "export declare const other: string;\n",
		}

		session, _ := projecttestutil.Setup(files)
		t.Cleanup(session.Close)

		ctx := context.Background()
		indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
		session.DidOpenFile(ctx, indexURI, 1, files[projectRoot+"/index.ts"].(string), lsproto.LanguageKindTypeScript)

		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
		assert.NilError(t, err)

		stats := autoImportStats(t, session)
		nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)

		// .yarn-store must not appear as a dependency name.
		// If it does, extractPackages will try to process the entire hidden
		// directory (ReadDirectory **/*), which is the CPU/memory blowup.
		assert.Assert(t, nodeModulesBucket.DependencyNames != nil, "DependencyNames should not be nil")
		for name := range nodeModulesBucket.DependencyNames.Keys() {
			assert.Assert(t, name[0] != '.', "hidden directory %q should not appear as a dependency name", name)
		}
	})
}

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

	projectRoot := "/home/src/entrypoint-search"
	nodeModulesDir := projectRoot + "/node_modules"
	pkgDir := nodeModulesDir + "/my-pkg"

	files := map[string]any{
		projectRoot + "/tsconfig.json": `{
			"compilerOptions": {
				"module": "commonjs",
				"target": "es2020"
			}
		}`,
		projectRoot + "/package.json": `{
			"name": "test-project",
			"dependencies": { "my-pkg": "*" }
		}`,
		projectRoot + "/index.ts": `import { main } from "my-pkg";`,
		// Package with NO "exports" field and multiple files.
		// Without the preference, only the main entrypoint is found.
		// With the preference, directory search finds all .d.ts files.
		pkgDir + "/package.json":       `{"name":"my-pkg","version":"1.0.0","types":"index.d.ts"}`,
		pkgDir + "/index.d.ts":         "export declare const main: number;\n",
		pkgDir + "/extra.d.ts":         "export declare const extra: string;\n",
		pkgDir + "/nested/deep.d.ts":   "export declare const deep: boolean;\n",
		pkgDir + "/nested/deeper.d.ts": "export declare const deeper: boolean;\n",
	}

	t.Run("default limits to main entrypoint", func(t *testing.T) {
		t.Parallel()
		session, _ := projecttestutil.Setup(files)
		t.Cleanup(session.Close)

		ctx := context.Background()
		indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
		session.DidOpenFile(ctx, indexURI, 1, files[projectRoot+"/index.ts"].(string), lsproto.LanguageKindTypeScript)

		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
		assert.NilError(t, err)

		stats := autoImportStats(t, session)
		nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
		// Without the preference, only the main entrypoint (index.d.ts) should be found
		assert.Equal(t, 1, nodeModulesBucket.FileCount, "expected only 1 file (main entrypoint) by default")
	})

	t.Run("autoImportEntrypointDirectorySearch enables all files", func(t *testing.T) {
		t.Parallel()
		session, _ := projecttestutil.Setup(files)
		t.Cleanup(session.Close)

		prefs := lsutil.NewDefaultUserPreferences()
		prefs.AutoImportEntrypointDirectorySearch = core.TSTrue
		session.Configure(prefs)

		ctx := context.Background()
		indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
		session.DidOpenFile(ctx, indexURI, 1, files[projectRoot+"/index.ts"].(string), lsproto.LanguageKindTypeScript)

		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
		assert.NilError(t, err)

		stats := autoImportStats(t, session)
		nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
		// With the preference, all 4 .d.ts files should be found via directory search
		assert.Assert(t, nodeModulesBucket.FileCount >= 4, "expected at least 4 files from directory search, got %d", nodeModulesBucket.FileCount)
	})

	t.Run("changing preference triggers rebuild", func(t *testing.T) {
		t.Parallel()
		session, _ := projecttestutil.Setup(files)
		t.Cleanup(session.Close)

		ctx := context.Background()
		indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
		session.DidOpenFile(ctx, indexURI, 1, files[projectRoot+"/index.ts"].(string), lsproto.LanguageKindTypeScript)

		// Build auto-imports with default preferences (directory search disabled)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
		assert.NilError(t, err)

		stats := autoImportStats(t, session)
		nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, 1, nodeModulesBucket.FileCount, "expected only 1 file initially")

		// Now enable directory search
		prefs := lsutil.NewDefaultUserPreferences()
		prefs.AutoImportEntrypointDirectorySearch = core.TSTrue
		session.Configure(prefs)

		// Registry should report not prepared (preference changed)
		snapshot := session.Snapshot()
		defaultProject := snapshot.GetDefaultProject(indexURI)
		assert.Assert(t, defaultProject != nil)
		projectPath := defaultProject.ConfigFilePath()
		isPrepared := snapshot.AutoImportRegistry().IsPreparedForImportingFile(
			projectRoot+"/index.ts", projectPath, prefs,
		)
		assert.Assert(t, !isPrepared, "registry should not be prepared after preference change")

		// Rebuild
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
		assert.NilError(t, err)

		stats = autoImportStats(t, session)
		nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
		assert.Assert(t, nodeModulesBucket.FileCount >= 4, "expected at least 4 files after rebuild with directory search enabled, got %d", nodeModulesBucket.FileCount)
	})

	t.Run("deep import from program update enables recursive search for that package", func(t *testing.T) {
		t.Parallel()
		session, _ := projecttestutil.Setup(files)
		t.Cleanup(session.Close)

		ctx := context.Background()
		indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
		session.DidOpenFile(ctx, indexURI, 1, files[projectRoot+"/index.ts"].(string), lsproto.LanguageKindTypeScript)

		// Initial build with top-level import only ("my-pkg", not a deep import)
		_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
		assert.NilError(t, err)

		stats := autoImportStats(t, session)
		nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
		assert.Equal(t, 1, nodeModulesBucket.FileCount, "expected only 1 file (main entrypoint) before deep import")

		// Now update the program to add a deep import from the same package
		newContent := "import { main } from \"my-pkg\";\nimport { deep } from \"my-pkg/nested/deep\";\n"
		session.DidChangeFile(ctx, indexURI, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{
			{WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: newContent}},
		})

		// After the program update, auto-imports should detect the deep import and
		// enable recursive directory search for my-pkg, finding all .d.ts files.
		_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
		assert.NilError(t, err)

		stats = autoImportStats(t, session)
		nodeModulesBucket = singleBucket(t, stats.NodeModulesBuckets)
		assert.Assert(t, nodeModulesBucket.FileCount >= 4, "expected at least 4 files after deep import triggers recursive search, got %d", nodeModulesBucket.FileCount)
	})
}

const (
	lifecycleProjectRoot = "/home/src/autoimport-lifecycle"
	monorepoProjectRoot  = "/home/src/autoimport-monorepo"
)

func TestUpdateIndexesConcurrentMapSafety(t *testing.T) {
	t.Parallel()
	const projectRoot = "/home/src/autoimport-fallback-race"
	const packageCount = 40

	files := map[string]any{
		projectRoot + "/tsconfig.json": `{
			"compilerOptions": { "module": "esnext", "target": "esnext", "strict": true }
		}`,
		projectRoot + "/index.ts": "export {};\n",
	}
	for i := range packageCount {
		pkgDir := fmt.Sprintf("%s/node_modules/pkg%d", projectRoot, i)
		files[pkgDir+"/package.json"] = fmt.Sprintf(`{"name":"pkg%d","version":"1.0.0","main":"index.js"}`, i)
		files[pkgDir+"/index.js"] = "module.exports = {};\n"
		typesDir := fmt.Sprintf("%s/node_modules/@types/pkg%d", projectRoot, i)
		files[typesDir+"/package.json"] = fmt.Sprintf(`{"name":"@types/pkg%d","version":"1.0.0","types":"index.d.ts"}`, i)
		files[typesDir+"/index.d.ts"] = fmt.Sprintf("export declare const foo%d: number;\n", i)
	}

	session, _ := projecttestutil.Setup(files)
	t.Cleanup(session.Close)

	ctx := context.Background()
	indexURI := lsproto.DocumentUri("file://" + projectRoot + "/index.ts")
	session.DidOpenFile(ctx, indexURI, 1, "export {};\n", lsproto.LanguageKindTypeScript)

	_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, indexURI)
	assert.NilError(t, err)

	stats := autoImportStats(t, session)
	nodeModulesBucket := singleBucket(t, stats.NodeModulesBuckets)
	assert.Equal(t, nodeModulesBucket.ExportCount, packageCount)
}

func autoImportStats(t *testing.T, session *project.Session) *autoimport.CacheStats {
	t.Helper()
	snapshot := session.Snapshot()
	registry := snapshot.AutoImportRegistry()
	if registry == nil {
		t.Fatal("auto import registry not initialized")
	}
	return registry.GetCacheStats()
}

func singleBucket(t *testing.T, buckets []autoimport.BucketStats) autoimport.BucketStats {
	t.Helper()
	if len(buckets) != 1 {
		t.Fatalf("expected 1 bucket, got %d", len(buckets))
	}
	return buckets[0]
}
