package incremental

import (
	"encoding/hex"
	"fmt"
	"strings"
	"sync"
	"sync/atomic"

	"github.com/microsoft/typescript-go/internal/ast"
	"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/diagnostics"
	"github.com/microsoft/typescript-go/internal/tspath"
	"github.com/zeebo/xxh3"
)

type FileInfo struct {
	version            string
	signature          string
	affectsGlobalScope bool
	impliedNodeFormat  core.ResolutionMode
}

func (f *FileInfo) Version() string                        { return f.version }
func (f *FileInfo) Signature() string                      { return f.signature }
func (f *FileInfo) AffectsGlobalScope() bool               { return f.affectsGlobalScope }
func (f *FileInfo) ImpliedNodeFormat() core.ResolutionMode { return f.impliedNodeFormat }

func ComputeHash(text string, hashWithText bool) string {
	hashBytes := xxh3.HashString128(text).Bytes()
	hash := hex.EncodeToString(hashBytes[:])
	if hashWithText {
		hash += "-" + text
	}
	return hash
}

type FileEmitKind uint32

const (
	FileEmitKindNone        FileEmitKind = 0
	FileEmitKindJs          FileEmitKind = 1 << 0 // emit js file
	FileEmitKindJsMap       FileEmitKind = 1 << 1 // emit js.map file
	FileEmitKindJsInlineMap FileEmitKind = 1 << 2 // emit inline source map in js file
	FileEmitKindDtsErrors   FileEmitKind = 1 << 3 // emit dts errors
	FileEmitKindDtsEmit     FileEmitKind = 1 << 4 // emit d.ts file
	FileEmitKindDtsMap      FileEmitKind = 1 << 5 // emit d.ts.map file

	FileEmitKindDts        = FileEmitKindDtsErrors | FileEmitKindDtsEmit
	FileEmitKindAllJs      = FileEmitKindJs | FileEmitKindJsMap | FileEmitKindJsInlineMap
	FileEmitKindAllDtsEmit = FileEmitKindDtsEmit | FileEmitKindDtsMap
	FileEmitKindAllDts     = FileEmitKindDts | FileEmitKindDtsMap
	FileEmitKindAll        = FileEmitKindAllJs | FileEmitKindAllDts
)

func GetFileEmitKind(options *core.CompilerOptions) FileEmitKind {
	result := FileEmitKindJs
	if options.SourceMap.IsTrue() {
		result |= FileEmitKindJsMap
	}
	if options.InlineSourceMap.IsTrue() {
		result |= FileEmitKindJsInlineMap
	}
	if options.GetEmitDeclarations() {
		result |= FileEmitKindDts
	}
	if options.DeclarationMap.IsTrue() {
		result |= FileEmitKindDtsMap
	}
	if options.EmitDeclarationOnly.IsTrue() {
		result &= FileEmitKindAllDts
	}
	return result
}

func getPendingEmitKindWithOptions(options *core.CompilerOptions, oldOptions *core.CompilerOptions) FileEmitKind {
	oldEmitKind := GetFileEmitKind(oldOptions)
	newEmitKind := GetFileEmitKind(options)
	return getPendingEmitKind(newEmitKind, oldEmitKind)
}

func getPendingEmitKind(emitKind FileEmitKind, oldEmitKind FileEmitKind) FileEmitKind {
	if oldEmitKind == emitKind {
		return FileEmitKindNone
	}
	if oldEmitKind == 0 || emitKind == 0 {
		return emitKind
	}
	diff := oldEmitKind ^ emitKind
	result := FileEmitKindNone
	// If there is diff in Js emit, pending emit is js emit flags
	if (diff & FileEmitKindAllJs) != 0 {
		result |= emitKind & FileEmitKindAllJs
	}
	// If dts errors pending, add dts errors flag
	if (diff & FileEmitKindDtsErrors) != 0 {
		result |= emitKind & FileEmitKindAllDts
	}
	// If there is diff in Dts emit, pending emit is dts emit flags
	if (diff & FileEmitKindAllDtsEmit) != 0 {
		result |= emitKind & FileEmitKindAllDtsEmit
	}
	return result
}

// Signature (Hash of d.ts emitted), is string if it was emitted using same d.ts.map option as what compilerOptions indicate,
// otherwise tuple of string
type emitSignature struct {
	signature                     string
	signatureWithDifferentOptions []string
}

