// Copyright 2023 Harness, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package api

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/harness/gitness/git/command"

	"github.com/rs/zerolog/log"
)

type CloneRepoOptions struct {
	Timeout       time.Duration
	Mirror        bool
	Bare          bool
	Quiet         bool
	Branch        string
	Shared        bool
	NoCheckout    bool
	Depth         int
	Filter        string
	SkipTLSVerify bool
}

type PushOptions struct {
	Remote         string
	Branch         string
	Force          bool
	ForceWithLease string
	Env            []string
	Timeout        time.Duration
	Mirror         bool
}

// ObjectCount represents the parsed information from the `git count-objects -v` command.
// For field meanings, see https://git-scm.com/docs/git-count-objects#_options.
type ObjectCount struct {
	Count         int
	Size          int64
	InPack        int
	Packs         int
	SizePack      int64
	PrunePackable int
	Garbage       int
	SizeGarbage   int64
}

const (
	gitReferenceNamePrefixBranch = "refs/heads/"
	gitReferenceNamePrefixTag    = "refs/tags/"
)

var lsRemoteHeadRegexp = regexp.MustCompile(`ref: refs/heads/([^\s]+)\s+HEAD`)

// InitRepository initializes a new Git repository.
func (g *Git) InitRepository(
	ctx context.Context,
	repoPath string,
	bare bool,
) error {
	if repoPath == "" {
		return ErrRepositoryPathEmpty
	}
	err := os.MkdirAll(repoPath, os.ModePerm)
	if err != nil {
		return fmt.Errorf("failed to create directory '%s', err: %w", repoPath, err)
	}

	cmd := command.New("init")
	if bare {
		cmd.Add(command.WithFlag("--bare"))
	}
	return cmd.Run(ctx, command.WithDir(repoPath))
}

// SetDefaultBranch sets the default branch of a repo.
func (g *Git) SetDefaultBranch(
	ctx context.Context,
	repoPath string,
	defaultBranch string,
	allowEmpty bool,
) error {
	if repoPath == "" {
		return ErrRepositoryPathEmpty
	}

	// if requested, error out if branch doesn't exist. Otherwise, blindly set it.
	exist, err := g.IsBranchExist(ctx, repoPath, defaultBranch)
	if err != nil {
		log.Ctx(ctx).Err(err).Msgf("failed to set default branch")
	}
	if !allowEmpty && !exist {
		// TODO: ensure this returns not found error to caller
		return fmt.Errorf("branch '%s' does not exist", defaultBranch)
	}

	// change default branch
	cmd := command.New("symbolic-ref",
		command.WithArg("HEAD", gitReferenceNamePrefixBranch+defaultBranch),
	)
	err = cmd.Run(ctx, command.WithDir(repoPath))
	if err != nil {
		return processGitErrorf(err, "failed to set new default branch")
	}

	return nil
}

// GetDefaultBranch gets the default branch of a repo.
func (g *Git) GetDefaultBranch(
	ctx context.Context,
	repoPath string,
) (string, error) {
	if repoPath == "" {
		return "", ErrRepositoryPathEmpty
	}

	// get default branch
	cmd := command.New("symbolic-ref",
		command.WithArg("HEAD"),
	)
	output := &bytes.Buffer{}
	err := cmd.Run(ctx,
		command.WithDir(repoPath),
		command.WithStdout(output))
	if err != nil {
		return "", processGitErrorf(err, "failed to get default branch")
	}

	return output.String(), nil
}

// GetRemoteDefaultBranch retrieves the default branch of a remote repository.
// If the repo doesn't have a default branch, types.ErrNoDefaultBranch is returned.
func (g *Git) GetRemoteDefaultBranch(
	ctx context.Context,
	remoteURL string,
) (string, error) {
	cmd := command.New("ls-remote",
		command.WithConfig("credential.helper", ""),
		command.WithFlag("--symref"),
		command.WithFlag("-q"),
		command.WithArg(remoteURL),
		command.WithArg("HEAD"),
	)
	output := &bytes.Buffer{}
	if err := cmd.Run(ctx, command.WithStdout(output)); err != nil {
		return "", processGitErrorf(err, "failed to ls remote repo")
	}

	// git output looks as follows, and we are looking for the ref that HEAD points to
	// 		ref: refs/heads/main    HEAD
	// 		46963bc7f0b5e8c5f039d50ac9e6e51933c78cdf        HEAD
	match := lsRemoteHeadRegexp.FindStringSubmatch(strings.TrimSpace(output.String()))
	if match == nil {
		return "", ErrNoDefaultBranch
	}

	return match[1], nil
}

