package pulls

import (
	"context"
	"errors"
	"fmt"
	"html/template"
	"io"
	"net/http"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/bluesky-social/indigo/api/atproto"
	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/bluesky-social/indigo/lex/util"
	indigoxrpc "github.com/bluesky-social/indigo/xrpc"
	"github.com/go-chi/chi/v5"
	"golang.org/x/sync/errgroup"
	"tangled.org/core/api/tangled"
	"tangled.org/core/appview/models"
	"tangled.org/core/appview/pages"
	gitmirrorv1 "tangled.org/core/gitmirror/proto/gen"
	"tangled.org/core/types"
)

// NOTE: parsing object in middleware is bad pattern
// you will have to check if object exist in context "just in case"
// so it's better to make helper function that can read the url pattern instead.

// A -- B -- C
// (master) (pr/123/0)
//
// A -- B -- C
//  \        (pr/123/0)
//   `-- D <- B' <- C'
//       (master)   (pr/123/1)

// 1. rebase B<-C to D
// 2. compare tree of C and D

var pull = &models.Pull2{
	ID:           123,
	RepoDid:      syntax.DID("did:plc:ofcpzigpnwrgtpl3ojrpg3y7"),
	AuthorDid:    syntax.DID("did:example:alice"),
	Rkey:         syntax.RecordKey("pr-rkey"),
	Title: "Spindle-owned data",
	Body: "something something",
	TargetBranch: "master",
	Versions: []models.PullVersion{
		{
			SourceRepo: syntax.DID("did:plc:ofcpzigpnwrgtpl3ojrpg3y7"),
			Head:       "262439d43001b6e258077d933c6f5a39b9f63abf",
			Base:       "71588c938e39599b17f7cf879e10644816b8a7f6",
		},
	},
	Created: time.Now(),
}

var comments = []*models.Comment{
	{
		Did: syntax.DID("did:example:alice"),
		Collection: syntax.NSID(tangled.FeedCommentNSID),
		Rkey:         syntax.RecordKey("comment"),
		Cid: syntax.CID(""),
		Subject: atproto.RepoStrongRef{
			Uri: "at://did:example:alice/sh.tangled.repo.pull/pr-rkey",
			Cid: "",
		},
		Body: tangled.MarkupMarkdown{
			Text: "review comment",
		},
		Created: time.Now(),
	},
}

// 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()

	_ = l

	var (
		version1 = 0
		version2 = 0
		changeId = chi.URLParam(r, "*")
	)
	if changeId == "all" {
		changeId = ""
	}

	// defer render
	var params pages.PullInterdiffParams
	params.Pull = pull
	params.Version1 = version1
	params.Version2 = version2
	params.ChangeId = changeId
	defer s.pages.PullInterdiff(w, params)

	// 1. resolve target branch -> (branch, commit)
	xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
	branch, err := tangled.GitTempGetBranch(ctx, xrpcc, pull.TargetBranch, pull.RepoDid.String())
	if err != nil {
		panic("unimplemented")
	}

	base := branch.Hash
	head1 := "" // pull.Versions[version1].Head
	head2 := "" // pull.Versions[version2].Head

	// 1. log commits from base..head1 and base..head2
	var commits1, commits2 []types.Commit
	g, gctx := errgroup.WithContext(ctx)
	g.Go(func() error {
		commits1, err = getTempListCommits(gctx, xrpcc, pull.LatestVersion().SourceRepo, base, head1)
		return err
	})
	g.Go(func() error {
		commits2, err = getTempListCommits(gctx, xrpcc, pull.LatestVersion().SourceRepo, base, head2)
		return err
	})
	if err := g.Wait(); err != nil {
		params.ErrorMsg = "something something"
		panic("unimplemented")
	}

	if changeId != "" {
		// interdiff by change-id
		var old, new *types.Commit
		for _, commit := range commits1 {
			if commit.ChangeId == changeId {
				old = &commit
				break
			}
		}
		for _, commit := range commits2 {
			if commit.ChangeId == changeId {
				new = &commit
				break
			}
		}
		_, _ = old, new
		panic("unimplemented")
	} else {
		// interdiff of two commit ranges
		panic("unimplemented")
	}
}

