package helpers

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"sync"

	appTypes "github.com/jesseduffield/lazygit/pkg/app/types"
	"github.com/jesseduffield/lazygit/pkg/commands"
	"github.com/jesseduffield/lazygit/pkg/commands/direnv"
	"github.com/jesseduffield/lazygit/pkg/commands/models"
	"github.com/jesseduffield/lazygit/pkg/env"
	"github.com/jesseduffield/lazygit/pkg/gui/context"
	"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
	"github.com/jesseduffield/lazygit/pkg/gui/style"
	"github.com/jesseduffield/lazygit/pkg/gui/types"
	"github.com/jesseduffield/lazygit/pkg/utils"
	"github.com/samber/lo"
)

type onNewRepoFn func(startArgs appTypes.StartArgs, contextKey types.ContextKey) error

// helps switch back and forth between repos
type ReposHelper struct {
	c                     *HelperCommon
	recordDirectoryHelper *RecordDirectoryHelper
	onNewRepo             onNewRepoFn
}

func NewRecentReposHelper(
	c *HelperCommon,
	recordDirectoryHelper *RecordDirectoryHelper,
	onNewRepo onNewRepoFn,
) *ReposHelper {
	return &ReposHelper{
		c:                     c,
		recordDirectoryHelper: recordDirectoryHelper,
		onNewRepo:             onNewRepo,
	}
}

func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error {
	// Check before pushing onto the repo-path stack, so a refused switch
	// doesn't leave a stale entry there (which escape would later switch back
	// to, needlessly reloading the current repo).
	if self.switchRefusedBecauseBusy() {
		return nil
	}

	wd, err := os.Getwd()
	if err != nil {
		return err
	}
	self.c.State().GetRepoPathStack().Push(types.RepoLocation{
		Path:               wd,
		GitLocationEnvVars: self.c.Git().RepoPaths.GitLocationEnvVars(),
	})

	return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
}

func (self *ReposHelper) getCurrentBranch(path string) string {
	readHeadFile := func(path string) (string, error) {
		headFile, err := os.ReadFile(filepath.Join(path, "HEAD"))
		if err == nil {
			content := strings.TrimSpace(string(headFile))
			refsPrefix := "ref: refs/heads/"
			var branchDisplay string
			if bareName, ok := strings.CutPrefix(content, refsPrefix); ok {
				// is a branch
				branchDisplay = bareName
			} else {
				// detached HEAD state, displaying short hash
				branchDisplay = utils.ShortHash(content)
			}
			return branchDisplay, nil
		}
		return "", err
	}

	gitDirPath := filepath.Join(path, ".git")

	if gitDir, err := os.Stat(gitDirPath); err == nil {
		if gitDir.IsDir() {
			// ordinary repo
			if branch, err := readHeadFile(gitDirPath); err == nil {
				return branch
			}
		} else {
			// worktree
			if worktreeGitDir, err := os.ReadFile(gitDirPath); err == nil {
				content := strings.TrimSpace(string(worktreeGitDir))
				worktreePath := strings.TrimPrefix(content, "gitdir: ")
				if branch, err := readHeadFile(worktreePath); err == nil {
					return branch
				}
			}
		}
	}

	return self.c.Tr.BranchUnknown
}

func (self *ReposHelper) CreateRecentReposMenu() error {
	// we'll show an empty panel if there are no recent repos
	recentRepoPaths := []string{}
	if len(self.c.GetAppState().RecentRepos) > 0 {
		// we skip the first one because we're currently in it
		recentRepoPaths = self.c.GetAppState().RecentRepos[1:]
	}

	currentBranches := sync.Map{}

	wg := sync.WaitGroup{}
	wg.Add(len(recentRepoPaths))

	for _, path := range recentRepoPaths {
		go func(path string) {
			defer wg.Done()
			currentBranches.Store(path, self.getCurrentBranch(path))
		}(path)
	}

	wg.Wait()

	menuItems := lo.Map(recentRepoPaths, func(path string, _ int) *types.MenuItem {
		branchName, _ := currentBranches.Load(path)
		if icons.IsIconEnabled() {
			branchName = icons.BRANCH_ICON + " " + fmt.Sprintf("%v", branchName)
		}

		return &types.MenuItem{
			LabelColumns: []string{
				filepath.Base(path),
				style.FgCyan.Sprint(branchName),
				style.FgMagenta.Sprint(path),
			},
			OnPress: func() error {
				// Check before clearing the stack, so a refused switch doesn't
				// forget the submodule breadcrumb (which would leave escape
				// unable to return to the parent repo).
				if self.switchRefusedBecauseBusy() {
					return nil
				}
				// if we were in a submodule, we want to forget about that stack of repos
				// so that hitting escape in the new repo does nothing
				self.c.State().GetRepoPathStack().Clear()
				return self.switchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
			},
		}
	})

	return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems})
}

