package pulls

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"strconv"
	"strings"

	"github.com/bluesky-social/indigo/atproto/syntax"
	indigoxrpc "github.com/bluesky-social/indigo/xrpc"
	"github.com/go-chi/chi/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
	"golang.org/x/sync/errgroup"
	"tangled.org/core/api/tangled"
	"tangled.org/core/appview/db"
	"tangled.org/core/appview/models"
	"tangled.org/core/appview/oauth"
	"tangled.org/core/appview/pages"
	gitmirrorv1 "tangled.org/core/gitmirror/proto/gen"
	"tangled.org/core/hostutil"
	"tangled.org/core/orm"
	"tangled.org/core/types"
)

func (s *Pulls) RedirectLatestVersion(w http.ResponseWriter, r *http.Request) {
	pull, ok := r.Context().Value("pull").(*models.Pull)
	if !ok {
		s.logger.Error("failed to get pull")
		s.pages.Error500(w)
		return
	}
	u := r.URL.JoinPath(strconv.Itoa(pull.LatestVersionNumber()))
	http.Redirect(w, r, u.String(), http.StatusFound)
}

func (s *Pulls) PullSingle(w http.ResponseWriter, r *http.Request) {
	if strings.Contains(chi.URLParam(r, "version"), "..") {
		s.PullInterDiff(w, r)
	} else {
		s.PullDiff(w, r)
	}
}

// PullDiff is router for /pulls/{pull}/{version}/{commit}..{commit}
//
// Examples:
//   - /pulls/123/latest
//   - /pulls/123/2/head
//   - /pulls/123/2/base..head
//   - /pulls/123/2/a53ab251e..d8add468c
//   - /pulls/123/2/d8add468c
func (s *Pulls) PullDiff(w http.ResponseWriter, r *http.Request) {
	l := s.logger.With("handler", "PullDiff")
	ctx := r.Context()

	user := s.oauth.GetMultiAccountUser(r)
	if user != nil {
		l = l.With("user", user.Did)
	}

	pull, ok := r.Context().Value("pull").(*models.Pull)
	if !ok {
		l.Error("failed to get pull")
		http.Error(w, "failed to get PR", http.StatusInternalServerError)
		return
	}

	var version models.PullVersion
	var versionIdRaw = chi.URLParam(r, "version")
	if versionIdRaw == "latest" {
		version = pull.LatestVersion()
	} else {
		versionId, err := strconv.Atoi(versionIdRaw)
		if err != nil {
			// invalid version number. redirect
			http.Redirect(w, r,
				fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId),
				http.StatusSeeOther,
			)
			return
		}
		var ok bool
		version, ok = pull.GetVersion(versionId)
		if !ok {
			// invalid version number. redirect
			http.Redirect(w, r,
				fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId),
				http.StatusSeeOther,
			)
			return
		}
	}

	diffBase, diffHead, err := parseRange(chi.URLParam(r, "revspec"))
	if err != nil {
		http.Redirect(w, r,
			fmt.Sprintf("/%s/pulls/%d/%s", pull.RepoDid, pull.PullId, versionIdRaw),
			http.StatusSeeOther,
		)
		return
	}

	// defer render
	var params pages.PullDiffParams
	params.PullPageBaseParams = s.makePullPageBaseParams(r, user, pull)
	params.VersionId = version.ID
	defer func() {
		if err := s.pages.PullDiff(w, params); err != nil {
			l.Error("Failed to render", "err", err)
		}
	}()

	// special cases
	// default to {current.base}..{current.head}
	if diffBase == "" || diffBase == "base" {
		params.IsDiffBase = true
		// NOTE: We fallback to target-branch for legacy reason.
		//       Old PRs before ref-based-PR refactor doesn't have `version.base`.
		diffBase = cmp.Or(version.Base, pull.TargetBranch)
	}
	if diffHead == "" || diffHead == "head" {
		params.IsDiffHead = true
		diffHead = version.Head
	}
	params.DiffParams.Base = diffBase
	params.DiffParams.Head = diffHead

	commits, err := s.listCommits(ctx, pull.SourceRepo, version.Base, version.Head)
	if err != nil {
		l.Error("failed to list commits", "err", err)
		params.ErrorMsg = "Failed to list commits. Try again later."
		return
	}
	params.Commits = commits

	// commitId -> latest pipeline
	shas := make([]string, len(params.Commits))
	for i, commit := range params.Commits {
		shas[i] = commit.Hash.String()
	}
	params.Pipelines = fetchPipelines(ctx, l, pull.Repo, shas)
}

