package git_commands

import (
	"fmt"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/jesseduffield/generics/set"
	"github.com/jesseduffield/lazygit/pkg/commands/models"
	"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
	"github.com/jesseduffield/lazygit/pkg/common"
	"github.com/jesseduffield/lazygit/pkg/utils"
	"github.com/samber/lo"
	"golang.org/x/sync/errgroup"
)

// context:
// we want to only show 'safe' branches (ones that haven't e.g. been deleted)
// which `git branch -a` gives us, but we also want the recency data that
// git reflog gives us.
// So we get the HEAD, then append get the reflog branches that intersect with
// our safe branches, then add the remaining safe branches, ensuring uniqueness
// along the way

// if we find out we need to use one of these functions in the git.go file, we
// can just pull them out of here and put them there and then call them from in here

type BranchLoaderConfigCommands interface {
	Branches(cmd oscommands.ICmdObjBuilder) map[string]*BranchConfig
}

type BranchInfo struct {
	RefName      string
	DisplayName  string // e.g. '(HEAD detached at 123asdf)'
	DetachedHead bool
}

// BranchLoader returns a list of Branch objects for the current repo
type BranchLoader struct {
	*common.Common
	*GitCommon
	cmd                  oscommands.ICmdObjBuilder
	getCurrentBranchInfo func() (BranchInfo, error)
	config               BranchLoaderConfigCommands
}

func NewBranchLoader(
	cmn *common.Common,
	gitCommon *GitCommon,
	cmd oscommands.ICmdObjBuilder,
	getCurrentBranchInfo func() (BranchInfo, error),
	config BranchLoaderConfigCommands,
) *BranchLoader {
	return &BranchLoader{
		Common:               cmn,
		GitCommon:            gitCommon,
		cmd:                  cmd,
		getCurrentBranchInfo: getCurrentBranchInfo,
		config:               config,
	}
}

// Load the list of branches for the current repo
func (self *BranchLoader) Load(reflogCommits []*models.Commit,
	mainBranches *MainBranches,
	oldBranches []*models.Branch,
	loadBehindCounts bool,
	onWorker func(func() error),
	renderFunc func(),
) ([]*models.Branch, error) {
	branches := self.obtainBranches()

	if self.UserConfig().Git.LocalBranchSortOrder == "recency" {
		reflogBranches := self.obtainReflogBranches(reflogCommits)
		// loop through reflog branches. If there is a match, merge them, then remove it from the branches and keep it in the reflog branches
		branchesWithRecency := make([]*models.Branch, 0)
	outer:
		for _, reflogBranch := range reflogBranches {
			for j, branch := range branches {
				if branch.Head {
					continue
				}
				if strings.EqualFold(reflogBranch.Name, branch.Name) {
					branch.Recency = reflogBranch.Recency
					branchesWithRecency = append(branchesWithRecency, branch)
					branches = utils.Remove(branches, j)
					continue outer
				}
			}
		}

		// Sort branches that don't have a recency value alphabetically
		// (we're really doing this for the sake of deterministic behaviour across git versions)
		slices.SortFunc(branches, func(a *models.Branch, b *models.Branch) int {
			return strings.Compare(a.Name, b.Name)
		})

		branches = utils.Prepend(branches, branchesWithRecency...)
	}

	foundHead := false
	for i, branch := range branches {
		if branch.Head {
			foundHead = true
			branch.Recency = "  *"
			branches = utils.Move(branches, i, 0)
			break
		}
	}
	if !foundHead {
		info, err := self.getCurrentBranchInfo()
		if err != nil {
			return nil, err
		}
		branches = utils.Prepend(branches, &models.Branch{Name: info.RefName, DisplayName: info.DisplayName, Head: true, DetachedHead: info.DetachedHead, Recency: "  *"})
	}

	configBranches := self.config.Branches(self.cmd)

	for _, branch := range branches {
		match := configBranches[branch.Name]
		if match != nil {
			branch.UpstreamRemote = match.Remote
			branch.UpstreamBranch = match.Merge
		}

		// If the branch already existed, take over its BehindBaseBranch value
		// to reduce flicker
		if oldBranch, found := lo.Find(oldBranches, func(b *models.Branch) bool {
			return b.Name == branch.Name
		}); found {
			branch.BehindBaseBranch.Store(oldBranch.BehindBaseBranch.Load())
		}
	}

	if loadBehindCounts && self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
		onWorker(func() error {
			return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
		})
	}

	return branches, nil
}

