package project

import (
	"context"
	"fmt"
	"maps"
	"slices"
	"sync/atomic"
	"time"

	"github.com/microsoft/typescript-go/internal/collections"
	"github.com/microsoft/typescript-go/internal/core"
	"github.com/microsoft/typescript-go/internal/ls"
	"github.com/microsoft/typescript-go/internal/ls/autoimport"
	"github.com/microsoft/typescript-go/internal/ls/lsconv"
	"github.com/microsoft/typescript-go/internal/ls/lsutil"
	"github.com/microsoft/typescript-go/internal/lsp/lsproto"
	"github.com/microsoft/typescript-go/internal/project/ata"
	"github.com/microsoft/typescript-go/internal/project/dirty"
	"github.com/microsoft/typescript-go/internal/project/logging"
	"github.com/microsoft/typescript-go/internal/sourcemap"
	"github.com/microsoft/typescript-go/internal/tspath"
	"github.com/microsoft/typescript-go/internal/vfs/vfsmatch"
)

type Snapshot struct {
	id       uint64
	parentId uint64
	refCount atomic.Int32

	// Session options are immutable for the server lifetime,
	// so can be a pointer.
	sessionOptions *SessionOptions
	toPath         func(fileName string) tspath.Path
	converters     *lsconv.Converters

	// Immutable state, cloned between snapshots
	fs                                 *SnapshotFS
	ProjectCollection                  *ProjectCollection
	ConfigFileRegistry                 *ConfigFileRegistry
	AutoImports                        *autoimport.Registry
	autoImportsWatch                   *WatchedFiles[map[tspath.Path]string]
	compilerOptionsForInferredProjects *core.CompilerOptions
	userPreferences                    lsutil.UserPreferences

	builderLogs *logging.LogTree
	apiError    error
}

// NewSnapshot initializes a snapshot with refCount 1.
// The caller is responsible for calling Deref when done.
func NewSnapshot(
	id uint64,
	fs *SnapshotFS,
	sessionOptions *SessionOptions,
	configFileRegistry *ConfigFileRegistry,
	compilerOptionsForInferredProjects *core.CompilerOptions,
	userPreferences lsutil.UserPreferences,
	autoImports *autoimport.Registry,
	autoImportsWatch *WatchedFiles[map[tspath.Path]string],
	toPath func(fileName string) tspath.Path,
) *Snapshot {
	s := &Snapshot{
		id: id,

		sessionOptions: sessionOptions,
		toPath:         toPath,

		fs:                                 fs,
		ConfigFileRegistry:                 configFileRegistry,
		ProjectCollection:                  &ProjectCollection{toPath: toPath, openFiles: openFilePaths(fs.overlays)},
		compilerOptionsForInferredProjects: compilerOptionsForInferredProjects,
		userPreferences:                    userPreferences,
		AutoImports:                        autoImports,
		autoImportsWatch:                   autoImportsWatch,
	}
	s.refCount.Store(1)
	s.converters = lsconv.NewConverters(s.sessionOptions.PositionEncoding, s.LSPLineMap)
	return s
}

func (s *Snapshot) GetDefaultProject(uri lsproto.DocumentUri) *Project {
	return s.ProjectCollection.GetDefaultProject(uri.Path(s.UseCaseSensitiveFileNames()))
}

func (s *Snapshot) GetProjectsContainingFile(uri lsproto.DocumentUri) []ls.Project {
	fileName := uri.FileName()
	path := s.toPath(fileName)
	// TODO!! sheetal may be change this to handle symlinks!!
	return s.ProjectCollection.GetProjectsContainingFile(path)
}

func (s *Snapshot) GetFile(fileName string) FileHandle {
	return s.fs.GetFile(fileName)
}

func (s *Snapshot) LSPLineMap(fileName string) *lsconv.LSPLineMap {
	if file := s.fs.GetFile(fileName); file != nil {
		return file.LSPLineMap()
	}
	return nil
}

func (s *Snapshot) GetECMALineInfo(fileName string) *sourcemap.ECMALineInfo {
	if file := s.fs.GetFile(fileName); file != nil {
		return file.ECMALineInfo()
	}
	return nil
}

func (s *Snapshot) GetPreferences(activeFile string) lsutil.UserPreferences {
	return s.userPreferences
}

func (s *Snapshot) UserPreferences() lsutil.UserPreferences {
	return s.userPreferences
}

func (s *Snapshot) Converters() *lsconv.Converters {
	return s.converters
}

func (s *Snapshot) AutoImportRegistry() *autoimport.Registry {
	return s.AutoImports
}