// PullInterDiff is router for /pulls/{pull}/{version}..{version}/{change}
//
// Examples:
//   - /pulls/123/0..2/all
//   - /pulls/123/0..2/nrpytyzw
func (s *Pulls) PullInterDiff(w http.ResponseWriter, r *http.Request) {
	l := s.logger.With("handler", "PullInterDiff")
	ctx := r.Context()

	user := s.oauth.GetMultiAccountUser(r)
	if user != nil {
		l = l.With("user", user.Did)
	}

	pull, ok := r.Context().Value("pull").(*models.Pull)
	if !ok {
		s.logger.Error("failed to get pull")
		s.pages.Error500(w)
		return
	}

	version1Raw, version2Raw, err := parseRange(chi.URLParam(r, "version"))
	if err != nil {
		http.Redirect(w, r,
			fmt.Sprintf("/%s/pulls/%d/0", pull.RepoDid, pull.PullId),
			http.StatusSeeOther,
		)
		return
	}
	version1, err := strconv.Atoi(version1Raw)
	version2, err := strconv.Atoi(version2Raw)
	if err != nil {
		http.Redirect(w, r,
			fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId),
			http.StatusSeeOther,
		)
		return
	}

	changeId := chi.URLParam(r, "revspec")
	if changeId == "all" {
		changeId = ""
	}

	// defer render
	var params pages.PullInterdiffParams
	params.PullPageBaseParams = s.makePullPageBaseParams(r, user, pull)
	params.Version1 = version1
	params.Version2 = version2
	params.ChangeId = changeId
	defer func() {
		if err := s.pages.PullInterdiff(w, params); err != nil {
			l.Error("Failed to render", "err", err)
		}
	}()

	var commits1, commits2 []types.Commit
	g, gctx := errgroup.WithContext(ctx)
	if changeId != "" {
		g.Go(func() error {
			commits1, err = s.listCommits(gctx, pull.SourceRepo, pull.Versions[version1].Base, pull.Versions[version1].Head)
			return err
		})
	}
	g.Go(func() error {
		commits2, err = s.listCommits(gctx, pull.SourceRepo, pull.Versions[version2].Base, pull.Versions[version2].Head)
		return err
	})
	if err := g.Wait(); err != nil {
		l.Error("failed to list commits", "err", err)
		params.ErrorMsg = "Failed to list commits. Try again later."
		return
	}
	params.Commits = commits2

	// commitId -> latest pipeline
	shas := make([]string, len(params.Commits))
	for i, commit := range params.Commits {
		shas[i] = commit.Hash.String()
	}
	params.Pipelines = fetchPipelines(ctx, l, pull.Repo, shas)

	if changeId != "" {
		// interdiff by change-id
		var from, to *types.Commit
		for _, commit := range commits1 {
			if commit.ChangeId == changeId {
				from = &commit
				break
			}
		}
		for _, commit := range commits2 {
			if commit.ChangeId == changeId {
				to = &commit
				break
			}
		}
		l.Debug("commits", "old", from, "new", to)

		switch {
		case to == nil:
			// can't find change-id from v2 branch.
			// NOTE: This can't happen because user selected from v2's commits
			params.ErrorMsg = "Can't find commit with given change-id."
		case from == nil:
			// new commit -> diff <parent1>..<new>
			params.ActiveCommitId = to.Hash.String()
			params.DiffParams.Diff = &pages.DiffParams_Diff{
				Base: to.FirstParentHash().String(),
				Head: to.Hash.String(),
			}
		default:
			// interdiff
			params.ActiveCommitId = to.Hash.String()
			// TODO: use merged tree of all parents
			params.DiffParams.Interdiff = &pages.DiffParams_Interdiff{
				From: pages.DiffParams_Diff{
					Base: from.FirstParentHash().String(),
					Head: from.Hash.String(),
				},
				To: pages.DiffParams_Diff{
					Base: to.FirstParentHash().String(),
					Head: to.Hash.String(),
				},
			}
		}
	} else {
		// interdiff of two versions
		params.DiffParams.Interdiff = &pages.DiffParams_Interdiff{
			From: pages.DiffParams_Diff{
				Base: pull.Versions[version1].Base,
				Head: pull.Versions[version1].Head,
			},
			To: pages.DiffParams_Diff{
				Base: pull.Versions[version2].Base,
				Head: pull.Versions[version2].Head,
			},
		}

		// TODO: if any of them is "", show error message
	}
}

