package main

import (
	"fmt"
	"image"
	"image/png"
	"math"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"testing"
)

// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------

func mustTerm(t *testing.T, cols, rows int) *terminal {
	t.Helper()
	term, err := newTerminal(cols, rows)
	if err != nil {
		t.Fatalf("newTerminal(%d,%d): %v", cols, rows, err)
	}
	return term
}

// feed writes a string to a fresh grid and returns the terminal.
func feed(t *testing.T, cols, rows int, in string) *terminal {
	t.Helper()
	term := mustTerm(t, cols, rows)
	if _, err := term.Write([]byte(in)); err != nil {
		t.Fatalf("Write: %v", err)
	}
	return term
}

// lines returns the non-padded text of the first n rows.
func lines(term *terminal, n int) []string {
	out := make([]string, n)
	for i := 0; i < n; i++ {
		out[i] = term.lineText(i)
	}
	return out
}

// ---------------------------------------------------------------------------
// ANSI parsing, per sequence type
// ---------------------------------------------------------------------------

func TestANSISequences(t *testing.T) {
	cases := []struct {
		name      string
		cols      int
		rows      int
		in        string
		wantLines []string // checked against rows 0..len-1
		wantCX    int
		wantCY    int
	}{
		{
			name: "plain text",
			cols: 20, rows: 3,
			in:        "hello",
			wantLines: []string{"hello", "", ""},
			wantCX:    5, wantCY: 0,
		},
		{
			name: "LF returns the carriage (pipe-captured output)",
			cols: 20, rows: 3,
			in:        "abc\ndef",
			wantLines: []string{"abc", "def", ""},
			wantCX:    3, wantCY: 1,
		},
		{
			name: "explicit CRLF is not a double newline",
			cols: 20, rows: 3,
			in:        "abc\r\ndef",
			wantLines: []string{"abc", "def", ""},
			wantCX:    3, wantCY: 1,
		},
		{
			name: "CR overwrites in place",
			cols: 20, rows: 2,
			in:        "abcdef\rXY",
			wantLines: []string{"XYcdef", ""},
			wantCX:    2, wantCY: 0,
		},
		{
			name: "backspace moves left without erasing",
			cols: 20, rows: 2,
			in:        "abc\b\bZ",
			wantLines: []string{"aZc", ""},
			wantCX:    2, wantCY: 0,
		},
		{
			name: "backspace stops at column 0",
			cols: 20, rows: 2,
			in:        "\b\b\bQ",
			wantLines: []string{"Q", ""},
			wantCX:    1, wantCY: 0,
		},
		{
			name: "tab stops are every 8 columns",
			cols: 40, rows: 2,
			in:        "a\tb\tc",
			wantLines: []string{"a       b       c", ""},
			wantCX:    17, wantCY: 0,
		},
		{
			name: "tab clamps at the last column",
			cols: 10, rows: 2,
			in:        "abc\t\t\tZ",
			wantLines: []string{"abc      Z", ""},
			wantCX:    9, wantCY: 0,
		},
		{
			// After two newlines the cursor is at row 2 column 0; up two rows
			// lands on row 0 column 0, so the X lands on top of the "o".
			name: "CUU cursor up",
			cols: 20, rows: 4,
			in:        "one\ntwo\n\x1b[2AX",
			wantLines: []string{"Xne", "two", "", ""},
			wantCX:    1, wantCY: 0,
		},
		{
			name: "CUU stops at the top row",
			cols: 20, rows: 4,
			in:        "one\ntwo\n\x1b[99AX",
			wantLines: []string{"Xne", "two", "", ""},
			wantCX:    1, wantCY: 0,
		},
		{
			name: "CUD cursor down",
			cols: 20, rows: 4,
			in:        "\x1b[2BX",
			wantLines: []string{"", "", "X", ""},
			wantCX:    1, wantCY: 2,
		},
		{
			name: "CUF cursor forward",
			cols: 20, rows: 2,
			in:        "\x1b[5CX",
			wantLines: []string{"     X", ""},
			wantCX:    6, wantCY: 0,
		},
		{
			name: "CUB cursor back",
			cols: 20, rows: 2,
			in:        "abcdef\x1b[3DX",
			wantLines: []string{"abcXef", ""},
			wantCX:    4, wantCY: 0,
		},
		{
			name: "CUF with no parameter defaults to 1",
			cols: 20, rows: 2,
			in:        "\x1b[CX",
			wantLines: []string{" X", ""},
			wantCX:    2, wantCY: 0,
		},
		{
			name: "CUP is 1-based row;column",
			cols: 20, rows: 5,
			in:        "\x1b[3;5HX",
			wantLines: []string{"", "", "    X", "", ""},
			wantCX:    5, wantCY: 2,
		},
		{
			name: "CUP with no parameters homes the cursor",
			cols: 20, rows: 3,
			in:        "abc\ndef\x1b[HZ",
			wantLines: []string{"Zbc", "def", ""},
			wantCX:    1, wantCY: 0,
		},
		{
			name: "CUP clamps out-of-range coordinates",
			cols: 10, rows: 3,
			in:        "\x1b[99;99HX",
			wantLines: []string{"", "", "         X"},
			wantCX:    9, wantCY: 2,
		},
		{
			name: "HVP (f) behaves like CUP",
			cols: 20, rows: 4,
			in:        "\x1b[2;3fX",
			wantLines: []string{"", "  X", "", ""},
			wantCX:    3, wantCY: 1,
		},
		{
			name: "CHA sets the absolute column",
			cols: 20, rows: 2,
			in:        "abcdef\x1b[3GZ",
			wantLines: []string{"abZdef", ""},
			wantCX:    3, wantCY: 0,
		},
		{
			name: "VPA sets the absolute row",
			cols: 20, rows: 4,
			in:        "\x1b[3dX",
			wantLines: []string{"", "", "X", ""},
			wantCX:    1, wantCY: 2,
		},
		{
			name: "CNL moves down and to column 0",
			cols: 20, rows: 4,
			in:        "abcdef\x1b[2EX",
			wantLines: []string{"abcdef", "", "X", ""},
			wantCX:    1, wantCY: 2,
		},
		{
			name: "ED 0 erases from the cursor to the end of the screen",
			cols: 10, rows: 3,
			in:        "aaaa\nbbbb\ncccc\x1b[2;3H\x1b[0J",
			wantLines: []string{"aaaa", "bb", ""},
			wantCX:    2, wantCY: 1,
		},
		{
			name: "ED 1 erases from the start of the screen to the cursor",
			cols: 10, rows: 3,
			in:        "aaaa\nbbbb\ncccc\x1b[2;3H\x1b[1J",
			wantLines: []string{"", "   b", "cccc"},
			wantCX:    2, wantCY: 1,
		},
		{
			name: "ED 2 clears the whole screen and leaves the cursor alone",
			cols: 10, rows: 3,
			in:        "aaaa\nbbbb\ncccc\x1b[2;3H\x1b[2J",
			wantLines: []string{"", "", ""},
			wantCX:    2, wantCY: 1,
		},
		{
			name: "EL 0 erases to the end of the line",
			cols: 10, rows: 2,
			in:        "abcdefgh\x1b[4G\x1b[0K",
			wantLines: []string{"abc", ""},
			wantCX:    3, wantCY: 0,
		},
		{
			name: "EL 1 erases from the start of the line to the cursor",
			cols: 10, rows: 2,
			in:        "abcdefgh\x1b[4G\x1b[1K",
			wantLines: []string{"    efgh", ""},
			wantCX:    3, wantCY: 0,
		},
		{
			name: "EL 2 erases the whole line",
			cols: 10, rows: 2,
			in:        "abcdefgh\x1b[4G\x1b[2K",
			wantLines: []string{"", ""},
			wantCX:    3, wantCY: 0,
		},
		{
			name: "clear screen and home, the classic redraw",
			cols: 10, rows: 3,
			in:        "junk\njunk\n\x1b[2J\x1b[Hfresh",
			wantLines: []string{"fresh", "", ""},
			wantCX:    5, wantCY: 0,
		},
		{
			name: "progress bar redrawn with CR",
			cols: 20, rows: 2,
			in:        "\r[..] 0%\r[##] 100%",
			wantLines: []string{"[##] 100%", ""},
			wantCX:    9, wantCY: 0,
		},
		{
			name: "unsupported CSI is skipped without corrupting the grid",
			cols: 20, rows: 2,
			in:        "ab\x1b[2Scd",
			wantLines: []string{"abcd", ""},
			wantCX:    4, wantCY: 0,
		},
		{
			name: "private mode set is skipped without corrupting the grid",
			cols: 20, rows: 2,
			in:        "ab\x1b[?25lcd\x1b[?25h",
			wantLines: []string{"abcd", ""},
			wantCX:    4, wantCY: 0,
		},
		{
			name: "OSC title is swallowed up to BEL",
			cols: 20, rows: 2,
			in:        "\x1b]0;my window title\x07ok",
			wantLines: []string{"ok", ""},
			wantCX:    2, wantCY: 0,
		},
		{
			name: "OSC terminated by ESC backslash",
			cols: 20, rows: 2,
			in:        "\x1b]0;title\x1b\\ok",
			wantLines: []string{"ok", ""},
			wantCX:    2, wantCY: 0,
		},
		{
			name: "charset designation is swallowed whole",
			cols: 20, rows: 2,
			in:        "\x1b(Bok",
			wantLines: []string{"ok", ""},
			wantCX:    2, wantCY: 0,
		},
		{
			name: "scroll drops the top line when output runs past the bottom",
			cols: 10, rows: 3,
			in:        "l1\nl2\nl3\nl4",
			wantLines: []string{"l2", "l3", "l4"},
			wantCX:    2, wantCY: 2,
		},
		{
			name: "non-ASCII rune occupies exactly one cell",
			cols: 10, rows: 2,
			in:        "aéb",
			wantLines: []string{"aéb", ""},
			wantCX:    3, wantCY: 0,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			term := feed(t, tc.cols, tc.rows, tc.in)
			got := lines(term, len(tc.wantLines))
			if !reflect.DeepEqual(got, tc.wantLines) {
				t.Errorf("grid mismatch\n got: %q\nwant: %q", got, tc.wantLines)
			}
			if term.cx != tc.wantCX || term.cy != tc.wantCY {
				t.Errorf("cursor = (%d,%d), want (%d,%d)", term.cx, term.cy, tc.wantCX, tc.wantCY)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// Line wrapping, at the exact column
// ---------------------------------------------------------------------------

func TestWrappingAtExactColumn(t *testing.T) {
	const cols = 10
	cases := []struct {
		name      string
		in        string
		wantLines []string
		wantCX    int
		wantCY    int
	}{
		{
			name:      "one short of the width does not wrap",
			in:        strings.Repeat("x", cols-1),
			wantLines: []string{"xxxxxxxxx", ""},
			wantCX:    cols - 1, wantCY: 0,
		},
		{
			name:      "exactly the width fills the row and does not open a new one",
			in:        strings.Repeat("x", cols),
			wantLines: []string{"xxxxxxxxxx", ""},
			wantCX:    cols - 1, wantCY: 0, // parked in the last column, wrap pending
		},
		{
			name:      "one past the width puts exactly one character on the next row",
			in:        strings.Repeat("x", cols) + "Y",
			wantLines: []string{"xxxxxxxxxx", "Y"},
			wantCX:    1, wantCY: 1,
		},
		{
			name:      "two full rows",
			in:        strings.Repeat("a", cols) + strings.Repeat("b", cols),
			wantLines: []string{"aaaaaaaaaa", "bbbbbbbbbb"},
			wantCX:    cols - 1, wantCY: 1,
		},
		{
			name:      "a newline right after a full row does not leave a blank row",
			in:        strings.Repeat("x", cols) + "\nZ",
			wantLines: []string{"xxxxxxxxxx", "Z"},
			wantCX:    1, wantCY: 1,
		},
		{
			name:      "a carriage return cancels the pending wrap",
			in:        strings.Repeat("x", cols) + "\rZ",
			wantLines: []string{"Zxxxxxxxxx", ""},
			wantCX:    1, wantCY: 0,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			term := feed(t, cols, 4, tc.in)
			got := lines(term, len(tc.wantLines))
			if !reflect.DeepEqual(got, tc.wantLines) {
				t.Errorf("grid mismatch\n got: %q\nwant: %q", got, tc.wantLines)
			}
			if term.cx != tc.wantCX || term.cy != tc.wantCY {
				t.Errorf("cursor = (%d,%d), want (%d,%d)", term.cx, term.cy, tc.wantCX, tc.wantCY)
			}
		})
	}
}

func TestWrappingScrollsAtTheBottom(t *testing.T) {
	// Three rows of 4 columns: 13 characters must scroll exactly once.
	term := feed(t, 4, 3, strings.Repeat("0123", 3)+"Z")
	want := []string{"0123", "0123", "Z"}
	if got := lines(term, 3); !reflect.DeepEqual(got, want) {
		t.Fatalf("got %q, want %q", got, want)
	}
}

// ---------------------------------------------------------------------------
// SGR colour and attribute state
// ---------------------------------------------------------------------------

func TestSGRState(t *testing.T) {
	cases := []struct {
		name      string
		in        string // the last printed character is the one inspected
		wantFG    termColor
		wantBG    termColor
		wantAttrs uint8
	}{
		{
			name: "no SGR leaves both colours at the theme default",
			in:   "x",
		},
		{
			name:   "SGR 31 sets ANSI red",
			in:     "\x1b[31mx",
			wantFG: indexedColor(1),
		},
		{
			name:   "SGR 42 sets ANSI green background",
			in:     "\x1b[42mx",
			wantBG: indexedColor(2),
		},
		{
			name:   "SGR 93 sets a bright foreground as index 11",
			in:     "\x1b[93mx",
			wantFG: indexedColor(11),
		},
		{
			name:   "SGR 104 sets a bright background as index 12",
			in:     "\x1b[104mx",
			wantBG: indexedColor(12),
		},
		{
			name:      "SGR 1 sets bold",
			in:        "\x1b[1mx",
			wantAttrs: attrBold,
		},
		{
			name:      "SGR 2 sets dim",
			in:        "\x1b[2mx",
			wantAttrs: attrDim,
		},
		{
			name:      "SGR 7 sets reverse",
			in:        "\x1b[7mx",
			wantAttrs: attrReverse,
		},
		{
			name:      "SGR 4 sets underline",
			in:        "\x1b[4mx",
			wantAttrs: attrUnderline,
		},
		{
			name:      "compound SGR sets everything at once",
			in:        "\x1b[1;4;33;44mx",
			wantFG:    indexedColor(3),
			wantBG:    indexedColor(4),
			wantAttrs: attrBold | attrUnderline,
		},
		{
			name:      "SGR 22 clears bold but keeps colour",
			in:        "\x1b[1;31m\x1b[22mx",
			wantFG:    indexedColor(1),
			wantAttrs: 0,
		},
		{
			name:      "SGR 27 clears reverse only",
			in:        "\x1b[7;4m\x1b[27mx",
			wantAttrs: attrUnderline,
		},
		{
			name: "SGR 0 resets everything",
			in:   "\x1b[1;4;7;31;42m\x1b[0mx",
		},
		{
			name: "SGR m with no parameters is a reset",
			in:   "\x1b[1;31m\x1b[mx",
		},
		{
			name:   "SGR 39 restores the default foreground only",
			in:     "\x1b[31;42m\x1b[39mx",
			wantBG: indexedColor(2),
		},
		{
			name:   "SGR 49 restores the default background only",
			in:     "\x1b[31;42m\x1b[49mx",
			wantFG: indexedColor(1),
		},
		{
			name:   "256-colour foreground, semicolon form",
			in:     "\x1b[38;5;208mx",
			wantFG: indexedColor(208),
		},
		{
			name:   "256-colour background, semicolon form",
			in:     "\x1b[48;5;17mx",
			wantBG: indexedColor(17),
		},
		{
			name:   "256-colour foreground, colon sub-parameter form",
			in:     "\x1b[38:5:196mx",
			wantFG: indexedColor(196),
		},
		{
			name:   "truecolour foreground, semicolon form",
			in:     "\x1b[38;2;255;120;180mx",
			wantFG: rgbColor(255, 120, 180),
		},
		{
			name:   "truecolour background, semicolon form",
			in:     "\x1b[48;2;12;34;56mx",
			wantBG: rgbColor(12, 34, 56),
		},
		{
			name:   "truecolour foreground, colon form with a colour space id",
			in:     "\x1b[38:2::12:34:56mx",
			wantFG: rgbColor(12, 34, 56),
		},
		{
			name:      "truecolour combined with bold and a 256-colour background",
			in:        "\x1b[1;38;2;10;20;30;48;5;99mx",
			wantFG:    rgbColor(10, 20, 30),
			wantBG:    indexedColor(99),
			wantAttrs: attrBold,
		},
		{
			name:   "an unknown SGR parameter does not disturb the ones around it",
			in:     "\x1b[31;53;42mx",
			wantFG: indexedColor(1),
			wantBG: indexedColor(2),
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			term := feed(t, 10, 2, tc.in)
			got := *term.at(0, 0)
			if got.Ch != 'x' {
				t.Fatalf("expected 'x' at (0,0), got %q", got.Ch)
			}
			if got.FG != tc.wantFG {
				t.Errorf("FG = %+v, want %+v", got.FG, tc.wantFG)
			}
			if got.BG != tc.wantBG {
				t.Errorf("BG = %+v, want %+v", got.BG, tc.wantBG)
			}
			if got.Attrs != tc.wantAttrs {
				t.Errorf("Attrs = %08b, want %08b", got.Attrs, tc.wantAttrs)
			}
		})
	}
}

func TestSGRColoursSurviveIntoPixels(t *testing.T) {
	// The whole point of the colour model is that it reaches the PNG, so check
	// the resolved pixel colours rather than only the cell state.
	term := feed(t, 4, 1, "\x1b[38;2;255;120;180mA\x1b[0m\x1b[38;5;46mB\x1b[0m\x1b[31mC")
	th := themeDark
	cases := []struct {
		x    int
		want rgb
	}{
		{0, rgb{255, 120, 180}}, // truecolour, verbatim
		{1, rgb{0, 255, 0}},     // 256-colour index 46 = cube (0,5,0)
		{2, palette256(1)},      // ANSI red
		{3, th.FG},              // untouched cell keeps the theme default
	}
	for _, tc := range cases {
		fg, _ := th.cellColors(*term.at(0, tc.x))
		if fg != tc.want {
			t.Errorf("cell %d foreground = %+v, want %+v", tc.x, fg, tc.want)
		}
	}
	// Reverse video must swap the pair, not merely flip a flag.
	rev := feed(t, 2, 1, "\x1b[7;31mZ")
	fg, bg := th.cellColors(*rev.at(0, 0))
	if fg != th.BG || bg != palette256(1) {
		t.Errorf("reverse video gave fg=%+v bg=%+v, want fg=%+v bg=%+v", fg, bg, th.BG, palette256(1))
	}
}

func TestPalette256(t *testing.T) {
	cases := []struct {
		idx  uint8
		want rgb
	}{
		{0, rgb{0, 0, 0}},
		{9, rgb{255, 0, 0}},
		{16, rgb{0, 0, 0}},        // first cube entry
		{46, rgb{0, 255, 0}},      // cube (0,5,0)
		{196, rgb{255, 0, 0}},     // cube (5,0,0)
		{231, rgb{255, 255, 255}}, // last cube entry
		{232, rgb{8, 8, 8}},       // first grey
		{255, rgb{238, 238, 238}}, // last grey
	}
	for _, tc := range cases {
		if got := palette256(tc.idx); got != tc.want {
			t.Errorf("palette256(%d) = %+v, want %+v", tc.idx, got, tc.want)
		}
	}
}

// ---------------------------------------------------------------------------
// Resumability: the state at T does not depend on how you got there
// ---------------------------------------------------------------------------

func TestResumeMatchesSinglePass(t *testing.T) {
	stream := "\x1b[1;36mheader\x1b[0m\n" +
		"line two with \x1b[38;5;208mcolour\x1b[0m\n" +
		"\ttabbed\ttext\n" +
		"progress \r[####] 100%\n" +
		strings.Repeat("wrapping text that runs past the right hand edge ", 4) +
		"\x1b[2;3H\x1b[7mreverse\x1b[0m\x1b[5;1H\x1b[0Kdone\n"

	for _, cut := range []int{1, 7, 23, 60, 137, len(stream) - 1} {
		if cut <= 0 || cut >= len(stream) {
			continue
		}
		t.Run(fmt.Sprintf("cut-at-%d", cut), func(t *testing.T) {
			onePass := feed(t, 24, 8, stream)

			resumed := mustTerm(t, 24, 8)
			resumed.Write([]byte(stream[:cut]))
			checkpoint := resumed.clone() // pretend we saved and reloaded here
			checkpoint.Write([]byte(stream[cut:]))

			if !reflect.DeepEqual(onePass.snapshot(), checkpoint.snapshot()) {
				t.Errorf("resuming at byte %d produced a different grid\none pass:\n%s\nresumed:\n%s",
					cut, onePass.text(), checkpoint.text())
			}
		})
	}
}

func TestCloneIsIndependent(t *testing.T) {
	a := feed(t, 10, 3, "hello")
	b := a.clone()
	b.Write([]byte(" world"))
	if got := a.lineText(0); got != "hello" {
		t.Errorf("writing to the clone changed the original: %q", got)
	}
	if got := b.lineText(0); got != "hello worl" {
		t.Errorf("clone = %q, want %q", got, "hello worl")
	}
}

func TestReplayToIsResumable(t *testing.T) {
	c := syntheticCast()
	const cols, rows = 40, 10

	full, err := replayTo(c, cols, rows, math.Inf(1))
	if err != nil {
		t.Fatal(err)
	}
	// Replay to a midpoint, then feed the remaining events by hand.
	mid := 2.0
	partial, err := replayTo(c, cols, rows, mid)
	if err != nil {
		t.Fatal(err)
	}
	resumed := partial.clone()
	for _, ev := range c.Events {
		if ev.Time > mid {
			resumed.Write(ev.Data)
		}
	}
	if !reflect.DeepEqual(full.snapshot(), resumed.snapshot()) {
		t.Errorf("resumed replay differs from a single pass\nfull:\n%s\nresumed:\n%s",
			full.text(), resumed.text())
	}
}

func TestReplayToRespectsTheTimestamp(t *testing.T) {
	c := syntheticCast()
	cases := []struct {
		at    float64
		want0 string
	}{
		{-1, ""},
		{0.0, "boot: stage one"},
		{0.5, "boot: stage one"},
		{3.0, "boot: stage one"},
	}
	for _, tc := range cases {
		term, err := replayTo(c, 40, 10, tc.at)
		if err != nil {
			t.Fatal(err)
		}
		if got := term.lineText(0); got != tc.want0 {
			t.Errorf("at %.1fs row 0 = %q, want %q", tc.at, got, tc.want0)
		}
	}
}

// ---------------------------------------------------------------------------
// Moment finding
// ---------------------------------------------------------------------------

// syntheticCast has three deliberate bursts separated by long silences, plus a
// pair of events 20ms apart that must be treated as ONE burst, not two.
func syntheticCast() *cast {
	return &cast{
		Path:   "synthetic.jsonl",
		Header: castHeader{Version: 1, Width: 40, Height: 10},
		Events: []castEvent{
			// burst A at t=0
			{Time: 0.000, Stream: "o", Data: []byte("boot: stage one\n")},
			{Time: 0.020, Stream: "o", Data: []byte("boot: stage two\n")},
			// long silence
			// burst B at t=2.0: a single short line
			{Time: 2.000, Stream: "o", Data: []byte("checking disks\n")},
			// long silence
			// burst C at t=5.0: a full-screen repaint
			{Time: 5.000, Stream: "o", Data: []byte("\x1b[2J\x1b[H")},
			{Time: 5.010, Stream: "o", Data: []byte(strings.Repeat("SUMMARY LINE OF TEXT\n", 8))},
		},
		Footer: &castFooter{ExitCode: 0, Duration: 5.5},
	}
}

func TestFindMomentsGroupsBursts(t *testing.T) {
	c := syntheticCast()
	ms, _, err := findMoments(c, 40, 10, defaultQuiet)
	if err != nil {
		t.Fatal(err)
	}
	if len(ms) != 3 {
		t.Fatalf("expected 3 bursts, got %d: %+v", len(ms), ms)
	}
	byTime := topMoments(ms, 0)
	wantAt := []float64{0.020, 2.000, 5.010}
	for i, m := range byTime {
		if math.Abs(m.At-wantAt[i]) > 1e-9 {
			t.Errorf("burst %d settled at %v, want %v", i, m.At, wantAt[i])
		}
	}
	if byTime[0].Events != 2 {
		t.Errorf("the two events 20ms apart should form one burst, got %d events", byTime[0].Events)
	}
	if byTime[2].Events != 2 {
		t.Errorf("the clear and the repaint should form one burst, got %d events", byTime[2].Events)
	}
}

func TestFindMomentsQuietThresholdSplitsBursts(t *testing.T) {
	c := syntheticCast()
	// A threshold below the 20ms gap must split the first burst in two.
	ms, _, err := findMoments(c, 40, 10, 0.005)
	if err != nil {
		t.Fatal(err)
	}
	if len(ms) != 5 {
		t.Fatalf("with --quiet 0.005 every event is its own burst: want 5, got %d", len(ms))
	}
}

func TestFindMomentsRanksAndPreviews(t *testing.T) {
	c := syntheticCast()
	ms, _, err := findMoments(c, 40, 10, defaultQuiet)
	if err != nil {
		t.Fatal(err)
	}
	for i, m := range ms {
		if m.Rank != i+1 {
			t.Errorf("moment %d has rank %d", i, m.Rank)
		}
		if i > 0 && ms[i-1].Score < m.Score {
			t.Errorf("moments are not sorted by descending score: %v then %v", ms[i-1].Score, m.Score)
		}
	}
	// The full-screen repaint changes the most cells, so it must rank first.
	if math.Abs(ms[0].At-5.010) > 1e-9 {
		t.Errorf("top-ranked moment is at %v, want the repaint at 5.010", ms[0].At)
	}
	if !strings.Contains(ms[0].Preview, "SUMMARY LINE OF TEXT") {
		t.Errorf("preview of the repaint = %q, want it to mention the text that appeared", ms[0].Preview)
	}
	var atTwo moment
	for _, m := range ms {
		if math.Abs(m.At-2.0) < 1e-9 {
			atTwo = m
		}
	}
	if atTwo.Preview != "checking disks" {
		t.Errorf("preview at 2.0s = %q, want %q", atTwo.Preview, "checking disks")
	}
	// "checking disks" is 14 characters, but the space lands on an already
	// blank cell, so only 13 cells actually differ. The change score counts
	// cells that changed, not bytes that arrived.
	if atTwo.Changed != 13 {
		t.Errorf("burst at 2.0s changed %d cells, want 13", atTwo.Changed)
	}
}

func TestBurstScoreCompressesLargeRepaints(t *testing.T) {
	const total = 1920
	small := burstScore(60, total, defaultQuiet, defaultQuiet)
	full := burstScore(total, total, defaultQuiet, defaultQuiet)
	if full <= small {
		t.Fatalf("a full repaint (%v) should still outscore a single line (%v)", full, small)
	}
	// The weighting exists so that 32x the changed cells is NOT 32x the score.
	if ratio := full / small; ratio > 2.0 {
		t.Errorf("full repaint scores %.2fx a single line; the log weighting should keep it under 2x", ratio)
	}
	// A longer pause after the burst must raise the score.
	if burstScore(60, total, 4*defaultQuiet, defaultQuiet) <= small {
		t.Error("a longer quiet interval should raise the settle score")
	}
}

func TestFindMomentsIgnoresInvisibleEvents(t *testing.T) {
	c := &cast{
		Header: castHeader{Version: 1, Width: 20, Height: 5},
		Events: []castEvent{
			{Time: 0.0, Stream: "o", Data: []byte("visible\n")},
			{Time: 1.0, Stream: "o", Data: []byte("\x1b[?25l")}, // cursor hide: nothing changes
			{Time: 2.0, Stream: "o", Data: []byte("")},
		},
	}
	ms, _, err := findMoments(c, 20, 5, defaultQuiet)
	if err != nil {
		t.Fatal(err)
	}
	if len(ms) != 1 {
		t.Fatalf("only the first burst changes the screen: want 1 moment, got %d (%+v)", len(ms), ms)
	}
}

func TestTopMomentsPicksByScoreAndOrdersByTime(t *testing.T) {
	all := []moment{
		{Rank: 1, At: 9, Score: 90},
		{Rank: 2, At: 2, Score: 80},
		{Rank: 3, At: 5, Score: 70},
		{Rank: 4, At: 1, Score: 10},
	}
	got := topMoments(all, 3)
	if len(got) != 3 {
		t.Fatalf("want 3, got %d", len(got))
	}
	wantAt := []float64{2, 5, 9}
	for i := range got {
		if got[i].At != wantAt[i] {
			t.Fatalf("got times %v, want %v", []float64{got[0].At, got[1].At, got[2].At}, wantAt)
		}
	}
}

// ---------------------------------------------------------------------------
// PNG output
// ---------------------------------------------------------------------------

// decodePNG reads a PNG back off disk and reports its size and whether it has
// more than one distinct colour in it.
func decodePNG(t *testing.T, path string) (image.Image, int) {
	t.Helper()
	f, err := os.Open(path)
	if err != nil {
		t.Fatalf("cannot open %s: %v", path, err)
	}
	defer f.Close()
	img, format, err := image.Decode(f)
	if err != nil {
		t.Fatalf("%s does not decode as an image: %v", path, err)
	}
	if format != "png" {
		t.Fatalf("%s decoded as %q, want png", path, format)
	}
	seen := map[uint32]bool{}
	b := img.Bounds()
	for y := b.Min.Y; y < b.Max.Y; y++ {
		for x := b.Min.X; x < b.Max.X; x++ {
			r, g, bl, _ := img.At(x, y).RGBA()
			seen[(r>>8)<<16|(g>>8)<<8|(bl>>8)] = true
		}
	}
	return img, len(seen)
}

func TestRenderedPNGDecodesWithExpectedGeometry(t *testing.T) {
	term := feed(t, 20, 5, "\x1b[1;31mALERT\x1b[0m\ndisk \x1b[38;2;0;200;255mfull\x1b[0m\n")
	cases := []struct {
		name  string
		opts  renderOpts
		wantW int
		wantH int
	}{
		{"scale 1, no padding", renderOpts{Scale: 1, Pad: 0, Theme: themeDark}, 20 * cellW, 5 * cellH},
		{"scale 1, 8px padding", renderOpts{Scale: 1, Pad: 8, Theme: themeDark}, 20*cellW + 16, 5*cellH + 16},
		{"scale 3, 4px padding", renderOpts{Scale: 3, Pad: 4, Theme: themeDark}, (20*cellW + 8) * 3, (5*cellH + 8) * 3},
		{"light theme", renderOpts{Scale: 2, Pad: 2, Theme: themeLight}, (20*cellW + 4) * 2, (5*cellH + 4) * 2},
	}
	dir := t.TempDir()
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			img, err := renderGrid(term, tc.opts)
			if err != nil {
				t.Fatal(err)
			}
			path := filepath.Join(dir, strings.ReplaceAll(tc.name, " ", "_")+".png")
			if err := writePNG(path, img, false); err != nil {
				t.Fatal(err)
			}
			got, colours := decodePNG(t, path)
			if got.Bounds().Dx() != tc.wantW || got.Bounds().Dy() != tc.wantH {
				t.Errorf("decoded %dx%d, want %dx%d",
					got.Bounds().Dx(), got.Bounds().Dy(), tc.wantW, tc.wantH)
			}
			if colours < 3 {
				t.Errorf("image has only %d distinct colours; a rendered frame with red and "+
					"truecolour text should not be near-uniform", colours)
			}
			if fi, err := os.Stat(path); err != nil || fi.Size() == 0 {
				t.Errorf("PNG on disk is empty or missing: %v", err)
			}
		})
	}
}

