package ui

import (
	"fmt"
	"strings"
	"testing"
)

// fakeItem is a deterministic MessageItem for ScrollList tests.
type fakeItem struct {
	id    string
	lines int
}

func (f *fakeItem) ID() string { return f.id }
func (f *fakeItem) Render(_ int) string {
	if f.lines <= 0 {
		return ""
	}
	parts := make([]string, f.lines)
	for i := range parts {
		parts[i] = fmt.Sprintf("%s-line-%d", f.id, i)
	}
	return strings.Join(parts, "\n")
}
func (f *fakeItem) Height() int { return f.lines }

// makeItems builds n fake items of `lines` height each.
func makeItems(n, lines int) []MessageItem {
	out := make([]MessageItem, n)
	for i := range out {
		out[i] = &fakeItem{id: fmt.Sprintf("item-%d", i), lines: lines}
	}
	return out
}

// TestScrollList_MouseDownPreventsAutoScroll verifies the core fix for the
// copy-selection drift bug: while the user has the mouse button held
// (drag-selecting), incoming content updates must NOT shift the viewport,
// because doing so moves the highlighted content out from under the cursor.
func TestScrollList_MouseDownPreventsAutoScroll(t *testing.T) {
	sl := NewScrollList(80, 10)
	sl.SetItems(makeItems(20, 2)) // 40 lines of content into a 10-line viewport
	// Capture the auto-scrolled-to-bottom position.
	startOffsetIdx := sl.offsetIdx
	startOffsetLine := sl.offsetLine

	// User clicks somewhere in the visible area, starting a drag-select.
	if !sl.HandleMouseDown(5, 3) {
		t.Fatalf("HandleMouseDown should accept a click inside the viewport")
	}
	if !sl.IsMouseDown() {
		t.Fatalf("IsMouseDown should be true after HandleMouseDown")
	}

	// New content arrives. With autoScroll still true, SetItems would
	// normally call GotoBottom() and shift the viewport. The fix should
	// suppress that while MouseDown is held.
	sl.SetItems(makeItems(30, 2)) // 60 lines now
	if sl.offsetIdx != startOffsetIdx || sl.offsetLine != startOffsetLine {
		t.Errorf("viewport scrolled during active drag: was (%d,%d), now (%d,%d)",
			startOffsetIdx, startOffsetLine, sl.offsetIdx, sl.offsetLine)
	}

	// User releases the mouse — drag is over.
	sl.HandleMouseUp()
	if sl.IsMouseDown() {
		t.Fatalf("IsMouseDown should be false after HandleMouseUp")
	}

	// After release, a fresh content update should resume auto-scrolling
	// (move the offset to track the new bottom).
	afterReleaseIdx := sl.offsetIdx
	afterReleaseLine := sl.offsetLine
	sl.SetItems(makeItems(50, 2))
	if sl.offsetIdx == afterReleaseIdx && sl.offsetLine == afterReleaseLine {
		t.Errorf("autoscroll did not resume after MouseUp: offset stuck at (%d,%d)",
			afterReleaseIdx, afterReleaseLine)
	}
}

// TestScrollList_DragDisablesAutoScroll verifies that any successful
// HandleMouseDrag call clears autoScroll, even when HandleMouseDown didn't
// observe it (e.g. a stale wheel-down event set it back to true mid-stream).
func TestScrollList_DragDisablesAutoScroll(t *testing.T) {
	sl := NewScrollList(80, 10)
	sl.SetItems(makeItems(20, 2))

	// Begin a selection.
	if !sl.HandleMouseDown(5, 3) {
		t.Fatalf("HandleMouseDown failed")
	}
	// Simulate an external code path that re-enabled autoScroll while
	// MouseDown is still held (the precise condition that caused drift).
	sl.autoScroll = true

	// Drag motion should hard-lock the viewport again.
	if !sl.HandleMouseDrag(10, 4) {
		t.Fatalf("HandleMouseDrag failed")
	}
	if sl.autoScroll {
		t.Errorf("HandleMouseDrag must clear autoScroll to prevent mid-drag jumps")
	}
}

// TestScrollList_SetItemsRespectsMouseDown is the most direct regression
// test: even with autoScroll enabled and new content appended at the
// bottom, SetItems must not move the viewport while a mouse drag is in
// progress. This is what caused the "highlighting shifts by 1+ rows
// during streaming" symptom reported by the user.
func TestScrollList_SetItemsRespectsMouseDown(t *testing.T) {
	sl := NewScrollList(80, 5)
	sl.SetItems(makeItems(10, 2)) // 20 lines into a 5-line viewport
	// At bottom.
	preIdx, preLine := sl.offsetIdx, sl.offsetLine

	// Hold mouse down (no actual drag needed).
	if !sl.HandleMouseDown(0, 0) {
		t.Fatalf("HandleMouseDown failed")
	}

	// Append several more items as if streaming. With the bug, each
	// SetItems would call GotoBottom and shift the offset.
	for n := 11; n <= 15; n++ {
		sl.SetItems(makeItems(n, 2))
		if sl.offsetIdx != preIdx || sl.offsetLine != preLine {
			t.Fatalf("viewport drifted during streaming with mouse held: "+
				"start=(%d,%d) now=(%d,%d) after adding item %d",
				preIdx, preLine, sl.offsetIdx, sl.offsetLine, n)
		}
	}
}

// TestScrollList_EmptyItemsDoNotShiftMouseMapping is the regression test
// for the second drift bug: items that render to "" must contribute the
// same number of rows in View() (zero) as in renderedHeight(), or mouse
// hit-testing drifts by one row per empty item between offsetIdx and the
// cursor. This was surfaced by extension widgets (e.g. subagent-monitor)
// that shrink the scrollback so empty streaming-reasoning items end up
// in the visible window.
//
// Setup: 1 normal item + 1 empty item + 1 normal item. Click on the line
// where the third item begins. With the bug, getItemAndLineAtY skips the
// empty item (renderedHeight=0) and reports lineIdx pointing one row
// past where View() actually painted that line.
func TestScrollList_EmptyItemsDoNotShiftMouseMapping(t *testing.T) {
	sl := NewScrollList(80, 10)
	sl.SetItems([]MessageItem{
		&fakeItem{id: "a", lines: 2},     // viewY 0–1
		&fakeItem{id: "empty", lines: 0}, // renders "" — contributes 0 rows
		&fakeItem{id: "b", lines: 2},     // viewY 2–3
	})

	// Render the viewport once so the cache reflects what View() actually
	// emits (this is the path that previously diverged from renderedHeight
	// for empty items).
	rendered := sl.View()
	lines := strings.Split(rendered, "\n")

	// Sanity: View() must emit exactly height lines.
	if len(lines) != 10 {
		t.Fatalf("View() returned %d lines, want 10", len(lines))
	}
	// Item b's first line should appear at viewY=2, NOT viewY=3.
	if !strings.Contains(lines[2], "b-line-0") {
		t.Errorf("viewY=2 should render b-line-0 (empty item contributes 0 rows), got %q", lines[2])
	}

	// Now the actual hit-test contract: clicking on viewY=2 must map to
	// item b line 0 — the same coordinate View() rendered there.
	idx, line := sl.getItemAndLineAtY(2)
	if idx != 2 || line != 0 {
		t.Errorf("getItemAndLineAtY(2) = (%d,%d), want (2,0)", idx, line)
	}

	// And clicking on the second line of b (viewY=3) must map to b line 1.
	idx, line = sl.getItemAndLineAtY(3)
	if idx != 2 || line != 1 {
		t.Errorf("getItemAndLineAtY(3) = (%d,%d), want (2,1)", idx, line)
	}
}
