package xrpc

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strings"

	"github.com/bluesky-social/indigo/atproto/atclient"
	"github.com/bluesky-social/indigo/atproto/syntax"
	"tangled.org/core/api/tangled"
	"tangled.org/core/knotserver/sandbox"
	"tangled.org/core/rbac"
)

func (x *Xrpc) MergePullRequest(w http.ResponseWriter, r *http.Request) {
	var input tangled.RepoMergePullRequest_Input
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "failed to decode json body"})
		return
	}
	l := x.Logger.With("handler", "MergePullRequest", "input", input)
	l.Debug("request")

	if err := repoMergePullRequest_Input_Validate(input); err != nil {
		writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: err.Error()})
		return
	}

	actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
	if !ok {
		writeJson(w, http.StatusUnauthorized, &atclient.ErrorBody{Name: "Unauthorized", Message: "missing actor DID"})
		return
	}
	if allowed, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, input.Repo); err != nil || !allowed {
		writeJson(w, http.StatusUnauthorized, &atclient.ErrorBody{Name: "Forbidden", Message: fmt.Sprintf("%s is not allowed to merge into this repository", actorDid.String())})
		return
	}

	output, status, apierr := x.mergePullRequest(r.Context(), input)
	if apierr != nil {
		l.Error("failed", "kind", apierr.Name, "error", apierr.Message)
		writeJson(w, status, apierr)
		return
	}

	writeJson(w, status, output)
}

