package project

import (
	"context"
	"fmt"
	"math"
	"runtime"
	gometrics "runtime/metrics"
	"slices"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	osmemory "github.com/mackerelio/go-osstat/memory"
	"github.com/microsoft/typescript-go/internal/ast"
	"github.com/microsoft/typescript-go/internal/collections"
	"github.com/microsoft/typescript-go/internal/core"
	"github.com/microsoft/typescript-go/internal/diagnostics"
	"github.com/microsoft/typescript-go/internal/json"
	"github.com/microsoft/typescript-go/internal/locale"
	"github.com/microsoft/typescript-go/internal/ls"
	"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/ata"
	"github.com/microsoft/typescript-go/internal/project/background"
	"github.com/microsoft/typescript-go/internal/project/logging"
	"github.com/microsoft/typescript-go/internal/tspath"
	"github.com/microsoft/typescript-go/internal/vfs"
)

type UpdateReason int

const (
	UpdateReasonUnknown UpdateReason = iota
	UpdateReasonDidOpenFile
	UpdateReasonDidCloseFile
	UpdateReasonDidChangeCompilerOptionsForInferredProjects
	UpdateReasonRequestedLanguageServicePendingChanges
	UpdateReasonRequestedLanguageServiceProjectNotLoaded
	UpdateReasonRequestedLanguageServiceForFileNotOpen
	UpdateReasonRequestedLanguageServiceProjectDirty
	UpdateReasonRequestedLoadProjectTree
	UpdateReasonRequestedLanguageServiceWithAutoImports
	UpdateReasonIdleCleanDiskCache
)

// watchRequestTimeout is the maximum time to wait for the client to respond to
// a WatchFiles or UnwatchFiles request while holding the watches mutex.
const watchRequestTimeout = time.Second

// SessionOptions are the immutable initialization options for a session.
// Snapshots may reference them as a pointer since they never change.
type SessionOptions struct {
	CurrentDirectory       string
	DefaultLibraryPath     string
	TypingsLocation        string
	PositionEncoding       lsproto.PositionEncodingKind
	WatchEnabled           bool
	LoggingEnabled         bool
	TelemetryEnabled       bool
	PushDiagnosticsEnabled bool
	DebounceDelay          time.Duration
	Locale                 locale.Locale
	CheckerPoolOptions     CheckerPoolOptions
}

type SessionInit struct {
	BackgroundCtx context.Context
	Options       *SessionOptions
	FS            vfs.FS
	Client        Client
	Logger        logging.Logger
	NpmExecutor   ata.NpmExecutor
	ParseCache    *ParseCache
}

// Session manages the state of an LSP session. It receives textDocument
// events and requests for LanguageService objects from the LPS server
// and processes them into immutable snapshots as the data source for
// LanguageServices. When Session transitions from one snapshot to the
// next, it diffs them and updates file watchers and Automatic Type
// Acquisition (ATA) state accordingly.
type Session struct {
	backgroundCtx context.Context
	options       *SessionOptions
	startTime     time.Time
	toPath        func(string) tspath.Path
	client        Client
	logger        logging.Logger
	npmExecutor   ata.NpmExecutor
	fs            *overlayFS

	// parseCache is the ref-counted cache of source files used when
	// creating programs during snapshot cloning.
	parseCache *ParseCache
	// extendedConfigCache is the ref-counted cache of tsconfig ASTs
	// that are used in the "extends" of another tsconfig.
	extendedConfigCache *ExtendedConfigCache
	// programCounter counts how many snapshots reference a program.
	// When a program is no longer referenced, its source files are
	// released from the parseCache.
	programCounter *programCounter

	// read-only after initialization
	initialUserPreferences lsutil.UserPreferences
	// current preferences
	workspaceUserPreferences           lsutil.UserPreferences
	compilerOptionsForInferredProjects *core.CompilerOptions
	typingsInstaller                   *ata.TypingsInstaller
	backgroundQueue                    *background.Queue

	// snapshotID is the counter for snapshot IDs. It does not necessarily
	// equal the `snapshot.ID`. It is stored on Session instead of globally
	// so IDs are predictable in tests.
	snapshotID atomic.Uint64

	// snapshot is the current immutable state of all projects.
	snapshot         *Snapshot
	snapshotMu       sync.RWMutex
	snapshotUpdateMu sync.Mutex

	// scheduledSnapshotUpdateCancel is the cancelation function for a scheduled
	// snapshot update. Snapshot updates are scheduled and debounced after file closes.
	scheduledSnapshotUpdateCancel     context.CancelFunc
	scheduledSnapshotUpdateGeneration uint64
	scheduledSnapshotUpdateMu         sync.Mutex

	pendingUserConfigChanges bool
	userConfigRWMu           sync.Mutex

	// pendingFileChanges are accumulated from textDocument/* events delivered
	// by the LSP server through DidOpenFile(), DidChangeFile(), etc. They are
	// applied to the next snapshot update.
	pendingFileChanges   []FileChange
	pendingFileChangesMu sync.Mutex

	// pendingATAChanges are produced by Automatic Type Acquisition (ATA)
	// installations and applied to the next snapshot update.
	pendingATAChanges   map[tspath.Path]*ATAStateChange
	pendingATAChangesMu sync.Mutex

	// diagnosticsRefreshCancel is the cancelation function for a scheduled
	// diagnostics refresh. Diagnostics refreshes are scheduled and debounced
	// after file watch changes and ATA updates.
	diagnosticsRefreshCancel     context.CancelFunc
	diagnosticsRefreshGeneration uint64
	diagnosticsRefreshMu         sync.Mutex

	// warmAutoImportCancel is the cancelation function for a running
	// auto-import cache warming task. It is cancelled on file opens,
	// closes, changes, watched-file changes, new auto-import warming
	// requests, and when the session closes.
	warmAutoImportCancel context.CancelFunc
	warmAutoImportMu     sync.Mutex

	// idleCacheCleanTimer is a resettable timer for scheduling idle disk
	// cache cleans. The timer resets on any file event (open, close,
	// change, save, watch) and fires after 30 seconds of inactivity.
	idleCacheCleanTimer *time.Timer
	idleCacheCleanMu    sync.Mutex

	// performanceTelemetryCancel cancels the periodic performance telemetry ticker.
	performanceTelemetryCancel context.CancelFunc

	// seenProjects tracks projects that have already had telemetry sent.
	seenProjects collections.SyncSet[tspath.Path]

	// watches tracks the current watch globs and how many individual WatchedFiles
	// are using each glob.
	watches *watchRegistry

	// globalDiagPublishPending is set to true when a global diagnostics publish
	// task should be enqueued. It is reset when the task runs, coalescing multiple
	// requests into a single background task.
	globalDiagPublishPending atomic.Bool
}

func NewSession(init *SessionInit) *Session {
	currentDirectory := init.Options.CurrentDirectory
	useCaseSensitiveFileNames := init.FS.UseCaseSensitiveFileNames()
	toPath := func(fileName string) tspath.Path {
		return tspath.ToPath(fileName, currentDirectory, useCaseSensitiveFileNames)
	}
	overlayFS := newOverlayFS(init.FS, make(map[tspath.Path]*Overlay), init.Options.PositionEncoding, toPath)
	parseCache := init.ParseCache
	if parseCache == nil {
		parseCache = NewParseCache(RefCountCacheOptions{})
	}
	extendedConfigCache := NewExtendedConfigCache()

	sessionLogger := init.Logger
	if sessionLogger == nil {
		sessionLogger = logging.NewNopLogger()
	}
	session := &Session{
		backgroundCtx:       init.BackgroundCtx,
		options:             init.Options,
		toPath:              toPath,
		client:              init.Client,
		logger:              sessionLogger,
		npmExecutor:         init.NpmExecutor,
		fs:                  overlayFS,
		parseCache:          parseCache,
		extendedConfigCache: extendedConfigCache,
		programCounter:      &programCounter{},
		backgroundQueue:     background.NewQueue(),
		startTime:           time.Now(),
		snapshot: NewSnapshot(
			uint64(0),
			&SnapshotFS{
				toPath: toPath,
				fs:     init.FS,
			},
			init.Options,
			&ConfigFileRegistry{},
			nil,
			lsutil.NewDefaultUserPreferences(),
			nil,
			NewWatchedFiles(
				"auto-import",
				lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete,
				lsproto.GetClientCapabilities(init.BackgroundCtx).Workspace.DidChangeWatchedFiles.RelativePatternSupport,
				func(nodeModulesDirs map[tspath.Path]string) PatternsAndIgnored {
					patterns := make([]string, 0, len(nodeModulesDirs))
					for _, dir := range nodeModulesDirs {
						patterns = append(patterns, getRecursiveGlobPattern(dir))
					}
					slices.Sort(patterns)
					return PatternsAndIgnored{
						patternsInsideWorkspace: patterns,
					}
				},
			),
			toPath,
		),
		initialUserPreferences:   lsutil.NewDefaultUserPreferences(),
		workspaceUserPreferences: lsutil.NewDefaultUserPreferences(),
		pendingATAChanges:        make(map[tspath.Path]*ATAStateChange),
		watches:                  newWatchRegistry(),
	}

	if init.Options.TypingsLocation != "" && init.NpmExecutor != nil {
		session.typingsInstaller = ata.NewTypingsInstaller(&ata.TypingsInstallerOptions{
			TypingsLocation: init.Options.TypingsLocation,
			ThrottleLimit:   5,
		}, session)
	}

	return session
}

// FS implements module.ResolutionHost
func (s *Session) FS() vfs.FS {
	return s.fs.fs
}

