package pulls

import (
	"cmp"
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"net/url"
	"slices"
	"sort"
	"strings"

	"tangled.org/core/api/tangled"
	"tangled.org/core/appview/db"
	"tangled.org/core/appview/knotcompat"
	"tangled.org/core/appview/models"
	"tangled.org/core/appview/pages"
	"tangled.org/core/appview/pages/markup/sanitizer"
	"tangled.org/core/consts"
	gitmirrorv1 "tangled.org/core/gitmirror/proto/gen"
	"tangled.org/core/types"

	"github.com/bluesky-social/indigo/atproto/syntax"
)

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

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

	f, err := s.repoResolver.Resolve(r)
	if err != nil {
		l.Error("failed to get repo and knot", "err", err)
		return
	}
	l = l.With("repo_at", f.RepoAt().String())

	switch r.Method {
	case http.MethodGet:
		params, err := s.composeParams(r, f)
		if err != nil {
			l.Error("failed to build compose params", "err", err)
			s.pages.Error503(w)
			return
		}
		if err := s.pages.RepoNewPull(w, params); err != nil {
			l.Error("failed to render", "err", err)
		}

	case http.MethodPost:
		userDid := syntax.DID(user.Did)
		var (
			title         = r.FormValue("title")
			body          = r.FormValue("body")
			targetBranch  = r.FormValue("targetBranch")
			sourceRepoRaw = cmp.Or(r.FormValue("fork"), f.RepoDid)
		)
		sourceRepoDid, err := syntax.ParseDID(sourceRepoRaw)
		if err != nil {
			s.pages.Notice(w, "pull", fmt.Sprintf("Source repo is invalid: %q", sourceRepoRaw))
			return
		}
		sourceBranch := r.FormValue("sourceBranch")
		patch := r.FormValue("patch")

		if title == "" {
			s.pages.Notice(w, "pull", "Title is required")
			return
		}
		if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
			s.pages.Notice(w, "pull", "Title is empty after HTML sanitization")
			return
		}

		if targetBranch == "" {
			s.pages.Notice(w, "pull", "Target branch is required.")
			return
		}

		// Validate we have at least one valid PR creation method
		if sourceBranch == "" && patch == "" {
			s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
			return
		}
		// Can't mix branch-based and patch-based approaches
		if sourceBranch != "" && patch != "" {
			s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
			return
		}

		var sourceRepo *models.Repo
		if sourceRepoDid == syntax.DID(f.RepoDid) {
			sourceRepo = f
		} else {
			var err error
			sourceRepo, err = db.GetRepoByDid(s.db, sourceRepoDid.String())
			if err != nil {
				s.pages.Notice(w, "pull", fmt.Sprintf("Unknown source repository: %q", sourceRepoDid))
				return
			}
		}

		if sourceBranch != "" {
			roles := s.acl.RolesInRepo(r.Context(), sourceRepo, userDid.String())
			if !roles.IsPushAllowed() {
				s.pages.Notice(w, "pull", "Cannot select forbidden branch.")
				return
			}
		}

		if sourceRepoDid == syntax.DID(f.RepoDid) && sourceBranch == targetBranch {
			s.pages.Notice(w, "pull", "Source and target branch must be different.")
			return
		}

		if ok := knotcompat.KnotHasCapability(r.Context(), f.Knot, s.config.Core.Dev, consts.CapKeepCommit); !ok {
			s.pages.Notice(w, "pull", "Source repo's knot doesn't support ref-based pull requests. Try another way?")
			return
		}

		if sourceBranch != "" {
			s.handlePull(w, r, userDid, f, targetBranch, sourceRepo, sourceBranch, title, body)
			return
		} else if patch != "" {
			s.pages.Notice(w, "pull", "Patch based PR is currently unsupported.")
			return
		}
	}
}

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

	var (
		baseRepoRaw = r.URL.Query().Get("baseRepo")
		baseBranch  = r.URL.Query().Get("base") // base branch name
		headRepoRaw = r.URL.Query().Get("headRepo")
		headBranch  = r.URL.Query().Get("head") // head branch name
		unified     = r.URL.Query().Get("view") == "unified"
	)
	baseRepo, err := syntax.ParseDID(baseRepoRaw)
	if err != nil {
		http.Error(w, "invalid base repo DID", http.StatusBadRequest)
		return
	}
	headRepo, err := syntax.ParseDID(headRepoRaw)
	if err != nil {
		http.Error(w, "invalid head repo DID", http.StatusBadRequest)
		return
	}
	l.Debug(
		"compose diff fragment",
		"base.repo", baseRepo,
		"base.commit", baseBranch,
		"head.repo", headRepo,
		"head.commit", headBranch,
	)

	// resolve branch to commit IDs
	base, err := s.resolveRev(ctx, baseRepo, baseBranch)
	if err != nil {
		l.Error("failed to resolve base branch", "branch", baseBranch, "repo", baseRepo, "err", err)
		s.renderComposeDiffErr(w, l, "Failed to resolve base branch.")
		return
	}
	head, err := s.resolveRev(ctx, headRepo, headBranch)
	if err != nil {
		l.Error("failed to resolve head branch", "branch", headBranch, "repo", headRepo, "err", err)
		s.renderComposeDiffErr(w, l, "Failed to resolve head branch.")
		return
	}

	var params pages.PullDiffFragmentParams
	params.BaseRepo = baseRepo
	params.HeadRepo = headRepo
	params.DiffBase = baseBranch
	params.DiffHead = headBranch
	params.DiffUrl = r.URL.Path
	params.Unified = unified
	params.Files, params.ErrorMsg = s.diffFragmentParams(ctx, l, baseRepo, base, headRepo, head, unified)
	if err := s.pages.PullComposeDiffFragment(w, params); err != nil {
		l.Error("failed to render", "err", err)
	}
}

