package service

import (
	"bufio"
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/textproto"
	"os"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/go-git/go-git/v5/plumbing"
	"github.com/pingcap/agent-git-service/internal/service/internal/repositorymutation"
)

const (
	repositoryReceivePackMaxHeaderBytes  = 64 << 10
	repositoryReceivePackMaxHeaders      = 100
	repositoryReceivePackMaxBodyBytes    = 64 << 20
	repositoryReceivePackConvergeTimeout = 30 * time.Second
	repositoryReceivePackMaxCommands     = 1024
	repositoryReceivePackMaxCommandBytes = 1 << 20
)

type repositoryReceivePackCGIRequest struct {
	Path         string
	Dir          string
	Env          []string
	Session      *repositorymutation.Session
	CreateSpool  func() (*os.File, error)
	MaxBodyBytes int64
}

var ErrRepositoryReceivePackResponseReplay = errors.New("repository receive-pack response replay failed after commit")

// RepositoryReceivePackResult reports transport-independent lifecycle facts.
// It contains no session, DB handle, path, or mutation authority.
type RepositoryReceivePackResult struct {
	ChildStarted      bool
	ChildJoined       bool
	Converged         bool
	ResponseCommitted bool
	RefDeltaKnown     bool
	PostPushEnqueued  bool
	RefUpdates        []RepositoryReceivePackRefUpdate
}

type RepositoryReceivePackRefUpdate struct {
	Ref        string
	Before     string
	After      string
	CommitSHAs []string
}

// RepositoryReceivePackResponse is the single-owner, transport-only handle for
// a frozen receive-pack response spool. It carries no repository path, DB,
// session, or mutation authority. The handler must Close it after one Replay.
type RepositoryReceivePackResponse struct {
	inner    *repositoryReceivePackCGIResponse
	replayed bool
}

func (response *RepositoryReceivePackResponse) Replay(writer http.ResponseWriter) error {
	if response == nil || response.inner == nil || response.replayed {
		return ErrRepositoryMigrationConflict
	}
	response.replayed = true
	return response.inner.WriteTo(writer)
}

func (response *RepositoryReceivePackResponse) Close() {
	if response == nil || response.inner == nil {
		return
	}
	response.inner.Close()
	response.inner = nil
}

func (result RepositoryReceivePackResult) ShouldSchedulePostPush() bool {
	return result.PostPushEnqueued || (result.ChildStarted && result.ChildJoined && result.Converged && result.RefDeltaKnown)
}

func (result RepositoryReceivePackResult) NeedsPostPushRecovery() bool {
	return result.ChildStarted && result.ChildJoined && !result.RefDeltaKnown
}

type repositoryReceivePackCGIResponse struct {
	statusCode int
	headers    http.Header
	body       *os.File
}

func (s *Service) EnsureRepository(ctx context.Context, repositoryID uint) error {
	if s == nil || s.repositoryMutations == nil {
		return ErrRepositoryMigrationConflict
	}
	if err := s.repositoryMutations.EnsureRepository(ctx, repositoryID); err != nil {
		if errors.Is(err, repositorymutation.ErrRepositoryMutationSealed) {
			return ErrRepositoryMigrationSealed
		}
		return err
	}
	return nil
}