func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches(
	branches []*models.Branch,
	mainBranches *MainBranches,
	renderFunc func(),
) error {
	mainBranchRefs := mainBranches.Get()
	if len(mainBranchRefs) == 0 {
		return nil
	}

	if self.version.IsAtLeast(2, 41, 0) {
		return self.getBehindBaseBranchValuesFast(branches, mainBranchRefs, renderFunc)
	}
	return self.getBehindBaseBranchValuesLegacy(branches, mainBranches, renderFunc)
}

func (self *BranchLoader) getBehindBaseBranchValuesLegacy(
	branches []*models.Branch,
	mainBranches *MainBranches,
	renderFunc func(),
) error {
	t := time.Now()
	errg := errgroup.Group{}

	for _, branch := range branches {
		errg.Go(func() error {
			baseBranch, err := self.GetBaseBranch(branch, mainBranches)
			if err != nil {
				return err
			}
			behind := 0 // prime it in case something below fails
			if baseBranch != "" {
				output, err := self.cmd.New(
					NewGitCmd("rev-list").
						Arg("--left-right").
						Arg("--count").
						Arg(fmt.Sprintf("%s...%s", branch.FullRefName(), baseBranch)).
						ToArgv(),
				).DontLog().RunWithOutput()
				if err != nil {
					return err
				}
				// The format of the output is "<ahead>\t<behind>"
				aheadBehindStr := strings.Split(strings.TrimSpace(output), "\t")
				if len(aheadBehindStr) == 2 {
					if value, err := strconv.Atoi(aheadBehindStr[1]); err == nil {
						behind = value
					}
				}
			}
			branch.BehindBaseBranch.Store(int32(behind))
			return nil
		})
	}

	err := errg.Wait()
	self.Log.Debugf("time to get behind base branch values for all branches (legacy): %s", time.Since(t))
	renderFunc()
	return err
}

// Holds parsed values from a single %(ahead-behind:<base>) field.
type aheadBehind struct {
	ahead, behind int
}

type branchAheadBehind struct {
	refName      string
	aheadBehinds []aheadBehind
}

// Parses output produced by:
//
//	git for-each-ref --format='%(refname)\x00%(ahead-behind:<base1>)\x00...' refs/heads
//
// Lines whose NUL-split column count doesn't match (1 + numBases) are dropped.
// Blank lines are ignored.
// Individual malformed ahead-behind fields produce {valid: false} entries
func parseAheadBehindForEachRefOutput(
	output string,
	numBases int, // number of %(ahead-behind:...) tokens
) []branchAheadBehind {
	if output == "" {
		return nil
	}
	lines := strings.Split(output, "\n")
	result := make([]branchAheadBehind, 0, len(lines))
	for _, line := range lines {
		cols := strings.Split(line, "\x00")
		if len(cols) != numBases+1 {
			continue
		}
		refName := cols[0]
		aheadBehinds := lo.FilterMap(cols[1:], func(col string, _ int) (aheadBehind, bool) {
			return parseAheadBehindField(col)
		})
		entry := branchAheadBehind{
			refName:      refName,
			aheadBehinds: aheadBehinds,
		}
		result = append(result, entry)
	}
	return result
}

func parseAheadBehindField(s string) (aheadBehind, bool) {
	parts := strings.Fields(s)
	if len(parts) != 2 {
		return aheadBehind{}, false
	}
	ahead, err1 := strconv.Atoi(parts[0])
	behind, err2 := strconv.Atoi(parts[1])
	if err1 != nil || err2 != nil {
		return aheadBehind{}, false
	}
	return aheadBehind{ahead: ahead, behind: behind}, true
}