func TestRenderedPNGActuallyContainsGlyphInk(t *testing.T) {
	blank := mustTerm(t, 8, 2)
	withText := feed(t, 8, 2, "HELLO")
	opts := renderOpts{Scale: 1, Pad: 0, Theme: themeDark}

	b, err := renderGrid(blank, opts)
	if err != nil {
		t.Fatal(err)
	}
	w, err := renderGrid(withText, opts)
	if err != nil {
		t.Fatal(err)
	}
	diff := 0
	for i := range b.Pix {
		if b.Pix[i] != w.Pix[i] {
			diff++
		}
	}
	if diff == 0 {
		t.Fatal("rendering text produced pixel-identical output to a blank grid")
	}
	// A blank dark frame must be uniform; the text frame must not be.
	if n := distinctColours(b); n != 1 {
		t.Errorf("blank frame has %d colours, want exactly 1", n)
	}
	if n := distinctColours(w); n < 2 {
		t.Errorf("text frame has %d colours, want at least 2", n)
	}
}

func distinctColours(img *image.RGBA) int {
	seen := map[uint32]bool{}
	b := img.Bounds()
	for y := b.Min.Y; y < b.Max.Y; y++ {
		for x := b.Min.X; x < b.Max.X; x++ {
			c := img.RGBAAt(x, y)
			seen[uint32(c.R)<<16|uint32(c.G)<<8|uint32(c.B)] = true
		}
	}
	return len(seen)
}