// ServeRepositoryReceivePack owns the complete source mutation lifecycle:
// canonical identity/epoch validation, process-local writer ownership,
// process-tree join, HEAD repair, and the default-branch DB projection.
func (s *Service) ServeRepositoryReceivePack(request *http.Request, repositoryID uint) (RepositoryReceivePackResult, *RepositoryReceivePackResponse, error) {
	result := RepositoryReceivePackResult{}
	if s == nil || s.repositoryMutations == nil || request == nil {
		return result, nil, ErrRepositoryMigrationConflict
	}
	session, err := s.repositoryMutations.AcquireReceivePackSession(request.Context(), repositoryID)
	if err != nil {
		if errors.Is(err, repositorymutation.ErrRepositoryMutationSealed) {
			return result, nil, ErrRepositoryMigrationSealed
		}
		return result, nil, err
	}
	sessionOwned := true
	defer func() {
		if sessionOwned {
			_ = session.Release()
		}
	}()
	recoveryCtx, recoveryCancel := context.WithTimeout(context.WithoutCancel(s.ServerCtx()), repositoryReceivePackConvergeTimeout)
	recovered, err := session.RecoverPendingReceivePack(recoveryCtx)
	recoveryCancel()
	if err != nil {
		return result, nil, fmt.Errorf("recover pending receive-pack: %w", repositoryMutationError(err))
	}
	result.PostPushEnqueued = recovered

	var commands []repositorymutation.ReceivePackCommand
	if s.testRepositoryReceivePackBackend == "" {
		commands, err = prepareRepositoryReceivePackRequest(request)
		if err != nil {
			return result, nil, err
		}
	}
	if len(commands) == 0 {
		commands = []repositorymutation.ReceivePackCommand{{Ref: "refs/heads/__test__", Before: zeroReceivePackSHA, After: zeroReceivePackSHA}}
	}
	actorID := uint(0)
	if actor, ok := UserFromContext(request.Context()); ok {
		actorID = actor.ID
	}
	operation, err := session.BeginReceivePack(request.Context(), commands, actorID, time.Now().UTC())
	if err != nil {
		return result, nil, repositoryMutationError(err)
	}
	childStarted := false
	defer func() {
		if !childStarted {
			abortCtx, cancel := context.WithTimeout(context.WithoutCancel(s.ServerCtx()), repositoryReceivePackConvergeTimeout)
			_ = operation.Abort(abortCtx)
			cancel()
		}
	}()
	if s.testRepositoryReceivePackAfterBeforeRefs != nil {
		s.testRepositoryReceivePackAfterBeforeRefs()
	}
	backend, err := s.repositoryMutations.GitHTTPBackend()
	if s.testRepositoryReceivePackBackend != "" {
		backend, err = s.testRepositoryReceivePackBackend, nil
	}
	if err != nil {
		return result, nil, err
	}
	fullName := session.FullName()
	owner, repository, ok := strings.Cut(fullName, "/")
	if !ok || owner == "" || repository == "" {
		return result, nil, ErrRepositoryMigrationConflict
	}
	pathInfo := fmt.Sprintf("/%s/%s.git/git-receive-pack", owner, repository)
	response, childStarted, runErr := runRepositoryReceivePackCGI(request, repositoryReceivePackCGIRequest{
		Path: backend, Dir: session.RepositoryRoot(), Env: repositoryReceivePackCGIEnvironment(request, backend, session.RepositoryRoot(), pathInfo), Session: session,
		CreateSpool: s.testRepositoryReceivePackSpool, MaxBodyBytes: s.testRepositoryReceivePackMaxBodyBytes,
	})
	result.ChildStarted = childStarted
	result.ChildJoined = childStarted
	responseOwned := response != nil
	defer func() {
		if responseOwned {
			response.Close()
		}
	}()
	if childStarted && s.testRepositoryReceivePackAfterChild != nil {
		s.testRepositoryReceivePackAfterChild()
	}
	if childStarted {
		snapshotCtx, snapshotCancel := context.WithTimeout(context.WithoutCancel(s.ServerCtx()), repositoryReceivePackConvergeTimeout)
		updates, snapshotErr := operation.Capture(snapshotCtx)
		if snapshotErr != nil {
			runErr = errors.Join(runErr, fmt.Errorf("capture receive-pack ref delta: %w", snapshotErr))
		} else {
			result.RefUpdates = repositoryReceivePackRefUpdates(updates)
		}
		snapshotCancel()
	}
	var projectionErr error
	if childStarted {
		convergeCtx, cancel := context.WithTimeout(context.WithoutCancel(s.ServerCtx()), repositoryReceivePackConvergeTimeout)
		defer cancel()
		var changed bool
		changed, projectionErr = session.ConvergeDefaultBranch(convergeCtx)
		result.Converged = projectionErr == nil
		if projectionErr == nil && changed {
			RepoCacheInvalidate(request.Context(), repositoryID)
		}
		if projectionErr == nil && result.RefUpdates != nil {
			completeCtx, completeCancel := context.WithTimeout(context.WithoutCancel(s.ServerCtx()), repositoryReceivePackConvergeTimeout)
			completeErr := operation.Complete(completeCtx, repositorymutationReceivePackRefUpdates(result.RefUpdates))
			completeCancel()
			if completeErr != nil {
				runErr = errors.Join(runErr, fmt.Errorf("publish receive-pack outbox: %w", completeErr))
			} else {
				result.RefDeltaKnown = true
				result.PostPushEnqueued = result.PostPushEnqueued || len(result.RefUpdates) != 0
			}
		}
	}
	if runErr != nil {
		return result, nil, errors.Join(runErr, repositoryMutationError(projectionErr))
	}
	if projectionErr != nil {
		return result, nil, repositoryMutationError(projectionErr)
	}
	if response == nil {
		return result, nil, ErrRepositoryMigrationConflict
	}
	if err := session.Release(); err != nil {
		return result, nil, repositoryMutationError(err)
	}
	sessionOwned = false
	responseOwned = false
	return result, &RepositoryReceivePackResponse{inner: response}, nil
}

