// Package githttp serves the Git Smart HTTP protocol backed by system git-http-backend.
// Routes:
//
//	GET  /{owner}/{repo}.git/info/refs?service=git-{upload,receive}-pack
//	POST /{owner}/{repo}.git/git-upload-pack
//	POST /{owner}/{repo}.git/git-receive-pack
package githttp

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"mime"
	"net/http"
	"net/http/cgi"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/go-chi/chi/v5"

	"github.com/pingcap/agent-git-service/internal/db"
	applog "github.com/pingcap/agent-git-service/internal/logging"
	"github.com/pingcap/agent-git-service/internal/rest/respond"
	"github.com/pingcap/agent-git-service/internal/service"
)

// defaultMaxPushBytes caps a single chunked git push when no explicit override
// is set. Chunked requests must be materialized in full before handing off to
// git-http-backend (the CGI contract requires Content-Length), so an
// unbounded body would translate directly into unbounded disk use.
const defaultMaxPushBytes int64 = 2 * 1024 * 1024 * 1024 // 2 GiB

// Store defines the git storage operations required by the HTTP handler.
type Store interface {
	Exists(ctx context.Context, fullName string) bool
	GetRepoPath(ctx context.Context, fullName string) (string, error)
	RepoRoot(ctx context.Context) (string, error)
}

// Handler wraps the gitstore for HTTP git protocol serving.
type Handler struct {
	store Store
	Svc   *service.Service

	pushOutboxOnce sync.Once
	pushOutboxKick chan uint
	// Embedders without a lifecycle context still get a bounded one-shot
	// recovery path. The generation bit prevents a wake arriving at the tail
	// of a drain from being lost without creating one goroutine per request.
	pushOutboxFallbackMu      sync.Mutex
	pushOutboxFallbackRunning bool
	pushOutboxFallbackPending bool
	pushOutboxFallbackTimer   *time.Timer
}

type serveRequest struct {
	projectRoot  string
	repoPath     string
	svcName      string
	advertise    bool
	repoID       uint
	repoFullName string
}

// repoContext holds the resolved repository context for serving git HTTP requests.
type repoContext struct {
	projectRoot  string
	repoPath     string
	repoID       uint
	repoFullName string
}

func gitAuthChallenge(w http.ResponseWriter) {
	w.Header().Set("WWW-Authenticate", `Basic realm="GitHub"`)
	respond.Error(w, http.StatusUnauthorized, "Requires authentication")
}

// New creates a new git HTTP handler.
func New(store Store, svc *service.Service) *Handler {
	handler := &Handler{store: store, Svc: svc}
	if svc != nil && svc.Ctx != nil {
		handler.ensurePushOutboxWorker()
	}
	return handler
}

// ensureRepo checks if the repository exists in the gitstore; if not but in DB, inits it.
func (h *Handler) ensureRepo(ctx context.Context, repository db.Repository) error {
	if h.store.Exists(ctx, repository.FullName) {
		return nil
	}
	return h.Svc.EnsureRepository(ctx, repository.ID)
}

