package compiler

import (
	"math"
	"slices"
	"strings"
	"sync"

	"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/module"
	"github.com/microsoft/typescript-go/internal/tracing"
	"github.com/microsoft/typescript-go/internal/tsoptions"
	"github.com/microsoft/typescript-go/internal/tspath"
)

type parseTask struct {
	normalizedFilePath          string
	path                        tspath.Path
	file                        *ast.SourceFile
	libFile                     *LibFile
	redirectedParseTask         *parseTask
	subTasks                    []*parseTask
	loaded                      bool
	startedSubTasks             bool
	isForAutomaticTypeDirective bool
	includeReason               *FileIncludeReason
	packageId                   module.PackageId

	metadata                     ast.SourceFileMetaData
	resolutionsInFile            module.ModeAwareCache[*module.ResolvedModule]
	resolutionsTrace             []module.DiagAndArgs
	typeResolutionsInFile        module.ModeAwareCache[*module.ResolvedTypeReferenceDirective]
	typeResolutionsTrace         []module.DiagAndArgs
	resolutionDiagnostics        []*ast.Diagnostic
	processingDiagnostics        []*processingDiagnostic
	importHelpersImportSpecifier *ast.StringLiteralNode
	jsxRuntimeImportSpecifier    *jsxRuntimeImportSpecifier

	increaseDepth bool
	elideOnDepth  bool

	loadedTask        *parseTask
	allIncludeReasons []*FileIncludeReason
}

func (t *parseTask) FileName() string {
	return t.normalizedFilePath
}

func (t *parseTask) Path() tspath.Path {
	return t.path
}

func (t *parseTask) load(loader *fileLoader) {
	t.loaded = true
	if t.isForAutomaticTypeDirective {
		t.loadAutomaticTypeDirectives(loader)
		return
	}
	if loader.opts.Tracing != nil {
		defer loader.opts.Tracing.Push(tracing.PhaseProgram, "findSourceFile", map[string]any{"fileName": t.normalizedFilePath}, false)()
	}
	redirect := loader.projectReferenceFileMapper.getParseFileRedirect(t)
	if redirect != "" {
		t.redirect(loader, redirect)
		return
	}

	if tspath.HasExtension(t.normalizedFilePath) {
		compilerOptions := loader.opts.Config.CompilerOptions()
		allowNonTsExtensions := compilerOptions.AllowNonTsExtensions.IsTrue()
		if !allowNonTsExtensions {
			canonicalFileName := tspath.GetCanonicalFileName(t.normalizedFilePath, loader.opts.Host.FS().UseCaseSensitiveFileNames())
			if !loader.isSupportedExtension(canonicalFileName) {
				if tspath.HasJSFileExtension(canonicalFileName) {
					t.processingDiagnostics = append(t.processingDiagnostics, &processingDiagnostic{
						kind: processingDiagnosticKindExplainingFileInclude,
						data: &includeExplainingDiagnostic{
							diagnosticReason: t.includeReason,
							message:          diagnostics.File_0_is_a_JavaScript_file_Did_you_mean_to_enable_the_allowJs_option,
							args:             []any{t.normalizedFilePath},
						},
					})
				} else {
					t.processingDiagnostics = append(t.processingDiagnostics, &processingDiagnostic{
						kind: processingDiagnosticKindExplainingFileInclude,
						data: &includeExplainingDiagnostic{
							diagnosticReason: t.includeReason,
							message:          diagnostics.File_0_has_an_unsupported_extension_The_only_supported_extensions_are_1,
							args:             []any{t.normalizedFilePath, "'" + strings.Join(core.Flatten(loader.supportedExtensions), "', '") + "'"},
						},
					})
				}
				return
			}
		}
	}

	loader.totalFileCount.Add(1)
	if t.libFile != nil {
		loader.libFileCount.Add(1)
		// Default lib files are all scripts; we can safely skip looking up their package.json
		// to avoid adding spurious lookups to file watcher tracking.
		t.metadata = ast.SourceFileMetaData{ImpliedNodeFormat: core.ResolutionModeCommonJS}
	} else {
		t.metadata = loader.loadSourceFileMetaData(t.normalizedFilePath)
	}

	file := loader.parseSourceFile(t)
	if file == nil {
		return
	}

	t.file = file
	t.subTasks = make([]*parseTask, 0, len(file.ReferencedFiles)+len(file.Imports())+len(file.ModuleAugmentations))

	compilerOptions := loader.opts.Config.CompilerOptions()
	if !compilerOptions.NoResolve.IsTrue() {
		for index, ref := range file.ReferencedFiles {
			resolvedRef, processingDiagnostic := loader.resolveTripleslashPathReference(ref.FileName, file.FileName(), index)
			if processingDiagnostic != nil {
				t.processingDiagnostics = append(t.processingDiagnostics, processingDiagnostic)
				continue
			}
			t.addSubTask(*resolvedRef, nil)
		}

		loader.resolveTypeReferenceDirectives(t)
	}

	if compilerOptions.NoLib != core.TSTrue {
		for index, lib := range file.LibReferenceDirectives {
			includeReason := &FileIncludeReason{
				kind: fileIncludeKindLibReferenceDirective,
				data: &referencedFileData{
					file:  t.path,
					index: index,
				},
			}
			if name, ok := tsoptions.GetLibFileName(lib.FileName); ok {
				libFile := loader.pathForLibFile(name)
				t.addSubTask(resolvedRef{
					fileName:      libFile.path,
					includeReason: includeReason,
				}, libFile)
			} else {
				t.processingDiagnostics = append(t.processingDiagnostics, &processingDiagnostic{
					kind: processingDiagnosticKindUnknownReference,
					data: includeReason,
				})
			}
		}
	}

	loader.resolveImportsAndModuleAugmentations(t)
}

