package incremental

import (
	"context"

	"github.com/microsoft/typescript-go/internal/ast"
	"github.com/microsoft/typescript-go/internal/binder"
	"github.com/microsoft/typescript-go/internal/checker"
	"github.com/microsoft/typescript-go/internal/collections"
	"github.com/microsoft/typescript-go/internal/compiler"
	"github.com/microsoft/typescript-go/internal/core"
	"github.com/microsoft/typescript-go/internal/tsoptions"
	"github.com/microsoft/typescript-go/internal/tspath"
)

func programToSnapshot(program *compiler.Program, oldProgram *Program, hashWithText bool) *snapshot {
	if oldProgram != nil && oldProgram.program == program {
		return oldProgram.snapshot
	}
	snapshot := &snapshot{
		options:      program.Options(),
		hashWithText: hashWithText,
		checkPending: program.Options().NoCheck.IsTrue(),
	}
	to := &toProgramSnapshot{
		program:    program,
		oldProgram: oldProgram,
		snapshot:   snapshot,
	}

	if to.snapshot.canUseIncrementalState() {
		to.reuseFromOldProgram()
		to.computeProgramFileChanges()
		to.handleFileDelete()
		to.handlePendingEmit()
		to.handlePendingCheck()
	}
	return snapshot
}

type toProgramSnapshot struct {
	program           *compiler.Program
	oldProgram        *Program
	snapshot          *snapshot
	globalFileRemoved bool
}

func (t *toProgramSnapshot) reuseFromOldProgram() {
	if t.oldProgram != nil {
		if t.snapshot.options.Composite.IsTrue() {
			t.snapshot.latestChangedDtsFile = t.oldProgram.snapshot.latestChangedDtsFile
		}
		// Copy old snapshot's changed files set
		t.oldProgram.snapshot.changedFilesSet.Range(func(key tspath.Path) bool {
			t.snapshot.changedFilesSet.Add(key)
			return true
		})
		t.oldProgram.snapshot.affectedFilesPendingEmit.Range(func(key tspath.Path, emitKind FileEmitKind) bool {
			t.snapshot.affectedFilesPendingEmit.Store(key, emitKind)
			return true
		})
		t.snapshot.buildInfoEmitPending.Store(t.oldProgram.snapshot.buildInfoEmitPending.Load())
		t.snapshot.hasErrorsFromOldState = t.oldProgram.snapshot.hasErrors
		t.snapshot.hasSemanticErrorsFromOldState = t.oldProgram.snapshot.hasSemanticErrors
		t.snapshot.packageJsonsFromOldState = t.oldProgram.snapshot.packageJsons
		t.snapshot.missingPackageJsonsFromOldState = t.oldProgram.snapshot.missingPackageJsons
	} else {
		t.snapshot.buildInfoEmitPending.Store(t.snapshot.options.IsIncremental())
	}
}

