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)
}

// createTemp creates a temporary patch file in the system temp directory.
func createTemp(data string) (string, error) {
	return createTempIn("", data)
}

// createTempIn creates a temporary patch file in dir (empty = system /tmp).
func createTempIn(dir string, data string) (string, error) {
	tmpFile, err := os.CreateTemp(dir, "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

	// wrapCmd optionally sandboxes a command to g.path.
	wrapCmd := func(cmd *exec.Cmd) (*exec.Cmd, error) {
		if g.sandbox != nil {
			return g.sandbox.Wrap(g.path, cmd)
		}
		cmd.Dir = g.path
		return cmd, nil
	}

	// configure default git user before merge
	for _, cfgArgs := range [][]string{
		{"-C", g.path, "config", "user.name", opts.CommitterName},
		{"-C", g.path, "config", "user.email", opts.CommitterEmail},
		{"-C", g.path, "config", "advice.mergeConflict", "false"},
		{"-C", g.path, "config", "advice.amWorkDir", "false"},
	} {
		var cfgStderr bytes.Buffer
		cfgCmd, _ := wrapCmd(exec.Command("git", cfgArgs...))
		cfgCmd.Stderr = &cfgStderr
		if err := cfgCmd.Run(); err != nil {
			log.Printf("git config %v failed (non-fatal): err=%v stderr=%q", cfgArgs, err, cfgStderr.String())
		}
	}

	// 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, err := wrapCmd(exec.Command("git", "-C", g.path, "apply", patchFile))
	if err != nil {
		return fmt.Errorf("sandbox wrap for git apply: %w", err)
	}
	applyCmd.Stderr = &stderr
	if err := applyCmd.Run(); err != nil {
		return fmt.Errorf("patch application failed: %s", stderr.String())
	}

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

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

	// 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, err := wrapCmd(exec.Command("git", commitArgs...))
	if err != nil {
		return fmt.Errorf("sandbox wrap for git commit: %w", err)
	}
	stderr.Reset()
	cmd.Stderr = &stderr

	if err := cmd.Run(); err != nil {
		conflicts := parseGitApplyErrors(stderr.String())
		log.Printf("git commit failed: err=%v stderr=%q", err, 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) {
	// when sandboxed, create the patch file inside g.path so it is
	// within the bound directory and visible to the git subprocess.
	patchDir := ""
	if g.sandbox != nil {
		patchDir = g.path
	}
	tmpPatch, err := createTempIn(patchDir, 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
	rawCmd := exec.Command("git", "-C", g.path, "am", tmpPatch)
	var cmd *exec.Cmd
	if g.sandbox != nil {
		cmd, err = g.sandbox.Wrap(g.path, rawCmd)
		if err != nil {
			return plumbing.ZeroHash, fmt.Errorf("sandbox wrap for git am: %w", err)
		}
	} else {
		rawCmd.Dir = g.path
		cmd = rawCmd
	}
	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())
		log.Printf("git am failed: err=%v stderr=%q", err, stderr.String())
		return plumbing.ZeroHash, &ErrMerge{
			Message:     "patch cannot be applied cleanly",
			Conflicts:   conflicts,
			HasConflict: len(conflicts) > 0,
			OtherError:  err,
		}
	}

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

	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
	}

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

	// when sandboxed, create the patch file inside tmpDir so it is
	// visible to the git subprocess.
	patchDir := ""
	if g.sandbox != nil {
		patchDir = tmpDir
	}
	patchFile, err := createTempIn(patchDir, patchData)
	if err != nil {
		return &ErrMerge{
			Message:    err.Error(),
			OtherError: err,
		}
	}
	defer os.Remove(patchFile)

	tmpRepo, err := PlainOpen(tmpDir)
	if err != nil {
		return err
	}
	if g.sandbox != nil {
		tmpRepo = tmpRepo.WithSandbox(g.sandbox)
	}

	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 {
	tmpDir, err := g.cloneTemp(targetBranch)
	if err != nil {
		return &ErrMerge{
			Message:    err.Error(),
			OtherError: err,
		}
	}
	defer os.RemoveAll(tmpDir)

	// when sandboxed, create the patch file inside tmpDir so it is
	// visible to the git subprocess.
	patchDir := ""
	if g.sandbox != nil {
		patchDir = tmpDir
	}
	patchFile, err := createTempIn(patchDir, patchData)
	if err != nil {
		return &ErrMerge{
			Message:    err.Error(),
			OtherError: err,
		}
	}
	defer os.Remove(patchFile)

	tmpRepo, err := PlainOpen(tmpDir)
	if err != nil {
		return err
	}
	if g.sandbox != nil {
		tmpRepo = tmpRepo.WithSandbox(g.sandbox)
	}

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

	pushCmd := exec.Command("git", "-C", tmpDir, "push")
	if g.sandbox != nil {
		// the push needs access to both tmpDir (source) and g.path (target bare repo).
		pushCmd, err = g.sandbox.WrapMulti([]string{tmpDir, g.path}, pushCmd)
		if err != nil {
			return &ErrMerge{
				Message:    "sandbox wrap for git push failed",
				OtherError: err,
			}
		}
	} else {
		pushCmd.Dir = tmpDir
	}
	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
}