func (t *parseTask) redirect(loader *fileLoader, fileName string) {
	t.redirectedParseTask = &parseTask{
		normalizedFilePath: tspath.NormalizePath(fileName),
		libFile:            t.libFile,
		includeReason:      t.includeReason,
	}
	// increaseDepth and elideOnDepth are not copied to redirects, otherwise their depth would be double counted.
	t.subTasks = []*parseTask{t.redirectedParseTask}
}

func (t *parseTask) loadAutomaticTypeDirectives(loader *fileLoader) {
	if loader.opts.Tracing != nil {
		defer loader.opts.Tracing.Push(tracing.PhaseProgram, "processTypeReferences", nil, false)()
	}
	toParseTypeRefs, typeResolutionsInFile, typeResolutionsTrace, pDiagnostics := loader.resolveAutomaticTypeDirectives(t.normalizedFilePath)
	t.typeResolutionsInFile = typeResolutionsInFile
	t.typeResolutionsTrace = typeResolutionsTrace
	t.processingDiagnostics = append(t.processingDiagnostics, pDiagnostics...)
	for _, typeResolution := range toParseTypeRefs {
		t.addSubTask(typeResolution, nil)
	}
}

type resolvedRef struct {
	fileName      string
	increaseDepth bool
	elideOnDepth  bool
	includeReason *FileIncludeReason
	packageId     module.PackageId
}

func (t *parseTask) addSubTask(ref resolvedRef, libFile *LibFile) {
	normalizedFilePath := tspath.NormalizePath(ref.fileName)
	subTask := &parseTask{
		normalizedFilePath: normalizedFilePath,
		libFile:            libFile,
		increaseDepth:      ref.increaseDepth,
		elideOnDepth:       ref.elideOnDepth,
		includeReason:      ref.includeReason,
		packageId:          ref.packageId,
	}
	t.subTasks = append(t.subTasks, subTask)
}

type filesParser struct {
	wg             core.WorkGroup
	taskDataByPath collections.SyncMap[tspath.Path, *parseTaskData]
	maxDepth       int
}

var parseTaskDataPool = sync.Pool{
	New: func() any {
		return &parseTaskData{
			tasks: make(map[string]*parseTask, 1),
		}
	},
}

func getParseTaskData(task *parseTask) *parseTaskData {
	td := parseTaskDataPool.Get().(*parseTaskData)
	td.tasks[task.normalizedFilePath] = task
	td.lowestDepth = math.MaxInt
	return td
}

func putParseTaskData(td *parseTaskData) {
	clear(td.tasks)
	parseTaskDataPool.Put(td)
}

type parseTaskData struct {
	// map of tasks by file casing
	tasks           map[string]*parseTask
	mu              sync.Mutex
	lowestDepth     int
	startedSubTasks bool
	packageId       module.PackageId
}

func (w *filesParser) parse(loader *fileLoader, tasks []*parseTask) {
	w.start(loader, tasks, 0)
	w.wg.RunAndWait()
}

