package autoimport

import (
	"context"
	"runtime"
	"strings"
	"sync/atomic"
	"unicode"
	"unicode/utf8"

	"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/module"
	"github.com/microsoft/typescript-go/internal/modulespecifiers"
	"github.com/microsoft/typescript-go/internal/packagejson"
	"github.com/microsoft/typescript-go/internal/tspath"
	"github.com/microsoft/typescript-go/internal/vfs"
	"github.com/microsoft/typescript-go/internal/vfs/wrapvfs"
)

func tryGetModuleIDAndFileNameOfModuleSymbol(symbol *ast.Symbol) (ModuleID, string, bool) {
	if !symbol.IsExternalModule() {
		return "", "", false
	}
	decl := ast.GetNonAugmentationDeclaration(symbol)
	if decl == nil {
		return "", "", false
	}
	if decl.Kind == ast.KindSourceFile {
		return ModuleID(decl.AsSourceFile().Path()), decl.AsSourceFile().FileName(), true
	}
	if ast.IsModuleWithStringLiteralName(decl) {
		return ModuleID(decl.Name().Text()), "", true
	}
	return "", "", false
}

func getModuleIDAndFileNameOfModuleSymbol(symbol *ast.Symbol) (ModuleID, string) {
	if !symbol.IsExternalModule() {
		panic("symbol is not an external module")
	}
	decl := ast.GetNonAugmentationDeclaration(symbol)
	if decl == nil {
		panic("module symbol has no non-augmentation declaration")
	}
	if decl.Kind == ast.KindSourceFile {
		return ModuleID(decl.AsSourceFile().Path()), decl.AsSourceFile().FileName()
	}
	if ast.IsModuleWithStringLiteralName(decl) {
		return ModuleID(decl.Name().Text()), ""
	}
	panic("could not determine module ID of module symbol")
}

// wordIndices splits an identifier into its constituent words based on camelCase and snake_case conventions
// by returning the starting byte indices of each word. The first index is always 0.
//   - CamelCase
//     ^    ^
//   - snake_case
//     ^     ^
//   - ParseURL
//     ^    ^
//   - __proto__
//     ^
func wordIndices(s string) []int {
	var indices []int
	for byteIndex, runeValue := range s {
		if byteIndex == 0 {
			indices = append(indices, byteIndex)
			continue
		}
		if runeValue == '_' {
			if byteIndex+1 < len(s) && s[byteIndex+1] != '_' {
				indices = append(indices, byteIndex+1)
			}
			continue
		}
		if unicode.IsUpper(runeValue) && (unicode.IsLower(core.FirstResult(utf8.DecodeLastRuneInString(s[:byteIndex]))) || (byteIndex+1 < len(s) && unicode.IsLower(core.FirstResult(utf8.DecodeRuneInString(s[byteIndex+1:]))))) {
			indices = append(indices, byteIndex)
		}
	}
	return indices
}

func getPackageNamesInNodeModules(nodeModulesDir string, fs vfs.FS) *collections.Set[string] {
	packageNames := &collections.Set[string]{}
	if tspath.GetBaseFileName(nodeModulesDir) != "node_modules" {
		panic("nodeModulesDir is not a node_modules directory")
	}
	// A missing node_modules directory yields no entries (GetAccessibleEntries returns
	// empty), so there's no need to check existence first: a deleted node_modules is
	// handled upstream in updateBucketAndDirectoryExistence, which drops the bucket.
	entries := fs.GetAccessibleEntries(nodeModulesDir)
	for _, baseName := range entries.Directories {
		if baseName[0] == '.' {
			continue
		}
		if baseName[0] == '@' {
			scopedDirPath := tspath.CombinePaths(nodeModulesDir, baseName)
			for _, scopedPackageDirName := range fs.GetAccessibleEntries(scopedDirPath).Directories {
				scopedBaseName := tspath.GetBaseFileName(scopedPackageDirName)
				if baseName == "@types" {
					packageNames.Add(module.GetPackageNameFromTypesPackageName(tspath.CombinePaths("@types", scopedBaseName)))
				} else {
					packageNames.Add(tspath.CombinePaths(baseName, scopedBaseName))
				}
			}
			continue
		}
		packageNames.Add(baseName)
	}
	return packageNames
}