func TestContactSheetDecodes(t *testing.T) {
	c := syntheticCast()
	ms, _, err := findMoments(c, 40, 10, defaultQuiet)
	if err != nil {
		t.Fatal(err)
	}
	picked := topMoments(ms, 3)
	img, frames, err := buildContactSheet(c, picked, 40, 10, themeDark, 2, 200)
	if err != nil {
		t.Fatal(err)
	}
	if len(frames) != 3 {
		t.Fatalf("want 3 frame reports, got %d", len(frames))
	}
	for i, f := range frames {
		if f.N != i+1 {
			t.Errorf("frame %d numbered %d", i, f.N)
		}
	}
	path := filepath.Join(t.TempDir(), "sheet.png")
	if err := writePNG(path, img, false); err != nil {
		t.Fatal(err)
	}
	got, colours := decodePNG(t, path)
	if got.Bounds().Dx() != img.Bounds().Dx() || got.Bounds().Dy() != img.Bounds().Dy() {
		t.Errorf("decoded %v, want %v", got.Bounds(), img.Bounds())
	}
	if colours < 3 {
		t.Errorf("contact sheet has only %d distinct colours", colours)
	}
	if got.Bounds().Dx() < 2*200 {
		t.Errorf("sheet is %dpx wide, too narrow for 2 thumbnails of 200px", got.Bounds().Dx())
	}
}

