// A live tool burst must settle once and invalidate its cache on reset and eviction.
package tui

import (
	"fmt"
	"strings"
	"testing"

	"albedo/cli/internal/daemon"
	"github.com/charmbracelet/x/ansi"
)

func traced(activities []daemon.ToolActivity, changes ...daemon.FileChange) HistoryEntry {
	return HistoryEntry{Kind: EntryTool, ToolName: "python", ToolResult: `{"status":"ok"}`,
		ToolTrace: &daemon.ToolTrace{Activities: activities, Changes: changes}}
}

func burstFixture() []HistoryEntry {
	ws := "/w/albedo/"
	return []HistoryEntry{
		{Kind: EntryThinking, Text: "hm", ElapsedMs: 34_000},
		traced([]daemon.ToolActivity{{Kind: "read", Target: ws + "cli/internal/tui/chat.go"}, {Kind: "read", Target: ws + "cli/internal/tui/transcript.go"}}),
		traced([]daemon.ToolActivity{{Kind: "run", Target: "cd cli && go test ./..."}}),
		traced([]daemon.ToolActivity{{Kind: "run", Target: "go test ./internal/tui"}}),
		traced([]daemon.ToolActivity{{Kind: "read", Target: ws + "priv/python/albedo_trace.py"}},
			daemon.FileChange{Path: ws + "priv/python/albedo_trace.py", Kind: "diff", Added: 5, Removed: 2}),
		{Kind: EntryTool, ToolName: "python", ToolResult: `{"status":"error","error":"boom"}`, ToolArgs: map[string]any{"code": "1/0"}},
	}
}

func TestBurstSettlesOnceProseEndsIt(t *testing.T) {
	m := newTestChatModel(t, &daemon.Session{ID: "s", Workspace: "/w/albedo"})
	m.SetSize(120, 40)
	m.appendSettledEntry(HistoryEntry{Kind: EntryUser, Text: "go"})
	settled := len(m.settledLines)
	for _, entry := range burstFixture()[:4] {
		m.appendSettledEntry(entry)
	}
	if len(m.settledLines) != settled {
		t.Fatal("an open burst wrote settled rows")
	}
	m.refreshViewportContent()
	if view := ansi.Strip(m.Viewport.View()); !strings.Contains(view, "ran go test ×2") {
		t.Fatalf("the open burst is not drawn: %q", view)
	}
	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventMessage, Text: "done"})
	joined := ansi.Strip(strings.Join(m.settledLines[settled:], "\n"))
	if strings.Count(joined, "ran go test") != 1 || !strings.Contains(joined, "done") ||
		strings.Index(joined, "ran go test") > strings.Index(joined, "done") {
		t.Fatalf("burst did not settle once, before the prose: %q", joined)
	}
	m.rebuildSettledLines()
	if again := ansi.Strip(strings.Join(m.settledLines, "\n")); strings.Count(again, "ran go test") != 1 {
		t.Fatalf("a rebuild drew the burst %d times", strings.Count(again, "ran go test"))
	}
}

// The cached live frame renders exactly what collecting the same entries
// renders, and is rebuilt only when one of the inputs in its key changes:
// each step below mutates exactly one, and the render counter must move once.
func TestCachedBurstMatchesCollectedBurst(t *testing.T) {
	m := newTestChatModel(t, &daemon.Session{ID: "s", Workspace: "/w/albedo"})
	m.SetSize(120, 30)
	for _, entry := range burstFixture() {
		m.appendSettledEntry(entry)
	}
	m.refreshViewportContent()
	entries := m.History.Entries()
	burst := trailingBurst(entries, m.Flags)
	if len(burst) != len(burstFixture()) {
		t.Fatalf("trailing burst is %d entries, want %d", len(burst), len(burstFixture()))
	}
	want := strings.Join(m.Renderer.BurstBlock(entries[:len(entries)-len(burst)], burst, m.Flags), "\n")
	if got := strings.Join(m.burstRows, "\n"); got != want {
		t.Fatalf("cached frame diverged:\n%s\nwant:\n%s", got, want)
	}

	renders := m.burstRenders
	step := func(name string, mutate func(), want int) {
		mutate()
		m.refreshViewportContent()
		if got := m.burstRenders - renders; got != want {
			t.Fatalf("%s: frame rebuilt %d times, want %d", name, got, want)
		}
		renders = m.burstRenders
	}
	step("idle refresh", func() {}, 0)
	step("a settled entry", func() { m.appendSettledEntry(burstFixture()[0]) }, 1)
	step("width", func() { m.SetSize(100, 30) }, 1)
	// the frame depends on the flags (diffs render the changes), so each flip
	// redraws once; flipping back lands on the cached frame again
	step("flags there and back", func() { m.Flags.Tools = true; m.refreshViewportContent(); m.Flags.Tools = false }, 2)
	step("workspace", func() { m.Renderer.Workspace = "/w/other" }, 1)
	step("reset then the same entries", func() {
		m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventReset})
		for _, entry := range burstFixture() {
			m.appendSettledEntry(entry)
		}
	}, 1)
	// same entry count after a reset, different content: only the epoch tells
	// the cache these are not the entries it rendered
	other := burstFixture()
	other[4].ToolTrace.Changes[0].Path = "/w/albedo/cli/internal/tui/other.go"
	step("reset then same-sized different entries", func() {
		m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventReset})
		for _, entry := range other {
			m.appendSettledEntry(entry)
		}
	}, 1)
	m.SetSize(240, 30) // wide enough that the ran list is not counted away
	m.refreshViewportContent()
	entries = m.History.Entries()
	burst = trailingBurst(entries, m.Flags)
	want = strings.Join(m.Renderer.BurstBlock(entries[:len(entries)-len(burst)], burst, m.Flags), "\n")
	if got := strings.Join(m.burstRows, "\n"); got != want || !strings.Contains(got, "tui/other.go") {
		t.Fatalf("same-sized history after a reset kept the old frame:\n%s\nwant:\n%s", got, want)
	}
	// the same equivalence under the width and workspace the steps left behind
	entries = m.History.Entries()
	burst = trailingBurst(entries, m.Flags)
	want = strings.Join(m.Renderer.BurstBlock(entries[:len(entries)-len(burst)], burst, m.Flags), "\n")
	if strings.Join(m.burstRows, "\n") != want {
		t.Fatalf("after a reset the frame went stale:\n%s\nwant:\n%s", strings.Join(m.burstRows, "\n"), want)
	}
}

// Eviction swaps the head of history without changing its length, so the
// cached frame must key on evictions too or the live burst freezes.
func TestEvictionKeepsTheLiveBurstCurrent(t *testing.T) {
	m := newTestChatModel(t, &daemon.Session{ID: "s", Workspace: "/w"})
	m.SetSize(120, 30)
	m.History = NewBoundedHistory(4, 1<<20)
	for i := range 8 {
		m.appendSettledEntry(traced([]daemon.ToolActivity{{Kind: "read", Target: fmt.Sprintf("/w/f%d.go", i)}}))
		m.refreshViewportContent()
	}
	rows := strings.Join(m.burstRows, "\n")
	if !strings.Contains(rows, "f7}") || strings.Contains(rows, "f0") || strings.Contains(rows, "f3") {
		t.Fatalf("live burst froze across eviction: %q", rows)
	}
}
