package handlers

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"io"
	"net/http"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"golang.org/x/crypto/bcrypt"
	"xorm.io/xorm"

	"github.com/forgeo/forgebucket/internal/config"
	"github.com/forgeo/forgebucket/internal/events"
	"github.com/forgeo/forgebucket/internal/models"
)

type GitHTTPHandler struct {
	db  *xorm.Engine
	cfg *config.Config
	bus events.EventBus
}

func NewGitHTTPHandler(db *xorm.Engine, cfg *config.Config, bus events.EventBus) *GitHTTPHandler {
	return &GitHTTPHandler{db: db, cfg: cfg, bus: bus}
}

// refUpdate captures one ref-update line from a git-receive-pack request.
type refUpdate struct {
	OldRev string
	NewRev string
	Ref    string
}

// ServeGit is the entry point for all git smart-HTTP requests.
// URL shape: /{owner}/{repo}.git/{service}
func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) {
	owner := chi.URLParam(r, "owner")
	repoGit := chi.URLParam(r, "repoGit") // e.g. "myrepo.git"
	repoName := strings.TrimSuffix(repoGit, ".git")

	// Resolve repo from DB
	var ownerUser models.User
	if found, _ := h.db.Where("username = ?", owner).Get(&ownerUser); !found {
		http.NotFound(w, r)
		return
	}
	var repo models.Repository
	if found, _ := h.db.Where("owner_id = ? AND name = ?", ownerUser.ID, repoName).Get(&repo); !found {
		http.NotFound(w, r)
		return
	}

	// Determine service from URL / query string
	service := r.URL.Query().Get("service")
	if service == "" {
		// POST bodies encode the service in the path
		path := r.URL.Path
		if strings.HasSuffix(path, "/git-upload-pack") {
			service = "git-upload-pack"
		} else if strings.HasSuffix(path, "/git-receive-pack") {
			service = "git-receive-pack"
		}
	}

	// Authenticate and enforce permission checks.
	// Priority: user account → deploy key → anonymous (public repos only).
	var authedUser string
	var authedReadOnly bool

	if _, p, hasAuth := r.BasicAuth(); hasAuth {
		if user, ok := h.basicAuth(r); ok {
			authedUser = user
			// User account: enforce member permissions.
			if service == "git-receive-pack" && !HasPermission(h.db, &repo, user, "write") {
				http.Error(w, "forbidden: you do not have write access to this repository", http.StatusForbidden)
				return
			}
			if repo.IsPrivate && !HasPermission(h.db, &repo, user, "read") {
				http.Error(w, "forbidden: you do not have read access to this repository", http.StatusForbidden)
				return
			}
		} else if rdOnly, ok := AuthenticateDeployKey(h.db, repo.ID, p); ok {
			// Deploy key: the password field carries the raw token; username is ignored.
			authedUser = "deploy-key"
			authedReadOnly = rdOnly
			if service == "git-receive-pack" && rdOnly {
				http.Error(w, "forbidden: this deploy key is read-only", http.StatusForbidden)
				return
			}
		} else if _, repoID, hasWrite, ok := LookupAccessToken(h.db, p); ok && repoID == repo.ID {
			// Access token used as git credential (username ignored, password = token).
			authedUser = "access-token"
			if service == "git-receive-pack" && !hasWrite {
				http.Error(w, "forbidden: this access token has read-only scope", http.StatusForbidden)
				return
			}
		} else {
			// Credentials provided but invalid.
			w.Header().Set("WWW-Authenticate", `Basic realm="ForgeBucket"`)
			http.Error(w, "invalid credentials", http.StatusUnauthorized)
			return
		}
	} else if service == "git-receive-pack" || repo.IsPrivate {
		w.Header().Set("WWW-Authenticate", `Basic realm="ForgeBucket"`)
		http.Error(w, "authentication required", http.StatusUnauthorized)
		return
	}
	_ = authedReadOnly

	// Branch protection check: parse pkt-lines from the receive-pack body,
	// check each ref against stored protection rules, then restore the body.
	var pushedRefs []refUpdate
	if service == "git-receive-pack" {
		reason, refs, newBody := parseAndCheckBody(h.db, repo.ID, authedUser, r.Body)
		if reason != "" {
			http.Error(w, reason, http.StatusForbidden)
			return
		}
		pushedRefs = refs
		r.Body = io.NopCloser(newBody)
	}

	// Build PATH_INFO: /{reponame}.git/{suffix}
	// Strip the /{owner}/{repoGit} prefix from the raw URL path to get the suffix.
	prefix := "/" + owner + "/" + repoGit
	suffix := strings.TrimPrefix(r.URL.Path, prefix)
	if suffix == "" {
		suffix = "/"
	}
	pathInfo := "/" + repoGit + suffix

	projectRoot := filepath.Dir(repo.DiskPath)

	env := []string{
		"GIT_PROJECT_ROOT=" + projectRoot,
		"GIT_HTTP_EXPORT_ALL=1",
		"PATH_INFO=" + pathInfo,
		"QUERY_STRING=" + r.URL.RawQuery,
		"REQUEST_METHOD=" + r.Method,
		"SERVER_PROTOCOL=HTTP/1.1",
		"SERVER_SOFTWARE=ForgeBucket/1.0",
		"REMOTE_ADDR=" + r.RemoteAddr,
		"HOME=/tmp",
		"GIT_TERMINAL_PROMPT=0",
	}
	if ct := r.Header.Get("Content-Type"); ct != "" {
		env = append(env, "CONTENT_TYPE="+ct)
	}
	if cl := r.ContentLength; cl > 0 {
		env = append(env, "CONTENT_LENGTH="+strconv.FormatInt(cl, 10))
	}
	if authedUser != "" {
		env = append(env, "REMOTE_USER="+authedUser)
	}

	gitExec, err := exec.LookPath("git")
	if err != nil {
		http.Error(w, "git not found on server", http.StatusInternalServerError)
		return
	}

	if err := runGitBackend(r.Context(), w, r.Body, gitExec, env); err != nil {
		http.Error(w, fmt.Sprintf("git http-backend: %v", err), http.StatusInternalServerError)
		return
	}

	// Publish push.received for each ref pushed so the CI orchestrator can react.
	if service == "git-receive-pack" {
		zeroOID := strings.Repeat("0", 40)
		for _, ref := range pushedRefs {
			if ref.NewRev == zeroOID {
				continue // branch deletion — skip CI trigger
			}
			go h.bus.Publish(events.SubjectPushReceived, events.PushEvent{ //nolint:errcheck
				RepoID:    repo.ID,
				RepoName:  repoName,
				OwnerName: owner,
				Ref:       ref.Ref,
				Before:    ref.OldRev,
				After:     ref.NewRev,
				Pusher:    authedUser,
				At:        time.Now().UTC(),
			})
		}
	}
}

