// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package cache

import (
	"context"
	"log"
	"maps"
	"slices"
	"strings"

	"golang.org/x/tools/gopls/internal/cache/metadata"
	"golang.org/x/tools/gopls/internal/cache/symbols"
	"golang.org/x/tools/gopls/internal/protocol"
	"golang.org/x/tools/gopls/internal/util/moremaps"
	"golang.org/x/tools/internal/imports"
)

// goplsSource is an imports.Source that provides import information using
// gopls and the module cache index.
type goplsSource struct {
	snapshot  *Snapshot
	envSource *imports.ProcessEnvSource

	// set by each invocation of ResolveReferences
	ctx context.Context
}

func (s *Snapshot) NewGoplsSource(is *imports.ProcessEnvSource) *goplsSource {
	return &goplsSource{
		snapshot:  s,
		envSource: is,
	}
}

func (s *goplsSource) LoadPackageNames(ctx context.Context, srcDir string, paths []imports.ImportPath) (map[imports.ImportPath]imports.PackageName, error) {
	// TODO: use metadata graph. Aside from debugging, this is the only used of envSource
	return s.envSource.LoadPackageNames(ctx, srcDir, paths)
}

type result struct {
	res        *imports.Result
	deprecated bool
}

// ResolveReferences tries to find resolving imports in the workspace, and failing
// that, in the module cache. It uses heuristics to decide among alternatives.
// The heuristics will usually prefer a v2 version, if there is one.
// TODO: It does not take advantage of hints provided by the user:
// 1. syntactic context: pkg.Name().Foo
// 3. already imported files in the same module
func (s *goplsSource) ResolveReferences(ctx context.Context, filename string, missing imports.References) ([]*imports.Result, error) {
	s.ctx = ctx
	// get results from the workspace. There will at most one for each package name
	fromWS, err := s.resolveWorkspaceReferences(filename, missing)
	if err != nil {
		return nil, err
	}
	// collect the ones that are still
	needed := maps.Clone(missing)
	for _, a := range fromWS {
		delete(needed, a.Package.Name)
	}
	// when debug (below) is gone, change this to: if len(needed) == 0 {return fromWS, nil}
	var fromCache []*result
	if len(needed) != 0 {
		var err error
		fromCache, err = s.resolveCacheReferences(needed)
		if err != nil {
			return nil, err
		}
		// trim candidates to one per missing package.
		byPkgNm := make(map[string][]*result)
		for _, c := range fromCache {
			// avoid internal and vendor
			if !imports.CanUse(filename, c.res.Import.ImportPath) {
				continue
			}
			byPkgNm[c.res.Package.Name] = append(byPkgNm[c.res.Package.Name], c)
		}
		for k, v := range byPkgNm {
			fromWS = append(fromWS, s.bestCache(k, v))
		}
	}
	const debug = false
	if debug { // debugging.
		// what does the old one find?
		old, err := s.envSource.ResolveReferences(ctx, filename, missing)
		if err != nil {
			log.Fatal(err)
		}
		log.Printf("fromCache:%d %s", len(fromCache), filename)
		for i, c := range fromCache {
			log.Printf("cans%d %#v %#v %v", i, c.res.Import, c.res.Package, c.deprecated)
		}
		for k, v := range missing {
			for x := range v {
				log.Printf("missing %s.%s", k, x)
			}
		}
		for k, v := range needed {
			for x := range v {
				log.Printf("needed %s.%s", k, x)
			}
		}

		dbgpr := func(hdr string, v []*imports.Result) {
			for i := range v {
				log.Printf("%s%d %+v %+v", hdr, i, v[i].Import, v[i].Package)
			}
		}

		dbgpr("fromWS", fromWS)
		dbgpr("old", old)
		for k, v := range s.snapshot.workspacePackages.All() {
			log.Printf("workspacePackages[%s]=%s", k, v)
		}
		// anything in ans with >1 matches?
		seen := make(map[string]int)
		for _, a := range fromWS {
			seen[a.Package.Name]++
		}
		for k, v := range seen {
			if v > 1 {
				log.Printf("saw %d %s", v, k)
				for i, x := range fromWS {
					if x.Package.Name == k {
						log.Printf("%d: %+v %+v", i, x.Package, x.Import)
					}
				}
			}
		}
	}
	return fromWS, nil

}