func TestDownscaleKeepsInkVisible(t *testing.T) {
	term := feed(t, 40, 10, strings.Repeat("MMMM ", 8)+"\n")
	full, err := renderGrid(term, renderOpts{Scale: 1, Pad: 0, Theme: themeDark})
	if err != nil {
		t.Fatal(err)
	}
	small := downscale(full, 120, 30)
	if small.Bounds().Dx() != 120 || small.Bounds().Dy() != 30 {
		t.Fatalf("downscale produced %v", small.Bounds())
	}
	if n := distinctColours(small); n < 2 {
		t.Errorf("downscaled thumbnail is uniform (%d colours); box averaging should keep the ink", n)
	}
}

// ---------------------------------------------------------------------------
// Output-file safety
// ---------------------------------------------------------------------------

func TestWritePNGRefusesToOverwrite(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "frame.png")
	term := feed(t, 4, 2, "hi")
	img, err := renderGrid(term, renderOpts{Scale: 1, Pad: 0, Theme: themeDark})
	if err != nil {
		t.Fatal(err)
	}
	if err := writePNG(path, img, false); err != nil {
		t.Fatalf("first write failed: %v", err)
	}
	before, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}

	err = writePNG(path, img, false)
	if err == nil {
		t.Fatal("writePNG overwrote an existing file without --force")
	}
	if !strings.Contains(err.Error(), "already exists") || !strings.Contains(err.Error(), "--force") {
		t.Errorf("error %q should name the file and mention --force", err)
	}
	after, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	if string(before) != string(after) {
		t.Error("the refused write still changed the file on disk")
	}

	// Different content plus --force must actually replace it.
	other := feed(t, 4, 2, "\x1b[41mZZ")
	img2, err := renderGrid(other, renderOpts{Scale: 1, Pad: 0, Theme: themeDark})
	if err != nil {
		t.Fatal(err)
	}
	if err := writePNG(path, img2, true); err != nil {
		t.Fatalf("--force write failed: %v", err)
	}
	forced, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	if string(forced) == string(before) {
		t.Error("--force did not replace the file contents")
	}
	if _, n := decodePNG(t, path); n < 2 {
		t.Error("the forced file is not a valid multi-colour PNG")
	}

	// No stray temporary files may be left behind.
	entries, err := os.ReadDir(dir)
	if err != nil {
		t.Fatal(err)
	}
	for _, e := range entries {
		if strings.HasPrefix(e.Name(), ".capturestudio-") {
			t.Errorf("temporary file %s was left behind", e.Name())
		}
	}
}

