// Copyright (c) Bartłomiej Płotka @bwplotka
// Licensed under the Apache License 2.0.

package bingo

import (
	"context"
	"fmt"
	"io"
	"log"
	"os"
	"path"
	"path/filepath"
	"sort"
	"strings"
	"text/tabwriter"

	"github.com/bwplotka/bingo/pkg/cpy"

	"github.com/bwplotka/bingo/pkg/envars"
	"github.com/bwplotka/bingo/pkg/mod"
	"github.com/bwplotka/bingo/pkg/runner"
	"github.com/efficientgo/core/errcapture"
	"github.com/efficientgo/core/errors"
	"golang.org/x/mod/module"
)

const (
	// FakeRootModFileName is a name for fake go module that we have to maintain, until https://github.com/bwplotka/bingo/issues/20 is fixed.
	FakeRootModFileName = "go.mod"

	NoDirectiveCommand = "bingo:no_directive_fetch"

	PackageRenderablesPrintHeader = "Name\tBinary Name\tPackage @ Version\tBuild EnvVars\tBuild Flags\n" +
		"----\t-----------\t-----------------\t-------------\t-----------\n"

	metaComment = "Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT"
)

// NameFromModFile returns binary name from module file path.
func NameFromModFile(modFile string) (name string, oneOfMany bool) {
	n := strings.Split(strings.TrimSuffix(filepath.Base(modFile), ".mod"), ".")
	if len(n) > 1 {
		oneOfMany = true
	}
	return n[0], oneOfMany
}

// A Package (for clients, a bingo.Package) is defined by a module path, package relative path and version pair.
// These are stored in their plain (unescaped) form.
type Package struct {
	Module module.Version

	// RelPath is a path that together with Module.Path composes a package path, like "/pkg/makefile".
	// Empty if the module is a full package path.
	// If Module.Path is empty and RelPath specified, it means that we don't know what is a module what is the package path.
	RelPath string

	// BuildEnvs are environment variables to be used during go build process.
	BuildEnvs envars.EnvSlice
	// BuildFlags are flags to be used during go build process.
	BuildFlags []string
}

// String returns a representation of the Package suitable for `go` tools and logging.
// (Module.Path/RelPath@Module.Version, or Module.Path/RelPath if Version is empty).
func (m Package) String() string {
	if m.Module.Version == "" {
		return m.Path()
	}
	return m.Path() + "@" + m.Module.Version
}

// Path returns a full package path. package path is platform independent, usually in
// the form of "a/b/c" with forward slashes
func (m Package) Path() string {
	return path.Join(m.Module.Path, m.RelPath)
}

// ModFile is a wrapper over module file with bingo specific data.
type ModFile struct {
	*mod.File

	directPackage               *Package
	directivesAutoFetchDisabled bool
}

// OpenModFile opens bingo mod file.
// It also adds meta if missing and trims all require direct module imports except first within the parsed syntax.
// It's a caller responsibility to Close the file when not using anymore.
func OpenModFile(modFile string) (_ *ModFile, err error) {
	f, err := mod.OpenFile(modFile)
	if err != nil {
		return nil, err
	}
	defer func() {
		if err != nil {
			errcapture.Do(&err, f.Close, "close")
		}
	}()

	m, comment := f.Module()
	if m == "" {
		m = "_"
	}
	if comment != metaComment {
		if err := f.SetModule(m, metaComment); err != nil {
			return nil, err
		}
	}

	mf := &ModFile{File: f}
	return mf, mf.Reload()
}

func (mf *ModFile) IsDirectivesAutoFetchDisabled() bool {
	return mf.directivesAutoFetchDisabled
}

func (mf *ModFile) Reload() error {
	if err := mf.File.Reload(); err != nil {
		return err
	}

	for _, c := range mf.Comments() {
		if strings.Contains(c, NoDirectiveCommand) {
			mf.directivesAutoFetchDisabled = true
			break
		}
	}

	// We expect just one direct import if any.
	var directPackage *Package
	for _, r := range mf.RequireDirectives() {
		if r.Indirect {
			continue
		}

		directPackage = &Package{Module: r.Module}
		if len(r.ExtraSuffixComment) > 0 {
			directPackage.RelPath, directPackage.BuildEnvs, directPackage.BuildFlags = parseDirectPackageMeta(strings.Trim(r.ExtraSuffixComment, "\n"))
		}
		break
	}

	if directPackage != nil {
		return mf.SetDirectRequire(*directPackage)
	}
	return nil
}