// GetCurrentDirectory implements module.ResolutionHost
func (s *Session) GetCurrentDirectory() string {
	return s.options.CurrentDirectory
}

// Gets copy of current configuration
func (s *Session) Config() lsutil.UserPreferences {
	s.userConfigRWMu.Lock()
	defer s.userConfigRWMu.Unlock()
	return s.workspaceUserPreferences
}

// Trace implements module.ResolutionHost
func (s *Session) Trace(msg string) {
	panic("ATA module resolution should not use tracing")
}

func (s *Session) Configure(config lsutil.UserPreferences) {
	s.userConfigRWMu.Lock()
	defer s.userConfigRWMu.Unlock()
	s.pendingUserConfigChanges = true
	oldConfig := s.workspaceUserPreferences
	s.workspaceUserPreferences = config

	// Tell the client to re-request certain commands depending on user preference changes.
	s.refreshInlayHintsIfNeeded(oldConfig, config)
	s.refreshCodeLensIfNeeded(oldConfig, config)
	s.refreshDiagnosticsIfNeeded(oldConfig, config)
	s.refreshATAIfNeeded(oldConfig, config)
}

func (s *Session) InitializeWithUserConfig(config lsutil.UserPreferences) {
	s.initialUserPreferences = config
	s.Configure(config)
}

func (s *Session) DidOpenFile(ctx context.Context, uri lsproto.DocumentUri, version int32, content string, languageKind lsproto.LanguageKind) {
	s.cancelWarmAutoImportCache()
	s.scheduleIdleCacheClean()
	s.cancelScheduledSnapshotUpdate()
	s.snapshotUpdateMu.Lock()
	defer s.snapshotUpdateMu.Unlock()
	s.pendingFileChangesMu.Lock()
	s.pendingFileChanges = append(s.pendingFileChanges, FileChange{
		Kind:         FileChangeKindOpen,
		URI:          uri,
		Version:      version,
		Content:      content,
		LanguageKind: languageKind,
	})
	changes, overlays := s.flushChangesLocked(ctx)
	s.pendingFileChangesMu.Unlock()
	s.UpdateSnapshot(ctx, overlays, SnapshotChange{
		reason:      UpdateReasonDidOpenFile,
		fileChanges: changes,
		ResourceRequest: ResourceRequest{
			Documents: []lsproto.DocumentUri{uri},
		},
	})
}

func (s *Session) DidCloseFile(ctx context.Context, uri lsproto.DocumentUri) {
	s.cancelWarmAutoImportCache()
	s.scheduleIdleCacheClean()
	s.pendingFileChangesMu.Lock()
	s.pendingFileChanges = append(s.pendingFileChanges, FileChange{
		Kind: FileChangeKindClose,
		URI:  uri,
	})
	s.pendingFileChangesMu.Unlock()
	s.ScheduleSnapshotUpdate(UpdateReasonDidCloseFile)
}

func (s *Session) DidChangeFile(ctx context.Context, uri lsproto.DocumentUri, version int32, changes []lsproto.TextDocumentContentChangePartialOrWholeDocument) {
	s.cancelDiagnosticsRefresh()
	s.cancelWarmAutoImportCache()
	s.scheduleIdleCacheClean()
	s.pendingFileChangesMu.Lock()
	defer s.pendingFileChangesMu.Unlock()
	s.pendingFileChanges = append(s.pendingFileChanges, FileChange{
		Kind:    FileChangeKindChange,
		URI:     uri,
		Version: version,
		Changes: changes,
	})
}

func (s *Session) DidSaveFile(ctx context.Context, uri lsproto.DocumentUri) {
	s.scheduleIdleCacheClean()
	s.pendingFileChangesMu.Lock()
	defer s.pendingFileChangesMu.Unlock()
	s.pendingFileChanges = append(s.pendingFileChanges, FileChange{
		Kind: FileChangeKindSave,
		URI:  uri,
	})
}

func (s *Session) DidChangeWatchedFiles(ctx context.Context, changes []*lsproto.FileEvent) {
	fileChanges := make([]FileChange, 0, len(changes))
	for _, change := range changes {
		var kind FileChangeKind
		switch change.Type {
		case lsproto.FileChangeTypeCreated:
			kind = FileChangeKindWatchCreate
		case lsproto.FileChangeTypeChanged:
			kind = FileChangeKindWatchChange
		case lsproto.FileChangeTypeDeleted:
			kind = FileChangeKindWatchDelete
		default:
			continue // Ignore unknown change types.
		}
		fileChanges = append(fileChanges, FileChange{
			Kind: kind,
			URI:  change.Uri,
		})
	}

	s.pendingFileChangesMu.Lock()
	s.pendingFileChanges = append(s.pendingFileChanges, fileChanges...)
	s.pendingFileChangesMu.Unlock()

	// Schedule a debounced diagnostics refresh
	s.ScheduleDiagnosticsRefresh()
	s.cancelWarmAutoImportCache()
	s.scheduleIdleCacheClean()
}

func (s *Session) DidChangeCompilerOptionsForInferredProjects(ctx context.Context, options *core.CompilerOptions) {
	s.compilerOptionsForInferredProjects = options
	s.UpdateSnapshot(ctx, s.fs.Overlays(), SnapshotChange{
		reason:                             UpdateReasonDidChangeCompilerOptionsForInferredProjects,
		compilerOptionsForInferredProjects: options,
	})
}

func (s *Session) ScheduleDiagnosticsRefresh() {
	s.diagnosticsRefreshMu.Lock()
	defer s.diagnosticsRefreshMu.Unlock()

	// Cancel any existing scheduled diagnostics refresh
	if s.diagnosticsRefreshCancel != nil {
		s.diagnosticsRefreshCancel()
		s.logger.Log("Delaying scheduled diagnostics refresh...")
	} else {
		s.logger.Log("Scheduling new diagnostics refresh...")
	}

	// Create a new cancellable context for the debounce task
	debounceCtx, cancel := context.WithCancel(s.backgroundCtx)
	s.diagnosticsRefreshGeneration++
	generation := s.diagnosticsRefreshGeneration
	s.diagnosticsRefreshCancel = cancel

	// Enqueue the debounced diagnostics refresh
	s.backgroundQueue.Enqueue(debounceCtx, func(ctx context.Context) {
		defer cancel()
		// Sleep for the debounce delay
		select {
		case <-time.After(s.options.DebounceDelay):
			// Delay completed, proceed with refresh
		case <-ctx.Done():
			// Context was cancelled, newer events arrived
			return
		}

		// Clear the cancel function since we're about to execute the refresh
		s.diagnosticsRefreshMu.Lock()
		if s.diagnosticsRefreshGeneration != generation {
			s.diagnosticsRefreshMu.Unlock()
			return
		}
		s.diagnosticsRefreshCancel = nil
		s.diagnosticsRefreshMu.Unlock()

		if s.options.LoggingEnabled {
			s.logger.Log("Running scheduled diagnostics refresh")
		}
		if err := s.client.RefreshDiagnostics(s.backgroundCtx); err != nil && s.options.LoggingEnabled {
			s.logger.Logf("Error refreshing diagnostics: %v", err)
		}
	})
}

func (s *Session) cancelDiagnosticsRefresh() {
	s.diagnosticsRefreshMu.Lock()
	defer s.diagnosticsRefreshMu.Unlock()
	if s.diagnosticsRefreshCancel != nil {
		s.diagnosticsRefreshCancel()
		s.logger.Log("Canceled scheduled diagnostics refresh")
		s.diagnosticsRefreshCancel = nil
		s.diagnosticsRefreshGeneration++
	}
}

func (s *Session) ScheduleSnapshotUpdate(reason UpdateReason) {
	s.scheduledSnapshotUpdateMu.Lock()
	defer s.scheduledSnapshotUpdateMu.Unlock()

	// Cancel any existing scheduled snapshot update
	if s.scheduledSnapshotUpdateCancel != nil {
		s.scheduledSnapshotUpdateCancel()
		if s.options.LoggingEnabled {
			s.logger.Log("Delaying scheduled snapshot update...")
		}
	} else if s.options.LoggingEnabled {
		s.logger.Log("Scheduling new snapshot update...")
	}

	// Create a new cancellable context for the debounce task
	debounceCtx, cancel := context.WithCancel(s.backgroundCtx)
	s.scheduledSnapshotUpdateGeneration++
	generation := s.scheduledSnapshotUpdateGeneration
	s.scheduledSnapshotUpdateCancel = cancel

	// Enqueue the debounced snapshot update
	s.backgroundQueue.Enqueue(debounceCtx, func(ctx context.Context) {
		defer cancel()
		// Sleep for the debounce delay
		select {
		case <-time.After(s.options.DebounceDelay):
			// Delay completed, proceed with update
		case <-ctx.Done():
			// Context was cancelled, newer events arrived or another snapshot update ran
			return
		}

		// Clear the cancel function since we're about to execute the update
		s.scheduledSnapshotUpdateMu.Lock()
		if s.scheduledSnapshotUpdateGeneration != generation {
			s.scheduledSnapshotUpdateMu.Unlock()
			return
		}
		s.scheduledSnapshotUpdateCancel = nil
		s.scheduledSnapshotUpdateMu.Unlock()

		if s.options.LoggingEnabled {
			s.logger.Log("Running scheduled snapshot update")
		}

		s.snapshotUpdateMu.Lock()
		defer s.snapshotUpdateMu.Unlock()

		fileChanges, overlays, ataChanges, newConfig := s.flushChanges(ctx)
		if fileChanges.IsEmpty() && len(ataChanges) == 0 && newConfig == nil {
			return
		}

		s.UpdateSnapshot(ctx, overlays, SnapshotChange{
			reason:      reason,
			fileChanges: fileChanges,
			ataChanges:  ataChanges,
			newConfig:   newConfig,
		})
	})
}