// ---------------------------------------------------------------------------
// Size guards
// ---------------------------------------------------------------------------

func TestAbsurdDimensionsFailCleanly(t *testing.T) {
	if _, err := newTerminal(100000, 100000); err == nil {
		t.Error("a 100000x100000 grid should be refused")
	} else if !strings.Contains(err.Error(), "limit") {
		t.Errorf("error %q should mention the limit", err)
	}
	if _, err := newTerminal(0, 24); err == nil {
		t.Error("a zero-width grid should be refused")
	}

	term := mustTerm(t, 1000, 1000)
	_, err := renderGrid(term, renderOpts{Scale: 32, Pad: 0, Theme: themeDark})
	if err == nil {
		t.Fatal("a 1000x1000 grid at scale 32 should be refused, not allocated")
	}
	if !strings.Contains(err.Error(), "--scale") {
		t.Errorf("error %q should tell the user which knob to turn", err)
	}

	if err := (renderOpts{Scale: 0, Pad: 0}).validate(); err == nil {
		t.Error("scale 0 should be rejected")
	}
	if err := (renderOpts{Scale: 1, Pad: -1}).validate(); err == nil {
		t.Error("negative padding should be rejected")
	}
	if err := (renderOpts{Scale: maxScale + 1, Pad: 0}).validate(); err == nil {
		t.Error("scale above the maximum should be rejected")
	}
}