type receivePackReplayBody struct {
	io.Reader
	io.Closer
}

const zeroReceivePackSHA = "0000000000000000000000000000000000000000"

func prepareRepositoryReceivePackRequest(request *http.Request) ([]repositorymutation.ReceivePackCommand, error) {
	if request == nil || request.Body == nil {
		return nil, fmt.Errorf("receive-pack request body is missing")
	}
	reader := bufio.NewReader(request.Body)
	var prefix bytes.Buffer
	seen := map[string]bool{}
	commands := make([]repositorymutation.ReceivePackCommand, 0)
	firstCommand := true
	for commandCount := 0; ; {
		var header [4]byte
		if _, err := io.ReadFull(reader, header[:]); err != nil {
			return nil, fmt.Errorf("read receive-pack command header: %w", err)
		}
		prefix.Write(header[:])
		length, err := strconv.ParseUint(string(header[:]), 16, 16)
		if err != nil || length == 1 || length == 2 || length == 3 || length > 65520 {
			return nil, fmt.Errorf("receive-pack command header is invalid")
		}
		if length == 0 {
			break
		}
		payloadBytes := int(length) - 4
		if payloadBytes < 0 || prefix.Len()+payloadBytes > repositoryReceivePackMaxCommandBytes {
			return nil, fmt.Errorf("receive-pack command section exceeds limit")
		}
		payload := make([]byte, payloadBytes)
		if _, err := io.ReadFull(reader, payload); err != nil {
			return nil, fmt.Errorf("read receive-pack command: %w", err)
		}
		prefix.Write(payload)
		line := strings.TrimSuffix(string(payload), "\n")
		if strings.HasPrefix(line, "shallow ") {
			continue
		}
		if firstCommand {
			line, _, _ = strings.Cut(line, "\x00")
			firstCommand = false
		}
		fields := strings.Fields(line)
		if len(fields) != 3 || !plumbing.IsHash(fields[0]) || !plumbing.IsHash(fields[1]) {
			return nil, fmt.Errorf("receive-pack command is invalid")
		}
		ref := fields[2]
		name := plumbing.ReferenceName(ref)
		if name.Validate() != nil || (!strings.HasPrefix(ref, "refs/heads/") && !strings.HasPrefix(ref, "refs/tags/")) {
			return nil, fmt.Errorf("receive-pack ref is invalid")
		}
		if seen[ref] {
			return nil, fmt.Errorf("receive-pack ref is duplicated")
		}
		seen[ref] = true
		commands = append(commands, repositorymutation.ReceivePackCommand{Ref: ref, Before: fields[0], After: fields[1]})
		commandCount++
		if commandCount > repositoryReceivePackMaxCommands {
			return nil, fmt.Errorf("receive-pack commands exceed limit")
		}
	}
	request.Body = receivePackReplayBody{Reader: io.MultiReader(bytes.NewReader(prefix.Bytes()), reader), Closer: request.Body}
	sort.Slice(commands, func(i, j int) bool { return commands[i].Ref < commands[j].Ref })
	return commands, nil
}

func repositorymutationReceivePackRefUpdates(values []RepositoryReceivePackRefUpdate) []repositorymutation.ReceivePackRefUpdate {
	updates := make([]repositorymutation.ReceivePackRefUpdate, len(values))
	for index, value := range values {
		updates[index] = repositorymutation.ReceivePackRefUpdate{Ref: value.Ref, Before: value.Before, After: value.After, CommitSHAs: append([]string(nil), value.CommitSHAs...)}
	}
	return updates
}