func (g *Git) Clone(
	ctx context.Context,
	from string,
	to string,
	opts CloneRepoOptions,
) error {
	if err := os.MkdirAll(to, os.ModePerm); err != nil {
		return err
	}

	cmd := command.New("clone")
	if opts.SkipTLSVerify {
		cmd.Add(command.WithConfig("http.sslVerify", "false"))
	}
	if opts.Mirror {
		cmd.Add(command.WithFlag("--mirror"))
	}
	if opts.Bare {
		cmd.Add(command.WithFlag("--bare"))
	}
	if opts.Quiet {
		cmd.Add(command.WithFlag("--quiet"))
	}
	if opts.Shared {
		cmd.Add(command.WithFlag("-s"))
	}
	if opts.NoCheckout {
		cmd.Add(command.WithFlag("--no-checkout"))
	}
	if opts.Depth > 0 {
		cmd.Add(command.WithFlag("--depth", strconv.Itoa(opts.Depth)))
	}
	if opts.Filter != "" {
		cmd.Add(command.WithFlag("--filter", opts.Filter))
	}
	if len(opts.Branch) > 0 {
		cmd.Add(command.WithFlag("-b", opts.Branch))
	}
	cmd.Add(command.WithPostSepArg(from, to))

	if err := cmd.Run(ctx); err != nil {
		return fmt.Errorf("failed to clone repository: %w", err)
	}

	return nil
}

// Sync synchronizes the repository to match the provided source.
// NOTE: This is a read operation and doesn't trigger any server side hooks.
func (g *Git) Sync(
	ctx context.Context,
	repoPath string,
	source string,
	refSpecs []string,
) error {
	if repoPath == "" {
		return ErrRepositoryPathEmpty
	}
	if len(refSpecs) == 0 {
		refSpecs = []string{"+refs/*:refs/*"}
	}
	cmd := command.New("fetch",
		command.WithConfig("advice.fetchShowForcedUpdates", "false"),
		command.WithConfig("credential.helper", ""),
		command.WithFlag(
			"--quiet",
			"--prune",
			"--atomic",
			"--force",
			"--no-write-fetch-head",
			"--no-show-forced-updates",
		),
		command.WithArg(source),
		command.WithArg(refSpecs...),
	)

	err := cmd.Run(ctx, command.WithDir(repoPath))
	if err != nil {
		return processGitErrorf(err, "failed to sync repo")
	}

	return nil
}

func (g *Git) AddFiles(
	ctx context.Context,
	repoPath string,
	all bool,
	files ...string,
) error {
	if repoPath == "" {
		return ErrRepositoryPathEmpty
	}

	cmd := command.New("add")
	if all {
		cmd.Add(command.WithFlag("--all"))
	}
	cmd.Add(command.WithPostSepArg(files...))

	err := cmd.Run(ctx, command.WithDir(repoPath))
	if err != nil {
		return processGitErrorf(err, "failed to add changes")
	}

	return nil
}

// Commit commits the changes of the repository.
func (g *Git) Commit(
	ctx context.Context,
	repoPath string,
	opts CommitChangesOptions,
) error {
	if repoPath == "" {
		return ErrRepositoryPathEmpty
	}

	cmd := command.New("commit",
		command.WithFlag("-m", opts.Message),
		command.WithAuthorAndDate(
			opts.Author.Identity.Name,
			opts.Author.Identity.Email,
			opts.Author.When,
		),
		command.WithCommitterAndDate(
			opts.Committer.Identity.Name,
			opts.Committer.Identity.Email,
			opts.Committer.When,
		),
	)
	err := cmd.Run(ctx, command.WithDir(repoPath))
	// No stderr but exit status 1 means nothing to commit (see gitea CommitChanges)
	if err != nil && err.Error() != "exit status 1" {
		return processGitErrorf(err, "failed to commit changes")
	}
	return nil
}