// resolveRepoContext resolves and prepares repository context for git HTTP serving.
// It handles repo lookup, ensures repo exists, and fetches repo path information.
// Returns repoContext on success, or writes error response and returns false on failure.
func (h *Handler) resolveRepoContext(w http.ResponseWriter, r *http.Request, action string, required service.RepoPermission) (*repoContext, bool) {
	owner := pathParam(r, "owner")
	repo := strings.TrimSuffix(pathParam(r, "repo"), ".git")
	requested := owner + "/" + repo
	applog.AddAttrs(r.Context(), slog.String("repo", requested))

	rep, err := h.Svc.LookupRepoIdentity(r.Context(), requested)
	if err != nil {
		if errors.Is(err, service.ErrNotFound) {
			respond.NotFound(w)
			return nil, false
		}
		slog.ErrorContext(r.Context(), "githttp resolve repo failed", "action", action, "repo", requested, "error", err)
		http.Error(w, "Internal Server Error", 500)
		return nil, false
	}
	fullName := rep.FullName
	applog.AddAttrs(r.Context(), slog.String("repo", fullName))

	allowAnonymousRead := !rep.Private && required.Effective() == service.RepoPermissionRead
	if !allowAnonymousRead {
		viewer, ok := service.UserFromContext(r.Context())
		if !ok {
			gitAuthChallenge(w)
			return nil, false
		}
		perm, err := h.Svc.HasRepoAccess(r.Context(), rep.ID, viewer.ID)
		if err != nil {
			if errors.Is(err, service.ErrNotFound) {
				respond.NotFound(w)
				return nil, false
			}
			slog.ErrorContext(r.Context(), "githttp permission check failed", "action", action, "repo", requested, "error", err)
			http.Error(w, "Internal Server Error", 500)
			return nil, false
		}
		if !perm.AtLeast(required) {
			respond.NotFound(w)
			return nil, false
		}
	}

	if err := h.ensureRepo(r.Context(), rep); err != nil {
		if errors.Is(err, service.ErrNotFound) {
			respond.NotFound(w)
			return nil, false
		}
		slog.ErrorContext(r.Context(), "githttp ensure repo failed", "action", action, "repo", requested, "resolved_repo", fullName, "error", err)
		http.Error(w, "Internal Server Error", 500)
		return nil, false
	}

	repoPath, err := h.store.GetRepoPath(r.Context(), fullName)
	if err != nil {
		slog.ErrorContext(r.Context(), "githttp repo path lookup failed", "action", action, "repo", fullName, "error", err)
		http.Error(w, "Internal Server Error", 500)
		return nil, false
	}

	projectRoot, err := h.store.RepoRoot(r.Context())
	if err != nil {
		slog.ErrorContext(r.Context(), "githttp repo root lookup failed", "action", action, "repo", fullName, "error", err)
		http.Error(w, "Internal Server Error", 500)
		return nil, false
	}

	return &repoContext{
		projectRoot:  projectRoot,
		repoPath:     repoPath,
		repoID:       rep.ID,
		repoFullName: fullName,
	}, true
}

func pathParam(r *http.Request, key string) string {
	raw := chi.URLParam(r, key)
	if r.URL.RawPath != "" {
		decoded, err := url.PathUnescape(raw)
		if err == nil {
			return decoded
		}
	}
	return raw
}

// InfoRefs handles GET /{owner}/{repo}.git/info/refs?service=git-*
func (h *Handler) InfoRefs(w http.ResponseWriter, r *http.Request) {
	svc := r.URL.Query().Get("service")
	required := service.RepoPermissionRead
	if svc == "git-receive-pack" {
		required = service.RepoPermissionWrite
	}
	repoCtx, ok := h.resolveRepoContext(w, r, "info/refs", required)
	if !ok {
		return
	}
	if _, _, err := h.serve(w, r, serveRequest{
		projectRoot:  repoCtx.projectRoot,
		repoPath:     repoCtx.repoPath,
		svcName:      svc,
		advertise:    true,
		repoFullName: repoCtx.repoFullName,
	}); err != nil {
		slog.ErrorContext(r.Context(), "githttp info refs failed", "repo", repoCtx.repoFullName, "error", err)
		http.Error(w, "Internal Server Error", 500)
	}
}

// UploadPack handles POST /{owner}/{repo}.git/git-upload-pack (clone/fetch)
func (h *Handler) UploadPack(w http.ResponseWriter, r *http.Request) {
	repoCtx, ok := h.resolveRepoContext(w, r, "upload-pack", service.RepoPermissionRead)
	if !ok {
		return
	}
	if rejectInvalidGitRPCMediaType(w, r, "git-upload-pack") {
		return
	}
	if _, _, err := h.serve(w, r, serveRequest{
		projectRoot:  repoCtx.projectRoot,
		repoPath:     repoCtx.repoPath,
		svcName:      "git-upload-pack",
		advertise:    false,
		repoFullName: repoCtx.repoFullName,
	}); err != nil {
		slog.ErrorContext(r.Context(), "githttp upload-pack failed", "repo", repoCtx.repoFullName, "error", err)
		http.Error(w, "Internal Server Error", 500)
		return
	}
	h.Svc.RecordGitRead(r.Context(), repoCtx.repoID)
}