func (s *Pulls) resolveRev(ctx context.Context, repo syntax.DID, rev string) (string, error) {
	out, err := s.gitmirror.ResolveRev(ctx, &gitmirrorv1.ResolveRevRequest{
		Repo: repo.String(),
		Rev:  []byte(rev),
	})
	if err != nil {
		return "", err
	}
	return out.GetCommit(), nil
}

func (s *Pulls) renderComposeDiffErr(w http.ResponseWriter, l *slog.Logger, msg string) {
	if err := s.pages.PullComposeDiffFragment(w, pages.PullDiffFragmentParams{ErrorMsg: msg}); err != nil {
		l.Error("failed to render", "err", err)
	}
}

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

	f, err := s.repoResolver.Resolve(r)
	if err != nil {
		l.Error("failed to resolve repo", "err", err)
		s.pages.Error503(w)
		return
	}

	params, err := s.composeParams(r, f)
	if err != nil {
		l.Error("failed to build compose params", "err", err)
		s.pages.Error503(w)
		return
	}
	w.Header().Set("HX-Replace-Url", composeCanonicalURL(params))
	s.pages.PullComposeHostFragment(w, params)
}

func composeCanonicalURL(params pages.RepoNewPullParams) string {
	base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName())
	q := url.Values{}
	if params.Source != "" && params.Source != pages.SourceBranch {
		q.Set("source", string(params.Source))
	}
	if params.SourceBranch != "" {
		q.Set("sourceBranch", params.SourceBranch)
	}
	if params.TargetBranch != "" {
		q.Set("targetBranch", params.TargetBranch)
	}
	if params.Source == pages.SourceFork && params.Fork != "" {
		q.Set("fork", params.Fork)
	}
	if len(q) == 0 {
		return base
	}
	return base + "?" + q.Encode()
}

