package pulls

import (
	"context"
	"errors"
	"html/template"
	"io"

	"github.com/bluesky-social/indigo/atproto/syntax"
	"tangled.org/core/appview/pages"
	gitmirrorv1 "tangled.org/core/gitmirror/proto/gen"
)

type fileDiff struct {
	diff      *gitmirrorv1.FileDiff
	baseLines []template.HTML
	headLines []template.HTML
}

const (
	numContextLines = 3
	maxDistance     = 4
)

type linePair struct {
	lhs int // 0-based line number, -1 when empty
	rhs int // 0-based line number, -1 when empty
}

type displayHunk struct {
	rows []diffRow
}

type diffRow struct {
	lhs     int // 0-based line number, -1 when empty
	rhs     int // 0-based line number, -1 when empty
	changed bool
}

func buildHunks(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) []displayHunk {
	var flat []linePair
	for _, h := range hunks {
		for _, lp := range h.Lines {
			flat = append(flat, toPair(lp))
		}
	}

	pairs, changed := alignFile(baseLines, headLines, hunks)
	merged := mergeAdjacent(linesToHunks(flat), pairs)

	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: 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)
		}
	}
	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 aligned pairs to display
// for a hunk: the span from its smallest to largest novel line, expanded by n
// context lines each side and clamped.
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.
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
}

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
}

// 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
}

// enforceIncreasing drops any line number that would go backwards, keeping each
// side monotonically 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.
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.
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 folds consecutive hunks whose context windows overlap in the
// aligned pair list into one group. It pads by numContextLines+1 (one more than
// the displayed context) so hunks separated only by shared context merge.
func mergeAdjacent(hunks [][]linePair, pairs []linePair) [][]linePair {
	var merged [][]linePair
	prevHi := -1
	for _, h := range hunks {
		lo, hi := indexesForHunk(pairs, h, numContextLines+1)
		if len(merged) > 0 && lo < prevHi {
			last := len(merged) - 1
			merged[last] = append(merged[last], h...)
			if hi > prevHi {
				prevHi = hi
			}
			continue
		}
		merged = append(merged, h)
		prevHi = hi
	}
	return merged
}

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: r.lhs + 1, Content: lineAt(baseLines, r.lhs)}
			row.Right = pages.DiffCell{Kind: "ctx", Num: r.rhs + 1, Content: lineAt(headLines, r.rhs)}
		} else {
			if r.lhs >= 0 {
				row.Left = pages.DiffCell{Kind: "del", Num: r.lhs + 1, Content: lineAt(baseLines, r.lhs)}
			} else {
				row.Left = pages.DiffCell{Kind: "empty", Num: 0}
			}
			if r.rhs >= 0 {
				row.Right = pages.DiffCell{Kind: "add", Num: r.rhs + 1, Content: lineAt(headLines, r.rhs)}
			} else {
				row.Right = pages.DiffCell{Kind: "empty", Num: 0}
			}
		}
		rows = append(rows, row)
	}
	return rows
}

func buildUnifiedLines(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: r.lhs + 1, New: r.rhs + 1, 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: cr.lhs + 1, New: 0, Content: lineAt(baseLines, cr.lhs)})
			}
		}
		for _, cr := range h.rows[i:j] {
			if cr.rhs >= 0 {
				out = append(out, pages.DiffLine{Op: "+", Old: 0, New: cr.rhs + 1, Content: lineAt(headLines, cr.rhs)})
			}
		}
		i = j
	}
	return out
}

func lineAt(lines []template.HTML, n int) template.HTML {
	if n < 0 || n >= len(lines) {
		return ""
	}
	return lines[n]
}

func (s *Pulls) getBlob(ctx context.Context, repo syntax.DID, oid string) ([]byte, error) {
	if isNullOid(oid) {
		return nil, nil
	}
	stream, err := s.gitmirror.GetBlob(ctx, &gitmirrorv1.GetBlobRequest{Repo: repo.String(), 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())
}

func isNullOid(oid string) bool {
	if oid == "" {
		return true
	}
	for _, c := range oid {
		if c != '0' {
			return false
		}
	}
	return true
}
