package pulls

import (
	"html/template"
	"testing"

	gitmirrorv1 "tangled.org/core/gitmirror/proto/gen"
)

func u32(v uint32) *uint32 { return &v }

// lines returns n lines named l0..l{n-1}, optionally overriding some by index.
func lines(n int, override map[int]string) []template.HTML {
	out := make([]template.HTML, n)
	for i := range out {
		if v, ok := override[i]; ok {
			out[i] = template.HTML(v)
		} else {
			out[i] = template.HTML("l" + string(rune('0'+i%10)))
		}
	}
	return out
}

func hunk(pairs ...*gitmirrorv1.LinePair) *gitmirrorv1.Hunk {
	return &gitmirrorv1.Hunk{Lines: pairs}
}

// identity opposite maps + generous bounds, for exercising the merge/context logic directly.
func identityOpp(n int) (map[int]int, map[int]int) {
	l, r := map[int]int{}, map[int]int{}
	for i := 0; i < n; i++ {
		l[i], r[i] = i, i
	}
	return l, r
}

func TestExtractLines_FillsInteriorGap(t *testing.T) {
	got := extractLines([]linePair{{0, 0}, {2, 2}})
	want := []linePair{{0, 0}, {1, 1}, {2, 2}}
	if len(got) != len(want) {
		t.Fatalf("got %+v, want %+v", got, want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("pair %d = %+v, want %+v", i, got[i], want[i])
		}
	}
}

func TestPadBeforeAfter_ClampAndCount(t *testing.T) {
	// n+1 = 4 lines when far from the edges.
	if got := padBefore(10, numContextLines); len(got) != 4 || got[0] != 6 || got[3] != 9 {
		t.Fatalf("padBefore(10) = %v", got)
	}
	if got := padAfter(10, 100, numContextLines); len(got) != 4 || got[0] != 11 || got[3] != 14 {
		t.Fatalf("padAfter(10,100) = %v", got)
	}
	// Clamp at start of file.
	if got := padBefore(2, numContextLines); len(got) != 2 || got[0] != 0 || got[1] != 1 {
		t.Fatalf("padBefore(2) = %v", got)
	}
	// Clamp at end of file.
	if got := padAfter(98, 100, numContextLines); len(got) != 2 || got[0] != 99 || got[1] != 100 {
		t.Fatalf("padAfter(98,100) = %v", got)
	}
}

func TestMergeAdjacent_MergeClose(t *testing.T) {
	oppL, oppR := identityOpp(200)
	// Two deletions 2 lines apart on the lhs -> context windows overlap -> merge.
	got := mergeAdjacent([][]linePair{{{2, -1}}, {{4, -1}}}, oppL, oppR, 199, 199, numContextLines)
	if len(got) != 1 {
		t.Fatalf("want 1 merged hunk, got %d: %+v", len(got), got)
	}
}

func TestMergeAdjacent_SplitFar(t *testing.T) {
	oppL, oppR := identityOpp(200)
	got := mergeAdjacent([][]linePair{{{2, -1}}, {{40, -1}}}, oppL, oppR, 199, 199, numContextLines)
	if len(got) != 2 {
		t.Fatalf("want 2 hunks, got %d: %+v", len(got), got)
	}
}

func TestMergeAdjacent_LargeOneSidedInsertion(t *testing.T) {
	oppL, oppR := identityOpp(200)
	// Two modifications adjacent on the lhs (2 and 4) but far apart on the rhs (2 and 40),
	// as if 30+ lines were inserted between them. difftastic merges by per-side line
	// distance, so the lhs overlap keeps them one hunk — the case aligned-index windowing
	// would have split.
	got := mergeAdjacent([][]linePair{{{2, 2}}, {{4, 40}}}, oppL, oppR, 199, 199, numContextLines)
	if len(got) != 1 {
		t.Fatalf("want 1 merged hunk (lhs overlap), got %d: %+v", len(got), got)
	}
}

