package project

import (
	"context"
	"fmt"
	"maps"
	"slices"
	"time"

	"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/diagnostics"
	"github.com/microsoft/typescript-go/internal/lsp/lsproto"
	"github.com/microsoft/typescript-go/internal/project/dirty"
	"github.com/microsoft/typescript-go/internal/project/logging"
	"github.com/microsoft/typescript-go/internal/tsoptions"
	"github.com/microsoft/typescript-go/internal/tspath"
)

type projectLoadKind int

const (
	// Project is not created or updated, only looked up in cache
	projectLoadKindFind projectLoadKind = iota
	// Project is created and then its graph is updated
	projectLoadKindCreate
)

type ProjectCollectionBuilder struct {
	sessionOptions      *SessionOptions
	parseCache          *ParseCache
	extendedConfigCache *ExtendedConfigCache
	toPath              func(fileName string) tspath.Path

	ctx                                context.Context
	fs                                 *snapshotFSBuilder
	base                               *ProjectCollection
	compilerOptionsForInferredProjects *core.CompilerOptions
	configFileRegistryBuilder          *configFileRegistryBuilder

	client Client // optional; used for project loading notifications

	newSnapshotID              uint64
	programStructureChanged    bool
	defaultProjectsInvalidated bool
	openFilesChanged           bool

	fileDefaultProjects map[tspath.Path]tspath.Path
	configuredProjects  *dirty.SyncMap[tspath.Path, *Project]
	inferredProject     *dirty.Box[*Project]

	apiState APIState
}

func newProjectCollectionBuilder(
	ctx context.Context,
	newSnapshotID uint64,
	fs *snapshotFSBuilder,
	oldProjectCollection *ProjectCollection,
	oldConfigFileRegistry *ConfigFileRegistry,
	oldAPIState APIState,
	compilerOptionsForInferredProjects *core.CompilerOptions,
	sessionOptions *SessionOptions,
	customConfigFileName string,
	parseCache *ParseCache,
	extendedConfigCache *ExtendedConfigCache,
	client Client,
) *ProjectCollectionBuilder {
	return &ProjectCollectionBuilder{
		ctx:                                ctx,
		fs:                                 fs,
		toPath:                             fs.toPath,
		compilerOptionsForInferredProjects: compilerOptionsForInferredProjects,
		sessionOptions:                     sessionOptions,
		parseCache:                         parseCache,
		extendedConfigCache:                extendedConfigCache,
		base:                               oldProjectCollection,
		configFileRegistryBuilder:          newConfigFileRegistryBuilder(lsproto.GetClientCapabilities(ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, fs, oldConfigFileRegistry, extendedConfigCache, newSnapshotID, sessionOptions, customConfigFileName, nil),
		newSnapshotID:                      newSnapshotID,
		configuredProjects:                 dirty.NewSyncMap(oldProjectCollection.configuredProjects),
		inferredProject:                    dirty.NewBox(oldProjectCollection.inferredProject),
		apiState:                           oldAPIState.clone(),
		client:                             client,
	}
}

func (b *ProjectCollectionBuilder) Finalize(logger *logging.LogTree) (*ProjectCollection, *ConfigFileRegistry) {
	var changed bool
	newProjectCollection := b.base
	ensureCloned := func() {
		if !changed {
			newProjectCollection = newProjectCollection.clone()
			changed = true
		}
	}

	if configuredProjects, configuredProjectsChanged := b.configuredProjects.Finalize(); configuredProjectsChanged {
		ensureCloned()
		newProjectCollection.configuredProjects = configuredProjects
	}

	if b.openFilesChanged {
		ensureCloned()
		newProjectCollection.openFiles = openFilePaths(b.fs.overlays)
	}

	if !maps.Equal(b.fileDefaultProjects, b.base.fileDefaultProjects) {
		ensureCloned()
		newProjectCollection.fileDefaultProjects = b.fileDefaultProjects
	}

	if newInferredProject, inferredProjectChanged := b.inferredProject.Finalize(); inferredProjectChanged {
		ensureCloned()
		newProjectCollection.inferredProject = newInferredProject
	}

	configFileRegistry := b.configFileRegistryBuilder.Finalize()
	if configFileRegistry != b.base.configFileRegistry {
		ensureCloned()
		newProjectCollection.configFileRegistry = configFileRegistry
	}

	if !b.apiState.equals(b.base.apiState) {
		ensureCloned()
		newProjectCollection.apiState = b.apiState
	}

	return newProjectCollection, configFileRegistry
}

func (b *ProjectCollectionBuilder) forEachProject(fn func(entry dirty.Value[*Project]) bool) {
	keepGoing := true
	b.configuredProjects.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *Project]) bool {
		keepGoing = fn(entry)
		return keepGoing
	})
	if !keepGoing {
		return
	}
	if b.inferredProject.Value() != nil {
		fn(b.inferredProject)
	}
}