func (s *Session) cancelScheduledSnapshotUpdate() {
	s.scheduledSnapshotUpdateMu.Lock()
	defer s.scheduledSnapshotUpdateMu.Unlock()
	if s.scheduledSnapshotUpdateCancel != nil {
		s.scheduledSnapshotUpdateCancel()
		if s.options.LoggingEnabled {
			s.logger.Log("Canceled scheduled snapshot update")
		}
		s.scheduledSnapshotUpdateCancel = nil
		s.scheduledSnapshotUpdateGeneration++
	}
}

func (s *Session) cancelWarmAutoImportCache() {
	s.warmAutoImportMu.Lock()
	defer s.warmAutoImportMu.Unlock()
	if s.warmAutoImportCancel != nil {
		s.warmAutoImportCancel()
		s.warmAutoImportCancel = nil
	}
}

const idleCacheCleanDelay = 30 * time.Second

func (s *Session) scheduleIdleCacheClean() {
	s.idleCacheCleanMu.Lock()
	defer s.idleCacheCleanMu.Unlock()

	if s.idleCacheCleanTimer != nil {
		s.idleCacheCleanTimer.Stop()
	}

	s.idleCacheCleanTimer = time.AfterFunc(idleCacheCleanDelay, func() {
		s.idleCacheCleanMu.Lock()
		s.idleCacheCleanTimer = nil
		s.idleCacheCleanMu.Unlock()

		s.snapshotUpdateMu.Lock()
		defer s.snapshotUpdateMu.Unlock()
		s.cancelScheduledSnapshotUpdate()

		ctx := s.backgroundCtx
		fileChanges, overlays, ataChanges, newConfig := s.flushChanges(ctx)
		s.UpdateSnapshot(ctx, overlays, SnapshotChange{
			reason:         UpdateReasonIdleCleanDiskCache,
			fileChanges:    fileChanges,
			ataChanges:     ataChanges,
			newConfig:      newConfig,
			cleanDiskCache: true,
		})

		go func() { runtime.GC() }()
	})
}

func (s *Session) cancelIdleCacheClean() {
	s.idleCacheCleanMu.Lock()
	defer s.idleCacheCleanMu.Unlock()
	if s.idleCacheCleanTimer != nil {
		s.idleCacheCleanTimer.Stop()
		s.idleCacheCleanTimer = nil
	}
}

const performanceTelemetryInterval = 5 * time.Minute

// StartPerformanceTelemetry begins periodic collection and sending of performance
// telemetry. It should be called once after the session is initialized.
func (s *Session) StartPerformanceTelemetry() {
	if !s.options.TelemetryEnabled {
		return
	}
	ctx, cancel := context.WithCancel(s.backgroundCtx)
	s.performanceTelemetryCancel = cancel
	s.backgroundQueue.Enqueue(ctx, func(ctx context.Context) {
		ticker := time.NewTicker(performanceTelemetryInterval)
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				if s.client == nil || !s.client.IsActive() {
					continue
				}
				s.sendPerformanceTelemetry(ctx)
			}
		}
	})
}

func (s *Session) stopPerformanceTelemetry() {
	if s.performanceTelemetryCancel != nil {
		s.performanceTelemetryCancel()
		s.performanceTelemetryCancel = nil
	}
}

func (s *Session) sendPerformanceTelemetry(ctx context.Context) {
	if s.client == nil || !s.options.TelemetryEnabled {
		return
	}
	s.snapshotMu.RLock()
	snapshot := s.snapshot
	s.snapshotMu.RUnlock()

	// Read Go runtime metrics in a single call
	const (
		sMemoryUsedBytes = iota
		sGoMemLimit
		sGoGCPercent
		sHeapGoalBytes
		sHeapLiveBytes
		sHeapObjectCount
		sHeapStackBytes
		sHeapReleasedBytes
		sHeapFreeBytes
		sGcScanHeapBytes
		sGoMaxProcs
		sGoroutineCount
		sGcCyclesTotal
		sGcCPUSeconds
		sUserCPUSeconds
		sMetricCount
	)
	samples := make([]gometrics.Sample, sMetricCount)
	samples[sMemoryUsedBytes].Name = "/memory/classes/total:bytes"
	samples[sGoMemLimit].Name = "/gc/gomemlimit:bytes"
	samples[sGoGCPercent].Name = "/gc/gogc:percent"
	samples[sHeapGoalBytes].Name = "/gc/heap/goal:bytes"
	samples[sHeapLiveBytes].Name = "/gc/heap/live:bytes"
	samples[sHeapObjectCount].Name = "/gc/heap/objects:objects"
	samples[sHeapStackBytes].Name = "/memory/classes/heap/stacks:bytes"
	samples[sHeapReleasedBytes].Name = "/memory/classes/heap/released:bytes"
	samples[sHeapFreeBytes].Name = "/memory/classes/heap/free:bytes"
	samples[sGcScanHeapBytes].Name = "/gc/scan/heap:bytes"
	samples[sGoMaxProcs].Name = "/sched/gomaxprocs:threads"
	samples[sGoroutineCount].Name = "/sched/goroutines:goroutines"
	samples[sGcCyclesTotal].Name = "/gc/cycles/total:gc-cycles"
	samples[sGcCPUSeconds].Name = "/cpu/classes/gc/total:cpu-seconds"
	samples[sUserCPUSeconds].Name = "/cpu/classes/user:cpu-seconds"
	gometrics.Read(samples)

	measurements := &lsproto.PerformanceStatsTelemetryMeasurements{
		OpenFileCount:       float64(len(snapshot.fs.overlays)),
		UptimeSeconds:       time.Since(s.startTime).Seconds(),
		ProjectCount:        float64(len(snapshot.ProjectCollection.Projects())),
		ConfigCount:         float64(len(snapshot.ConfigFileRegistry.configs)),
		CachedDiskFileCount: float64(len(snapshot.fs.diskFiles)),
	}

	readUint64 := func(s gometrics.Sample) float64 {
		if s.Value.Kind() == gometrics.KindUint64 {
			return float64(s.Value.Uint64())
		}
		return 0
	}
	readFloat64 := func(s gometrics.Sample) float64 {
		if s.Value.Kind() == gometrics.KindFloat64 {
			return s.Value.Float64()
		}
		return 0
	}

	measurements.MemoryUsedBytes = readUint64(samples[sMemoryUsedBytes])
	if samples[sGoMemLimit].Value.Kind() == gometrics.KindUint64 {
		v := samples[sGoMemLimit].Value.Uint64()
		if v < uint64(math.MaxInt64) {
			measurements.GoMemLimit = float64(v)
		}
		// else: default (MaxInt64) exceeds MAX_SAFE_INTEGER; leave as 0 to indicate unconfigured
	}
	measurements.GoGCPercent = readUint64(samples[sGoGCPercent])
	measurements.HeapGoalBytes = readUint64(samples[sHeapGoalBytes])
	measurements.HeapLiveBytes = readUint64(samples[sHeapLiveBytes])
	measurements.HeapObjectCount = readUint64(samples[sHeapObjectCount])
	measurements.HeapStackBytes = readUint64(samples[sHeapStackBytes])
	measurements.HeapReleasedBytes = readUint64(samples[sHeapReleasedBytes])
	measurements.HeapFreeBytes = readUint64(samples[sHeapFreeBytes])
	measurements.GcScanHeapBytes = readUint64(samples[sGcScanHeapBytes])
	measurements.GoMaxProcs = readUint64(samples[sGoMaxProcs])
	measurements.GoroutineCount = readUint64(samples[sGoroutineCount])
	measurements.GcCyclesTotal = readUint64(samples[sGcCyclesTotal])
	measurements.GcCPUSeconds = readFloat64(samples[sGcCPUSeconds])
	measurements.UserCPUSeconds = readFloat64(samples[sUserCPUSeconds])

	// Read system memory stats
	if sysMem, err := osmemory.Get(); err == nil {
		measurements.SystemMemTotal = float64(sysMem.Total)
		measurements.SystemMemUsed = float64(sysMem.Used)
	}

	// Read auto-import registry stats
	if registry := snapshot.AutoImportRegistry(); registry != nil {
		autoImportStats := registry.GetCacheStats()
		measurements.AutoImportProjectBucketCount = float64(len(autoImportStats.ProjectBuckets))
		measurements.AutoImportNodeModulesBucketCount = float64(len(autoImportStats.NodeModulesBuckets))
		measurements.AutoImportUniquePackageCount = float64(autoImportStats.UniquePackageCount)
		for _, b := range autoImportStats.ProjectBuckets {
			measurements.AutoImportProjectExportCount += float64(b.ExportCount)
			measurements.AutoImportProjectFileCount += float64(b.FileCount)
		}
		for _, b := range autoImportStats.NodeModulesBuckets {
			measurements.AutoImportNodeModulesExportCount += float64(b.ExportCount)
			measurements.AutoImportNodeModulesFileCount += float64(b.FileCount)
			if b.DependencyNames == nil {
				measurements.AutoImportNodeModulesUnfilteredBucketCount++
			}
		}
	}

	if err := s.client.SendTelemetry(ctx, lsproto.TelemetryEvent{
		PerformanceStatsTelemetryEvent: &lsproto.PerformanceStatsTelemetryEvent{
			Measurements: measurements,
		},
	}); err != nil && s.options.LoggingEnabled {
		s.logger.Logf("Error sending performance telemetry: %v", err)
	}
}

