package autoimport

import (
	"context"
	"slices"
	"strings"
	"unicode"

	"github.com/microsoft/typescript-go/internal/ast"
	"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/ls/lsutil"
	"github.com/microsoft/typescript-go/internal/lsp/lsproto"
	"github.com/microsoft/typescript-go/internal/module"
	"github.com/microsoft/typescript-go/internal/modulespecifiers"
	"github.com/microsoft/typescript-go/internal/scanner"
	"github.com/microsoft/typescript-go/internal/tspath"
)

type View struct {
	registry      *Registry
	importingFile *ast.SourceFile
	program       *compiler.Program
	preferences   modulespecifiers.UserPreferences
	projectKey    tspath.Path

	allowedEndings                   []modulespecifiers.ModuleSpecifierEnding
	conditions                       *collections.Set[string]
	shouldUseUriStyleNodeCoreModules core.Tristate
	existingImports                  *collections.MultiMap[ModuleID, existingImport]
	shouldUseRequireForFixes         *bool
}

func NewView(registry *Registry, importingFile *ast.SourceFile, projectKey tspath.Path, program *compiler.Program, preferences modulespecifiers.UserPreferences) *View {
	return &View{
		registry:      registry,
		importingFile: importingFile,
		program:       program,
		projectKey:    projectKey,
		preferences:   preferences,
		conditions: collections.NewSetFromItems(
			module.GetConditions(program.Options(),
				program.GetDefaultResolutionModeForFile(importingFile))...,
		),
		shouldUseUriStyleNodeCoreModules: lsutil.ShouldUseUriStyleNodeCoreModules(importingFile, program),
	}
}

func (v *View) getAllowedEndings() []modulespecifiers.ModuleSpecifierEnding {
	if v.allowedEndings == nil {
		resolutionMode := v.program.GetDefaultResolutionModeForFile(v.importingFile)
		v.allowedEndings = modulespecifiers.GetAllowedEndingsInPreferredOrder(
			v.preferences,
			v.program,
			v.program.Options(),
			v.importingFile,
			"",
			resolutionMode,
		)
	}
	return v.allowedEndings
}

type QueryKind int

const (
	QueryKindWordPrefix QueryKind = iota
	QueryKindExactMatch
	QueryKindCaseInsensitiveMatch
)

func (v *View) Search(query string, kind QueryKind) []*Export {
	searchFn := func(bucket *RegistryBucket) []*Export {
		switch kind {
		case QueryKindWordPrefix:
			return bucket.Index.SearchWordPrefix(query)
		case QueryKindExactMatch:
			return bucket.Index.Find(query, true)
		case QueryKindCaseInsensitiveMatch:
			return bucket.Index.Find(query, false)
		default:
			panic("unreachable")
		}
	}

	return v.search(searchFn)
}

func (v *View) SearchByExportID(id ExportID) []*Export {
	search := func(bucket *RegistryBucket) []*Export {
		return core.Filter(bucket.Index.entries, func(e *Export) bool {
			return e.ExportID == id
		})
	}

	return v.search(search)
}

func (v *View) search(searchFn func(*RegistryBucket) []*Export) []*Export {
	var results []*Export

	if bucket, ok := v.registry.projects[v.projectKey]; ok {
		exports := searchFn(bucket)
		results = slices.Grow(results, len(exports))
		for _, e := range exports {
			if string(e.ModuleID) == string(v.importingFile.Path()) {
				// Don't auto-import from the importing file itself
				continue
			}
			results = append(results, e)
		}
	}

	// Compute the set of packages accessible to the importing file.
	// This includes packages from package.json dependencies (aggregated from ancestor directories)
	// plus packages that are directly imported by the project's program files.
	// If no package.json is found, allowedPackages remains nil and all packages are allowed.
	var allowedPackages *collections.Set[string]
	tspath.ForEachAncestorDirectoryPath(v.importingFile.Path().GetDirectoryPath(), func(dirPath tspath.Path) (result any, stop bool) {
		if dir, ok := v.registry.directories[dirPath]; ok {
			if pj := dir.packageJson; pj.Exists() && pj.Contents.Parseable {
				// Initialize to empty set if this is the first package.json we've seen
				if allowedPackages == nil {
					allowedPackages = &collections.Set[string]{}
				}
				addPackageJsonDependencies(pj.Contents, allowedPackages)
			}
		}
		return nil, false
	})
	// If we found at least one package.json, also include packages directly imported by the project
	if allowedPackages != nil {
		if bucket, ok := v.registry.projects[v.projectKey]; ok {
			allowedPackages = allowedPackages.UnionedWith(bucket.ResolvedPackageNames)
		}
	}

	excludePackages := &collections.Set[string]{}
	tspath.ForEachAncestorDirectoryPath(v.importingFile.Path().GetDirectoryPath(), func(dirPath tspath.Path) (result any, stop bool) {
		if nodeModulesBucket, ok := v.registry.nodeModules[dirPath]; ok {
			exports := searchFn(nodeModulesBucket)
			results = slices.Grow(results, len(exports))
			for _, e := range exports {
				// Exclude packages found in lower node_modules (shadowing)
				if excludePackages.Has(e.PackageName) {
					continue
				}
				// If allowedPackages is nil, no package.json was found, so include all packages.
				// Otherwise, only include packages that are dependencies or directly imported.
				if allowedPackages != nil && !allowedPackages.Has(e.PackageName) {
					continue
				}
				results = append(results, e)
			}

			// As we go up the directory tree, exclude packages found in lower node_modules
			for pkgName := range nodeModulesBucket.PackageFiles {
				excludePackages.Add(pkgName)
			}
		}
		return nil, false
	})
	return results
}