// 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()

	_ = l

	var err error

	var version int
	var versionRaw = chi.URLParam(r, "version")
	if versionRaw == "latest" {
		version = pull.LatestVersionId()
	} else {
		version, err = strconv.Atoi(versionRaw)
		if err != nil {
			// invalid version number. redirect
			http.Redirect(w, r,
				fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.ID),
				http.StatusSeeOther,
			)
			return
		}
	}

	var range_ = chi.URLParam(r, "changeId")
	base, head, err := parseRevRange(range_)
	if err != nil {
		http.Redirect(w, r,
			fmt.Sprintf("/%s/pulls/%d/%s", pull.RepoDid, pull.ID, versionRaw),
			http.StatusSeeOther,
		)
		return
	}

	// defer render
	var params pages.PullDiffParams
	params.Pull = pull
	params.Version = version
	params.Comments = comments
	defer s.pages.PullDiff(w, params)

	// 1. resolve target branch -> (branch, commit)
	xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
	branch, err := tangled.GitTempGetBranch(ctx, xrpcc, pull.TargetBranch, pull.RepoDid.String())
	if err != nil {
		l.Warn("Failed to resolve target branch", "branch", pull.TargetBranch, "err", err)
		params.ErrorMsg = fmt.Sprintf("Failed to resolve target branch %q", pull.TargetBranch)
		return
	}

	if base == "base" {
		base = branch.Hash
	}
	if head == "head" {
		head = pull.LatestVersion().Head
	}

	sourceRepoDid := pull.LatestVersion().SourceRepo

	// 2. list diverged commits using knotmirror (BASE..HEAD) -> ([]commit)
	//    - knotmirror needs on-demand fetch implementation for this
	commits, err := getTempListCommits(ctx, xrpcc, sourceRepoDid, base, head)
	if err != nil {
		panic("unimplemented")
	}

	// 3. list every commits in UI. They will be lazy-loaded
	params.Commits = commits
}

// fileDiff pairs a streamed FileDiff with the line content of its base/head blobs.
type fileDiff struct {
	diff      *gitmirrorv1.FileDiff
	baseLines []template.HTML // lines of the base (lhs) blob; nil for binary/submodule/absent
	headLines []template.HTML // lines of the head (rhs) blob; nil for binary/submodule/absent
}

// htmx fragment. render diff between commits
func (s *Pulls) PullDiffFragment(w http.ResponseWriter, r *http.Request) {
	var (
		// repoRaw = r.URL.Query().Get("repo")
		base    = r.URL.Query().Get("base") // base commit ID
		head    = r.URL.Query().Get("head") // head commit ID
		unified = r.URL.Query().Get("view") == "unified"
	)
	// repo, err := syntax.ParseDID(repoRaw)
	// if err != nil {
	// 	http.Error(w, fmt.Sprintf("invalid repo DID: %q", repoRaw), http.StatusBadRequest)
	// 	return
	// }
	repo, err := s.repoResolver.Resolve(r)
	if err != nil {
		panic("unimplemented")
	}

	l := s.logger
	ctx := r.Context()
	_ = l

	var params pages.PullDiffFragmentParams
	defer func() {
		s.pages.PullDiffFragment(w, params)
	}()

	// a. drain the diff stream into one fileDiff per changed file.
	stream, err := s.gitmirror.Diff(ctx, &gitmirrorv1.DiffRequest{
		Repo: repo.RepoDid,
		Base: base,
		Head: head,
	})
	if err != nil {
		panic(err)
	}
	var files []*fileDiff
	for {
		fd, err := stream.Recv()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			panic(err)
		}
		files = append(files, &fileDiff{diff: fd})
	}

	// b. fetch each file's base/head blob in parallel and split into lines.
	g, gctx := errgroup.WithContext(ctx)
	for _, f := range files {
		g.Go(func() error {
			lhs, rhs := f.diff.GetLhsSrc(), f.diff.GetRhsSrc()
			// Binary/submodule files have no line content to fetch.
			if isBinaryOrSubmodule(lhs) || isBinaryOrSubmodule(rhs) {
				return nil
			}
			baseBlob, err := s.getBlob(gctx, repo.RepoDid, lhs.GetOid())
			if err != nil {
				return err
			}
			headBlob, err := s.getBlob(gctx, repo.RepoDid, rhs.GetOid())
			if err != nil {
				return err
			}

			// TODO: highlight each blobs
			// pass lhs_positions & rhs_positions so highlighter can apply diff highlights
			for line := range strings.SplitSeq(strings.TrimSuffix(string(baseBlob), "\n"), "\n") {
				f.baseLines = append(f.baseLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line))))
			}
			for line := range strings.SplitSeq(strings.TrimSuffix(string(headBlob), "\n"), "\n") {
				f.headLines = append(f.headLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line))))
			}

			// f.baseLines = strings.Split(strings.TrimSuffix(string(baseBlob), "\n"), "\n")
			// f.headLines = strings.Split(strings.TrimSuffix(string(headBlob), "\n"), "\n")
			return nil
		})
	}
	if err := g.Wait(); err != nil {
		panic(err)
	}

	// c. build the render model, following difftastic's display layout
	// (github.com/Wilfred/difftastic src/display): side-by-side by default, inline when
	// ?view=unified. All markup/styling lives in the template; here we only assemble data.
	params.Split = !unified
	for _, f := range files {
		df := pages.DiffFile{Path: f.diff.GetRhsSrc().GetPath()}

		if isBinaryOrSubmodule(f.diff.GetLhsSrc()) || isBinaryOrSubmodule(f.diff.GetRhsSrc()) {
			df.Note = "binary or submodule"
			params.Files = append(params.Files, df)
			continue
		}

		for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) {
			var dh pages.DiffHunk
			if unified {
				dh.Lines = buildInlineLines(h, f.baseLines, f.headLines)
			} else {
				dh.Rows = buildSplitRows(h, f.baseLines, f.headLines)
			}
			df.Hunks = append(df.Hunks, dh)
		}
		params.Files = append(params.Files, df)
	}
}