func SumFilePath(modFilePath string) string {
	return strings.TrimSuffix(modFilePath, ".mod") + ".sum"
}

// CreateFromExistingOrNew creates and opens new bingo enhanced module file.
// If existing file exists and is not malformed it copies this as the source, otherwise completely new is created.
// It's a caller responsibility to Close the file when not using anymore.
func CreateFromExistingOrNew(ctx context.Context, r *runner.Runner, logger *log.Logger, existingFile, modFile string) (*ModFile, error) {
	if err := os.RemoveAll(modFile); err != nil {
		return nil, errors.Wrap(err, "rm")
	}

	if existingFile != "" {
		_, err := os.Stat(existingFile)
		if err != nil && !os.IsNotExist(err) {
			return nil, errors.Wrapf(err, "stat module file %s", existingFile)
		}
		if err == nil {
			// Only use existing mod file on successful parse.
			o, err := OpenModFile(existingFile)
			if err == nil {
				if err := o.Close(); err != nil {
					return nil, err
				}
				if err := cpy.File(existingFile, modFile); err != nil {
					return nil, err
				}
				existingSumFile := SumFilePath(existingFile)
				sumFile := SumFilePath(modFile)
				if err := cpy.File(existingSumFile, sumFile); err != nil && !os.IsNotExist(err) {
					return nil, err
				}
				return OpenModFile(modFile)
			}
			logger.Printf("bingo tool module file %v is malformed; it will be recreated; err: %v\n", existingFile, err)
		}
	}

	// Create from scratch.
	if err := r.ModInit(ctx, filepath.Dir(existingFile), modFile, "_"); err != nil {
		return nil, errors.Wrap(err, "mod init")
	}
	return OpenModFile(modFile)
}

func parseDirectPackageMeta(line string) (relPath string, buildEnv []string, buildFlags []string) {
	elem := strings.Split(line, " ")
	for i, l := range elem {
		if l == "" {
			continue
		}

		if l[0] == '-' {
			buildFlags = elem[i:]
			break
		}

		if !strings.Contains(l, "=") {
			relPath = l
			continue
		}
		buildEnv = append(buildEnv, l)
	}
	return relPath, buildEnv, buildFlags
}

func (mf *ModFile) DirectPackage() *Package {
	return mf.directPackage
}

// SetDirectRequire removes all require statements and set to the given one. It supports package level versioning.
func (mf *ModFile) SetDirectRequire(target Package) (err error) {
	r := mod.RequireDirective{Module: target.Module}

	var meta []string

	// Add sub package info if needed.
	if target.RelPath != "" && target.RelPath != "." {
		meta = append(meta, target.RelPath)
	}
	meta = append(meta, target.BuildEnvs...)
	meta = append(meta, target.BuildFlags...)

	if len(meta) > 0 {
		r.ExtraSuffixComment = strings.Join(meta, " ")
	}
	mf.directPackage = &target
	return mf.SetRequireDirectives(r)
}

// ModDirectPackage return the first direct package from bingo enhanced module file. The package suffix (if any) is
// encoded in the line comment, in the same line as module and version.
func ModDirectPackage(modFile string) (pkg Package, err error) {
	mf, err := OpenModFile(modFile)
	if err != nil {
		return Package{}, err
	}
	defer errcapture.Do(&err, mf.Close, "close")

	if mf.directPackage == nil {
		return Package{}, errors.Newf("no direct package found in %s; empty module?", mf.Filepath())
	}
	return *mf.directPackage, nil
}

// ModIndirectModules return the all indirect mod from any module file.
func ModIndirectModules(modFile string) (mods []module.Version, err error) {
	m, err := mod.OpenFile(modFile)
	if err != nil {
		return nil, err
	}

	for _, r := range m.RequireDirectives() {
		if !r.Indirect {
			continue
		}

		mods = append(mods, r.Module)
	}
	return mods, nil
}

// PackageVersionRenderable is used in variables.go. Modify with care.
type PackageVersionRenderable struct {
	Version string
	ModFile string
}

