package gittransport

import (
	"bufio"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strconv"
	"strings"

	reposync "github.com/pingcap/agent-git-service/internal/sync"
	"github.com/pingcap/agent-git-service/internal/sync/canonical"
)

const maxLFSPointerBytes = 1024

type lfsPointer struct {
	OID  string
	Size int64
}

// InventoryLFS discovers LFS pointers reachable from the remote's synchronized
// branches and tags. The pointer is part of Git; binary availability is checked
// on transfer/read-back so an absent media object can never be marked applied.
func InventoryLFS(ctx context.Context, remote reposync.GitRemote) ([]reposync.InventoryObject, error) {
	tempRoot, repositoryPath, empty, err := cloneRemoteMirror(ctx, remote, true)
	if err != nil {
		return nil, err
	}
	if tempRoot != "" {
		defer os.RemoveAll(tempRoot)
	}
	if empty {
		return nil, nil
	}
	pointers, err := discoverLFSPointers(ctx, repositoryPath)
	if err != nil {
		return nil, err
	}
	objects := make([]reposync.InventoryObject, 0, len(pointers))
	for _, pointer := range pointers {
		record := canonical.NewRecord(canonical.TypeLFSObject, pointer.OID, "lfs:"+pointer.OID, map[string]any{"oid": pointer.OID, "size": pointer.Size})
		record.UniqueKey = pointer.OID
		object, err := record.Inventory(pointer.OID+":"+strconv.FormatInt(pointer.Size, 10), "native")
		if err != nil {
			return nil, err
		}
		objects = append(objects, object)
	}
	return objects, nil
}

// ApplyLFSBatch transfers only the LFS OIDs represented by this physical
// RunItem batch. Ref updates happen in a later dependency phase.
func ApplyLFSBatch(ctx context.Context, source, target reposync.GitRemote, items []reposync.PlannedItem) (map[string]error, error) {
	results := make(map[string]error, len(items))
	oids := make([]string, 0, len(items))
	expected := make(map[string]int64, len(items))
	for _, item := range items {
		if item.Action == reposync.ActionDelete {
			results[item.ObjectKey] = nil
			continue
		}
		record, err := canonical.Decode(item.Payload)
		if err != nil {
			results[item.ObjectKey] = err
			continue
		}
		oid := canonical.StringField(record.Fields, "oid")
		size := int64(canonical.IntField(record.Fields, "size"))
		if len(oid) != 64 || size < 0 {
			results[item.ObjectKey] = fmt.Errorf("invalid LFS pointer")
			continue
		}
		oids = append(oids, oid)
		expected[oid] = size
	}
	if len(oids) == 0 {
		return results, nil
	}
	if _, err := exec.LookPath("git-lfs"); err != nil {
		return results, fmt.Errorf("Git LFS media is present but git-lfs is not installed: %w", err)
	}
	tempRoot, err := os.MkdirTemp("", "ags-sync-lfs-transfer-*")
	if err != nil {
		return results, err
	}
	defer os.RemoveAll(tempRoot)
	repositoryPath := filepath.Join(tempRoot, "repository.git")
	if err := run(ctx, "", "init", "--bare", repositoryPath); err != nil {
		return results, err
	}
	// git-lfs accepts a remote URL directly. A small bare repository is enough
	// for object-id transfer, so physical LFS batches do not repeatedly clone a
	// TiDB-sized Git history just to move media objects.
	fetchArgs := []string{"-C", repositoryPath, "lfs", "fetch", "--object-id", source.URL}
	fetchArgs = append(fetchArgs, oids...)
	if err := runWithToken(ctx, repositoryPath, source.Token, fetchArgs...); err != nil {
		return results, fmt.Errorf("fetch source LFS objects: %w", err)
	}
	for _, oid := range oids {
		if err := verifyLocalLFSObject(repositoryPath, oid, expected[oid]); err != nil {
			return results, fmt.Errorf("verify source LFS object %s: %w", oid, err)
		}
	}
	pushArgs := []string{"-C", repositoryPath, "lfs", "push", "--object-id", target.URL}
	pushArgs = append(pushArgs, oids...)
	if err := runWithToken(ctx, repositoryPath, target.Token, pushArgs...); err != nil {
		return results, fmt.Errorf("push target LFS objects: %w", err)
	}
	verified, err := VerifyLFSBatch(ctx, target, expected)
	if err != nil {
		return results, err
	}
	for _, item := range items {
		if item.Action == reposync.ActionDelete {
			continue
		}
		record, decodeErr := canonical.Decode(item.Payload)
		if decodeErr != nil {
			continue
		}
		oid := canonical.StringField(record.Fields, "oid")
		if !verified[oid] {
			results[item.ObjectKey] = fmt.Errorf("target LFS read-back failed for %s", oid)
		} else {
			results[item.ObjectKey] = nil
		}
	}
	return results, nil
}