func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotRequest, logger *logging.LogTree) error {
	var projectsToClose map[tspath.Path]struct{}
	if apiRequest.CloseProjects != nil {
		for projectPath := range apiRequest.CloseProjects.Keys() {
			// Ref-counted close: only actually close the project once the last
			// API client that opened it releases it.
			if count := b.apiState.openProjects[projectPath]; count > 1 {
				b.apiState.openProjects[projectPath] = count - 1
			} else if count == 1 {
				delete(b.apiState.openProjects, projectPath)
				if projectsToClose == nil {
					projectsToClose = make(map[tspath.Path]struct{})
				}
				projectsToClose[projectPath] = struct{}{}
			}
		}
	}

	if apiRequest.OpenProjects != nil {
		for configFileName := range apiRequest.OpenProjects.Keys() {
			configPath := b.toPath(configFileName)
			if entry := b.findOrCreateProject(configFileName, configPath, projectLoadKindCreate, logger); entry != nil {
				if b.apiState.openProjects == nil {
					b.apiState.openProjects = make(map[tspath.Path]int)
				}
				b.apiState.openProjects[configPath]++
				// A project re-opened in the same request shouldn't be closed.
				delete(projectsToClose, configPath)
			} else {
				return fmt.Errorf("project not found for open: %s", configFileName)
			}
		}
	}

	if apiRequest.CloseFiles != nil {
		for path := range apiRequest.CloseFiles.Keys() {
			// Ref-counted close mirroring projects above.
			if entry, ok := b.apiState.openFiles[path]; ok {
				if entry.refCount > 1 {
					entry.refCount--
					b.apiState.openFiles[path] = entry
				} else {
					delete(b.apiState.openFiles, path)
				}
			}
		}
	}

	if apiRequest.OpenFiles != nil {
		for uri := range apiRequest.OpenFiles.Keys() {
			fileName := uri.FileName()
			path := b.toPath(fileName)
			if b.apiState.openFiles == nil {
				b.apiState.openFiles = make(map[tspath.Path]apiOpenedFile)
			}
			entry := b.apiState.openFiles[path]
			entry.fileName = fileName
			entry.refCount++
			b.apiState.openFiles[path] = entry
		}
	}

	for configPath := range b.apiState.openProjects {
		if entry, ok := b.configuredProjects.Load(configPath); ok {
			b.updateProgram(entry, logger)
		} else {
			return fmt.Errorf("project not found for update: %s", configPath)
		}
	}

	for _, overlay := range b.fs.overlays {
		if entry := b.findDefaultConfiguredProject(overlay.FileName(), b.toPath(overlay.FileName())); entry != nil {
			delete(projectsToClose, entry.Value().configFilePath)
		}
	}

	for projectPath := range projectsToClose {
		if entry, ok := b.configuredProjects.Load(projectPath); ok {
			b.deleteConfiguredProject(entry, logger)
		}
	}

	// Ensure each API-opened file is placed like LSP's textDocument/didOpen: search
	// up ancestor directories for a configured project that contains it, and only
	// fall back to the inferred project if none is found. This also keeps already
	// loaded configured projects up to date. Then run the same cleanup the LSP open
	// path uses, so configured projects auto-loaded for files that are no longer open
	// are torn down instead of leaking.
	if apiRequest.OpenFiles != nil || apiRequest.CloseFiles != nil {
		var retain collections.Set[tspath.Path]
		for path, file := range b.apiState.openFiles {
			if b.fs.isOpenFile(path) {
				// Already an LSP overlay; its project membership is handled by the
				// overlay pass in cleanupConfiguredProjects.
				continue
			}
			result := b.ensureConfiguredProjectAndAncestorsForFile(file.fileName, path, logger)
			retain.Union(&result.retain)
		}
		b.cleanupConfiguredProjects(&retain, logger)
		if b.inferredProject.Value() != nil {
			b.updateProgram(b.inferredProject, logger)
		}
	}

	return nil
}

func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, logger *logging.LogTree) {
	b.openFilesChanged = b.openFilesChanged || summary.Opened != "" || summary.Closed.Len() > 0

	changedFiles := make([]tspath.Path, 0, summary.Changed.Len())
	for uri := range summary.Changed.Keys() {
		fileName := uri.FileName()
		path := b.toPath(fileName)
		changedFiles = append(changedFiles, path)
	}

	configChangeLogger := logger.Fork("Checking for changes affecting config files")
	configChangeResult := b.configFileRegistryBuilder.DidChangeFiles(summary, configChangeLogger)
	logChangeFileResult(configChangeResult, configChangeLogger)

	b.programStructureChanged = b.markProjectsAffectedByConfigChanges(configChangeResult, logger)

	b.forEachProject(func(entry dirty.Value[*Project]) bool {
		// Only consider change/delete; creates are handled by the config file registry
		if summary.HasExcessiveNonCreateWatchEvents() {
			entry.Change(func(p *Project) {
				p.dirty = true
				p.dirtyFilePath = ""
				if logger != nil {
					logger.Logf("Marking project as dirty due to excessive watch changes: %s", p.configFilePath)
				}
			})
			return true
		}

		// Handle closed and changed files
		b.markFilesChanged(entry, changedFiles, lsproto.FileChangeTypeChanged, logger)
		if entry.Value().Kind == KindInferred && summary.Closed.Len() > 0 {
			rootFilesMap := entry.Value().CommandLine.FileNamesByPath()
			newRootFiles := entry.Value().CommandLine.FileNames()
			for uri := range summary.Closed.Keys() {
				fileName := uri.FileName()
				path := b.toPath(fileName)
				if _, ok := rootFilesMap[path]; ok {
					newRootFiles = slices.Delete(newRootFiles, slices.Index(newRootFiles, fileName), slices.Index(newRootFiles, fileName)+1)
				}
			}
			b.updateInferredProjectRoots(newRootFiles, logger)
		}

		// Handle deleted files
		if summary.Deleted.Len() > 0 {
			deletedPaths := make([]tspath.Path, 0, summary.Deleted.Len())
			for uri := range summary.Deleted.Keys() {
				fileName := uri.FileName()
				path := b.toPath(fileName)
				deletedPaths = append(deletedPaths, path)
			}
			b.markFilesChanged(entry, deletedPaths, lsproto.FileChangeTypeDeleted, logger)
		}

		// Handle created files
		if summary.Created.Len() > 0 {
			createdPaths := make([]tspath.Path, 0, summary.Created.Len())
			for uri := range summary.Created.Keys() {
				fileName := uri.FileName()
				path := b.toPath(fileName)
				createdPaths = append(createdPaths, path)
			}
			b.markFilesChanged(entry, createdPaths, lsproto.FileChangeTypeCreated, logger)
		}

		return true
	})

	// Handle opened file
	if summary.Opened != "" || summary.Reopened != "" {
		fileName := core.FirstNonZero(summary.Opened, summary.Reopened).FileName()
		path := b.toPath(fileName)
		openFileResult := b.ensureConfiguredProjectAndAncestorsForFile(fileName, path, logger)
		b.cleanupConfiguredProjects(&openFileResult.retain, logger)
	}
}