// Push pushs local commits to given remote branch.
// TODOD: return our own error types and move to above api.Push method
func (g *Git) Push(
	ctx context.Context,
	repoPath string,
	opts PushOptions,
) error {
	if repoPath == "" {
		return ErrRepositoryPathEmpty
	}
	cmd := command.New("push",
		command.WithConfig("credential.helper", ""),
	)
	if opts.Force {
		cmd.Add(command.WithFlag("-f"))
	}
	if opts.ForceWithLease != "" {
		cmd.Add(command.WithFlag("--force-with-lease=" + opts.ForceWithLease))
	}
	if opts.Mirror {
		cmd.Add(command.WithFlag("--mirror"))
	}
	cmd.Add(command.WithPostSepArg(opts.Remote))

	if len(opts.Branch) > 0 {
		cmd.Add(command.WithPostSepArg(opts.Branch))
	}

	if g.traceGit {
		cmd.Add(command.WithEnv(command.GitTrace, "true"))
	}

	// remove credentials if there are any
	if strings.Contains(opts.Remote, "://") && strings.Contains(opts.Remote, "@") {
		opts.Remote = SanitizeCredentialURLs(opts.Remote)
	}

	var outbuf, errbuf strings.Builder
	err := cmd.Run(ctx,
		command.WithDir(repoPath),
		command.WithStdout(&outbuf),
		command.WithStderr(&errbuf),
		command.WithEnvs(opts.Env...),
	)

	if g.traceGit {
		log.Ctx(ctx).Trace().
			Str("git", "push").
			Err(err).
			Msgf("IN:\n%#v\n\nSTDOUT:\n%s\n\nSTDERR:\n%s", opts, outbuf.String(), errbuf.String())
	}

	if err != nil {
		switch {
		case strings.Contains(errbuf.String(), "non-fast-forward"):
			return &PushOutOfDateError{
				StdOut: outbuf.String(),
				StdErr: errbuf.String(),
				Err:    err,
			}
		case strings.Contains(errbuf.String(), "! [remote rejected]"):
			err := &PushRejectedError{
				StdOut: outbuf.String(),
				StdErr: errbuf.String(),
				Err:    err,
			}
			err.GenerateMessage()
			return err
		case strings.Contains(errbuf.String(), "matches more than one"):
			err := &MoreThanOneError{
				StdOut: outbuf.String(),
				StdErr: errbuf.String(),
				Err:    err,
			}
			return err
		default:
			// fall through to normal error handling
		}
	}

	if err != nil {
		// add commandline error output to error
		if errbuf.Len() > 0 {
			err = fmt.Errorf("%w\ncmd error output: %s", err, errbuf.String())
		}

		return processGitErrorf(err, "failed to push changes")
	}

	return nil
}

func (g *Git) CountObjects(ctx context.Context, repoPath string) (ObjectCount, error) {
	var outbuf strings.Builder
	cmd := command.New("count-objects", command.WithFlag("-v"))
	err := cmd.Run(ctx,
		command.WithDir(repoPath),
		command.WithStdout(&outbuf),
	)
	if err != nil {
		return ObjectCount{}, fmt.Errorf("error running git count-objects: %w", err)
	}

	objectCount := parseGitCountObjectsOutput(ctx, outbuf.String())
	return objectCount, nil
}

func parseGitCountObjectsOutput(ctx context.Context, output string) ObjectCount {
	info := ObjectCount{}

	output = strings.TrimSpace(output)
	lines := strings.Split(output, "\n")

	for _, line := range lines {
		fields := strings.Fields(line)

		switch fields[0] {
		case "count:":
			fmt.Sscanf(fields[1], "%d", &info.Count)
		case "size:":
			fmt.Sscanf(fields[1], "%d", &info.Size)
		case "in-pack:":
			fmt.Sscanf(fields[1], "%d", &info.InPack)
		case "packs:":
			fmt.Sscanf(fields[1], "%d", &info.Packs)
		case "size-pack:":
			fmt.Sscanf(fields[1], "%d", &info.SizePack)
		case "prune-packable:":
			fmt.Sscanf(fields[1], "%d", &info.PrunePackable)
		case "garbage:":
			fmt.Sscanf(fields[1], "%d", &info.Garbage)
		case "size-garbage:":
			fmt.Sscanf(fields[1], "%d", &info.SizeGarbage)
		default:
			log.Ctx(ctx).Warn().Msgf("line '%s: %s' not processed", fields[0], fields[1])
		}
	}

	return info
}
