package gittransport

import (
	"context"
	"fmt"
	"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"
	"github.com/pingcap/agent-git-service/internal/sync/gitmirror"
)

func InventoryRefs(ctx context.Context, remote reposync.GitRemote, objectType string, allowMissing bool) ([]reposync.InventoryObject, error) {
	args := []string{"ls-remote", "--heads", "--tags", remote.URL}
	command := exec.CommandContext(ctx, "git", args...)
	command.Env = gitEnvironment(remote.Token)
	output, err := command.CombinedOutput()
	if err != nil {
		message := redact(string(output))
		if allowMissing && (strings.Contains(strings.ToLower(message), "not found") || strings.Contains(strings.ToLower(message), "does not appear to be a git repository")) {
			return nil, nil
		}
		return nil, fmt.Errorf("list Git refs: %w: %s", err, message)
	}
	byRef := make(map[string]string)
	for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
		parts := strings.Fields(line)
		if len(parts) != 2 || strings.HasSuffix(parts[1], "^{}") {
			continue
		}
		byRef[parts[1]] = parts[0]
	}
	refs := make([]string, 0, len(byRef))
	for ref := range byRef {
		refs = append(refs, ref)
	}
	sort.Strings(refs)
	objects := make([]reposync.InventoryObject, 0, len(refs))
	for _, ref := range refs {
		sha := byRef[ref]
		record := canonical.NewRecord(objectType, ref, objectType+":"+ref, map[string]any{"ref": ref, "sha": sha})
		record.UniqueKey = ref
		object, err := record.Inventory(sha, "native")
		if err != nil {
			return nil, err
		}
		objects = append(objects, object)
	}
	return objects, nil
}

// InventoryPullHeadRefs exposes GitHub's read-only pull head namespace as
// writable, provider-neutral shadow branches. Identity is the shadow ref on
// both endpoints; _source_ref records where Git transport must fetch the
// object when the source branch belongs to a fork or was deleted.
func InventoryPullHeadRefs(ctx context.Context, remote reposync.GitRemote, existing map[string]bool) ([]reposync.InventoryObject, error) {
	command := exec.CommandContext(ctx, "git", "ls-remote", remote.URL, "refs/pull/*/head")
	command.Env = gitEnvironment(remote.Token)
	output, err := command.CombinedOutput()
	if err != nil {
		return nil, fmt.Errorf("list pull request head refs: %w: %s", err, redact(string(output)))
	}
	byRef := make(map[string]string)
	for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
		parts := strings.Fields(line)
		if len(parts) != 2 {
			continue
		}
		segments := strings.Split(parts[1], "/")
		if len(segments) != 4 || segments[0] != "refs" || segments[1] != "pull" || segments[3] != "head" {
			continue
		}
		number, parseErr := strconv.Atoi(segments[2])
		if parseErr != nil || number <= 0 {
			continue
		}
		shadow := fmt.Sprintf("refs/heads/ags-sync/pr/%d/head", number)
		if existing[shadow] {
			continue
		}
		byRef[shadow] = parts[0]
	}
	refs := make([]string, 0, len(byRef))
	for ref := range byRef {
		refs = append(refs, ref)
	}
	sort.Strings(refs)
	objects := make([]reposync.InventoryObject, 0, len(refs))
	for _, ref := range refs {
		sha := byRef[ref]
		number := strings.Split(ref, "/")[4]
		record := canonical.NewRecord(canonical.TypeGitRef, ref, canonical.TypeGitRef+":"+ref, map[string]any{
			"ref": ref, "sha": sha, "_source_ref": "refs/pull/" + number + "/head",
		})
		record.UniqueKey = ref
		object, err := record.Inventory(sha, "native")
		if err != nil {
			return nil, err
		}
		objects = append(objects, object)
	}
	return objects, nil
}

// RefAncestryCandidate is an unlinked ref collision whose source and target
// tips must be compared before a first sync can safely adopt it.
type RefAncestryCandidate struct {
	Key    string
	Source reposync.InventoryObject
	Target reposync.InventoryObject
}

