package incremental

import (
	"context"
	"fmt"
	"slices"
	"strings"

	"github.com/microsoft/typescript-go/internal/ast"
	"github.com/microsoft/typescript-go/internal/collections"
	"github.com/microsoft/typescript-go/internal/compiler"
	"github.com/microsoft/typescript-go/internal/core"
	"github.com/microsoft/typescript-go/internal/diagnostics"
	"github.com/microsoft/typescript-go/internal/json"
	"github.com/microsoft/typescript-go/internal/outputpaths"
	"github.com/microsoft/typescript-go/internal/packagejson"
	"github.com/microsoft/typescript-go/internal/tracing"
	"github.com/microsoft/typescript-go/internal/tspath"
)

type SignatureUpdateKind byte

const (
	SignatureUpdateKindComputedDts SignatureUpdateKind = iota
	SignatureUpdateKindStoredAtEmit
	SignatureUpdateKindUsedVersion
)

type Program struct {
	snapshot *snapshot
	program  *compiler.Program
	host     Host

	// Testing data
	testingData *TestingData
}

var _ compiler.ProgramLike = (*Program)(nil)

func NewProgram(program *compiler.Program, oldProgram *Program, host Host, testing bool) *Program {
	incrementalProgram := &Program{
		snapshot: programToSnapshot(program, oldProgram, testing),
		program:  program,
		host:     host,
	}

	if testing {
		incrementalProgram.testingData = &TestingData{}
		incrementalProgram.testingData.SemanticDiagnosticsPerFile = &incrementalProgram.snapshot.semanticDiagnosticsPerFile
		if oldProgram != nil {
			incrementalProgram.testingData.OldProgramSemanticDiagnosticsPerFile = &oldProgram.snapshot.semanticDiagnosticsPerFile
		} else {
			incrementalProgram.testingData.OldProgramSemanticDiagnosticsPerFile = &collections.SyncMap[tspath.Path, *DiagnosticsOrBuildInfoDiagnosticsWithFileName]{}
		}
		incrementalProgram.testingData.UpdatedSignatureKinds = make(map[tspath.Path]SignatureUpdateKind)
	}
	return incrementalProgram
}

type TestingData struct {
	SemanticDiagnosticsPerFile           *collections.SyncMap[tspath.Path, *DiagnosticsOrBuildInfoDiagnosticsWithFileName]
	OldProgramSemanticDiagnosticsPerFile *collections.SyncMap[tspath.Path, *DiagnosticsOrBuildInfoDiagnosticsWithFileName]
	UpdatedSignatureKinds                map[tspath.Path]SignatureUpdateKind
}

func (p *Program) GetTestingData() *TestingData {
	return p.testingData
}

func (p *Program) panicIfNoProgram(method string) {
	if p.program == nil {
		panic(method + ": should not be called without program")
	}
}

func (p *Program) GetProgram() *compiler.Program {
	p.panicIfNoProgram("GetProgram")
	return p.program
}

func (p *Program) HasChangedDtsFile() bool {
	return p.snapshot.hasChangedDtsFile
}

// Options implements compiler.AnyProgram interface.
func (p *Program) Options() *core.CompilerOptions {
	return p.snapshot.options
}

// CommonSourceDirectory implements compiler.AnyProgram interface.
func (p *Program) CommonSourceDirectory() string {
	p.panicIfNoProgram("CommonSourceDirectory")
	return p.program.CommonSourceDirectory()
}

// Program implements compiler.AnyProgram interface.
func (p *Program) Program() *compiler.Program {
	p.panicIfNoProgram("Program")
	return p.program
}

// IsSourceFileDefaultLibrary implements compiler.AnyProgram interface.
func (p *Program) IsSourceFileDefaultLibrary(path tspath.Path) bool {
	p.panicIfNoProgram("IsSourceFileDefaultLibrary")
	return p.program.IsSourceFileDefaultLibrary(path)
}

// GetSourceFiles implements compiler.AnyProgram interface.
func (p *Program) GetSourceFiles() []*ast.SourceFile {
	p.panicIfNoProgram("GetSourceFiles")
	return p.program.GetSourceFiles()
}

// GetSourceFile implements compiler.AnyProgram interface.
func (p *Program) GetSourceFile(path string) *ast.SourceFile {
	p.panicIfNoProgram("GetSourceFile")
	return p.program.GetSourceFile(path)
}

// GetConfigFileParsingDiagnostics implements compiler.AnyProgram interface.
func (p *Program) GetConfigFileParsingDiagnostics() []*ast.Diagnostic {
	p.panicIfNoProgram("GetConfigFileParsingDiagnostics")
	return p.program.GetConfigFileParsingDiagnostics()
}