// Picks the "closest" base by smallest ahead value (commits the branch
// has that the base doesn't = roughly "since fork point") and returns
// its behind value.
// Ties are broken by index order
func selectBehindForBranch(aheadBehinds []aheadBehind) int {
	return lo.MinBy(aheadBehinds, func(a, b aheadBehind) bool {
		return a.ahead < b.ahead
	}).behind
}

// The output format is:
//
//	<refname>\x00<ahead> <behind>\x00<ahead> <behind>...\n
//
// with one ahead-behind field per base, in the same order as mainBranchRefs.
//
// Requires git >= 2.41 (when %(ahead-behind:...) was added).
func buildAheadBehindForEachRefArgs(mainBranchRefs []string) []string {
	formatParts := make([]string, 0, 1+len(mainBranchRefs))
	formatParts = append(formatParts, "%(refname)")
	for _, ref := range mainBranchRefs {
		formatParts = append(formatParts, "%(ahead-behind:"+ref+")")
	}
	format := strings.Join(formatParts, "%00")

	return NewGitCmd("for-each-ref").
		Arg("--format=" + format).
		Arg("refs/heads").
		ToArgv()
}

func (self *BranchLoader) getBehindBaseBranchValuesFast(
	branches []*models.Branch,
	mainBranchRefs []string,
	renderFunc func(),
) error {
	t := time.Now()

	output, err := self.cmd.New(
		buildAheadBehindForEachRefArgs(mainBranchRefs),
	).DontLog().RunWithOutput()
	if err != nil {
		return err
	}

	parsed := parseAheadBehindForEachRefOutput(output, len(mainBranchRefs))
	branchByRef := lo.KeyBy(branches, (*models.Branch).FullRefName)

	for _, p := range parsed {
		if branch, ok := branchByRef[p.refName]; ok {
			behind := selectBehindForBranch(p.aheadBehinds)
			branch.BehindBaseBranch.Store(int32(behind))
			delete(branchByRef, p.refName)
		}
	}

	// Branches not in parse are default to 0
	for _, branch := range branchByRef {
		branch.BehindBaseBranch.Store(0)
	}

	self.Log.Debugf("time to get behind base branch values for all branches (fast): %s", time.Since(t))
	renderFunc()
	return nil
}

// Find the base branch for the given branch (i.e. the main branch that the
// given branch was forked off of)
//
// Note that this function may return an empty string even if the returned error
// is nil, e.g. when none of the configured main branches exist. This is not
// considered an error condition, so callers need to check both the returned
// error and whether the returned base branch is empty (and possibly react
// differently in both cases).
func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *MainBranches) (string, error) {
	mergeBase := mainBranches.GetMergeBase(branch.FullRefName())
	if mergeBase == "" {
		return "", nil
	}

	output, err := self.cmd.New(
		NewGitCmd("for-each-ref").
			Arg("--contains").
			Arg(mergeBase).
			Arg("--format=%(refname)").
			Arg(mainBranches.Get()...).
			ToArgv(),
	).DontLog().RunWithOutput()
	if err != nil {
		return "", err
	}
	trimmedOutput := strings.TrimSpace(output)
	split := strings.Split(trimmedOutput, "\n")
	if len(split) == 0 || split[0] == "" {
		return "", nil
	}
	return split[0], nil
}

func (self *BranchLoader) obtainBranches() []*models.Branch {
	output, err := self.getRawBranches()
	if err != nil {
		panic(err)
	}

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

	return lo.FilterMap(outputLines, func(line string, _ int) (*models.Branch, bool) {
		if line == "" {
			return nil, false
		}

		split := strings.Split(line, "\x00")
		if len(split) != len(branchFields) {
			// Ignore line if it isn't separated into the expected number of parts
			// This is probably a warning message, for more info see:
			// https://github.com/jesseduffield/lazygit/issues/1385#issuecomment-885580439
			return nil, false
		}

		storeCommitDateAsRecency := self.UserConfig().Git.LocalBranchSortOrder != "recency"
		return obtainBranch(split, storeCommitDateAsRecency), true
	})
}