// cleanupConfiguredProjects sweeps the loaded configured projects and unloads those
// that are no longer needed. Starting from the set of all configured projects, it
// retains any project that is the default project (along with its references and
// ancestor configs) of an open overlay file or an API-opened file, any project
// explicitly opened through the API, and any project in retain (e.g. the ancestor
// solution tree built for a freshly opened overlay file). Every other configured
// project is deleted, the inferred project roots are recomputed, and the config file
// registry is cleaned up. This is the shared mechanism that keeps the set of loaded
// projects minimal for both LSP file opens and API file opens/closes.
func (b *ProjectCollectionBuilder) cleanupConfiguredProjects(retain *collections.Set[tspath.Path], logger *logging.LogTree) {
	var toRemoveProjects collections.Set[tspath.Path]
	b.configuredProjects.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *Project]) bool {
		toRemoveProjects.Add(entry.Key())
		return true
	})

	retainProjectAndReferences := func(project *Project) {
		// Retain project
		toRemoveProjects.Delete(project.configFilePath)
		if program := project.GetProgram(); program != nil {
			program.RangeResolvedProjectReference(func(referencePath tspath.Path, _ *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool {
				if _, ok := b.configuredProjects.Load(referencePath); ok {
					toRemoveProjects.Delete(referencePath)
				}
				return true
			})
		}
	}

	retainDefaultConfiguredProject := func(openFilePath tspath.Path, project *Project) {
		// Retain project and its references
		retainProjectAndReferences(project)

		// Retain all the ancestor projects
		b.configFileRegistryBuilder.forEachConfigFileNameFor(openFilePath, func(configFileName string) {
			if ancestor := b.findOrCreateProject(configFileName, b.toPath(configFileName), projectLoadKindFind, logger); ancestor != nil {
				retainProjectAndReferences(ancestor.Value())
			}
		})
	}

	var inferredProjectFiles []string
	for _, overlay := range b.fs.overlays {
		openFile := overlay.FileName()
		openFilePath := b.toPath(openFile)
		if p := b.findDefaultConfiguredProject(openFile, openFilePath); p != nil {
			retainDefaultConfiguredProject(openFilePath, p.Value())
		} else {
			inferredProjectFiles = append(inferredProjectFiles, openFile)
		}
	}
	// Treat API-opened files like open files: retain their configured project (so
	// an LSP-driven open doesn't close it), or keep them as inferred project roots.
	for path, file := range b.apiState.openFiles {
		if b.fs.isOpenFile(path) {
			continue
		}
		if p := b.findDefaultConfiguredProject(file.fileName, path); p != nil {
			retainDefaultConfiguredProject(path, p.Value())
		} else {
			inferredProjectFiles = append(inferredProjectFiles, file.fileName)
		}
	}

	for projectPath := range toRemoveProjects.Keys() {
		if retain.Has(projectPath) {
			continue
		}
		if _, ok := b.apiState.openProjects[projectPath]; ok {
			continue
		}
		if p, ok := b.configuredProjects.Load(projectPath); ok {
			b.deleteConfiguredProject(p, logger)
		}
	}
	b.updateInferredProjectRoots(inferredProjectFiles, logger)
	b.configFileRegistryBuilder.Cleanup()
}

func logChangeFileResult(result changeFileResult, logger *logging.LogTree) {
	if len(result.affectedProjects) > 0 {
		logger.Logf("Config file change affected projects: %v", slices.Collect(maps.Keys(result.affectedProjects)))
	}
	if len(result.affectedFiles) > 0 {
		logger.Logf("Config file change affected config file lookups for %d files", len(result.affectedFiles))
	}
}

func (b *ProjectCollectionBuilder) collectInferredProjectRoots() []string {
	var inferredProjectFiles []string
	for path, overlay := range b.fs.overlays {
		if b.findDefaultConfiguredProject(overlay.FileName(), path) == nil {
			inferredProjectFiles = append(inferredProjectFiles, overlay.FileName())
		}
	}
	return b.appendAPIOpenedInferredRoots(inferredProjectFiles)
}

// appendAPIOpenedInferredRoots appends API-opened files that aren't open in an
// overlay and have no configured project, so they're kept as inferred project
// roots and persist across snapshots.
func (b *ProjectCollectionBuilder) appendAPIOpenedInferredRoots(inferredProjectFiles []string) []string {
	for path, file := range b.apiState.openFiles {
		if b.fs.isOpenFile(path) {
			continue
		}
		if b.findDefaultConfiguredProject(file.fileName, path) == nil {
			inferredProjectFiles = append(inferredProjectFiles, file.fileName)
		}
	}
	return inferredProjectFiles
}

func (b *ProjectCollectionBuilder) cleanupInferredProject(logger *logging.LogTree) {
	b.updateInferredProjectRoots(b.collectInferredProjectRoots(), logger)
}

func (b *ProjectCollectionBuilder) ensureInferredProjectIncludesClosedFile(fileName string, logger *logging.LogTree) {
	// Collect existing inferred project roots (open files not in configured projects)
	// plus this closed file.
	inferredProjectFiles := append(b.collectInferredProjectRoots(), fileName)
	b.updateInferredProjectRoots(inferredProjectFiles, logger)
	if b.inferredProject.Value() != nil {
		b.updateProgram(b.inferredProject, logger)
	}
}