type FixAndExport struct {
	Fix    *Fix
	Export *Export
}

func (v *View) GetCompletions(ctx context.Context, prefix string, position lsproto.Position, forJSX bool, isTypeOnlyLocation bool) []*FixAndExport {
	results := v.Search(prefix, QueryKindWordPrefix)

	type exportGroupKey struct {
		target                     ExportID
		name                       string
		ambientModuleOrPackageName string
	}
	grouped := make(map[exportGroupKey][]*Export, len(results))
outer:
	for _, e := range results {
		name := e.Name()
		if !scanner.IsIdentifierText(name, core.LanguageVariantStandard) {
			continue
		}
		if forJSX && !(unicode.IsUpper(rune(name[0])) || e.IsRenameable()) {
			continue
		}
		target := e.ExportID
		if e.Target != (ExportID{}) {
			target = e.Target
		}
		key := exportGroupKey{
			target:                     target,
			name:                       name,
			ambientModuleOrPackageName: core.FirstNonZero(e.AmbientModuleName(), e.PackageName),
		}
		if e.PackageName == "@types/node" || strings.Contains(string(e.Path), "/node_modules/@types/node/") {
			if _, ok := core.UnprefixedNodeCoreModules[key.ambientModuleOrPackageName]; ok {
				// Group URI-style and non-URI style node core modules together so the ranking logic
				// is allowed to drop one if an explicit preference is detected.
				key.ambientModuleOrPackageName = "node:" + key.ambientModuleOrPackageName
			}
		}
		if existing, ok := grouped[key]; ok {
			for i, ex := range existing {
				if e.ExportID == ex.ExportID {
					grouped[key] = slices.Replace(existing, i, i+1, &Export{
						ExportID:                   e.ExportID,
						ModuleFileName:             e.ModuleFileName,
						PackageName:                e.PackageName,
						IsTypeOnly:                 e.IsTypeOnly || ex.IsTypeOnly,
						Syntax:                     min(e.Syntax, ex.Syntax),
						Flags:                      e.Flags | ex.Flags,
						ScriptElementKind:          min(e.ScriptElementKind, ex.ScriptElementKind),
						ScriptElementKindModifiers: e.ScriptElementKindModifiers | ex.ScriptElementKindModifiers,
						localName:                  e.localName,
						Target:                     e.Target,
						Path:                       e.Path,
					})
					continue outer
				}
			}
		}
		grouped[key] = append(grouped[key], e)
	}

	fixes := make([]*FixAndExport, 0, len(results))
	compareFixes := func(a, b *FixAndExport) int {
		return v.CompareFixesForRanking(a.Fix, b.Fix)
	}

	for _, exps := range grouped {
		fixesForGroup := make([]*FixAndExport, 0, len(exps))
		for _, e := range exps {
			for _, fix := range v.GetFixes(ctx, e, forJSX, isTypeOnlyLocation, &position) {
				fixesForGroup = append(fixesForGroup, &FixAndExport{
					Fix:    fix,
					Export: e,
				})
			}
		}
		fixes = append(fixes, core.MinAllFunc(fixesForGroup, compareFixes)...)
	}

	// The client will do additional sorting by SortText and Label, so we don't
	// need to consider the name in our sorting here; we only need to produce a
	// stable relative ordering between completions that the client will consider
	// equivalent.
	slices.SortFunc(fixes, func(a, b *FixAndExport) int {
		return v.CompareFixesForSorting(a.Fix, b.Fix)
	})

	return fixes
}