// numContextLines is difftastic's default (`num_context_lines`); pad_before/pad_after add
// one extra (n+1), which is what we display and use as the hunk-merge window.
const numContextLines = 3

// linePair is one aligned display line: 0-based line numbers into the base (lhs) and head
// (rhs) blobs; -1 means that side has no line here (a blank/padding cell).
type linePair struct {
	lhs int
	rhs int
}

// diffRow is one aligned display line plus whether it is novel (removed/added/modified) or
// unchanged context. -1 on a side means that side has no line here (a blank cell).
type diffRow struct {
	lhs     int
	rhs     int
	changed bool
}

// displayHunk is one rendered hunk: context + changed rows, in display order.
type displayHunk struct {
	rows []diffRow
}

// maxDistance mirrors difftastic's MAX_DISTANCE (hunks.rs): the most unchanged lines that
// may sit between two changes before they are split into separate hunks (before context).
const maxDistance = 4

// buildHunks ports difftastic's src/display pre-processing (hunks.rs + context.rs): it
// regroups the changed line pairs into hunks by per-side line distance, merges hunks whose
// context windows overlap, and expands each with context lines, yielding displayHunks.
//
// gitmirror's Hunk.Lines are difftastic's novel line pairs; we lack its token-level
// MatchedPos, but a line-granular diff's unchanged lines form a 1:1 bijection, which stands
// in for difftastic's opposite_positions map (buildOpposites).
func buildHunks(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) []displayHunk {
	oppLhs, oppRhs, maxLhs, maxRhs := buildOpposites(baseLines, headLines, hunks)

	var flat []linePair
	for _, h := range hunks {
		for _, lp := range h.Lines {
			flat = append(flat, toPair(lp))
		}
	}

	merged := mergeAdjacent(linesToHunks(flat), oppLhs, oppRhs, maxLhs, maxRhs, numContextLines)

	// Display rows come from the whole-file aligned list sliced per hunk (difftastic's
	// side_by_side: all_matched_lines_filled + matched_lines_indexes_for_hunk). Slicing a
	// contiguous list keeps the interior context lines of merged hunks, which reconstructing
	// via extractLines/fillBetween drops across a deletion->addition boundary.
	pairs, changed := alignFile(baseLines, headLines, hunks)

	var out []displayHunk
	prevEnd := 0
	for _, h := range merged {
		lo, hi := indexesForHunk(pairs, h, numContextLines)
		if lo < prevEnd {
			lo = prevEnd // don't re-emit rows shared with the previous hunk's slice
		}
		var dh displayHunk
		for i := lo; i < hi; i++ {
			dh.rows = append(dh.rows, diffRow{lhs: pairs[i].lhs, rhs: pairs[i].rhs, changed: changed[i]})
		}
		out = append(out, dh)
		prevEnd = hi
	}
	return out
}

// alignFile builds the whole-file aligned list (difftastic's all_matched_lines_filled,
// specialized to a line-granular diff): every displayed line as a pair, plus a parallel
// `changed` flag for pairs that came from a gitmirror hunk. Unchanged lines are a 1:1
// bijection, so the two cursors advance together across gaps.
func alignFile(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) (pairs []linePair, changed []bool) {
	li, ri := 0, 0
	emitContext := func(n int) {
		for k := range n {
			pairs = append(pairs, linePair{lhs: li + k, rhs: ri + k})
			changed = append(changed, false)
		}
		li += n
		ri += n
	}
	for _, h := range hunks {
		lhsStart, _, ok := hunkStart(h, li, ri)
		if !ok {
			continue
		}
		emitContext(lhsStart - li) // unchanged gap before this change (== rhsStart-ri)
		for _, lp := range h.Lines {
			p := toPair(lp)
			if p.lhs >= 0 {
				li = p.lhs + 1
			}
			if p.rhs >= 0 {
				ri = p.rhs + 1
			}
			pairs = append(pairs, p)
			changed = append(changed, true)
		}
	}
	// Trailing unchanged tail (both sides advance equally).
	for li < len(baseLines) && ri < len(headLines) {
		pairs = append(pairs, linePair{lhs: li, rhs: ri})
		changed = append(changed, false)
		li++
		ri++
	}
	return pairs, changed
}

