// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.

package gitea

import (
	"bufio"
	"bytes"
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"

	"github.com/harness/gitness/gitrpc/internal/tempdir"
	"github.com/harness/gitness/gitrpc/internal/types"

	"code.gitea.io/gitea/modules/git"
)

// CreateTemporaryRepo creates a temporary repo with "base" for pr.BaseBranch and "tracking" for  pr.HeadBranch
// it also create a second base branch called "original_base".
//
//nolint:funlen,gocognit // need refactor
func (g Adapter) CreateTemporaryRepoForPR(
	ctx context.Context,
	reposTempPath string,
	pr *types.PullRequest,
) (string, error) {
	if pr.BaseRepoPath == "" && pr.HeadRepoPath != "" {
		pr.BaseRepoPath = pr.HeadRepoPath
	}

	if pr.HeadRepoPath == "" && pr.BaseRepoPath != "" {
		pr.HeadRepoPath = pr.BaseRepoPath
	}

	if pr.BaseBranch == "" {
		return "", errors.New("empty base branch")
	}

	if pr.HeadBranch == "" {
		return "", errors.New("empty head branch")
	}

	baseRepoPath := pr.BaseRepoPath
	headRepoPath := pr.HeadRepoPath

	// Clone base repo.
	tmpBasePath, err := tempdir.CreateTemporaryPath(reposTempPath, "pull")
	if err != nil {
		return "", err
	}

	if err = g.InitRepository(ctx, tmpBasePath, false); err != nil {
		// log.Error("git init tmpBasePath: %v", err)
		_ = tempdir.RemoveTemporaryPath(tmpBasePath)
		return "", err
	}

	remoteRepoName := "head_repo"
	baseBranch := "base"

	// Add head repo remote.
	addCacheRepo := func(staging, cache string) error {
		var f *os.File
		alternates := filepath.Join(staging, ".git", "objects", "info", "alternates")
		f, err = os.OpenFile(alternates, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
		if err != nil {
			return fmt.Errorf("failed to open alternates file '%s': %w", alternates, err)
		}
		defer f.Close()
		data := filepath.Join(cache, "objects")
		if _, err = fmt.Fprintln(f, data); err != nil {
			return fmt.Errorf("failed to write alternates file '%s': %w", alternates, err)
		}
		return nil
	}

	if err = addCacheRepo(tmpBasePath, baseRepoPath); err != nil {
		_ = tempdir.RemoveTemporaryPath(tmpBasePath)
		return "", fmt.Errorf("unable to add base repository to temporary repo [%s -> tmpBasePath]: %w", pr.BaseRepoPath, err)
	}

	var outbuf, errbuf strings.Builder
	if err = git.NewCommand(ctx, "remote", "add", "-t", pr.BaseBranch, "-m", pr.BaseBranch, "origin", baseRepoPath).
		Run(&git.RunOpts{
			Dir:    tmpBasePath,
			Stdout: &outbuf,
			Stderr: &errbuf,
		}); err != nil {
		_ = tempdir.RemoveTemporaryPath(tmpBasePath)
		giteaErr := &giteaRunStdError{err: err, stderr: errbuf.String()}
		return "", processGiteaErrorf(giteaErr, "unable to add base repository as origin "+
			"[%s -> tmpBasePath]:\n%s\n%s", pr.BaseRepoPath, outbuf.String(), errbuf.String())
	}
	outbuf.Reset()
	errbuf.Reset()

	if err = git.NewCommand(ctx, "fetch", "origin", "--no-tags", "--",
		pr.BaseBranch+":"+baseBranch, pr.BaseBranch+":original_"+baseBranch).
		Run(&git.RunOpts{
			Dir:    tmpBasePath,
			Stdout: &outbuf,
			Stderr: &errbuf,
		}); err != nil {
		_ = tempdir.RemoveTemporaryPath(tmpBasePath)
		giteaErr := &giteaRunStdError{err: err, stderr: errbuf.String()}
		return "", processGiteaErrorf(giteaErr, "unable to fetch origin base branch "+
			"[%s:%s -> base, original_base in tmpBasePath].\n%s\n%s",
			pr.BaseRepoPath, pr.BaseBranch, outbuf.String(), errbuf.String())
	}
	outbuf.Reset()
	errbuf.Reset()

	if err = git.NewCommand(ctx, "symbolic-ref", "HEAD", git.BranchPrefix+baseBranch).
		Run(&git.RunOpts{
			Dir:    tmpBasePath,
			Stdout: &outbuf,
			Stderr: &errbuf,
		}); err != nil {
		_ = tempdir.RemoveTemporaryPath(tmpBasePath)
		giteaErr := &giteaRunStdError{err: err, stderr: errbuf.String()}
		return "", processGiteaErrorf(giteaErr, "unable to set HEAD as base "+
			"branch [tmpBasePath]:\n%s\n%s", outbuf.String(), errbuf.String())
	}
	outbuf.Reset()
	errbuf.Reset()

	if err = addCacheRepo(tmpBasePath, headRepoPath); err != nil {
		_ = tempdir.RemoveTemporaryPath(tmpBasePath)
		giteaErr := &giteaRunStdError{err: err, stderr: errbuf.String()}
		return "", processGiteaErrorf(giteaErr, "unable to head base repository "+
			"to temporary repo [%s -> tmpBasePath]", pr.HeadRepoPath)
	}

	if err = git.NewCommand(ctx, "remote", "add", remoteRepoName, headRepoPath).
		Run(&git.RunOpts{
			Dir:    tmpBasePath,
			Stdout: &outbuf,
			Stderr: &errbuf,
		}); err != nil {
		_ = tempdir.RemoveTemporaryPath(tmpBasePath)
		giteaErr := &giteaRunStdError{err: err, stderr: errbuf.String()}
		return "", processGiteaErrorf(giteaErr, "unable to add head repository as head_repo "+
			"[%s -> tmpBasePath]:\n%s\n%s", pr.HeadRepoPath, outbuf.String(), errbuf.String())
	}
	outbuf.Reset()
	errbuf.Reset()

	trackingBranch := "tracking"
	headBranch := git.BranchPrefix + pr.HeadBranch
	if err = git.NewCommand(ctx, "fetch", "--no-tags", remoteRepoName, headBranch+":"+trackingBranch).
		Run(&git.RunOpts{
			Dir:    tmpBasePath,
			Stdout: &outbuf,
			Stderr: &errbuf,
		}); err != nil {
		_ = tempdir.RemoveTemporaryPath(tmpBasePath)
		giteaErr := &giteaRunStdError{err: err, stderr: errbuf.String()}
		return "", processGiteaErrorf(giteaErr, "unable to fetch head_repo head branch "+
			"[%s:%s -> tracking in tmpBasePath]:\n%s\n%s",
			pr.HeadRepoPath, headBranch, outbuf.String(), errbuf.String())
	}
	outbuf.Reset()
	errbuf.Reset()

	return tmpBasePath, nil
}

func (g Adapter) Merge(
	ctx context.Context,
	pr *types.PullRequest,
	mergeMethod string,
	trackingBranch string,
	tmpBasePath string,
	mergeMsg string,
	env []string,
) error {
	// TODO: mergeMethod should be an enum.
	if mergeMethod != "merge" {
		return fmt.Errorf("merge method '%s' is not supported", mergeMethod)
	}
	var outbuf, errbuf strings.Builder
	args := []string{
		mergeMethod,
		"--no-ff",
		trackingBranch,
	}

	// override message for merging iff mergeMsg was provided (only for merge for now)
	if mergeMethod == "merge" && mergeMsg != "" {
		args = append(args, "-m", mergeMsg)
	}

	cmd := git.NewCommand(ctx, args...)
	if err := cmd.Run(&git.RunOpts{
		Env:    env,
		Dir:    tmpBasePath,
		Stdout: &outbuf,
		Stderr: &errbuf,
	}); err != nil {
		// Merge will leave a MERGE_HEAD file in the .git folder if there is a conflict
		if _, statErr := os.Stat(filepath.Join(tmpBasePath, ".git", "MERGE_HEAD")); statErr == nil {
			// We have a merge conflict error
			if err = conflictFiles(ctx, pr, env, tmpBasePath, &outbuf); err != nil {
				return err
			}
			return &types.MergeConflictsError{
				Method: mergeMethod,
				StdOut: outbuf.String(),
				StdErr: errbuf.String(),
				Err:    err,
			}
		} else if strings.Contains(errbuf.String(), "refusing to merge unrelated histories") {
			return &types.MergeUnrelatedHistoriesError{
				Method: mergeMethod,
				StdOut: outbuf.String(),
				StdErr: errbuf.String(),
				Err:    err,
			}
		}
		giteaErr := &giteaRunStdError{err: err, stderr: errbuf.String()}
		return processGiteaErrorf(giteaErr, "git merge [%s -> %s]\n%s\n%s",
			pr.HeadBranch, pr.BaseBranch, outbuf.String(), errbuf.String())
	}

	return nil
}

func conflictFiles(ctx context.Context,
	pr *types.PullRequest,
	env []string,
	repoPath string,
	buf *strings.Builder,
) error {
	stdout, stderr, cferr := git.NewCommand(
		ctx, "diff", "--name-only", "--diff-filter=U", "--relative",
	).RunStdString(&git.RunOpts{
		Env: env,
		Dir: repoPath,
	})
	if cferr != nil {
		return processGiteaErrorf(cferr, "failed to list conflict files [%s -> %s], stderr: %v, err: %v",
			pr.HeadBranch, pr.BaseBranch, stderr, cferr)
	}
	if len(stdout) > 0 {
		buf.Reset()
		buf.WriteString(stdout)
	}
	return nil
}

func (g Adapter) GetDiffTree(ctx context.Context, repoPath, baseBranch, headBranch string) (string, error) {
	getDiffTreeFromBranch := func(repoPath, baseBranch, headBranch string) (string, error) {
		var outbuf, errbuf strings.Builder
		if err := git.NewCommand(ctx, "diff-tree", "--no-commit-id",
			"--name-only", "-r", "-z", "--root", baseBranch, headBranch, "--").
			Run(&git.RunOpts{
				Dir:    repoPath,
				Stdout: &outbuf,
				Stderr: &errbuf,
			}); err != nil {
			giteaErr := &giteaRunStdError{err: err, stderr: errbuf.String()}
			return "", processGiteaErrorf(giteaErr, "git diff-tree [%s base:%s head:%s]: %s",
				repoPath, baseBranch, headBranch, errbuf.String())
		}
		return outbuf.String(), nil
	}

	scanNullTerminatedStrings := func(data []byte, atEOF bool) (advance int, token []byte, err error) {
		if atEOF && len(data) == 0 {
			return 0, nil, nil
		}
		if i := bytes.IndexByte(data, '\x00'); i >= 0 {
			return i + 1, data[0:i], nil
		}
		if atEOF {
			return len(data), data, nil
		}
		return 0, nil, nil
	}

	list, err := getDiffTreeFromBranch(repoPath, baseBranch, headBranch)
	if err != nil {
		return "", err
	}

	// Prefixing '/' for each entry, otherwise all files with the same name in subdirectories would be matched.
	out := bytes.Buffer{}
	scanner := bufio.NewScanner(strings.NewReader(list))
	scanner.Split(scanNullTerminatedStrings)
	for scanner.Scan() {
		filepath := scanner.Text()
		// escape '*', '?', '[', spaces and '!' prefix
		filepath = escapedSymbols.ReplaceAllString(filepath, `\$1`)
		// no necessary to escape the first '#' symbol because the first symbol is '/'
		fmt.Fprintf(&out, "/%s\n", filepath)
	}

	return out.String(), nil
}

// GetMergeBase checks and returns merge base of two branches and the reference used as base.
func (g Adapter) GetMergeBase(ctx context.Context, repoPath, remote, base, head string) (string, string, error) {
	if remote == "" {
		remote = "origin"
	}

	if remote != "origin" {
		tmpBaseName := git.RemotePrefix + remote + "/tmp_" + base
		// Fetch commit into a temporary branch in order to be able to handle commits and tags
		_, _, err := git.NewCommand(ctx, "fetch", "--no-tags", remote, "--",
			base+":"+tmpBaseName).RunStdString(&git.RunOpts{Dir: repoPath})
		if err == nil {
			base = tmpBaseName
		}
	}

	stdout, _, err := git.NewCommand(ctx, "merge-base", "--", base, head).RunStdString(&git.RunOpts{Dir: repoPath})
	if err != nil {
		return "", "", processGiteaErrorf(err, "failed to get merge-base")
	}

	return strings.TrimSpace(stdout), base, nil
}

// giteaRunStdError is an implementation of the RunStdError interface in the gitea codebase.
// It allows us to process gitea errors even when using cmd.Run() instead of cmd.RunStdString() or run.StdBytes().
// TODO: solve this nicer once we have proper gitrpc error handling.
type giteaRunStdError struct {
	err    error
	stderr string
}

func (e *giteaRunStdError) Error() string {
	return fmt.Sprintf("failed with %s, error output: %s", e.err, e.stderr)
}

func (e *giteaRunStdError) Unwrap() error {
	return e.err
}

func (e *giteaRunStdError) Stderr() string {
	return e.stderr
}

func (e *giteaRunStdError) IsExitCode(code int) bool {
	var exitError *exec.ExitError
	if errors.As(e.err, &exitError) {
		return exitError.ExitCode() == code
	}
	return false
}