// VerifyLFSBatch downloads the requested OIDs from the target and validates
// both SHA-256 and declared pointer size.
func VerifyLFSBatch(ctx context.Context, remote reposync.GitRemote, expected map[string]int64) (map[string]bool, error) {
	verified := make(map[string]bool, len(expected))
	if len(expected) == 0 {
		return verified, nil
	}
	if _, err := exec.LookPath("git-lfs"); err != nil {
		return verified, fmt.Errorf("git-lfs is required to verify LFS media: %w", err)
	}
	tempRoot, err := os.MkdirTemp("", "ags-sync-lfs-verify-*")
	if err != nil {
		return verified, err
	}
	defer os.RemoveAll(tempRoot)
	repositoryPath := filepath.Join(tempRoot, "repository.git")
	if err := run(ctx, "", "init", "--bare", repositoryPath); err != nil {
		return verified, err
	}
	oids := make([]string, 0, len(expected))
	for oid := range expected {
		oids = append(oids, oid)
	}
	sort.Strings(oids)
	args := []string{"-C", repositoryPath, "lfs", "fetch", "--object-id", remote.URL}
	args = append(args, oids...)
	if err := runWithToken(ctx, repositoryPath, remote.Token, args...); err != nil {
		return verified, fmt.Errorf("download target LFS read-back: %w", err)
	}
	for _, oid := range oids {
		verified[oid] = verifyLocalLFSObject(repositoryPath, oid, expected[oid]) == nil
	}
	return verified, nil
}