func (w *filesParser) start(loader *fileLoader, tasks []*parseTask, depth int) {
	for i, task := range tasks {
		task.path = loader.toPath(task.normalizedFilePath)
		candidate := getParseTaskData(task)
		data, loaded := w.taskDataByPath.LoadOrStore(task.path, candidate)
		if loaded {
			putParseTaskData(candidate)
		}

		w.wg.Queue(func() {
			data.mu.Lock()
			defer data.mu.Unlock()

			startSubtasks := false
			if loaded {
				if existingTask, ok := data.tasks[task.normalizedFilePath]; ok {
					tasks[i].loadedTask = existingTask
				} else {
					data.tasks[task.normalizedFilePath] = task
					// This is new task for file name - so load subtasks if there was loading for any other casing
					startSubtasks = data.startedSubTasks
				}
			}

			// Propagate packageId to data if we have one and data doesn't yet
			if data.packageId.Name == "" && task.packageId.Name != "" {
				data.packageId = task.packageId
			}

			currentDepth := core.IfElse(task.increaseDepth, depth+1, depth)
			if currentDepth < data.lowestDepth {
				// If we're seeing this task at a lower depth than before,
				// reprocess its subtasks to ensure they are loaded.
				data.lowestDepth = currentDepth
				startSubtasks = true
				data.startedSubTasks = true
			}

			if task.elideOnDepth && currentDepth > w.maxDepth {
				return
			}

			for _, taskByFileName := range data.tasks {
				loadSubTasks := startSubtasks
				if !taskByFileName.loaded {
					taskByFileName.load(loader)
					if taskByFileName.redirectedParseTask != nil {
						// Always load redirected task
						loadSubTasks = true
						data.startedSubTasks = true
					}
				}
				if !taskByFileName.startedSubTasks && loadSubTasks {
					taskByFileName.startedSubTasks = true
					w.start(loader, taskByFileName.subTasks, data.lowestDepth)
				}
			}
		})
	}
}