// DidRequestFile ensures projects are loaded for the given URI.
// If configuredProjectsOnly is true, only configured projects are loaded; no inferred project is created
// and it is not guaranteed that there will be any project containing the file in the resulting snapshot.
func (b *ProjectCollectionBuilder) DidRequestFile(uri lsproto.DocumentUri, configuredProjectsOnly bool, logger *logging.LogTree) {
	startTime := time.Now()
	fileName := uri.FileName()
	path := b.toPath(fileName)
	if b.defaultProjectsInvalidated {
		b.ensureConfiguredProjectAndAncestorsForFile(fileName, path, logger)
		if !b.fs.isOpenFile(path) {
			return
		}
	}
	if b.fs.isOpenFile(path) {
		hasChanges := b.programStructureChanged

		// See if we can find a default project without updating a bunch of stuff.
		if result := b.findDefaultProject(fileName, path); result != nil {
			hasChanges = b.updateProgram(result, logger) || hasChanges
			if result.Value() != nil {
				if hasChanges {
					b.cleanupInferredProject(logger)
					if b.inferredProject.Value() != nil {
						b.updateProgram(b.inferredProject, logger)
					}
				}
				return
			}
		}

		// Make sure all projects we know about are up to date...
		b.configuredProjects.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *Project]) bool {
			hasChanges = b.updateProgram(entry, logger) || hasChanges
			return true
		})
		if hasChanges {
			// If the structure of other projects changed, we might need to move files
			// in/out of the inferred project.
			b.cleanupInferredProject(logger)
		}

		if b.inferredProject.Value() != nil {
			b.updateProgram(b.inferredProject, logger)
		}

		// At this point we should be able to find the default project for the file without
		// creating anything else. Initially, I verified that and panicked if nothing was found,
		// but that panic was getting triggered by fourslash infrastructure when it told us to
		// open a package.json file. This is something the VS Code client would never do, but
		// it seems possible that another client would. There's no point in panicking; we don't
		// really even have an error condition until it tries to ask us language questions about
		// a non-TS-handleable file.
	} else {
		result := b.ensureConfiguredProjectAndAncestorsForFile(fileName, path, logger)
		if result.project == nil && !configuredProjectsOnly {
			// No configured project found for this closed file.
			// Add it to the inferred project so language service requests can be served.
			b.ensureInferredProjectIncludesClosedFile(fileName, logger)
		}
	}

	if logger != nil {
		elapsed := time.Since(startTime)
		logger.Log(fmt.Sprintf("Completed file request for %s in %v", fileName, elapsed))
	}
}

func (b *ProjectCollectionBuilder) DidRequestProject(projectId tspath.Path, logger *logging.LogTree) {
	startTime := time.Now()
	if projectId == inferredProjectName {
		// Update inferred project
		if b.inferredProject.Value() != nil {
			b.updateProgram(b.inferredProject, logger)
		}
	} else {
		if entry, ok := b.configuredProjects.Load(projectId); ok {
			b.updateProgram(entry, logger)
		}
	}

	if logger != nil {
		elapsed := time.Since(startTime)
		logger.Log(fmt.Sprintf("Completed project update request for %s in %v", projectId, elapsed))
	}
}

func (b *ProjectCollectionBuilder) DidRequestProjectTrees(projectTreeRequest *ProjectTreeRequest, logger *logging.LogTree) {
	startTime := time.Now()

	var currentProjects []tspath.Path
	b.configuredProjects.Range(func(sme *dirty.SyncMapEntry[tspath.Path, *Project]) bool {
		currentProjects = append(currentProjects, sme.Key())
		return true
	})

	var seenProjects collections.SyncSet[tspath.Path]
	wg := core.NewWorkGroup(false)
	for _, projectId := range currentProjects {
		wg.Queue(func() {
			if entry, ok := b.configuredProjects.Load(projectId); ok {
				// If this project has potential project reference for any of the project we are loading ancestor tree for
				// load this project first
				if project := entry.Value(); project != nil && (projectTreeRequest.IsAllProjects() || project.hasPotentialProjectReference(projectTreeRequest)) {
					b.updateProgram(entry, logger)
				}
				b.ensureProjectTree(wg, entry, projectTreeRequest, &seenProjects, logger)
			}
		})
	}
	wg.RunAndWait()

	if logger != nil {
		elapsed := time.Since(startTime)
		logger.Log(fmt.Sprintf("Completed project tree request for %v in %v", projectTreeRequest.Projects(), elapsed))
	}
}

func (b *ProjectCollectionBuilder) ensureProjectTree(
	wg core.WorkGroup,
	entry *dirty.SyncMapEntry[tspath.Path, *Project],
	projectTreeRequest *ProjectTreeRequest,
	seenProjects *collections.SyncSet[tspath.Path],
	logger *logging.LogTree,
) {
	if !seenProjects.AddIfAbsent(entry.Key()) {
		return
	}

	project := entry.Value()
	if project == nil {
		return
	}

	program := project.GetProgram()
	if program == nil {
		return
	}

	// If this project disables child load ignore it
	if program.CommandLine().CompilerOptions().DisableReferencedProjectLoad.IsTrue() {
		return
	}

	children := program.GetResolvedProjectReferences()
	if children == nil {
		return
	}
	for _, childConfig := range children {
		if childConfig == nil {
			continue
		}
		wg.Queue(func() {
			if !projectTreeRequest.IsAllProjects() && program.RangeResolvedProjectReferenceInChildConfig(
				childConfig,
				func(referencePath tspath.Path, config *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool {
					return !projectTreeRequest.IsProjectReferenced(referencePath)
				},
			) {
				return
			}

			// Load this child project since this is referenced
			childProjectEntry := b.findOrCreateProject(childConfig.ConfigName(), childConfig.ConfigFile.SourceFile.Path(), projectLoadKindCreate, logger)
			b.updateProgram(childProjectEntry, logger)

			// Ensure children for this project
			b.ensureProjectTree(wg, childProjectEntry, projectTreeRequest, seenProjects, logger)
		})
	}
}

func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[tspath.Path]*ATAStateChange, logger *logging.LogTree) {
	updateProject := func(project dirty.Value[*Project], ataChange *ATAStateChange) {
		project.ChangeIf(
			func(p *Project) bool {
				if p == nil {
					return false
				}
				// Consistency check: the ATA demands (project options, unresolved imports) of this project
				// has not changed since the time the ATA request was dispatched; the change can still be
				// applied to this project in its current state.
				return ataChange.TypingsInfo.Equals(p.ComputeTypingsInfo())
			},
			func(p *Project) {
				// We checked before triggering this change (in Session.triggerATAForUpdatedProjects) that
				// the set of typings files is actually different.
				p.installedTypingsInfo = ataChange.TypingsInfo
				p.typingsFiles = ataChange.TypingsFiles
				typingsWatchGlobs := getTypingsLocationsGlobs(
					ataChange.TypingsFilesToWatch,
					b.sessionOptions.TypingsLocation,
					b.sessionOptions.CurrentDirectory,
					p.currentDirectory,
					b.fs.fs.UseCaseSensitiveFileNames(),
				)
				p.typingsWatch = p.typingsWatch.Clone(typingsWatchGlobs)
				p.dirty = true
				p.dirtyFilePath = ""
			},
		)
	}

	for projectPath, ataChange := range ataChanges {
		logger.Embed(ataChange.Logs)
		if projectPath == inferredProjectName {
			updateProject(b.inferredProject, ataChange)
		} else if project, ok := b.configuredProjects.Load(projectPath); ok {
			updateProject(project, ataChange)
		}

		if logger != nil {
			logger.Log(fmt.Sprintf("Updated ATA state for project %s", projectPath))
		}
	}
}

