package git

import (
	"bytes"
	"crypto/sha256"
	"fmt"
	"log"
	"os"
	"os/exec"
	"regexp"
	"strings"

	"github.com/dgraph-io/ristretto"
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"tangled.org/core/patchutil"
	"tangled.org/core/types"
)

type MergeCheckCache struct {
	cache *ristretto.Cache
}

var (
	mergeCheckCache    MergeCheckCache
	conflictErrorRegex = regexp.MustCompile(`^error: (.*):(\d+): (.*)$`)
)

func init() {
	cache, _ := ristretto.NewCache(&ristretto.Config{
		NumCounters:            1e7,
		MaxCost:                1 << 30,
		BufferItems:            64,
		TtlTickerDurationInSec: 60 * 60 * 24 * 2, // 2 days
	})
	mergeCheckCache = MergeCheckCache{cache}
}

func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string {
	sep := byte(':')
	hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch))
	return fmt.Sprintf("%x", hash)
}

// we can't cache "mergeable" in risetto, nil is not cacheable
//
// we use the sentinel value instead
func (m *MergeCheckCache) cacheVal(check error) any {
	if check == nil {
		return struct{}{}
	} else {
		return check
	}
}

func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) {
	key := m.cacheKey(g, patch, targetBranch)
	val := m.cacheVal(mergeCheck)
	m.cache.Set(key, val, 0)
}

func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) {
	key := m.cacheKey(g, patch, targetBranch)
	if val, ok := m.cache.Get(key); ok {
		if val == struct{}{} {
			// cache hit for mergeable
			return nil, true
		} else if e, ok := val.(error); ok {
			// cache hit for merge conflict
			return e, true
		}
	}

	// cache miss
	return nil, false
}

type ErrMerge struct {
	Message     string
	Conflicts   []ConflictInfo
	HasConflict bool
	OtherError  error
}

type ConflictInfo struct {
	Filename string
	Reason   string
}

// MergeOptions specifies the configuration for a merge operation
type MergeOptions struct {
	CommitMessage  string
	CommitBody     string
	AuthorName     string
	AuthorEmail    string
	CommitterName  string
	CommitterEmail string
	FormatPatch    bool
}

func (e ErrMerge) Error() string {
	if e.HasConflict {
		return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts))
	}
	if e.OtherError != nil {
		return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError)
	}
	return fmt.Sprintf("merge failed: %s", e.Message)
}

func createTemp(data string) (string, error) {
	tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
	if err != nil {
		return "", fmt.Errorf("failed to create temporary patch file: %w", err)
	}

	if _, err := tmpFile.Write([]byte(data)); err != nil {
		tmpFile.Close()
		os.Remove(tmpFile.Name())
		return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
	}

	if err := tmpFile.Close(); err != nil {
		os.Remove(tmpFile.Name())
		return "", fmt.Errorf("failed to close temporary patch file: %w", err)
	}

	return tmpFile.Name(), nil
}

func (g *GitRepo) cloneTemp(targetBranch string) (string, error) {
	tmpDir, err := os.MkdirTemp("", "git-clone-")
	if err != nil {
		return "", fmt.Errorf("failed to create temporary directory: %w", err)
	}

	_, err = git.PlainClone(tmpDir, false, &git.CloneOptions{
		URL:           "file://" + g.path,
		Depth:         1,
		SingleBranch:  true,
		ReferenceName: plumbing.NewBranchReferenceName(targetBranch),
	})
	if err != nil {
		os.RemoveAll(tmpDir)
		return "", fmt.Errorf("failed to clone repository: %w", err)
	}

	return tmpDir, nil
}

func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error {
	var stderr bytes.Buffer
	var cmd *exec.Cmd

	// configure default git user before merge
	exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run()
	exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run()
	exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run()
	exec.Command("git", "-C", g.path, "config", "advice.amWorkDir", "false").Run()

	// if patch is a format-patch, apply using 'git am'
	if opts.FormatPatch {
		return g.applyMailbox(patchData)
	}

	// else, apply using 'git apply' and commit it manually
	applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile)
	applyCmd.Stderr = &stderr
	if err := applyCmd.Run(); err != nil {
		return fmt.Errorf("patch application failed: %s", stderr.String())
	}

	stageCmd := exec.Command("git", "-C", g.path, "add", ".")
	if err := stageCmd.Run(); err != nil {
		return fmt.Errorf("failed to stage changes: %w", err)
	}

	commitArgs := []string{"-C", g.path, "commit"}

	// Set author if provided
	authorName := opts.AuthorName
	authorEmail := opts.AuthorEmail

	if authorName != "" && authorEmail != "" {
		commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
	}
	// else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables

	commitArgs = append(commitArgs, "-m", opts.CommitMessage)

	if opts.CommitBody != "" {
		commitArgs = append(commitArgs, "-m", opts.CommitBody)
	}

	cmd = exec.Command("git", commitArgs...)

	cmd.Stderr = &stderr

	if err := cmd.Run(); err != nil {
		conflicts := parseGitApplyErrors(stderr.String())
		return &ErrMerge{
			Message:     "patch cannot be applied cleanly",
			Conflicts:   conflicts,
			HasConflict: len(conflicts) > 0,
			OtherError:  err,
		}
	}

	return nil
}