// ReceivePack handles POST /{owner}/{repo}.git/git-receive-pack (push)
func (h *Handler) ReceivePack(w http.ResponseWriter, r *http.Request) {
	repoCtx, ok := h.resolveRepoContext(w, r, "receive-pack", service.RepoPermissionWrite)
	if !ok {
		return
	}
	if rejectInvalidGitRPCMediaType(w, r, "git-receive-pack") {
		return
	}
	if rejectOversizedReceivePack(w, r) {
		return
	}
	receiveResult, receiveResponse, serveErr := h.serve(w, r, serveRequest{
		projectRoot:  repoCtx.projectRoot,
		repoPath:     repoCtx.repoPath,
		svcName:      "git-receive-pack",
		advertise:    false,
		repoID:       repoCtx.repoID,
		repoFullName: repoCtx.repoFullName,
	})
	if receiveResult.ShouldSchedulePostPush() {
		// The durable outbox is already committed. Wake its fixed-concurrency
		// worker before any potentially blocking HTTP error response.
		h.kickPushOutbox(repoCtx.repoID)
	}
	if receiveResult.NeedsPostPushRecovery() {
		slog.ErrorContext(r.Context(), "receive-pack ref delta is unknown; durable post-push recovery is required", "repo", repoCtx.repoFullName)
		h.kickPushOutbox(repoCtx.repoID)
	}
	if receiveResponse != nil {
		defer receiveResponse.Close()
		replayErr := receiveResponse.Replay(w)
		receiveResult.ResponseCommitted = true
		serveErr = errors.Join(serveErr, replayErr)
	}
	if serveErr != nil {
		slog.ErrorContext(r.Context(), "githttp receive-pack failed", "repo", repoCtx.repoFullName, "error", serveErr)
		respondReceivePackFailure(w, serveErr)
	}
}

const (
	pushOutboxPollInterval = 5 * time.Second
	pushOutboxPageSize     = 16
)

func (h *Handler) ensurePushOutboxWorker() {
	if h == nil || h.Svc == nil {
		return
	}
	h.pushOutboxOnce.Do(func() {
		h.pushOutboxKick = make(chan uint, 64)
		h.Svc.Wg.Add(1)
		go func() {
			defer h.Svc.Wg.Done()
			ticker := time.NewTicker(pushOutboxPollInterval)
			defer ticker.Stop()
			for {
				select {
				case <-h.Svc.ServerCtx().Done():
					return
				case repositoryID := <-h.pushOutboxKick:
					h.processPushOutboxPage(h.Svc.ServerCtx(), repositoryID)
				case <-ticker.C:
					h.processPushOutboxPage(h.Svc.ServerCtx(), 0)
				}
			}
		}()
	})
}

func (h *Handler) kickPushOutbox(repositoryID uint) {
	if h == nil || h.Svc == nil {
		return
	}
	if h.Svc.Ctx == nil {
		// Tests and embedders without a lifecycle context get one bounded
		// worker. Concurrent wakes set a generation bit so a wake racing the
		// final empty claim forces another drain instead of being lost.
		h.pushOutboxFallbackMu.Lock()
		if h.pushOutboxFallbackRunning {
			h.pushOutboxFallbackPending = true
			h.pushOutboxFallbackMu.Unlock()
			return
		}
		h.pushOutboxFallbackRunning = true
		h.pushOutboxFallbackMu.Unlock()
		h.Svc.Wg.Add(1)
		go func() {
			defer h.Svc.Wg.Done()
			for {
				// Concurrent wakes may name different repositories. With no
				// lifecycle ticker, each generation globally drains fixed pages.
				for {
					claimed, retryPage := h.processPushOutboxPage(context.Background(), 0)
					if claimed < pushOutboxPageSize && !retryPage {
						break
					}
				}
				h.pushOutboxFallbackMu.Lock()
				if h.pushOutboxFallbackPending {
					h.pushOutboxFallbackPending = false
					h.pushOutboxFallbackMu.Unlock()
					continue
				}
				h.pushOutboxFallbackRunning = false
				h.pushOutboxFallbackMu.Unlock()
				h.schedulePushOutboxFallback()
				return
			}
		}()
		return
	}
	h.ensurePushOutboxWorker()
	select {
	case h.pushOutboxKick <- repositoryID:
	default:
		// The indexed periodic claim is the durable fallback; dropping an
		// in-memory wake never drops the outbox row.
	}
}