func (s *goplsSource) resolveCacheReferences(missing imports.References) ([]*result, error) {
	ix, err := s.snapshot.view.ModcacheIndex()
	if err != nil {
		return nil, err
	}

	found := make(map[string]*result)
	for pkgName, nameSet := range missing {
		names := moremaps.KeySlice(nameSet)
		for importPath, cands := range ix.LookupAll(pkgName, names...) {
			res := found[importPath]
			if res == nil {
				res = &result{
					res: &imports.Result{
						Import: &imports.ImportInfo{
							ImportPath: importPath,
						},
						Package: &imports.PackageInfo{
							Name:    pkgName,
							Exports: make(map[string]bool)},
					},
					deprecated: false,
				}
				found[importPath] = res
			}
			for _, c := range cands {
				res.res.Package.Exports[c.Name] = true
				// The import path is deprecated if a symbol that would be used is deprecated
				res.deprecated = res.deprecated || c.Deprecated
			}
		}

	}
	// return results in some deterministic order
	got := moremaps.ValueSlice(found)
	slices.SortFunc(got, func(a, b *result) int {
		return strings.Compare(a.res.Import.ImportPath, b.res.Import.ImportPath)
	})
	return got, nil
}

type found struct {
	sym *symbols.Package
	res *imports.Result
}

func (s *goplsSource) resolveWorkspaceReferences(filename string, missing imports.References) ([]*imports.Result, error) {
	uri := protocol.URIFromPath(filename)
	mypkgs, err := s.snapshot.MetadataForFile(s.ctx, uri, false)
	if err != nil {
		return nil, err
	}
	if len(mypkgs) == 0 {
		return nil, nil
	}
	mypkg := mypkgs[0] // narrowest package
	// search the metadata graph for package ids corresponding to missing
	g := s.snapshot.MetadataGraph()
	var ids []metadata.PackageID
	var pkgs []*metadata.Package
	for pid, pkg := range g.Packages {
		// no test packages, except perhaps for ourselves
		if pkg.ForTest != "" && pkg != mypkg {
			continue
		}
		if missingWants(missing, pkg.Name) {
			ids = append(ids, pid)
			pkgs = append(pkgs, pkg)
		}
	}
	// find the symbols in those packages
	// the syms occur in the same order as the ids and the pkgs
	syms, err := s.snapshot.Symbols(s.ctx, ids...)
	if err != nil {
		return nil, err
	}
	// keep track of used syms and found results by package name
	// TODO: avoid import cycles (is current package in forward closure)
	founds := make(map[string][]found)
	for i := range len(ids) {
		nm := string(pkgs[i].Name)
		if satisfies(syms[i], missing[nm]) {
			got := &imports.Result{
				Import: &imports.ImportInfo{
					Name:       "",
					ImportPath: string(pkgs[i].PkgPath),
				},
				Package: &imports.PackageInfo{
					Name:    string(pkgs[i].Name),
					Exports: missing[imports.PackageName(pkgs[i].Name)],
				},
			}
			founds[nm] = append(founds[nm], found{syms[i], got})
		}
	}
	var ans []*imports.Result
	for _, v := range founds {
		// make sure the elements of v are unique
		// (Import.ImportPath or Package.Name must differ)
		cmp := func(l, r found) int {
			switch strings.Compare(l.res.Import.ImportPath, r.res.Import.ImportPath) {
			case -1:
				return -1
			case 1:
				return 1
			}
			return strings.Compare(l.res.Package.Name, r.res.Package.Name)
		}
		slices.SortFunc(v, cmp)
		newv := make([]found, 0, len(v))
		newv = append(newv, v[0])
		for i := 1; i < len(v); i++ {
			if cmp(v[i], v[i-1]) != 0 {
				newv = append(newv, v[i])
			}
		}
		ans = append(ans, bestImport(filename, newv))
	}
	return ans, nil
}