// if customConfigFileName changes, invalidate default projects.
func (b *ProjectCollectionBuilder) DidChangeCustomConfigFileName(logger *logging.LogTree) {
	if !b.configFileRegistryBuilder.DidChangeCustomConfigFileName(logger) {
		return
	}

	b.fileDefaultProjects = nil
	b.defaultProjectsInvalidated = true
	b.programStructureChanged = true
}

func (b *ProjectCollectionBuilder) markProjectsAffectedByConfigChanges(
	configChangeResult changeFileResult,
	logger *logging.LogTree,
) bool {
	for projectPath := range configChangeResult.affectedProjects {
		project, ok := b.configuredProjects.Load(projectPath)
		if !ok {
			panic(fmt.Sprintf("project %s affected by config change not found", projectPath))
		}
		project.ChangeIf(
			func(p *Project) bool { return !p.dirty || p.dirtyFilePath != "" },
			func(p *Project) {
				p.dirty = true
				p.dirtyFilePath = ""
				if logger != nil {
					logger.Logf("Marking project %s as dirty due to change affecting config", projectPath)
				}
			},
		)
	}

	// Recompute default projects for open files that now have different config file presence.
	var hasChanges bool
	for path := range configChangeResult.affectedFiles {
		fileName := b.fs.overlays[path].FileName()
		_ = b.ensureConfiguredProjectAndAncestorsForFile(fileName, path, logger)
		hasChanges = true
	}

	return hasChanges
}

func (b *ProjectCollectionBuilder) findDefaultProject(fileName string, path tspath.Path) dirty.Value[*Project] {
	if configuredProject := b.findDefaultConfiguredProject(fileName, path); configuredProject != nil {
		return configuredProject
	}
	if key, ok := b.fileDefaultProjects[path]; ok && key == inferredProjectName {
		return b.inferredProject
	}
	if inferredProject := b.inferredProject.Value(); inferredProject != nil && inferredProject.containsFile(path) {
		if b.fileDefaultProjects == nil {
			b.fileDefaultProjects = make(map[tspath.Path]tspath.Path)
		}
		b.fileDefaultProjects[path] = inferredProjectName
		return b.inferredProject
	}
	return nil
}

func (b *ProjectCollectionBuilder) findDefaultConfiguredProject(fileName string, path tspath.Path) *dirty.SyncMapEntry[tspath.Path, *Project] {
	if key, ok := b.fileDefaultProjects[path]; ok && key != inferredProjectName {
		if entry, ok := b.configuredProjects.Load(key); ok {
			return entry
		}
	}
	// Sort configured projects so we can use a deterministic "first" as a last resort.
	var configuredProjectPaths []tspath.Path
	configuredProjects := make(map[tspath.Path]*dirty.SyncMapEntry[tspath.Path, *Project])
	b.configuredProjects.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *Project]) bool {
		configuredProjectPaths = append(configuredProjectPaths, entry.Key())
		configuredProjects[entry.Key()] = entry
		return true
	})
	slices.Sort(configuredProjectPaths)

	project, multipleCandidates := findDefaultConfiguredProjectFromProgramInclusion(fileName, path, configuredProjectPaths, func(path tspath.Path) *Project {
		return configuredProjects[path].Value()
	})

	if multipleCandidates {
		if p := b.findOrCreateDefaultConfiguredProjectForFile(fileName, path, projectLoadKindFind, nil).project; p != nil {
			return p
		}
	}

	return configuredProjects[project]
}

func (b *ProjectCollectionBuilder) ensureConfiguredProjectAndAncestorsForFile(fileName string, path tspath.Path, logger *logging.LogTree) searchResult {
	result := b.findOrCreateDefaultConfiguredProjectForFile(fileName, path, projectLoadKindCreate, logger)
	if result.project != nil && b.fs.isOpenFile(path) {
		b.createAncestorTree(fileName, path, &result, logger)
	}
	return result
}

