package web

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"net/http"
	"strings"

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

	"go.bigb.es/sourcehut-compare/authz"
	"go.bigb.es/sourcehut-compare/core"
	"go.bigb.es/sourcehut-compare/gitx"
)

// recentCommitLimit bounds the first-parent history shown on the repo page.
const recentCommitLimit = 20

// compareLogLimit bounds the commit list on the compare page.
const compareLogLimit = 50

// ---- JSON transport (consumed by the front-end bundle) --------------------

// jsonFile mirrors one gitx.FileChange for the browser. path is the plain repo
// path with NO a/ or b/ prefix.
type jsonFile struct {
	Path      string `json:"path"`
	OldPath   string `json:"oldPath"`
	Status    string `json:"status"`
	Additions int    `json:"additions"`
	Deletions int    `json:"deletions"`
	Binary    bool   `json:"binary"`
}

type jsonSpec struct {
	Base     string `json:"base"`
	Head     string `json:"head"`
	ThreeDot bool   `json:"threeDot"`
}

type compareData struct {
	Mode      string     `json:"mode"`
	Patch     string     `json:"patch"`
	Truncated bool       `json:"truncated"`
	Files     []jsonFile `json:"files"`
	Spec      jsonSpec   `json:"spec"`
}

// buildCompareJSON marshals the browser payload. json.Marshal escapes <, > and &
// (Go's default HTML-safe mode), so the result is safe to drop verbatim inside a
// <script> element even when a file path contains "</script>". The bytes are
// returned as template.JS: any <script> is a JS context to html/template, so a
// plain string (or template.HTML) would be JS-escaped and corrupted; template.JS
// is emitted verbatim, and the marshaler's escaping already blocks a breakout.
func buildCompareJSON(mode string, patch *gitx.Patch, files []gitx.FileChange, spec jsonSpec) (template.JS, error) {
	cd := compareData{
		Mode:      mode,
		Patch:     patch.Text,
		Truncated: patch.Truncated,
		Files:     []jsonFile{},
		Spec:      spec,
	}
	for _, f := range files {
		cd.Files = append(cd.Files, jsonFile{
			Path:      f.Path,
			OldPath:   f.OldPath,
			Status:    f.Status,
			Additions: f.Additions,
			Deletions: f.Deletions,
			Binary:    f.Binary,
		})
	}
	b, err := json.Marshal(cd)
	if err != nil {
		return "", err
	}
	return template.JS(b), nil
}

// ---- error mapping --------------------------------------------------------

// httpStatusFor maps a domain error to an HTTP status. Repo visibility uses
// core.ErrNotFound so a private repo is a 404, never a 403.
func httpStatusFor(err error) int {
	switch {
	case errors.Is(err, core.ErrNotFound):
		return http.StatusNotFound
	case errors.Is(err, core.ErrBadRef):
		return http.StatusBadRequest
	case errors.Is(err, core.ErrForbidden):
		return http.StatusForbidden
	default:
		return http.StatusInternalServerError
	}
}

// fail renders the chrome error page for err, logging 5xx causes.
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
	status := httpStatusFor(err)
	if status >= 500 {
		logrus.WithError(err).WithField("path", r.URL.Path).Error("web: request failed")
		s.renderError(w, r, status, "an internal error occurred")
		return
	}
	s.renderError(w, r, status, err.Error())
}

// resolve authorizes and opens a repository, returning the git handle and the
// authz metadata. Any error is already mapped to the right HTTP status by the
// caller via fail.
func (s *Server) resolve(ctx context.Context, owner, repo string) (*gitx.Repo, *authz.RepoInfo, error) {
	viewer := authz.ForContext(ctx)
	info, err := s.authorizer.Repo(ctx, viewer, owner, repo)
	if err != nil {
		return nil, nil, err
	}
	g, err := gitx.Open(s.reposRoot, owner, repo)
	if err != nil {
		return nil, nil, err
	}
	return g, info, nil
}

