package ls

import (
	"context"
	"slices"
	"strings"

	"github.com/microsoft/typescript-go/internal/ast"
	"github.com/microsoft/typescript-go/internal/astnav"
	"github.com/microsoft/typescript-go/internal/checker"
	"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/locale"
	"github.com/microsoft/typescript-go/internal/ls/autoimport"
	"github.com/microsoft/typescript-go/internal/lsp/lsproto"
	"github.com/microsoft/typescript-go/internal/scanner"
	"github.com/microsoft/typescript-go/internal/tspath"
)

var importFixErrorCodes = []int32{
	diagnostics.Cannot_find_name_0.Code(),
	diagnostics.Cannot_find_name_0_Did_you_mean_1.Code(),
	diagnostics.Cannot_find_name_0_Did_you_mean_the_instance_member_this_0.Code(),
	diagnostics.Cannot_find_name_0_Did_you_mean_the_static_member_1_0.Code(),
	diagnostics.Cannot_find_namespace_0.Code(),
	diagnostics.X_0_refers_to_a_UMD_global_but_the_current_file_is_a_module_Consider_adding_an_import_instead.Code(),
	diagnostics.X_0_only_refers_to_a_type_but_is_being_used_as_a_value_here.Code(),
	diagnostics.No_value_exists_in_scope_for_the_shorthand_property_0_Either_declare_one_or_provide_an_initializer.Code(),
	diagnostics.X_0_cannot_be_used_as_a_value_because_it_was_imported_using_import_type.Code(),
	diagnostics.Cannot_find_name_0_Do_you_need_to_install_type_definitions_for_jQuery_Try_npm_i_save_dev_types_Slashjquery.Code(),
	diagnostics.Cannot_find_name_0_Do_you_need_to_change_your_target_library_Try_changing_the_lib_compiler_option_to_1_or_later.Code(),
	diagnostics.Cannot_find_name_0_Do_you_need_to_change_your_target_library_Try_changing_the_lib_compiler_option_to_include_dom.Code(),
	diagnostics.Cannot_find_name_0_Do_you_need_to_install_type_definitions_for_a_test_runner_Try_npm_i_save_dev_types_Slashjest_or_npm_i_save_dev_types_Slashmocha_and_then_add_jest_or_mocha_to_the_types_field_in_your_tsconfig.Code(),
	diagnostics.Cannot_find_name_0_Did_you_mean_to_write_this_in_an_async_function.Code(),
	diagnostics.Cannot_find_name_0_Do_you_need_to_install_type_definitions_for_jQuery_Try_npm_i_save_dev_types_Slashjquery_and_then_add_jquery_to_the_types_field_in_your_tsconfig.Code(),
	diagnostics.Cannot_find_name_0_Do_you_need_to_install_type_definitions_for_a_test_runner_Try_npm_i_save_dev_types_Slashjest_or_npm_i_save_dev_types_Slashmocha.Code(),
	diagnostics.Cannot_find_name_0_Do_you_need_to_install_type_definitions_for_node_Try_npm_i_save_dev_types_Slashnode.Code(),
	diagnostics.Cannot_find_name_0_Do_you_need_to_install_type_definitions_for_node_Try_npm_i_save_dev_types_Slashnode_and_then_add_node_to_the_types_field_in_your_tsconfig.Code(),
	diagnostics.Cannot_find_namespace_0_Did_you_mean_1.Code(),
	diagnostics.Cannot_extend_an_interface_0_Did_you_mean_implements.Code(),
	diagnostics.This_JSX_tag_requires_0_to_be_in_scope_but_it_could_not_be_found.Code(),
}

const (
	importFixID = "fixMissingImport"
)

// ImportFixProvider is the CodeFixProvider for import-related fixes
var ImportFixProvider = &CodeFixProvider{
	ErrorCodes:        importFixErrorCodes,
	GetCodeActions:    getImportCodeActions,
	FixIds:            []string{importFixID},
	GetAllCodeActions: getAllImportCodeActions,
}

