// Copyright 2022 The Dawn & Tint Authors
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
//  1. Redistributions of source code must retain the above copyright notice, this
//     list of conditions and the following disclaimer.
//
//  2. Redistributions in binary form must reproduce the above copyright notice,
//     this list of conditions and the following disclaimer in the documentation
//     and/or other materials provided with the distribution.
//
//  3. Neither the name of the copyright holder nor the names of its
//     contributors may be used to endorse or promote products derived from
//     this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

// Package git provides helpers for interfacing with the git tool
package git

import (
	"context"
	"encoding/hex"
	"errors"
	"fmt"
	"net/url"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"dawn.googlesource.com/dawn/tools/src/oswrapper"
)

// Hash is a 20 byte, git object hash.
type Hash [20]byte

func (h Hash) String() string { return hex.EncodeToString(h[:]) }

// IsZero returns true if the hash h is all zeros
func (h Hash) IsZero() bool {
	zero := Hash{}
	return h == zero
}

// ParseHash returns a Hash from a hexadecimal string.
func ParseHash(s string) (Hash, error) {
	b, err := hex.DecodeString(s)
	if err != nil {
		return Hash{}, fmt.Errorf("failed to parse hash '%v':\n  %w", s, err)
	}
	h := Hash{}
	copy(h[:], b)
	return h, nil
}

// The timeout for git operations if no other timeout is specified
var DefaultTimeout = 5 * time.Minute

// Git wraps the 'git' executable
type Git struct {
	// Path to the git executable
	exe string
	// OS is the wrapper for all os functions
	os oswrapper.OSWrapper
	// Debug flag to print all command to the `git` executable
	LogAllActions bool
}

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
// New returns a new Git instance
func New(exe string, osWrapper oswrapper.OSWrapper) (*Git, error) {
	if exe == "" {
		g, err := exec.LookPath("git")
		if err != nil {
			return nil, fmt.Errorf("failed to find git: %v", err)
		}
		exe = g
	}
	if _, err := osWrapper.Stat(exe); err != nil {
		return nil, err
	}
	return &Git{exe: exe, os: osWrapper}, nil
}

// Credentials holds the user name and password used to perform git operations.
type Credentials struct {
	Username string
	Password string
}

// Empty return true if there's no username or password for authentication
func (c Credentials) Empty() bool {
	return c.Username == "" && c.Password == ""
}

// addToURL returns the url with the credentials appended
func (c Credentials) addToURL(u string) (string, error) {
	if !c.Empty() {
		modified, err := url.Parse(u)
		if err != nil {
			return "", fmt.Errorf("failed to parse url '%v': %v", u, err)
		}
		modified.User = url.UserPassword(c.Username, c.Password)
		u = modified.String()
	}
	return u, nil
}

// ErrRepositoryDoesNotExist indicates that a repository does not exist
var ErrRepositoryDoesNotExist = errors.New("repository does not exist")

// Open opens an existing git repo at path. If the repository does not exist at
// path then ErrRepositoryDoesNotExist is returned.
func (g Git) Open(path string) (*Repository, error) {
	info, err := g.os.Stat(filepath.Join(path, ".git"))
	if err != nil || !info.IsDir() {
		return nil, ErrRepositoryDoesNotExist
	}
	return &Repository{g, path}, nil
}

// Optional settings for Git.Clone
type CloneOptions struct {
	// If specified then the given branch will be cloned instead of the default
	Branch string
	// Timeout for the operation
	Timeout time.Duration
	// Authentication for the clone
	Credentials Credentials
}

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
// Clone performs a clone of the repository at url to path.
func (g Git) Clone(path, urlStr string, opt *CloneOptions) (*Repository, error) {
	if err := g.os.MkdirAll(path, 0777); err != nil {
		return nil, err
	}
	if opt == nil {
		opt = &CloneOptions{}
	}
	urlStr, err := opt.Credentials.addToURL(urlStr)
	if err != nil {
		return nil, err
	}
	r := &Repository{g, path}
	args := []string{"clone", urlStr, "."}
	if opt.Branch != "" {
		args = append(args, "--branch", opt.Branch)
	}
	if _, err := r.run(nil, opt.Timeout, args...); err != nil {
		return nil, err
	}
	return r, nil
}

// Repository points to a git repository
type Repository struct {
	// Path to the 'git' executable
	Git Git
	// Repo directory
	Path string
}

// Optional settings for Repository.Fetch
type FetchOptions struct {
	// The remote name. Defaults to 'origin'
	Remote string
	// Timeout for the operation
	Timeout time.Duration
	// Git authentication for the remote
	Credentials Credentials
}

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
// Fetch performs a fetch of a reference from the remote, returning the Hash of
// the fetched reference.
func (r Repository) Fetch(ref string, opt *FetchOptions) (Hash, error) {
	if opt == nil {
		opt = &FetchOptions{}
	}
	if opt.Remote == "" {
		opt.Remote = "origin"
	}
	if _, err := r.run(nil, opt.Timeout, "fetch", opt.Remote, ref); err != nil {
		return Hash{}, err
	}
	out, err := r.run(nil, 0, "rev-parse", "FETCH_HEAD")
	if err != nil {
		return Hash{}, err
	}
	return ParseHash(out)
}