func (b *ProjectCollectionBuilder) createAncestorTree(fileName string, path tspath.Path, openResult *searchResult, logger *logging.LogTree) {
	project := openResult.project.Value()
	for {
		// Skip if project is not composite and we are only looking for solution
		if project.CommandLine != nil &&
			(!project.CommandLine.CompilerOptions().Composite.IsTrue() ||
				project.CommandLine.CompilerOptions().DisableSolutionSearching.IsTrue()) {
			return
		}

		// Get config file name
		ancestorConfigName := b.configFileRegistryBuilder.getAncestorConfigFileName(fileName, path, project.configFileName, logger)
		if ancestorConfigName == "" {
			return
		}

		// find or delay load the project
		ancestorPath := b.toPath(ancestorConfigName)
		ancestor := b.findOrCreateProject(ancestorConfigName, ancestorPath, projectLoadKindCreate, logger)
		if ancestor == nil {
			return
		}

		openResult.retain.Add(ancestorPath)

		// If this ancestor is new and was not updated because we are just creating it for future loading
		// eg when invoking find all references or rename that could span multiple projects
		// we would make the current project as its potential project reference
		if ancestor.Value().CommandLine == nil &&
			(project.CommandLine == nil || project.CommandLine.CompilerOptions().Composite.IsTrue()) {
			ancestor.Change(func(ancestorProject *Project) {
				ancestorProject.setPotentialProjectReference(project.configFilePath)
			})
		}

		project = ancestor.Value()
	}
}

type searchNode struct {
	configFileName string
	loadKind       projectLoadKind
	logger         *logging.LogTree
}

type searchNodeKey struct {
	configFileName string
	loadKind       projectLoadKind
}

type searchResult struct {
	project *dirty.SyncMapEntry[tspath.Path, *Project]
	retain  collections.Set[tspath.Path]
}

func (b *ProjectCollectionBuilder) findOrCreateDefaultConfiguredProjectWorker(
	fileName string,
	path tspath.Path,
	configFileName string,
	loadKind projectLoadKind,
	visited *collections.SyncSet[searchNodeKey],
	fallback *searchResult,
	logger *logging.LogTree,
) searchResult {
	var configs collections.SyncMap[tspath.Path, *tsoptions.ParsedCommandLine]
	if visited == nil {
		visited = &collections.SyncSet[searchNodeKey]{}
	}

	search := core.BreadthFirstSearchParallelEx(
		searchNode{configFileName: configFileName, loadKind: loadKind, logger: logger},
		func(node searchNode) []searchNode {
			if config, ok := configs.Load(b.toPath(node.configFileName)); ok && len(config.ProjectReferences()) > 0 {
				referenceLoadKind := node.loadKind
				if config.CompilerOptions().DisableReferencedProjectLoad.IsTrue() {
					referenceLoadKind = projectLoadKindFind
				}

				var refLogger *logging.LogTree
				references := config.ResolvedProjectReferencePaths()
				if len(references) > 0 && node.logger != nil {
					refLogger = node.logger.Fork(fmt.Sprintf("Searching %d project references of %s", len(references), node.configFileName))
				}
				return core.Map(references, func(configFileName string) searchNode {
					return searchNode{configFileName: configFileName, loadKind: referenceLoadKind, logger: refLogger.Fork("Searching project reference " + configFileName)}
				})
			}
			return nil
		},
		func(node searchNode) (isResult bool, stop bool) {
			configFilePath := b.toPath(node.configFileName)
			config := b.configFileRegistryBuilder.findOrAcquireConfigForFile(node.configFileName, configFilePath, path, node.loadKind, node.logger.Fork("Acquiring config for open file"))
			if config == nil {
				node.logger.Log("Config file for project does not already exist")
				return false, false
			}
			configs.Store(configFilePath, config)
			if len(config.FileNames()) == 0 {
				// Likely a solution tsconfig.json - the search will fan out to its references.
				node.logger.Log("Project does not contain file (no root files)")
				return false, false
			}

			if config.CompilerOptions().Composite == core.TSTrue {
				// For composite projects, we can get an early negative result.
				// !!! what about declaration files in node_modules? wouldn't it be better to
				//     check project inclusion if the project is already loaded?
				if _, ok := config.FileNamesByPath()[path]; !ok {
					node.logger.Log("Project does not contain file (by composite config inclusion)")
					return false, false
				}
			}

			project := b.findOrCreateProject(node.configFileName, configFilePath, node.loadKind, node.logger)
			if project == nil {
				node.logger.Log("Project does not already exist")
				return false, false
			}

			if node.loadKind == projectLoadKindCreate {
				// Ensure project is up to date before checking for file inclusion
				b.updateProgram(project, node.logger)
			}

			if project.Value().containsFile(path) {
				isDirectInclusion := !project.Value().IsSourceFromProjectReference(path)
				if node.logger != nil {
					node.logger.Logf("Project contains file %s", core.IfElse(isDirectInclusion, "directly", "as a source of a referenced project"))
				}
				return true, isDirectInclusion
			}

			node.logger.Log("Project does not contain file")
			return false, false
		},
		core.BreadthFirstSearchOptions[searchNodeKey, searchNode]{
			Visited: visited,
			PreprocessLevel: func(level *core.BreadthFirstSearchLevel[searchNodeKey, searchNode]) {
				level.Range(func(node searchNode) bool {
					if node.loadKind == projectLoadKindFind && level.Has(searchNodeKey{configFileName: node.configFileName, loadKind: projectLoadKindCreate}) {
						// Remove find requests when a create request for the same project is already present.
						level.Delete(searchNodeKey{configFileName: node.configFileName, loadKind: node.loadKind})
					}
					return true
				})
			},
		},
		func(node searchNode) searchNodeKey {
			return searchNodeKey{configFileName: node.configFileName, loadKind: node.loadKind}
		},
	)

	var retain collections.Set[tspath.Path]
	var project *dirty.SyncMapEntry[tspath.Path, *Project]
	if len(search.Path) > 0 {
		project, _ = b.configuredProjects.Load(b.toPath(search.Path[0].configFileName))
		// If we found a project, we retain each project along the BFS path.
		// We don't want to retain everything we visited since BFS can terminate
		// early, and we don't want to retain nondeterministically.
		for _, node := range search.Path {
			retain.Add(b.toPath(node.configFileName))
		}
	}

	if search.Stopped {
		// Found a project that directly contains the file.
		return searchResult{
			project: project,
			retain:  retain,
		}
	}

	if project != nil {
		// If we found a project that contains the file, but it is a source from
		// a project reference, record it as a fallback.
		fallback = &searchResult{
			project: project,
			retain:  retain,
		}
	}

	// Look for tsconfig.json files higher up the directory tree and do the same. This handles
	// the common case where a higher-level "solution" tsconfig.json contains all projects in a
	// workspace.
	if config, ok := configs.Load(b.toPath(configFileName)); ok && config.CompilerOptions().DisableSolutionSearching.IsTrue() {
		if fallback != nil {
			return *fallback
		}
	}
	if ancestorConfigName := b.configFileRegistryBuilder.getAncestorConfigFileName(fileName, path, configFileName, logger); ancestorConfigName != "" {
		return b.findOrCreateDefaultConfiguredProjectWorker(
			fileName,
			path,
			ancestorConfigName,
			loadKind,
			visited,
			fallback,
			logger.Fork("Searching ancestor config file at "+ancestorConfigName),
		)
	}
	if fallback != nil {
		return *fallback
	}
	// If we didn't find anything, we can retain everything we visited,
	// since the whole graph must have been traversed (i.e., the set of
	// retained projects is guaranteed to be deterministic).
	visited.Range(func(node searchNodeKey) bool {
		retain.Add(b.toPath(node.configFileName))
		return true
	})
	return searchResult{retain: retain}
}