// FastForwardableRefs returns only ref names for which Git proved that the
// target tip is an ancestor of the source tip. A matching ref name is not
// sufficient: a divergent branch remains absent from the result and is left
// for an explicit conflict resolution flow.
func FastForwardableRefs(ctx context.Context, source, target reposync.GitRemote, candidates []RefAncestryCandidate) (map[string]bool, error) {
	result := make(map[string]bool)
	if len(candidates) == 0 {
		return result, nil
	}
	if strings.TrimSpace(source.URL) == "" || strings.TrimSpace(target.URL) == "" {
		return nil, fmt.Errorf("Git source and target remotes are required")
	}
	tempRoot, err := os.MkdirTemp("", "ags-sync-ref-ancestry-*")
	if err != nil {
		return nil, err
	}
	defer os.RemoveAll(tempRoot)
	repositoryPath := filepath.Join(tempRoot, "repository.git")
	if err := run(ctx, "", "init", "--bare", repositoryPath); err != nil {
		return nil, fmt.Errorf("initialize ref ancestry check: %w", err)
	}

	type loadedCandidate struct {
		key, sourceLocal, targetLocal, sourceSHA, targetSHA string
	}
	sourceFetch := []string{"fetch", "--no-tags", source.URL}
	targetFetch := []string{"fetch", "--no-tags", target.URL}
	loaded := make([]loadedCandidate, 0, len(candidates))
	for index, candidate := range candidates {
		sourceRef, sourceSHA, err := inventoryRef(candidate.Source, true)
		if err != nil {
			return nil, fmt.Errorf("source ref %q: %w", candidate.Key, err)
		}
		targetRef, targetSHA, err := inventoryRef(candidate.Target, false)
		if err != nil {
			return nil, fmt.Errorf("target ref %q: %w", candidate.Key, err)
		}
		sourceLocal := "refs/ags-sync-ancestry/source/" + strconv.Itoa(index)
		targetLocal := "refs/ags-sync-ancestry/target/" + strconv.Itoa(index)
		sourceFetch = append(sourceFetch, "+"+sourceRef+":"+sourceLocal)
		targetFetch = append(targetFetch, "+"+targetRef+":"+targetLocal)
		loaded = append(loaded, loadedCandidate{candidate.Key, sourceLocal, targetLocal, sourceSHA, targetSHA})
	}
	if err := runWithToken(ctx, repositoryPath, source.Token, sourceFetch...); err != nil {
		return nil, fmt.Errorf("fetch source refs for ancestry check: %w", err)
	}
	if err := runWithToken(ctx, repositoryPath, target.Token, targetFetch...); err != nil {
		return nil, fmt.Errorf("fetch target refs for ancestry check: %w", err)
	}
	for _, candidate := range loaded {
		sourceSHA, err := revisionAt(ctx, repositoryPath, candidate.sourceLocal)
		if err != nil {
			return nil, fmt.Errorf("read source ref %q: %w", candidate.key, err)
		}
		targetSHA, err := revisionAt(ctx, repositoryPath, candidate.targetLocal)
		if err != nil {
			return nil, fmt.Errorf("read target ref %q: %w", candidate.key, err)
		}
		// The source or target moved after inventory. Let normal stale-plan
		// protection retry it rather than proving ancestry for a different pair.
		if sourceSHA != candidate.sourceSHA || targetSHA != candidate.targetSHA {
			continue
		}
		command := exec.CommandContext(ctx, "git", "-C", repositoryPath, "merge-base", "--is-ancestor", candidate.targetLocal, candidate.sourceLocal)
		if err := command.Run(); err == nil {
			result[candidate.key] = true
		} else if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 1 {
			return nil, fmt.Errorf("compare ref ancestry for %q: %w", candidate.key, err)
		}
	}
	return result, nil
}

func inventoryRef(object reposync.InventoryObject, source bool) (string, string, error) {
	// Collision probes built from a compact target read may not include the
	// canonical payload. A Git ref's unique key and revision remain sufficient
	// for ordinary heads; use canonical fields when present for GitHub's pull
	// head shadow refs.
	ref, sha := object.UniqueKey, object.Revision
	if len(object.Payload) != 0 {
		record, err := canonical.Decode(object.Payload)
		if err != nil {
			return "", "", err
		}
		if canonicalRef := canonical.StringField(record.Fields, "ref"); canonicalRef != "" {
			ref = canonicalRef
		}
		if canonicalSHA := canonical.StringField(record.Fields, "sha"); canonicalSHA != "" {
			sha = canonicalSHA
		}
		if source {
			if sourceRef := canonical.StringField(record.Fields, "_source_ref"); sourceRef != "" {
				ref = sourceRef
			}
		}
	}
	if sha == "" {
		sha = object.ContentHash
	}
	if ref == "" || sha == "" {
		return "", "", fmt.Errorf("canonical Git ref is missing ref name or SHA")
	}
	return ref, sha, nil
}