func (h *Handler) schedulePushOutboxFallback() {
	if h == nil || h.Svc == nil || h.Svc.Ctx != nil {
		return
	}
	pending, err := h.Svc.HasPendingRepositoryPushWork(context.Background())
	if err != nil {
		slog.Error("inspect pending repository push wake", "error", err)
		pending = true
	}
	h.pushOutboxFallbackMu.Lock()
	defer h.pushOutboxFallbackMu.Unlock()
	if !pending {
		if h.pushOutboxFallbackTimer != nil {
			h.pushOutboxFallbackTimer.Stop()
			h.pushOutboxFallbackTimer = nil
		}
		return
	}
	if h.pushOutboxFallbackTimer != nil {
		return
	}
	var timer *time.Timer
	timer = time.AfterFunc(pushOutboxPollInterval, func() {
		h.pushOutboxFallbackMu.Lock()
		if h.pushOutboxFallbackTimer != timer {
			h.pushOutboxFallbackMu.Unlock()
			return
		}
		h.pushOutboxFallbackTimer = nil
		h.pushOutboxFallbackMu.Unlock()
		h.kickPushOutbox(0)
	})
	h.pushOutboxFallbackTimer = timer
}

func (h *Handler) processPushOutboxPage(parent context.Context, repositoryID uint) (int, bool) {
	ctx, cancel := context.WithTimeout(parent, 30*time.Second)
	defer cancel()
	_, attempted, recoveryErr := h.Svc.RecoverRepositoryPushMarkers(ctx, pushOutboxPageSize)
	if recoveryErr != nil {
		slog.ErrorContext(ctx, "recover repository push markers", "error", recoveryErr)
	}
	claims, err := h.Svc.ClaimRepositoryPushOutbox(ctx, repositoryID, pushOutboxPageSize)
	if err != nil {
		slog.ErrorContext(ctx, "claim repository push outbox", "error", err)
	}
	for _, claim := range claims {
		if err := h.processPushOutboxClaim(ctx, claim); err != nil {
			if retryErr := h.Svc.RetryRepositoryPushOutbox(ctx, claim, err); retryErr != nil {
				slog.ErrorContext(ctx, "retry repository push outbox", "error", retryErr)
			}
			continue
		}
		if err := h.Svc.CompleteRepositoryPushOutbox(ctx, claim); err != nil {
			slog.ErrorContext(ctx, "complete repository push outbox", "error", err)
		}
	}
	// Identity errors here are malformed claimed rows that Owner has already
	// backed off. Continue to the next bounded page so they cannot hide due
	// rows behind them; dependency/DB errors stop this wake and retain leases.
	return max(attempted, len(claims)), errors.Is(err, service.ErrRepositoryMigrationConflict)
}

func (h *Handler) processPushOutboxClaim(ctx context.Context, claim *service.RepositoryPushOutboxClaim) error {
	event := claim.Event()
	if event.RepositoryID == 0 || event.FullName == "" || event.OperationID == "" {
		return fmt.Errorf("repository push outbox event is invalid")
	}
	if event.Actor.ID != 0 {
		ctx = service.ContextWithUser(ctx, event.Actor)
	}
	repositoryPath, err := h.store.GetRepoPath(ctx, event.FullName)
	if err != nil {
		return err
	}
	if err := h.handlePostPushWebhooks(ctx, event.FullName, repositoryPath, event.RefUpdates, event.OccurredAt, event.OperationID); err != nil {
		return err
	}
	if err := h.Svc.SyncWorkflowsFromRepo(ctx, event.FullName); err != nil {
		return err
	}
	if parent, ok := wikiRepoParentName(event.FullName); ok {
		h.Svc.KickBackgroundWikiGitIngest(ctx, parent)
	}
	return nil
}