func (s *Snapshot) ID() uint64 {
	return s.id
}

func (s *Snapshot) UseCaseSensitiveFileNames() bool {
	return s.fs.fs.UseCaseSensitiveFileNames()
}

func (s *Snapshot) ReadFile(fileName string) (string, bool) {
	handle := s.GetFile(fileName)
	if handle == nil {
		return "", false
	}
	return handle.Content(), true
}

func (s *Snapshot) DirectoryExists(path string) bool {
	return s.fs.fs.DirectoryExists(path)
}

func (s *Snapshot) FileExists(path string) bool {
	return s.fs.fs.FileExists(path)
}

func (s *Snapshot) GetDirectories(path string) []string {
	return s.fs.fs.GetAccessibleEntries(path).Directories
}

func (s *Snapshot) ReadDirectory(currentDir string, path string, extensions []string, excludes []string, includes []string, depth int) []string {
	return vfsmatch.ReadDirectory(s.fs.fs, currentDir, path, extensions, excludes, includes, depth)
}

type APISnapshotRequest struct {
	OpenProjects  *collections.Set[string]
	CloseProjects *collections.Set[tspath.Path]
	OpenFiles     *collections.Set[lsproto.DocumentUri]
	CloseFiles    *collections.Set[tspath.Path]
}

type ProjectTreeRequest struct {
	// If null, all project trees need to be loaded, otherwise only those that are referenced
	referencedProjects *collections.Set[tspath.Path]
}

func (p *ProjectTreeRequest) IsAllProjects() bool {
	return p.referencedProjects == nil
}

func (p *ProjectTreeRequest) IsProjectReferenced(projectID tspath.Path) bool {
	return p.referencedProjects.Has(projectID)
}

func (p *ProjectTreeRequest) Projects() []tspath.Path {
	if p.referencedProjects == nil {
		return nil
	}
	return slices.Collect(maps.Keys(p.referencedProjects.Keys()))
}

type ResourceRequest struct {
	// Documents are URIs that were requested by the client.
	// The new snapshot should ensure projects for these URIs have loaded programs.
	Documents []lsproto.DocumentUri
	// ConfiguredProjectDocuments are URIs for which configured projects should be loaded
	// (if disableSolutionSearching/disableReferencedProjectLoad settings allow),
	// but no inferred project should be created if no configured project is found.
	// This is used by cross-project operations like find-all-references.
	ConfiguredProjectDocuments []lsproto.DocumentUri
	// Update requested Projects.
	// this is used when we want to get LS and from all the Projects the file can be part of
	Projects []tspath.Path
	// Update and ensure project trees that reference the projects
	// This is used to compute the solution and project tree so that
	// we can find references across all the projects in the solution irrespective of which project is open
	ProjectTree *ProjectTreeRequest
	// AutoImports is the document URI for which auto imports should be prepared.
	AutoImports lsproto.DocumentUri
}

type SnapshotChange struct {
	ResourceRequest
	reason UpdateReason
	// fileChanges are the changes that have occurred since the last snapshot.
	fileChanges FileChangeSummary
	// compilerOptionsForInferredProjects is the compiler options to use for inferred projects.
	// It should only be set the value in the next snapshot should be changed. If nil, the
	// value from the previous snapshot will be copied to the new snapshot.
	compilerOptionsForInferredProjects *core.CompilerOptions
	newConfig                          *lsutil.UserPreferences
	// ataChanges contains ATA-related changes to apply to projects in the new snapshot.
	ataChanges map[tspath.Path]*ATAStateChange
	apiRequest *APISnapshotRequest
	// cleanDiskCache triggers cleaning of cached disk files not referenced by any open project.
	cleanDiskCache bool
}

// ATAStateChange represents a change to a project's ATA state.
type ATAStateChange struct {
	ProjectID tspath.Path
	// TypingsInfo is the new typings info for the project.
	TypingsInfo *ata.TypingsInfo
	// TypingsFiles is the new list of typing files for the project.
	TypingsFiles []string
	// TypingsFilesToWatch is the new list of typing files to watch for changes.
	TypingsFilesToWatch []string
	Logs                *logging.LogTree
}