// ---- index ----------------------------------------------------------------

type indexData struct {
	LoggedIn bool
	Repos    []authz.RepoInfo
}

func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	username := authz.ForContext(ctx)

	vd := s.chrome(r)
	vd.Title = s.siteName + " compare"

	if username == "" {
		vd.Data = indexData{LoggedIn: false}
		s.render(w, http.StatusOK, "index", vd)
		return
	}

	repos, err := s.authorizer.MyRepos(ctx, username)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	vd.Data = indexData{LoggedIn: true, Repos: repos}
	s.render(w, http.StatusOK, "index", vd)
}

// ---- repo page ------------------------------------------------------------

type repoData struct {
	Owner         string
	Info          *authz.RepoInfo
	DefaultBranch string
	Branches      []gitx.Ref
	Tags          []gitx.Ref
	Commits       []gitx.CommitInfo
}

func (s *Server) handleRepo(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	owner := chi.URLParam(r, "owner")
	repo := chi.URLParam(r, "repo")

	g, info, err := s.resolve(ctx, owner, repo)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	branches, tags, err := g.Refs(ctx)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	def, _ := g.DefaultBranch(ctx)
	commits, _ := recentCommits(ctx, g, def, recentCommitLimit)

	vd := s.chrome(r)
	vd.Title = "~" + owner + "/" + repo
	vd.Data = repoData{
		Owner:         owner,
		Info:          info,
		DefaultBranch: def,
		Branches:      branches,
		Tags:          tags,
		Commits:       commits,
	}
	s.render(w, http.StatusOK, "repo", vd)
}

// recentCommits walks first-parent history from rev, returning up to limit
// commits. It relies only on gitx.ResolveCommit so it needs no dedicated log
// range. An unresolvable starting revision (e.g. an empty repository) yields an
// empty slice rather than an error.
func recentCommits(ctx context.Context, g *gitx.Repo, rev string, limit int) ([]gitx.CommitInfo, error) {
	if rev == "" {
		return nil, nil
	}
	var out []gitx.CommitInfo
	cur := rev
	for i := 0; i < limit; i++ {
		ci, err := g.ResolveCommit(ctx, cur)
		if err != nil {
			if i == 0 {
				return nil, nil
			}
			break
		}
		out = append(out, *ci)
		if len(ci.ParentSHAs) == 0 {
			break
		}
		cur = ci.ParentSHAs[0]
	}
	return out, nil
}

// ---- compare page ---------------------------------------------------------

type compareView struct {
	Owner      string
	RepoName   string
	Info       *authz.RepoInfo
	Spec       core.CompareSpec
	MergeBase  string
	Commits    []gitx.CommitInfo
	Files      []gitx.FileChange
	Truncated  bool
	CompareURL string
	PatchURL   string
	JSON       template.JS
}

func (s *Server) handleCompare(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	owner := chi.URLParam(r, "owner")
	repo := chi.URLParam(r, "repo")
	raw := chi.URLParam(r, "*")

	// Empty wildcard: this is the compare form's GET target. Canonicalize the
	// base/head/mode query into a clean compare URL and redirect.
	if raw == "" {
		s.compareRedirect(w, r, owner, repo)
		return
	}

	patchMode := strings.HasSuffix(raw, ".patch")
	specRaw := strings.TrimSuffix(raw, ".patch")

	spec, err := core.ParseCompareSpec(specRaw)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	g, info, err := s.resolve(ctx, owner, repo)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	compareURL := fmt.Sprintf("/~%s/%s/compare/%s", owner, repo, specRaw)

	if patchMode {
		patch, err := g.RawDiff(ctx, spec)
		if err != nil {
			s.fail(w, r, err)
			return
		}
		s.writePatch(w, patch.Text)
		return
	}

	patch, err := g.Diff(ctx, spec)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	files, err := g.DiffStat(ctx, spec)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	commits, err := g.Log(ctx, spec.Base, spec.Head, compareLogLimit)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	var mergeBase string
	if spec.ThreeDot {
		mergeBase, _ = g.MergeBase(ctx, spec.Base, spec.Head)
	}

	jsonPayload, err := buildCompareJSON("compare", patch, files, jsonSpec{
		Base:     spec.Base,
		Head:     spec.Head,
		ThreeDot: spec.ThreeDot,
	})
	if err != nil {
		s.fail(w, r, err)
		return
	}

	vd := s.chrome(r)
	vd.Title = fmt.Sprintf("~%s/%s: %s...%s", owner, repo, spec.Base, spec.Head)
	vd.Data = compareView{
		Owner:      owner,
		RepoName:   repo,
		Info:       info,
		Spec:       spec,
		MergeBase:  mergeBase,
		Commits:    commits,
		Files:      files,
		Truncated:  patch.Truncated,
		CompareURL: compareURL,
		PatchURL:   compareURL + ".patch",
		JSON:       jsonPayload,
	}
	s.render(w, http.StatusOK, "compare", vd)
}