func (s *Pulls) PullPatchRaw(w http.ResponseWriter, r *http.Request) {
	l := s.logger.With("handler", "RepoPullPatchRaw")

	pull, ok := r.Context().Value("pull").(*models.Pull)
	if !ok {
		l.Error("failed to get pull")
		s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
		return
	}
	l = l.With("pull_id", pull.PullId)

	var version models.PullVersion
	var versionIdRaw = chi.URLParam(r, "version")
	if versionIdRaw == "latest" {
		version = pull.LatestVersion()
	} else {
		versionId, err := strconv.Atoi(versionIdRaw)
		if err != nil {
			http.Error(w, "bad version id", http.StatusBadRequest)
			return
		}
		var ok bool
		version, ok = pull.GetVersion(versionId)
		if !ok {
			http.Error(w, "unknown version", http.StatusNotFound)
			return
		}
	}

	xrpcc := s.knotMirrorXRPC
	rawOut, err := tangled.GitTempFormatPatch(r.Context(), xrpcc, version.Base, pull.RepoDid.String(), version.Head)
	if err != nil {
		http.Error(w, "Failed to compute patch", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Write(rawOut)
}

func (s *Pulls) makePullPageBaseParams(r *http.Request, user *oauth.MultiAccountUser, pull *models.Pull) pages.PullPageBaseParams {
	l := s.logger
	ctx := r.Context()

	entities := []syntax.ATURI{pull.AtUri()}
	for _, v := range pull.Versions {
		for _, c := range v.Comments {
			entities = append(entities, c.FeedCommentAtUri())
		}
	}
	reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20)
	if err != nil {
		l.Error("failed to get reactions", "err", err)
	}

	var userReactions map[syntax.ATURI]map[models.ReactionKind]bool
	if user != nil {
		userReactions, err = db.ListReactionStatusMap(s.db, entities, syntax.DID(user.Did))
		if err != nil {
			s.logger.Error("failed to get user reactions", "err", err)
		}
	}

	labelDefs, err := db.GetLabelDefinitions(
		s.db,
		orm.FilterIn("at_uri", pull.Repo.Labels),
		orm.FilterContains("scope", tangled.RepoPullNSID),
	)
	if err != nil {
		l.Error("failed to fetch labels", "err", err)
	}
	defs := make(map[string]*models.LabelDefinition)
	for _, l := range labelDefs {
		defs[l.AtUri().String()] = &l
	}

	vouchRelationships := make(map[syntax.DID]*models.VouchRelationship)
	vouchSkips := make(map[syntax.DID]bool)
	if user != nil {
		participants := pull.Participants()
		vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants)
		if err != nil {
			l.Error("failed to fetch vouch relationships", "err", err)
		}
		ownerDid := syntax.DID(pull.OwnerDid)
		skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid.String())
		if err != nil {
			l.Error("failed to check vouch skip", "err", err)
		}
		vouchSkips[ownerDid] = skipped
	}

	var isSubscribed *bool
	if user != nil {
		pullDbId := int64(pull.ID)
		sub, found, err2 := db.GetPullSubscription(s.db, user.Did, pullDbId)
		if err2 == nil {
			if found {
				isSubscribed = &sub
			} else {
				// Implicitly subscribed if author or participant.
				isAuthorOrParticipant := pull.OwnerDid == syntax.DID(user.Did)
				if !isAuthorOrParticipant {
					for _, p := range pull.Participants() {
						if p.String() == user.Did {
							isAuthorOrParticipant = true
							break
						}
					}
				}
				if isAuthorOrParticipant {
					t := true
					isSubscribed = &t
				}
			}
		}
	}

	params := pages.PullPageBaseParams{}
	params.BaseParams = pages.BaseParamsFromContext(ctx)
	params.RepoInfo = s.repoResolver.GetRepoInfo(r, user)
	params.Pull = pull
	params.Backlinks = nil
	params.LabelDefs = defs
	params.Reactions = reactions
	params.UserReacted = userReactions
	params.VouchRelationships = vouchRelationships
	params.VouchSkips = vouchSkips
	params.IsSubscribed = isSubscribed
	return params
}