func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) {
	l := s.logger.With("handler", "composeParams")
	user := s.oauth.GetMultiAccountUser(r)

	branches, err := s.listBranches(r.Context(), repo)
	if err != nil {
		return pages.RepoNewPullParams{}, fmt.Errorf("failed to list branches: %w", err)
	}

	var forks []models.Repo
	if user != nil {
		forks, err = db.GetForksByDid(s.db, user.Did)
		if err != nil {
			l.Warn("failed to list user forks", "err", err, "user", user.Did)
		}
	}
	forks = slices.DeleteFunc(forks, func(f models.Repo) bool {
		return f.RepoDid == ""
	})

	f, err := s.repoResolver.Resolve(r)
	if err != nil {
		return pages.RepoNewPullParams{}, fmt.Errorf("failed to resolve repo: %w", err)
	}

	repoInfo := s.repoResolver.GetRepoInfo(r, user)
	source, ok := pages.ParseSource(r.FormValue("source"))
	if !ok {
		source = pages.SourceBranch
		if !repoInfo.Roles.IsPushAllowed() {
			source = pages.SourceFork
		}
	}

	sourceBranch := r.FormValue("sourceBranch")
	targetBranch := r.FormValue("targetBranch")
	fork := r.FormValue("fork")
	patch := r.FormValue("patch")

	if source == pages.SourceFork && fork == "" && len(forks) == 1 {
		fork = forks[0].RepoDid
	}

	var prefillErr error

	var forkBranches []types.Branch
	if source == pages.SourceFork && fork != "" {
		forkBranches, err = s.listForkBranches(r.Context(), fork)
		if err != nil {
			l.Warn("failed to list fork branches", "err", prefillErr, "fork", fork)
			prefillErr = errors.Join(prefillErr, err)
		}
	}

	sourceBranchList := sourceBranchChoices(branches)
	targetBranch = defaultTargetBranch(branches, targetBranch)
	sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches)

	var sourceRepo syntax.DID
	if fork != "" {
		sourceRepo = syntax.DID(fork)
	} else {
		sourceRepo = syntax.DID(repoInfo.RepoDid)
	}

	if sourceRepo == "" || sourceBranch == "" || targetBranch == "" {
		l.Error("params missing", "source", sourceRepo, "source.branch", sourceBranch, "target.branch", targetBranch)
		return pages.RepoNewPullParams{
			BaseParams:     pages.BaseParamsFromContext(r.Context()),
			RepoInfo:       repoInfo,
			Branches:       branches,
			SourceBranches: sourceBranchList,
			ForkBranches:   forkBranches,
			Forks:          forks,
			Source:         source,
			SourceBranch:   sourceBranch,
			TargetBranch:   targetBranch,
			Fork:           fork,
			Patch:          patch,
		}, nil
	}

	var stepReviewParams pages.RepoNewPull_StepReviewParams

	commits, err := s.listCommits(r.Context(), sourceRepo, targetBranch, sourceBranch)
	if err != nil {
		prefillErr = errors.Join(prefillErr, err)
	}
	stepReviewParams.Commits = commits

	var prefillErrorMsg string
	if prefillErr != nil {
		prefillErrorMsg = prefillErr.Error()
	}

	labelDefs, err := s.pullLabelDefs(repo)
	if err != nil {
		l.Error("failed to load label definitions", "err", err)
	}
	labelState := labelStateFromForm(r.Form, labelDefs)

	title := r.FormValue("title")
	body := r.FormValue("body")
	titleDirty := r.FormValue("titleDirty") == "1"
	bodyDirty := r.FormValue("bodyDirty") == "1"
	if len(commits) == 1 {
		message := strings.SplitN(strings.TrimSpace(commits[0].Message), "\n\n", 2)
		if !titleDirty {
			title = message[0]
		}
		if !bodyDirty && len(message) > 1 && message[1] != "" {
			// TODO: strip trailers?
			body = message[1]
		}
	}

	l.Debug("label defs", "defs", labelDefs)

	var mergeCheckParams pages.MergeCheckParams
	if len(commits) > 0 {
		mergeCheckParams = s.composeMergeCheck(r.Context(), f, targetBranch, sourceRepo, commits[0].Hash.String())
	}

	return pages.RepoNewPullParams{
		BaseParams:       pages.BaseParamsFromContext(r.Context()),
		RepoInfo:         repoInfo,
		Branches:         branches,
		SourceBranches:   sourceBranchList,
		ForkBranches:     forkBranches,
		Forks:            forks,
		Source:           source,
		SourceBranch:     sourceBranch,
		TargetBranch:     targetBranch,
		Fork:             fork,
		Patch:            patch,
		Title:            title,
		Body:             body,
		TitleDirty:       titleDirty,
		BodyDirty:        bodyDirty,
		StepReviewParams: &stepReviewParams,
		MergeCheck:       mergeCheckParams,
		PrefillError:     prefillErrorMsg,
		LabelDefs:        labelDefs,
		LabelState:       labelState,
	}, nil
}