func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles {
	totalFileCount := int(loader.totalFileCount.Load())
	libFileCount := int(loader.libFileCount.Load())

	var missingFiles []string
	var duplicateSourceFiles []*DuplicateSourceFile
	files := make([]*ast.SourceFile, 0, totalFileCount-libFileCount)
	libFiles := make([]*ast.SourceFile, 0, totalFileCount) // totalFileCount here since we append files to it later to construct the final list

	filesByPath := make(map[tspath.Path]*ast.SourceFile, totalFileCount)
	// stores 'filename -> file association' ignoring case
	// used to track cases when two file names differ only in casing
	var tasksSeenByNameIgnoreCase map[string]*parseTask
	if loader.comparePathsOptions.UseCaseSensitiveFileNames {
		tasksSeenByNameIgnoreCase = make(map[string]*parseTask, totalFileCount)
	}

	includeProcessor := &includeProcessor{
		fileIncludeReasons: make(map[tspath.Path][]*FileIncludeReason, totalFileCount),
	}
	var outputFileToProjectReferenceSource map[tspath.Path]string
	if !loader.opts.canUseProjectReferenceSource() {
		outputFileToProjectReferenceSource = make(map[tspath.Path]string, totalFileCount)
	}
	resolvedModules := make(map[tspath.Path]module.ModeAwareCache[*module.ResolvedModule], totalFileCount+1)
	typeResolutionsInFile := make(map[tspath.Path]module.ModeAwareCache[*module.ResolvedTypeReferenceDirective], totalFileCount)
	sourceFileMetaDatas := make(map[tspath.Path]ast.SourceFileMetaData, totalFileCount)
	var jsxRuntimeImportSpecifiers map[tspath.Path]*jsxRuntimeImportSpecifier
	var importHelpersImportSpecifiers map[tspath.Path]*ast.StringLiteralNode
	var sourceFilesFoundSearchingNodeModules collections.Set[tspath.Path]
	libFilesMap := make(map[tspath.Path]*LibFile, libFileCount)

	var redirectTargetsMap map[tspath.Path][]string
	var redirectFilesByPath map[tspath.Path]*redirectsFile
	var packageIdToSourceFile map[module.PackageId]*ast.SourceFile
	if !loader.opts.Config.CompilerOptions().DeduplicatePackages.IsFalse() {
		redirectTargetsMap = make(map[tspath.Path][]string)
		packageIdToSourceFile = make(map[module.PackageId]*ast.SourceFile)
	}

	var collectFiles func(tasks []*parseTask, seen map[*parseTaskData]string)
	// recordedDuplicates tracks, per task data, the set of file-name casings that
	// have already been recorded in duplicateSourceFiles. A file that is reached
	// from multiple import sites is walked once per site, but each distinct casing
	// is only parsed and acquired in the parse cache once. Recording the same casing
	// as a duplicate more than once would cause it to be released more times than it
	// was acquired when the snapshot is disposed, leaving a dangling cache entry that
	// panics the next time it is referenced.
	var recordedDuplicates map[*parseTaskData]*collections.Set[string]
	collectFiles = func(tasks []*parseTask, seen map[*parseTaskData]string) {
		for _, task := range tasks {
			includeReason := task.includeReason
			// Exclude automatic type directive tasks from include reason processing,
			// as these are internal implementation details and should not contribute
			// to the reasons for including files.
			if task.redirectedParseTask == nil && !task.isForAutomaticTypeDirective {
				if task.loadedTask != nil {
					task = task.loadedTask
				}
				w.addIncludeReason(includeProcessor, task, includeReason)
			}
			data, _ := w.taskDataByPath.Load(task.path)
			if !task.loaded {
				continue
			}

			// ensure we only walk each task once
			if checkedName, ok := seen[data]; ok {
				if task.file != nil && checkedName != task.normalizedFilePath {
					if recordedDuplicates == nil {
						recordedDuplicates = make(map[*parseTaskData]*collections.Set[string])
					}
					dups := recordedDuplicates[data]
					if dups == nil {
						dups = &collections.Set[string]{}
						recordedDuplicates[data] = dups
					}
					if dups.AddIfAbsent(task.normalizedFilePath) {
						duplicateSourceFiles = append(duplicateSourceFiles, &DuplicateSourceFile{
							ParseOptions: task.file.ParseOptions(),
							Hash:         task.file.Hash,
							ScriptKind:   task.file.ScriptKind,
						})
					}
				}
				if !loader.opts.Config.CompilerOptions().ForceConsistentCasingInFileNames.IsFalse() {
					// Check if it differs only in drive letters its ok to ignore that error:
					checkedAbsolutePath := tspath.GetNormalizedAbsolutePathWithoutRoot(checkedName, loader.comparePathsOptions.CurrentDirectory)
					inputAbsolutePath := tspath.GetNormalizedAbsolutePathWithoutRoot(task.normalizedFilePath, loader.comparePathsOptions.CurrentDirectory)
					if checkedAbsolutePath != inputAbsolutePath {
						includeProcessor.addProcessingDiagnosticsForFileCasing(task.path, checkedName, task.normalizedFilePath, includeReason)
					}
				}
				continue
			} else {
				seen[data] = task.normalizedFilePath
			}

			if tasksSeenByNameIgnoreCase != nil {
				pathLowerCase := tspath.ToFileNameLowerCase(string(task.path))
				if taskByIgnoreCase, ok := tasksSeenByNameIgnoreCase[pathLowerCase]; ok {
					includeProcessor.addProcessingDiagnosticsForFileCasing(taskByIgnoreCase.path, taskByIgnoreCase.normalizedFilePath, task.normalizedFilePath, includeReason)
				} else {
					tasksSeenByNameIgnoreCase[pathLowerCase] = task
				}
			}

			for _, trace := range task.typeResolutionsTrace {
				loader.opts.Host.Trace(trace.Message, trace.Args...)
			}
			for _, trace := range task.resolutionsTrace {
				loader.opts.Host.Trace(trace.Message, trace.Args...)
			}

			file := task.file
			if packageIdToSourceFile != nil && data.packageId.Name != "" {
				if packageIdFile, exists := packageIdToSourceFile[data.packageId]; exists {
					if file != nil {
						// Package deduplication keeps the first package instance in the
						// program, but we still parsed this file and acquired it through
						// the host, so snapshot disposal must release that extra owner.
						duplicateSourceFiles = append(duplicateSourceFiles, &DuplicateSourceFile{
							ParseOptions: file.ParseOptions(),
							Hash:         file.Hash,
							ScriptKind:   file.ScriptKind,
						})
					}
					redirectTargetsMap[packageIdFile.Path()] = append(redirectTargetsMap[packageIdFile.Path()], task.normalizedFilePath)
					if redirectFilesByPath == nil {
						redirectFilesByPath = make(map[tspath.Path]*redirectsFile, totalFileCount)
					}
					redirectFilesByPath[task.path] = &redirectsFile{
						index:    len(files) + len(redirectFilesByPath),
						fileName: task.normalizedFilePath,
						path:     task.path,
						target:   packageIdFile.Path(),
					}
					filesByPath[task.path] = packageIdFile
					if data.lowestDepth > 0 {
						sourceFilesFoundSearchingNodeModules.Add(task.path)
					}
					continue
				} else if file != nil {
					packageIdToSourceFile[data.packageId] = file
				}
			}

			if subTasks := task.subTasks; len(subTasks) > 0 {
				collectFiles(subTasks, seen)
			}

			// Exclude automatic type directive tasks from include reason processing,
			// as these are internal implementation details and should not contribute
			// to the reasons for including files.
			if task.redirectedParseTask != nil {
				if !loader.opts.canUseProjectReferenceSource() {
					outputFileToProjectReferenceSource[task.redirectedParseTask.path] = task.FileName()
				}
				continue
			}

			if task.isForAutomaticTypeDirective {
				typeResolutionsInFile[task.path] = task.typeResolutionsInFile
				if len(task.processingDiagnostics) > 0 {
					includeProcessor.processingDiagnostics = append(includeProcessor.processingDiagnostics, task.processingDiagnostics...)
				}
				continue
			}

			path := task.path

			if len(task.processingDiagnostics) > 0 {
				includeProcessor.processingDiagnostics = append(includeProcessor.processingDiagnostics, task.processingDiagnostics...)
			}

			if file == nil {
				missingFiles = append(missingFiles, task.normalizedFilePath)
				continue
			}

			if task.libFile != nil {
				libFiles = append(libFiles, file)
				libFilesMap[path] = task.libFile
			} else {
				files = append(files, file)
			}
			filesByPath[path] = file
			resolvedModules[path] = task.resolutionsInFile
			typeResolutionsInFile[path] = task.typeResolutionsInFile
			sourceFileMetaDatas[path] = task.metadata

			if task.jsxRuntimeImportSpecifier != nil {
				if jsxRuntimeImportSpecifiers == nil {
					jsxRuntimeImportSpecifiers = make(map[tspath.Path]*jsxRuntimeImportSpecifier, totalFileCount)
				}
				jsxRuntimeImportSpecifiers[path] = task.jsxRuntimeImportSpecifier
			}
			if task.importHelpersImportSpecifier != nil {
				if importHelpersImportSpecifiers == nil {
					importHelpersImportSpecifiers = make(map[tspath.Path]*ast.StringLiteralNode, totalFileCount)
				}
				importHelpersImportSpecifiers[path] = task.importHelpersImportSpecifier
			}
			if data.lowestDepth > 0 {
				sourceFilesFoundSearchingNodeModules.Add(path)
			}
		}
	}

	collectFiles(loader.rootTasks, make(map[*parseTaskData]string, totalFileCount))
	loader.sortLibs(libFiles)

	allFiles := append(libFiles, files...)
	for _, redirectFile := range redirectFilesByPath {
		redirectFile.index += len(libFiles)
	}

	keys := slices.Collect(loader.pathForLibFileResolutions.Keys())
	slices.Sort(keys)
	for _, key := range keys {
		value, _ := loader.pathForLibFileResolutions.Load(key)
		resolvedModules[key] = module.ModeAwareCache[*module.ResolvedModule]{
			module.ModeAwareCacheKey{Name: value.libraryName, Mode: core.ModuleKindCommonJS}: value.resolution,
		}
		for _, trace := range value.trace {
			loader.opts.Host.Trace(trace.Message, trace.Args...)
		}
	}

	return processedFiles{
		finishedProcessing:                   true,
		resolver:                             loader.resolver,
		files:                                allFiles,
		duplicateSourceFiles:                 duplicateSourceFiles,
		filesByPath:                          filesByPath,
		projectReferenceFileMapper:           loader.projectReferenceFileMapper,
		resolvedModules:                      resolvedModules,
		typeResolutionsInFile:                typeResolutionsInFile,
		sourceFileMetaDatas:                  sourceFileMetaDatas,
		jsxRuntimeImportSpecifiers:           jsxRuntimeImportSpecifiers,
		importHelpersImportSpecifiers:        importHelpersImportSpecifiers,
		sourceFilesFoundSearchingNodeModules: sourceFilesFoundSearchingNodeModules,
		libFiles:                             libFilesMap,
		missingFiles:                         missingFiles,
		includeProcessor:                     includeProcessor,
		outputFileToProjectReferenceSource:   outputFileToProjectReferenceSource,
		redirectTargetsMap:                   redirectTargetsMap,
		redirectFilesByPath:                  redirectFilesByPath,
	}
}

func (w *filesParser) addIncludeReason(includeProcessor *includeProcessor, task *parseTask, reason *FileIncludeReason) {
	if task.redirectedParseTask != nil {
		w.addIncludeReason(includeProcessor, task.redirectedParseTask, reason)
	} else if task.loaded {
		if existing, ok := includeProcessor.fileIncludeReasons[task.path]; ok {
			includeProcessor.fileIncludeReasons[task.path] = append(existing, reason)
		} else {
			includeProcessor.fileIncludeReasons[task.path] = []*FileIncludeReason{reason}
		}
	}
}