func getDefaultLikeExportNameFromDeclaration(symbol *ast.Symbol) string {
	for _, d := range symbol.Declarations {
		// "export default" in this case. See `ExportAssignment`for more details.
		if ast.IsExportAssignment(d) {
			if innerExpression := ast.SkipOuterExpressions(d.Expression(), ast.OEKAll); ast.IsIdentifier(innerExpression) {
				return innerExpression.Text()
			}
			continue
		}
		// "export { ~ as default }"
		if ast.IsExportSpecifier(d) && d.Symbol().Flags == ast.SymbolFlagsAlias && d.PropertyName() != nil {
			if d.PropertyName().Kind == ast.KindIdentifier {
				return d.PropertyName().Text()
			}
			continue
		}
		// GH#52694
		if name := ast.GetNameOfDeclaration(d); name != nil && name.Kind == ast.KindIdentifier {
			return name.Text()
		}
		if symbol.Parent != nil && !checker.IsExternalModuleSymbol(symbol.Parent) {
			return symbol.Parent.Name
		}
	}
	return ""
}

func getResolvedPackageNames(ctx context.Context, program *compiler.Program) *collections.Set[string] {
	rawNames := program.ResolvedPackageNames()
	unresolvedPackageNames := program.UnresolvedPackageNames()

	// Normalize @types/ package names to their actual package names
	// (e.g., "@types/react" → "react"). ResolvedPackageNames can contain
	// @types names when the program resolves an import like "react" to
	// "@types/react/index.d.ts" via the PackageId.Name field.
	resolvedPackageNames := collections.NewSetWithSizeHint[string](rawNames.Len())
	for name := range rawNames.Keys() {
		resolvedPackageNames.Add(module.GetPackageNameFromTypesPackageName(name))
	}

	for _, name := range program.Options().Types {
		if name != "*" {
			resolvedPackageNames.Add(module.GetPackageNameFromTypesPackageName(name))
		}
	}

	if unresolvedPackageNames.Len() > 0 {
		checker, done := program.GetTypeChecker(ctx)
		defer done()
		for name := range unresolvedPackageNames.Keys() {
			if symbol := checker.TryFindAmbientModule(name); symbol != nil {
				declaringFile := ast.GetSourceFileOfModule(symbol)
				if packageName := modulespecifiers.GetPackageNameFromDirectory(declaringFile.FileName()); packageName != "" {
					resolvedPackageNames.Add(module.GetPackageNameFromTypesPackageName(packageName))
				}
			}
		}
	}
	return resolvedPackageNames
}

// addProjectReferenceOutputMappings adds output .d.ts to source file mappings
// from a program's project references to the provided map.
// This is used during node_modules bucket building to redirect extraction
// from output files to source files when the output is from a project reference.
func addProjectReferenceOutputMappings(program *compiler.Program, result map[tspath.Path]string) {
	refs := program.GetResolvedProjectReferences()
	for _, ref := range refs {
		if ref == nil {
			continue
		}
		ref.ParseInputOutputNames()
		for outputDtsPath, mapping := range ref.OutputDtsToProjectReference() {
			// Only add if not already present (first program wins)
			if _, exists := result[outputDtsPath]; !exists {
				result[outputDtsPath] = mapping.Source
			}
		}
	}
}

func createCheckerPool(program checker.Program) (getChecker func() (*checker.Checker, func()), closePool func(), getCreatedCount func() int32) {
	maxSize := int32(runtime.GOMAXPROCS(0))
	pool := make(chan *checker.Checker, maxSize)
	var created atomic.Int32

	return func() (*checker.Checker, func()) {
			// Try to get an existing checker
			select {
			case ch := <-pool:
				return ch, func() { pool <- ch }
			default:
				break
			}
			// Try to create a new one if under limit
			for {
				current := created.Load()
				if current >= maxSize {
					// At limit, wait for one to become available
					ch := <-pool
					return ch, func() { pool <- ch }
				}
				if created.CompareAndSwap(current, current+1) {
					ch := core.FirstResult(checker.NewChecker(program, nil))
					return ch, func() { pool <- ch }
				}
			}
		}, func() {
			close(pool)
		}, func() int32 {
			return created.Load()
		}
}