// GetSyntacticDiagnostics implements compiler.AnyProgram interface.
func (p *Program) GetSyntacticDiagnostics(ctx context.Context, file *ast.SourceFile) []*ast.Diagnostic {
	p.panicIfNoProgram("GetSyntacticDiagnostics")
	return p.program.GetSyntacticDiagnostics(ctx, file)
}

// GetBindDiagnostics implements compiler.AnyProgram interface.
func (p *Program) GetBindDiagnostics(ctx context.Context, file *ast.SourceFile) []*ast.Diagnostic {
	p.panicIfNoProgram("GetBindDiagnostics")
	return p.program.GetBindDiagnostics(ctx, file)
}

func (p *Program) GetProgramDiagnostics() []*ast.Diagnostic {
	p.panicIfNoProgram("GetProgramDiagnostics")
	return p.program.GetProgramDiagnostics()
}

func (p *Program) GetGlobalDiagnostics(ctx context.Context) []*ast.Diagnostic {
	p.panicIfNoProgram("GetGlobalDiagnostics")
	return p.program.GetGlobalDiagnostics(ctx)
}

// GetSemanticDiagnostics implements compiler.AnyProgram interface.
func (p *Program) GetSemanticDiagnostics(ctx context.Context, file *ast.SourceFile) []*ast.Diagnostic {
	p.panicIfNoProgram("GetSemanticDiagnostics")
	if p.snapshot.options.NoCheck.IsTrue() {
		return nil
	}

	// Ensure all the diagnsotics are cached
	p.collectSemanticDiagnosticsOfAffectedFiles(ctx, file)
	if ctx.Err() != nil {
		return nil
	}

	// Return result from cache
	if file != nil {
		return p.getSemanticDiagnosticsOfFile(file)
	}

	var diagnostics []*ast.Diagnostic
	for _, file := range p.program.GetSourceFiles() {
		diagnostics = append(diagnostics, p.getSemanticDiagnosticsOfFile(file)...)
	}
	return diagnostics
}

func (p *Program) getSemanticDiagnosticsOfFile(file *ast.SourceFile) []*ast.Diagnostic {
	cachedDiagnostics, ok := p.snapshot.semanticDiagnosticsPerFile.Load(file.Path())
	if !ok {
		panic("After handling all the affected files, there shouldnt be more changes")
	}
	return slices.Concat(
		compiler.FilterNoEmitSemanticDiagnostics(cachedDiagnostics.getDiagnostics(p.program, file), p.snapshot.options),
		p.program.GetIncludeProcessorDiagnostics(file),
	)
}

// GetDeclarationDiagnostics implements compiler.AnyProgram interface.
func (p *Program) GetDeclarationDiagnostics(ctx context.Context, file *ast.SourceFile) []*ast.Diagnostic {
	p.panicIfNoProgram("GetDeclarationDiagnostics")
	result := emitFiles(ctx, p, compiler.EmitOptions{
		TargetSourceFile: file,
	}, true)
	if result != nil {
		return result.Diagnostics
	}
	return nil
}

// GetSuggestionDiagnostics implements compiler.AnyProgram interface.
func (p *Program) GetSuggestionDiagnostics(ctx context.Context, file *ast.SourceFile) []*ast.Diagnostic {
	p.panicIfNoProgram("GetSuggestionDiagnostics")
	return p.program.GetSuggestionDiagnostics(ctx, file) // TODO: incremental suggestion diagnostics (only relevant in editor incremental builder?)
}

// GetModeForUsageLocation implements compiler.AnyProgram interface.
func (p *Program) Emit(ctx context.Context, options compiler.EmitOptions) *compiler.EmitResult {
	p.panicIfNoProgram("Emit")

	var result *compiler.EmitResult
	if p.snapshot.options.NoEmit.IsTrue() {
		result = &compiler.EmitResult{EmitSkipped: true}
	} else {
		result = compiler.HandleNoEmitOnError(ctx, p, options.TargetSourceFile)
		if ctx.Err() != nil {
			return nil
		}
	}
	if result != nil {
		if options.TargetSourceFile != nil {
			return result
		}

		// Emit buildInfo and combine result
		buildInfoResult := p.emitBuildInfo(ctx, options)
		if buildInfoResult != nil {
			result.Diagnostics = append(result.Diagnostics, buildInfoResult.Diagnostics...)
			result.EmittedFiles = append(result.EmittedFiles, buildInfoResult.EmittedFiles...)
		}
		return result
	}
	return emitFiles(ctx, p, options, false)
}