func respondReceivePackFailure(writer http.ResponseWriter, err error) {
	if errors.Is(err, service.ErrRepositoryReceivePackResponseReplay) {
		return
	}
	http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}

func rejectInvalidGitRPCMediaType(w http.ResponseWriter, r *http.Request, serviceName string) bool {
	mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
	if err != nil || !strings.EqualFold(mediaType, "application/x-"+serviceName+"-request") {
		http.Error(w, "Unsupported Media Type", http.StatusUnsupportedMediaType)
		return true
	}
	return false
}

// wikiRepoParentName reports whether full names a wiki repo
// (suffix ".wiki") and returns the parent repository's full name when
// it does. Used by the post-receive hook to drive IngestWikiGit for
// wiki pushes.
func wikiRepoParentName(full string) (string, bool) {
	const suffix = ".wiki"
	if !strings.HasSuffix(full, suffix) {
		return "", false
	}
	return strings.TrimSuffix(full, suffix), true
}

func rejectOversizedReceivePack(w http.ResponseWriter, r *http.Request) bool {
	limit := maxPushBytes()
	if r.ContentLength > limit {
		slog.WarnContext(r.Context(), "githttp push body exceeded limit", "size", r.ContentLength, "limit", limit)
		http.Error(w, "push body exceeds maximum size", http.StatusRequestEntityTooLarge)
		return true
	}
	return false
}