func (s *Session) sendProjectInfoTelemetryForNewProjects(oldSnapshot *Snapshot, newSnapshot *Snapshot) {
	if !s.options.TelemetryEnabled {
		return
	}
	ctx := s.backgroundCtx
	collections.DiffOrderedMaps(
		oldSnapshot.ProjectCollection.ProjectsByPath(),
		newSnapshot.ProjectCollection.ProjectsByPath(),
		func(_ tspath.Path, addedProject *Project) {
			s.sendProjectInfoTelemetry(ctx, addedProject)
		},
		func(_ tspath.Path, _ *Project) {},
		func(_ tspath.Path, _, _ *Project) {},
	)
}

func (s *Session) sendProjectInfoTelemetry(ctx context.Context, project *Project) {
	if s.client == nil || !s.options.TelemetryEnabled {
		return
	}
	if s.seenProjects.Has(project.configFilePath) {
		return
	}

	if project.Program == nil || project.CommandLine == nil {
		return
	}

	info := s.collectProjectInfoTelemetry(project)
	if err := s.client.SendTelemetry(ctx, info); err != nil {
		if s.options.LoggingEnabled {
			s.logger.Logf("Error sending project info telemetry: %v", err)
		}
		return
	}

	s.seenProjects.Add(project.configFilePath)
}

func (s *Session) collectProjectInfoTelemetry(project *Project) lsproto.TelemetryEvent {
	opts := project.CommandLine.CompilerOptions()
	if opts == nil {
		opts = &core.CompilerOptions{}
	}

	configFileName := "other"
	if project.Kind == KindConfigured {
		baseName := tspath.GetBaseFileName(project.configFileName)
		if baseName == "tsconfig.json" || baseName == "jsconfig.json" {
			configFileName = baseName
		}
	}

	projectType := "inferred"
	if project.Kind == KindConfigured {
		projectType = "configured"
	}

	props := map[string]string{
		"configFileName": configFileName,
		"projectType":    projectType,
		"version":        core.Version(),
	}

	// Compiler options — same approach as Strada's convertCompilerOptionsForTelemetry:
	// booleans and enum string names, no paths.
	compilerOptions := map[string]any{}
	setTristate(compilerOptions, "strict", opts.Strict)
	setTristate(compilerOptions, "noImplicitAny", opts.NoImplicitAny)
	setTristate(compilerOptions, "noImplicitThis", opts.NoImplicitThis)
	setTristate(compilerOptions, "strictNullChecks", opts.StrictNullChecks)
	setTristate(compilerOptions, "strictFunctionTypes", opts.StrictFunctionTypes)
	setTristate(compilerOptions, "strictBindCallApply", opts.StrictBindCallApply)
	setTristate(compilerOptions, "strictPropertyInitialization", opts.StrictPropertyInitialization)
	setTristate(compilerOptions, "strictBuiltinIteratorReturn", opts.StrictBuiltinIteratorReturn)
	setTristate(compilerOptions, "useUnknownInCatchVariables", opts.UseUnknownInCatchVariables)
	setTristate(compilerOptions, "exactOptionalPropertyTypes", opts.ExactOptionalPropertyTypes)
	setTristate(compilerOptions, "allowJs", opts.AllowJs)
	setTristate(compilerOptions, "checkJs", opts.CheckJs)
	setTristate(compilerOptions, "noEmit", opts.NoEmit)
	setTristate(compilerOptions, "declaration", opts.Declaration)
	setTristate(compilerOptions, "composite", opts.Composite)
	setTristate(compilerOptions, "isolatedModules", opts.IsolatedModules)
	setTristate(compilerOptions, "skipLibCheck", opts.SkipLibCheck)
	setTristate(compilerOptions, "incremental", opts.Incremental)
	if opts.Target != core.ScriptTargetNone {
		compilerOptions["target"] = opts.Target.String()
	}
	if opts.Module != core.ModuleKindNone {
		compilerOptions["module"] = opts.Module.String()
	}
	if opts.ModuleResolution != core.ModuleResolutionKindUnknown {
		compilerOptions["moduleResolution"] = opts.ModuleResolution.String()
	}
	if opts.Jsx != core.JsxEmitNone {
		compilerOptions["jsx"] = opts.Jsx.String()
	}
	if b, err := json.Marshal(compilerOptions); err == nil {
		props["compilerOptions"] = string(b)
	}

	// Config file shape
	if raw, ok := project.CommandLine.Raw.(*collections.OrderedMap[string, any]); ok {
		props["extends"] = boolTelemetry(raw.Has("extends"))
		props["files"] = boolTelemetry(raw.Has("files"))
		props["include"] = boolTelemetry(raw.Has("include"))
		props["exclude"] = boolTelemetry(raw.Has("exclude"))
	}

	return lsproto.TelemetryEvent{
		ProjectInfoTelemetryEvent: &lsproto.ProjectInfoTelemetryEvent{
			Properties:   props,
			Measurements: countFileStats(project.Program.GetSourceFiles()),
		},
	}
}

func setTristate(m map[string]any, key string, v core.Tristate) {
	if v == core.TSTrue {
		m[key] = true
	} else if v == core.TSFalse {
		m[key] = false
	}
}

func boolTelemetry(v bool) string {
	if v {
		return "true"
	}
	return "false"
}

func countFileStats(sourceFiles []*ast.SourceFile) *lsproto.ProjectInfoTelemetryMeasurements {
	var stats lsproto.ProjectInfoTelemetryMeasurements
	for _, sf := range sourceFiles {
		size := float64(sf.End())
		switch sf.ScriptKind {
		case core.ScriptKindJS:
			stats.JsFileCount++
			stats.JsFileSize += size
		case core.ScriptKindJSX:
			stats.JsxFileCount++
			stats.JsxFileSize += size
		case core.ScriptKindTS:
			if tspath.IsDeclarationFileName(sf.FileName()) {
				stats.DtsFileCount++
				stats.DtsFileSize += size
			} else {
				stats.TsFileCount++
				stats.TsFileSize += size
			}
		case core.ScriptKindTSX:
			stats.TsxFileCount++
			stats.TsxFileSize += size
		}
	}
	return &stats
}

func (s *Session) Snapshot() *Snapshot {
	s.snapshotMu.RLock()
	defer s.snapshotMu.RUnlock()
	return s.snapshot
}

// getSnapshot flushes pending changes and updates the session's snapshot
// if needed for the given request. When callerRef is true, the returned
// snapshot has an extra reference for the caller (taken atomically under
// snapshotMu), guaranteeing it stays alive until the caller calls Deref.
func (s *Session) getSnapshot(
	ctx context.Context,
	request ResourceRequest,
	callerRef bool,
) *Snapshot {
	s.snapshotUpdateMu.Lock()
	defer s.snapshotUpdateMu.Unlock()
	s.cancelScheduledSnapshotUpdate()

	fileChanges, overlays, ataChanges, newConfig := s.flushChanges(ctx)
	updateSnapshot := !fileChanges.IsEmpty() || len(ataChanges) > 0 || newConfig != nil
	if updateSnapshot {
		// If there are pending file changes, we need to update the snapshot.
		// Sending the requested URI ensures that the project for this URI is loaded.
		return s.updateSnapshot(ctx, overlays, SnapshotChange{
			reason:          UpdateReasonRequestedLanguageServicePendingChanges,
			fileChanges:     fileChanges,
			ataChanges:      ataChanges,
			newConfig:       newConfig,
			ResourceRequest: request,
		}, callerRef)
	}
	// If there are no pending file changes, we can try to use the current snapshot.
	s.snapshotMu.RLock()
	snapshot := s.snapshot
	var updateReason UpdateReason
	if len(request.Projects) > 0 {
		updateReason = UpdateReasonRequestedLanguageServiceProjectDirty
	} else if request.ProjectTree != nil {
		updateReason = UpdateReasonRequestedLoadProjectTree
	} else if request.AutoImports != "" {
		updateReason = UpdateReasonRequestedLanguageServiceWithAutoImports
	} else {
		for _, document := range request.Documents {
			project := snapshot.GetDefaultProject(document)
			if project == nil {
				updateReason = UpdateReasonRequestedLanguageServiceProjectNotLoaded
			} else if project.dirty {
				updateReason = UpdateReasonRequestedLanguageServiceProjectDirty
			}
		}
		if updateReason == UpdateReasonUnknown {
			for _, document := range request.ConfiguredProjectDocuments {
				if snapshot.fs.isOpenFile(document.FileName()) {
					project := snapshot.GetDefaultProject(document)
					if project == nil {
						updateReason = UpdateReasonRequestedLanguageServiceProjectNotLoaded
					} else if project.dirty {
						updateReason = UpdateReasonRequestedLanguageServiceProjectDirty
					}
				} else {
					updateReason = UpdateReasonRequestedLanguageServiceForFileNotOpen
				}
			}
		}
	}
	if updateReason == UpdateReasonUnknown {
		if callerRef {
			snapshot.ref()
		}
		s.snapshotMu.RUnlock()
		return snapshot
	}

	s.snapshotMu.RUnlock()
	return s.updateSnapshot(ctx, overlays, SnapshotChange{
		reason:          updateReason,
		ResourceRequest: request,
	}, callerRef)
}