// indexesForHunk returns the [start,end) slice of the whole-file aligned pairs to display
// for a merged hunk: the span from its smallest to largest novel line, expanded by n context
// lines on each side and clamped (difftastic's matched_lines_indexes_for_hunk).
func indexesForHunk(pairs, hunkLines []linePair, n int) (start, end int) {
	minLhs, minRhs, maxLhs, maxRhs := -1, -1, -1, -1
	for _, lp := range hunkLines {
		if lp.lhs >= 0 {
			if minLhs < 0 {
				minLhs = lp.lhs
			}
			maxLhs = lp.lhs
		}
		if lp.rhs >= 0 {
			if minRhs < 0 {
				minRhs = lp.rhs
			}
			maxRhs = lp.rhs
		}
	}
	smallest, largest := linePair{minLhs, minRhs}, linePair{maxLhs, maxRhs}

	start = 0
	for i, p := range pairs {
		if eitherSideEqual(p, smallest) {
			start = i
			break
		}
	}
	end = len(pairs)
	for i := len(pairs) - 1; i >= 0; i-- {
		if eitherSideEqual(pairs[i], largest) {
			end = i + 1
			break
		}
	}

	start = max(0, start-n)
	end = min(len(pairs), end+n)
	return start, end
}

// eitherSideEqual reports whether a and b share a present line number on the same side
// (difftastic's either_side_equal).
func eitherSideEqual(a, b linePair) bool {
	if a.lhs >= 0 && a.lhs == b.lhs {
		return true
	}
	if a.rhs >= 0 && a.rhs == b.rhs {
		return true
	}
	return false
}

// toPair converts a proto LinePair to a linePair, mapping absent sides to -1.
func toPair(lp *gitmirrorv1.LinePair) linePair {
	p := linePair{lhs: -1, rhs: -1}
	if lp.Lhs != nil {
		p.lhs = int(*lp.Lhs)
	}
	if lp.Rhs != nil {
		p.rhs = int(*lp.Rhs)
	}
	return p
}

// buildOpposites is our stand-in for difftastic's opposite_positions: the 1:1 mapping
// between unchanged base and head lines. It walks the gitmirror hunks with two cursors —
// unchanged runs advance both sides together — recording each unchanged line's counterpart.
// maxLhs/maxRhs are the 0-based last line numbers of each blob.
func buildOpposites(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) (oppLhs, oppRhs map[int]int, maxLhs, maxRhs int) {
	oppLhs, oppRhs = map[int]int{}, map[int]int{}
	li, ri := 0, 0

	pair := func(gap int) {
		for k := range gap {
			oppLhs[li+k] = ri + k
			oppRhs[ri+k] = li + k
		}
		li += gap
		ri += gap
	}

	for _, h := range hunks {
		lhsStart, _, ok := hunkStart(h, li, ri)
		if !ok {
			continue
		}
		pair(lhsStart - li) // unchanged gap before this change
		for _, lp := range h.Lines {
			if lp.Lhs != nil {
				li = int(*lp.Lhs) + 1
			}
			if lp.Rhs != nil {
				ri = int(*lp.Rhs) + 1
			}
		}
	}
	// Trailing unchanged tail (both sides advance equally).
	for li < len(baseLines) && ri < len(headLines) {
		oppLhs[li] = ri
		oppRhs[ri] = li
		li++
		ri++
	}
	return oppLhs, oppRhs, len(baseLines) - 1, len(headLines) - 1
}

// hunkStart returns the first changed line number on each side, deriving the empty side
// from the cursors (unchanged lines advance both sides equally). ok is false for an
// empty hunk.
func hunkStart(h *gitmirrorv1.Hunk, li, ri int) (lhsStart, rhsStart int, ok bool) {
	lhsStart, rhsStart = -1, -1
	for _, lp := range h.Lines {
		if lp.Lhs != nil && lhsStart < 0 {
			lhsStart = int(*lp.Lhs)
		}
		if lp.Rhs != nil && rhsStart < 0 {
			rhsStart = int(*lp.Rhs)
		}
	}
	switch {
	case lhsStart < 0 && rhsStart < 0:
		return 0, 0, false
	case lhsStart < 0: // pure insertion
		lhsStart = li + (rhsStart - ri)
	case rhsStart < 0: // pure deletion
		rhsStart = ri + (lhsStart - li)
	}
	return lhsStart, rhsStart, true
}