func (x *Xrpc) mergePullRequest(ctx context.Context, input tangled.RepoMergePullRequest_Input) (any, int, *atclient.ErrorBody) {
	l := x.Logger.With("handler", "mergePullRequest")

	fail := func(status int, name, clientMsg string, detail ...any) (any, int, *atclient.ErrorBody) {
		l.Error(clientMsg, append([]any{"name", name}, detail...)...)
		return nil, status, &atclient.ErrorBody{Name: name, Message: clientMsg}
	}

	baseRepoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Repo)
	if err != nil {
		return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "repo", input.Repo, "err", err)
	}
	sourceRepoDid := syntax.DID(input.Source.Repo)

	var sourceRepoUrl string
	var sourceRepoPath string // non-empty only when the source is local to this knot
	if p, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Source.Repo); err == nil {
		sourceRepoPath = p
		sourceRepoUrl = "file://" + p
	} else {
		ident, err := x.Resolver.Directory().LookupDID(ctx, sourceRepoDid)
		if err != nil {
			return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "err", err)
		}
		sourceKnot := ident.GetServiceEndpoint("atproto_pds")
		u, err := url.Parse(sourceKnot)
		if err != nil {
			return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "knot", sourceKnot, "err", err)
		}
		sourceRepoUrl = u.JoinPath(sourceRepoDid.String()).String()
	}

	var authorName, authorEmail, authorDate string
	if input.Commit != nil && input.Commit.Author != nil && input.Commit.Author.Name != "" && input.Commit.Author.Email != "" {
		authorName = input.Commit.Author.Name
		authorEmail = input.Commit.Author.Email
		authorDate = input.Commit.Author.When
	} else {
		authorName = x.Config.Git.UserName
		authorEmail = x.Config.Git.UserEmail
	}

	var message string
	if input.Commit != nil && input.Commit.Message != nil && *input.Commit.Message != "" {
		message = *input.Commit.Message
	} else {
		shortSha := input.Source.Commit
		if len(shortSha) > 8 {
			shortSha = shortSha[:8]
		}
		message = fmt.Sprintf("Merge %s into %s", shortSha, input.Branch)
	}

	env := append(os.Environ(),
		"GIT_TERMINAL_PROMPT=0",
		"GIT_AUTHOR_NAME="+authorName,
		"GIT_AUTHOR_EMAIL="+authorEmail,
		"GIT_COMMITTER_NAME="+x.Config.Git.UserName,
		"GIT_COMMITTER_EMAIL="+x.Config.Git.UserEmail,
	)
	if authorDate != "" {
		env = append(env, "GIT_AUTHOR_DATE="+authorDate)
	}

	// 1. create temp repo with git alternate
	tmpRepoPath, cleanup, err := createTemporaryRepoForMerge(ctx, x.Sandbox, baseRepoPath, input.Branch)
	if err != nil {
		return fail(http.StatusInternalServerError, "InternalError", "failed to prepare merge", "err", err)
	}
	defer cleanup()

	runGit := func(args ...string) ([]byte, []byte, error) {
		args = append([]string{"-C", tmpRepoPath}, args...)
		return gitWithSandbox(ctx, x.Sandbox, env, []string{tmpRepoPath}, args...)
	}

	// 2. fetch source as "tracking" branch
	fetchPaths := []string{tmpRepoPath}
	if sourceRepoPath != "" {
		fetchPaths = append(fetchPaths, sourceRepoPath)
	}
	if _, stderr, err := gitWithSandbox(ctx, x.Sandbox, env, fetchPaths, "-C", tmpRepoPath, "fetch", sourceRepoUrl, input.Source.Commit); err != nil {
		return fail(http.StatusInternalServerError, "InternalError", "failed to fetch source commit", "err", err, "stderr", strings.TrimSpace(string(stderr)))
	}
	if _, stderr, err := runGit("branch", "tracking", input.Source.Commit); err != nil {
		return fail(http.StatusBadRequest, "InvalidCommit", "source commit unavailable", "commit", input.Source.Commit, "err", err, "stderr", strings.TrimSpace(string(stderr)))
	}

	// populate the working tree on the base branch.
	if _, stderr, err := runGit("checkout", "-f", "base"); err != nil {
		return fail(http.StatusInternalServerError, "InternalError", "failed to merge pull request", "step", "checkout base", "err", err, "stderr", strings.TrimSpace(string(stderr)))
	}

	// 3. merge
	switch input.Style {
	case "rebase":
		if status, apierr := rebaseTrackingOntoBase(runGit, l, tmpRepoPath); apierr != nil {
			return nil, status, apierr
		}
		if _, stderr, err := runGit("checkout", "base"); err != nil {
			return fail(http.StatusInternalServerError, "InternalError", "failed to merge pull request", "step", "checkout base after rebase", "err", err, "stderr", strings.TrimSpace(string(stderr)))
		}
		if _, stderr, err := runGit("merge", "--ff-only", "staging"); err != nil {
			return mergeConflictError(runGit, l, stderr)
		}

	case "merge":
		if _, stderr, err := runGit("merge", "--no-ff", "--no-commit", "tracking"); err != nil {
			return mergeConflictError(runGit, l, stderr)
		}
		if _, stderr, err := runGit("commit", "--no-gpg-sign", "--message="+message); err != nil {
			return fail(http.StatusInternalServerError, "InternalError", "failed to merge pull request", "step", "merge commit", "err", err, "stderr", strings.TrimSpace(string(stderr)))
		}

	case "rebase-merge":
		if status, apierr := rebaseTrackingOntoBase(runGit, l, tmpRepoPath); apierr != nil {
			return nil, status, apierr
		}
		if _, stderr, err := runGit("checkout", "base"); err != nil {
			return fail(http.StatusInternalServerError, "InternalError", "failed to merge pull request", "step", "checkout base after rebase", "err", err, "stderr", strings.TrimSpace(string(stderr)))
		}
		if _, stderr, err := runGit("merge", "--no-ff", "--no-commit", "staging"); err != nil {
			return mergeConflictError(runGit, l, stderr)
		}
		if _, stderr, err := runGit("commit", "--no-gpg-sign", "--message="+message); err != nil {
			return fail(http.StatusInternalServerError, "InternalError", "failed to merge pull request", "step", "merge commit", "err", err, "stderr", strings.TrimSpace(string(stderr)))
		}

	case "squash-rebase":
		if _, stderr, err := runGit("merge", "--squash", "tracking"); err != nil {
			return mergeConflictError(runGit, l, stderr)
		}
		if _, stderr, err := runGit("commit", "--no-gpg-sign", "--message="+message); err != nil {
			return fail(http.StatusInternalServerError, "InternalError", "failed to merge pull request", "step", "squash commit", "err", err, "stderr", strings.TrimSpace(string(stderr)))
		}

	case "squash-rebase-merge":
		return fail(http.StatusInternalServerError, "InternalError", "squash-rebase-merge is not yet supported")

	case "fast-forward-only":
		if _, stderr, err := runGit("merge", "--ff-only", "tracking"); err != nil {
			return fail(http.StatusConflict, "MergeConflict", "cannot fast-forward: source and target have diverged", "commit", input.Source.Commit, "branch", input.Branch, "err", err, "stderr", strings.TrimSpace(string(stderr)))
		}

	default:
		return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("unknown merge style: %q", input.Style)}
	}

	// 4. push the merged "base" back to the real base repo's branch. this goes through
	// receive-pack so the base repo's hooks fire (ref-update notification).
	if _, stderr, err := gitWithSandbox(ctx, x.Sandbox, env, []string{tmpRepoPath, baseRepoPath}, "-C", tmpRepoPath, "push", "origin", "base:refs/heads/"+input.Branch); err != nil {
		msg := strings.TrimSpace(string(stderr))
		if strings.Contains(msg, "non-fast-forward") || strings.Contains(msg, "rejected") {
			return fail(http.StatusConflict, "PushRejected", "target branch changed; retry the merge", "branch", input.Branch, "err", err, "stderr", msg)
		}
		return fail(http.StatusInternalServerError, "InternalError", "failed to complete merge", "step", "push", "err", err, "stderr", msg)
	}

	return nil, http.StatusOK, nil
}