func (s *Session) getSnapshotAndDefaultProject(ctx context.Context, uri lsproto.DocumentUri, callerRef bool) (*Snapshot, *Project, *ls.LanguageService, error) {
	snapshot := s.getSnapshot(
		ctx,
		ResourceRequest{Documents: []lsproto.DocumentUri{uri}},
		callerRef,
	)
	project := snapshot.GetDefaultProject(uri)
	if project == nil {
		return nil, nil, nil, fmt.Errorf("no project found for URI %s", uri)
	}
	return snapshot, project, ls.NewLanguageService(project.configFilePath, project.GetProgram(), snapshot, uri.FileName()), nil
}

func (s *Session) GetLanguageService(ctx context.Context, uri lsproto.DocumentUri) (*ls.LanguageService, error) {
	_, _, languageService, err := s.getSnapshotAndDefaultProject(ctx, uri, false /*callerRef*/)
	if err != nil {
		return nil, err
	}
	return languageService, nil
}

func (s *Session) GetLanguageServiceAndProjectsForFile(ctx context.Context, uri lsproto.DocumentUri) (*Project, *ls.LanguageService, []ls.Project, error) {
	snapshot, project, defaultLs, err := s.getSnapshotAndDefaultProject(ctx, uri, false /*callerRef*/)
	if err != nil {
		return nil, nil, nil, err
	}
	// !!! TODO: sheetal:  Get other projects that contain the file with symlink
	allProjects := snapshot.GetProjectsContainingFile(uri)
	return project, defaultLs, allProjects, nil
}

func (s *Session) GetProjectsForFile(ctx context.Context, uri lsproto.DocumentUri) ([]ls.Project, error) {
	snapshot := s.getSnapshot(
		ctx,
		ResourceRequest{ConfiguredProjectDocuments: []lsproto.DocumentUri{uri}},
		false, /*callerRef*/
	)

	// !!! TODO: sheetal:  Get other projects that contain the file with symlink
	allProjects := snapshot.GetProjectsContainingFile(uri)
	return allProjects, nil
}

func (s *Session) GetLanguageServicesForDocuments(ctx context.Context, uris []lsproto.DocumentUri) []*ls.LanguageService {
	snapshot := s.getSnapshot(
		ctx,
		ResourceRequest{Documents: uris},
		false, /*callerRef*/
	)

	activeFile := ""
	if len(uris) > 0 {
		activeFile = uris[0].FileName()
	}

	projects := snapshot.ProjectCollection.Projects()
	services := make([]*ls.LanguageService, 0, len(projects))
	for _, project := range projects {
		program := project.GetProgram()
		if program == nil {
			continue
		}

		services = append(services, ls.NewLanguageService(project.configFilePath, program, snapshot, activeFile))
	}
	return services
}

func (s *Session) GetLanguageServiceForProjectWithFile(ctx context.Context, project *Project, uri lsproto.DocumentUri) *ls.LanguageService {
	snapshot := s.getSnapshot(
		ctx,
		ResourceRequest{Projects: []tspath.Path{project.Id()}},
		false, /*callerRef*/
	)
	// Ensure we have updated project
	project = snapshot.ProjectCollection.GetProjectByPath(project.Id())
	if project == nil {
		return nil
	}
	// if program doesnt contain this file any more ignore it
	if !project.HasFile(uri.FileName()) {
		return nil
	}
	return ls.NewLanguageService(project.configFilePath, project.GetProgram(), snapshot, uri.FileName())
}

// WithSnapshotLoadingProjectTree acquires a ref'd snapshot with the
// requested project trees loaded, then calls fn. The snapshot stays alive
// for the duration of fn.
func (s *Session) WithSnapshotLoadingProjectTree(
	ctx context.Context,
	requestedProjectTrees *collections.Set[tspath.Path],
	fn func(*Snapshot),
) {
	snapshot := s.getSnapshot(
		ctx,
		ResourceRequest{ProjectTree: &ProjectTreeRequest{requestedProjectTrees}},
		true, /*callerRef*/
	)
	defer snapshot.Deref(s)
	fn(snapshot)
}

// GetCurrentLanguageServiceWithAutoImports flushes pending file changes, clones the
// current snapshot with auto-import preparation for the given URI, then returns a
// LanguageService for the default project. Use this only outside of request handling
// (e.g. cache warming). For request handlers, use GetLanguageServiceWithAutoImports
// with the request-level snapshot instead.
func (s *Session) GetCurrentLanguageServiceWithAutoImports(ctx context.Context, uri lsproto.DocumentUri) (*ls.LanguageService, error) {
	snapshot := s.getSnapshot(ctx, ResourceRequest{
		Documents:   []lsproto.DocumentUri{uri},
		AutoImports: uri,
	}, false /*callerRef*/)
	project := snapshot.GetDefaultProject(uri)
	if project == nil {
		return nil, fmt.Errorf("no project found for URI %s", uri)
	}
	return ls.NewLanguageService(project.configFilePath, project.GetProgram(), snapshot, uri.FileName()), nil
}

// WithLanguageServiceAndSnapshot synchronously acquires a ref'd snapshot and
// creates a language service for the given URI. fn receives both the language
// service and the backing snapshot so it can clone the snapshot (e.g. to
// enable auto-imports). The snapshot is kept alive until the async work
// completes.
//
// Only use this method when the callback needs direct access to the snapshot.
// For handlers that only need a LanguageService, use GetLanguageService
// directly—language services continue to work even after their backing
// snapshot has been disposed.
func (s *Session) WithLanguageServiceAndSnapshot(
	ctx context.Context,
	uri lsproto.DocumentUri,
	fn func(*ls.LanguageService, *Snapshot) (func() error, error),
) (func() error, error) {
	snapshot, _, languageService, err := s.getSnapshotAndDefaultProject(ctx, uri, true /*callerRef*/)
	if err != nil {
		return nil, err
	}
	asyncWork, err := fn(languageService, snapshot)
	if err != nil || asyncWork == nil {
		snapshot.Deref(s)
		return nil, err
	}
	return func() error {
		defer snapshot.Deref(s)
		return asyncWork()
	}, nil
}

// GetLanguageServiceWithAutoImports clones the given snapshot with auto-import
// preparation for the given URI, without flushing pending file changes.
// The cloned snapshot will be adopted as the session's current snapshot in the background
// if other changes haven't been adopted in the meantime.
func (s *Session) GetLanguageServiceWithAutoImports(ctx context.Context, baseSnapshot *Snapshot, uri lsproto.DocumentUri) (*ls.LanguageService, error) {
	change := SnapshotChange{
		reason: UpdateReasonRequestedLanguageServiceWithAutoImports,
		ResourceRequest: ResourceRequest{
			Documents:   []lsproto.DocumentUri{uri},
			AutoImports: uri,
		},
	}
	newSnapshot := baseSnapshot.Clone(ctx, change, baseSnapshot.fs.overlays, s)

	project := newSnapshot.GetDefaultProject(uri)
	if project == nil {
		// Clone's initial ref (1) is released since we won't use this snapshot.
		newSnapshot.Deref(s)
		return nil, fmt.Errorf("no project found for URI %s", uri)
	}

	// The clone's initial ref (1) is transferred to adoptSnapshotChange,
	// which will either promote it as the session's current snapshot or
	// release it if the session has moved on.
	s.backgroundQueue.Enqueue(s.backgroundCtx, func(ctx context.Context) {
		s.adoptSnapshotChange(baseSnapshot, newSnapshot)
	})

	return ls.NewLanguageService(project.configFilePath, project.GetProgram(), newSnapshot, uri.FileName()), nil
}

// adoptSnapshotChange promotes a cloned snapshot as the session's current
// snapshot so future requests benefit from the work already done. If the
// session has moved on, the snapshot is discarded; the next request needing
// auto-imports will redo the work on the latest snapshot.
func (s *Session) adoptSnapshotChange(baseSnapshot, newSnapshot *Snapshot) {
	s.snapshotMu.Lock()
	oldSnapshot := s.snapshot
	if oldSnapshot == baseSnapshot {
		// Session hasn't moved on; adopt the new snapshot. The clone's initial
		// ref is transferred to become the session's ref for its current snapshot.
		s.snapshot = newSnapshot
		s.snapshotMu.Unlock()
		if s.options.LoggingEnabled {
			s.logger.Logf("Adopted snapshot %d (parent %d) as current session snapshot (replacing %d)", newSnapshot.id, newSnapshot.parentId, oldSnapshot.id)
			s.logger.Log(newSnapshot.builderLogs.String())
		}
		oldSnapshot.Deref(s)
	} else {
		// Session has moved on to a newer snapshot; discard this one.
		// Release the clone's initial ref. If a handler is still using
		// the snapshot, its own ref keeps it alive.
		s.snapshotMu.Unlock()
		if s.options.LoggingEnabled {
			s.logger.Logf("Discarded snapshot %d (parent %d); session has moved on to snapshot %d", newSnapshot.id, newSnapshot.parentId, oldSnapshot.id)
			if logs := newSnapshot.builderLogs.String(); logs != "" {
				s.logger.Logf("--- Discarded snapshot %d builder logs (NOT adopted) ---", newSnapshot.id)
				s.logger.Log(logs)
				s.logger.Logf("--- End discarded snapshot %d builder logs ---", newSnapshot.id)
			}
		}
		newSnapshot.Deref(s)
	}
}

func (s *Session) UpdateSnapshot(ctx context.Context, overlays map[tspath.Path]*Overlay, change SnapshotChange) {
	s.updateSnapshot(ctx, overlays, change, false)
}

// updateSnapshotRef is like UpdateSnapshot but returns the created snapshot
// with an extra reference for the caller. The ref is taken atomically with
// the snapshot assignment under snapshotMu, so the snapshot is guaranteed
// to be alive when returned. The caller must call snapshot.Deref(s) when done.
func (s *Session) updateSnapshotRef(ctx context.Context, overlays map[tspath.Path]*Overlay, change SnapshotChange) *Snapshot {
	return s.updateSnapshot(ctx, overlays, change, true)
}