type fixInfo struct {
	fix                 *autoimport.Fix
	symbolName          string
	errorIdentifierText string
	isJsxNamespaceFix   bool
}

func getImportCodeActions(ctx context.Context, fixContext *CodeFixContext) ([]*CodeAction, error) {
	info, err := getFixInfos(ctx, fixContext, fixContext.ErrorCode, fixContext.Span.Pos())
	if err != nil {
		return nil, err
	}
	if len(info) == 0 {
		return nil, nil
	}

	var actions []*CodeAction
	for _, fixInfo := range info {
		edits, description := fixInfo.fix.Edits(
			ctx,
			fixContext.SourceFile,
			fixContext.Program.Options(),
			fixContext.LS.FormatOptions(),
			fixContext.LS.converters,
			fixContext.LS.UserPreferences(),
		)

		actions = append(actions, &CodeAction{
			Description:       description,
			Changes:           edits,
			FixID:             importFixID,
			FixAllDescription: diagnostics.Add_all_missing_imports.Localize(locale.FromContext(ctx)),
		})
	}
	return actions, nil
}

func getAllImportCodeActions(ctx context.Context, fixContext *CodeFixContext) (*CombinedCodeActions, error) {
	if tspath.IsDynamicFileName(fixContext.SourceFile.FileName()) {
		return nil, nil
	}

	allDiagnostics := fixContext.Program.GetSemanticDiagnostics(ctx, fixContext.SourceFile)

	var importDiags []*ast.Diagnostic
	for _, diag := range allDiagnostics {
		if containsErrorCode(importFixErrorCodes, diag.Code()) {
			importDiags = append(importDiags, diag)
		}
	}

	if len(importDiags) == 0 {
		return nil, nil
	}

	view, err := fixContext.LS.getPreparedAutoImportView(fixContext.SourceFile)
	if err != nil {
		return nil, err
	}
	if view == nil {
		view = fixContext.LS.getCurrentAutoImportView(fixContext.SourceFile)
	}

	ch, done := fixContext.Program.GetTypeChecker(ctx)
	defer done()

	importAdder := autoimport.NewImportAdder(
		ctx,
		fixContext.Program,
		ch,
		fixContext.SourceFile,
		view,
		fixContext.LS.FormatOptions(),
		fixContext.LS.converters,
		fixContext.LS.UserPreferences(),
	)

	for _, diag := range importDiags {
		if err := addImportFromDiagnostic(ctx, importAdder, diag, fixContext); err != nil {
			return nil, err
		}
	}

	if !importAdder.HasFixes() {
		return nil, nil
	}

	return &CombinedCodeActions{
		Description: diagnostics.Add_all_missing_imports.Localize(locale.FromContext(ctx)),
		Changes:     importAdder.Edits(),
	}, nil
}

// addImportFromDiagnostic finds the best import fix for a diagnostic and adds it to the adder.
func addImportFromDiagnostic(ctx context.Context, importAdder autoimport.ImportAdder, diag *ast.Diagnostic, fixContext *CodeFixContext) error {
	diagFixContext := &CodeFixContext{
		SourceFile: fixContext.SourceFile,
		Span:       core.NewTextRange(diag.Pos(), diag.End()),
		ErrorCode:  diag.Code(),
		Program:    fixContext.Program,
		LS:         fixContext.LS,
	}

	infos, err := getFixInfos(ctx, diagFixContext, diag.Code(), diag.Pos())
	if err != nil {
		return err
	}
	if len(infos) > 0 {
		importAdder.AddImportFix(infos[0].fix)
	}
	return nil
}