// zipPadShorter pairs a[i] with b[i], padding the shorter side with -1 (difftastic's
// zip_pad_shorter).
func zipPadShorter(a, b []int) []linePair {
	out := make([]linePair, 0, max(len(a), len(b)))
	for i := 0; i < len(a) || i < len(b); i++ {
		p := linePair{lhs: -1, rhs: -1}
		if i < len(a) {
			p.lhs = a[i]
		}
		if i < len(b) {
			p.rhs = b[i]
		}
		out = append(out, p)
	}
	return out
}

// fillBetween returns the unchanged line pairs strictly between two changed pairs, so a
// hunk's lines become contiguous per side (difftastic's fill_between).
func fillBetween(prevLhs, nextLhs, prevRhs, nextRhs int) []linePair {
	var lhs, rhs []int
	if prevLhs >= 0 && nextLhs >= 0 {
		for x := prevLhs + 1; x < nextLhs; x++ {
			lhs = append(lhs, x)
		}
	}
	if prevRhs >= 0 && nextRhs >= 0 {
		for x := prevRhs + 1; x < nextRhs; x++ {
			rhs = append(rhs, x)
		}
	}
	return zipPadShorter(lhs, rhs)
}

// extractLines fills the interior gaps of a hunk's line pairs (difftastic's extract_lines).
func extractLines(lines []linePair) []linePair {
	maxLhs, maxRhs := -1, -1
	var out []linePair
	for _, lp := range lines {
		out = append(out, fillBetween(maxLhs, lp.lhs, maxRhs, lp.rhs)...)
		if lp.lhs >= 0 {
			maxLhs = lp.lhs
		}
		if lp.rhs >= 0 {
			maxRhs = lp.rhs
		}
		out = append(out, lp)
	}
	return out
}

// padBefore returns up to numContextLines+1 line numbers immediately before ln, clamped at
// 0, ascending (difftastic's pad_before). The extra line lets immediately-adjacent hunks
// merge.
func padBefore(ln, n int) []int {
	var out []int
	cur := ln
	for i := 0; i < n+1; i++ {
		if cur == 0 {
			break
		}
		cur--
		out = append(out, cur)
	}
	slices.Reverse(out)
	return out
}

// padAfter returns up to numContextLines+1 line numbers immediately after ln, clamped at
// maxLine (difftastic's pad_after).
func padAfter(ln, maxLine, n int) []int {
	var out []int
	cur := ln
	for i := 0; i < n+1; i++ {
		if cur >= maxLine {
			break
		}
		cur++
		out = append(out, cur)
	}
	return out
}

// beforeWithOpposites assigns each before-context line its counterpart on the opposite
// side, seeding from the opposite map then walking backwards (difftastic's
// before_with_opposites). Input lines are ascending; result pairs are (line, opposite).
func beforeWithOpposites(before []int, opp map[int]int) []linePair {
	lines := slices.Clone(before)
	slices.Reverse(lines)

	prevOpp := -1
	var res []linePair
	for _, line := range lines {
		cur := -1
		if prevOpp >= 0 {
			if prevOpp > 0 {
				cur = prevOpp - 1
			}
		} else if v, ok := opp[line]; ok {
			cur = v
		}
		res = append(res, linePair{lhs: line, rhs: cur})
		if cur >= 0 {
			prevOpp = cur
		}
	}
	slices.Reverse(res)
	return res
}

// afterWithOpposites is the forward counterpart of beforeWithOpposites (difftastic's
// after_with_opposites): opposites walk forward and stop at maxOpp; the first (map-seeded)
// opposite is dropped if it is not past prevMaxOpp (-1 = none).
func afterWithOpposites(after []int, opp map[int]int, prevMaxOpp, maxOpp int) []linePair {
	prevOpp := -1
	var res []linePair
	for _, line := range after {
		cur := -1
		if prevOpp >= 0 {
			if prevOpp < maxOpp {
				cur = prevOpp + 1
			}
		} else if v, ok := opp[line]; ok {
			if prevMaxOpp < 0 || v > prevMaxOpp {
				cur = v
			}
		}
		res = append(res, linePair{lhs: line, rhs: cur})
		if cur >= 0 {
			prevOpp = cur
		}
	}
	return res
}

// flipPairs swaps the lhs/rhs of each pair (difftastic's flip_tuples).
func flipPairs(pairs []linePair) []linePair {
	out := make([]linePair, len(pairs))
	for i, p := range pairs {
		out[i] = linePair{lhs: p.rhs, rhs: p.lhs}
	}
	return out
}