// addPackageJsonDependencies adds all dependencies and peerDependencies from a package.json
// to the given set, canonicalizing @types package names to their base names.
func addPackageJsonDependencies(contents *packagejson.PackageJson, deps *collections.Set[string]) {
	contents.RangeDependencies(func(name, _, field string) bool {
		if name == "" || name == "@types/" || name[0] == '.' {
			// Edge cases that could make us blow up probably
			return true
		}
		if field == "dependencies" || field == "peerDependencies" {
			deps.Add(module.GetPackageNameFromTypesPackageName(name))
		}
		return true
	})
}

// getPackageRealpathFuncs returns functions to transform between symlink and realpath for files within a package.
// It calls FS.Realpath once per package directory and uses prefix substitution for files within that directory,
// avoiding expensive realpath syscalls for each file. For files outside the package (e.g. re-exported
// dependencies reached through node_modules symlinks), it resolves the file's directory realpath once,
// finds the symlink boundary (the package root where the symlink lives), and caches that prefix mapping.
// All subsequent files under the same symlinked package directory use prefix substitution with no syscalls.
func getPackageRealpathFuncs(fs vfs.FS, packageDir string) (toRealpath, toSymlink func(string) string) {
	realPackageDir := fs.Realpath(packageDir)
	isSymlinked := realPackageDir != packageDir
	// Cache of package-directory-level symlink→realpath prefix mappings for
	// external packages encountered via re-exports. Keyed by the node_modules
	// package directory (e.g. "/app/node_modules/dep"), so all files under
	// that package reuse a single realpath lookup.
	dirCache := make(map[string]string)
	toRealpath = func(fileName string) string {
		// Fast path: files within the package use prefix substitution.
		if isSymlinked {
			if after, ok := strings.CutPrefix(fileName, packageDir); ok {
				return realPackageDir + after
			}
		}
		// Files outside the package (e.g. re-exports into symlinked deps):
		// find the node_modules package directory, resolve it once, and cache.
		pkgDir := module.ParseNodeModuleFromPath(fileName, false /*isFolder*/)
		if pkgDir == "" {
			return fileName
		}
		if realDir, ok := dirCache[pkgDir]; ok {
			if realDir == pkgDir {
				return fileName
			}
			return realDir + fileName[len(pkgDir):]
		}
		realDir := fs.Realpath(pkgDir)
		dirCache[pkgDir] = realDir
		if realDir == pkgDir {
			return fileName
		}
		return realDir + fileName[len(pkgDir):]
	}
	if !isSymlinked {
		return toRealpath, core.Identity
	}
	// toSymlink only handles files within the package directory (reversing the
	// packageDir→realPackageDir substitution). It does not handle arbitrary external
	// paths; callers should only use it for files known to be within the package.
	toSymlink = func(fileName string) string {
		if after, ok := strings.CutPrefix(fileName, realPackageDir); ok {
			return packageDir + after
		}
		return fileName
	}
	return toRealpath, toSymlink
}

type resolutionHost struct {
	fs               vfs.FS
	currentDirectory string
}

var _ module.ResolutionHost = (*resolutionHost)(nil)

func (rh *resolutionHost) GetCurrentDirectory() string {
	return rh.currentDirectory
}

func (rh *resolutionHost) FS() vfs.FS {
	return rh.fs
}

func getModuleResolver(host RegistryCloneHost, realpath func(string) string, opts module.ResolverOptions) *module.Resolver {
	rh := &resolutionHost{
		fs:               wrapvfs.Wrap(host.FS(), wrapvfs.Replacements{Realpath: realpath}),
		currentDirectory: host.GetCurrentDirectory(),
	}
	return module.NewResolverWithOptions(rh, core.EmptyCompilerOptions, "", "", opts)
}