func (self *BranchLoader) getRawBranches() (string, error) {
	format := strings.Join(
		lo.Map(branchFields, func(thing string, _ int) string {
			return "%(" + thing + ")"
		}),
		"%00",
	)

	var sortOrder string
	switch strings.ToLower(self.UserConfig().Git.LocalBranchSortOrder) {
	case "recency", "date":
		sortOrder = "-committerdate"
	case "alphabetical":
		sortOrder = "refname"
	default:
		sortOrder = "refname"
	}

	cmdArgs := NewGitCmd("for-each-ref").
		Arg(fmt.Sprintf("--sort=%s", sortOrder)).
		Arg(fmt.Sprintf("--format=%s", format)).
		Arg("refs/heads").
		ToArgv()

	return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
}

var branchFields = []string{
	"HEAD",
	"refname:short",
	"upstream:short",
	"upstream:track",
	"push:track",
	"subject",
	"objectname",
	"committerdate:unix",
}

// Obtain branch information from parsed line output of getRawBranches()
func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch {
	headMarker := split[0]
	fullName := split[1]
	upstreamName := split[2]
	track := split[3]
	pushTrack := split[4]
	subject := split[5]
	commitHash := split[6]
	commitDate := split[7]

	name := strings.TrimPrefix(fullName, "heads/")
	aheadForPull, behindForPull, gone := parseUpstreamInfo(upstreamName, track)
	aheadForPush, behindForPush, _ := parseUpstreamInfo(upstreamName, pushTrack)

	recency := ""
	if storeCommitDateAsRecency {
		if unixTimestamp, err := strconv.ParseInt(commitDate, 10, 64); err == nil {
			recency = utils.UnixToTimeAgo(unixTimestamp)
		}
	}

	return &models.Branch{
		Name:          name,
		Recency:       recency,
		AheadForPull:  aheadForPull,
		BehindForPull: behindForPull,
		AheadForPush:  aheadForPush,
		BehindForPush: behindForPush,
		UpstreamGone:  gone,
		Head:          headMarker == "*",
		Subject:       subject,
		CommitHash:    commitHash,
	}
}

func parseUpstreamInfo(upstreamName string, track string) (string, string, bool) {
	if upstreamName == "" {
		// if we're here then it means we do not have a local version of the remote.
		// The branch might still be tracking a remote though, we just don't know
		// how many commits ahead/behind it is
		return "?", "?", false
	}

	if track == "[gone]" {
		return "?", "?", true
	}

	ahead := parseDifference(track, `ahead (\d+)`)
	behind := parseDifference(track, `behind (\d+)`)

	return ahead, behind, false
}

func parseDifference(track string, regexStr string) string {
	re := regexp.MustCompile(regexStr)
	match := re.FindStringSubmatch(track)
	if len(match) > 1 {
		return match[1]
	}
	return "0"
}

// TODO: only look at the new reflog commits, and otherwise store the recencies in
// int form against the branch to recalculate the time ago
func (self *BranchLoader) obtainReflogBranches(reflogCommits []*models.Commit) []*models.Branch {
	foundBranches := set.New[string]()
	re := regexp.MustCompile(`checkout: moving from ([\S]+) to ([\S]+)`)
	reflogBranches := make([]*models.Branch, 0, len(reflogCommits))

	for _, commit := range reflogCommits {
		match := re.FindStringSubmatch(commit.Name)
		if len(match) != 3 {
			continue
		}

		recency := utils.UnixToTimeAgo(commit.UnixTimestamp)
		for _, branchName := range match[1:] {
			if !foundBranches.Includes(branchName) {
				foundBranches.Add(branchName)
				reflogBranches = append(reflogBranches, &models.Branch{
					Recency: recency,
					Name:    branchName,
				})
			}
		}
	}
	return reflogBranches
}