func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) {
	xrpcc := s.knotMirrorXRPC
	xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid)
	if err != nil {
		return nil, err
	}
	var result types.RepoBranchesResponse
	if err := json.Unmarshal(xrpcBytes, &result); err != nil {
		return nil, err
	}
	return result.Branches, nil
}

func (s *Pulls) listForkBranches(ctx context.Context, forkRepoDid string) ([]types.Branch, error) {
	if forkRepoDid == "" {
		return nil, fmt.Errorf("fork not found")
	}
	forkRepo, err := db.GetForkByRepoDid(s.db, forkRepoDid)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("fork not found")
	}
	if err != nil {
		return nil, err
	}
	branches, err := s.listBranches(ctx, forkRepo)
	if err != nil {
		return nil, err
	}
	return sortBranchesByRecency(branches), nil
}

func sourceBranchChoices(branches []types.Branch) []types.Branch {
	withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool {
		return b.IsDefault
	})
	return sortBranchesByRecency(withoutDefault)
}

func defaultTargetBranch(branches []types.Branch, current string) string {
	if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) {
		return current
	}
	if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 {
		return branches[idx].Reference.Name
	}
	return ""
}

func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string {
	var candidates []types.Branch
	switch source {
	case pages.SourceFork:
		candidates = forkBranches
	case pages.SourceBranch:
		candidates = branchChoices
	default:
		return current
	}
	if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) {
		return current
	}
	if len(candidates) == 0 {
		return ""
	}
	return candidates[0].Reference.Name
}

func sortBranchesByRecency(branches []types.Branch) []types.Branch {
	out := slices.Clone(branches)
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].Commit == nil || out[j].Commit == nil {
			return out[i].Commit != nil
		}
		return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When)
	})
	return out
}

func (s *Pulls) composeMergeCheck(ctx context.Context, targetRepo *models.Repo, targetBranch string, sourceRepoDid syntax.DID, sourceCommit string) pages.MergeCheckParams {
	l := s.logger.With("handler", "composeMergeCheck", "repo", targetRepo.RepoDid, "branch", targetBranch, "source", sourceCommit)

	targetSha, err := s.resolveRev(ctx, syntax.DID(targetRepo.RepoDid), targetBranch)
	if err != nil {
		l.Warn("failed to resolve target branch", "err", err)
		return pages.MergeCheckParams{Error: "merge check failed"}
	}

	out, err := s.gitmirror.MergeCheck(ctx, &gitmirrorv1.MergeCheckRequest{
		Target: &gitmirrorv1.RepoCommit{Repo: targetRepo.RepoDid, Commit: []byte(targetSha)},
		Source: &gitmirrorv1.RepoCommit{Repo: sourceRepoDid.String(), Commit: []byte(sourceCommit)},
	})
	if err != nil {
		l.Warn("failed to do merge-check", "err", err)
		return pages.MergeCheckParams{Error: "merge check failed"}
	}
	return pages.MergeCheckParams{
		IsConflicted: out.IsConflicted,
		Conflicts:    out.Conflicts,
	}
}

func bracketComponents(key, prefix string) ([]string, bool) {
	if !strings.HasPrefix(key, prefix) {
		return nil, false
	}
	rest := key[len(prefix):]
	var parts []string
	for len(rest) > 0 {
		if !strings.HasPrefix(rest, "[") {
			return nil, false
		}
		end := strings.Index(rest, "]")
		if end <= 0 {
			return nil, false
		}
		parts = append(parts, rest[1:end])
		rest = rest[end+1:]
	}
	if len(parts) == 0 {
		return nil, false
	}
	return parts, true
}

func parseBracketedForm(form url.Values, prefix string) map[string]string {
	out := make(map[string]string)
	for key, vals := range form {
		parts, ok := bracketComponents(key, prefix)
		if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 {
			continue
		}
		out[parts[0]] = vals[0]
	}
	return out
}

func parseStackLabelForms(form url.Values) map[string]url.Values {
	out := make(map[string]url.Values)
	for key, vals := range form {
		parts, ok := bracketComponents(key, "stackLabel")
		if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" {
			continue
		}
		cid, atUri := parts[0], parts[1]
		if _, ok := out[cid]; !ok {
			out[cid] = make(url.Values)
		}
		out[cid][atUri] = append(out[cid][atUri], vals...)
	}
	return out
}