func TestBuildOpposites_DeletionPairing(t *testing.T) {
	// Delete base line 4 of a 10-line file -> head has 9 lines. Unchanged base 5..9 must
	// pair with head 4..8 (the bijection difftastic's opposite_positions encodes).
	base := lines(10, nil)
	head := lines(9, nil)
	hunks := []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(4)})}

	oppLhs, oppRhs, maxLhs, maxRhs := buildOpposites(base, head, hunks)
	if maxLhs != 9 || maxRhs != 8 {
		t.Fatalf("max lines = %d,%d want 9,8", maxLhs, maxRhs)
	}
	for l := 5; l <= 9; l++ {
		if oppLhs[l] != l-1 {
			t.Fatalf("oppLhs[%d] = %d, want %d", l, oppLhs[l], l-1)
		}
	}
	if oppRhs[4] != 5 { // head line 4 <-> base line 5
		t.Fatalf("oppRhs[4] = %d, want 5", oppRhs[4])
	}
	// The deleted base line 4 has no counterpart.
	if _, ok := oppLhs[4]; ok {
		t.Fatalf("deleted base line 4 should have no opposite")
	}
}

func TestBuildHunks_MarksNovelAndAddsContext(t *testing.T) {
	base := lines(10, map[int]string{5: "old"})
	head := lines(10, map[int]string{5: "new"})
	built := buildHunks(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})})
	if len(built) != 1 {
		t.Fatalf("want 1 hunk, got %d", len(built))
	}
	var nChanged, nContext int
	for _, r := range built[0].rows {
		if r.changed {
			nChanged++
			if r.lhs != 5 || r.rhs != 5 {
				t.Fatalf("changed row = %+v, want {5,5}", r)
			}
		} else {
			nContext++
		}
	}
	if nChanged != 1 {
		t.Fatalf("want 1 changed row, got %d", nChanged)
	}
	// Side-by-side display shows numContextLines (3) context rows each side, index-based
	// (difftastic's matched_lines_indexes_for_hunk), both clamped inside the 10-line file.
	if nContext != 6 {
		t.Fatalf("want 6 context rows, got %d: %+v", nContext, built[0].rows)
	}
}

func TestBuildHunks_MergedKeepsInteriorContext(t *testing.T) {
	// Regression: a deletion and a nearby addition, merged into one hunk, are separated by
	// unchanged lines. Those interior context rows must survive (reconstructing context via
	// extractLines/fillBetween dropped them across the deletion->addition boundary).
	// Base: 20 lines; delete base line 5. Head: base with line 5 removed and a new line
	// inserted at head index 8 (a few lines after the deletion point).
	base := lines(20, nil)
	head := make([]template.HTML, 0, 20)
	head = append(head, base[:5]...)          // head 0..4  == base 0..4
	head = append(head, base[6:9]...)         // head 5..7  == base 6..8
	head = append(head, template.HTML("INSERTED")) // head 8 (new)
	head = append(head, base[9:]...)          // head 9..   == base 9..

	hunks := []*gitmirrorv1.Hunk{
		hunk(&gitmirrorv1.LinePair{Lhs: u32(5)}),            // delete base line 5
		hunk(&gitmirrorv1.LinePair{Rhs: u32(8)}),            // insert head line 8
	}
	built := buildHunks(base, head, hunks)
	if len(built) != 1 {
		t.Fatalf("want 1 merged hunk, got %d", len(built))
	}
	// The unchanged lines between the deletion (base 5) and the insertion (head 8, i.e. base
	// 8) — base lines 6,7,8 — must appear as context rows (both sides present, unchanged).
	want := map[int]bool{6: false, 7: false, 8: false}
	got := map[int]bool{}
	for _, r := range built[0].rows {
		if !r.changed && r.lhs >= 0 {
			if _, ok := want[r.lhs]; ok {
				got[r.lhs] = true
			}
		}
	}
	for ln := range want {
		if !got[ln] {
			t.Fatalf("interior context base line %d missing from merged hunk: %+v", ln, built[0].rows)
		}
	}
}

func TestIndexesForHunk_SliceAndClamp(t *testing.T) {
	base := lines(20, nil)
	head := lines(20, nil)
	// Single change at line 10.
	pairs, _ := alignFile(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(10), Rhs: u32(10)})})
	lo, hi := indexesForHunk(pairs, []linePair{{10, 10}}, numContextLines)
	if lo != 7 || hi != 14 { // [10-3 .. 10+3+1)
		t.Fatalf("slice = [%d,%d), want [7,14)", lo, hi)
	}
	// Clamp at start of file.
	pairs2, _ := alignFile(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(1), Rhs: u32(1)})})
	lo2, _ := indexesForHunk(pairs2, []linePair{{1, 1}}, numContextLines)
	if lo2 != 0 {
		t.Fatalf("start clamp = %d, want 0", lo2)
	}
}