// compareRedirect turns ?base=&head=&mode= into a canonical compare URL. mode
// "two" selects the two-dot range; anything else (the default) is three-dot.
func (s *Server) compareRedirect(w http.ResponseWriter, r *http.Request, owner, repo string) {
	q := r.URL.Query()
	base := strings.TrimSpace(q.Get("base"))
	head := strings.TrimSpace(q.Get("head"))
	if base == "" || head == "" {
		s.renderError(w, r, http.StatusBadRequest, "both base and head are required")
		return
	}
	sep := "..."
	if q.Get("mode") == "two" {
		sep = ".."
	}
	http.Redirect(w, r, fmt.Sprintf("/~%s/%s/compare/%s%s%s", owner, repo, base, sep, head), http.StatusFound)
}

// ---- commit page ----------------------------------------------------------

type commitView struct {
	Owner     string
	RepoName  string
	Info      *authz.RepoInfo
	Commit    *gitx.CommitInfo
	Files     []gitx.FileChange
	IsMerge   bool
	Truncated bool
	CommitURL string
	PatchURL  string
	JSON      template.JS
}

func (s *Server) handleCommit(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	owner := chi.URLParam(r, "owner")
	repo := chi.URLParam(r, "repo")
	rev := chi.URLParam(r, "rev")

	patchMode := strings.HasSuffix(rev, ".patch")
	rev = strings.TrimSuffix(rev, ".patch")

	g, info, err := s.resolve(ctx, owner, repo)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	patch, files, ci, err := g.CommitPatch(ctx, rev)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	if patchMode {
		s.writePatch(w, patch.Text)
		return
	}

	commitURL := fmt.Sprintf("/~%s/%s/commit/%s", owner, repo, rev)

	base := ""
	if len(ci.ParentSHAs) > 0 {
		base = ci.ParentSHAs[0]
	}
	jsonPayload, err := buildCompareJSON("commit", patch, files, jsonSpec{
		Base:     base,
		Head:     ci.SHA,
		ThreeDot: false,
	})
	if err != nil {
		s.fail(w, r, err)
		return
	}

	vd := s.chrome(r)
	vd.Title = fmt.Sprintf("~%s/%s: %s", owner, repo, ci.ShortSHA)
	vd.Data = commitView{
		Owner:     owner,
		RepoName:  repo,
		Info:      info,
		Commit:    ci,
		Files:     files,
		IsMerge:   len(ci.ParentSHAs) > 1,
		Truncated: patch.Truncated,
		CommitURL: commitURL,
		PatchURL:  commitURL + ".patch",
		JSON:      jsonPayload,
	}
	s.render(w, http.StatusOK, "commit", vd)
}

// writePatch emits a raw unified diff as an inline text/plain document.
func (s *Server) writePatch(w http.ResponseWriter, text string) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Header().Set("Content-Disposition", "inline")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(text))
}