func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays map[tspath.Path]*Overlay, session *Session) *Snapshot {
	var logger *logging.LogTree

	// Print in-progress logs immediately if cloning fails
	if session.options.LoggingEnabled {
		defer func() {
			if r := recover(); r != nil {
				session.logger.Log(logger.String())
				panic(r)
			}
		}()
	}

	if session.options.LoggingEnabled {
		logger = logging.NewLogTree(fmt.Sprintf("Cloning snapshot %d", s.id))
		getDetails := func() string {
			details := ""
			if len(change.Documents) != 0 {
				details += fmt.Sprintf(" Documents: %v", change.Documents)
			}
			if len(change.ConfiguredProjectDocuments) != 0 {
				details += fmt.Sprintf(" ConfiguredProjectDocuments: %v", change.ConfiguredProjectDocuments)
			}
			if len(change.Projects) != 0 {
				details += fmt.Sprintf(" Projects: %v", change.Projects)
			}
			if change.ProjectTree != nil {
				details += fmt.Sprintf(" ProjectTree: %v", change.ProjectTree.Projects())
			}
			return details
		}
		switch change.reason {
		case UpdateReasonDidOpenFile:
			logger.Logf("Reason: DidOpenFile - %s", change.fileChanges.Opened)
		case UpdateReasonDidCloseFile:
			logger.Logf("Reason: DidCloseFile - %v", change.fileChanges.Closed)
		case UpdateReasonDidChangeCompilerOptionsForInferredProjects:
			logger.Logf("Reason: DidChangeCompilerOptionsForInferredProjects")
		case UpdateReasonRequestedLanguageServicePendingChanges:
			logger.Logf("Reason: RequestedLanguageService (pending file changes) - %v", getDetails())
		case UpdateReasonRequestedLanguageServiceProjectNotLoaded:
			logger.Logf("Reason: RequestedLanguageService (project not loaded) - %v", getDetails())
		case UpdateReasonRequestedLanguageServiceForFileNotOpen:
			logger.Logf("Reason: RequestedLanguageService (file not open) - %v", getDetails())
		case UpdateReasonRequestedLanguageServiceProjectDirty:
			logger.Logf("Reason: RequestedLanguageService (project dirty) - %v", getDetails())
		case UpdateReasonRequestedLoadProjectTree:
			logger.Logf("Reason: RequestedLoadProjectTree - %v", getDetails())
		case UpdateReasonIdleCleanDiskCache:
			logger.Logf("Reason: IdleCleanDiskCache")
		}
	}

	start := time.Now()
	fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath)
	if change.fileChanges.HasExcessiveWatchEvents() {
		invalidateStart := time.Now()
		if change.fileChanges.InvalidateAll {
			fs.invalidateCache()
			logger.Logf("InvalidateAll: invalidated file cache in %v", time.Since(invalidateStart))
		} else if !fs.watchChangesOverlapCache(change.fileChanges) {
			// All watch changes/deletes are files we haven't seen; should be irrelevant to us (probably an external tool's build or something)
			change.fileChanges.Changed = collections.Set[lsproto.DocumentUri]{}
			change.fileChanges.Deleted = collections.Set[lsproto.DocumentUri]{}
		} else if change.fileChanges.IncludesWatchChangeOutsideNodeModules {
			fs.invalidateCache()
			logger.Logf("Excessive watch changes detected, invalidated file cache in %v", time.Since(invalidateStart))
		} else {
			fs.invalidateNodeModulesCache()
			logger.Logf("npm install detected, invalidated node_modules cache in %v", time.Since(invalidateStart))
		}
	} else {
		change.fileChanges = fs.expandAndFilterWatchEvents(change.fileChanges)
		change.fileChanges = s.fs.expandRealpathAliases(change.fileChanges)
		fs.markDirtyFiles(change.fileChanges)
		change.fileChanges = fs.convertOpenAndCloseToChanges(change.fileChanges)
	}

	compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects
	if change.compilerOptionsForInferredProjects != nil {
		// !!! mark inferred projects as dirty?
		compilerOptionsForInferredProjects = change.compilerOptionsForInferredProjects
	}

	// Compute effective customConfigFileName from user preferences
	customConfigFileName := s.ConfigFileRegistry.customConfigFileName
	if change.newConfig != nil {
		customConfigFileName = change.newConfig.CustomConfigFileName
	}

	newSnapshotID := session.snapshotID.Add(1)
	projectCollectionBuilder := newProjectCollectionBuilder(
		ctx,
		newSnapshotID,
		fs,
		s.ProjectCollection,
		s.ConfigFileRegistry,
		s.ProjectCollection.apiState,
		compilerOptionsForInferredProjects,
		s.sessionOptions,
		customConfigFileName,
		session.parseCache,
		session.extendedConfigCache,
		session.client,
	)

	if len(change.ataChanges) != 0 {
		projectCollectionBuilder.DidUpdateATAState(change.ataChanges, logger.Fork("DidUpdateATAState"))
	}

	projectCollectionBuilder.DidChangeCustomConfigFileName(logger.Fork("DidChangeCustomConfigFileName"))

	if !change.fileChanges.IsEmpty() {
		projectCollectionBuilder.DidChangeFiles(change.fileChanges, logger.Fork("DidChangeFiles"))
	}

	var apiError error
	if change.apiRequest != nil {
		apiError = projectCollectionBuilder.HandleAPIRequest(change.apiRequest, logger.Fork("HandleAPIRequest"))
	}

	for _, uri := range change.Documents {
		projectCollectionBuilder.DidRequestFile(uri, false /*configuredProjectsOnly*/, logger.Fork("DidRequestFile"))
	}

	for _, uri := range change.ConfiguredProjectDocuments {
		projectCollectionBuilder.DidRequestFile(uri, true /*configuredProjectsOnly*/, logger.Fork("DidRequestFile (optional)"))
	}

	for _, projectId := range change.Projects {
		projectCollectionBuilder.DidRequestProject(projectId, logger.Fork("DidRequestProject"))
	}

	if change.ProjectTree != nil {
		projectCollectionBuilder.DidRequestProjectTrees(change.ProjectTree, logger.Fork("DidRequestProjectTrees"))
	}

	projectCollection, configFileRegistry := projectCollectionBuilder.Finalize(logger)

	projectsWithNewProgramStructure := make(map[tspath.Path]bool)
	for _, project := range projectCollection.Projects() {
		if project.ProgramLastUpdate == newSnapshotID && project.ProgramUpdateKind != ProgramUpdateKindCloned {
			projectsWithNewProgramStructure[project.configFilePath] = project.ProgramUpdateKind == ProgramUpdateKindNewFiles
		}
	}

	// Clean cached disk files not touched by any open project on file open, close, delete,
	// or when explicitly requested (e.g. by an idle timer).
	shouldCleanDiskCache := change.cleanDiskCache ||
		change.fileChanges.Opened != "" ||
		change.fileChanges.Reopened != "" ||
		change.fileChanges.Closed.Len() > 0 ||
		change.fileChanges.Deleted.Len() > 0
	if shouldCleanDiskCache {
		// The set of seen files can change only if a program was constructed (not cloned) during this snapshot.
		// When cleanDiskCache is explicitly set, always attempt cleaning.
		if len(projectsWithNewProgramStructure) > 0 || change.cleanDiskCache {
			cleanFilesStart := time.Now()
			removedFiles := 0
			fs.diskFiles.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *diskFile]) bool {
				for _, project := range projectCollection.Projects() {
					if project.host != nil && project.host.sourceFS.SeenFile(entry.Key()) {
						return true
					}
				}
				entry.Delete()
				removedFiles++
				return true
			})
			if session.options.LoggingEnabled {
				logger.Logf("Removed %d cached file(s) in %v", removedFiles, time.Since(cleanFilesStart))
			}
		}
	}

	config := s.userPreferences
	if change.newConfig != nil {
		config = *change.newConfig
	}

	autoImportHost := newAutoImportRegistryCloneHost(
		projectCollection,
		session.parseCache,
		fs,
		s.sessionOptions.CurrentDirectory,
		s.toPath,
	)
	openFiles := make(map[tspath.Path]string, len(overlays))
	for path, overlay := range overlays {
		openFiles[path] = overlay.FileName()
	}
	prepareAutoImports := tspath.Path("")
	if change.ResourceRequest.AutoImports != "" {
		prepareAutoImports = change.ResourceRequest.AutoImports.Path(s.UseCaseSensitiveFileNames())
	}
	oldAutoImports := s.AutoImports
	if oldAutoImports == nil {
		oldAutoImports = autoimport.NewRegistry(s.toPath, s.userPreferences)
	}
	var autoImportsWatch *WatchedFiles[map[tspath.Path]string]
	autoImports, err := oldAutoImports.Clone(ctx, autoimport.RegistryChange{
		RequestedFile:   prepareAutoImports,
		OpenFiles:       openFiles,
		Changed:         change.fileChanges.Changed,
		Created:         change.fileChanges.Created,
		Deleted:         change.fileChanges.Deleted,
		RebuiltPrograms: projectsWithNewProgramStructure,
		UserPreferences: change.newConfig,
	}, autoImportHost, logger.Fork("UpdateAutoImports"))
	if err == nil {
		autoImportsWatch = s.autoImportsWatch.Clone(autoImports.NodeModulesDirectories())
	}

	snapshotFS, _ := fs.Finalize()
	newSnapshot := NewSnapshot(
		newSnapshotID,
		snapshotFS,
		s.sessionOptions,
		nil,
		compilerOptionsForInferredProjects,
		config,
		autoImports,
		autoImportsWatch,
		s.toPath,
	)
	newSnapshot.parentId = s.id
	newSnapshot.ProjectCollection = projectCollection
	newSnapshot.ConfigFileRegistry = configFileRegistry
	newSnapshot.builderLogs = logger
	newSnapshot.apiError = apiError

	for _, project := range newSnapshot.ProjectCollection.Projects() {
		if project.Program != nil {
			session.programCounter.Ref(project.Program)
			if project.ProgramLastUpdate == newSnapshotID {
				// If the program was updated during this clone, the project and its host are new
				// and still retain references to the builder. Freezing clears the builder reference
				// so it's GC'd and to ensure the project can't access any data not already in the
				// snapshot during use. This is pretty kludgy, but it's an artifact of Program design:
				// Program has a single host, which is expected to implement a full vfs.FS, among
				// other things. That host is *mostly* only used during program *construction*, but a
				// few methods may get exercised during program *use*. So, our compiler host is allowed
				// to access caches and perform mutating effects (like acquire referenced project
				// config files) during snapshot building, and then we call `freeze` to ensure those
				// mutations don't happen afterwards. In the future, we might improve things by
				// separating what it takes to build a program from what it takes to use a program,
				// and only pass the former into NewProgram instead of retaining it indefinitely.
				project.host.freeze(snapshotFS, newSnapshot.ConfigFileRegistry)
			}
		}
	}
	for _, config := range newSnapshot.ConfigFileRegistry.configs {
		if config.commandLine != nil && config.commandLine.ConfigFile != nil {
			for _, file := range config.commandLine.ConfigFile.ExtendedSourceFiles {
				session.extendedConfigCache.AddOwner(newSnapshot.toPath(file), newSnapshot.id)
			}
		}
	}

	autoImportHost.Dispose()

	logger.Logf("Finished cloning snapshot %d into snapshot %d in %v", s.id, newSnapshot.id, time.Since(start))
	return newSnapshot
}