// Covert to Emit signature based on oldOptions and EmitSignature format
// If d.ts map options differ then swap the format, otherwise use as is
func (e *emitSignature) getNewEmitSignature(oldOptions *core.CompilerOptions, newOptions *core.CompilerOptions) *emitSignature {
	if oldOptions.DeclarationMap.IsTrue() == newOptions.DeclarationMap.IsTrue() {
		return e
	}
	if e.signatureWithDifferentOptions == nil {
		return &emitSignature{
			signatureWithDifferentOptions: []string{e.signature},
		}
	} else {
		return &emitSignature{
			signature: e.signatureWithDifferentOptions[0],
		}
	}
}

type buildInfoDiagnosticWithFileName struct {
	// filename if it is for a File thats other than its stored for
	file               tspath.Path
	noFile             bool
	pos                int
	end                int
	code               int32
	category           diagnostics.Category
	messageKey         diagnostics.Key
	messageArgs        []string
	messageChain       []*buildInfoDiagnosticWithFileName
	relatedInformation []*buildInfoDiagnosticWithFileName
	reportsUnnecessary bool
	reportsDeprecated  bool
	skippedOnNoEmit    bool
	repopulateInfo     *ast.RepopulateDiagnosticInfo
}

type DiagnosticsOrBuildInfoDiagnosticsWithFileName struct {
	diagnostics          []*ast.Diagnostic
	buildInfoDiagnostics []*buildInfoDiagnosticWithFileName
}

func (b *buildInfoDiagnosticWithFileName) toDiagnostic(p *compiler.Program, file *ast.SourceFile) *ast.Diagnostic {
	var fileForDiagnostic *ast.SourceFile
	if b.file != "" {
		fileForDiagnostic = p.GetSourceFileByPath(b.file)
	} else if !b.noFile {
		fileForDiagnostic = file
	}

	if b.repopulateInfo != nil {
		return repopulateDiagnosticChain(b, p, fileForDiagnostic)
	}

	var messageChain []*ast.Diagnostic
	for _, msg := range b.messageChain {
		messageChain = append(messageChain, msg.toDiagnostic(p, fileForDiagnostic))
	}
	var relatedInformation []*ast.Diagnostic
	for _, info := range b.relatedInformation {
		relatedInformation = append(relatedInformation, info.toDiagnostic(p, fileForDiagnostic))
	}
	return ast.NewDiagnosticFromSerialized(
		fileForDiagnostic,
		core.NewTextRange(b.pos, b.end),
		b.code,
		b.category,
		b.messageKey,
		b.messageArgs,
		messageChain,
		relatedInformation,
		b.reportsUnnecessary,
		b.reportsDeprecated,
		b.skippedOnNoEmit,
	)
}

// repopulateDiagnosticChain recomputes a diagnostic chain entry that depends on
// program state which may have changed between incremental builds.
func repopulateDiagnosticChain(b *buildInfoDiagnosticWithFileName, p *compiler.Program, file *ast.SourceFile) *ast.Diagnostic {
	info := b.repopulateInfo
	switch info.Kind {
	case ast.RepopulateModeMismatch:
		return repopulateModeMismatchChain(b, p, file)
	case ast.RepopulateModuleNotFound:
		return repopulateModuleNotFoundChain(b, p, file, info)
	default:
		// Fall back to using the stored (possibly stale) data
		return b.toDiagnosticWithoutRepopulate(p, file)
	}
}

func (b *buildInfoDiagnosticWithFileName) toDiagnosticWithoutRepopulate(p *compiler.Program, file *ast.SourceFile) *ast.Diagnostic {
	var messageChain []*ast.Diagnostic
	for _, msg := range b.messageChain {
		messageChain = append(messageChain, msg.toDiagnostic(p, file))
	}
	var relatedInformation []*ast.Diagnostic
	for _, info := range b.relatedInformation {
		relatedInformation = append(relatedInformation, info.toDiagnostic(p, file))
	}
	return ast.NewDiagnosticFromSerialized(
		file,
		core.NewTextRange(b.pos, b.end),
		b.code,
		b.category,
		b.messageKey,
		b.messageArgs,
		messageChain,
		relatedInformation,
		b.reportsUnnecessary,
		b.reportsDeprecated,
		b.skippedOnNoEmit,
	)
}

