package imports

import (
	"bytes"
	"encoding/json"
	"io/ioutil"
	"log"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/andeya/aster/internal/gopathwalk"
	"github.com/andeya/aster/internal/module"
)

// moduleResolver implements resolver for modules using the go command as little
// as feasible.
type moduleResolver struct {
	env *fixEnv

	initialized   bool
	main          *moduleJSON
	modsByModPath []*moduleJSON // All modules, ordered by # of path components in module Path...
	modsByDir     []*moduleJSON // ...or Dir.
}

type moduleJSON struct {
	Path     string           // module path
	Version  string           // module version
	Versions []string         // available module versions (with -versions)
	Replace  *moduleJSON      // replaced by this module
	Time     *time.Time       // time version was created
	Update   *moduleJSON      // available update, if any (with -u)
	Main     bool             // is this the main module?
	Indirect bool             // is this module only an indirect dependency of main module?
	Dir      string           // directory holding files for this module, if any
	GoMod    string           // path to go.mod file for this module, if any
	Error    *moduleErrorJSON // error loading module
}

type moduleErrorJSON struct {
	Err string // the error itself
}

func (r *moduleResolver) init() error {
	if r.initialized {
		return nil
	}
	stdout, err := r.env.invokeGo("list", "-m", "-json", "...")
	if err != nil {
		return err
	}
	for dec := json.NewDecoder(stdout); dec.More(); {
		mod := &moduleJSON{}
		if err := dec.Decode(mod); err != nil {
			return err
		}
		if mod.Dir == "" {
			if Debug {
				log.Printf("module %v has not been downloaded and will be ignored", mod.Path)
			}
			// Can't do anything with a module that's not downloaded.
			continue
		}
		r.modsByModPath = append(r.modsByModPath, mod)
		r.modsByDir = append(r.modsByDir, mod)
		if mod.Main {
			r.main = mod
		}
	}

	sort.Slice(r.modsByModPath, func(i, j int) bool {
		count := func(x int) int {
			return strings.Count(r.modsByModPath[x].Path, "/")
		}
		return count(j) < count(i) // descending order
	})
	sort.Slice(r.modsByDir, func(i, j int) bool {
		count := func(x int) int {
			return strings.Count(r.modsByDir[x].Dir, "/")
		}
		return count(j) < count(i) // descending order
	})

	r.initialized = true
	return nil
}

// findPackage returns the module and directory that contains the package at
// the given import path, or returns nil, "" if no module is in scope.
func (r *moduleResolver) findPackage(importPath string) (*moduleJSON, string) {
	for _, m := range r.modsByModPath {
		if !strings.HasPrefix(importPath, m.Path) {
			continue
		}
		pathInModule := importPath[len(m.Path):]
		pkgDir := filepath.Join(m.Dir, pathInModule)
		if dirIsNestedModule(pkgDir, m) {
			continue
		}

		pkgFiles, err := ioutil.ReadDir(pkgDir)
		if err != nil {
			continue
		}

		// A module only contains a package if it has buildable go
		// files in that directory. If not, it could be provided by an
		// outer module. See #29736.
		for _, fi := range pkgFiles {
			if ok, _ := r.env.buildContext().MatchFile(pkgDir, fi.Name()); ok {
				return m, pkgDir
			}
		}
	}
	return nil, ""
}

// findModuleByDir returns the module that contains dir, or nil if no such
// module is in scope.
func (r *moduleResolver) findModuleByDir(dir string) *moduleJSON {
	// This is quite tricky and may not be correct. dir could be:
	// - a package in the main module.
	// - a replace target underneath the main module's directory.
	//    - a nested module in the above.
	// - a replace target somewhere totally random.
	//    - a nested module in the above.
	// - in the mod cache.
	// - in /vendor/ in -mod=vendor mode.
	//    - nested module? Dunno.
	// Rumor has it that replace targets cannot contain other replace targets.
	for _, m := range r.modsByDir {
		if !strings.HasPrefix(dir, m.Dir) {
			continue
		}

		if dirIsNestedModule(dir, m) {
			continue
		}

		return m
	}
	return nil
}

// dirIsNestedModule reports if dir is contained in a nested module underneath
// mod, not actually in mod.
func dirIsNestedModule(dir string, mod *moduleJSON) bool {
	if !strings.HasPrefix(dir, mod.Dir) {
		return false
	}
	mf := findModFile(dir)
	if mf == "" {
		return false
	}
	return filepath.Dir(mf) != mod.Dir
}

func findModFile(dir string) string {
	for {
		f := filepath.Join(dir, "go.mod")
		info, err := os.Stat(f)
		if err == nil && !info.IsDir() {
			return f
		}
		d := filepath.Dir(dir)
		if len(d) >= len(dir) {
			return "" // reached top of file system, no go.mod
		}
		dir = d
	}
}

func (r *moduleResolver) loadPackageNames(importPaths []string, srcDir string) (map[string]string, error) {
	if err := r.init(); err != nil {
		return nil, err
	}
	names := map[string]string{}
	for _, path := range importPaths {
		_, packageDir := r.findPackage(path)
		if packageDir == "" {
			continue
		}
		name, err := packageDirToName(packageDir)
		if err != nil {
			continue
		}
		names[path] = name
	}
	return names, nil
}