// rebaseTrackingOntoBase checks out "tracking" as "staging" and rebases it onto "base".
func rebaseTrackingOntoBase(run func(...string) ([]byte, []byte, error), l *slog.Logger, tmpRepoPath string) (int, *atclient.ErrorBody) {
	if _, stderr, err := run("checkout", "-b", "staging", "tracking"); err != nil {
		l.Error("failed to merge pull request", "step", "checkout staging", "err", err, "stderr", strings.TrimSpace(string(stderr)))
		return http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalError", Message: "failed to merge pull request"}
	}
	if _, stderr, err := run("rebase", "base"); err != nil {
		if _, statErr := os.Stat(filepath.Join(tmpRepoPath, ".git", "REBASE_HEAD")); statErr == nil {
			l.Error("rebase produced conflicts", "err", err, "stderr", strings.TrimSpace(string(stderr)))
			return http.StatusConflict, &atclient.ErrorBody{Name: "MergeConflict", Message: "rebase produced conflicts"}
		}
		l.Error("failed to merge pull request", "step", "rebase", "err", err, "stderr", strings.TrimSpace(string(stderr)))
		return http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalError", Message: "failed to merge pull request"}
	}
	return 0, nil
}

// mergeConflictError inspects the temp repo for unmerged paths after a failed merge and
// returns a 409 listing them, falling back to a 500 when the failure is not a conflict.
func mergeConflictError(runGit func(...string) ([]byte, []byte, error), l *slog.Logger, mergeStderr []byte) (any, int, *atclient.ErrorBody) {
	stdout, _, _ := runGit("ls-files", "--unmerged")
	files := parseUnmergedFiles(stdout)
	if len(files) > 0 {
		l.Error("merge produced conflicts", "files", files, "stderr", strings.TrimSpace(string(mergeStderr)))
		return nil, http.StatusConflict, &atclient.ErrorBody{Name: "MergeConflict", Message: "merge produced conflicts"}
	}
	l.Error("failed to merge pull request", "step", "merge", "stderr", strings.TrimSpace(string(mergeStderr)))
	return nil, http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalError", Message: "failed to merge pull request"}
}

// parseUnmergedFiles parses `git ls-files --unmerged` output into a deduplicated list of paths.
// Each line looks like: "<mode> <sha> <stage>\t<path>".
func parseUnmergedFiles(out []byte) []string {
	seen := make(map[string]struct{})
	var files []string
	for line := range strings.SplitSeq(string(out), "\n") {
		_, path, ok := strings.Cut(line, "\t")
		if !ok {
			continue
		}
		if _, ok := seen[path]; ok {
			continue
		}
		seen[path] = struct{}{}
		files = append(files, path)
	}
	return files
}