// SwitchToParentRepo switches back to the repo the current submodule was
// entered from (the top of the repo-path stack). Like the other callers that do
// work before switching, it checks for an in-flight operation *before* popping
// the stack, so a refused switch leaves the stack intact — otherwise the entry
// would be consumed and escape would no longer return to the parent once the
// operation finished. The caller must only call this when the stack is
// non-empty.
func (self *ReposHelper) SwitchToParentRepo() error {
	if self.switchRefusedBecauseBusy() {
		return nil
	}
	return self.switchToLocation(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
}

func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error {
	if self.switchRefusedBecauseBusy() {
		return nil
	}
	return self.switchTo(path, errMsg, contextKey)
}

// switchRefusedBecauseBusy reports (and shows a toast) whether a repo switch
// must be refused because a foreground git operation is in flight. Switching
// reassigns gui.git and the process cwd, so switching mid-operation would run
// the operation's remaining git commands against the wrong repo. Callers that
// do work before the switch (creating a worktree, recording the repo-path
// stack) check this up front, so they don't do that work only to have the
// switch refused; the switch itself (switchTo) is then unguarded.
func (self *ReposHelper) switchRefusedBecauseBusy() bool {
	if self.c.GocuiGui().Busy() {
		self.c.ErrorToast(self.c.Tr.CantSwitchWhileOperationInProgress)
		return true
	}
	return false
}

// switchTo switches lazygit to the repository (or worktree) at the given path,
// which git is expected to find from that path alone. That's true of every repo
// we switch to without having been there before.
func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.ContextKey) error {
	return self.switchToLocation(types.RepoLocation{Path: path}, errMsg, contextKey)
}

// switchToLocation switches lazygit to the repository (or worktree) at the
// given location. It runs synchronously on the UI thread: the switch swaps
// gui.State (in resetState) and reassigns gui.git and the process cwd, all of
// which the UI thread also reads, so doing it here rather than on a worker
// avoids racing those reads. The heavy data loading is still dispatched
// asynchronously by the refresh that onNewRepo kicks off.
//
// Everything from here on has to find the repo the way git does, from the
// directory we're about to change to, so the location's environment goes into
// the process env before we do. Usually that just clears whatever the repo
// we're leaving needed, but going back to a repo whose git dir isn't in its
// work tree (a dotfile repo opened with --git-dir/--work-tree, say) is the
// reason we remember the environment at all: nothing in the path leads to its
// git dir. On failure we put back what the repo we're staying in needs.
func (self *ReposHelper) switchToLocation(location types.RepoLocation, errMsg string, contextKey types.ContextKey) error {
	originalPath, err := os.Getwd()
	if err != nil {
		return nil
	}
	originalGitLocationEnvVars := env.GetGitLocationEnvVars()

	env.SetGitLocationEnvVars(location.GitLocationEnvVars)

	msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": location.Path})
	self.c.LogCommand(msg, false)

	if err := os.Chdir(location.Path); err != nil {
		env.SetGitLocationEnvVars(originalGitLocationEnvVars)
		if os.IsNotExist(err) {
			return errors.New(errMsg)
		}
		return err
	}

	if err := commands.VerifyInGitRepo(self.c.OS()); err != nil {
		env.SetGitLocationEnvVars(originalGitLocationEnvVars)
		if err := os.Chdir(originalPath); err != nil {
			return err
		}

		return err
	}

	direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd))

	if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil {
		self.c.Log.Errorf("error recording current directory: %v", err)
	}

	if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil {
		return err
	}

	if direnvResult.Blocked {
		self.promptDirenvApproval(direnvResult.EnvrcPath)
		return nil
	}

	return direnvResult.Err
}

// logDirenvResult writes whatever direnv emitted to the command log and the
// debug log; both happen for every load attempt regardless of outcome.
func (self *ReposHelper) logDirenvResult(result direnv.LoadResult) direnv.LoadResult {
	if result.Message != "" {
		self.c.LogCommand(result.Message, false)
	}
	if result.Err != nil {
		self.c.Log.WithError(result.Err).Warn("direnv load failed")
	}
	return result
}

// promptDirenvApproval shows the user the contents of an unapproved .envrc
// and offers to run `direnv allow` for them. On confirm, we approve the
// file and re-run Load so the new env reaches subprocesses; on cancel we
// leave the env as-is (the previous repo's vars are already unloaded by
// the initial Load call, which is the correct state).
func (self *ReposHelper) promptDirenvApproval(envrcPath string) {
	content, err := os.ReadFile(envrcPath)
	if err != nil {
		self.c.Log.WithError(err).Warn("could not read .envrc for approval prompt")
		return
	}

	indented := "  " + strings.ReplaceAll(strings.TrimRight(string(content), "\n"), "\n", "\n  ")
	prompt := utils.ResolvePlaceholderString(self.c.Tr.DirenvApprovalPrompt, map[string]string{
		"confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm.String(),
		"cancelKey":  self.c.UserConfig().Keybinding.Universal.Return.String(),
		"content":    indented,
	})

	self.c.Confirm(types.ConfirmOpts{
		Title:  self.c.Tr.DirenvApprovalTitle,
		Prompt: prompt,
		HandleConfirm: func() error {
			if err := direnv.Allow(self.c.OS().Cmd, envrcPath); err != nil {
				return err
			}
			return self.logDirenvResult(direnv.Load(self.c.OS().Cmd)).Err
		},
	})
}