// Optional settings for Repository.Push
type PushOptions struct {
	// The remote name. Defaults to 'origin'
	Remote string
	// Timeout for the operation
	Timeout time.Duration
	// Git authentication for the remote
	Credentials Credentials
}

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
// Push performs a push of the local reference to the remote reference.
func (r Repository) Push(localRef, remoteRef string, opt *PushOptions) error {
	if opt == nil {
		opt = &PushOptions{}
	}
	if opt.Remote == "" {
		opt.Remote = "origin"
	}
	urlStr, err := r.run(nil, opt.Timeout, "remote", "get-url", opt.Remote)
	if err != nil {
		return err
	}
	urlStr, err = opt.Credentials.addToURL(urlStr)
	if err != nil {
		return err
	}
	if _, err := r.run(nil, opt.Timeout, "push", urlStr, localRef+":"+remoteRef); err != nil {
		return err
	}
	return nil
}

// Optional settings for Repository.Add
type AddOptions struct {
	// Timeout for the operation
	Timeout time.Duration
	// Git authentication for the remote
	Credentials Credentials
}

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
// Add stages the listed files
func (r Repository) Add(path string, opt *AddOptions) error {
	if opt == nil {
		opt = &AddOptions{}
	}
	if _, err := r.run(nil, opt.Timeout, "add", path); err != nil {
		return err
	}
	return nil
}

// Optional settings for Repository.Commit
type CommitOptions struct {
	// Timeout for the operation
	Timeout time.Duration
	// Author name
	AuthorName string
	// Author email address
	AuthorEmail string
	// Amend last commit?
	Amend bool
}

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
// Commit commits the staged files with the given message, returning the hash of
// commit
func (r Repository) Commit(msg string, opt *CommitOptions) (Hash, error) {
	if opt == nil {
		opt = &CommitOptions{}
	}

	var env []string
	if opt.AuthorName != "" || opt.AuthorEmail != "" {
		env = []string{
			fmt.Sprintf("GIT_AUTHOR_NAME=%v", opt.AuthorName),
			fmt.Sprintf("GIT_AUTHOR_EMAIL=%v", opt.AuthorEmail),
			fmt.Sprintf("GIT_COMMITTER_NAME=%v", opt.AuthorName),
			fmt.Sprintf("GIT_COMMITTER_EMAIL=%v", opt.AuthorEmail),
		}
	}

	args := []string{"commit"}
	if opt.Amend {
		args = append(args, "--amend")
	} else {
		args = append(args, "-m", msg)
	}
	if _, err := r.run(env, opt.Timeout, args...); err != nil {
		return Hash{}, err
	}
	out, err := r.run(nil, 0, "rev-parse", "HEAD")
	if err != nil {
		return Hash{}, err
	}
	return ParseHash(out)
}

// Optional settings for Repository.Clean
type CleanOptions struct {
	// Timeout for the operation
	Timeout time.Duration
}

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
// Clean deletes all the untracked files in the repo
func (r Repository) Clean(opt *CleanOptions) error {
	if opt == nil {
		opt = &CleanOptions{}
	}
	if _, err := r.run(nil, opt.Timeout, "clean", "-fd"); err != nil {
		return err
	}
	return nil
}

// Optional settings for Repository.Checkout
type CheckoutOptions struct {
	// Timeout for the operation
	Timeout time.Duration
}

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
// Checkout performs a checkout of a reference.
func (r Repository) Checkout(ref string, opt *CheckoutOptions) error {
	if opt == nil {
		opt = &CheckoutOptions{}
	}
	if _, err := r.run(nil, opt.Timeout, "checkout", ref); err != nil {
		return err
	}
	return nil
}

// Optional settings for Repository.Log
type LogOptions struct {
	// The git reference to the oldest commit in the range to query.
	From string
	// The git reference to the newest commit in the range to query.
	To string
	// Timeout for the operation
	Timeout time.Duration
}

const logPrettyFormatArg = "--pretty=format:ǁ%Hǀ%cIǀ%an <%ae>ǀ%sǀ%b"

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
// Log returns the list of commits between two references (inclusive).
// The first returned commit is the most recent.
func (r Repository) Log(opt *LogOptions) ([]CommitInfo, error) {
	if opt == nil {
		opt = &LogOptions{}
	}
	args := []string{"log"}
	rng := "HEAD"
	if opt.To != "" {
		rng = opt.To
	}
	if opt.From != "" {
		rng = opt.From + "^.." + rng
	}
	args = append(args, rng, logPrettyFormatArg)
	out, err := r.run(nil, opt.Timeout, args...)
	if err != nil {
		return nil, err
	}
	return parseLog(out)
}