func (g *GitRepo) applyMailbox(patchData string) error {
	fps, err := patchutil.ExtractPatches(patchData)
	if err != nil {
		return fmt.Errorf("failed to extract patches: %w", err)
	}

	// apply each patch one by one
	// update the newly created commit object to add the change-id header
	total := len(fps)
	for i, p := range fps {
		newCommit, err := g.applySingleMailbox(p)
		if err != nil {
			return err
		}

		log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String())
	}

	return nil
}

func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) {
	tmpPatch, err := createTemp(singlePatch.Raw)
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singular mailbox patch: %w", err)
	}

	var stderr bytes.Buffer
	cmd := exec.Command("git", "-C", g.path, "am", tmpPatch)
	cmd.Stderr = &stderr

	head, err := g.r.Head()
	if err != nil {
		return plumbing.ZeroHash, err
	}
	log.Println("head before apply", head.Hash().String())

	if err := cmd.Run(); err != nil {
		conflicts := parseGitApplyErrors(stderr.String())
		return plumbing.ZeroHash, &ErrMerge{
			Message:     "patch cannot be applied cleanly",
			Conflicts:   conflicts,
			HasConflict: len(conflicts) > 0,
			OtherError:  err,
		}
	}

	if err := g.Refresh(); err != nil {
		return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err)
	}

	head, err = g.r.Head()
	if err != nil {
		return plumbing.ZeroHash, err
	}
	log.Println("head after apply", head.Hash().String())

	newHash := head.Hash()
	if changeId, err := singlePatch.ChangeId(); err != nil {
		// no change ID
	} else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil {
		return plumbing.ZeroHash, err
	} else {
		newHash = updatedHash
	}

	return newHash, nil
}

func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) {
	log.Printf("updating change ID of %s to %s\n", hash.String(), changeId)
	obj, err := g.r.CommitObject(hash)
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err)
	}

	// write the change-id header
	obj.ExtraHeaders["change-id"] = []byte(changeId)

	// create a new object
	dest := g.r.Storer.NewEncodedObject()
	if err := obj.Encode(dest); err != nil {
		return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err)
	}

	// store the new object
	newHash, err := g.r.Storer.SetEncodedObject(dest)
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err)
	}

	log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String())

	// find the branch that HEAD is pointing to
	ref, err := g.r.Head()
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err)
	}

	// and update that branch to point to new commit
	if ref.Name().IsBranch() {
		err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash))
		if err != nil {
			return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err)
		}
	}

	// new hash of commit
	return newHash, nil
}

func (g *GitRepo) MergeCheckWithOptions(patchData string, targetBranch string, mo MergeOptions) error {
	if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
		return val
	}

	patchFile, err := createTemp(patchData)
	if err != nil {
		return &ErrMerge{
			Message:    err.Error(),
			OtherError: err,
		}
	}
	defer os.Remove(patchFile)

	tmpDir, err := g.cloneTemp(targetBranch)
	if err != nil {
		return &ErrMerge{
			Message:    err.Error(),
			OtherError: err,
		}
	}
	defer os.RemoveAll(tmpDir)

	tmpRepo, err := PlainOpen(tmpDir)
	if err != nil {
		return err
	}

	result := tmpRepo.applyPatch(patchData, patchFile, mo)
	mergeCheckCache.Set(g, patchData, targetBranch, result)
	return result
}

func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error {
	patchFile, err := createTemp(patchData)
	if err != nil {
		return &ErrMerge{
			Message:    err.Error(),
			OtherError: err,
		}
	}
	defer os.Remove(patchFile)

	tmpDir, err := g.cloneTemp(targetBranch)
	if err != nil {
		return &ErrMerge{
			Message:    err.Error(),
			OtherError: err,
		}
	}
	defer os.RemoveAll(tmpDir)

	tmpRepo, err := PlainOpen(tmpDir)
	if err != nil {
		return err
	}

	if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil {
		return err
	}

	pushCmd := exec.Command("git", "-C", tmpDir, "push")
	if err := pushCmd.Run(); err != nil {
		return &ErrMerge{
			Message:    "failed to push changes to bare repository",
			OtherError: err,
		}
	}

	return nil
}

func parseGitApplyErrors(errorOutput string) []ConflictInfo {
	var conflicts []ConflictInfo
	lines := strings.Split(errorOutput, "\n")

	var currentFile string

	for i := range lines {
		line := strings.TrimSpace(lines[i])

		if strings.HasPrefix(line, "error: patch failed:") {
			parts := strings.SplitN(line, ":", 3)
			if len(parts) >= 3 {
				currentFile = strings.TrimSpace(parts[2])
			}
			continue
		}

		if match := conflictErrorRegex.FindStringSubmatch(line); len(match) >= 4 {
			if currentFile == "" {
				currentFile = match[1]
			}

			conflicts = append(conflicts, ConflictInfo{
				Filename: currentFile,
				Reason:   match[3],
			})
			continue
		}

		if strings.Contains(line, "already exists in working directory") {
			conflicts = append(conflicts, ConflictInfo{
				Filename: currentFile,
				Reason:   "file already exists",
			})
		} else if strings.Contains(line, "does not exist in working tree") {
			conflicts = append(conflicts, ConflictInfo{
				Filename: currentFile,
				Reason:   "file does not exist",
			})
		} else if strings.Contains(line, "patch does not apply") {
			conflicts = append(conflicts, ConflictInfo{
				Filename: currentFile,
				Reason:   "patch does not apply",
			})
		}
	}

	return conflicts
}