func (r *moduleResolver) scan(_ references) ([]*pkg, error) {
	if err := r.init(); err != nil {
		return nil, err
	}

	// Walk GOROOT, GOPATH/pkg/mod, and the main module.
	roots := []gopathwalk.Root{
		{filepath.Join(r.env.GOROOT, "/src"), gopathwalk.RootGOROOT},
	}
	if r.main != nil {
		roots = append(roots, gopathwalk.Root{r.main.Dir, gopathwalk.RootCurrentModule})
	}
	for _, p := range filepath.SplitList(r.env.GOPATH) {
		roots = append(roots, gopathwalk.Root{filepath.Join(p, "/pkg/mod"), gopathwalk.RootModuleCache})
	}

	// Walk replace targets, just in case they're not in any of the above.
	for _, mod := range r.modsByModPath {
		if mod.Replace != nil {
			roots = append(roots, gopathwalk.Root{mod.Dir, gopathwalk.RootOther})
		}
	}

	var result []*pkg
	dupCheck := make(map[string]bool)
	var mu sync.Mutex

	gopathwalk.Walk(roots, func(root gopathwalk.Root, dir string) {
		mu.Lock()
		defer mu.Unlock()

		if _, dup := dupCheck[dir]; dup {
			return
		}

		dupCheck[dir] = true

		subdir := ""
		if dir != root.Path {
			subdir = dir[len(root.Path)+len("/"):]
		}
		importPath := filepath.ToSlash(subdir)
		if strings.HasPrefix(importPath, "vendor/") {
			// Ignore vendor dirs. If -mod=vendor is on, then things
			// should mostly just work, but when it's not vendor/
			// is a mess. There's no easy way to tell if it's on.
			// We can still find things in the mod cache and
			// map them into /vendor when -mod=vendor is on.
			return
		}
		switch root.Type {
		case gopathwalk.RootCurrentModule:
			importPath = path.Join(r.main.Path, filepath.ToSlash(subdir))
		case gopathwalk.RootModuleCache:
			matches := modCacheRegexp.FindStringSubmatch(subdir)
			modPath, err := module.DecodePath(filepath.ToSlash(matches[1]))
			if err != nil {
				if Debug {
					log.Printf("decoding module cache path %q: %v", subdir, err)
				}
				return
			}
			importPath = path.Join(modPath, filepath.ToSlash(matches[3]))
		case gopathwalk.RootGOROOT:
			importPath = subdir
		}

		// Check if the directory is underneath a module that's in scope.
		if mod := r.findModuleByDir(dir); mod != nil {
			// It is. If dir is the target of a replace directive,
			// our guessed import path is wrong. Use the real one.
			if mod.Dir == dir {
				importPath = mod.Path
			} else {
				dirInMod := dir[len(mod.Dir)+len("/"):]
				importPath = path.Join(mod.Path, filepath.ToSlash(dirInMod))
			}
		} else {
			// The package is in an unknown module. Check that it's
			// not obviously impossible to import.
			var modFile string
			switch root.Type {
			case gopathwalk.RootModuleCache:
				matches := modCacheRegexp.FindStringSubmatch(subdir)
				modFile = filepath.Join(matches[1], "@", matches[2], "go.mod")
			default:
				modFile = findModFile(dir)
			}

			modBytes, err := ioutil.ReadFile(modFile)
			if err == nil && !strings.HasPrefix(importPath, modulePath(modBytes)) {
				// The module's declared path does not match
				// its expected path. It probably needs a
				// replace directive we don't have.
				return
			}
		}
		// We may have discovered a package that has a different version
		// in scope already. Canonicalize to that one if possible.
		if _, canonicalDir := r.findPackage(importPath); canonicalDir != "" {
			dir = canonicalDir
		}

		result = append(result, &pkg{
			importPathShort: VendorlessPath(importPath),
			dir:             dir,
		})
	}, gopathwalk.Options{Debug: Debug, ModulesEnabled: true})
	return result, nil
}

// modCacheRegexp splits a path in a module cache into module, module version, and package.
var modCacheRegexp = regexp.MustCompile(`(.*)@([^/\\]*)(.*)`)

var (
	slashSlash = []byte("//")
	moduleStr  = []byte("module")
)

// modulePath returns the module path from the gomod file text.
// If it cannot find a module path, it returns an empty string.
// It is tolerant of unrelated problems in the go.mod file.
//
// Copied from cmd/go/internal/modfile.
func modulePath(mod []byte) string {
	for len(mod) > 0 {
		line := mod
		mod = nil
		if i := bytes.IndexByte(line, '\n'); i >= 0 {
			line, mod = line[:i], line[i+1:]
		}
		if i := bytes.Index(line, slashSlash); i >= 0 {
			line = line[:i]
		}
		line = bytes.TrimSpace(line)
		if !bytes.HasPrefix(line, moduleStr) {
			continue
		}
		line = line[len(moduleStr):]
		n := len(line)
		line = bytes.TrimSpace(line)
		if len(line) == n || len(line) == 0 {
			continue
		}

		if line[0] == '"' || line[0] == '`' {
			p, err := strconv.Unquote(string(line))
			if err != nil {
				return "" // malformed quoted string or multiline module path
			}
			return p
		}

		return string(line)
	}
	return "" // missing module path
}