func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.Path]*Overlay, change SnapshotChange, callerRef bool) *Snapshot {
	s.snapshotMu.Lock()
	oldSnapshot := s.snapshot
	newSnapshot := oldSnapshot.Clone(ctx, change, overlays, s)
	s.snapshot = newSnapshot
	if callerRef {
		newSnapshot.ref()
	}
	if newSnapshot != oldSnapshot {
		// Release the session's reference to the old snapshot. The new snapshot's
		// clone ref (1) is transferred to become the session's ref for its current
		// snapshot. Other holders (e.g. active handlers) keep the old snapshot alive
		// via their own refs until they complete.
		oldSnapshot.Deref(s)
	}
	s.snapshotMu.Unlock()

	// Enqueue ATA updates if needed
	if s.typingsInstaller != nil && !s.Config().IsATADisabled() {
		s.triggerATAForUpdatedProjects(newSnapshot)
	}

	// Enqueue logging, watch updates, and diagnostic refresh tasks
	// !!! userPreferences/configuration updates
	s.backgroundQueue.Enqueue(s.backgroundCtx, func(ctx context.Context) {
		if s.options.LoggingEnabled {
			s.logger.Logf("Adopted snapshot %d (parent %d) as current session snapshot (replacing %d)", newSnapshot.id, newSnapshot.parentId, oldSnapshot.id)
			s.logger.Log(newSnapshot.builderLogs.String())
			s.logProjectChanges(oldSnapshot, newSnapshot)
			s.logger.Log("")
		}
		if s.options.WatchEnabled {
			if err := s.updateWatches(oldSnapshot, newSnapshot); err != nil && s.options.LoggingEnabled {
				s.logger.Log(err)
			}
		}
		s.publishProgramDiagnostics(oldSnapshot, newSnapshot)
		s.sendProjectInfoTelemetryForNewProjects(oldSnapshot, newSnapshot)
		s.warmAutoImportCache(ctx, change, oldSnapshot, newSnapshot)
	})

	return newSnapshot
}

// WaitForBackgroundTasks waits for all background tasks to complete.
// This is intended to be used only for testing purposes.
func (s *Session) WaitForBackgroundTasks() {
	s.cancelIdleCacheClean()
	s.backgroundQueue.Wait()
}

func updateWatch[T any](ctx context.Context, session *Session, logger logging.Logger, oldWatcher, newWatcher *WatchedFiles[T]) []error {
	var errors []error
	if newWatcher != nil {
		w := newWatcher.Watchers()
		watchers := append(w.WorkspaceWatchers, w.OutsideWorkspaceWatchers...)
		if len(watchers) > 0 {
			var newWatchers collections.OrderedMap[WatcherID, *lsproto.FileSystemWatcher]
			for i, watcher := range watchers {
				globId := WatcherID(fmt.Sprintf("%s.%d", w.WatcherID, i))
				if session.watches.Acquire(watcher, globId) {
					newWatchers.Set(globId, watcher)
				}
			}
			var watchErrors []error
			for id, watcher := range newWatchers.Entries() {
				// Create a fresh timeout per client call so earlier calls
				// don't consume the deadline for later ones.
				callCtx, callCancel := context.WithTimeout(ctx, watchRequestTimeout)
				err := session.client.WatchFiles(callCtx, id, []*lsproto.FileSystemWatcher{watcher})
				callCancel()
				if err != nil {
					watchErrors = append(watchErrors, err)
				} else if logger != nil {
					if oldWatcher == nil {
						logger.Log(fmt.Sprintf("Added new watch: %s", id))
					} else {
						logger.Log(fmt.Sprintf("Updated watch: %s", id))
					}
					logger.Log("\t" + fileSystemWatcherGlobString(watcher))
					logger.Log("")
				}
			}
			if len(watchErrors) > 0 {
				// Roll back ALL newly-acquired watchers on any failure to keep
				// refcounts clean. On retry, Acquire will see them as new again.
				// Re-registering an already-registered watcher with the client
				// is harmless (registerCapability with the same ID replaces it).
				for _, watcher := range newWatchers.Entries() {
					session.watches.Release(watcher)
				}
				session.watches.MarkPending(w.WatcherID)
				errors = append(errors, watchErrors...)
			} else {
				session.watches.ClearPending(w.WatcherID)
			}
			if len(w.IgnoredPaths) > 0 {
				logger.Logf("%d paths ineligible for watching", len(w.IgnoredPaths))
				if logger.IsVerbose() {
					for path := range w.IgnoredPaths {
						logger.Log("\t" + path)
					}
				}
			}
		}
	}
	if oldWatcher != nil {
		w := oldWatcher.Watchers()
		watchers := append(w.WorkspaceWatchers, w.OutsideWorkspaceWatchers...)
		if len(watchers) > 0 {
			var removedIDs []WatcherID
			for _, watcher := range watchers {
				if id, removed := session.watches.Release(watcher); removed {
					removedIDs = append(removedIDs, id)
				}
			}
			for _, id := range removedIDs {
				callCtx, callCancel := context.WithTimeout(ctx, watchRequestTimeout)
				err := session.client.UnwatchFiles(callCtx, id)
				callCancel()
				if err != nil {
					errors = append(errors, err)
				} else if logger != nil && newWatcher == nil {
					logger.Log(fmt.Sprintf("Removed watch: %s", id))
				}
			}
		}
	}
	return errors
}

func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) error {
	var errors []error
	start := time.Now()
	ctx := s.backgroundCtx
	core.DiffMapsFunc(
		oldSnapshot.ConfigFileRegistry.configs,
		newSnapshot.ConfigFileRegistry.configs,
		func(a, b *configFileEntry) bool {
			return a.rootFilesWatch.ID() == b.rootFilesWatch.ID()
		},
		func(_ tspath.Path, addedEntry *configFileEntry) {
			errors = append(errors, updateWatch(ctx, s, s.logger, nil, addedEntry.rootFilesWatch)...)
		},
		func(_ tspath.Path, removedEntry *configFileEntry) {
			errors = append(errors, updateWatch(ctx, s, s.logger, removedEntry.rootFilesWatch, nil)...)
		},
		func(_ tspath.Path, oldEntry, newEntry *configFileEntry) {
			errors = append(errors, updateWatch(ctx, s, s.logger, oldEntry.rootFilesWatch, newEntry.rootFilesWatch)...)
		},
	)
	// Retry config watchers whose IDs didn't change but whose previous registration failed.
	for path, newEntry := range newSnapshot.ConfigFileRegistry.configs {
		if oldEntry, ok := oldSnapshot.ConfigFileRegistry.configs[path]; ok {
			if oldEntry.rootFilesWatch.ID() == newEntry.rootFilesWatch.ID() {
				if s.watches.IsPending(newEntry.rootFilesWatch.ID()) {
					errors = append(errors, updateWatch(ctx, s, s.logger, nil, newEntry.rootFilesWatch)...)
				}
			}
		}
	}

	collections.DiffOrderedMaps(
		oldSnapshot.ProjectCollection.ProjectsByPath(),
		newSnapshot.ProjectCollection.ProjectsByPath(),
		func(_ tspath.Path, addedProject *Project) {
			errors = append(errors, updateWatch(ctx, s, s.logger, nil, addedProject.programFilesWatch)...)
			errors = append(errors, updateWatch(ctx, s, s.logger, nil, addedProject.typingsWatch)...)
		},
		func(_ tspath.Path, removedProject *Project) {
			errors = append(errors, updateWatch(ctx, s, s.logger, removedProject.programFilesWatch, nil)...)
			errors = append(errors, updateWatch(ctx, s, s.logger, removedProject.typingsWatch, nil)...)
		},
		func(_ tspath.Path, oldProject, newProject *Project) {
			if oldProject.programFilesWatch.ID() != newProject.programFilesWatch.ID() {
				errors = append(errors, updateWatch(ctx, s, s.logger, oldProject.programFilesWatch, newProject.programFilesWatch)...)
			} else {
				if s.watches.IsPending(newProject.programFilesWatch.ID()) {
					errors = append(errors, updateWatch(ctx, s, s.logger, nil, newProject.programFilesWatch)...)
				}
			}
			if oldProject.typingsWatch.ID() != newProject.typingsWatch.ID() {
				errors = append(errors, updateWatch(ctx, s, s.logger, oldProject.typingsWatch, newProject.typingsWatch)...)
			} else {
				if s.watches.IsPending(newProject.typingsWatch.ID()) {
					errors = append(errors, updateWatch(ctx, s, s.logger, nil, newProject.typingsWatch)...)
				}
			}
		},
	)

	if oldSnapshot.autoImportsWatch.ID() != newSnapshot.autoImportsWatch.ID() {
		errors = append(errors, updateWatch(ctx, s, s.logger, oldSnapshot.autoImportsWatch, newSnapshot.autoImportsWatch)...)
	} else {
		if s.watches.IsPending(newSnapshot.autoImportsWatch.ID()) {
			errors = append(errors, updateWatch(ctx, s, s.logger, nil, newSnapshot.autoImportsWatch)...)
		}
	}

	if len(errors) > 0 {
		return fmt.Errorf("errors updating watches: %v", errors)
	} else if s.options.LoggingEnabled {
		s.logger.Log(fmt.Sprintf("Updated watches in %v", time.Since(start)))
	}
	return nil
}