// ref increments the snapshot's reference count, preventing it from being
// disposed until a corresponding Deref is called. The snapshot must still
// be alive (refCount > 0) when ref is called. Only the project Session
// should call ref(), and it should be done while holding session.snapshotMu.
func (s *Snapshot) ref() {
	if s.refCount.Add(1) <= 1 {
		panic(fmt.Sprintf("snapshot %d: ref on disposed snapshot, parentId=%d", s.id, s.parentId))
	}
}

// tryRef attempts to increment the snapshot's reference count. If the
// snapshot is already disposed (refCount == 0), it returns false without
// modifying the count. On success the caller must eventually call Deref.
func (s *Snapshot) tryRef() bool {
	for {
		rc := s.refCount.Load()
		if rc <= 0 {
			return false
		}
		if s.refCount.CompareAndSwap(rc, rc+1) {
			return true
		}
	}
}

// Deref decrements the snapshot's reference count. When the count reaches
// zero, the snapshot is disposed and its resources are released.
func (s *Snapshot) Deref(session *Session) {
	rc := s.refCount.Add(-1)
	if rc < 0 {
		panic(fmt.Sprintf("snapshot %d: ref count below zero, parentId=%d", s.id, s.parentId))
	}
	if rc == 0 {
		s.dispose(session)
	}
}

func (s *Snapshot) dispose(session *Session) {
	for _, project := range s.ProjectCollection.Projects() {
		if project.Program != nil && session.programCounter.Deref(project.Program) {
			// This program is no longer referenced by any snapshot.
			// Mark its checker pool as discarded so its idle-cleanup timer stops
			// keeping the pool alive, allowing the pool and any idle checkers it
			// still references to be reclaimed when the pool is garbage-collected.
			if project.checkerPool != nil {
				project.checkerPool.Discard()
			}
			for _, file := range project.Program.SourceFiles() {
				session.parseCache.Deref(NewParseCacheKey(file.ParseOptions(), file.Hash, file.ScriptKind))
			}
			for _, file := range project.Program.DuplicateSourceFiles() {
				session.parseCache.Deref(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind))
			}
		}
	}
	for _, config := range s.ConfigFileRegistry.configs {
		if config.commandLine != nil {
			for _, file := range config.commandLine.ExtendedSourceFiles() {
				session.extendedConfigCache.Release(session.toPath(file), s.id)
			}
		}
	}
}