// lexgen doesn't give Validate() method...
func repoMergePullRequest_Input_Validate(input tangled.RepoMergePullRequest_Input) error {
	if _, err := syntax.ParseDID(input.Repo); err != nil {
		return fmt.Errorf("repo: invalid DID: %w", err)
	}
	if input.Branch == "" {
		return fmt.Errorf("branch: required")
	}
	if input.Source == nil {
		return fmt.Errorf("source: required")
	}
	if _, err := syntax.ParseDID(input.Source.Repo); err != nil {
		return fmt.Errorf("source.repo: invalid DID: %w", err)
	}
	if input.Source.Commit == "" {
		return fmt.Errorf("source.commit: required")
	}
	switch input.Style {
	case "merge", "rebase", "rebase-merge", "squash", "fast-forward-only":
	default:
		return fmt.Errorf("style: unknown merge style %q", input.Style)
	}
	return nil
}

// createTemporaryRepoForMerge creates a temporary non-bare repo with the base repo's
// "base" branch (and a copy "original_base") checked out via a git alternate to the base
// repo's object store. Returns the temp repo path and a cleanup func that removes it.
func createTemporaryRepoForMerge(ctx context.Context, sb sandbox.Backend, baseRepoPath string, baseBranch string) (path string, cleanup context.CancelFunc, err error) {
	tmp, err := os.MkdirTemp("", "merge-*")
	if err != nil {
		return "", nil, fmt.Errorf("create temp dir: %w", err)
	}
	cleanup = func() { os.RemoveAll(tmp) }

	env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
	run := func(paths []string, args ...string) ([]byte, []byte, error) {
		return gitWithSandbox(ctx, sb, env, paths, args...)
	}

	// git init needs a working dir (non-bare).
	if _, stderr, err := run([]string{tmp}, "-C", tmp, "init"); err != nil {
		cleanup()
		return "", nil, fmt.Errorf("git init: %s", strings.TrimSpace(string(stderr)))
	}

	// borrow the base repo's objects via alternates.
	if err := func(repoPath, srcRepoPath string) error {
		p := filepath.Join(repoPath, ".git", "objects", "info", "alternates")
		f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
		if err != nil {
			return err
		}
		defer f.Close()
		_, err = fmt.Fprintln(f, filepath.Join(srcRepoPath, "objects"))
		return err
	}(tmp, baseRepoPath); err != nil {
		cleanup()
		return "", nil, fmt.Errorf("add base objects: %w", err)
	}

	if _, stderr, err := run([]string{tmp}, "-C", tmp, "remote", "add", "origin", baseRepoPath); err != nil {
		cleanup()
		return "", nil, fmt.Errorf("git remote add: %s", strings.TrimSpace(string(stderr)))
	}

	if _, stderr, err := run([]string{tmp, baseRepoPath}, "-C", tmp, "fetch", "--no-tags", "origin", baseBranch+":base", baseBranch+":original_base"); err != nil {
		cleanup()
		return "", nil, fmt.Errorf("git fetch base branch %q: %s", baseBranch, strings.TrimSpace(string(stderr)))
	}

	if _, stderr, err := run([]string{tmp}, "-C", tmp, "symbolic-ref", "HEAD", "refs/heads/base"); err != nil {
		cleanup()
		return "", nil, fmt.Errorf("git symbolic-ref: %s", strings.TrimSpace(string(stderr)))
	}

	return tmp, cleanup, nil
}

func gitWithSandbox(ctx context.Context, sb sandbox.Backend, env []string, paths []string, args ...string) (stdout, stderr []byte, err error) {
	cmd := exec.CommandContext(ctx, "git", args...)
	var outBuf, errBuf bytes.Buffer
	// set stdout/stderr/env before wrapping: the landlock backend copies these into a
	// fresh *exec.Cmd, so mutating the original afterwards would be lost.
	cmd.Stdout = &outBuf
	cmd.Stderr = &errBuf
	cmd.Env = env

	if sb != nil {
		wrapped, werr := sb.WrapMulti(paths, cmd)
		if werr != nil {
			return nil, nil, fmt.Errorf("sandbox wrap: %w", werr)
		}
		cmd = wrapped
	} else if len(paths) > 0 {
		cmd.Dir = paths[0]
	}

	err = cmd.Run()
	return outBuf.Bytes(), errBuf.Bytes(), err
}
