package git

import (
	"context"
	"fmt"
	"io"
	"log/slog"
	"os"
	"time"

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/config"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/go-git/go-git/v5/plumbing/transport"
)

const (
	remoteName         = "origin"
	newFilePermissions = 0o644
)

type Commit struct {
	Hash    string
	Message string

	PullRequest *PullRequest
}

type PullRequest struct {
	ID          int
	Title       string
	Description string
}

type Tag struct {
	Hash string
	Name string
}

type Releases struct {
	Latest *Tag
	Stable *Tag
}

type Author struct {
	Name  string
	Email string
}

func (a Author) signature(when time.Time) *object.Signature {
	return &object.Signature{
		Name:  a.Name,
		Email: a.Email,
		When:  when,
	}
}

func (a Author) String() string {
	return fmt.Sprintf("%s <%s>", a.Name, a.Email)
}

var (
	committer = Author{Name: "releaser-pleaser", Email: ""}
)

func CloneRepo(ctx context.Context, logger *slog.Logger, cloneURL, branch string, auth transport.AuthMethod) (*Repository, error) {
	dir, err := os.MkdirTemp("", "releaser-pleaser.*")
	if err != nil {
		return nil, fmt.Errorf("failed to create temporary directory for repo clone: %w", err)
	}

	repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{
		URL:           cloneURL,
		RemoteName:    remoteName,
		ReferenceName: plumbing.NewBranchReferenceName(branch),
		SingleBranch:  false,
		Auth:          auth,
	})
	if err != nil {
		return nil, fmt.Errorf("failed to clone repository: %w", err)
	}

	return &Repository{r: repo, logger: logger, auth: auth}, nil
}

type Repository struct {
	r      *git.Repository
	logger *slog.Logger
	auth   transport.AuthMethod
}

func (r *Repository) DeleteBranch(ctx context.Context, branch string) error {
	if b, _ := r.r.Branch(branch); b != nil {
		r.logger.DebugContext(ctx, "deleting local branch", "branch.name", branch)
		if err := r.r.DeleteBranch(branch); err != nil {
			return err
		}
	}

	return nil
}

func (r *Repository) Checkout(_ context.Context, branch string) error {
	worktree, err := r.r.Worktree()
	if err != nil {
		return err
	}

	if err = worktree.Checkout(&git.CheckoutOptions{
		Branch: plumbing.NewBranchReferenceName(branch),
		Create: true,
	}); err != nil {
		return fmt.Errorf("failed to check out branch: %w", err)
	}

	return nil
}

func (r *Repository) UpdateFile(_ context.Context, path string, create bool, updateHook func(string) (string, error)) error {
	worktree, err := r.r.Worktree()
	if err != nil {
		return err
	}

	fileFlags := os.O_RDWR
	if create {
		fileFlags |= os.O_CREATE
	}

	file, err := worktree.Filesystem.OpenFile(path, fileFlags, newFilePermissions)
	if err != nil {
		return err
	}
	defer file.Close() //nolint:errcheck

	content, err := io.ReadAll(file)
	if err != nil {
		return err
	}

	updatedContent, err := updateHook(string(content))
	if err != nil {
		return fmt.Errorf("failed to run update hook on file %s", path)
	}

	err = file.Truncate(0)
	if err != nil {
		return fmt.Errorf("failed to replace file content: %w", err)
	}
	_, err = file.Seek(0, 0)
	if err != nil {
		return fmt.Errorf("failed to replace file content: %w", err)
	}
	_, err = file.Write([]byte(updatedContent))
	if err != nil {
		return fmt.Errorf("failed to replace file content: %w", err)
	}

	_, err = worktree.Add(path)
	if err != nil {
		return fmt.Errorf("failed to add updated file to git worktree: %w", err)
	}

	return nil
}