// Optional settings for Repository.LogBetween
type LogBetweenOptions struct {
	// Timeout for the operation
	Timeout time.Duration
}

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
// LogBetween returns the list of commits between two timestamps
// The first returned commit is the most recent.
func (r Repository) LogBetween(since, until time.Time, opt *LogBetweenOptions) ([]CommitInfo, error) {
	if opt == nil {
		opt = &LogBetweenOptions{}
	}
	args := []string{"log",
		"--since", since.Format(time.RFC3339),
		"--until", until.Format(time.RFC3339),
		logPrettyFormatArg,
	}
	out, err := r.run(nil, opt.Timeout, args...)
	if err != nil {
		return nil, err
	}
	return parseLog(out)
}

// FileStats describes the changes to a given file in a commit
type FileStats struct {
	Insertions int
	Deletions  int
}

// CommitStats is a map of file to FileStats
type CommitStats map[string]FileStats

// Optional settings for Repository.Stats
type StatsOptions struct {
	// Timeout for the operation
	Timeout time.Duration
}

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
// StatsOptions returns the statistics for a given change
func (r Repository) Stats(commit CommitInfo, opt *StatsOptions) (CommitStats, error) {
	if opt == nil {
		opt = &StatsOptions{}
	}

	hash := commit.Hash.String()
	args := []string{"diff", "--numstat", hash, hash + "^"}
	out, err := r.run(nil, opt.Timeout, args...)
	if err != nil {
		return nil, err
	}
	stats := CommitStats{}
	for _, line := range strings.Split(out, "\n") {
		if out == "" {
			continue
		}
		parts := strings.Split(line, "\t")
		if len(parts) != 3 {
			return nil, fmt.Errorf("failed to parse stat line: '%v'", line)
		}
		insertions, deletions := 0, 0
		if parts[0] != "-" {
			insertions, err = strconv.Atoi(parts[0])
			if err != nil {
				return nil, fmt.Errorf("failed to stat insertions '%v': %w", parts[0], err)
			}
		}
		if parts[1] != "-" {
			deletions, err = strconv.Atoi(parts[1])
			if err != nil {
				return nil, fmt.Errorf("failed to stat deletions '%v': %w", parts[1], err)
			}
		}
		file := parts[2]
		stats[file] = FileStats{Insertions: insertions, Deletions: deletions}
	}
	return stats, nil
}

// CommitInfo describes a single git commit
type CommitInfo struct {
	Hash        Hash
	Date        time.Time
	Author      string
	Subject     string
	Description string
}

// Optional settings for Repository.ConfigOptions
type ConfigOptions struct {
	// Timeout for the operation
	Timeout time.Duration
}

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
// Config returns the git configuration values for the repo
func (r Repository) Config(opt *ConfigOptions) (map[string]string, error) {
	if opt == nil {
		opt = &ConfigOptions{}
	}
	text, err := r.run(nil, opt.Timeout, "config", "-l")
	if err != nil {
		return nil, err
	}
	lines := strings.Split(text, "\n")
	out := make(map[string]string, len(lines))
	for _, line := range lines {
		idx := strings.Index(line, "=")
		if idx > 0 {
			key, value := line[:idx], line[idx+1:]
			out[key] = value
		}
	}
	return out, nil
}

func (r Repository) run(env []string, timeout time.Duration, args ...string) (string, error) {
	return r.Git.run(r.Path, env, timeout, args...)
}

// TODO(crbug.com/416755658): Add unittest coverage once exec is handled via
// dependency injection.
func (g Git) run(dir string, env []string, timeout time.Duration, args ...string) (string, error) {
	if timeout == 0 {
		timeout = DefaultTimeout
	}
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()
	cmd := exec.CommandContext(ctx, g.exe, args...)
	cmd.Dir = dir
	if env != nil {
		// Godocs for exec.Cmd.Env:
		// "If Env contains duplicate environment keys, only the last value in
		// the slice for each duplicate key is used.
		cmd.Env = append(g.os.Environ(), env...)
	}
	if g.LogAllActions {
		fmt.Printf("%v> %v %v\n", dir, g.exe, strings.Join(args, " "))
	}
	out, err := cmd.CombinedOutput()
	if g.LogAllActions {
		fmt.Println(string(out))
	}
	if err != nil {
		msg := fmt.Sprintf("%v> %v %v failed:", dir, g.exe, strings.Join(args, " "))
		if err := ctx.Err(); err != nil {
			msg += "\n" + err.Error()
		}
		return string(out), fmt.Errorf("%s\n  %w\n%v", msg, err, string(out))
	}
	return strings.TrimSpace(string(out)), nil
}

func parseLog(str string) ([]CommitInfo, error) {
	msgs := strings.Split(str, "ǁ")
	cls := make([]CommitInfo, 0, len(msgs))
	for _, s := range msgs {
		if parts := strings.Split(s, "ǀ"); len(parts) == 5 {
			hash, err := ParseHash(parts[0])
			if err != nil {
				return nil, err
			}
			date, err := time.Parse(time.RFC3339, parts[1])
			if err != nil {
				return nil, err
			}
			cl := CommitInfo{
				Hash:        hash,
				Date:        date,
				Author:      strings.TrimSpace(parts[2]),
				Subject:     strings.TrimSpace(parts[3]),
				Description: strings.TrimSpace(parts[4]),
			}

			cls = append(cls, cl)
		}
	}
	return cls, nil
}