// ---------------------------------------------------------------------------
// Cast reading (SessionForge interoperability)
// ---------------------------------------------------------------------------

func TestLoadCastReadsSessionForgeFormat(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "session.jsonl")
	content := `{"version":1,"command":["sh","-c","demo"],"started_at":"2026-08-10T04:10:29Z","shell":"/bin/bash","width":100,"height":30,"title":"paused demo"}
[0.001227, "o", "first\n"]
[1.002926, "e", "warning\n"]
[3.004161, "o", "AAECAwQF", "b64"]
{"exit_code":0,"duration":3.004391188}
`
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
		t.Fatal(err)
	}
	c, err := loadCast(path)
	if err != nil {
		t.Fatalf("loadCast: %v", err)
	}
	if c.Header.Width != 100 || c.Header.Height != 30 {
		t.Errorf("header size = %dx%d, want 100x30", c.Header.Width, c.Header.Height)
	}
	if c.Header.Title != "paused demo" {
		t.Errorf("title = %q", c.Header.Title)
	}
	if len(c.Events) != 3 {
		t.Fatalf("want 3 events, got %d", len(c.Events))
	}
	if c.Events[1].Stream != "e" || string(c.Events[1].Data) != "warning\n" {
		t.Errorf("stderr event = %+v", c.Events[1])
	}
	if want := []byte{0, 1, 2, 3, 4, 5}; string(c.Events[2].Data) != string(want) {
		t.Errorf("b64 event decoded to %v, want %v", c.Events[2].Data, want)
	}
	if c.Footer == nil || c.Footer.ExitCode != 0 {
		t.Errorf("footer = %+v", c.Footer)
	}
	if got := c.duration(); math.Abs(got-3.004161) > 1e-9 {
		t.Errorf("duration = %v", got)
	}
	out, errb := c.byteCounts()
	if out != int64(len("first\n")+6) || errb != int64(len("warning\n")) {
		t.Errorf("byte counts = (%d, %d)", out, errb)
	}
	cols, rows := c.gridSize(0, 0)
	if cols != 100 || rows != 30 {
		t.Errorf("gridSize from header = %dx%d", cols, rows)
	}
	if cols, rows = c.gridSize(40, 0); cols != 40 || rows != 30 {
		t.Errorf("gridSize with a column override = %dx%d", cols, rows)
	}

	// The cast must still be byte-identical after being read.
	again, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	if string(again) != content {
		t.Error("loadCast modified the input file")
	}
}