func revisionAt(ctx context.Context, repositoryPath, ref string) (string, error) {
	command := exec.CommandContext(ctx, "git", "-C", repositoryPath, "rev-parse", ref)
	output, err := command.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("git rev-parse failed: %w: %s", err, redact(string(output)))
	}
	return strings.TrimSpace(string(output)), nil
}

// ApplyRefBatch transfers Git objects once and atomically applies all
// compatible ref RunItems. Unmanaged target refs are never included in the
// push, so a non-empty standby is preserved.
func ApplyRefBatch(ctx context.Context, source, target reposync.GitRemote, items []reposync.PlannedItem) error {
	if strings.TrimSpace(source.URL) == "" || strings.TrimSpace(target.URL) == "" {
		return fmt.Errorf("Git source and target remotes are required")
	}
	tempRoot, err := os.MkdirTemp("", "ags-sync-git-*")
	if err != nil {
		return err
	}
	defer os.RemoveAll(tempRoot)
	repositoryPath := filepath.Join(tempRoot, "repository.git")
	if err := run(ctx, "", "init", "--bare", repositoryPath); err != nil {
		return fmt.Errorf("initialize sync ref transfer: %w", err)
	}
	args := []string{"-C", repositoryPath, "push", "--atomic"}
	refspecs := make([]string, 0, len(items))
	type fetchedRef struct {
		local string
		sha   string
	}
	fetchArgs := []string{"fetch", "--no-tags", source.URL}
	fetched := make([]fetchedRef, 0, len(items))
	for index, item := range items {
		record, err := canonical.Decode(item.Payload)
		if err != nil {
			return err
		}
		ref := canonical.StringField(record.Fields, "ref")
		sha := canonical.StringField(record.Fields, "sha")
		if ref == "" {
			return fmt.Errorf("Git ref RunItem is missing ref name")
		}
		expected := item.ExpectedTargetRevision
		args = append(args, "--force-with-lease="+ref+":"+expected)
		if item.Action == reposync.ActionDelete {
			refspecs = append(refspecs, ":"+ref)
		} else {
			if sha == "" {
				return fmt.Errorf("Git ref %s is missing source SHA", ref)
			}
			sourceRef := canonical.StringField(record.Fields, "_source_ref")
			if sourceRef == "" {
				sourceRef = ref
			}
			localRef := "refs/ags-sync-source/item-" + strconv.Itoa(index)
			fetchArgs = append(fetchArgs, "+"+sourceRef+":"+localRef)
			fetched = append(fetched, fetchedRef{local: localRef, sha: sha})
			refspecs = append(refspecs, "+"+sha+":"+ref)
		}
	}
	if len(fetched) != 0 {
		if err := runWithToken(ctx, repositoryPath, source.Token, fetchArgs...); err != nil {
			return fmt.Errorf("fetch changed sync refs: %w", err)
		}
		for _, expected := range fetched {
			command := exec.CommandContext(ctx, "git", "-C", repositoryPath, "rev-parse", expected.local)
			output, err := command.CombinedOutput()
			if err != nil || strings.TrimSpace(string(output)) != expected.sha {
				return fmt.Errorf("source ref changed during transfer: %s", expected.local)
			}
		}
	}
	args = append(args, target.URL)
	args = append(args, refspecs...)
	if err := runWithToken(ctx, repositoryPath, target.Token, args...); err != nil {
		return fmt.Errorf("push managed sync refs: %w", err)
	}
	return nil
}

func run(ctx context.Context, dir string, args ...string) error {
	return runWithToken(ctx, dir, "", args...)
}

func runWithToken(ctx context.Context, dir, token string, args ...string) error {
	command := exec.CommandContext(ctx, "git", args...)
	command.Dir = dir
	command.Env = gitEnvironment(token)
	output, err := command.CombinedOutput()
	if err != nil {
		// Authentication is injected through the environment; never include the
		// child environment or command line in returned/logged errors.
		return fmt.Errorf("git command failed: %w: %s", err, redact(string(output)))
	}
	return nil
}

func gitEnvironment(token string) []string {
	environment := os.Environ()
	if strings.TrimSpace(token) != "" {
		environment = append(environment, gitmirror.GitHTTPAuthorizationConfig(token)...)
	}
	return environment
}

func redact(value string) string {
	for _, marker := range []string{"Authorization:", "AUTHORIZATION:"} {
		if at := strings.Index(value, marker); at >= 0 {
			end := strings.IndexByte(value[at:], '\n')
			if end < 0 {
				end = len(value) - at
			}
			value = value[:at] + marker + " [REDACTED]" + value[at+end:]
		}
	}
	return value
}