func (s *Session) Close() {
	// Cancel any pending scheduled snapshot update
	s.cancelScheduledSnapshotUpdate()
	// Cancel any pending diagnostics refresh
	s.cancelDiagnosticsRefresh()
	// Cancel any pending auto-import cache warming
	s.cancelWarmAutoImportCache()
	// Cancel any pending idle cache clean
	s.cancelIdleCacheClean()
	// Cancel periodic performance telemetry
	s.stopPerformanceTelemetry()
	s.backgroundQueue.Close()
}

func (s *Session) flushChanges(ctx context.Context) (FileChangeSummary, map[tspath.Path]*Overlay, map[tspath.Path]*ATAStateChange, *lsutil.UserPreferences) {
	s.pendingFileChangesMu.Lock()
	defer s.pendingFileChangesMu.Unlock()
	s.pendingATAChangesMu.Lock()
	defer s.pendingATAChangesMu.Unlock()
	pendingATAChanges := s.pendingATAChanges
	s.pendingATAChanges = make(map[tspath.Path]*ATAStateChange)
	fileChanges, overlays := s.flushChangesLocked(ctx)
	s.userConfigRWMu.Lock()
	defer s.userConfigRWMu.Unlock()
	var newPrefs *lsutil.UserPreferences
	if s.pendingUserConfigChanges {
		p := s.workspaceUserPreferences
		newPrefs = &p
	}
	s.pendingUserConfigChanges = false
	return fileChanges, overlays, pendingATAChanges, newPrefs
}

// flushChangesLocked should only be called with s.pendingFileChangesMu held.
func (s *Session) flushChangesLocked(ctx context.Context) (FileChangeSummary, map[tspath.Path]*Overlay) {
	if len(s.pendingFileChanges) == 0 {
		return FileChangeSummary{}, s.fs.Overlays()
	}

	start := time.Now()
	changes, overlays := s.fs.processChanges(s.pendingFileChanges)
	if s.options.LoggingEnabled {
		s.logger.Log(fmt.Sprintf("Processed %d file changes in %v", len(s.pendingFileChanges), time.Since(start)))
	}
	s.pendingFileChanges = nil
	return changes, overlays
}

// logProjectChanges logs information about projects that have changed between snapshots
func (s *Session) logProjectChanges(oldSnapshot *Snapshot, newSnapshot *Snapshot) {
	var loggedProjectChanges bool
	logProject := func(project *Project) {
		var builder strings.Builder
		project.print(s.logger.IsVerbose() /*writeFileNames*/, s.logger.IsVerbose() /*writeFileExplanation*/, &builder)
		s.logger.Log(builder.String())
		loggedProjectChanges = true
	}
	collections.DiffOrderedMaps(
		oldSnapshot.ProjectCollection.ProjectsByPath(),
		newSnapshot.ProjectCollection.ProjectsByPath(),
		func(path tspath.Path, addedProject *Project) {
			// New project added
			logProject(addedProject)
		},
		func(path tspath.Path, removedProject *Project) {
			// Project removed
			s.logger.Logf("\nProject '%s' removed\n%s", removedProject.Name(), hr)
		},
		func(path tspath.Path, oldProject, newProject *Project) {
			// Project updated
			if newProject.ProgramUpdateKind == ProgramUpdateKindNewFiles {
				logProject(newProject)
			}
		},
	)

	if loggedProjectChanges || s.logger.IsVerbose() {
		s.logCacheStats(newSnapshot)
	}
}

func (s *Session) logCacheStats(snapshot *Snapshot) {
	var parseCacheSize int
	var extendedConfigCount int
	if s.logger.IsVerbose() {
		s.parseCache.entries.Range(func(_ ParseCacheKey, _ *refCountCacheEntry[*ast.SourceFile]) bool {
			parseCacheSize++
			return true
		})
		s.extendedConfigCache.entries.Range(func(_ tspath.Path, _ *ownerCacheEntry[*ExtendedConfigCacheEntry]) bool {
			extendedConfigCount++
			return true
		})
	}
	s.logger.Log("\n======== Cache Statistics ========")
	s.logger.Logf("Open file count:   %6d", len(snapshot.fs.overlays))
	s.logger.Logf("Cached disk files: %6d", len(snapshot.fs.diskFiles))
	s.logger.Logf("Realpath aliases:  %6d", len(snapshot.fs.nodeModulesRealpathAliases))
	s.logger.Logf("Project count:     %6d", len(snapshot.ProjectCollection.Projects()))
	s.logger.Logf("Config count:      %6d", len(snapshot.ConfigFileRegistry.configs))
	if s.logger.IsVerbose() {
		s.logger.Logf("Parse cache size:           %6d", parseCacheSize)
		s.logger.Logf("Program count:              %6d", s.programCounter.Len())
		s.logger.Logf("Extended config cache size: %6d", extendedConfigCount)

		s.logger.Log("Auto Imports:")
		autoImportStats := snapshot.AutoImportRegistry().GetCacheStats()
		s.logger.Logf("\tUnique packages (by realpath): %d", autoImportStats.UniquePackageCount)
		if len(autoImportStats.ProjectBuckets) > 0 {
			s.logger.Log("\tProject buckets:")
			for _, bucket := range autoImportStats.ProjectBuckets {
				s.logger.Logf("\t\t%s%s:", bucket.Path, core.IfElse(bucket.State.Dirty(), " (dirty)", ""))
				s.logger.Logf("\t\t\tFiles: %d", bucket.FileCount)
				s.logger.Logf("\t\t\tExports: %d", bucket.ExportCount)
			}
		}
		if len(autoImportStats.NodeModulesBuckets) > 0 {
			s.logger.Log("\tnode_modules buckets:")
			for _, bucket := range autoImportStats.NodeModulesBuckets {
				s.logger.Logf("\t\t%s%s:", bucket.Path, core.IfElse(bucket.State.Dirty(), " (dirty)", ""))
				for packageName := range bucket.State.DirtyPackages().Keys() {
					s.logger.Logf("\t\t\tNeeds granular update: %s", packageName)
				}
				if bucket.DependencyNames != nil {
					s.logger.Logf("\t\t\tCollected packages: %d", bucket.DependencyNames.Len())
				} else {
					s.logger.Logf("\t\t\tCollected packages: all, due to no package.json!")
				}
				s.logger.Logf("\t\t\tTotal packages: %d", bucket.PackageNames.Len())
				s.logger.Logf("\t\t\tFiles: %d", bucket.FileCount)
				s.logger.Logf("\t\t\tExports: %d", bucket.ExportCount)
				if bucket.State.RecursiveSearchPackages() == nil {
					s.logger.Log("\t\t\tRecursive search: all")
				} else if bucket.State.RecursiveSearchPackages().Len() > 0 {
					s.logger.Logf("\t\t\tRecursive search: %d packages", bucket.State.RecursiveSearchPackages().Len())
				} else {
					s.logger.Log("\t\t\tRecursive search: none")
				}
			}
		}
	}
}

func (s *Session) NpmInstall(cwd string, npmInstallArgs []string) ([]byte, error) {
	return s.npmExecutor.NpmInstall(cwd, npmInstallArgs)
}

func (s *Session) refreshInlayHintsIfNeeded(oldPrefs lsutil.UserPreferences, newPrefs lsutil.UserPreferences) {
	if oldPrefs.InlayHints != newPrefs.InlayHints {
		if err := s.client.RefreshInlayHints(s.backgroundCtx); err != nil && s.options.LoggingEnabled {
			s.logger.Logf("Error refreshing inlay hints: %v", err)
		}
	}
}

func (s *Session) refreshCodeLensIfNeeded(oldPrefs lsutil.UserPreferences, newPrefs lsutil.UserPreferences) {
	if oldPrefs.CodeLens != newPrefs.CodeLens {
		if err := s.client.RefreshCodeLens(s.backgroundCtx); err != nil && s.options.LoggingEnabled {
			s.logger.Logf("Error refreshing code lens: %v", err)
		}
	}
}

func (s *Session) refreshDiagnosticsIfNeeded(oldPrefs lsutil.UserPreferences, newPrefs lsutil.UserPreferences) {
	if oldPrefs.CustomConfigFileName != newPrefs.CustomConfigFileName ||
		oldPrefs.ReportStyleChecksAsWarnings != newPrefs.ReportStyleChecksAsWarnings ||
		oldPrefs.EnableValidation != newPrefs.EnableValidation {
		s.ScheduleDiagnosticsRefresh()
	}
}

func (s *Session) refreshATAIfNeeded(oldPrefs lsutil.UserPreferences, newPrefs lsutil.UserPreferences) {
	if oldPrefs.IsATADisabled() && !newPrefs.IsATADisabled() {
		// ATA was re-enabled; schedule a diagnostics refresh so the next snapshot update
		// re-triggers ATA for existing projects with the new setting.
		s.ScheduleDiagnosticsRefresh()
	}
}