func getFixInfos(ctx context.Context, fixContext *CodeFixContext, errorCode int32, pos int) ([]*fixInfo, error) {
	// Can't compute import fixes for dynamic/untitled files since they don't have real file paths
	if tspath.IsDynamicFileName(fixContext.SourceFile.FileName()) {
		return nil, nil
	}

	symbolToken := astnav.GetTokenAtPosition(fixContext.SourceFile, pos)

	var view *autoimport.View
	var info []*fixInfo

	if errorCode == diagnostics.X_0_refers_to_a_UMD_global_but_the_current_file_is_a_module_Consider_adding_an_import_instead.Code() {
		view = fixContext.LS.getCurrentAutoImportView(fixContext.SourceFile)
		info = getFixesInfoForUMDImport(ctx, fixContext, symbolToken, view)
	} else if !ast.IsIdentifier(symbolToken) {
		return nil, nil
	} else if errorCode == diagnostics.X_0_cannot_be_used_as_a_value_because_it_was_imported_using_import_type.Code() {
		ch, done := fixContext.Program.GetTypeChecker(ctx)
		defer done()
		compilerOptions := fixContext.Program.Options()
		symbolNames := getSymbolNamesToImport(fixContext.SourceFile, ch, symbolToken, compilerOptions)

		var allTypeOnlyFixes []*fixInfo
		for _, sn := range symbolNames {
			if !sn.isTypeOnly {
				continue
			}
			fix := getTypeOnlyPromotionFix(ctx, fixContext.SourceFile, symbolToken, sn.name, fixContext.Program)
			if fix != nil {
				allTypeOnlyFixes = append(allTypeOnlyFixes, &fixInfo{fix: fix, symbolName: sn.name, errorIdentifierText: symbolToken.Text()})
			}
		}

		// For JSX opening tags, there can be separate type-only errors for both the tag name
		// identifier and the JSX namespace identifier. When both produce valid fixes, we
		// disambiguate using the diagnostic message, which quotes the symbol name in single
		// quotes (e.g., "'React' cannot be used as a value..."). If filtering yields nothing
		// (e.g., due to localization), fall back to returning all candidates.
		diagnosticMessage := ""
		if fixContext.Diagnostic != nil {
			diagnosticMessage = fixContext.Diagnostic.Message.AsString()
		}
		if len(allTypeOnlyFixes) > 1 && diagnosticMessage != "" {
			for _, fi := range allTypeOnlyFixes {
				if strings.Contains(diagnosticMessage, "'"+fi.symbolName+"'") {
					info = append(info, fi)
				}
			}
		}
		if len(info) == 0 {
			info = allTypeOnlyFixes
		}
		return info, nil
	} else {
		var err error
		view, err = fixContext.LS.getPreparedAutoImportView(fixContext.SourceFile)
		if err != nil {
			return nil, err
		}
		if view != nil {
			info = getFixesInfoForNonUMDImport(ctx, fixContext, symbolToken, view)
		}
	}

	// Sort fixes by preference
	if view == nil {
		view = fixContext.LS.getCurrentAutoImportView(fixContext.SourceFile)
	}
	return sortFixInfo(info, fixContext, view), nil
}

func getFixesInfoForUMDImport(ctx context.Context, fixContext *CodeFixContext, token *ast.Node, view *autoimport.View) []*fixInfo {
	ch, done := fixContext.Program.GetTypeChecker(ctx)
	defer done()

	umdSymbol := getUmdSymbol(token, ch)
	if umdSymbol == nil {
		return nil
	}

	export := autoimport.SymbolToExport(umdSymbol, ch)
	isValidTypeOnlyUseSite := ast.IsValidTypeOnlyAliasUseSite(token)

	var result []*fixInfo
	for _, fix := range view.GetFixes(ctx, export, false, isValidTypeOnlyUseSite, nil) {
		errorIdentifierText := ""
		if ast.IsIdentifier(token) {
			errorIdentifierText = token.Text()
		}
		result = append(result, &fixInfo{
			fix:                 fix,
			symbolName:          umdSymbol.Name,
			errorIdentifierText: errorIdentifierText,
		})
	}
	return result
}