// for each package name, choose one using heuristics
func bestImport(filename string, got []found) *imports.Result {
	if len(got) == 1 {
		return got[0].res
	}
	isTestFile := strings.HasSuffix(filename, "_test.go")
	var leftovers []found
	for _, g := range got {
		// don't use _test packages unless isTestFile
		testPkg := strings.HasSuffix(string(g.res.Package.Name), "_test") || strings.HasSuffix(string(g.res.Import.Name), "_test")
		if testPkg && !isTestFile {
			continue // no test covers this
		}
		if imports.CanUse(filename, g.sym.Files[0].DirPath()) {
			leftovers = append(leftovers, g)
		}
	}
	switch len(leftovers) {
	case 0:
		break // use got, they are all bad
	case 1:
		return leftovers[0].res // only one left
	default:
		got = leftovers // filtered some out
	}

	// TODO: if there are versions (like /v2) prefer them

	// use distance to common ancestor with filename
	// (TestDirectoryFilters_MultiRootImportScanning)
	// filename is .../a/main.go, choices are
	// .../a/hi/hi.go and .../b/hi/hi.go
	longest := -1
	ix := -1
	for i := 0; i < len(got); i++ {
		d := commonpref(filename, got[i].sym.Files[0].Path())
		if d > longest {
			longest = d
			ix = i
		}
	}
	// it is possible that there were several tied, but we return the first
	return got[ix].res
}

// choose the best result for the package named nm from the module cache
func (s *goplsSource) bestCache(nm string, got []*result) *imports.Result {
	if len(got) == 1 {
		return got[0].res
	}
	// does the go.mod file choose one?
	if ans := s.fromGoMod(got); ans != nil {
		return ans
	}
	got = preferUndeprecated(got)
	// want the best Import.ImportPath
	// these are all for the package named nm,
	// nm (probably) occurs in all the paths;
	// choose the longest (after nm), so as to get /v2
	maxlen, which := -1, -1
	for i := 0; i < len(got); i++ {
		ix := strings.Index(got[i].res.Import.ImportPath, nm)
		if ix == -1 {
			continue // now what?
		}
		cnt := len(got[i].res.Import.ImportPath) - ix
		if cnt > maxlen {
			maxlen = cnt
			which = i
		}
		// what about ties? (e.g., /v2 and /v3)
	}
	if which >= 0 {
		return got[which].res
	}
	return got[0].res // arbitrary guess
}

// if go.mod requires one of the packages, return that
func (s *goplsSource) fromGoMod(got []*result) *imports.Result {
	// should we use s.S.view.worsspaceModFiles, and the union of their requires?
	// (note that there are no tests where it contains more than one)
	modURI := s.snapshot.view.gomod
	modfh, ok := s.snapshot.files.get(modURI)
	if !ok {
		return nil
	}
	parsed, err := s.snapshot.ParseMod(s.ctx, modfh)
	if err != nil {
		return nil
	}
	reqs := parsed.File.Require
	for _, g := range got {
		for _, req := range reqs {
			if strings.HasPrefix(g.res.Import.ImportPath, req.Syntax.Token[1]) {
				return g.res
			}
		}
	}
	return nil
}

func commonpref(filename string, path string) int {
	k := 0
	for ; k < len(filename) && k < len(path) && filename[k] == path[k]; k++ {
	}
	return k
}

func satisfies(pkg *symbols.Package, missing map[string]bool) bool {
	syms := make(map[string]bool)
	for _, x := range pkg.Symbols {
		for _, s := range x {
			syms[s.Name] = true
		}
	}
	for k := range missing {
		if !syms[k] {
			return false
		}
	}
	return true
}

// does pkgPath potentially satisfy a missing reference?
func missingWants(missing imports.References, pkgPath metadata.PackageName) bool {
	for k := range missing {
		if string(k) == string(pkgPath) {
			return true
		}
	}
	return false
}

// If there are both deprecated and undprecated ones
// then return only the undeprecated one
func preferUndeprecated(got []*result) []*result {
	var ok []*result
	for _, g := range got {
		if !g.deprecated {
			ok = append(ok, g)
		}
	}
	if len(ok) > 0 {
		return ok
	}
	return got
}