func repositoryReceivePackRefUpdates(values []repositorymutation.ReceivePackRefUpdate) []RepositoryReceivePackRefUpdate {
	updates := make([]RepositoryReceivePackRefUpdate, len(values))
	for index, value := range values {
		updates[index] = RepositoryReceivePackRefUpdate{
			Ref: value.Ref, Before: value.Before, After: value.After,
			CommitSHAs: append([]string(nil), value.CommitSHAs...),
		}
	}
	return updates
}

func repositoryMutationError(err error) error {
	if errors.Is(err, repositorymutation.ErrRepositoryMutationSealed) {
		return ErrRepositoryMigrationSealed
	}
	if errors.Is(err, repositorymutation.ErrRepositoryMutationIdentity) || errors.Is(err, repositorymutation.ErrRepositoryMutationCapabilityRequired) {
		return ErrRepositoryMigrationConflict
	}
	if errors.Is(err, repositorymutation.ErrRepositoryMutationConflict) {
		return ErrConflict
	}
	return err
}

// runRepositoryReceivePackCGI is the receive-pack-only CGI runner. Unlike
// net/http/cgi it owns the whole process group, so cancellation kills the group
// and joins every descendant before the process-local repository session is
// released. Upload-pack and advertisement continue to use the standard runner.
func runRepositoryReceivePackCGI(request *http.Request, config repositoryReceivePackCGIRequest) (*repositoryReceivePackCGIResponse, bool, error) {
	if request == nil || config.Session == nil {
		return nil, false, fmt.Errorf("receive-pack CGI request is invalid")
	}
	var stdin io.Reader
	if request.ContentLength != 0 {
		stdin = request.Body
	}
	process, stdout, err := config.Session.StartCommand(request.Context(), config.Path, nil, config.Dir, config.Env, stdin, os.Stderr)
	if err != nil {
		return nil, false, fmt.Errorf("start receive-pack CGI: %w", err)
	}
	waited := false
	wait := func(cancel bool) error {
		if waited {
			return nil
		}
		waited = true
		if cancel {
			_ = process.Cancel()
		}
		return process.Wait()
	}
	defer func() { _ = wait(true) }()
	reader := bufio.NewReaderSize(stdout, repositoryReceivePackMaxHeaderBytes)
	drainAndWait := func(primary error) error {
		cancelErr := process.Cancel()
		_, drainErr := io.Copy(io.Discard, reader)
		waitErr := wait(false)
		return errors.Join(primary, cancelErr, drainErr, waitErr)
	}

	headers := make(http.Header)
	statusCode := 0
	totalBytes := 0
	for count := 0; ; count++ {
		if count >= repositoryReceivePackMaxHeaders {
			return nil, true, drainAndWait(fmt.Errorf("receive-pack CGI returned too many headers"))
		}
		line, prefix, readErr := reader.ReadLine()
		if prefix || totalBytes+len(line) > repositoryReceivePackMaxHeaderBytes {
			return nil, true, drainAndWait(fmt.Errorf("receive-pack CGI header exceeds limit"))
		}
		totalBytes += len(line)
		if readErr != nil {
			return nil, true, drainAndWait(fmt.Errorf("read receive-pack CGI header: %w", readErr))
		}
		if len(line) == 0 {
			break
		}
		name, value, ok := strings.Cut(string(line), ":")
		if !ok {
			return nil, true, drainAndWait(fmt.Errorf("receive-pack CGI returned malformed header"))
		}
		value = textproto.TrimString(value)
		if strings.EqualFold(name, "Status") {
			if len(value) < 3 {
				return nil, true, drainAndWait(fmt.Errorf("receive-pack CGI returned malformed status"))
			}
			statusCode, err = strconv.Atoi(value[:3])
			if err != nil || statusCode < 100 || statusCode > 999 {
				return nil, true, drainAndWait(fmt.Errorf("receive-pack CGI returned malformed status"))
			}
			continue
		}
		if !validRepositoryReceivePackCGIHeaderName(name) {
			return nil, true, drainAndWait(fmt.Errorf("receive-pack CGI returned invalid header"))
		}
		headers.Add(name, value)
	}
	if statusCode == 0 {
		statusCode = http.StatusOK
	}
	if headers.Get("Content-Type") == "" {
		return nil, true, drainAndWait(fmt.Errorf("receive-pack CGI response is missing content type"))
	}
	createSpool := config.CreateSpool
	if createSpool == nil {
		createSpool = func() (*os.File, error) {
			return os.CreateTemp(config.Session.RepositoryRoot(), ".ags-receive-pack-*.response")
		}
	}
	body, err := createSpool()
	if err != nil {
		return nil, true, drainAndWait(fmt.Errorf("create receive-pack CGI response spool: %w", err))
	}
	releaseBody := true
	defer func() {
		if releaseBody {
			_ = body.Close()
			_ = os.Remove(body.Name())
		}
	}()
	maxBodyBytes := config.MaxBodyBytes
	if maxBodyBytes <= 0 {
		maxBodyBytes = repositoryReceivePackMaxBodyBytes
	}
	written, copyErr := io.Copy(body, io.LimitReader(reader, maxBodyBytes+1))
	if copyErr != nil {
		return nil, true, drainAndWait(fmt.Errorf("spool receive-pack CGI response: %w", copyErr))
	}
	if written > maxBodyBytes {
		return nil, true, drainAndWait(fmt.Errorf("receive-pack CGI response exceeds limit"))
	}
	if err := wait(false); err != nil {
		if request.Context().Err() != nil {
			return nil, true, request.Context().Err()
		}
		return nil, true, fmt.Errorf("wait receive-pack CGI: %w", err)
	}
	if _, err := body.Seek(0, io.SeekStart); err != nil {
		return nil, true, fmt.Errorf("rewind receive-pack CGI response: %w", err)
	}
	releaseBody = false
	return &repositoryReceivePackCGIResponse{statusCode: statusCode, headers: headers, body: body}, true, nil
}