func TestLoadCastRejectsGarbage(t *testing.T) {
	dir := t.TempDir()
	cases := []struct {
		name    string
		content string
		wantSub string
	}{
		{"empty file", "", "no cast header"},
		{"no header", "[0.1, \"o\", \"hi\"]\n", "expected a header object"},
		{"wrong version", "{\"version\":9}\n", "unsupported cast version"},
		{"bad stream tag", "{\"version\":1}\n[0.1, \"x\", \"hi\"]\n", "unknown stream tag"},
		{"short event", "{\"version\":1}\n[0.1, \"o\"]\n", "at least 3 fields"},
		{"bad base64", "{\"version\":1}\n[0.1, \"o\", \"!!!!\", \"b64\"]\n", "base64"},
		{"junk record", "{\"version\":1}\nnope\n", "unrecognised record"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			path := filepath.Join(dir, strings.ReplaceAll(tc.name, " ", "_")+".jsonl")
			if err := os.WriteFile(path, []byte(tc.content), 0o644); err != nil {
				t.Fatal(err)
			}
			_, err := loadCast(path)
			if err == nil {
				t.Fatalf("expected an error for %s", tc.name)
			}
			if !strings.Contains(err.Error(), tc.wantSub) {
				t.Errorf("error %q should contain %q", err, tc.wantSub)
			}
		})
	}
	if _, err := loadCast(filepath.Join(dir, "definitely-absent.jsonl")); err == nil {
		t.Error("a missing cast should be an error")
	}
}

// ---------------------------------------------------------------------------
// Strict reporting
// ---------------------------------------------------------------------------

func TestStrictRecordsWhatWasSkipped(t *testing.T) {
	term := feed(t, 20, 3, "ok\x1b[?25l\x1b[2S\x1b[2S\x1b[5mblink")
	if term.skippedTotal() != 4 {
		t.Errorf("skippedTotal = %d, want 4", term.skippedTotal())
	}
	report := strings.Join(term.skippedReport(), "\n")
	for _, want := range []string{"CSI S", "SGR 5", "private mode"} {
		if !strings.Contains(report, want) {
			t.Errorf("report %q should mention %q", report, want)
		}
	}
	// The most frequent entry must be listed first.
	if !strings.HasPrefix(term.skippedReport()[0], "CSI S") {
		t.Errorf("report is not sorted by frequency: %v", term.skippedReport())
	}
	// A clean stream must report nothing.
	clean := feed(t, 20, 3, "\x1b[1;32mall good\x1b[0m\n\x1b[2J\x1b[H")
	if clean.skippedTotal() != 0 {
		t.Errorf("clean stream reported %d skips: %v", clean.skippedTotal(), clean.skippedReport())
	}
}

