package autoimport

import (
	"slices"
	"sync/atomic"

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

type symbolExtractor struct {
	packageName string
	stats       *extractorStats

	localNameResolver *binder.NameResolver
	checker           *checker.Checker
	toPath            func(fileName string) tspath.Path
	// realpath, if set, is used to resolve symlinks for ModuleID generation.
	// This ensures that symlinked packages use their realpath as ModuleID,
	// deduplicating exports from files that appear via multiple symlink paths.
	realpath func(fileName string) string
}

type exportExtractor struct {
	*symbolExtractor
	moduleResolver *module.Resolver
}

type extractorStats struct {
	exports     atomic.Int32
	usedChecker atomic.Int32
}

func (e *exportExtractor) Stats() *extractorStats {
	return e.stats
}

type checkerLease struct {
	used    bool
	checker *checker.Checker
}

func (l *checkerLease) GetChecker() *checker.Checker {
	l.used = true
	return l.checker
}

func (l *checkerLease) TryChecker() *checker.Checker {
	if l.used {
		return l.checker
	}
	return nil
}

func newSymbolExtractor(packageName string, checker *checker.Checker, toPath func(string) tspath.Path, realpath func(string) string) *symbolExtractor {
	return &symbolExtractor{
		packageName: packageName,
		checker:     checker,
		localNameResolver: &binder.NameResolver{
			CompilerOptions: core.EmptyCompilerOptions,
		},
		stats:    &extractorStats{},
		toPath:   toPath,
		realpath: realpath,
	}
}

func (b *registryBuilder) newExportExtractor(packageName string, checker *checker.Checker, moduleResolver *module.Resolver, realpath func(string) string) *exportExtractor {
	return &exportExtractor{
		symbolExtractor: newSymbolExtractor(packageName, checker, b.base.toPath, realpath),
		moduleResolver:  moduleResolver,
	}
}

// getModuleID returns the ModuleID for a file, using realpath if available.
func (e *symbolExtractor) getModuleID(file *ast.SourceFile) ModuleID {
	if e.realpath != nil && e.toPath != nil {
		realpath := e.realpath(file.FileName())
		return ModuleID(e.toPath(realpath))
	}
	return ModuleID(file.Path())
}

// getModuleIDForSymbol returns the ModuleID for a module symbol, using realpath
// normalization when available for source files.
func (e *symbolExtractor) getModuleIDForSymbol(symbol *ast.Symbol) (ModuleID, bool) {
	moduleID, fileName, ok := tryGetModuleIDAndFileNameOfModuleSymbol(symbol)
	if !ok {
		return "", false
	}
	// If fileName is set, this is a source file that may need realpath normalization
	if fileName != "" && e.realpath != nil {
		decl := ast.GetNonAugmentationDeclaration(symbol)
		if decl != nil && decl.Kind == ast.KindSourceFile {
			return e.getModuleID(decl.AsSourceFile()), true
		}
	}
	return moduleID, true
}

func (e *exportExtractor) extractFromFile(file *ast.SourceFile) []*Export {
	if file.Symbol != nil {
		return e.extractFromModule(file)
	}
	if len(file.AmbientModuleNames) > 0 {
		moduleDeclarations := core.Filter(file.Statements.Nodes, ast.IsModuleWithStringLiteralName)
		var exportCount int
		for _, decl := range moduleDeclarations {
			exportCount += len(decl.AsModuleDeclaration().Symbol.Exports)
		}
		exports := make([]*Export, 0, exportCount)
		for _, decl := range moduleDeclarations {
			e.extractFromModuleDeclaration(decl.AsModuleDeclaration(), file, ModuleID(decl.Name().Text()), "", &exports)
		}
		return exports
	}
	return nil
}

func (e *exportExtractor) extractFromModule(file *ast.SourceFile) []*Export {
	moduleAugmentations := core.MapNonNil(file.ModuleAugmentations, func(name *ast.ModuleName) *ast.ModuleDeclaration {
		decl := name.Parent
		if ast.IsGlobalScopeAugmentation(decl) {
			return nil
		}
		return decl.AsModuleDeclaration()
	})
	var augmentationExportCount int
	for _, decl := range moduleAugmentations {
		augmentationExportCount += len(decl.Symbol.Exports)
	}
	moduleID := e.getModuleID(file)
	exports := make([]*Export, 0, len(file.Symbol.Exports)+augmentationExportCount)
	for name, symbol := range file.Symbol.Exports {
		e.extractFromSymbol(name, symbol, moduleID, file.FileName(), file, &exports)
	}
	for _, decl := range moduleAugmentations {
		name := decl.Name().AsStringLiteral().Text
		moduleID := ModuleID(name)
		var moduleFileName string
		if tspath.IsExternalModuleNameRelative(name) {
			if resolved, _ := e.moduleResolver.ResolveModuleName(name, file.FileName(), core.ModuleKindCommonJS, nil); resolved.IsResolved() {
				moduleFileName = resolved.ResolvedFileName
				moduleID = ModuleID(e.toPath(moduleFileName))
			} else {
				// :shrug:
				moduleFileName = tspath.ResolvePath(tspath.GetDirectoryPath(file.FileName()), name)
				moduleID = ModuleID(e.toPath(moduleFileName))
			}
		}
		e.extractFromModuleDeclaration(decl, file, moduleID, moduleFileName, &exports)
	}
	return exports
}

func (e *exportExtractor) extractFromModuleDeclaration(decl *ast.ModuleDeclaration, file *ast.SourceFile, moduleID ModuleID, moduleFileName string, exports *[]*Export) {
	for name, symbol := range decl.Symbol.Exports {
		e.extractFromSymbol(name, symbol, moduleID, moduleFileName, file, exports)
	}
}

func (e *symbolExtractor) extractFromSymbol(name string, symbol *ast.Symbol, moduleID ModuleID, moduleFileName string, file *ast.SourceFile, exports *[]*Export) {
	if shouldIgnoreSymbol(symbol) {
		return
	}

	if name == ast.InternalSymbolNameExportStar {
		checkerLease := &checkerLease{checker: e.checker}
		allExports := e.checker.GetExportsOfModule(symbol.Parent)
		// allExports includes named exports from the file that will be processed separately;
		// we want to add only the ones that come from the star
		for name, namedExport := range symbol.Parent.Exports {
			if name != ast.InternalSymbolNameExportStar {
				idx := slices.Index(allExports, namedExport)
				if idx >= 0 || shouldIgnoreSymbol(namedExport) {
					allExports = slices.Delete(allExports, idx, idx+1)
				}
			}
		}

		*exports = slices.Grow(*exports, len(allExports))
		for _, reexportedSymbol := range allExports {
			export, _ := e.createExport(reexportedSymbol, moduleID, moduleFileName, ExportSyntaxStar, file, checkerLease)
			if export != nil {
				parent := checkerLease.GetChecker().GetMergedSymbol(reexportedSymbol.Parent)
				if parent != nil && parent.IsExternalModule() {
					if targetModuleID, ok := e.getModuleIDForSymbol(parent); ok {
						export.Target = ExportID{
							ExportName: reexportedSymbol.Name,
							ModuleID:   targetModuleID,
						}
					}
				}
				export.through = ast.InternalSymbolNameExportStar
				*exports = append(*exports, export)
			}
		}
		return
	}

	syntax := getSyntax(symbol)
	checkerLease := &checkerLease{checker: e.checker}
	export, target := e.createExport(symbol, moduleID, moduleFileName, syntax, file, checkerLease)
	if export == nil {
		return
	}

	*exports = append(*exports, export)

	if target != nil {
		if syntax == ExportSyntaxEquals && target.Flags&ast.SymbolFlagsNamespace != 0 {
			*exports = slices.Grow(*exports, len(target.Exports))
			for innerName, namedExport := range target.Exports {
				if innerName != ast.InternalSymbolNameExportStar {
					export, _ := e.createExport(namedExport, moduleID, moduleFileName, syntax, file, checkerLease)
					if export != nil {
						export.through = name
						*exports = append(*exports, export)
					}
				}
			}
		}
	} else if syntax == ExportSyntaxCommonJSModuleExports {
		expression := symbol.Declarations[0].AsBinaryExpression().Right
		if expression.Kind == ast.KindObjectLiteralExpression {
			// what is actually desirable here? I think it would be reasonable to only treat these as exports
			// if *every* property is a shorthand property or identifier: identifier
			// At least, it would be sketchy if there were any methods, computed properties...
			*exports = slices.Grow(*exports, len(expression.AsObjectLiteralExpression().Properties.Nodes))
			for _, prop := range expression.AsObjectLiteralExpression().Properties.Nodes {
				if ast.IsShorthandPropertyAssignment(prop) || ast.IsPropertyAssignment(prop) && prop.AsPropertyAssignment().Name().Kind == ast.KindIdentifier {
					export, _ := e.createExport(expression.Symbol().Members[prop.Name().Text()], moduleID, moduleFileName, syntax, file, checkerLease)
					if export != nil {
						export.through = name
						*exports = append(*exports, export)
					}
				}
			}
		}
	}
}

// createExport creates an Export for the given symbol, returning the Export and the target symbol if the export is an alias.
func (e *symbolExtractor) createExport(symbol *ast.Symbol, moduleID ModuleID, moduleFileName string, syntax ExportSyntax, file *ast.SourceFile, checkerLease *checkerLease) (*Export, *ast.Symbol) {
	if shouldIgnoreSymbol(symbol) {
		return nil, nil
	}

	export := &Export{
		ExportID: ExportID{
			ExportName: symbol.Name,
			ModuleID:   moduleID,
		},
		ModuleFileName: moduleFileName,
		Syntax:         syntax,
		Flags:          symbol.CombinedLocalAndExportSymbolFlags(),
		Path:           file.Path(),
		PackageName:    e.packageName,
	}

	if syntax == ExportSyntaxUMD {
		export.ExportName = ast.InternalSymbolNameExportEquals
		export.localName = symbol.Name
	}

	var targetSymbol *ast.Symbol
	if symbol.Flags&ast.SymbolFlagsAlias != 0 {
		targetSymbol = e.tryResolveSymbol(symbol, syntax, checkerLease)
		if targetSymbol != nil {
			var decl *ast.Node
			if len(targetSymbol.Declarations) > 0 {
				decl = targetSymbol.Declarations[0]
			} else if targetSymbol.CheckFlags&ast.CheckFlagsMapped != 0 {
				if mappedDecl := checkerLease.GetChecker().GetMappedTypeSymbolOfProperty(targetSymbol); mappedDecl != nil && len(mappedDecl.Declarations) > 0 {
					decl = mappedDecl.Declarations[0]
				}
			}
			if decl == nil {
				// !!! consider GetImmediateAliasedSymbol to go as far as we can
				decl = symbol.Declarations[0]
			}
			if decl == nil {
				panic("no declaration for aliased symbol")
			}

			parent := targetSymbol.Parent
			if checker := checkerLease.TryChecker(); checker != nil {
				export.Flags = checker.GetSymbolFlags(targetSymbol)
				export.IsTypeOnly = checker.GetTypeOnlyAliasDeclaration(symbol) != nil
				parent = checker.GetMergedSymbol(parent)
			} else {
				export.Flags = targetSymbol.Flags
				export.IsTypeOnly = core.Some(symbol.Declarations, ast.IsPartOfTypeOnlyImportOrExportDeclaration)
			}
			export.ScriptElementKind = lsutil.GetSymbolKind(checkerLease.TryChecker(), targetSymbol, decl)
			export.ScriptElementKindModifiers = lsutil.GetSymbolModifiers(checkerLease.TryChecker(), targetSymbol)
			targetModuleID := ModuleID(ast.GetSourceFileOfNode(decl).Path())
			if parent != nil && parent.IsExternalModule() {
				if id, ok := e.getModuleIDForSymbol(parent); ok {
					targetModuleID = id
				}
			}
			export.Target = ExportID{
				ExportName: targetSymbol.Name,
				ModuleID:   targetModuleID,
			}
		}
	} else {
		export.ScriptElementKind = lsutil.GetSymbolKind(checkerLease.TryChecker(), symbol, symbol.Declarations[0])
		export.ScriptElementKindModifiers = lsutil.GetSymbolModifiers(checkerLease.TryChecker(), symbol)
	}

	if symbol.Name == ast.InternalSymbolNameDefault || symbol.Name == ast.InternalSymbolNameExportEquals {
		namedSymbol := symbol
		if s := binder.GetLocalSymbolForExportDefault(symbol); s != nil {
			namedSymbol = s
		}
		export.localName = getDefaultLikeExportNameFromDeclaration(namedSymbol)
		if isUnusableName(export.localName) {
			export.localName = export.Target.ExportName
		}
		if isUnusableName(export.localName) {
			if targetSymbol != nil {
				namedSymbol = targetSymbol
				if s := binder.GetLocalSymbolForExportDefault(targetSymbol); s != nil {
					namedSymbol = s
				}
				export.localName = getDefaultLikeExportNameFromDeclaration(namedSymbol)
			}
		}
		if isUnusableName(export.localName) {
			// Last resort: derive identifier from the file name. Use FileName() (original
			// casing) rather than ModuleID/Path() which is lowercased on case-insensitive
			// file systems, losing PascalCase.
			export.localName = lsutil.ModuleSpecifierToValidIdentifier(fileNameForDefaultExportName(targetSymbol, moduleFileName, moduleID), false)
		}
	}

	if isUnusableName(export.Name()) {
		return nil, nil
	}

	e.stats.exports.Add(1)
	if checkerLease.TryChecker() != nil {
		e.stats.usedChecker.Add(1)
	}

	return export, targetSymbol
}

func (e *symbolExtractor) tryResolveSymbol(symbol *ast.Symbol, syntax ExportSyntax, checkerLease *checkerLease) *ast.Symbol {
	if !ast.IsNonLocalAlias(symbol, ast.SymbolFlagsNone) {
		return symbol
	}

	var loc *ast.Node
	var name string
	switch syntax {
	case ExportSyntaxNamed:
		decl := ast.GetDeclarationOfKind(symbol, ast.KindExportSpecifier)
		if decl.Parent.Parent.AsExportDeclaration().ModuleSpecifier == nil {
			if n := core.FirstNonZero(decl.Name(), decl.PropertyName()); n.Kind == ast.KindIdentifier {
				loc = n
				name = n.Text()
			}
		}
	// !!! check if module.exports = foo is marked as an alias
	case ExportSyntaxEquals:
		if symbol.Name != ast.InternalSymbolNameExportEquals {
			break
		}
		fallthrough
	case ExportSyntaxDefaultDeclaration:
		decl := ast.GetDeclarationOfKind(symbol, ast.KindExportAssignment)
		if decl.Expression().Kind == ast.KindIdentifier {
			loc = decl.Expression()
			name = loc.Text()
		}
	}

	if loc != nil {
		local := e.localNameResolver.Resolve(loc, name, ast.SymbolFlagsAll, nil, false, false)
		if local != nil && !ast.IsNonLocalAlias(local, ast.SymbolFlagsNone) {
			return local
		}
	}

	checker := checkerLease.GetChecker()
	if resolved := checker.GetAliasedSymbol(symbol); !checker.IsUnknownSymbol(resolved) {
		return resolved
	}
	return nil
}

func shouldIgnoreSymbol(symbol *ast.Symbol) bool {
	if symbol.Flags&ast.SymbolFlagsPrototype != 0 {
		return true
	}
	return false
}

func getSyntax(symbol *ast.Symbol) ExportSyntax {
	for _, decl := range symbol.Declarations {
		switch decl.Kind {
		case ast.KindExportSpecifier:
			return ExportSyntaxNamed
		case ast.KindExportAssignment:
			return core.IfElse(
				decl.AsExportAssignment().IsExportEquals,
				ExportSyntaxEquals,
				ExportSyntaxDefaultDeclaration,
			)
		case ast.KindNamespaceExportDeclaration:
			return ExportSyntaxUMD
		case ast.KindBinaryExpression:
			switch ast.GetAssignmentDeclarationKind(decl) {
			case ast.JSDeclarationKindModuleExports:
				return ExportSyntaxCommonJSModuleExports
			case ast.JSDeclarationKindExportsProperty:
				return ExportSyntaxCommonJSExportsProperty
			}
		default:
			if ast.GetCombinedModifierFlags(decl)&ast.ModifierFlagsDefault != 0 {
				return ExportSyntaxDefaultModifier
			} else {
				return ExportSyntaxModifier
			}
		}
	}
	return ExportSyntaxNone
}

func isUnusableName(name string) bool {
	return name == "" ||
		name == "_default" ||
		name == ast.InternalSymbolNameExportStar ||
		name == ast.InternalSymbolNameDefault ||
		name == ast.InternalSymbolNameExportEquals
}

// fileNameForDefaultExportName returns the best file name to use when deriving
// a fallback identifier for a default-like export. It prefers the target symbol's
// source file (closest to the export origin), falls back to the module's original
// file name, and uses the lowercased moduleID only for ambient modules where no
// original file name is available.
func fileNameForDefaultExportName(targetSymbol *ast.Symbol, moduleFileName string, moduleID ModuleID) string {
	if targetSymbol != nil && len(targetSymbol.Declarations) > 0 {
		if fn := ast.GetSourceFileOfNode(targetSymbol.Declarations[0]).FileName(); fn != "" {
			return fn
		}
	}
	if moduleFileName != "" {
		return moduleFileName
	}
	return string(moduleID)
}
