package lint

import (
	"fmt"
	"go/build"
	"go/parser"
	"go/types"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/golangci/golangci-lint/pkg/exitcodes"
	"github.com/golangci/golangci-lint/pkg/fsutils"
	"github.com/golangci/golangci-lint/pkg/goutils"
	"github.com/golangci/golangci-lint/pkg/logutils"

	"github.com/golangci/golangci-lint/pkg/config"
	"github.com/golangci/golangci-lint/pkg/lint/astcache"
	"github.com/golangci/golangci-lint/pkg/lint/linter"
	"github.com/golangci/golangci-lint/pkg/packages"
	"github.com/golangci/tools/go/ssa"
	"github.com/golangci/tools/go/ssa/ssautil"
	"golang.org/x/tools/go/loader"
)

var loadDebugf = logutils.Debug("load")

func isFullImportNeeded(linters []linter.Config, cfg *config.Config) bool {
	for _, lc := range linters {
		if lc.NeedsProgramLoading() {
			if lc.Name() == "govet" && cfg.LintersSettings.Govet.UseInstalledPackages {
				// TODO: remove this hack
				continue
			}

			return true
		}
	}

	return false
}

func isSSAReprNeeded(linters []linter.Config) bool {
	for _, linter := range linters {
		if linter.NeedsSSARepresentation() {
			return true
		}
	}

	return false
}

func normalizePaths(paths []string) ([]string, error) {
	ret := make([]string, 0, len(paths))
	for _, p := range paths {
		relPath, err := fsutils.ShortestRelPath(p, "")
		if err != nil {
			return nil, fmt.Errorf("can't get relative path for path %s: %s", p, err)
		}
		p = relPath

		ret = append(ret, "./"+p)
	}

	return ret, nil
}

func getCurrentProjectImportPath() (string, error) {
	gopath := os.Getenv("GOPATH")
	if gopath == "" {
		return "", fmt.Errorf("no GOPATH env variable")
	}

	wd, err := fsutils.Getwd()
	if err != nil {
		return "", fmt.Errorf("can't get workind directory: %s", err)
	}

	if !strings.HasPrefix(wd, gopath) {
		return "", fmt.Errorf("currently no in gopath: %q isn't a prefix of %q", gopath, wd)
	}

	path := strings.TrimPrefix(wd, gopath)
	path = strings.TrimPrefix(path, string(os.PathSeparator)) // if GOPATH contains separator at the end
	src := "src" + string(os.PathSeparator)
	if !strings.HasPrefix(path, src) {
		return "", fmt.Errorf("currently no in gopath/src: %q isn't a prefix of %q", src, path)
	}

	path = strings.TrimPrefix(path, src)
	path = strings.Replace(path, string(os.PathSeparator), "/", -1)
	return path, nil
}

func isLocalProjectAnalysis(args []string) bool {
	for _, arg := range args {
		if strings.HasPrefix(arg, "..") || filepath.IsAbs(arg) {
			return false
		}
	}

	return true
}

func getTypeCheckFuncBodies(cfg *config.Run, linters []linter.Config,
	pkgProg *packages.Program, log logutils.Log) func(string) bool {

	if !isLocalProjectAnalysis(cfg.Args) {
		loadDebugf("analysis in nonlocal, don't optimize loading by not typechecking func bodies")
		return nil
	}

	if isSSAReprNeeded(linters) {
		loadDebugf("ssa repr is needed, don't optimize loading by not typechecking func bodies")
		return nil
	}

	if len(pkgProg.Dirs()) == 0 {
		// files run, in this mode packages are fake: can't check their path properly
		return nil
	}

	projPath, err := getCurrentProjectImportPath()
	if err != nil {
		log.Infof("Can't get cur project path: %s", err)
		return nil
	}

	return func(path string) bool {
		if strings.HasPrefix(path, ".") {
			loadDebugf("%s: dot import: typecheck func bodies", path)
			return true
		}

		isLocalPath := strings.HasPrefix(path, projPath)
		if isLocalPath {
			localPath := strings.TrimPrefix(path, projPath)
			localPath = strings.TrimPrefix(localPath, "/")
			if strings.HasPrefix(localPath, "vendor/") {
				loadDebugf("%s: local vendor import: DO NOT typecheck func bodies", path)
				return false
			}

			loadDebugf("%s: local import: typecheck func bodies", path)
			return true
		}

		loadDebugf("%s: not local import: DO NOT typecheck func bodies", path)
		return false
	}
}