// basicAuth validates HTTP Basic Auth credentials against the DB.
func (h *GitHTTPHandler) basicAuth(r *http.Request) (username string, ok bool) {
	u, p, hasAuth := r.BasicAuth()
	if !hasAuth {
		return "", false
	}
	var user models.User
	found, _ := h.db.Where("username = ?", u).Get(&user)
	if !found {
		return "", false
	}
	if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(p)) != nil {
		return "", false
	}
	return u, true
}

// runGitBackend executes `git http-backend` as a CGI subprocess and forwards
// its response (headers + body) to the HTTP response writer.
func runGitBackend(ctx context.Context, w http.ResponseWriter, body io.Reader, gitExec string, env []string) error {
	cmd := exec.CommandContext(ctx, gitExec, "http-backend")
	cmd.Env = env

	pr, pw := io.Pipe()
	cmd.Stdout = pw
	cmd.Stdin = body

	var stderrBuf strings.Builder
	cmd.Stderr = &stderrBuf

	if err := cmd.Start(); err != nil {
		return fmt.Errorf("start: %w", err)
	}

	// Close write-end of pipe once git finishes
	done := make(chan error, 1)
	go func() {
		err := cmd.Wait()
		pw.Close()
		done <- err
	}()

	// Parse CGI response: headers then body
	br := bufio.NewReader(pr)
	statusCode := http.StatusOK

	for {
		line, err := br.ReadString('\n')
		line = strings.TrimRight(line, "\r\n")
		if line == "" {
			break
		}
		if strings.HasPrefix(line, "Status:") {
			rest := strings.TrimSpace(strings.TrimPrefix(line, "Status:"))
			if parts := strings.SplitN(rest, " ", 2); len(parts) >= 1 {
				if code, e := strconv.Atoi(parts[0]); e == nil {
					statusCode = code
				}
			}
		} else if idx := strings.Index(line, ":"); idx > 0 {
			key := strings.TrimSpace(line[:idx])
			val := strings.TrimSpace(line[idx+1:])
			w.Header().Set(key, val)
		}
		if err != nil {
			break
		}
	}

	w.WriteHeader(statusCode)
	io.Copy(w, br) //nolint:errcheck

	waitErr := <-done
	if waitErr != nil && stderrBuf.Len() > 0 {
		return fmt.Errorf("%w: %s", waitErr, stderrBuf.String())
	}
	return waitErr
}

// parseAndCheckBody parses git pkt-line ref updates from a receive-pack body,
// checks each ref against stored branch protection rules, and returns a denial
// reason (or ""), the list of parsed ref updates, and a restored reader.
func parseAndCheckBody(db *xorm.Engine, repoID int64, pusher string, body io.Reader) (reason string, refs []refUpdate, restored io.Reader) {
	var buf bytes.Buffer
	zeroOID := strings.Repeat("0", 40)

	for {
		lenBuf := make([]byte, 4)
		if _, err := io.ReadFull(body, lenBuf); err != nil {
			break
		}
		buf.Write(lenBuf)

		pktLen64, err := strconv.ParseInt(string(lenBuf), 16, 32)
		if err != nil {
			break
		}
		if pktLen64 == 0 {
			break // flush packet — end of ref-update list
		}
		dataLen := int(pktLen64) - 4
		if dataLen <= 0 {
			break
		}
		data := make([]byte, dataLen)
		if _, err := io.ReadFull(body, data); err != nil {
			break
		}
		buf.Write(data)

		// Strip NUL-separated capabilities (only on first pkt-line) and trailing newline.
		line := strings.TrimRight(strings.SplitN(string(data), "\x00", 2)[0], "\n")
		parts := strings.SplitN(line, " ", 3)
		if len(parts) != 3 {
			continue
		}
		oldRev, newRev, refname := parts[0], parts[1], parts[2]

		refs = append(refs, refUpdate{OldRev: oldRev, NewRev: newRev, Ref: refname})

		if oldRev == zeroOID {
			continue // new branch — not subject to protection
		}
		isForcePush := newRev == zeroOID
		if msg := CheckBranchProtection(db, repoID, pusher, refname, isForcePush); msg != "" {
			return msg, refs, io.MultiReader(&buf, body)
		}
	}
	return "", refs, io.MultiReader(&buf, body)
}