func repopulateModeMismatchChain(b *buildInfoDiagnosticWithFileName, p *compiler.Program, file *ast.SourceFile) *ast.Diagnostic {
	if file == nil {
		return b.toDiagnosticWithoutRepopulate(p, file)
	}

	details := checker.CreateModeMismatchDetails(p, file)

	var nextChain []*ast.Diagnostic
	for _, msg := range b.messageChain {
		nextChain = append(nextChain, msg.toDiagnostic(p, file))
	}

	return ast.NewDiagnosticFromSerialized(
		file,
		core.NewTextRange(b.pos, b.end),
		details.Message.Code(),
		details.Message.Category(),
		details.Message.Key(),
		diagnostics.StringifyArgs(details.Args),
		nextChain,
		nil,
		false,
		false,
		false,
	)
}

func repopulateModuleNotFoundChain(b *buildInfoDiagnosticWithFileName, p *compiler.Program, file *ast.SourceFile, info *ast.RepopulateDiagnosticInfo) *ast.Diagnostic {
	if file == nil {
		return b.toDiagnosticWithoutRepopulate(p, file)
	}

	packageName := info.PackageName
	if packageName == "" {
		packageName = info.ModuleReference
	}

	details := checker.CreateModuleNotFoundChain(p, file, info.ModuleReference, info.Mode, packageName)

	var nextChain []*ast.Diagnostic
	for _, msg := range b.messageChain {
		nextChain = append(nextChain, msg.toDiagnostic(p, file))
	}

	return ast.NewDiagnosticFromSerialized(
		file,
		core.NewTextRange(b.pos, b.end),
		details.Message.Code(),
		details.Message.Category(),
		details.Message.Key(),
		diagnostics.StringifyArgs(details.Args),
		nextChain,
		nil,
		false,
		false,
		false,
	)
}

func (d *DiagnosticsOrBuildInfoDiagnosticsWithFileName) getDiagnostics(p *compiler.Program, file *ast.SourceFile) []*ast.Diagnostic {
	if d.diagnostics != nil {
		return d.diagnostics
	}
	// Convert and cache the diagnostics
	d.diagnostics = core.Map(d.buildInfoDiagnostics, func(diag *buildInfoDiagnosticWithFileName) *ast.Diagnostic {
		return diag.toDiagnostic(p, file)
	})
	return d.diagnostics
}

type snapshot struct {
	// These are the fields that get serialized

	// Information of the file eg. its version, signature etc
	fileInfos collections.SyncMap[tspath.Path, *FileInfo]
	options   *core.CompilerOptions
	//  Contains the map of ReferencedSet=Referenced files of the file if module emit is enabled
	referencedMap referenceMap
	// Cache of semantic diagnostics for files with their Path being the key
	semanticDiagnosticsPerFile collections.SyncMap[tspath.Path, *DiagnosticsOrBuildInfoDiagnosticsWithFileName]
	// Cache of dts emit diagnostics for files with their Path being the key
	emitDiagnosticsPerFile collections.SyncMap[tspath.Path, *DiagnosticsOrBuildInfoDiagnosticsWithFileName]
	// The map has key by source file's path that has been changed
	changedFilesSet collections.SyncSet[tspath.Path]
	// Files pending to be emitted
	affectedFilesPendingEmit collections.SyncMap[tspath.Path, FileEmitKind]
	// Name of the file whose dts was the latest to change
	latestChangedDtsFile string
	// Hash of d.ts emitted for the file, use to track when emit of d.ts changes
	emitSignatures collections.SyncMap[tspath.Path, *emitSignature]
	// Recorded if program had errors that need to be reported even with --noCheck
	hasErrors core.Tristate
	// Recorded if program had semantic errors only for non incremental build
	hasSemanticErrors bool
	// If semantic diagnostic check is pending
	checkPending bool
	// Looked up package.json files from
	packageJsons        []string
	missingPackageJsons []string

	// Additional fields that are not serialized but needed to track state

	// true if build info emit is pending
	buildInfoEmitPending                    atomic.Bool
	hasErrorsFromOldState                   core.Tristate
	hasSemanticErrorsFromOldState           bool
	allFilesExcludingDefaultLibraryFileOnce sync.Once
	packageJsonsFromOldState                []string
	missingPackageJsonsFromOldState         []string
	//  Cache of all files excluding default library file for the current program
	allFilesExcludingDefaultLibraryFile []*ast.SourceFile
	hasChangedDtsFile                   bool
	hasEmitDiagnostics                  bool

	// Used with testing to add text of hash for better comparison
	hashWithText bool
}