// serve delegates to the system git-http-backend CGI program using net/http/cgi.
func (h *Handler) serve(w http.ResponseWriter, r *http.Request, req serveRequest) (service.RepositoryReceivePackResult, *service.RepositoryReceivePackResponse, error) {
	emptyResult := service.RepositoryReceivePackResult{}
	// Auth is handled by middleware/service before this point. Avoid forwarding
	// bearer credentials to git-http-backend via CGI environment.
	cgiReq := r.Clone(r.Context())
	cgiReq.Header = r.Header.Clone()
	cgiReq.Header.Del("Authorization")

	// git-http-backend runs as a CGI program and requires Content-Length, so we
	// must convert chunked transfer-encoding requests to a sized body. Spool to a
	// temp file instead of RAM so hostile or oversized pushes don't exhaust
	// memory; the cap is configurable via GITHTTP_MAX_PUSH_BYTES and the spool
	// directory via GITHTTP_SPOOL_DIR (for environments where $TMPDIR is tmpfs).
	isReceivePack := req.svcName == "git-receive-pack" && !req.advertise
	if isReceivePack || hasChunkedTransferEncoding(cgiReq.TransferEncoding) {
		declaredLength := cgiReq.ContentLength
		tmp, size, exceeded, err := spoolBoundedRequestBody(r.Context(), w, cgiReq.Body, maxPushBytes())
		if err != nil {
			return emptyResult, nil, fmt.Errorf("spool request body: %w", err)
		}
		if exceeded {
			return emptyResult, nil, nil
		}
		if isReceivePack && declaredLength >= 0 && size != declaredLength {
			_ = tmp.Close()
			_ = os.Remove(tmp.Name())
			return emptyResult, nil, fmt.Errorf("receive-pack body length = %d, want %d", size, declaredLength)
		}
		defer func() {
			_ = tmp.Close()
			_ = os.Remove(tmp.Name())
		}()
		cgiReq.Body = tmp
		cgiReq.ContentLength = size
		cgiReq.TransferEncoding = nil
		cgiReq.Header.Del("Transfer-Encoding")
	}
	if isReceivePack {
		return h.Svc.ServeRepositoryReceivePack(cgiReq, req.repoID)
	}

	backend, err := findGitHTTPBackend()
	if err != nil {
		return emptyResult, nil, fmt.Errorf("git-http-backend not found: %w", err)
	}

	action := ""
	if strings.HasSuffix(r.URL.Path, "info/refs") {
		action = "info/refs"
	} else if strings.HasSuffix(r.URL.Path, "git-upload-pack") {
		action = "git-upload-pack"
	} else if strings.HasSuffix(r.URL.Path, "git-receive-pack") {
		action = "git-receive-pack"
	}

	// Build PATH_INFO for read-only git-http-backend operations. Receive-pack
	// derives its executable, root, and canonical path inside the Service owner.
	owner, repo, ok := strings.Cut(req.repoFullName, "/")
	if !ok || owner == "" || repo == "" {
		return emptyResult, nil, errors.New("invalid repository full name")
	}
	pathInfo := fmt.Sprintf("/%s/%s.git/%s", owner, repo, action)
	env := []string{
		"GIT_PROJECT_ROOT=" + req.projectRoot,
		"GIT_HTTP_EXPORT_ALL=1",
		"PATH_INFO=" + pathInfo,
		"REMOTE_USER=git",
	}

	dir, _ := os.Getwd()
	handler := &cgi.Handler{
		Path:       backend,
		Dir:        dir,
		Env:        env,
		InheritEnv: []string{"PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "LC_ALL"},
	}
	handler.ServeHTTP(w, cgiReq)
	return emptyResult, nil, nil
}

func hasChunkedTransferEncoding(encodings []string) bool {
	for _, encoding := range encodings {
		if strings.EqualFold(encoding, "chunked") {
			return true
		}
	}
	return false
}

// maxPushBytes returns the configured chunked-push size cap.
func maxPushBytes() int64 {
	if env := os.Getenv("GITHTTP_MAX_PUSH_BYTES"); env != "" {
		if n, err := strconv.ParseInt(env, 10, 64); err == nil && n > 0 {
			return n
		}
	}
	return defaultMaxPushBytes
}

// pushSpoolDir returns the directory where chunked-push temp files are
// written. Defaults to "" (system tmpdir); set GITHTTP_SPOOL_DIR to force a
// disk-backed location when $TMPDIR is mounted as tmpfs.
func pushSpoolDir() string {
	return os.Getenv("GITHTTP_SPOOL_DIR")
}

// spoolBoundedRequestBody drains a request body into a seekable temp file
// bounded by maxBytes. Returns the spooled file (rewound to offset 0) and its
// size on success — the caller then owns closing and removing the file.
// On overflow, a 413 has already been written to w, the temp file has been
// cleaned up internally, and exceeded=true is returned.
func spoolBoundedRequestBody(ctx context.Context, w http.ResponseWriter, body io.ReadCloser, maxBytes int64) (*os.File, int64, bool, error) {
	tmp, err := os.CreateTemp(pushSpoolDir(), "git-push-*.body")
	if err != nil {
		return nil, 0, false, fmt.Errorf("create temp: %w", err)
	}
	release := tmp
	defer func() {
		if release != nil {
			_ = release.Close()
			_ = os.Remove(release.Name())
		}
	}()

	n, err := io.Copy(tmp, io.LimitReader(body, maxBytes+1))
	_ = body.Close()
	if err != nil {
		return nil, 0, false, fmt.Errorf("spool body: %w", err)
	}
	if n > maxBytes {
		slog.WarnContext(ctx, "githttp push body exceeded limit", "size", n, "limit", maxBytes)
		http.Error(w, "push body exceeds maximum size", http.StatusRequestEntityTooLarge)
		return nil, 0, true, nil
	}
	if _, err := tmp.Seek(0, io.SeekStart); err != nil {
		return nil, 0, false, fmt.Errorf("rewind temp: %w", err)
	}
	release = nil
	return tmp, n, false, nil
}

// findGitHTTPBackend locates the git-http-backend executable.
func findGitHTTPBackend() (string, error) {
	candidates := []string{
		"/usr/lib/git-core/git-http-backend",
		"/usr/libexec/git-core/git-http-backend",
		"/opt/homebrew/libexec/git-core/git-http-backend", // Add homebrew for mac users
	}
	if out, err := exec.Command("git", "--exec-path").Output(); err == nil {
		execPath := strings.TrimSpace(string(out))
		candidates = append([]string{filepath.Join(execPath, "git-http-backend")}, candidates...)
	}
	for _, p := range candidates {
		if _, err := os.Stat(p); err == nil {
			return p, nil
		}
	}
	return "", fmt.Errorf("not found in common locations")
}
