package log_extractor

import (
	"bytes"
	"errors"
	"go/build"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/pingcap-inc/alioth/extractor/go/compiler"
	"github.com/pingcap-inc/alioth/pkg/worker"
	"github.com/pingcap/log"
	"go.uber.org/zap"
)

// Builer used to compile golang project into multiple package compilations
// input - ctx is go/build Conext with customize the variables for build golang package
// input - repoPath is the directory path where project under $GOPATH or $GOROOT
// return - Repo is a object contains all package compilations under the repo
/* uasage:
    builder := &logextractor.Builder{}
	...
	path, err := filepath.Abs("./")
	or
	path, err := filepath.Abs("/Users/ianz/Work/go/src/github.com/pingcap/ticdc/dm")
	...
	repo, err := builder.Build(build.Default, path)
*/
type Builder struct{}

func (b *Builder) Build(ctx build.Context, repoPath string, ignoreDirs []string, pw *worker.Pool) (*Repo, error) {
	pkgPaths, err := fetchAllPkgs(ctx, repoPath, ignoreDirs)
	if err != nil {
		return nil, err
	}

	compilations := make([]*compiler.PackageCompilation, 0, len(pkgPaths))
	c := compiler.NewPackageComplier(ctx)

	// compile packages concurrently
	wg := sync.WaitGroup{}
	ch := make(chan *compiler.PackageCompilation, len(pkgPaths)+1)
	startTime := time.Now()
	for _, pkgPath := range pkgPaths {
		wg.Add(1)
		go func(importPath string) {
			wk := pw.Apply()
			//log.Info("start compile package", zap.String("package", importPath))
			compilation, err := c.Compile(importPath)
			if err != nil {
				log.Warn("compile package failed, skip it", zap.String("package", importPath), zap.Error(err))
			} else {
				ch <- compilation
			}
			//log.Info("finish compile package", zap.String("package", importPath))
			pw.Recycle(wk)
			wg.Done()
		}(pkgPath)
	}
	wg.Wait()
	close(ch)
	log.Info("compile package cost time", zap.Any("duration", time.Since(startTime)))

	repo := repoPath
	for {
		compilation, ok := <-ch
		if !ok {
			break
		}
		repo = compilation.PackagePath.Repo
		compilations = append(compilations, compilation)
	}
	return NewRepo(repo, compilations), nil
}

// fetchAllPkgs finds all directories that contains at least one go source file,
// one directory is one package
func fetchAllPkgs(ctx build.Context, repoPath string, ignoreDirs []string) ([]string, error) {
	dirMap := make(map[string]struct{})
	err := filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		if !info.IsDir() {
			if filepath.Ext(path) == ".go" {
				dirMap[filepath.Dir(path)] = struct{}{}
			}
		}

		return nil
	})
	if err != nil {
		return nil, err
	}

	pkgDirs := make([]string, 0, len(dirMap))
	for pkg := range dirMap {
		// try to compute import path
		//pkgImportPath, _ := dirToImport(ctx, pkg)
		rel, _ := filepath.Rel(repoPath, pkg)
		needIgnored := false
		for _, dir := range ignoreDirs {
			if strings.HasPrefix(rel, dir) {
				needIgnored = true
				break
			}
		}
		if needIgnored {
			log.Info("ignore package", zap.String("package", pkg))
			continue
		}

		pkgDirs = append(pkgDirs, pkg)
	}

	return pkgDirs, nil
}

// Repo is a object contains multiple package compilations
// it provide ForEach() function to let caller visit every package compilation serially
/* uasage:
    repo := NewRepo(repo)
	...
	repo.ForEach(func(p *compiler.PackageCompilation) error {
		fmt.Println("package compilation", p)
		...
	})
*/
type Repo struct {
	repoRoot string
	hash     string
	tag      string
	pkgSet   []*compiler.PackageCompilation
}

func NewRepo(root string, pkgs []*compiler.PackageCompilation) *Repo {
	repo := &Repo{
		repoRoot: root,
		pkgSet:   pkgs,
	}

	repo.hash, repo.tag = fetchGitVersion()
	return repo
}

func (r *Repo) ForEach(fn func(*compiler.PackageCompilation) error) error {
	for _, pkg := range r.pkgSet {
		if err := fn(pkg); err != nil {
			return err
		}
	}

	return nil
}

func (r *Repo) GetRepoPath() string {
	return r.repoRoot
}

func (r *Repo) GetGitVersion() (string, string) {
	return r.hash, r.tag
}

var ErrNotSupportLocalImport = errors.New("not support local import, please put repo under right path of the $GOPATH, e.g. $GOPATH/src/github.com/org/repo")

// try to compute import path relative to GOPATH or GOROOT.
// Now we only support import package that under GOPATH/GOROOT, otherwise return ErrNotSupportLocalImport
func dirToImport(ctx build.Context, dir string) (string, error) {
	for _, src := range ctx.SrcDirs() {
		if strings.HasPrefix(dir, src) {
			if rel, err := filepath.Rel(src, dir); err == nil {
				return rel, nil
			}
		}
	}
	return dir, ErrNotSupportLocalImport
}

func fetchGitVersion() (string, string) {
	var (
		err  error
		hash string
		tag  string
	)
	hash, err = fetchGitHash()
	if err != nil {
		log.Warn("fetch git hash failed", zap.Error(err))
	}

	tag, err = fetchGitTag()
	if err != nil {
		log.Warn("fetch git tag failed", zap.Error(err))
	}

	return strings.TrimSpace(hash), strings.TrimSpace(tag)
}

// fetchGitHash tries to get repo git hash
func fetchGitHash() (string, error) {
	cmd := exec.Command("git", "rev-parse", "HEAD")

	var out bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = os.Stderr

	err := cmd.Run()
	if err != nil {
		return "", err
	}
	return out.String(), nil
}

// fetchGitTag tries to get repo git tag
func fetchGitTag() (string, error) {
	cmd := exec.Command("git", "describe", "--tags", "--dirty")

	var out bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = os.Stderr

	err := cmd.Run()
	if err != nil {
		return "", err
	}
	return out.String(), nil
}
