package lint

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

	"github.com/golangci/go-tools/ssa"
	"github.com/golangci/go-tools/ssa/ssautil"
	"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/sirupsen/logrus"
	"golang.org/x/tools/go/loader"
)

func isFullImportNeeded(linters []linter.Config) bool {
	for _, linter := range linters {
		if linter.NeedsProgramLoading() {
			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) {
	root, err := os.Getwd()
	if err != nil {
		return nil, fmt.Errorf("can't get working dir: %s", err)
	}

	ret := make([]string, 0, len(paths))
	for _, p := range paths {
		if filepath.IsAbs(p) {
			relPath, err := filepath.Rel(root, p)
			if err != nil {
				return nil, fmt.Errorf("can't get relative path for path %s and root %s: %s",
					p, root, err)
			}
			p = relPath
		}

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

	return ret, nil
}

func loadWholeAppIfNeeded(ctx context.Context, linters []linter.Config, cfg *config.Run, pkgProg *packages.Program) (*loader.Program, *loader.Config, error) {
	if !isFullImportNeeded(linters) {
		return nil, nil, nil
	}

	startedAt := time.Now()
	defer func() {
		logrus.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
	}

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

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

	rest, err := loadcfg.FromArgs(nLoaderArgs, cfg.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)
	}

	return prog, loadcfg, nil
}

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

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

func discoverGoRoot() (string, error) {
	goroot := os.Getenv("GOROOT")
	if goroot != "" {
		return goroot, nil
	}

	output, err := exec.Command("go", "env", "GOROOT").Output()
	if err != nil {
		return "", fmt.Errorf("can't execute go env GOROOT: %s", err)
	}

	return strings.TrimSpace(string(output)), nil
}

// 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.
func separateNotCompilingPackages(lintCtx *linter.Context) {
	prog := lintCtx.Program

	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)
			} else {
				compilingCreated = append(compilingCreated, info)
			}
		}
		prog.Created = compilingCreated
	}

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

//nolint:gocyclo
func LoadContext(ctx context.Context, linters []linter.Config, cfg *config.Config) (*linter.Context, error) {
	// Set GOROOT to have working cross-compilation: cross-compiled binaries
	// have invalid GOROOT. XXX: can't use runtime.GOROOT().
	goroot, err := 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)
	if err != nil {
		return nil, err
	}

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

	prog, loaderConfig, err := loadWholeAppIfNeeded(ctx, linters, &cfg.Run, pkgProg)
	if err != nil {
		return nil, err
	}

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

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

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

	if prog != nil {
		separateNotCompilingPackages(ret)
	}

	return ret, nil
}