func (b *ProjectCollectionBuilder) findOrCreateDefaultConfiguredProjectForFile(
	fileName string,
	path tspath.Path,
	loadKind projectLoadKind,
	logger *logging.LogTree,
) searchResult {
	if key, ok := b.fileDefaultProjects[path]; ok {
		if key == inferredProjectName {
			// The file belongs to the inferred project
			return searchResult{}
		}
		entry, _ := b.configuredProjects.Load(key)
		return searchResult{project: entry}
	}
	if configFileName := b.configFileRegistryBuilder.getConfigFileNameForFile(fileName, path, logger); configFileName != "" {
		startTime := time.Now()
		result := b.findOrCreateDefaultConfiguredProjectWorker(
			fileName,
			path,
			configFileName,
			loadKind,
			nil,
			nil,
			logger.Fork("Searching for default configured project for "+fileName),
		)
		if result.project != nil {
			if b.fileDefaultProjects == nil {
				b.fileDefaultProjects = make(map[tspath.Path]tspath.Path)
			}
			b.fileDefaultProjects[path] = result.project.Value().configFilePath
		}
		if logger != nil {
			elapsed := time.Since(startTime)
			if result.project != nil {
				logger.Log(fmt.Sprintf("Found default configured project for %s: %s (in %v)", fileName, result.project.Value().configFileName, elapsed))
			} else {
				logger.Log(fmt.Sprintf("No default configured project found for %s (searched in %v)", fileName, elapsed))
			}
		}
		return result
	}
	return searchResult{}
}

func (b *ProjectCollectionBuilder) findOrCreateProject(
	configFileName string,
	configFilePath tspath.Path,
	loadKind projectLoadKind,
	logger *logging.LogTree,
) *dirty.SyncMapEntry[tspath.Path, *Project] {
	if loadKind == projectLoadKindFind {
		entry, _ := b.configuredProjects.Load(configFilePath)
		return entry
	}
	entry, _ := b.configuredProjects.LoadOrStore(configFilePath, NewConfiguredProject(configFileName, configFilePath, b, logger))
	return entry
}

func (b *ProjectCollectionBuilder) updateInferredProjectRoots(rootFileNames []string, logger *logging.LogTree) bool {
	if len(rootFileNames) == 0 {
		if b.inferredProject.Value() != nil {
			if logger != nil {
				logger.Log("Deleting inferred project")
			}
			b.inferredProject.Delete()
			return true
		}
		return false
	}

	slices.Sort(rootFileNames)
	if b.inferredProject.Value() == nil {
		b.inferredProject.Set(NewInferredProject(b.sessionOptions.CurrentDirectory, b.compilerOptionsForInferredProjects, rootFileNames, b, logger))
	} else {
		newCompilerOptions := b.inferredProject.Value().CommandLine.CompilerOptions()
		if b.compilerOptionsForInferredProjects != nil {
			newCompilerOptions = b.compilerOptionsForInferredProjects
		}
		newCommandLine := tsoptions.NewParsedCommandLine(newCompilerOptions, rootFileNames, tspath.ComparePathsOptions{
			UseCaseSensitiveFileNames: b.fs.fs.UseCaseSensitiveFileNames(),
			CurrentDirectory:          b.sessionOptions.CurrentDirectory,
		})
		changed := b.inferredProject.ChangeIf(
			func(p *Project) bool {
				return !maps.Equal(p.CommandLine.FileNamesByPath(), newCommandLine.FileNamesByPath())
			},
			func(p *Project) {
				if logger != nil {
					logger.Log(fmt.Sprintf("Updating inferred project config with %d root files", len(rootFileNames)))
				}
				p.SetCommandLine(newCommandLine)
			},
		)
		if !changed {
			return false
		}
	}
	return true
}