func TestSkippedSequencesDoNotCorruptTheGrid(t *testing.T) {
	// Every unsupported form must leave the visible text exactly as if it were
	// not there at all.
	noise := []string{
		"\x1b[?1049h", "\x1b[2S", "\x1b[1T", "\x1b[5m", "\x1b]0;title\x07",
		"\x1b(B", "\x1bM", "\x1b[6n", "\x1b[" + strings.Repeat("9", 200) + "m",
	}
	for _, n := range noise {
		t.Run(strings.ReplaceAll(n, "\x1b", "ESC"), func(t *testing.T) {
			got := feed(t, 20, 3, "ab"+n+"cd").lineText(0)
			if got != "abcd" {
				t.Errorf("noise %q corrupted the line: %q", n, got)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// Font
// ---------------------------------------------------------------------------

func TestFontCoversPrintableASCII(t *testing.T) {
	if len(fontBits) != 95 {
		t.Fatalf("font has %d glyphs, want 95", len(fontBits))
	}
	for r := rune(0x21); r <= 0x7e; r++ { // space is legitimately blank
		rows := glyphRows(r)
		ink := false
		for _, b := range rows {
			if b != 0 {
				ink = true
			}
			if b&^0x1f != 0 {
				t.Fatalf("glyph %q has bits set outside the %d-pixel width: %08b", r, glyphW, b)
			}
		}
		if !ink {
			t.Errorf("glyph %q is blank", r)
		}
	}
	if glyphRows(' ') != fontBits[0] {
		t.Error("space should map to the first table entry")
	}
	for _, r := range []rune{'é', '█', '\U0001f600'} {
		if glyphRows(r) != fontFallback {
			t.Errorf("rune %q should use the missing-glyph box", r)
		}
	}
	// Glyphs must be distinct enough to read: no two letters may be identical.
	seen := map[[8]uint8]rune{}
	for r := rune(0x21); r <= 0x7e; r++ {
		rows := glyphRows(r)
		if other, dup := seen[rows]; dup {
			t.Errorf("glyphs %q and %q are pixel-identical", other, r)
		}
		seen[rows] = r
	}
}

// ---------------------------------------------------------------------------
// CLI helpers
// ---------------------------------------------------------------------------

func TestReorderFlags(t *testing.T) {
	cases := []struct {
		name string
		in   []string
		want []string
	}{
		{"already ordered", []string{"--at", "1.5", "cast.jsonl"}, []string{"--at", "1.5", "cast.jsonl"}},
		{"flags after the positional", []string{"cast.jsonl", "--at", "1.5"}, []string{"--at", "1.5", "cast.jsonl"}},
		{"boolean flag last", []string{"cast.jsonl", "--json"}, []string{"--json", "cast.jsonl"}},
		{"mixed", []string{"--scale", "3", "cast.jsonl", "--force", "--out", "a.png"},
			[]string{"--scale", "3", "--force", "--out", "a.png", "cast.jsonl"}},
		{"value flag with no value", []string{"cast.jsonl", "--out"}, []string{"--out", "cast.jsonl"}},
		{"no flags", []string{"cast.jsonl"}, []string{"cast.jsonl"}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := reorderFlags(tc.in, valueFlags)
			if !reflect.DeepEqual(got, tc.want) {
				t.Errorf("got %q, want %q", got, tc.want)
			}
		})
	}
}

func TestParseAt(t *testing.T) {
	const dur = 10.0
	cases := []struct {
		in      string
		want    float64
		wantErr bool
	}{
		{"0", 0, false},
		{"12.5", 12.5, false},
		{"12.5s", 12.5, false},
		{"  3 ", 3, false},
		{"50%", 5, false},
		{"100%", 10, false},
		{"start", 0, false},
		{"end", 10, false},
		{"last", 10, false},
		{"-1", 0, true},
		{"abc", 0, true},
		{"", 0, true},
		{"150%", 0, true},
		{"NaN", 0, true},
	}
	for _, tc := range cases {
		got, err := parseAt(tc.in, dur)
		if tc.wantErr {
			if err == nil {
				t.Errorf("parseAt(%q) should have failed", tc.in)
			}
			continue
		}
		if err != nil {
			t.Errorf("parseAt(%q): %v", tc.in, err)
			continue
		}
		if math.Abs(got-tc.want) > 1e-9 {
			t.Errorf("parseAt(%q) = %v, want %v", tc.in, got, tc.want)
		}
	}
}

func TestParseAtList(t *testing.T) {
	got, err := parseAtList([]string{"0,25%", "end"}, 8)
	if err != nil {
		t.Fatal(err)
	}
	want := []float64{0, 2, 8}
	if !reflect.DeepEqual(got, want) {
		t.Errorf("got %v, want %v", got, want)
	}
	if _, err := parseAtList([]string{"1,nope"}, 8); err == nil {
		t.Error("a bad token in the list should fail the whole list")
	}
}

func TestHumanBytes(t *testing.T) {
	cases := []struct {
		in   int64
		want string
	}{
		{0, "0 B"},
		{999, "999 B"},
		{1023, "1023 B"},
		{1024, "1.0 KiB"},
		{1536, "1.5 KiB"},
		{1048576, "1.0 MiB"},
		{1073741824, "1.0 GiB"},
	}
	for _, tc := range cases {
		if got := humanBytes(tc.in); got != tc.want {
			t.Errorf("humanBytes(%d) = %q, want %q", tc.in, got, tc.want)
		}
	}
}

func TestLookupTheme(t *testing.T) {
	if th, err := lookupTheme("dark"); err != nil || th.Name != "dark" {
		t.Errorf("dark: %+v %v", th, err)
	}
	if th, err := lookupTheme("light"); err != nil || th.Name != "light" {
		t.Errorf("light: %+v %v", th, err)
	}
	if _, err := lookupTheme("solarized"); err == nil {
		t.Error("an unknown theme should be an error")
	}
	if themeDark.BG == themeLight.BG {
		t.Error("the two themes should not share a background")
	}
}

func TestTruncateTo(t *testing.T) {
	cases := []struct {
		in   string
		n    int
		want string
	}{
		{"hello", 10, "hello"},
		{"hello", 5, "hello"},
		{"hello world", 8, "hello..."},
		{"hello", 2, "he"},
		{"hello", 0, ""},
	}
	for _, tc := range cases {
		if got := truncateTo(tc.in, tc.n); got != tc.want {
			t.Errorf("truncateTo(%q,%d) = %q, want %q", tc.in, tc.n, got, tc.want)
		}
	}
}

func TestCondenseAndStripANSI(t *testing.T) {
	if got := condense(stripANSI("\x1b[1;31m  hello   world  \x1b[0m\n")); got != "hello world" {
		t.Errorf("got %q", got)
	}
	if got := stripANSI("\x1b]0;title\x07after"); got != "after" {
		t.Errorf("OSC not stripped: %q", got)
	}
	long := strings.Repeat("abcdefghij", 12)
	got := condense(long)
	if len([]rune(got)) > 68 || !strings.HasSuffix(got, "...") {
		t.Errorf("condense did not truncate: %q (%d runes)", got, len([]rune(got)))
	}
}

func init() {
	// image.Decode needs the PNG decoder registered; the blank import would be
	// unused otherwise, so reference it explicitly.
	_ = png.Encode
}