func getUmdSymbol(token *ast.Node, ch *checker.Checker) *ast.Symbol {
	// try the identifier to see if it is the umd symbol
	var umdSymbol *ast.Symbol
	if ast.IsIdentifier(token) {
		umdSymbol = ch.GetResolvedSymbol(token)
	}
	if isUMDExportSymbol(umdSymbol) {
		return umdSymbol
	}

	// The error wasn't for the symbolAtLocation, it was for the JSX tag itself, which needs access to e.g. `React`.
	parent := token.Parent
	if (ast.IsJsxOpeningLikeElement(parent) && parent.TagName() == token) ||
		ast.IsJsxOpeningFragment(parent) {
		var location *ast.Node
		if ast.IsJsxOpeningLikeElement(parent) {
			location = token
		} else {
			location = parent
		}
		jsxNamespace := ch.GetJsxNamespace(parent)
		parentSymbol := ch.ResolveName(jsxNamespace, location, ast.SymbolFlagsValue, false /* excludeGlobals */)
		if isUMDExportSymbol(parentSymbol) {
			return parentSymbol
		}
	}
	return nil
}

func isUMDExportSymbol(symbol *ast.Symbol) bool {
	return symbol != nil && len(symbol.Declarations) > 0 &&
		symbol.Declarations[0] != nil &&
		ast.IsNamespaceExportDeclaration(symbol.Declarations[0])
}

func getFixesInfoForNonUMDImport(ctx context.Context, fixContext *CodeFixContext, symbolToken *ast.Node, view *autoimport.View) []*fixInfo {
	ch, done := fixContext.Program.GetTypeChecker(ctx)
	defer done()
	compilerOptions := fixContext.Program.Options()

	isValidTypeOnlyUseSite := ast.IsValidTypeOnlyAliasUseSite(symbolToken)
	symbolNames := getSymbolNamesToImport(fixContext.SourceFile, ch, symbolToken, compilerOptions)
	var allInfo []*fixInfo

	// Compute usage position for JSDoc import type fixes
	usagePosition := fixContext.LS.converters.PositionToLineAndCharacter(fixContext.SourceFile, core.TextPos(scanner.GetTokenPosOfNode(symbolToken, fixContext.SourceFile, false)))

	for _, sn := range symbolNames {
		// Type-only imports are handled by the promotion code path, not the auto-import path.
		if sn.isTypeOnly {
			continue
		}

		symbolName := sn.name
		// "default" is a keyword and not a legal identifier for the import
		if symbolName == "default" {
			continue
		}

		isJSXTagName := symbolName == symbolToken.Text() && ast.IsJsxTagName(symbolToken)
		queryKind := autoimport.QueryKindExactMatch
		if isJSXTagName {
			queryKind = autoimport.QueryKindCaseInsensitiveMatch
		}

		exports := view.Search(symbolName, queryKind)
		for _, export := range exports {
			if isJSXTagName && !(export.Name() == symbolName || export.IsRenameable()) {
				continue
			}

			fixes := view.GetFixes(ctx, export, isJSXTagName, isValidTypeOnlyUseSite, &usagePosition)
			for _, fix := range fixes {
				allInfo = append(allInfo, &fixInfo{
					fix:               fix,
					symbolName:        symbolName,
					isJsxNamespaceFix: symbolName != symbolToken.Text(),
				})
			}
		}
	}

	return allInfo
}

func getTypeOnlyPromotionFix(ctx context.Context, sourceFile *ast.SourceFile, symbolToken *ast.Node, symbolName string, program *compiler.Program) *autoimport.Fix {
	ch, done := program.GetTypeChecker(ctx)
	defer done()

	// Get the symbol at the token location
	symbol := ch.ResolveName(symbolName, symbolToken, ast.SymbolFlagsValue, true /* excludeGlobals */)
	if symbol == nil {
		return nil
	}

	// Get the type-only alias declaration
	typeOnlyAliasDeclaration := ch.GetTypeOnlyAliasDeclaration(symbol)
	if typeOnlyAliasDeclaration == nil || ast.GetSourceFileOfNode(typeOnlyAliasDeclaration) != sourceFile {
		return nil
	}

	return &autoimport.Fix{
		AutoImportFix: &lsproto.AutoImportFix{
			Kind: lsproto.AutoImportFixKindPromoteTypeOnly,
		},
		TypeOnlyAliasDeclaration: typeOnlyAliasDeclaration,
	}
}