// updateProgram updates the program for the given project entry if necessary. It returns
// a boolean indicating whether the update could have caused any structure-affecting changes.
func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], logger *logging.LogTree) bool {
	var updateProgram bool
	var deleteProject bool
	var filesChanged bool
	configFileName := entry.Value().configFileName
	startTime := time.Now()
	var notifiedLoading bool
	var displayName string
	entry.Locked(func(entry dirty.Value[*Project]) {
		if entry.Value().Kind == KindConfigured {
			commandLine := b.configFileRegistryBuilder.acquireConfigForProject(
				entry.Value().configFileName,
				entry.Value().configFilePath,
				entry.Value(),
				logger.Fork("Acquiring config for project"),
			)
			if commandLine == nil {
				deleteProject = true
				filesChanged = true
				return
			}
			if entry.Value().CommandLine != commandLine {
				updateProgram = true
				entry.Change(func(p *Project) {
					p.SetCommandLine(commandLine)
				})
			}
		}
		if !updateProgram {
			updateProgram = entry.Value().dirty
		}
		if updateProgram {
			if b.client != nil {
				displayName = entry.Value().DisplayName(b.sessionOptions.CurrentDirectory)
				notifiedLoading = true
			}
		}
	})
	if notifiedLoading && b.client != nil {
		b.client.ProgressStart(diagnostics.Project_0, displayName)
	}
	if deleteProject {
		b.deleteConfiguredProject(entry, logger)
	}
	if updateProgram {
		entry.Locked(func(entry dirty.Value[*Project]) {
			entry.Change(func(project *Project) {
				oldHost := project.host
				oldProgram := project.Program
				oldCheckerPool := project.checkerPool
				project.host = newCompilerHost(project.currentDirectory, project, b, logger.Fork("CompilerHost"))
				result := project.CreateProgram()
				project.Program = result.Program
				project.checkerPool = result.Program.GetCheckerPool().(*checkerPool)
				project.ProgramUpdateKind = result.UpdateKind
				project.ProgramLastUpdate = b.newSnapshotID
				if result.UpdateKind == ProgramUpdateKindCloned {
					project.host.sourceFS.seenFiles = oldHost.sourceFS.seenFiles
				}
				if result.UpdateKind == ProgramUpdateKindNewFiles {
					filesChanged = true
					project.programFilesWatch = project.CloneWatchers()
				}
				project.dirty = false
				project.dirtyFilePath = ""
				b.releaseDroppedProjectReferences(oldProgram, result.Program, project.configFilePath)
				if oldCheckerPool != nil {
					oldCheckerPool.Discard()
				}
			})
		})
	}
	if notifiedLoading && b.client != nil {
		b.client.ProgressFinish(diagnostics.Project_0, displayName)
	}
	if updateProgram && logger != nil {
		elapsed := time.Since(startTime)
		logger.Log(fmt.Sprintf("Program update for %s completed in %v", configFileName, elapsed))
	}
	return filesChanged
}

func (b *ProjectCollectionBuilder) markFilesChanged(entry dirty.Value[*Project], paths []tspath.Path, changeType lsproto.FileChangeType, logger *logging.LogTree) {
	var dirty bool
	var dirtyFilePath tspath.Path
	entry.ChangeIf(
		func(p *Project) bool {
			if p.Program == nil || p.dirty && p.dirtyFilePath == "" {
				return false
			}

			dirtyFilePath = p.dirtyFilePath
			for _, path := range paths {
				if p.containsFile(path) {
					dirty = true
					if changeType == lsproto.FileChangeTypeDeleted {
						dirtyFilePath = ""
						break
					}
					// package.json changes can affect module resolution and package
					// identity (e.g. dedup decisions), so they must always trigger
					// a full rebuild rather than a single-file clone.
					if tspath.GetBaseFileName(string(path)) == "package.json" {
						dirtyFilePath = ""
						break
					}
					if dirtyFilePath == "" {
						dirtyFilePath = path
					} else if dirtyFilePath != path {
						dirtyFilePath = ""
						break
					}
				} else if p.host != nil &&
					(changeType == lsproto.FileChangeTypeCreated && p.host.sourceFS.SeenFileOrMissingParentDirectory(path) ||
						changeType != lsproto.FileChangeTypeCreated && p.host.sourceFS.SeenFile(path)) {
					dirty = true
					dirtyFilePath = ""
					break
				}
			}
			return dirty || p.dirtyFilePath != dirtyFilePath
		},
		func(p *Project) {
			p.dirty = true
			p.dirtyFilePath = dirtyFilePath
			if logger != nil {
				if dirtyFilePath != "" {
					logger.Logf("Marking project %s as dirty due to changes in %s", p.configFileName, dirtyFilePath)
				} else {
					logger.Logf("Marking project %s as dirty", p.configFileName)
				}
			}
		},
	)
}

func (b *ProjectCollectionBuilder) deleteConfiguredProject(project dirty.Value[*Project], logger *logging.LogTree) {
	projectPath := project.Value().configFilePath
	if logger != nil {
		logger.Log("Deleting configured project: " + project.Value().configFileName)
	}
	if program := project.Value().Program; program != nil {
		program.RangeResolvedProjectReference(func(referencePath tspath.Path, config *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool {
			b.configFileRegistryBuilder.releaseConfigForProject(referencePath, projectPath)
			return true
		})
	}
	b.configFileRegistryBuilder.releaseConfigForProject(projectPath, projectPath)
	project.Delete()
}

// releaseDroppedProjectReferences releases the config entries for project references
// that were present in oldProgram but are no longer referenced by newProgram. Creating
// newProgram already re-acquires the config for every reference it still resolves, so
// only the dropped references need to be released here.
func (b *ProjectCollectionBuilder) releaseDroppedProjectReferences(oldProgram *compiler.Program, newProgram *compiler.Program, projectPath tspath.Path) {
	if oldProgram == nil || oldProgram == newProgram {
		return
	}
	var newReferences collections.Set[tspath.Path]
	if newProgram != nil {
		newProgram.RangeResolvedProjectReference(func(referencePath tspath.Path, _ *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool {
			newReferences.Add(referencePath)
			return true
		})
	}
	oldProgram.RangeResolvedProjectReference(func(referencePath tspath.Path, _ *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool {
		if !newReferences.Has(referencePath) {
			b.configFileRegistryBuilder.releaseConfigForProject(referencePath, projectPath)
		}
		return true
	})
}