func (t *toProgramSnapshot) computeProgramFileChanges() {
	canCopySemanticDiagnostics := t.oldProgram != nil &&
		!tsoptions.CompilerOptionsAffectSemanticDiagnostics(t.oldProgram.snapshot.options, t.program.Options())
	// We can only reuse emit signatures (i.e. .d.ts signatures) if the .d.ts file is unchanged,
	// which will eg be depedent on change in options like declarationDir and outDir options are unchanged.
	// We need to look in oldState.compilerOptions, rather than oldCompilerOptions (i.e.we need to disregard useOldState) because
	// oldCompilerOptions can be undefined if there was change in say module from None to some other option
	// which would make useOldState as false since we can now use reference maps that are needed to track what to emit, what to check etc
	// but that option change does not affect d.ts file name so emitSignatures should still be reused.
	canCopyEmitSignatures := t.snapshot.options.Composite.IsTrue() &&
		t.oldProgram != nil &&
		!tsoptions.CompilerOptionsAffectDeclarationPath(t.oldProgram.snapshot.options, t.program.Options())
	copyDeclarationFileDiagnostics := canCopySemanticDiagnostics &&
		t.snapshot.options.SkipLibCheck.IsTrue() == t.oldProgram.snapshot.options.SkipLibCheck.IsTrue()
	copyLibFileDiagnostics := copyDeclarationFileDiagnostics &&
		t.snapshot.options.SkipDefaultLibCheck.IsTrue() == t.oldProgram.snapshot.options.SkipDefaultLibCheck.IsTrue()

	files := t.program.GetSourceFiles()
	wg := core.NewWorkGroup(t.program.SingleThreaded())
	for _, file := range files {
		wg.Queue(func() {
			version := t.snapshot.computeHash(file.Text())
			impliedNodeFormat := t.program.GetSourceFileMetaData(file.Path()).ImpliedNodeFormat
			affectsGlobalScope := fileAffectsGlobalScope(file)
			var signature string
			newReferences := getReferencedFiles(t.program, file)
			if newReferences != nil {
				t.snapshot.referencedMap.storeReferences(file.Path(), newReferences)
			}
			if t.oldProgram != nil {
				if oldFileInfo, ok := t.oldProgram.snapshot.fileInfos.Load(file.Path()); ok {
					signature = oldFileInfo.signature
					if oldFileInfo.version != version || oldFileInfo.affectsGlobalScope != affectsGlobalScope || oldFileInfo.impliedNodeFormat != impliedNodeFormat {
						t.snapshot.addFileToChangeSet(file.Path())
					} else if oldReferences, _ := t.oldProgram.snapshot.referencedMap.getReferences(file.Path()); !newReferences.Equals(oldReferences) {
						// Referenced files changed
						t.snapshot.addFileToChangeSet(file.Path())
					} else if newReferences != nil {
						for refPath := range newReferences.Keys() {
							if t.program.GetSourceFileByPath(refPath) == nil {
								if _, ok := t.oldProgram.snapshot.fileInfos.Load(refPath); ok {
									// Referenced file was deleted in the new program
									t.snapshot.addFileToChangeSet(file.Path())
									break
								}
							}
						}
					}
				} else {
					t.snapshot.addFileToChangeSet(file.Path())
				}
				if !t.snapshot.changedFilesSet.Has(file.Path()) {
					if emitDiagnostics, ok := t.oldProgram.snapshot.emitDiagnosticsPerFile.Load(file.Path()); ok {
						t.snapshot.emitDiagnosticsPerFile.Store(file.Path(), repopulateDiagnosticsOfFile(emitDiagnostics, t.program, file))
					}
					if canCopySemanticDiagnostics {
						if (!file.IsDeclarationFile || copyDeclarationFileDiagnostics) &&
							(!t.program.IsSourceFileDefaultLibrary(file.Path()) || copyLibFileDiagnostics) {
							// Unchanged file copy diagnostics
							if diagnostics, ok := t.oldProgram.snapshot.semanticDiagnosticsPerFile.Load(file.Path()); ok {
								t.snapshot.semanticDiagnosticsPerFile.Store(file.Path(), repopulateDiagnosticsOfFile(diagnostics, t.program, file))
							}
						}
					}
				}
				if canCopyEmitSignatures {
					if oldEmitSignature, ok := t.oldProgram.snapshot.emitSignatures.Load(file.Path()); ok {
						t.snapshot.emitSignatures.Store(file.Path(), oldEmitSignature.getNewEmitSignature(t.oldProgram.snapshot.options, t.snapshot.options))
					}
				}
			} else {
				t.snapshot.addFileToAffectedFilesPendingEmit(file.Path(), GetFileEmitKind(t.snapshot.options))
				signature = version
			}
			t.snapshot.fileInfos.Store(file.Path(), &FileInfo{
				version:            version,
				signature:          signature,
				affectsGlobalScope: affectsGlobalScope,
				impliedNodeFormat:  impliedNodeFormat,
			})
		})
	}
	wg.RunAndWait()
}

func (t *toProgramSnapshot) handleFileDelete() {
	if t.oldProgram != nil {
		// If the global file is removed, add all files as changed
		t.oldProgram.snapshot.fileInfos.Range(func(filePath tspath.Path, oldInfo *FileInfo) bool {
			if _, ok := t.snapshot.fileInfos.Load(filePath); !ok {
				if oldInfo.affectsGlobalScope {
					for _, file := range t.snapshot.getAllFilesExcludingDefaultLibraryFile(t.program, nil) {
						t.snapshot.addFileToChangeSet(file.Path())
					}
					t.globalFileRemoved = true
				} else {
					t.snapshot.buildInfoEmitPending.Store(true)
				}
				return false
			}
			return true
		})
	}
}