// calculateBeforeContext returns the context pairs preceding a hunk, anchored to whichever
// side the first line has (difftastic's calculate_before_context).
func calculateBeforeContext(lines []linePair, oppLhs, oppRhs map[int]int, n int) []linePair {
	if len(lines) == 0 {
		return nil
	}
	first := lines[0]
	switch {
	case first.lhs >= 0:
		return beforeWithOpposites(padBefore(first.lhs, n), oppLhs)
	case first.rhs >= 0:
		return flipPairs(beforeWithOpposites(padBefore(first.rhs, n), oppRhs))
	}
	return nil
}

// calculateAfterContext returns the context pairs following a hunk (difftastic's
// calculate_after_context). maxLhs/maxRhs are the blobs' last line numbers.
func calculateAfterContext(lines []linePair, oppLhs, oppRhs map[int]int, maxLhs, maxRhs, n int) []linePair {
	if len(lines) == 0 {
		return nil
	}
	last := lines[len(lines)-1]
	switch {
	case last.lhs >= 0:
		maxOpp := -1
		for _, lp := range lines {
			if lp.rhs >= 0 {
				maxOpp = lp.rhs
			}
		}
		return afterWithOpposites(padAfter(last.lhs, maxLhs, n), oppLhs, maxOpp, maxRhs)
	case last.rhs >= 0:
		maxOpp := -1
		for _, lp := range lines {
			if lp.lhs >= 0 {
				maxOpp = lp.lhs
			}
		}
		return flipPairs(afterWithOpposites(padAfter(last.rhs, maxRhs, n), oppRhs, maxOpp, maxLhs))
	}
	return nil
}

// addContext prepends before-context and appends after-context to a hunk's lines
// (difftastic's add_context).
func addContext(lines []linePair, oppLhs, oppRhs map[int]int, maxLhs, maxRhs, n int) []linePair {
	before := calculateBeforeContext(lines, oppLhs, oppRhs, n)
	after := calculateAfterContext(append(slices.Clone(before), lines...), oppLhs, oppRhs, maxLhs, maxRhs, n)

	out := make([]linePair, 0, len(before)+len(lines)+len(after))
	out = append(out, before...)
	out = append(out, lines...)
	out = append(out, after...)
	return out
}

// enforceIncreasing drops any line number that would go backwards, keeping each side
// monotonically increasing (difftastic's enforce_increasing).
func enforceIncreasing(lines []linePair) []linePair {
	var out []linePair
	maxLhs, maxRhs := -1, -1
	for _, lp := range lines {
		l, r := lp.lhs, lp.rhs
		if maxLhs < 0 {
			maxLhs = l
		} else if l >= 0 && l > maxLhs {
			maxLhs = l
		} else {
			l = -1
		}
		if maxRhs < 0 {
			maxRhs = r
		} else if r >= 0 && r > maxRhs {
			maxRhs = r
		} else {
			r = -1
		}
		if l >= 0 || r >= 0 {
			out = append(out, linePair{lhs: l, rhs: r})
		}
	}
	return out
}

// linesAreClose reports whether a line is within maxDistance of the last seen line on
// either side (difftastic's lines_are_close).
func linesAreClose(maxLhs, maxRhs int, lp linePair) bool {
	if maxLhs >= 0 && lp.lhs >= 0 && lp.lhs <= maxLhs+maxDistance {
		return true
	}
	if maxRhs >= 0 && lp.rhs >= 0 && lp.rhs <= maxRhs+maxDistance {
		return true
	}
	return false
}

// linesToHunks splits changed line pairs into hunks by per-side proximity (difftastic's
// lines_to_hunks over matched novel lines).
func linesToHunks(flat []linePair) [][]linePair {
	var hunks [][]linePair
	var cur []linePair
	maxLhs, maxRhs := -1, -1
	for _, lp := range enforceIncreasing(flat) {
		if len(cur) == 0 || linesAreClose(maxLhs, maxRhs, lp) {
			cur = append(cur, lp)
		} else {
			hunks = append(hunks, cur)
			cur = []linePair{lp}
		}
		if lp.lhs >= 0 {
			maxLhs = lp.lhs
		}
		if lp.rhs >= 0 {
			maxRhs = lp.rhs
		}
	}
	if len(cur) > 0 {
		hunks = append(hunks, cur)
	}
	return hunks
}

// mergeAdjacent merges hunks whose context-padded line sets overlap on either side
// (difftastic's merge_adjacent). Merging concatenates and de-duplicates lines.
func mergeAdjacent(hunks [][]linePair, oppLhs, oppRhs map[int]int, maxLhs, maxRhs, n int) [][]linePair {
	var merged [][]linePair
	var prev []linePair
	var prevLhs, prevRhs map[int]struct{}

	for _, h := range hunks {
		lhsSet, rhsSet := lineSets(addContext(extractLines(h), oppLhs, oppRhs, maxLhs, maxRhs, n))
		if prev == nil {
			prev, prevLhs, prevRhs = h, lhsSet, rhsSet
			continue
		}
		if disjoint(lhsSet, prevLhs) && disjoint(rhsSet, prevRhs) {
			merged = append(merged, prev)
			prev, prevLhs, prevRhs = h, lhsSet, rhsSet
		} else {
			prev = mergeHunkLines(prev, h)
			unionInto(prevLhs, lhsSet)
			unionInto(prevRhs, rhsSet)
		}
	}
	if prev != nil {
		merged = append(merged, prev)
	}
	return merged
}