// Handle affected files and cache the semantic diagnostics for all of them or the file asked for
func (p *Program) collectSemanticDiagnosticsOfAffectedFiles(ctx context.Context, file *ast.SourceFile) {
	if p.snapshot.canUseIncrementalState() {
		// Get all affected files
		collectAllAffectedFiles(ctx, p)
		if ctx.Err() != nil {
			return
		}

		if p.snapshot.semanticDiagnosticsPerFile.Size() == len(p.program.GetSourceFiles()) {
			// If we have all the files,
			return
		}
	}

	var affectedFiles []*ast.SourceFile
	if file != nil {
		_, ok := p.snapshot.semanticDiagnosticsPerFile.Load(file.Path())
		if ok {
			return
		}
		affectedFiles = []*ast.SourceFile{file}
	} else {
		for _, file := range p.program.GetSourceFiles() {
			if _, ok := p.snapshot.semanticDiagnosticsPerFile.Load(file.Path()); !ok {
				affectedFiles = append(affectedFiles, file)
			}
		}
	}

	// Get their diagnostics and cache them
	diagnosticsPerFile := p.program.GetSemanticDiagnosticsWithoutNoEmitFiltering(ctx, affectedFiles)
	// commit changes if no err
	if ctx.Err() != nil {
		return
	}

	// Commit changes to snapshot
	for file, diagnostics := range diagnosticsPerFile {
		p.snapshot.semanticDiagnosticsPerFile.Store(file.Path(), &DiagnosticsOrBuildInfoDiagnosticsWithFileName{diagnostics: diagnostics})
	}
	if p.snapshot.semanticDiagnosticsPerFile.Size() == len(p.program.GetSourceFiles()) && p.snapshot.checkPending && !p.snapshot.options.NoCheck.IsTrue() {
		p.snapshot.checkPending = false
	}
	p.snapshot.buildInfoEmitPending.Store(true)
}

func (p *Program) emitBuildInfo(ctx context.Context, options compiler.EmitOptions) *compiler.EmitResult {
	if tr := p.program.Tracing(); tr != nil {
		defer tr.Push(tracing.PhaseEmit, "emitBuildInfo", nil, true)()
	}
	buildInfoFileName := outputpaths.GetBuildInfoFileName(p.snapshot.options, tspath.ComparePathsOptions{
		CurrentDirectory:          p.program.GetCurrentDirectory(),
		UseCaseSensitiveFileNames: p.program.UseCaseSensitiveFileNames(),
	})
	if buildInfoFileName == "" || p.program.IsEmitBlocked(buildInfoFileName) {
		return nil
	}
	if p.snapshot.hasErrors == core.TSUnknown {
		p.ensureHasErrorsForState(ctx, p.program)
		if p.snapshot.hasErrors != p.snapshot.hasErrorsFromOldState || p.snapshot.hasSemanticErrors != p.snapshot.hasSemanticErrorsFromOldState {
			p.snapshot.buildInfoEmitPending.Store(true)
		}
	}
	if p.snapshot.packageJsons == nil {
		p.ensurePackageJsonsForState()
		if !slices.Equal(p.snapshot.packageJsons, p.snapshot.packageJsonsFromOldState) ||
			!slices.Equal(p.snapshot.missingPackageJsons, p.snapshot.missingPackageJsonsFromOldState) {
			p.snapshot.buildInfoEmitPending.Store(true)
		}
	}
	if !p.snapshot.buildInfoEmitPending.Load() {
		return nil
	}
	if ctx.Err() != nil {
		return nil
	}
	buildInfo := snapshotToBuildInfo(p.snapshot, p.program, buildInfoFileName)
	text, err := json.Marshal(buildInfo)
	if err != nil {
		panic(fmt.Sprintf("Failed to marshal build info: %v", err))
	}
	if options.WriteFile != nil {
		err = options.WriteFile(buildInfoFileName, string(text), &compiler.WriteFileData{
			BuildInfo: buildInfo,
		})
	} else {
		err = p.program.Host().FS().WriteFile(buildInfoFileName, string(text))
	}
	if err != nil {
		return &compiler.EmitResult{
			EmitSkipped: true,
			Diagnostics: []*ast.Diagnostic{
				ast.NewCompilerDiagnostic(diagnostics.Could_not_write_file_0_Colon_1, buildInfoFileName, err.Error()),
			},
		}
	}
	p.snapshot.buildInfoEmitPending.Store(false)
	return &compiler.EmitResult{
		EmitSkipped:  false,
		EmittedFiles: []string{buildInfoFileName},
	}
}