func (s *snapshot) addFileToChangeSet(filePath tspath.Path) {
	s.changedFilesSet.Add(filePath)
	s.buildInfoEmitPending.Store(true)
}

func (s *snapshot) addFileToAffectedFilesPendingEmit(filePath tspath.Path, emitKind FileEmitKind) {
	existingKind, _ := s.affectedFilesPendingEmit.Load(filePath)
	s.affectedFilesPendingEmit.Store(filePath, existingKind|emitKind)
	if emitKind&FileEmitKindDtsErrors != 0 {
		s.emitDiagnosticsPerFile.Delete(filePath)
	}
	s.buildInfoEmitPending.Store(true)
}

func (s *snapshot) getAllFilesExcludingDefaultLibraryFile(program *compiler.Program, firstSourceFile *ast.SourceFile) []*ast.SourceFile {
	s.allFilesExcludingDefaultLibraryFileOnce.Do(func() {
		files := program.GetSourceFiles()
		s.allFilesExcludingDefaultLibraryFile = make([]*ast.SourceFile, 0, len(files))
		addSourceFile := func(file *ast.SourceFile) {
			if !program.IsSourceFileDefaultLibrary(file.Path()) {
				s.allFilesExcludingDefaultLibraryFile = append(s.allFilesExcludingDefaultLibraryFile, file)
			}
		}
		if firstSourceFile != nil {
			addSourceFile(firstSourceFile)
		}
		for _, file := range files {
			if file != firstSourceFile {
				addSourceFile(file)
			}
		}
	})
	return s.allFilesExcludingDefaultLibraryFile
}

func getTextHandlingSourceMapForSignature(text string, data *compiler.WriteFileData) string {
	if data.SourceMapUrlPos != -1 {
		return text[:data.SourceMapUrlPos]
	}
	return text
}

func (s *snapshot) computeSignatureWithDiagnostics(file *ast.SourceFile, text string, data *compiler.WriteFileData) string {
	var builder strings.Builder
	builder.WriteString(getTextHandlingSourceMapForSignature(text, data))
	for _, diag := range data.Diagnostics {
		diagnosticToStringBuilder(diag, file, &builder)
	}
	return s.computeHash(builder.String())
}

func diagnosticToStringBuilder(diagnostic *ast.Diagnostic, file *ast.SourceFile, builder *strings.Builder) {
	if diagnostic == nil {
		return
	}
	builder.WriteString("\n")
	if diagnostic.File() != file {
		builder.WriteString(tspath.EnsurePathIsNonModuleName(tspath.GetRelativePathFromDirectory(
			tspath.GetDirectoryPath(string(file.Path())),
			string(diagnostic.File().Path()),
			tspath.ComparePathsOptions{},
		)))
	}
	if diagnostic.File() != nil {
		builder.WriteString(fmt.Sprintf("(%d,%d): ", diagnostic.Pos(), diagnostic.Len()))
	}
	builder.WriteString(diagnostic.Category().Name())
	builder.WriteString(fmt.Sprintf("%d: ", diagnostic.Code()))
	builder.WriteString(string(diagnostic.MessageKey()))
	builder.WriteString("\n")
	for _, arg := range diagnostic.MessageArgs() {
		builder.WriteString(arg)
		builder.WriteString("\n")
	}
	for _, chain := range diagnostic.MessageChain() {
		diagnosticToStringBuilder(chain, file, builder)
	}
	for _, info := range diagnostic.RelatedInformation() {
		diagnosticToStringBuilder(info, file, builder)
	}
}

func (s *snapshot) computeHash(text string) string {
	return ComputeHash(text, s.hashWithText)
}

func (s *snapshot) canUseIncrementalState() bool {
	if !s.options.IsIncremental() && s.options.Build.IsTrue() {
		// If not incremental build (with tsc -b), we don't need to track state except diagnostics per file so we can use it
		return false
	}
	return true
}