// mergeHunkLines concatenates two hunks' lines and drops duplicate line numbers per side,
// nulling the already-seen side (difftastic's Hunk::merge).
func mergeHunkLines(a, b []linePair) []linePair {
	lhsSeen, rhsSeen := map[int]struct{}{}, map[int]struct{}{}
	var out []linePair
	for _, lp := range append(slices.Clone(a), b...) {
		lhsDupe, rhsDupe := false, false
		if lp.lhs >= 0 {
			_, lhsDupe = lhsSeen[lp.lhs]
			lhsSeen[lp.lhs] = struct{}{}
		}
		if lp.rhs >= 0 {
			_, rhsDupe = rhsSeen[lp.rhs]
			rhsSeen[lp.rhs] = struct{}{}
		}
		if lhsDupe && rhsDupe {
			continue
		}
		np := lp
		if lhsDupe {
			np.lhs = -1
		}
		if rhsDupe {
			np.rhs = -1
		}
		out = append(out, np)
	}
	return out
}

// lineSets returns the sets of present lhs and rhs line numbers in a run of pairs.
func lineSets(lines []linePair) (lhs, rhs map[int]struct{}) {
	lhs, rhs = map[int]struct{}{}, map[int]struct{}{}
	for _, lp := range lines {
		if lp.lhs >= 0 {
			lhs[lp.lhs] = struct{}{}
		}
		if lp.rhs >= 0 {
			rhs[lp.rhs] = struct{}{}
		}
	}
	return
}

func disjoint(a, b map[int]struct{}) bool {
	for k := range a {
		if _, ok := b[k]; ok {
			return false
		}
	}
	return true
}

func unionInto(dst, src map[int]struct{}) {
	for k := range src {
		dst[k] = struct{}{}
	}
}

// dispNum maps a 0-based line number to its 1-based display value, passing through the -1
// absent sentinel unchanged.
func dispNum(n int) int {
	if n < 0 {
		return -1
	}
	return n + 1
}