func (p *Program) ensureHasErrorsForState(ctx context.Context, program *compiler.Program) {
	var hasIncludeProcessingDiagnostics func() bool
	var hasEmitDiagnostics bool
	if p.snapshot.canUseIncrementalState() {
		if slices.ContainsFunc(program.GetSourceFiles(), func(file *ast.SourceFile) bool {
			if _, ok := p.snapshot.emitDiagnosticsPerFile.Load(file.Path()); ok {
				// emit diagnostics will be encoded in buildInfo;
				return true
			}
			if hasIncludeProcessingDiagnostics == nil && len(p.program.GetIncludeProcessorDiagnostics(file)) > 0 {
				hasIncludeProcessingDiagnostics = func() bool { return true }
			}
			return false
		}) {
			hasEmitDiagnostics = true
		}
		if hasIncludeProcessingDiagnostics == nil {
			hasIncludeProcessingDiagnostics = func() bool { return false }
		}
	} else {
		hasEmitDiagnostics = p.snapshot.hasEmitDiagnostics
		hasIncludeProcessingDiagnostics = func() bool {
			return slices.ContainsFunc(program.GetSourceFiles(), func(file *ast.SourceFile) bool {
				return len(p.program.GetIncludeProcessorDiagnostics(file)) > 0
			})
		}
	}

	if hasEmitDiagnostics {
		// Record this for only non incremental build info
		p.snapshot.hasErrors = core.IfElse(p.snapshot.options.IsIncremental(), core.TSFalse, core.TSTrue)
		// Dont need to encode semantic errors state since the emit diagnostics are encoded
		p.snapshot.hasSemanticErrors = false
		return
	}

	if hasIncludeProcessingDiagnostics() ||
		len(program.GetConfigFileParsingDiagnostics()) > 0 ||
		len(program.GetSyntacticDiagnostics(ctx, nil)) > 0 ||
		len(program.GetProgramDiagnostics()) > 0 ||
		len(program.GetGlobalDiagnostics(ctx)) > 0 {
		p.snapshot.hasErrors = core.TSTrue
		// Dont need to encode semantic errors state since the syntax and program diagnostics are encoded as present
		p.snapshot.hasSemanticErrors = false
		return
	}

	p.snapshot.hasErrors = core.TSFalse
	// Check semantic and emit diagnostics first as we dont need to ask program about it
	if slices.ContainsFunc(p.program.GetSourceFiles(), func(file *ast.SourceFile) bool {
		semanticDiagnostics, ok := p.snapshot.semanticDiagnosticsPerFile.Load(file.Path())
		if !ok {
			// Missing semantic diagnostics in cache will be encoded in incremental buildInfo
			return p.snapshot.options.IsIncremental()
		}
		if len(semanticDiagnostics.diagnostics) > 0 || len(semanticDiagnostics.buildInfoDiagnostics) > 0 {
			// cached semantic diagnostics will be encoded in buildInfo
			return true
		}
		return false
	}) {
		// Because semantic diagnostics are recorded in buildInfo, we dont need to encode hasErrors in incremental buildInfo
		// But encode as errors in non incremental buildInfo
		p.snapshot.hasSemanticErrors = !p.snapshot.options.IsIncremental()
	}
}

func (p *Program) ensurePackageJsonsForState() {
	config := tspath.GetDirectoryPath(p.program.CommandLine().ConfigName())
	if config != "" {
		p.program.PackageJsonCacheEntries(func(key tspath.Path, value *packagejson.InfoCacheEntry) bool {
			if value == nil {
				return true
			}
			packageJson := tspath.CombinePaths(value.PackageDirectory, "package.json")
			if value.Exists() || value.DirectoryExists {
				packageJson = p.program.Host().FS().Realpath(packageJson)
			}
			if value.Exists() {
				p.snapshot.packageJsons = append(p.snapshot.packageJsons, packageJson)
			} else if strings.Contains(packageJson, "/node_modules/") {
				p.snapshot.missingPackageJsons = append(p.snapshot.missingPackageJsons, packageJson)
			}
			return true
		})
	}
	p.snapshot.packageJsons = normalizePackageJsons(p.snapshot.packageJsons)
	p.snapshot.missingPackageJsons = normalizePackageJsons(p.snapshot.missingPackageJsons)
}

func normalizePackageJsons(packageJsons []string) []string {
	if packageJsons == nil {
		return make([]string, 0)
	}
	slices.Sort(packageJsons)
	return core.Deduplicate(packageJsons)
}

func (p *Program) PackageJsonLookupPaths() []string {
	config := tspath.GetDirectoryPath(p.program.CommandLine().ConfigName())
	if config == "" {
		return nil
	}

	var packageJsons []string
	p.program.PackageJsonCacheEntries(func(key tspath.Path, value *packagejson.InfoCacheEntry) bool {
		if value == nil {
			return true
		}
		packageJson := tspath.CombinePaths(value.PackageDirectory, "package.json")
		if value.Exists() || value.DirectoryExists {
			packageJson = p.program.Host().FS().Realpath(packageJson)
		}
		packageJsons = append(packageJsons, packageJson)
		return true
	})
	slices.Sort(packageJsons)
	return core.Deduplicate(packageJsons)
}