func loadWholeAppIfNeeded(linters []linter.Config, cfg *config.Config,
	pkgProg *packages.Program, log logutils.Log) (*loader.Program, *loader.Config, error) {

	if !isFullImportNeeded(linters, cfg) {
		return nil, nil, nil
	}

	startedAt := time.Now()
	defer func() {
		log.Infof("Program loading took %s", time.Since(startedAt))
	}()

	bctx := pkgProg.BuildContext()
	loadcfg := &loader.Config{
		Build:               bctx,
		AllowErrors:         true,                 // Try to analyze partially
		ParserMode:          parser.ParseComments, // AST will be reused by linters
		TypeCheckFuncBodies: getTypeCheckFuncBodies(&cfg.Run, linters, pkgProg, log),
		TypeChecker: types.Config{
			Sizes: types.SizesFor(build.Default.Compiler, build.Default.GOARCH),
		},
	}

	var loaderArgs []string
	dirs := pkgProg.Dirs()
	if len(dirs) != 0 {
		loaderArgs = dirs // dirs run
	} else {
		loaderArgs = pkgProg.Files(cfg.Run.AnalyzeTests) // files run
	}

	nLoaderArgs, err := normalizePaths(loaderArgs)
	if err != nil {
		return nil, nil, err
	}

	rest, err := loadcfg.FromArgs(nLoaderArgs, cfg.Run.AnalyzeTests)
	if err != nil {
		return nil, nil, fmt.Errorf("can't parepare load config with paths: %s", err)
	}
	if len(rest) > 0 {
		return nil, nil, fmt.Errorf("unhandled loading paths: %v", rest)
	}

	prog, err := loadcfg.Load()
	if err != nil {
		return nil, nil, fmt.Errorf("can't load program from paths %v: %s", loaderArgs, err)
	}

	if len(prog.InitialPackages()) == 1 {
		pkg := prog.InitialPackages()[0]
		var files []string
		for _, f := range pkg.Files {
			files = append(files, prog.Fset.Position(f.Pos()).Filename)
		}
		log.Infof("pkg %s files: %s", pkg, files)
	}

	return prog, loadcfg, nil
}

func buildSSAProgram(lprog *loader.Program, log logutils.Log) *ssa.Program {
	startedAt := time.Now()
	defer func() {
		log.Infof("SSA repr building took %s", time.Since(startedAt))
	}()

	ssaProg := ssautil.CreateProgram(lprog, ssa.GlobalDebug)
	ssaProg.Build()
	return ssaProg
}

// separateNotCompilingPackages moves not compiling packages into separate slices:
// a lot of linters crash on such packages. Leave them only for those linters
// which can work with them.
//nolint:gocyclo
func separateNotCompilingPackages(lintCtx *linter.Context) {
	prog := lintCtx.Program

	notCompilingPackagesSet := map[*loader.PackageInfo]bool{}

	if prog.Created != nil {
		compilingCreated := make([]*loader.PackageInfo, 0, len(prog.Created))
		for _, info := range prog.Created {
			if len(info.Errors) != 0 {
				lintCtx.NotCompilingPackages = append(lintCtx.NotCompilingPackages, info)
				notCompilingPackagesSet[info] = true
			} else {
				compilingCreated = append(compilingCreated, info)
			}
		}
		prog.Created = compilingCreated
	}

	if prog.Imported != nil {
		for k, info := range prog.Imported {
			if len(info.Errors) == 0 {
				continue
			}

			lintCtx.NotCompilingPackages = append(lintCtx.NotCompilingPackages, info)
			notCompilingPackagesSet[info] = true
			delete(prog.Imported, k)
		}
	}

	if prog.AllPackages != nil {
		for k, info := range prog.AllPackages {
			if len(info.Errors) == 0 {
				continue
			}

			if !notCompilingPackagesSet[info] {
				lintCtx.NotCompilingPackages = append(lintCtx.NotCompilingPackages, info)
				notCompilingPackagesSet[info] = true
			}
			delete(prog.AllPackages, k)
		}
	}

	if len(lintCtx.NotCompilingPackages) != 0 {
		lintCtx.Log.Infof("Not compiling packages: %+v", lintCtx.NotCompilingPackages)
	}
}

//nolint:gocyclo
func LoadContext(linters []linter.Config, cfg *config.Config, log logutils.Log) (*linter.Context, error) {
	// Set GOROOT to have working cross-compilation: cross-compiled binaries
	// have invalid GOROOT. XXX: can't use runtime.GOROOT().
	goroot, err := goutils.DiscoverGoRoot()
	if err != nil {
		return nil, fmt.Errorf("can't discover GOROOT: %s", err)
	}
	os.Setenv("GOROOT", goroot)
	build.Default.GOROOT = goroot

	args := cfg.Run.Args
	if len(args) == 0 {
		args = []string{"./..."}
	}

	skipDirs := append([]string{}, packages.StdExcludeDirRegexps...)
	skipDirs = append(skipDirs, cfg.Run.SkipDirs...)
	r, err := packages.NewResolver(cfg.Run.BuildTags, skipDirs, log.Child("path_resolver"))
	if err != nil {
		return nil, err
	}

	pkgProg, err := r.Resolve(args...)
	if err != nil {
		return nil, err
	}

	if len(pkgProg.Packages()) == 0 {
		return nil, exitcodes.ErrNoGoFiles
	}

	prog, loaderConfig, err := loadWholeAppIfNeeded(linters, cfg, pkgProg, log)
	if err != nil {
		return nil, err
	}

	var ssaProg *ssa.Program
	if prog != nil && isSSAReprNeeded(linters) {
		ssaProg = buildSSAProgram(prog, log)
	}

	astLog := log.Child("astcache")
	var astCache *astcache.Cache
	if prog != nil {
		astCache, err = astcache.LoadFromProgram(prog, astLog)
	} else {
		astCache, err = astcache.LoadFromFiles(pkgProg.Files(cfg.Run.AnalyzeTests), astLog)
	}
	if err != nil {
		return nil, err
	}

	ret := &linter.Context{
		PkgProgram:   pkgProg,
		Cfg:          cfg,
		Program:      prog,
		SSAProgram:   ssaProg,
		LoaderConfig: loaderConfig,
		ASTCache:     astCache,
		Log:          log,
	}

	if prog != nil {
		separateNotCompilingPackages(ret)
	}

	return ret, nil
}