type symbolNameInfo struct {
	name       string
	isTypeOnly bool // whether the symbol currently resolves to a type-only import
}

func getSymbolNamesToImport(sourceFile *ast.SourceFile, ch *checker.Checker, symbolToken *ast.Node, compilerOptions *core.CompilerOptions) []symbolNameInfo {
	parent := symbolToken.Parent
	if (ast.IsJsxOpeningLikeElement(parent) || ast.IsJsxClosingElement(parent)) &&
		parent.TagName() == symbolToken &&
		jsxModeNeedsExplicitImport(compilerOptions.Jsx) {
		jsxNamespace := ch.GetJsxNamespace(sourceFile.AsNode())
		if needsJsxNamespaceFix(jsxNamespace, symbolToken, ch) {
			var result []symbolNameInfo
			if !scanner.IsIntrinsicJsxName(symbolToken.Text()) {
				compSymbol := ch.ResolveName(symbolToken.Text(), symbolToken, ast.SymbolFlagsValue, false /* excludeGlobals */)
				if compSymbol == nil {
					result = append(result, symbolNameInfo{name: symbolToken.Text()})
				} else if ch.GetTypeOnlyAliasDeclaration(compSymbol) != nil {
					result = append(result, symbolNameInfo{name: symbolToken.Text(), isTypeOnly: true})
				}
			}
			nsIsTypeOnly := false
			if nsSymbol := ch.ResolveName(jsxNamespace, symbolToken, ast.SymbolFlagsValue, true /* excludeGlobals */); nsSymbol != nil {
				nsIsTypeOnly = ch.GetTypeOnlyAliasDeclaration(nsSymbol) != nil
			}
			result = append(result, symbolNameInfo{name: jsxNamespace, isTypeOnly: nsIsTypeOnly})
			return result
		}
	}
	tokenIsTypeOnly := false
	if sym := ch.ResolveName(symbolToken.Text(), symbolToken, ast.SymbolFlagsValue, true /* excludeGlobals */); sym != nil {
		tokenIsTypeOnly = ch.GetTypeOnlyAliasDeclaration(sym) != nil
	}
	return []symbolNameInfo{{name: symbolToken.Text(), isTypeOnly: tokenIsTypeOnly}}
}

func needsJsxNamespaceFix(jsxNamespace string, symbolToken *ast.Node, ch *checker.Checker) bool {
	if scanner.IsIntrinsicJsxName(symbolToken.Text()) {
		return true
	}
	namespaceSymbol := ch.ResolveName(jsxNamespace, symbolToken, ast.SymbolFlagsValue, true /* excludeGlobals */)
	if namespaceSymbol == nil {
		return true
	}
	if slices.ContainsFunc(namespaceSymbol.Declarations, ast.IsTypeOnlyImportOrExportDeclaration) {
		return (namespaceSymbol.Flags & ast.SymbolFlagsValue) == 0
	}
	return false
}

func jsxModeNeedsExplicitImport(jsx core.JsxEmit) bool {
	return jsx == core.JsxEmitReact || jsx == core.JsxEmitReactNative
}

func sortFixInfo(fixes []*fixInfo, fixContext *CodeFixContext, view *autoimport.View) []*fixInfo {
	if len(fixes) == 0 {
		return fixes
	}

	// Create a copy to avoid modifying the original
	sorted := make([]*fixInfo, len(fixes))
	copy(sorted, fixes)

	// Sort by:
	// 1. JSX namespace fixes last
	// 2. Fix comparison using view.CompareFixes
	slices.SortFunc(sorted, func(a, b *fixInfo) int {
		// JSX namespace fixes should come last
		if cmp := core.CompareBooleans(a.isJsxNamespaceFix, b.isJsxNamespaceFix); cmp != 0 {
			return cmp
		}
		return view.CompareFixesForSorting(a.fix, b.fix)
	})

	return sorted
}