// buildSplitRows turns a hunk into side-by-side rows (difftastic's SideBySideShowBoth): each
// row always has a left (base) and right (head) cell. Unchanged rows show context on both
// sides; a changed row shows a deletion on the left and/or an addition on the right, padding
// the missing side with an empty cell.
func buildSplitRows(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffRow {
	rows := make([]pages.DiffRow, 0, len(h.rows))
	for _, r := range h.rows {
		var row pages.DiffRow
		if !r.changed {
			row.Left = pages.DiffCell{Kind: "ctx", Num: dispNum(r.lhs), Content: lineAt(baseLines, r.lhs)}
			row.Right = pages.DiffCell{Kind: "ctx", Num: dispNum(r.rhs), Content: lineAt(headLines, r.rhs)}
		} else {
			if r.lhs >= 0 {
				row.Left = pages.DiffCell{Kind: "del", Num: dispNum(r.lhs), Content: lineAt(baseLines, r.lhs)}
			} else {
				row.Left = pages.DiffCell{Kind: "empty", Num: -1}
			}
			if r.rhs >= 0 {
				row.Right = pages.DiffCell{Kind: "add", Num: dispNum(r.rhs), Content: lineAt(headLines, r.rhs)}
			} else {
				row.Right = pages.DiffCell{Kind: "empty", Num: -1}
			}
		}
		rows = append(rows, row)
	}
	return rows
}

// buildInlineLines turns a hunk into unified-diff lines: context lines shown once (no
// marker), and each contiguous run of changed rows emitted as all removed (LHS, "-") then
// all added (RHS, "+"). Interior context between merged changes stays a single line. Removed
// lines carry only the old number, added lines only the new number.
func buildInlineLines(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffLine {
	var out []pages.DiffLine
	for i := 0; i < len(h.rows); {
		r := h.rows[i]
		if !r.changed {
			if r.lhs >= 0 {
				out = append(out, pages.DiffLine{Op: " ", Old: dispNum(r.lhs), New: dispNum(r.rhs), Content: lineAt(baseLines, r.lhs)})
			}
			i++
			continue
		}
		j := i
		for j < len(h.rows) && h.rows[j].changed {
			j++
		}
		for _, cr := range h.rows[i:j] {
			if cr.lhs >= 0 {
				out = append(out, pages.DiffLine{Op: "-", Old: dispNum(cr.lhs), New: -1, Content: lineAt(baseLines, cr.lhs)})
			}
		}
		for _, cr := range h.rows[i:j] {
			if cr.rhs >= 0 {
				out = append(out, pages.DiffLine{Op: "+", Old: -1, New: dispNum(cr.rhs), Content: lineAt(headLines, cr.rhs)})
			}
		}
		i = j
	}
	return out
}

// lineAt returns lines[n], or "" when n is the -1 absent sentinel or out of range.
func lineAt(lines []template.HTML, n int) template.HTML {
	if n < 0 || n >= len(lines) {
		return ""
	}
	return lines[n]
}

// getBlob streams a blob's bytes from gitmirror by OID and concatenates them.
// A null (all-zero) oid — the absent side of an addition/deletion — yields nil.
func (s *Pulls) getBlob(ctx context.Context, repo, oid string) ([]byte, error) {
	if isNullOid(oid) {
		return nil, nil
	}
	stream, err := s.gitmirror.GetBlob(ctx, &gitmirrorv1.GetBlobRequest{Repo: repo, Oid: oid})
	if err != nil {
		return nil, err
	}
	var buf []byte
	for {
		chunk, err := stream.Recv()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return nil, err
		}
		buf = append(buf, chunk.GetData()...)
	}
	return buf, nil
}

func isBinaryOrSubmodule(fc *gitmirrorv1.FileContent) bool {
	return fc != nil && (fc.GetIsBinary() || fc.GetIsSubmodule())
}

// isNullOid reports whether oid is empty or the all-zero object id.
func isNullOid(oid string) bool {
	if oid == "" {
		return true
	}
	for _, c := range oid {
		if c != '0' {
			return false
		}
	}
	return true
}

// htmx fragment. render interdiff between changes
func (s *Pulls) PullInterdiffFragment(w http.ResponseWriter, r *http.Request) {
	// var (
	// 	base1   = r.URL.Query().Get("base1") // base1 commit ID
	// 	base2   = r.URL.Query().Get("base2") // base2 commit ID
	// 	head1   = r.URL.Query().Get("head1") // head1 commit ID
	// 	head2   = r.URL.Query().Get("head2") // head1 commit ID
	// 	unified = r.URL.Query().Get("view") == "unified"
	// )

	// 1. compute interdiff. (knotmirror)
	// 2. return rich diff data. (knotmirror)
	// 3. load old/new blobs & run syntax highlight
	// 4. render diff
}

// parseRevRange parses <head>..<base> string.
// base and head will default to "base" and "head" when omitted.
func parseRevRange(range_ string) (base string, head string, err error) {
	return "base", "head", nil
	// panic("unimplemented")
}

// parseVersionRange parses <version>..<version> string.
// Each versions will default to "base" and "latest" when omitted.
func parseVersionRange(range_ string) (base string, head string, err error) {
	return "base", "latest", nil
}

func getTempListCommits(ctx context.Context, xrpcc util.LexClient, repo syntax.DID, base, head string) ([]types.Commit, error) {
	// panic("unimplemented")
	return nil, nil
	// raw, err := tangled.GitTempListCommits(ctx, xrpcc, "", 1000, head, repo.String())
	// if err != nil {
	// 	return nil, err
	// }
	//
	// var xrpcResp types.RepoLogResponse
	// if err := json.Unmarshal(raw, &xrpcResp); err != nil {
	// 	return nil, fmt.Errorf("failed to decode XRPC response: %w", err)
	// }
	//
	// return xrpcResp.Commits, nil
}

// htmx fragment. render interdiff between commits
func (s *Pulls) PullInterDiffFragment(w http.ResponseWriter, r *http.Request) {
	panic("unimplemented")
}

// gitmirror
// - git.ListCommitsSinceMergeBase(repo, base, head)
// - git.Diff(repo, base, head, mode)
// - git.Interdiff(repo,

// for interdiff, we want: from{start,end}, to{start,end}
// 1. squash from.start ~ from.end into one commit
// 2. rebase that commit to to.start.parent()
// 3. diff from_squashed.tree and to.end.tree

// we want git log BASE..HEAD (only commits in HEAD) diverged=false
//   and git diff BASE...HEAD (changes from HEAD since merge-base) absolute=false

// commands.go:56 picks comparison type:
// - diff BASE..HEAD (COMPARISON_TYPE_ONLY_IN_HEAD) = direct
// - diff BASE...HEAD (COMPARISON_TYPE_INTERSECTION) = merge-base. Server resolves merge-base via g.MergeBase() first (diff.go:43) then diffs.
// we want second one. we should compute merge-base first.
// we can have