func cloneRemoteMirror(ctx context.Context, remote reposync.GitRemote, allowEmpty bool) (string, string, bool, error) {
	lsArgs := []string{"ls-remote", "--heads", "--tags", remote.URL}
	command := exec.CommandContext(ctx, "git", lsArgs...)
	command.Env = gitEnvironment(remote.Token)
	output, err := command.CombinedOutput()
	if err != nil {
		return "", "", false, fmt.Errorf("inspect LFS remote: %w: %s", err, redact(string(output)))
	}
	if strings.TrimSpace(string(output)) == "" && allowEmpty {
		return "", "", true, nil
	}
	tempRoot, err := os.MkdirTemp("", "ags-sync-lfs-*")
	if err != nil {
		return "", "", false, err
	}
	repositoryPath := filepath.Join(tempRoot, "repository.git")
	if err := run(ctx, "", "init", "--bare", repositoryPath); err != nil {
		_ = os.RemoveAll(tempRoot)
		return "", "", false, err
	}
	if err := run(ctx, repositoryPath, "remote", "add", "origin", remote.URL); err != nil {
		_ = os.RemoveAll(tempRoot)
		return "", "", false, err
	}
	args := []string{"-C", repositoryPath, "fetch", "--prune", "origin", "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
	if err := runWithToken(ctx, repositoryPath, remote.Token, args...); err != nil {
		_ = os.RemoveAll(tempRoot)
		return "", "", false, fmt.Errorf("fetch LFS inventory refs: %w", err)
	}
	return tempRoot, repositoryPath, false, nil
}

func discoverLFSPointers(ctx context.Context, repositoryPath string) ([]lfsPointer, error) {
	check := exec.CommandContext(ctx, "git", "-C", repositoryPath, "cat-file", "--batch-all-objects", "--batch-check=%(objectname) %(objecttype) %(objectsize)")
	stdout, err := check.StdoutPipe()
	if err != nil {
		return nil, err
	}
	if err := check.Start(); err != nil {
		return nil, err
	}
	candidates := make([]string, 0)
	scanner := bufio.NewScanner(stdout)
	for scanner.Scan() {
		parts := strings.Fields(scanner.Text())
		if len(parts) != 3 || parts[1] != "blob" {
			continue
		}
		size, parseErr := strconv.ParseInt(parts[2], 10, 64)
		if parseErr == nil && size > 0 && size <= maxLFSPointerBytes {
			candidates = append(candidates, parts[0])
		}
	}
	if err := scanner.Err(); err != nil {
		_ = check.Wait()
		return nil, err
	}
	if err := check.Wait(); err != nil {
		return nil, err
	}
	batch := exec.CommandContext(ctx, "git", "-C", repositoryPath, "cat-file", "--batch")
	stdin, err := batch.StdinPipe()
	if err != nil {
		return nil, err
	}
	contents, err := batch.StdoutPipe()
	if err != nil {
		return nil, err
	}
	if err := batch.Start(); err != nil {
		return nil, err
	}
	go func() {
		for _, oid := range candidates {
			_, _ = io.WriteString(stdin, oid+"\n")
		}
		_ = stdin.Close()
	}()
	reader := bufio.NewReader(contents)
	byOID := make(map[string]int64)
	for range candidates {
		header, readErr := reader.ReadString('\n')
		if readErr != nil {
			_ = batch.Process.Kill()
			return nil, readErr
		}
		parts := strings.Fields(header)
		if len(parts) != 3 {
			_ = batch.Process.Kill()
			return nil, fmt.Errorf("unexpected git cat-file header %q", strings.TrimSpace(header))
		}
		size, parseErr := strconv.ParseInt(parts[2], 10, 64)
		if parseErr != nil || size < 0 || size > maxLFSPointerBytes {
			_ = batch.Process.Kill()
			return nil, fmt.Errorf("invalid git blob size %q", parts[2])
		}
		payload := make([]byte, size+1)
		if _, readErr := io.ReadFull(reader, payload); readErr != nil {
			_ = batch.Process.Kill()
			return nil, readErr
		}
		if pointer, ok := parseLFSPointer(payload[:size]); ok {
			if previous, exists := byOID[pointer.OID]; exists && previous != pointer.Size {
				_ = batch.Process.Kill()
				return nil, fmt.Errorf("LFS pointer %s has inconsistent sizes", pointer.OID)
			}
			byOID[pointer.OID] = pointer.Size
		}
	}
	if err := batch.Wait(); err != nil {
		return nil, err
	}
	oids := make([]string, 0, len(byOID))
	for oid := range byOID {
		oids = append(oids, oid)
	}
	sort.Strings(oids)
	result := make([]lfsPointer, 0, len(oids))
	for _, oid := range oids {
		result = append(result, lfsPointer{OID: oid, Size: byOID[oid]})
	}
	return result, nil
}

func parseLFSPointer(payload []byte) (lfsPointer, bool) {
	lines := strings.Split(strings.TrimSpace(string(payload)), "\n")
	if len(lines) != 3 || strings.TrimSpace(lines[0]) != "version https://git-lfs.github.com/spec/v1" {
		return lfsPointer{}, false
	}
	oid := strings.TrimPrefix(strings.TrimSpace(lines[1]), "oid sha256:")
	if len(oid) != 64 {
		return lfsPointer{}, false
	}
	if _, err := hex.DecodeString(oid); err != nil {
		return lfsPointer{}, false
	}
	size, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(lines[2]), "size "), 10, 64)
	if err != nil || size < 0 {
		return lfsPointer{}, false
	}
	return lfsPointer{OID: strings.ToLower(oid), Size: size}, true
}

func verifyLocalLFSObject(repositoryPath, oid string, size int64) error {
	path := filepath.Join(repositoryPath, "lfs", "objects", oid[:2], oid[2:4], oid)
	file, err := os.Open(path)
	if err != nil {
		return err
	}
	defer file.Close()
	hash := sha256.New()
	written, err := io.Copy(hash, file)
	if err != nil {
		return err
	}
	if written != size {
		return fmt.Errorf("size=%d want=%d", written, size)
	}
	if actual := hex.EncodeToString(hash.Sum(nil)); actual != oid {
		return fmt.Errorf("sha256=%s want=%s", actual, oid)
	}
	return nil
}