func (s *Pulls) listCommits(ctx context.Context, repo syntax.DID, base, head string) ([]types.Commit, error) {
	s.logger.Debug("logging commits", "repo", repo, "base", base, "head", head)
	stream, err := s.gitmirror.CommitLog(ctx, &gitmirrorv1.CommitLogRequest{
		Repo:    repo.String(),
		Ranges:  [][]byte{fmt.Appendf(nil, "%s..%s", base, head)},
		AllRefs: false,
	})
	if err != nil {
		return nil, err
	}
	var commits []types.Commit
	for {
		res, err := stream.Recv()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return nil, err
		}
		for _, commit := range res.Commits {
			commits = append(commits, types.Commit{
				Hash: plumbing.NewHash(commit.Oid),
				Author: object.Signature{
					Name:  string(commit.Author.GetName()),
					Email: string(commit.Author.GetEmail()),
					When:  commit.Author.Date.AsTime(),
				},
				Committer: object.Signature{
					Name:  string(commit.Committer.GetName()),
					Email: string(commit.Committer.GetEmail()),
					When:  commit.Committer.Date.AsTime(),
				},
				Message: string(commit.Message),
				ParentHashes: func() []plumbing.Hash {
					var parents []plumbing.Hash
					for _, hash := range commit.Parents {
						parents = append(parents, plumbing.NewHash(hash))
					}
					return parents
				}(),
				ChangeId: commit.ExtraHeaders["change-id"],
			})
		}
	}
	return commits, err
}

// SubscribePull handles subscribe/unsubscribe for a specific pull request.
func (s *Pulls) SubscribePull(w http.ResponseWriter, r *http.Request) {
	l := s.logger.With("handler", "SubscribePull")
	user := s.oauth.GetMultiAccountUser(r)
	if user == nil {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	pull, ok := r.Context().Value("pull").(*models.Pull)
	if !ok {
		l.Error("failed to get pull from context")
		w.WriteHeader(http.StatusNotFound)
		return
	}

	subscribe := r.FormValue("subscribe") != "false"
	pullDbId := int64(pull.ID)

	if err := db.UpsertPullSubscription(s.db, user.Did, pullDbId, subscribe); err != nil {
		l.Error("failed to update pull subscription", "err", err)
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	repoInfo := s.repoResolver.GetRepoInfo(r, user)
	s.pages.PullSubscribeFragment(w, pages.PullSubscribeParams{
		RepoInfo:     repoInfo,
		PullId:       pull.PullId,
		IsSubscribed: &subscribe,
	})
}

func (s *Pulls) fetchPipelines(ctx context.Context, spindle string, repoDid string, shas []string) (map[string]types.Pipeline, error) {
	if spindle == "" || len(shas) == 0 {
		return nil, nil
	}
	spindleUrl, err := hostutil.EnsureHttpScheme(spindle)
	if err != nil {
		return nil, err
	}
	xrpcc := &indigoxrpc.Client{Host: spindleUrl}
	out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, repoDid)
	if err != nil {
		return nil, err
	}
	return types.PipelinesByCommit(out.Pipelines), nil
}

func fetchPipelines(ctx context.Context, l *slog.Logger, f *models.Repo, shas []string) map[string]types.Pipeline {
	m := make(map[string]types.Pipeline)
	if f.Spindle == "" || len(shas) == 0 {
		return m
	}
	spindleUrl, err := hostutil.EnsureHttpScheme(f.Spindle)
	if err != nil {
		l.Error("invalid spindle host", "host", f.Spindle, "err", err)
		return m
	}
	xrpcc := &indigoxrpc.Client{Host: spindleUrl}
	out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, f.RepoDid)
	if err != nil {
		l.Error("failed to fetch pipelines", "err", err)
		return m
	}

	for _, pipeline := range out.Pipelines {
		if pipeline == nil {
			continue
		}
		m[pipeline.Commit] = types.Pipeline{CiPipeline: pipeline}
	}
	return m
}

// parseRange parses <base>..<base> string
func parseRange(input string) (base string, head string, err error) {
	input = strings.TrimSpace(input)
	if input == "" {
		return "", "", nil
	}

	if strings.Count(input, "..") > 1 || strings.Contains(input, "...") {
		return "", "", fmt.Errorf("invalid revspec format: %q", input)
	}

	// /{head}
	if !strings.Contains(input, "..") {
		return "", input, nil
	}

	// /{base}..{head}
	parts := strings.SplitN(input, "..", 2)
	base = strings.TrimSpace(parts[0])
	head = strings.TrimSpace(parts[1])
	if base == "" && head == "" {
		return "", "", fmt.Errorf("invalid empty range: \"..\"")
	}

	return base, head, nil
}