func (response *repositoryReceivePackCGIResponse) Close() {
	if response == nil || response.body == nil {
		return
	}
	name := response.body.Name()
	_ = response.body.Close()
	_ = os.Remove(name)
	response.body = nil
}

func (response *repositoryReceivePackCGIResponse) WriteTo(writer http.ResponseWriter) error {
	if response == nil || response.body == nil || writer == nil {
		return ErrRepositoryMigrationConflict
	}
	for name, values := range response.headers {
		for _, value := range values {
			writer.Header().Add(name, value)
		}
	}
	writer.WriteHeader(response.statusCode)
	if _, err := io.Copy(writer, response.body); err != nil {
		return fmt.Errorf("%w: %v", ErrRepositoryReceivePackResponseReplay, err)
	}
	return nil
}

func repositoryReceivePackCGIEnvironment(request *http.Request, backend, root, pathInfo string) []string {
	values := make(map[string]string)
	for _, name := range []string{"PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "LC_ALL"} {
		if value := os.Getenv(name); value != "" {
			values[name] = value
		}
	}
	values["GIT_PROJECT_ROOT"] = root
	values["GIT_HTTP_EXPORT_ALL"] = "1"
	values["PATH_INFO"] = pathInfo
	values["REMOTE_USER"] = "git"
	values["GATEWAY_INTERFACE"] = "CGI/1.1"
	values["SERVER_SOFTWARE"] = "agent-git-service"
	values["SERVER_PROTOCOL"] = "HTTP/1.1"
	values["REQUEST_METHOD"] = http.MethodPost
	values["QUERY_STRING"] = ""
	values["REQUEST_URI"] = pathInfo
	values["SCRIPT_FILENAME"] = backend
	values["SERVER_NAME"] = "localhost"
	values["SERVER_PORT"] = "80"
	if _, ok := values["PATH"]; !ok {
		values["PATH"] = "/usr/bin:/bin"
	}
	if request.ContentLength > 0 {
		values["CONTENT_LENGTH"] = strconv.FormatInt(request.ContentLength, 10)
	}
	values["CONTENT_TYPE"] = "application/x-git-receive-pack-request"
	keys := make([]string, 0, len(values))
	for key := range values {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	environment := make([]string, 0, len(keys))
	for _, key := range keys {
		environment = append(environment, key+"="+values[key])
	}
	return environment
}

func validRepositoryReceivePackCGIHeaderName(value string) bool {
	if value == "" {
		return false
	}
	for _, character := range value {
		if character <= 32 || character >= 127 || strings.ContainsRune("()<>@,;:\\\"/[]?={}", character) {
			return false
		}
	}
	return true
}