func (t *toProgramSnapshot) handlePendingEmit() {
	if t.oldProgram != nil && !t.globalFileRemoved {
		// If options affect emit, then we need to do complete emit per compiler options
		// otherwise only the js or dts that needs to emitted because its different from previously emitted options
		var pendingEmitKind FileEmitKind
		if tsoptions.CompilerOptionsAffectEmit(t.oldProgram.snapshot.options, t.snapshot.options) {
			pendingEmitKind = GetFileEmitKind(t.snapshot.options)
		} else {
			pendingEmitKind = getPendingEmitKindWithOptions(t.snapshot.options, t.oldProgram.snapshot.options)
		}
		if pendingEmitKind != FileEmitKindNone {
			// Add all files to affectedFilesPendingEmit since emit changed
			for _, file := range t.program.GetSourceFiles() {
				// Add to affectedFilesPending emit only if not changed since any changed file will do full emit
				if !t.snapshot.changedFilesSet.Has(file.Path()) {
					t.snapshot.addFileToAffectedFilesPendingEmit(file.Path(), pendingEmitKind)
				}
			}
			t.snapshot.buildInfoEmitPending.Store(true)
		}
	}
}

func (t *toProgramSnapshot) handlePendingCheck() {
	if t.oldProgram != nil &&
		t.snapshot.semanticDiagnosticsPerFile.Size() != len(t.program.GetSourceFiles()) &&
		t.oldProgram.snapshot.checkPending != t.snapshot.checkPending {
		t.snapshot.buildInfoEmitPending.Store(true)
	}
}

func fileAffectsGlobalScope(file *ast.SourceFile) bool {
	binder.BindSourceFile(file)
	// if file contains anything that augments to global scope we need to build them as if
	// they are global files as well as module
	if core.Some(file.ModuleAugmentations, func(augmentation *ast.ModuleName) bool {
		return ast.IsGlobalScopeAugmentation(augmentation.Parent)
	}) {
		return true
	}

	if ast.IsExternalOrCommonJSModule(file) || ast.IsJsonSourceFile(file) {
		return false
	}

	// For script files that contains only ambient external modules, although they are not actually external module files,
	// they can only be consumed via importing elements from them. Regular script files cannot consume them. Therefore,
	// there are no point to rebuild all script files if these special files have changed. However, if any statement
	// in the file is not ambient external module, we treat it as a regular script file.
	return file.Statements != nil &&
		file.Statements.Nodes != nil &&
		core.Some(file.Statements.Nodes, func(stmt *ast.Node) bool {
			return !ast.IsModuleWithStringLiteralName(stmt)
		})
}

func addReferencedFilesFromSymbol(file *ast.SourceFile, referencedFiles *collections.Set[tspath.Path], symbol *ast.Symbol) {
	if symbol == nil {
		return
	}
	for _, declaration := range symbol.Declarations {
		fileOfDecl := ast.GetSourceFileOfNode(declaration)
		if fileOfDecl == nil {
			continue
		}
		if file != fileOfDecl {
			referencedFiles.Add(fileOfDecl.Path())
		}
	}
}

// Get the module source file and all augmenting files from the import name node from file
func addReferencedFilesFromImportLiteral(file *ast.SourceFile, referencedFiles *collections.Set[tspath.Path], checker *checker.Checker, importName *ast.LiteralLikeNode) {
	symbol := checker.GetSymbolAtLocation(importName)
	addReferencedFilesFromSymbol(file, referencedFiles, symbol)
}

// Gets the path to reference file from file name, it could be resolvedPath if present otherwise path
func addReferencedFileFromFileName(program *compiler.Program, fileName string, referencedFiles *collections.Set[tspath.Path], sourceFileDirectory string) {
	if redirect := program.GetParseFileRedirect(fileName); redirect != "" {
		referencedFiles.Add(tspath.ToPath(redirect, program.GetCurrentDirectory(), program.UseCaseSensitiveFileNames()))
	} else {
		referencedFiles.Add(tspath.ToPath(fileName, sourceFileDirectory, program.UseCaseSensitiveFileNames()))
	}
}

// Gets the referenced files for a file from the program with values for the keys as referenced file's path to be true
func getReferencedFiles(program *compiler.Program, file *ast.SourceFile) *collections.Set[tspath.Path] {
	referencedFiles := collections.Set[tspath.Path]{}

	// We need to use a set here since the code can contain the same import twice,
	// but that will only be one dependency.
	// To avoid invernal conversion, the key of the referencedFiles map must be of type Path
	checker, done := program.GetTypeCheckerForFileExclusive(context.TODO(), file)
	defer done()
	for _, importName := range file.Imports() {
		addReferencedFilesFromImportLiteral(file, &referencedFiles, checker, importName)
	}

	sourceFileDirectory := tspath.GetDirectoryPath(file.FileName())
	// Handle triple slash references
	for _, referencedFile := range file.ReferencedFiles {
		addReferencedFileFromFileName(program, referencedFile.FileName, &referencedFiles, sourceFileDirectory)
	}

	// Handle type reference directives
	if typeRefsInFile, ok := program.GetResolvedTypeReferenceDirectives()[file.Path()]; ok {
		for _, typeRef := range typeRefsInFile {
			if typeRef.ResolvedFileName != "" {
				addReferencedFileFromFileName(program, typeRef.ResolvedFileName, &referencedFiles, sourceFileDirectory)
			}
		}
	}

	// Add module augmentation as references
	for _, moduleName := range file.ModuleAugmentations {
		if !ast.IsStringLiteral(moduleName) {
			continue
		}
		addReferencedFilesFromImportLiteral(file, &referencedFiles, checker, moduleName)
	}

	// From ambient modules
	for _, ambientModule := range checker.GetAmbientModules() {
		addReferencedFilesFromSymbol(file, &referencedFiles, ambientModule)
	}
	return core.IfElse(referencedFiles.Len() > 0, &referencedFiles, nil)
}

