// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package funcs

import (
	"fmt"
	"io"
	"io/ioutil"
	"os/exec"
	"time"

	"github.com/go-git/go-git/v5"
	"github.com/zclconf/go-cty/cty"
	"github.com/zclconf/go-cty/cty/function"
)

func VCSGitFuncs(path string) map[string]function.Function {
	state := &VCSGit{Path: path}

	return map[string]function.Function{
		"gitrefpretty": state.RefPrettyFunc(),
		"gitrefhash":   state.RefHashFunc(),
		"gitreftag":    state.RefTagFunc(),
		"gitremoteurl": state.RemoteUrlFunc(),
	}
}

type VCSGit struct {
	// Path of the git repository. Parent directories will be searched for
	// a ".git" folder automatically.
	Path string

	initErr error
	repo    *git.Repository
}

// RefPrettyFunc returns a string format of the current Git ref. This function
// takes some liberties to humanize the output: it will use a tag if the
// ref matches a tag, it will append "+CHANGES" to the commit if there are
// uncommitted changed files, etc.
//
// You may use direct functions such as `gitrefhash` if you want the direct
// hash. Or `gitreftag` to get the current tag.
//
// vagrant:gitrefpretty
func (s *VCSGit) RefPrettyFunc() function.Function {
	return function.New(&function.Spec{
		Params: []function.Parameter{},
		Type:   function.StaticReturnType(cty.String),
		Impl:   s.refPrettyFunc,
	})
}

func (s *VCSGit) refPrettyFunc(args []cty.Value, retType cty.Type) (cty.Value, error) {
	if err := s.init(); err != nil {
		return cty.UnknownVal(cty.String), err
	}

	ref, err := s.repo.Head()
	if err != nil {
		return cty.UnknownVal(cty.String), err
	}
	result := ref.Hash().String()

	// Get the tags
	iter, err := s.repo.Tags()
	if err != nil {
		return cty.UnknownVal(cty.String), err
	}
	defer iter.Close()
	for {
		tagRef, err := iter.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return cty.UnknownVal(cty.String), err
		}
		if tagRef.Hash() == ref.Hash() {
			result = tagRef.Name().Short()
			break
		}
	}

	// To determine if there are changes we subprocess because go-git's Status
	// function is really, really slow sadly. On the vagrant repo at the time
	// of this commit, go-git took 12s on my machine vs. 50ms for `git`.
	cmd := exec.Command("git", "diff", "--quiet")
	cmd.Stdout = ioutil.Discard
	cmd.Stderr = ioutil.Discard
	if err := cmd.Run(); err != nil {
		exitError, ok := err.(*exec.ExitError)
		if !ok {
			return cty.UnknownVal(cty.String), fmt.Errorf("error executing git: %s", err)
		}

		if exitError.ExitCode() != 0 {
			result += fmt.Sprintf("_CHANGES_%d", time.Now().Unix())
		}
	}

	return cty.StringVal(result), nil
}

// RefHashFunc returns the full hash of the HEAD ref.
//
// vagrant:gitrefhash
func (s *VCSGit) RefHashFunc() function.Function {
	return function.New(&function.Spec{
		Params: []function.Parameter{},
		Type:   function.StaticReturnType(cty.String),
		Impl:   s.refHashFunc,
	})
}

func (s *VCSGit) refHashFunc(args []cty.Value, retType cty.Type) (cty.Value, error) {
	if err := s.init(); err != nil {
		return cty.UnknownVal(cty.String), err
	}

	ref, err := s.repo.Head()
	if err != nil {
		return cty.UnknownVal(cty.String), err
	}

	return cty.StringVal(ref.Hash().String()), nil
}

// RefTagFunc returns the tag of the HEAD ref or empty if not tag is found.
//
// vagrant:gitreftag
func (s *VCSGit) RefTagFunc() function.Function {
	return function.New(&function.Spec{
		Params: []function.Parameter{},
		Type:   function.StaticReturnType(cty.String),
		Impl:   s.refTagFunc,
	})
}

func (s *VCSGit) refTagFunc(args []cty.Value, retType cty.Type) (cty.Value, error) {
	if err := s.init(); err != nil {
		return cty.UnknownVal(cty.String), err
	}

	ref, err := s.repo.Head()
	if err != nil {
		return cty.UnknownVal(cty.String), err
	}

	// Get the tags
	iter, err := s.repo.Tags()
	if err != nil {
		return cty.UnknownVal(cty.String), err
	}
	defer iter.Close()
	for {
		tagRef, err := iter.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return cty.UnknownVal(cty.String), err
		}
		if tagRef.Hash() == ref.Hash() {
			return cty.StringVal(tagRef.Name().Short()), nil
		}
	}

	return cty.StringVal(""), nil
}

// RemoteUrlFunc returns the URL for the matching remote or unknown
// if it can't be found.
//
// vagrant:gitremoteurl
func (s *VCSGit) RemoteUrlFunc() function.Function {
	return function.New(&function.Spec{
		Params: []function.Parameter{
			{
				Name: "name",
				Type: cty.String,
			},
		},
		Type: function.StaticReturnType(cty.String),
		Impl: s.remoteUrlFunc,
	})
}

func (s *VCSGit) remoteUrlFunc(args []cty.Value, retType cty.Type) (cty.Value, error) {
	if err := s.init(); err != nil {
		return cty.UnknownVal(cty.String), err
	}

	name := args[0].AsString()

	remote, err := s.repo.Remote(name)
	if err != nil {
		if err == git.ErrRemoteNotFound {
			err = nil
		}

		return cty.UnknownVal(cty.String), err
	}

	urls := remote.Config().URLs
	if len(urls) == 0 {
		return cty.UnknownVal(cty.String), nil
	}

	return cty.StringVal(urls[0]), nil
}

func (s *VCSGit) init() error {
	// If we initialized already return
	if s.initErr != nil {
		return s.initErr
	}
	if s.repo != nil {
		return nil
	}

	// Check if `git` is installed. We'll use this sometimes.
	if _, err := exec.LookPath("git"); err != nil {
		s.initErr = fmt.Errorf("git was not found on the system and is required")
		return s.initErr
	}

	// Open the repo
	repo, err := git.PlainOpenWithOptions(s.Path, &git.PlainOpenOptions{
		DetectDotGit: true,
	})
	if err != nil {
		s.initErr = err
		return err
	}
	s.repo = repo
	return nil
}