func (r *Repository) Commit(_ context.Context, message string, author Author) (Commit, error) {
	worktree, err := r.r.Worktree()
	if err != nil {
		return Commit{}, err
	}

	now := time.Now()

	releaseCommitHash, err := worktree.Commit(message, &git.CommitOptions{
		Author:    author.signature(now),
		Committer: committer.signature(now),
	})
	if err != nil {
		return Commit{}, fmt.Errorf("failed to commit changes: %w", err)
	}

	return Commit{
		Hash:    releaseCommitHash.String(),
		Message: message,
	}, nil
}

// HasChangesWithRemote checks if the following two diffs are equal:
//
// - **Local**:                                 remote/main..branch
// - **Remote**: (git merge-base remote/main remote/branch)..remote/branch
//
// This is done to avoid pushing when the only change would be a rebase of remote/branch onto the current remote/main.
func (r *Repository) HasChangesWithRemote(ctx context.Context, mainBranch, prBranch string) (bool, error) {
	return r.hasChangesWithRemote(ctx,
		plumbing.NewRemoteReferenceName(remoteName, mainBranch),
		plumbing.NewBranchReferenceName(prBranch),
		plumbing.NewRemoteReferenceName(remoteName, prBranch),
	)
}

func (r *Repository) hasChangesWithRemote(ctx context.Context, mainBranchRef, localPRBranchRef, remotePRBranchRef plumbing.ReferenceName) (bool, error) {
	commitOnRemoteMain, err := r.commitFromRef(mainBranchRef)
	if err != nil {
		return false, err
	}

	commitOnRemotePRBranch, err := r.commitFromRef(remotePRBranchRef)
	if err != nil {
		if err.Error() == "reference not found" {
			// No remote branch means that there are changes
			return true, nil
		}

		return false, err
	}

	currentRemotePRMergeBase, err := r.mergeBase(commitOnRemoteMain, commitOnRemotePRBranch)
	if err != nil {
		return false, err
	}
	if currentRemotePRMergeBase == nil {
		// If there is no merge base something weird has happened with the
		// remote main branch, and we should definitely push updates.
		return false, nil
	}

	remoteDiff, err := currentRemotePRMergeBase.PatchContext(ctx, commitOnRemotePRBranch)
	if err != nil {
		return false, err
	}

	commitOnLocalPRBranch, err := r.commitFromRef(localPRBranchRef)
	if err != nil {
		return false, err
	}

	localDiff, err := commitOnRemoteMain.PatchContext(ctx, commitOnLocalPRBranch)
	if err != nil {
		return false, err
	}

	return remoteDiff.String() != localDiff.String(), nil
}

func (r *Repository) commitFromRef(refName plumbing.ReferenceName) (*object.Commit, error) {
	ref, err := r.r.Reference(refName, false)
	if err != nil {
		return nil, err
	}

	commit, err := r.r.CommitObject(ref.Hash())
	if err != nil {
		return nil, err
	}

	return commit, nil
}

func (r *Repository) mergeBase(a, b *object.Commit) (*object.Commit, error) {
	mergeBases, err := a.MergeBase(b)
	if err != nil {
		return nil, err
	}

	if len(mergeBases) == 0 {
		return nil, nil
	}

	// :shrug: We dont really care which commit we pick, at worst we do an unnecessary push.
	return mergeBases[0], nil
}

func (r *Repository) ForcePush(ctx context.Context, branch string) error {
	pushRefSpec := config.RefSpec(fmt.Sprintf(
		"+%s:%s",
		plumbing.NewBranchReferenceName(branch),
		// This needs to be the local branch name, not the remotes/origin ref
		// See https://stackoverflow.com/a/75727620
		plumbing.NewBranchReferenceName(branch),
	))

	r.logger.DebugContext(ctx, "pushing branch", "branch.name", branch, "refspec", pushRefSpec.String())
	return r.r.PushContext(ctx, &git.PushOptions{
		RemoteName: remoteName,
		RefSpecs:   []config.RefSpec{pushRefSpec},
		Force:      true,
		Auth:       r.auth,
	})
}