// repopulateDiagnosticsOfFile repopulates diagnostic chains that depend on program state.
// When diagnostics are copied from a previous build, their message chains may reference
// stale program state (e.g., resolved module alternate results, package.json scope).
// This function recomputes those chains using the current program's state.
func repopulateDiagnosticsOfFile(diags *DiagnosticsOrBuildInfoDiagnosticsWithFileName, p *compiler.Program, file *ast.SourceFile) *DiagnosticsOrBuildInfoDiagnosticsWithFileName {
	if diags.diagnostics != nil {
		repopulated := repopulateDiagnosticsList(diags.diagnostics, p, file)
		if repopulated == nil {
			return diags
		}
		return &DiagnosticsOrBuildInfoDiagnosticsWithFileName{diagnostics: repopulated}
	}
	// buildInfoDiagnostics will be repopulated via toDiagnostic's repopulateInfo handling
	return diags
}

// repopulateDiagnosticsList repopulates diagnostic chains in a list of diagnostics.
// Returns nil if no diagnostics needed repopulation (i.e., no changes were made).
func repopulateDiagnosticsList(diags []*ast.Diagnostic, p *compiler.Program, file *ast.SourceFile) []*ast.Diagnostic {
	changed := false
	result := make([]*ast.Diagnostic, len(diags))
	for i, d := range diags {
		repopulated := repopulateDiagnosticMessageChain(d.MessageChain(), p, file)
		if repopulated != nil {
			clone := d.Clone()
			clone.SetMessageChain(repopulated)
			result[i] = clone
			changed = true
		} else {
			result[i] = d
		}
	}
	if !changed {
		return nil
	}
	return result
}

// repopulateDiagnosticMessageChain repopulates chains that have repopulate info.
// Returns nil if no changes were made.
func repopulateDiagnosticMessageChain(chain []*ast.Diagnostic, p *compiler.Program, file *ast.SourceFile) []*ast.Diagnostic {
	if len(chain) == 0 {
		return nil
	}
	changed := false
	result := make([]*ast.Diagnostic, len(chain))
	for i, c := range chain {
		if c.RepopulateInfo() != nil {
			// Convert to buildInfoDiagnosticWithFileName and repopulate
			b := &buildInfoDiagnosticWithFileName{
				pos:            c.Pos(),
				end:            c.End(),
				code:           c.Code(),
				category:       c.Category(),
				messageKey:     c.MessageKey(),
				messageArgs:    c.MessageArgs(),
				repopulateInfo: c.RepopulateInfo(),
			}
			// Recursively handle nested chains
			for _, nested := range c.MessageChain() {
				b.messageChain = append(b.messageChain, astDiagToBuildInfoDiag(nested))
			}
			result[i] = repopulateDiagnosticChain(b, p, file)
			changed = true
		} else {
			// Check nested chains
			nested := repopulateDiagnosticMessageChain(c.MessageChain(), p, file)
			if nested != nil {
				clone := c.Clone()
				clone.SetMessageChain(nested)
				result[i] = clone
				changed = true
			} else {
				result[i] = c
			}
		}
	}
	if !changed {
		return nil
	}
	return result
}

func astDiagToBuildInfoDiag(d *ast.Diagnostic) *buildInfoDiagnosticWithFileName {
	b := &buildInfoDiagnosticWithFileName{
		pos:            d.Pos(),
		end:            d.End(),
		code:           d.Code(),
		category:       d.Category(),
		messageKey:     d.MessageKey(),
		messageArgs:    d.MessageArgs(),
		repopulateInfo: d.RepopulateInfo(),
	}
	for _, nested := range d.MessageChain() {
		b.messageChain = append(b.messageChain, astDiagToBuildInfoDiag(nested))
	}
	return b
}
