package gitutil

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"

	"github.com/hashicorp/go-version"
)

// don't let two requests mutate the same checkout at once
var repoLocks keyedMutex

// entries drop once nobody holds them since callers pick the keys and nothing else bounds the map
type keyedMutex struct {
	mu sync.Mutex
	m  map[string]*keyedLock
}

type keyedLock struct {
	mu    sync.Mutex
	users int
}

func (k *keyedMutex) lock(key string) func() {
	k.mu.Lock()
	if k.m == nil {
		k.m = make(map[string]*keyedLock)
	}
	l, ok := k.m[key]
	if !ok {
		l = &keyedLock{}
		k.m[key] = l
	}
	l.users++
	k.mu.Unlock()

	l.mu.Lock()
	return func() {
		l.mu.Unlock()
		k.mu.Lock()
		defer k.mu.Unlock()
		if l.users--; l.users == 0 {
			delete(k.m, key)
		}
	}
}

func Version() (*version.Version, error) {
	out, err := Output(context.Background(), "version")
	if err != nil {
		return nil, err
	}
	fields := strings.Fields(string(out))
	if len(fields) < 3 {
		return nil, fmt.Errorf("invalid git version: %s", out)
	}

	// git version 2.29.3.windows.1
	versionString := fields[2]
	if pos := strings.Index(versionString, "windows"); pos >= 1 {
		versionString = versionString[:pos-1]
	}
	return version.NewVersion(versionString)
}

// SparseSync updates a sparse checkout, fetching it first if needed
// this should only fail if the repo is completely empty!
func SparseSync(ctx context.Context, cloneUri, path, rev string, sparse ...string) error {
	defer repoLocks.lock(path)()

	exist, err := isDir(path)
	if err != nil {
		return err
	}
	if exist {
		gitDirExist, err := isDir(path + "/.git")
		if err != nil {
			return err
		}
		if !gitDirExist {
			if err := os.RemoveAll(path); err != nil {
				return fmt.Errorf("cleanup invalid git dir: %w", err)
			}
			exist = false
		}
	}
	if rev == "" {
		rev = "HEAD"
	}
	setup := func() error {
		if err := os.MkdirAll(path, 0o755); err != nil {
			return fmt.Errorf("mkdir repo dir: %w", err)
		}
		if err := Run(ctx, "init", "-q", "--object-format="+objectFormat(ctx, cloneUri, rev), path); err != nil {
			return fmt.Errorf("git init: %w", err)
		}
		if err := Run(ctx, "-C", path, "remote", "add", "origin", cloneUri); err != nil {
			return fmt.Errorf("git remote add: %w", err)
		}
		if err := Run(ctx, append([]string{"-C", path, "sparse-checkout", "set", "--no-cone"}, sparse...)...); err != nil {
			return fmt.Errorf("git sparse-checkout set: %w", err)
		}
		return nil
	}
	fetch := func() error {
		return Run(ctx, "-C", path, "fetch", "--depth=1", "--filter=tree:0", "origin", rev)
	}

	if !exist {
		if err := setup(); err != nil {
			return err
		}
		if err := fetch(); err != nil {
			os.RemoveAll(path)
			return fmt.Errorf("git fetch: %w", err)
		}
	} else {
		if err := fetch(); err != nil {
			// a cancelled fetch can leave lock files behind
			removeStaleLocks(path)
			if retryErr := fetch(); retryErr != nil {
				if rmErr := os.RemoveAll(path); rmErr != nil {
					return fmt.Errorf("git fetch: %w (cleanup failed: %v)", retryErr, rmErr)
				}
				if setupErr := setup(); setupErr != nil {
					return fmt.Errorf("git fetch: %w (re-init failed: %v)", retryErr, setupErr)
				}
				if cloneErr := fetch(); cloneErr != nil {
					return fmt.Errorf("git fetch: %w (re-fetch failed: %v)", retryErr, cloneErr)
				}
			}
		}
	}
	removeStaleLocks(path)
	if err := Run(ctx, "-C", path, "checkout", "--detach", "FETCH_HEAD"); err != nil {
		return fmt.Errorf("git checkout: %w", err)
	}
	return nil
}

// a clone can't fetch objects of the other hash, so match the remote's format
// if ls-remote fails we guess sha1 and let the fetch report the real error
func objectFormat(ctx context.Context, cloneUri, rev string) string {
	if !Rev(rev).IsObjectID() {
		out, err := Output(ctx, "ls-remote", cloneUri, rev)
		if err != nil {
			return "sha1"
		}
		rev, _, _ = strings.Cut(string(out), "\t")
	}
	if len(rev) == sha256HexLen {
		return "sha256"
	}
	return "sha1"
}

const maxAncestryDepth = 100

// reports whether ref's recent history includes sha
// this doesnt use SparseSync bcz that is shallow and we need the partially-fully-graph
func RefContainsCommit(ctx context.Context, cloneUri, ref, sha string) (bool, error) {
	path, err := os.MkdirTemp("", "ref-ancestry-")
	if err != nil {
		return false, fmt.Errorf("making a scratch dir: %w", err)
	}
	defer os.RemoveAll(path)

	if err := Run(ctx, "init", "-q", "--object-format="+objectFormat(ctx, cloneUri, ref), path); err != nil {
		return false, fmt.Errorf("git init: %w", err)
	}
	if err := Run(ctx, "-C", path, "remote", "add", "origin", cloneUri); err != nil {
		return false, fmt.Errorf("git remote add: %w", err)
	}
	if err := Run(ctx, "-C", path, "fetch", "--filter=tree:0", "--depth="+strconv.Itoa(maxAncestryDepth), "origin", ref); err != nil {
		return false, fmt.Errorf("git fetch %s: %w", ref, err)
	}

	// this exits 0 when the ref's history contains it
	// a sha outside the fetched window fails the command
	out, err := Output(ctx, "-C", path, "merge-base", sha, "FETCH_HEAD")
	if err != nil {
		return false, nil
	}
	return strings.TrimSpace(string(out)) == sha, nil
}

func removeStaleLocks(path string) {
	locks, _ := filepath.Glob(filepath.Join(path, ".git", "*.lock"))
	for _, lock := range locks {
		os.Remove(lock)
	}
}

func isDir(path string) (bool, error) {
	info, err := os.Stat(path)
	if err == nil && info.IsDir() {
		return true, nil
	}
	if os.IsNotExist(err) {
		return false, nil
	}
	return false, err
}