func (s *Session) publishProgramDiagnostics(oldSnapshot *Snapshot, newSnapshot *Snapshot) {
	if !s.options.PushDiagnosticsEnabled {
		return
	}
	if newSnapshot.UserPreferences().EnableValidation.IsFalse() {
		if oldSnapshot.UserPreferences().EnableValidation.IsFalse() {
			return
		}
		for configFilePath, oldProject := range oldSnapshot.ProjectCollection.ProjectsByPath().Entries() {
			if oldProject.Kind == KindConfigured && oldSnapshot.ProjectCollection.GetOpenConfiguredProjects().Has(configFilePath) {
				s.publishProjectDiagnostics(s.backgroundCtx, string(configFilePath), nil, oldSnapshot.converters)
			}
		}
		return
	}

	ctx := s.backgroundCtx
	oldProjects := oldSnapshot.ProjectCollection.ProjectsByPath()
	newProjects := newSnapshot.ProjectCollection.ProjectsByPath()
	oldOpenProjects := oldSnapshot.ProjectCollection.GetOpenConfiguredProjects()
	newOpenProjects := newSnapshot.ProjectCollection.GetOpenConfiguredProjects()
	collections.DiffOrderedMaps(
		oldProjects,
		newProjects,
		func(configFilePath tspath.Path, addedProject *Project) {
			if !shouldPublishProgramDiagnostics(addedProject, newSnapshot.ID()) || !newOpenProjects.Has(configFilePath) {
				return
			}
			s.publishProjectDiagnostics(ctx, string(configFilePath), addedProject.GetProjectDiagnostics(ctx), newSnapshot.converters)
		},
		func(configFilePath tspath.Path, removedProject *Project) {
			if removedProject.Kind != KindConfigured {
				return
			}
			s.publishProjectDiagnostics(ctx, string(configFilePath), nil, oldSnapshot.converters)
		},
		func(configFilePath tspath.Path, oldProject, newProject *Project) {
			if !shouldPublishProgramDiagnostics(newProject, newSnapshot.ID()) || !newOpenProjects.Has(configFilePath) {
				return
			}
			s.publishProjectDiagnostics(ctx, string(configFilePath), newProject.GetProjectDiagnostics(ctx), newSnapshot.converters)
		},
	)
	// Sync diagnostics for projects whose open-file state changed without a program update.
	for configFilePath, newProject := range newProjects.Entries() {
		if newProject.Kind != KindConfigured {
			continue
		}
		if !oldProjects.Has(configFilePath) {
			continue // Handled by added project case above
		}
		oldProject, _ := oldProjects.Get(configFilePath)
		newHasOpenFiles := newOpenProjects.Has(configFilePath)
		oldHasOpenFiles := oldOpenProjects.Has(configFilePath)
		if newHasOpenFiles && !oldHasOpenFiles &&
			(newProject == oldProject || !shouldPublishProgramDiagnostics(newProject, newSnapshot.ID())) {
			// Project reopened without a program update
			s.publishProjectDiagnostics(ctx, string(configFilePath), newProject.GetProjectDiagnostics(ctx), newSnapshot.converters)
		} else if !newHasOpenFiles && oldHasOpenFiles {
			// Project closed
			s.publishProjectDiagnostics(ctx, string(configFilePath), nil, newSnapshot.converters)
		}
	}
}

func shouldPublishProgramDiagnostics(p *Project, snapshotID uint64) bool {
	if p.Kind != KindConfigured || p.Program == nil || p.ProgramLastUpdate != snapshotID {
		return false
	}
	return p.ProgramUpdateKind > ProgramUpdateKindCloned
}

func (s *Session) publishProjectDiagnostics(ctx context.Context, configFilePath string, diagnostics []*ast.Diagnostic, converters *lsconv.Converters) {
	if s.Config().EnableValidation.IsFalse() {
		diagnostics = nil
	}
	lspDiagnostics := make([]*lsproto.Diagnostic, 0, len(diagnostics))
	for _, diag := range diagnostics {
		lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPush(ctx, converters, diag))
	}

	if err := s.client.PublishDiagnostics(ctx, &lsproto.PublishDiagnosticsParams{
		Uri:         lsconv.FileNameToDocumentURI(configFilePath),
		Diagnostics: lspDiagnostics,
	}); err != nil && s.options.LoggingEnabled {
		s.logger.Logf("Error publishing diagnostics: %v", err)
	}
}

// EnqueuePublishGlobalDiagnostics schedules a background check for new accumulated
// global diagnostics from checker pools, re-publishing tsconfig diagnostics if changed.
// Multiple calls are coalesced into a single background task.
func (s *Session) EnqueuePublishGlobalDiagnostics() {
	if !s.options.PushDiagnosticsEnabled || s.Config().EnableValidation.IsFalse() {
		return
	}
	if s.globalDiagPublishPending.CompareAndSwap(false, true) {
		s.backgroundQueue.Enqueue(s.backgroundCtx, s.publishGlobalDiagnostics)
	}
}

func (s *Session) publishGlobalDiagnostics(ctx context.Context) {
	defer s.globalDiagPublishPending.Store(false)

	s.snapshotMu.RLock()
	snapshot := s.snapshot
	snapshot.ref()
	s.snapshotMu.RUnlock()
	defer snapshot.Deref(s)

	for _, project := range snapshot.ProjectCollection.Projects() {
		if project.Kind != KindConfigured || project.checkerPool == nil {
			continue
		}
		if project.checkerPool.TakeNewGlobalDiagnostics() {
			s.publishProjectDiagnostics(ctx, string(project.configFilePath), project.GetProjectDiagnostics(ctx), snapshot.converters)
		}
	}
}

func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) {
	for _, project := range newSnapshot.ProjectCollection.Projects() {
		if project.ShouldTriggerATA(newSnapshot.ID()) {
			s.backgroundQueue.Enqueue(s.backgroundCtx, func(ctx context.Context) {
				var logTree *logging.LogTree
				if s.options.LoggingEnabled {
					logTree = logging.NewLogTree("Triggering ATA for project " + project.Name())
				}

				typingsInfo := project.ComputeTypingsInfo()
				request := &ata.TypingsInstallRequest{
					ProjectID:        project.configFilePath,
					TypingsInfo:      &typingsInfo,
					FileNames:        core.Map(project.Program.GetSourceFiles(), func(file *ast.SourceFile) string { return file.FileName() }),
					ProjectRootPath:  project.currentDirectory,
					CompilerOptions:  project.CommandLine.CompilerOptions(),
					CurrentDirectory: s.options.CurrentDirectory,
					GetScriptKind:    core.GetScriptKindFromFileName,
					FS:               s.fs.fs,
					Logger:           logTree,
				}

				projectDisplayName := project.DisplayName(s.options.CurrentDirectory)
				if s.client != nil {
					s.client.ProgressStart(diagnostics.Installing_types_for_0, projectDisplayName)
				}
				result, err := s.typingsInstaller.InstallTypings(request)
				if s.client != nil {
					s.client.ProgressFinish(diagnostics.Installing_types_for_0, projectDisplayName)
				}
				if err != nil {
					if logTree != nil {
						s.logger.Log(fmt.Sprintf("ATA installation failed for project %s: %v", project.Name(), err))
						s.logger.Log(logTree.String())
					}
				} else {
					if !slices.Equal(result.TypingsFiles, project.typingsFiles) {
						s.pendingATAChangesMu.Lock()
						defer s.pendingATAChangesMu.Unlock()
						s.pendingATAChanges[project.configFilePath] = &ATAStateChange{
							TypingsInfo:         &typingsInfo,
							TypingsFiles:        result.TypingsFiles,
							TypingsFilesToWatch: result.FilesToWatch,
							Logs:                logTree,
						}
						s.ScheduleDiagnosticsRefresh()
					}
				}
			})
		}
	}
}

func (s *Session) warmAutoImportCache(ctx context.Context, change SnapshotChange, oldSnapshot, newSnapshot *Snapshot) {
	if change.fileChanges.Changed.Len() == 1 {
		var changedFile lsproto.DocumentUri
		for uri := range change.fileChanges.Changed.Keys() {
			changedFile = uri
		}
		if !newSnapshot.fs.isOpenFile(changedFile.FileName()) {
			return
		}
		prefs := newSnapshot.UserPreferences()
		if prefs.IncludeCompletionsForModuleExports.IsFalse() {
			return
		}
		project := newSnapshot.GetDefaultProject(changedFile)
		if project == nil {
			return
		}
		if newSnapshot.AutoImports.IsPreparedForImportingFile(
			changedFile.FileName(),
			project.configFilePath,
			prefs,
		) {
			return
		}

		// Cancel any previous auto-import warming and create a new cancellable context.
		// Only publish the new cancel func if the derived context is still active,
		// and make the stored cancel func a no-op once that warming task is done.
		s.warmAutoImportMu.Lock()
		if s.warmAutoImportCancel != nil {
			s.warmAutoImportCancel()
		}
		warmCtx, cancel := context.WithCancel(ctx)
		if warmCtx.Err() == nil {
			s.warmAutoImportCancel = func() {
				if warmCtx.Err() != nil {
					return
				}
				s.logger.Logf("Cancelling auto-import warming for file %s", changedFile.FileName())
				cancel()
			}
		}
		s.warmAutoImportMu.Unlock()

		if warmCtx.Err() != nil {
			cancel()
			return
		}
		defer cancel()

		// Clone the snapshot with auto-imports using warmCtx so the expensive
		// extraction work is cancelled if a file change arrives.
		if !newSnapshot.tryRef() {
			return
		}
		defer newSnapshot.Deref(s)

		warmChange := SnapshotChange{
			reason: UpdateReasonRequestedLanguageServiceWithAutoImports,
			ResourceRequest: ResourceRequest{
				Documents:   []lsproto.DocumentUri{changedFile},
				AutoImports: changedFile,
			},
		}
		clonedSnapshot := newSnapshot.Clone(warmCtx, warmChange, newSnapshot.fs.overlays, s)

		// If cancelled during clone, discard the incomplete result.
		if warmCtx.Err() != nil {
			clonedSnapshot.Deref(s)
			return
		}

		// Conditionally adopt: if the session hasn't moved past newSnapshot,
		// promote the clone so future requests benefit from the warmed cache.
		s.adoptSnapshotChange(newSnapshot, clonedSnapshot)
	}
}