// PackageRenderable is used in variables.go. Modify with care.
type PackageRenderable struct {
	Name        string
	ModPath     string
	PackagePath string
	EnvVarName  string
	Versions    []PackageVersionRenderable

	BuildFlags   []string
	BuildEnvVars []string
}

func (p PackageRenderable) ToPackages() []Package {
	ret := make([]Package, 0, len(p.Versions))
	for _, v := range p.Versions {
		relPath, _ := filepath.Rel(p.ModPath, p.PackagePath)

		ret = append(ret, Package{
			Module: module.Version{
				Version: v.Version,
				Path:    p.ModPath,
			},
			RelPath: relPath,
		})
	}
	return ret
}

type PackageRenderables []PackageRenderable

func (pkgs PackageRenderables) PrintTab(target string, w io.Writer) error {
	tw := new(tabwriter.Writer)
	tw.Init(w, 1, 8, 1, '\t', tabwriter.AlignRight)
	defer func() { _ = tw.Flush() }()

	_, _ = fmt.Fprint(tw, PackageRenderablesPrintHeader)
	for _, p := range pkgs {
		if target != "" && p.Name != target {
			continue
		}
		for _, v := range p.Versions {
			fields := []string{
				p.Name,
				p.Name + "-" + v.Version,
				p.PackagePath + "@" + v.Version,
				strings.Join(p.BuildEnvVars, " "),
				strings.Join(p.BuildFlags, " "),
			}
			_, _ = fmt.Fprintln(tw, strings.Join(fields, "\t"))
		}
		if target != "" {
			return nil
		}
	}

	if target != "" {
		return errors.Newf("Pinned tool %s not found", target)
	}
	return nil
}

// ListPinnedMainPackages lists all bingo pinned binaries (Go main packages) in the same order as seen in the filesystem.
func ListPinnedMainPackages(logger *log.Logger, modDir string, remMalformed bool) (pkgs PackageRenderables, _ error) {
	modFiles, err := filepath.Glob(filepath.Join(modDir, "*.mod"))
	if err != nil {
		return nil, err
	}
ModLoop:
	for _, f := range modFiles {
		if filepath.Base(f) == FakeRootModFileName {
			continue
		}

		pkg, err := ModDirectPackage(f)
		if err != nil {
			if remMalformed {
				logger.Printf("found malformed module file %v, removing due to error: %v\n", f, err)
				if err := os.RemoveAll(strings.TrimSuffix(f, ".") + "*"); err != nil {
					return nil, err
				}
			}
			continue
		}

		name, _ := NameFromModFile(f)
		varName := strings.ReplaceAll(strings.ReplaceAll(strings.ToUpper(name), ".", "_"), "-", "_")
		for i, p := range pkgs {
			if p.Name == name {
				pkgs[i].EnvVarName = varName + "_ARRAY"
				// Preserve order. Unfortunately first array mod file has no number, so it's last.
				if filepath.Base(f) == p.Name+".mod" {
					pkgs[i].Versions = append([]PackageVersionRenderable{{
						Version: pkg.Module.Version,
						ModFile: filepath.Base(f),
					}}, pkgs[i].Versions...)
					continue ModLoop
				}

				pkgs[i].Versions = append(pkgs[i].Versions, PackageVersionRenderable{
					Version: pkg.Module.Version,
					ModFile: filepath.Base(f),
				})
				continue ModLoop
			}
		}
		pkgs = append(pkgs, PackageRenderable{
			Name: name,
			Versions: []PackageVersionRenderable{
				{Version: pkg.Module.Version, ModFile: filepath.Base(f)},
			},
			BuildFlags:   pkg.BuildFlags,
			BuildEnvVars: pkg.BuildEnvs,

			EnvVarName:  varName,
			PackagePath: pkg.Path(),
			ModPath:     pkg.Module.Path,
		})
	}
	return pkgs, nil
}

func SortRenderables(pkgs []PackageRenderable) {
	for _, p := range pkgs {
		sort.Slice(p.Versions, func(i, j int) bool {
			return p.Versions[i].Version < p.Versions[j].Version
		})
	}
	sort.Slice(pkgs, func(i, j int) bool {
		if pkgs[i].Name == pkgs[j].Name {
			return pkgs[i].PackagePath < pkgs[j].PackagePath
		}
		return pkgs[i].Name < pkgs[j].Name
	})
}
