// Tests for autotable.go.
package webui

import (
	"bytes"
	"encoding/csv"
	"errors"
	"math"
	"reflect"
	"strconv"
	"strings"
	"testing"
	"time"
)

// ==========================================================================
// Data pipeline: filter, sort, paginate
// ==========================================================================

type person struct {
	Name   string
	Email  string
	Age    int
	Salary string
	Status string
	Hired  time.Time
	Rank   string `json:"rank_label"`
}

func people() []any {
	return []any{
		person{Name: "Ada", Email: "ada@x.com", Age: 36, Salary: "$1,200.50", Status: "active", Hired: date("2020-03-01"), Rank: "Item 10"},
		person{Name: "Grace", Email: "grace@y.com", Age: 45, Salary: "$980.00", Status: "inactive", Hired: date("2018-07-15"), Rank: "Item 2"},
		person{Name: "alan", Email: "alan@x.com", Age: 41, Salary: "$1,500.00", Status: "active", Hired: date("2021-01-20"), Rank: "Item 1"},
		person{Name: "", Email: "ghost@z.com", Age: 0, Salary: "", Status: "", Hired: time.Time{}, Rank: ""},
	}
}

func date(s string) time.Time {
	t, _ := time.Parse("2006-01-02", s)
	return t
}

func names(rows []any) []string {
	out := make([]string, len(rows))
	for i, r := range rows {
		out[i] = r.(person).Name
	}
	return out
}

// The default (single value, not exact) is a case-insensitive SUBSTRING match.
func TestSearchContains(t *testing.T) {
	got := ApplySearchFilters(people(), []AutoTableSearchEntry{
		{Identifier: "Name", Values: []string{"a"}},
	}, nil)
	if want := []string{"Ada", "Grace", "alan"}; !reflect.DeepEqual(names(got), want) {
		t.Errorf("contains: got %v, want %v", names(got), want)
	}

	// Case-insensitive both ways.
	got = ApplySearchFilters(people(), []AutoTableSearchEntry{
		{Identifier: "Name", Values: []string{"ALAN"}},
	}, nil)
	if want := []string{"alan"}; !reflect.DeepEqual(names(got), want) {
		t.Errorf("case-insensitive: got %v, want %v", names(got), want)
	}
}

// Exact means equality, not substring.
func TestSearchExact(t *testing.T) {
	got := ApplySearchFilters(people(), []AutoTableSearchEntry{
		{Identifier: "Status", Values: []string{"active"}, Exact: true},
	}, nil)
	if want := []string{"Ada", "alan"}; !reflect.DeepEqual(names(got), want) {
		t.Errorf("exact: got %v, want %v", names(got), want)
	}

	// Without Exact, "active" would also match "inactive" — the distinction that
	// makes Exact worth having.
	got = ApplySearchFilters(people(), []AutoTableSearchEntry{
		{Identifier: "Status", Values: []string{"active"}},
	}, nil)
	if len(got) != 3 {
		t.Errorf("substring search matched %d rows, want 3 (active + inactive)", len(got))
	}
}

// Several values in one entry = an IN-set test (what a multi-select produces),
// and it is exact, not substring.
func TestSearchInSet(t *testing.T) {
	got := ApplySearchFilters(people(), []AutoTableSearchEntry{
		{Identifier: "Status", Values: []string{"active", "inactive"}},
	}, nil)
	if len(got) != 3 {
		t.Errorf("IN-set matched %d rows, want 3", len(got))
	}
	if got := ApplySearchFilters(people(), []AutoTableSearchEntry{
		{Identifier: "Name", Values: []string{"Ada", "Grace"}},
	}, nil); len(got) != 2 {
		t.Errorf("IN-set on Name matched %d, want 2", len(got))
	}
}

// A multi-search identifier ORs across fields — the global search box.
func TestMultiSearchOrsAcrossFields(t *testing.T) {
	id := MultiSearchIdentifier("Name", "Email")
	got := ApplySearchFilters(people(), []AutoTableSearchEntry{
		{Identifier: id, Values: []string{"y.com"}},
	}, nil)
	if want := []string{"Grace"}; !reflect.DeepEqual(names(got), want) {
		t.Errorf("multi-search on email: got %v, want %v", names(got), want)
	}

	// Matches on EITHER field.
	got = ApplySearchFilters(people(), []AutoTableSearchEntry{
		{Identifier: id, Values: []string{"ada"}},
	}, nil)
	if want := []string{"Ada"}; !reflect.DeepEqual(names(got), want) {
		t.Errorf("multi-search on name: got %v, want %v", names(got), want)
	}
}

// Entries AND together.
func TestSearchEntriesAnd(t *testing.T) {
	got := ApplySearchFilters(people(), []AutoTableSearchEntry{
		{Identifier: "Status", Values: []string{"active"}, Exact: true},
		{Identifier: "Name", Values: []string{"ad"}},
	}, nil)
	if want := []string{"Ada"}; !reflect.DeepEqual(names(got), want) {
		t.Errorf("got %v, want %v", names(got), want)
	}
}

// An empty search box must not filter everything away.
func TestEmptySearchIsInert(t *testing.T) {
	for _, s := range [][]AutoTableSearchEntry{
		nil,
		{{Identifier: "Name", Values: nil}},
		{{Identifier: "Name", Values: []string{""}}},
		{{Identifier: "Name", Values: []string{"   "}}},
		{{Identifier: "", Values: []string{"x"}}},
	} {
		if got := ApplySearchFilters(people(), s, nil); len(got) != 4 {
			t.Errorf("search %+v filtered to %d rows, want all 4", s, len(got))
		}
	}
}

func TestFieldReaderStructTagsAndCase(t *testing.T) {
	row := people()[0]
	if got := DefaultFieldReader(row, "Name"); got != "Ada" {
		t.Errorf("by field name: %v", got)
	}
	if got := DefaultFieldReader(row, "rank_label"); got != "Item 10" {
		t.Errorf("by json tag: %v", got)
	}
	if got := DefaultFieldReader(row, "name"); got != "Ada" {
		t.Errorf("case-insensitive: %v", got)
	}
	if got := DefaultFieldReader(row, "nope"); got != nil {
		t.Errorf("missing field: %v, want nil", got)
	}
	if got := DefaultFieldReader(map[string]any{"k": 1}, "k"); got != 1 {
		t.Errorf("map: %v", got)
	}
}

func TestSortStringsAndDirection(t *testing.T) {
	cols := []AutoTableColumn{{SortIdentifier: "Name", Sortable: true}}

	asc := SortRows(people(), AutoTableOrderBy{Identifier: "Name"}, cols, nil)
	// Case-insensitive: "Ada" < "alan" < "Grace". The blank sorts last.
	if want := []string{"Ada", "alan", "Grace", ""}; !reflect.DeepEqual(names(asc), want) {
		t.Errorf("asc: got %v, want %v", names(asc), want)
	}

	desc := SortRows(people(), AutoTableOrderBy{Identifier: "Name", Descending: true}, cols, nil)
	// Reversed — but the blank STILL sorts last, not first.
	if want := []string{"Grace", "alan", "Ada", ""}; !reflect.DeepEqual(names(desc), want) {
		t.Errorf("desc: got %v, want %v", names(desc), want)
	}
}

// The empties-last rule is the one people get wrong, so pin it explicitly.
func TestEmptiesAlwaysSortLast(t *testing.T) {
	cols := []AutoTableColumn{{SortIdentifier: "Salary", SortType: SortTypeMoney}}
	for _, desc := range []bool{false, true} {
		got := SortRows(people(), AutoTableOrderBy{Identifier: "Salary", Descending: desc}, cols, nil)
		if last := got[len(got)-1].(person).Name; last != "" {
			t.Errorf("descending=%v: last row is %q, want the empty one", desc, last)
		}
	}
}

func TestSortNumeric(t *testing.T) {
	cols := []AutoTableColumn{{SortIdentifier: "Rank", SortType: SortTypeNumeric}}
	got := SortRows(people(), AutoTableOrderBy{Identifier: "Rank"}, cols, nil)
	// Item 1 < Item 2 < Item 10. A plain string sort gives 1, 10, 2 — and so did the
	// TSX, whose parseInt returns NaN on anything not starting with a digit.
	want := []string{"alan", "Grace", "Ada", ""}
	if !reflect.DeepEqual(names(got), want) {
		t.Errorf("numeric sort: got %v, want %v", names(got), want)
	}
}

// Natural ordering, pinned directly — this is where we deliberately diverge from
// the TSX, so it is worth being explicit about what it does.
func TestCompareNatural(t *testing.T) {
	cases := []struct {
		a, b string
		want int
	}{
		{"2", "10", -1},           // plain numbers: agrees with the TSX's parseInt
		{"Item 2", "Item 10", -1}, // embedded numbers: the TSX got this backwards
		{"A9", "A10", -1},
		{"a", "b", -1},
		{"file10", "file9", 1},
		{"x", "x", 0},
		{"item", "item2", -1}, // a prefix sorts first
		// "1.5" splits into the runs 1 | . | 5, so it compares like a VERSION, not a
		// decimal: 1.5 < 1.10. Decimal strings want SortTypeMoney (or a real numeric
		// field) — this is inherent to natural ordering, not a bug.
		{"1.5", "1.10", -1},
	}
	for _, c := range cases {
		if got := sign(compareNatural(c.a, c.b)); got != c.want {
			t.Errorf("compareNatural(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
		}
		if got := sign(compareNatural(c.b, c.a)); got != -c.want {
			t.Errorf("compareNatural(%q, %q) = %d, want %d (not antisymmetric)", c.b, c.a, got, -c.want)
		}
	}
}

func sign(n int) int {
	switch {
	case n < 0:
		return -1
	case n > 0:
		return 1
	}
	return 0
}

func TestSortMoney(t *testing.T) {
	cols := []AutoTableColumn{{SortIdentifier: "Salary", SortType: SortTypeMoney}}
	got := SortRows(people(), AutoTableOrderBy{Identifier: "Salary"}, cols, nil)
	// $980 < $1,200.50 < $1,500 — a string sort would put "$1,200.50" first.
	if want := []string{"Grace", "Ada", "alan", ""}; !reflect.DeepEqual(names(got), want) {
		t.Errorf("money sort: got %v, want %v", names(got), want)
	}
}

func TestSortNativeTypes(t *testing.T) {
	cols := []AutoTableColumn{{SortIdentifier: "Age"}, {SortIdentifier: "Hired"}}

	got := SortRows(people(), AutoTableOrderBy{Identifier: "Age"}, cols, nil)
	if want := []string{"", "Ada", "alan", "Grace"}; !reflect.DeepEqual(names(got), want) {
		// Age 0 is not "empty" for an int — only nil/""/zero-time are. So it sorts
		// numerically first, which is right: 0 is a real age reading.
		t.Errorf("int sort: got %v, want %v", names(got), want)
	}

	got = SortRows(people(), AutoTableOrderBy{Identifier: "Hired"}, cols, nil)
	if want := []string{"Grace", "Ada", "alan", ""}; !reflect.DeepEqual(names(got), want) {
		t.Errorf("time sort: got %v, want %v (zero time sorts last)", names(got), want)
	}
}

func TestSortValueOverride(t *testing.T) {
	// Sort by a key that is not a field at all: a status rank.
	rank := map[string]int{"active": 0, "inactive": 1, "": 2}
	cols := []AutoTableColumn{{
		SortIdentifier: "status_rank",
		SortValue:      func(row any) any { return rank[row.(person).Status] },
	}}
	got := SortRows(people(), AutoTableOrderBy{Identifier: "status_rank"}, cols, nil)
	if first := got[0].(person).Status; first != "active" {
		t.Errorf("SortValue override ignored: first row status = %q", first)
	}
}

func TestPositionalIdentifier(t *testing.T) {
	cols := []AutoTableColumn{{SortIdentifier: PositionalIdentifier(0)}}
	got := SortRows(people(), AutoTableOrderBy{Identifier: PositionalIdentifier(0)}, cols, nil)
	// Field 0 is Name.
	if want := []string{"Ada", "alan", "Grace", ""}; !reflect.DeepEqual(names(got), want) {
		t.Errorf("positional sort: got %v, want %v", names(got), want)
	}
}

func TestSortIsStable(t *testing.T) {
	rows := []any{
		person{Name: "b", Status: "x"},
		person{Name: "a", Status: "1"},
		person{Name: "a", Status: "2"},
	}
	got := SortRows(rows, AutoTableOrderBy{Identifier: "Name"}, nil, nil)
	if got[0].(person).Status != "1" || got[1].(person).Status != "2" {
		t.Error("equal rows lost their original relative order")
	}
}

func TestPaginate(t *testing.T) {
	rows := make([]any, 12)
	for i := range rows {
		rows[i] = person{Name: string(rune('a' + i))}
	}

	page, p := Paginate(rows, AutoTablePagination{CurrentPage: 2, MaxItemsPerPage: 5})
	if len(page) != 5 || page[0].(person).Name != "f" {
		t.Errorf("page 2 = %v", names(page))
	}
	if p.TotalPages != 3 || p.TotalItems != 12 {
		t.Errorf("totals: pages=%d items=%d, want 3/12", p.TotalPages, p.TotalItems)
	}
	if p.ViewRangeLower != 6 || p.ViewRangeUpper != 10 {
		t.Errorf("view range = %d-%d, want 6-10", p.ViewRangeLower, p.ViewRangeUpper)
	}

	// The last page is short.
	page, p = Paginate(rows, AutoTablePagination{CurrentPage: 3, MaxItemsPerPage: 5})
	if len(page) != 2 || p.ViewRangeUpper != 12 {
		t.Errorf("last page: %d rows, upper=%d", len(page), p.ViewRangeUpper)
	}

	// "All".
	page, p = Paginate(rows, AutoTablePagination{CurrentPage: 1, MaxItemsPerPage: PageSizeAll})
	if len(page) != 12 || p.TotalPages != 1 {
		t.Errorf("PageSizeAll: %d rows over %d pages, want 12/1", len(page), p.TotalPages)
	}
}

// A filter that shrinks the result set can strand CurrentPage past the end.
// Showing an empty table there would look like "no results" — clamp instead.
func TestPaginateClampsAnOutOfRangePage(t *testing.T) {
	rows := []any{person{Name: "a"}, person{Name: "b"}}
	page, p := Paginate(rows, AutoTablePagination{CurrentPage: 9, MaxItemsPerPage: 5})
	if p.CurrentPage != 1 || len(page) != 2 {
		t.Errorf("page %d with %d rows, want page 1 with 2 rows", p.CurrentPage, len(page))
	}
}

func TestPaginateEmpty(t *testing.T) {
	page, p := Paginate(nil, AutoTablePagination{CurrentPage: 1, MaxItemsPerPage: 10})
	if len(page) != 0 || p.TotalPages != 1 || p.ViewRangeLower != 0 || p.ViewRangeUpper != 0 {
		t.Errorf("empty: %d rows, pages=%d, range=%d-%d", len(page), p.TotalPages, p.ViewRangeLower, p.ViewRangeUpper)
	}
}

// The whole pipeline, and the reason allFiltered is returned separately: export
// must see everything that matched, not just the page on screen.
func TestProcessLocally(t *testing.T) {
	cols := []AutoTableColumn{{SortIdentifier: "Name", Sortable: true}}
	filter := AutoTableFilter{
		Search:     []AutoTableSearchEntry{{Identifier: "Status", Values: []string{"active"}, Exact: true}},
		OrderBy:    AutoTableOrderBy{Identifier: "Name", Descending: true},
		Pagination: AutoTablePagination{CurrentPage: 1, MaxItemsPerPage: 1},
	}
	page, all, p := ProcessLocally(people(), filter, cols, nil)

	if len(all) != 2 {
		t.Fatalf("allFiltered = %d rows, want 2 (both active)", len(all))
	}
	if len(page) != 1 || page[0].(person).Name != "alan" {
		t.Errorf("page = %v, want [alan] (descending)", names(page))
	}
	if p.TotalPages != 2 || p.TotalItems != 2 {
		t.Errorf("pagination = %d pages / %d items, want 2/2", p.TotalPages, p.TotalItems)
	}
}

func TestBuildQueryString(t *testing.T) {
	filter := AutoTableFilter{
		Search: []AutoTableSearchEntry{
			{Identifier: "status", Values: []string{"active", "pending"}},
			{Identifier: "name", Values: []string{"ada"}, Exact: true},
			{Identifier: "blank", Values: []string{"  "}},
		},
		OrderBy:    AutoTableOrderBy{Identifier: "name", Descending: true},
		Pagination: AutoTablePagination{CurrentPage: 3, MaxItemsPerPage: 25},
	}

	got := BuildQueryString(filter, false)
	for _, want := range []string{
		"status=active", "status=pending", "name=ada", "name_exact=true",
		"order_by=name", "order_desc=true", "page_num=3", "items_per_page=25",
	} {
		if !contains(got, want) {
			t.Errorf("query %q missing %q", got, want)
		}
	}
	if contains(got, "blank") {
		t.Errorf("query %q included a blank search value", got)
	}

	// Export: the whole filtered set, not the current page.
	got = BuildQueryString(filter, true)
	if !contains(got, "items_per_page=-1") || contains(got, "page_num") {
		t.Errorf("export query = %q, want no paging", got)
	}
}

func contains(haystack, needle string) bool {
	return len(haystack) >= len(needle) && (func() bool {
		for i := 0; i+len(needle) <= len(haystack); i++ {
			if haystack[i:i+len(needle)] == needle {
				return true
			}
		}
		return false
	})()
}

// ==========================================================================
// Column management and export column selection
// ==========================================================================

func testCols() []AutoTableColumn {
	return []AutoTableColumn{
		{Key: "name", DisplayName: "Name"}, // not toggleable: pinned on
		{Key: "email", DisplayName: "Email", Toggleable: true},
		{Key: "age", DisplayName: "Age", Toggleable: true},
		{Key: "notes", DisplayName: "Notes", Toggleable: true, HiddenByDefault: true},
	}
}

func newCols() *AutoTableState {
	return NewAutoTableState(testCols(), AutoTableStateOptions{
		Columns: AutoTableColumnOptions{Toggleable: true, Draggable: true, Resizable: true},
	})
}

func keys(cols []AutoTableColumn) []string {
	out := make([]string, len(cols))
	for i, c := range cols {
		out[i] = ColumnKey(c, i)
	}
	return out
}

func TestVisibleColumnsRespectsHiddenByDefault(t *testing.T) {
	s := newCols()
	if want := []string{"name", "email", "age"}; !reflect.DeepEqual(keys(s.VisibleColumns()), want) {
		t.Errorf("got %v, want %v (notes is HiddenByDefault)", keys(s.VisibleColumns()), want)
	}
}

func TestToggleColumn(t *testing.T) {
	s := newCols()

	s.ToggleColumn("email")
	if want := []string{"name", "age"}; !reflect.DeepEqual(keys(s.VisibleColumns()), want) {
		t.Errorf("after hiding email: got %v, want %v", keys(s.VisibleColumns()), want)
	}

	s.ToggleColumn("email")
	if want := []string{"name", "email", "age"}; !reflect.DeepEqual(keys(s.VisibleColumns()), want) {
		t.Errorf("after re-showing email: got %v, want %v", keys(s.VisibleColumns()), want)
	}

	// A non-toggleable column cannot be hidden — hiding the identifying column would
	// leave rows unrecognisable.
	s.ToggleColumn("name")
	if !contains2(keys(s.VisibleColumns()), "name") {
		t.Error("a non-toggleable column was hidden")
	}
}

// Moving an item that sits BEFORE its destination is the off-by-one trap: removing
// it first shifts the destination left by one.
func TestMoveColumn(t *testing.T) {
	cases := []struct {
		name     string
		from, to string
		want     []string
	}{
		{"forward", "name", "age", []string{"email", "age", "name", "notes"}},
		{"backward", "age", "name", []string{"age", "name", "email", "notes"}},
		{"onto itself", "email", "email", []string{"name", "email", "age", "notes"}},
		{"to the end", "name", "notes", []string{"email", "age", "notes", "name"}},
		{"to the front", "notes", "name", []string{"notes", "name", "email", "age"}},
		{"adjacent forward", "name", "email", []string{"email", "name", "age", "notes"}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			s := newCols()
			s.MoveColumn(c.from, c.to)
			if got := s.currentOrder(); !reflect.DeepEqual(got, c.want) {
				t.Errorf("move %s -> %s: got %v, want %v", c.from, c.to, got, c.want)
			}
		})
	}
}

func TestMoveColumnIgnoresUnknownKeys(t *testing.T) {
	s := newCols()
	before := s.currentOrder()
	s.MoveColumn("nope", "name")
	s.MoveColumn("name", "nope")
	s.MoveColumn("", "")
	if got := s.currentOrder(); !reflect.DeepEqual(got, before) {
		t.Errorf("order changed on an unknown key: %v", got)
	}
}

// A persisted order outlives the code that wrote it. A column deleted since must
// not resurrect, and one added since must still appear.
func TestVisibleColumnsToleratesStalePersistedOrder(t *testing.T) {
	s := newCols()
	s.order.Set([]string{"age", "gone", "name"}) // "gone" no longer exists; "email"/"notes" are new

	got := keys(s.VisibleColumns())
	if contains2(got, "gone") {
		t.Errorf("a deleted column came back from storage: %v", got)
	}
	if !contains2(got, "email") {
		t.Errorf("a column missing from the saved order was dropped: %v", got)
	}
	// The saved order still governs the columns it does name.
	if got[0] != "age" || got[1] != "name" {
		t.Errorf("saved order not honoured: %v", got)
	}
}

func TestResetColumns(t *testing.T) {
	s := newCols()
	s.MoveColumn("age", "name")
	s.ToggleColumn("email")
	s.widths.Set(map[string]float64{"name": 200})

	s.ResetColumns()

	if want := []string{"name", "email", "age"}; !reflect.DeepEqual(keys(s.VisibleColumns()), want) {
		t.Errorf("after reset: got %v, want %v", keys(s.VisibleColumns()), want)
	}
	if s.ColumnWidth("name") != 0 {
		t.Errorf("width survived the reset: %v", s.ColumnWidth("name"))
	}
	// HiddenByDefault is part of the DEFAULT, so reset restores it rather than
	// showing everything.
	if !s.HiddenColumns()["notes"] {
		t.Error("reset unhid a HiddenByDefault column")
	}
}

// The order and hidden state drive what an export writes, so VisibleColumns has to
// be the single source of truth for both rendering and export.
func TestVisibleColumnsAfterReorderAndHide(t *testing.T) {
	s := newCols()
	s.ToggleColumn("notes") // show it
	s.MoveColumn("notes", "name")
	s.ToggleColumn("age") // hide it

	if want := []string{"notes", "name", "email"}; !reflect.DeepEqual(keys(s.VisibleColumns()), want) {
		t.Errorf("got %v, want %v", keys(s.VisibleColumns()), want)
	}
}

func contains2(xs []string, x string) bool {
	for _, v := range xs {
		if v == x {
			return true
		}
	}
	return false
}

// An export must write what the user is looking at: the VISIBLE columns, in the
// order they dragged them into, plus any calculated columns — not the declared
// column list. Exporting a hidden column, or losing a running total, is a bug you
// only discover once the spreadsheet is open.
func TestExportUsesVisibleAndCalculatedColumns(t *testing.T) {
	type rec struct{ Name, Secret, Amount string }
	cols := []AutoTableColumn{
		{Key: "name", DisplayName: "Name", SortIdentifier: "Name", CSV: true},
		{Key: "secret", DisplayName: "Secret", SortIdentifier: "Secret", CSV: true, Toggleable: true},
		{Key: "amount", DisplayName: "Amount", SortIdentifier: "Amount", CSV: true},
	}
	s := NewAutoTableState(cols, AutoTableStateOptions{
		PerPage: 1, // only one row is ON SCREEN; the export must still write both
		Columns: AutoTableColumnOptions{Toggleable: true},
		Calculated: []UserCalculatedColumn{{
			ID: "running", DisplayName: "Running", Fn: CALC_FN_CUSTOM,
			Formula: "SUM({Amount:1:ROW()})", DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
		}},
	})
	s.SetRows([]any{
		rec{"Ada", "hush", "10"},
		rec{"Grace", "hush", "5"},
	})
	s.ToggleColumn("secret") // hide it
	s.Render()               // resolves FilteredRows

	got := string(ExportCSV(s.ExportColumns(), s.FilteredRows(), s.read))

	if strings.Contains(got, "Secret") || strings.Contains(got, "hush") {
		t.Errorf("a hidden column was exported:\n%s", got)
	}
	if !strings.Contains(got, "Running") {
		t.Errorf("the calculated column was not exported:\n%s", got)
	}
	// Both rows, not just the one page.
	if !strings.Contains(got, "Ada") || !strings.Contains(got, "Grace") {
		t.Errorf("export covered only the current page:\n%s", got)
	}
	// The running total must actually accumulate — 10, then 15. An index-free
	// CSVValue would have written the same number twice.
	if !strings.Contains(got, ",10\n") || !strings.Contains(got, ",15\n") {
		t.Errorf("running total did not accumulate across exported rows:\n%s", got)
	}
}

// ==========================================================================
// The formula engine
// ==========================================================================

// The fixture every reference test resolves against: three rows, one non-numeric
// column (Label) so blank/unreadable values get exercised too.
//
//	Revenue: 100  200  300     (sum 600)
//	Cost:     60  150  100
//	Qty:       2    4    4     (mode 4, median 4)
type sale struct {
	Revenue float64
	Cost    float64
	Qty     int
	Label   string
}

func saleRows() []any {
	return []any{
		sale{Revenue: 100, Cost: 60, Qty: 2, Label: "a"},
		sale{Revenue: 200, Cost: 150, Qty: 4, Label: "b"},
		sale{Revenue: 300, Cost: 100, Qty: 4, Label: "c"},
	}
}

func saleColumns() []AutoTableColumn {
	return []AutoTableColumn{
		{DisplayName: "Revenue", SortIdentifier: "Revenue"},
		{DisplayName: "Cost", SortIdentifier: "Cost"},
		{DisplayName: "Qty", SortIdentifier: "Qty"},
		{DisplayName: "Label", SortIdentifier: "Label"},
	}
}

// saleCtx is a context over the fixture with no current row (a summary's view).
func saleCtx(calcs ...UserCalculatedColumn) *CalcContext {
	return NewCalcContext(saleRows(), saleColumns(), calcs, nil)
}

// nearly compares results tolerantly, and treats NaN as equal to NaN (which is
// how the engine reports "not computable").
func nearly(a, b float64) bool {
	if math.IsNaN(a) || math.IsNaN(b) {
		return math.IsNaN(a) && math.IsNaN(b)
	}
	return math.Abs(a-b) <= 1e-9*math.Max(1, math.Abs(b))
}

// evalOK evaluates and fails the test on any error.
func evalOK(t *testing.T, src string, ctx FormulaContext) float64 {
	t.Helper()
	n, err := EvalFormula(src, ctx)
	if err != nil {
		t.Fatalf("EvalFormula(%q): unexpected error: %v", src, err)
	}
	return n
}

func TestFormulaOperatorPrecedence(t *testing.T) {
	tests := []struct {
		src  string
		want float64
	}{
		{"1 + 2 * 3", 7},
		{"(1 + 2) * 3", 9},
		{"10 - 2 - 3", 5},   // + - are left-associative
		{"100 / 5 / 2", 10}, // so are * / %
		{"8 / 2 * 3", 12},
		{"2 ^ 3 ^ 2", 512}, // ^ is right-associative
		{"4 ^ 0.5", 2},
		{"2 ^ -1", 0.5},
		{"10 % 3", 1},   // % is the remainder, not a percentage
		{"-10 % 3", -1}, // ... and takes the sign of the dividend
		{"2 + 3 * 4 ^ 2 - 1", 49},
		{"1 + 2 < 4", 1}, // comparison binds loosest
		{"2 * 3 = 6", 1}, //
		{"1 < 2 < 3", 1}, // (1<2) -> 1, then 1 < 3
		{"2 * (3 + 4)", 14},
	}
	for _, tt := range tests {
		if got := evalOK(t, tt.src, nil); !nearly(got, tt.want) {
			t.Errorf("%s = %v, want %v", tt.src, got, tt.want)
		}
	}
}

func TestFormulaUnaryMinus(t *testing.T) {
	tests := []struct {
		src  string
		want float64
	}{
		{"-5", -5},
		{"- -4", 4},
		{"+5", 5},
		{"-(2 + 3)", -5},
		{"3 - -2", 5},
		{"-2 ^ 2", 4}, // unary binds TIGHTER than ^, as in Excel: (-2)^2
		{"-(2 ^ 2)", -4},
		{"-PI", -math.Pi},
		{"-ABS(-3)", -3},
	}
	for _, tt := range tests {
		if got := evalOK(t, tt.src, nil); !nearly(got, tt.want) {
			t.Errorf("%s = %v, want %v", tt.src, got, tt.want)
		}
	}
}

func TestFormulaComparisons(t *testing.T) {
	tests := []struct {
		src  string
		want float64
	}{
		{"5 > 3", 1},
		{"5 < 3", 0},
		{"5 = 5", 1},
		{"5 = 4", 0},
		{"5 <> 4", 1},
		{"5 <> 5", 0},
		{"5 >= 5", 1},
		{"5 <= 4", 0},
		{"(3 > 1) * 10", 10}, // 1/0, so it feeds straight back into arithmetic
	}
	for _, tt := range tests {
		if got := evalOK(t, tt.src, nil); !nearly(got, tt.want) {
			t.Errorf("%s = %v, want %v", tt.src, got, tt.want)
		}
	}

	// A NaN operand makes a comparison FALSE (0), not NaN: a blank cell fails a
	// test rather than poisoning the whole formula.
	ctx := saleCtx().ForRow(0)
	if got := evalOK(t, "[Label] = [Label]", ctx); got != 0 {
		t.Errorf("[Label] = [Label] = %v, want 0 (NaN compares false)", got)
	}
	if got := evalOK(t, "[Label] > 0", ctx); got != 0 {
		t.Errorf("[Label] > 0 = %v, want 0", got)
	}
}

func TestFormulaFunctions(t *testing.T) {
	ctx := saleCtx().ForRow(1) // Revenue 200, Cost 150, Qty 4 -- ROW() is 2

	tests := []struct {
		src  string
		want float64
	}{
		// Aggregates: they flatten {Column} arrays and skip non-numbers.
		{"SUM({Revenue})", 600},
		{"SUM(1, 2, 3)", 6},
		{"SUM({Revenue}, 400)", 1000},
		{"SUM()", math.NaN()},
		{"AVERAGE({Revenue})", 200},
		{"AVG(2, 4)", 3}, // AVERAGE's alias
		{"MEDIAN(1, 2, 3, 4)", 2.5},
		{"MEDIAN({Qty})", 4},
		{"MODE(1, 2, 2, 3)", 2},
		{"MODE(1, 2, 3)", math.NaN()}, // nothing repeats -> no mode
		{"MIN({Revenue})", 100},
		{"MAX({Revenue})", 300},
		{"COUNT({Revenue})", 3},
		{"COUNT({Label})", 0}, // no numbers in the column
		{"COUNT()", 0},        // ... and COUNT is the one aggregate that is 0, not NaN
		{"COUNT(1, [Label], 3)", 2},

		// Math.
		{"ABS(-3)", 3},
		{"ABS()", math.NaN()}, // a missing argument is NaN
		{"ROUND(3.7)", 4},
		{"ROUND(3.14159, 3)", 3.142},
		{"ROUND(2.5)", 3},
		{"ROUND(-2.5)", -2}, // JS's Math.round: a half goes UP, not away from zero
		{"FLOOR(2.7)", 2},
		{"FLOOR(-2.1)", -3},
		{"CEILING(2.1)", 3},
		{"CEIL(2.1)", 3}, // CEILING's alias
		{"SQRT(16)", 4},
		{"POWER(2, 10)", 1024},
		{"MOD(7, 3)", 1},
		{"MOD(-7, 3)", -1},
		{"EXP(0)", 1},
		{"EXP(1)", math.E},
		{"LN(E)", 1},
		{"LOG(100)", 2}, // base 10 by default, as in Excel
		{"LOG(8, 2)", 3},

		// Trigonometry (radians).
		{"SIN(0)", 0},
		{"COS(0)", 1},
		{"TAN(0)", 0},
		{"ASIN(1)", math.Pi / 2},
		{"ACOS(1)", 0},
		{"ATAN(1)", math.Pi / 4},
		{"ATAN2(0, 1)", math.Pi / 2}, // ATAN2(x, y) -- Excel's order, not Go's
		{"ATAN2(1, 0)", 0},
		{"SINH(0)", 0},
		{"COSH(0)", 1},
		{"TANH(0)", 0},
		{"PI()", math.Pi},
		{"RADIANS(180)", math.Pi},
		{"DEGREES(PI)", 180},

		// Logic. Any nonzero, non-NaN value is true.
		{"IF(1, 10, 20)", 10},
		{"IF(0, 10, 20)", 20},
		{"IF(0, 10)", 0}, // no else branch -> 0
		{"IF([Label], 1, 2)", 2},
		{"IF([Revenue] > 150, 1, 0)", 1},
		{"AND(1, 1)", 1},
		{"AND(1, 0)", 0},
		{"AND(1, [Label])", 0}, // NaN is not true
		{"AND()", 1},
		{"OR(0, 0)", 0},
		{"OR(0, 1)", 1},
		{"OR()", 0},
		{"NOT(0)", 1},
		{"NOT(5)", 0},
		{"NOT([Label])", 1},

		// Row.
		{"ROW()", 2},
	}
	for _, tt := range tests {
		got, err := EvalFormula(tt.src, ctx)
		if err != nil {
			t.Errorf("%s: unexpected error: %v", tt.src, err)
			continue
		}
		if !nearly(got, tt.want) {
			t.Errorf("%s = %v, want %v", tt.src, got, tt.want)
		}
	}
}

// The untaken branch of an IF is not evaluated, so guarding a division works.
func TestFormulaIfShortCircuits(t *testing.T) {
	n, err := EvalFormula("IF(0 <> 0, 1 / 0, 42)", nil)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if n != 42 {
		t.Errorf("got %v, want 42", n)
	}
}

func TestFormulaConstants(t *testing.T) {
	tests := []struct {
		src  string
		want float64
	}{
		{"PI", math.Pi},
		{"E", math.E},
		{"TAU", 2 * math.Pi},
		{"PHI", (1 + math.Sqrt(5)) / 2},
		{"SQRT2", math.Sqrt2},
		{"pi", math.Pi}, // names are matched case-insensitively
		{"2 * PI", 2 * math.Pi},
		{"TAU = 2 * PI", 1},
	}
	for _, tt := range tests {
		if got := evalOK(t, tt.src, nil); !nearly(got, tt.want) {
			t.Errorf("%s = %v, want %v", tt.src, got, tt.want)
		}
	}
}

func TestFormulaCellRefs(t *testing.T) {
	tests := []struct {
		src  string
		row  int
		want float64
	}{
		{"[Revenue]", 0, 100},
		{"[Revenue] - [Cost]", 0, 40},
		{"[Revenue] - [Cost]", 1, 50},
		{"[revenue]", 2, 300},                  // names are matched case-insensitively
		{"[  Revenue  ]", 2, 300},              // ... and trimmed
		{"[Label]", 0, math.NaN()},             // a non-numeric cell is NaN
		{"[Revenue] + [Label]", 0, math.NaN()}, // ... and it propagates through arithmetic
		{"([Revenue] - [Cost]) / [Revenue] * 100", 0, 40},
	}
	for _, tt := range tests {
		ctx := saleCtx().ForRow(tt.row)
		if got := evalOK(t, tt.src, ctx); !nearly(got, tt.want) {
			t.Errorf("row %d: %s = %v, want %v", tt.row, tt.src, got, tt.want)
		}
	}

	// With no current row (a summary), a cell reference is NaN -- but it is not an
	// error, because the reference itself is valid.
	n, err := EvalFormula("[Revenue]", saleCtx())
	if err != nil {
		t.Errorf("summary [Revenue]: unexpected error: %v", err)
	}
	if !math.IsNaN(n) {
		t.Errorf("summary [Revenue] = %v, want NaN", n)
	}
}

func TestFormulaColumnAggregates(t *testing.T) {
	tests := []struct {
		src  string
		row  int
		want float64
	}{
		{"SUM({Revenue})", 0, 600},
		{"AVERAGE({Qty})", 0, 10.0 / 3},
		{"MIN({Cost})", 0, 60},
		{"MAX({Cost})", 0, 150},
		{"[Revenue] / SUM({Revenue}) * 100", 1, 100.0 / 3}, // % of the column total
		{"[Revenue] - AVERAGE({Revenue})", 2, 100},         // deviation from the mean
		{"SUM({Revenue}) - SUM({Cost})", 0, 290},
		{"{Revenue} + 1", 0, math.NaN()}, // an array in scalar position is NaN
		{"COUNT({Label})", 0, 0},
	}
	for _, tt := range tests {
		ctx := saleCtx().ForRow(tt.row)
		if got := evalOK(t, tt.src, ctx); !nearly(got, tt.want) {
			t.Errorf("row %d: %s = %v, want %v", tt.row, tt.src, got, tt.want)
		}
	}
}

func TestFormulaColumnIndexing(t *testing.T) {
	tests := []struct {
		src  string
		row  int
		want float64
	}{
		{"{Revenue:1}", 0, 100}, // 1-based
		{"{Revenue:2}", 0, 200},
		{"{Revenue:3}", 0, 300},
		{"{Revenue:0}", 0, math.NaN()}, // out of range is NaN, not an error
		{"{Revenue:4}", 0, math.NaN()},
		{"{Revenue:2.9}", 0, 200}, // the index is truncated
		{"{Revenue:ROW()}", 1, 200},
		{"{Revenue:ROW()}", 2, 300},
		{"{Revenue:ROW()-1}", 2, 200},        // the previous row
		{"{Revenue:ROW()-1}", 0, math.NaN()}, // ... which does not exist on the first
		{"{Revenue:1+1}", 0, 200},            // the index is an expression
		{"{Revenue:{Qty:1}}", 0, 200},        // ... which may itself be a reference
		{"[Revenue] - {Revenue:ROW()-1}", 1, 100},
		{"{ Revenue : 1 }", 0, 100}, // whitespace is trimmed
	}
	for _, tt := range tests {
		ctx := saleCtx().ForRow(tt.row)
		if got := evalOK(t, tt.src, ctx); !nearly(got, tt.want) {
			t.Errorf("row %d: %s = %v, want %v", tt.row, tt.src, got, tt.want)
		}
	}

	// With no current row, ROW() is NaN and so is any index built on it.
	if got := evalOK(t, "{Revenue:ROW()}", saleCtx()); !math.IsNaN(got) {
		t.Errorf("summary {Revenue:ROW()} = %v, want NaN", got)
	}
}

func TestFormulaColumnRanges(t *testing.T) {
	tests := []struct {
		src  string
		row  int
		want float64
	}{
		{"SUM({Revenue:1:2})", 0, 300},
		{"SUM({Revenue:2:3})", 0, 500},
		{"COUNT({Revenue:1:3})", 0, 3},
		{"SUM({Revenue:3:1})", 0, 600},  // reversed bounds are swapped
		{"SUM({Revenue:0:10})", 0, 600}, // ... and clipped to the column
		{"AVERAGE({Revenue:1:2})", 0, 150},

		// The running total: the range's end is the current row.
		{"SUM({Revenue:1:ROW()})", 0, 100},
		{"SUM({Revenue:1:ROW()})", 1, 300},
		{"SUM({Revenue:1:ROW()})", 2, 600},

		// A trailing moving average: both bounds are expressions.
		{"AVERAGE({Revenue:ROW()-1:ROW()})", 2, 250},
		{"AVERAGE({Revenue:ROW()-2:ROW()})", 2, 200},
		{"COUNT({Revenue:ROW()-2:ROW()})", 0, 1}, // the range runs off the top and is clipped

		{"SUM({Revenue:ROW():ROW()})", 1, 200},
	}
	for _, tt := range tests {
		ctx := saleCtx().ForRow(tt.row)
		if got := evalOK(t, tt.src, ctx); !nearly(got, tt.want) {
			t.Errorf("row %d: %s = %v, want %v", tt.row, tt.src, got, tt.want)
		}
	}

	// A NaN bound yields an empty range (SUM of nothing is NaN, COUNT is 0).
	summary := saleCtx()
	if got := evalOK(t, "COUNT({Revenue:1:ROW()})", summary); got != 0 {
		t.Errorf("summary COUNT({Revenue:1:ROW()}) = %v, want 0", got)
	}
}

// margin/pctCol are two calculated columns that build on each other:
// Margin = Revenue - Cost, and Margin % = Margin / Revenue * 100.
func marginCol() UserCalculatedColumn {
	return UserCalculatedColumn{
		ID: "m1", DisplayName: "Margin", Fn: CALC_FN_CUSTOM,
		Formula: "[Revenue] - [Cost]", DataType: CALC_TYPE_MONEY,
	}
}

func TestFormulaCalcColumnReferencesCalcColumn(t *testing.T) {
	margin := marginCol()
	pct := UserCalculatedColumn{
		ID: "m2", DisplayName: "Margin %", Fn: CALC_FN_CUSTOM,
		Formula: "[Margin] / [Revenue] * 100", DataType: CALC_TYPE_PERCENT,
	}
	ctx := saleCtx(margin, pct)

	// Row 0: margin 40 of 100 -> 40%. Row 1: 50 of 200 -> 25%.
	for _, tt := range []struct {
		row         int
		margin, pct float64
	}{
		{0, 40, 40},
		{1, 50, 25},
		{2, 200, 200.0 / 3},
	} {
		got, err := ComputeCalculatedColumn(margin, ctx.ForRow(tt.row))
		if err != nil || !nearly(got, tt.margin) {
			t.Errorf("row %d Margin = %v (err %v), want %v", tt.row, got, err, tt.margin)
		}
		got, err = ComputeCalculatedColumn(pct, ctx.ForRow(tt.row))
		if err != nil || !nearly(got, tt.pct) {
			t.Errorf("row %d Margin %% = %v (err %v), want %v", tt.row, got, err, tt.pct)
		}
	}

	// A calculated column is also a whole COLUMN: {Margin} spans the rows.
	if got := evalOK(t, "SUM({Margin})", ctx.ForRow(0)); !nearly(got, 290) {
		t.Errorf("SUM({Margin}) = %v, want 290", got)
	}
	// ... including inside a range, which is the running total of a derived column.
	if got := evalOK(t, "SUM({Margin:1:ROW()})", ctx.ForRow(1)); !nearly(got, 90) {
		t.Errorf("SUM({Margin:1:ROW()}) = %v, want 90", got)
	}

	// The same chain through the predefined functions and _calc_ operands.
	basic := UserCalculatedColumn{
		ID: "b1", DisplayName: "Basic Margin", Fn: CALC_FN_SUBTRACT,
		Operands: []string{"Revenue", "Cost"},
	}
	ratio := UserCalculatedColumn{
		ID: "b2", DisplayName: "Basic Ratio", Fn: CALC_FN_DIVIDE,
		Operands: []string{CalcRef("b1"), "Revenue"},
	}
	bctx := saleCtx(basic, ratio)
	if got, err := ComputeCalculatedColumn(ratio, bctx.ForRow(0)); err != nil || !nearly(got, 0.4) {
		t.Errorf("Basic Ratio row 0 = %v (err %v), want 0.4", got, err)
	}
	// ... and a formula can reach a predefined column by display name.
	if got := evalOK(t, "[Basic Margin] * 2", bctx.ForRow(1)); !nearly(got, 100) {
		t.Errorf("[Basic Margin] * 2 = %v, want 100", got)
	}
}

func TestFormulaCycleDetection(t *testing.T) {
	// Direct: a formula that names its own column.
	self := UserCalculatedColumn{
		ID: "s1", DisplayName: "Self", Fn: CALC_FN_CUSTOM, Formula: "[Self] + 1",
	}
	got, err := ComputeCalculatedColumn(self, saleCtx(self).ForRow(0))
	if !errors.Is(err, ErrFormulaCycle) {
		t.Errorf("self-reference: err = %v, want ErrFormulaCycle", err)
	}
	if !math.IsNaN(got) {
		t.Errorf("self-reference = %v, want NaN", got)
	}

	// Transitive: A -> B -> A.
	a := UserCalculatedColumn{ID: "a", DisplayName: "A", Fn: CALC_FN_CUSTOM, Formula: "[B] + 1"}
	b := UserCalculatedColumn{ID: "b", DisplayName: "B", Fn: CALC_FN_CUSTOM, Formula: "[A] + 1"}
	got, err = ComputeCalculatedColumn(a, saleCtx(a, b).ForRow(0))
	if !errors.Is(err, ErrFormulaCycle) {
		t.Errorf("A -> B -> A: err = %v, want ErrFormulaCycle", err)
	}
	if !math.IsNaN(got) {
		t.Errorf("A -> B -> A = %v, want NaN", got)
	}

	// Through a column reference rather than a cell reference, which recurses over
	// every row and would blow the stack fastest.
	ca := UserCalculatedColumn{ID: "ca", DisplayName: "CA", Fn: CALC_FN_CUSTOM, Formula: "SUM({CB})"}
	cb := UserCalculatedColumn{ID: "cb", DisplayName: "CB", Fn: CALC_FN_CUSTOM, Formula: "SUM({CA})"}
	if _, err := ComputeCalculatedColumn(ca, saleCtx(ca, cb).ForRow(0)); !errors.Is(err, ErrFormulaCycle) {
		t.Errorf("{CA} <-> {CB}: err = %v, want ErrFormulaCycle", err)
	}

	// And through the predefined operands.
	pa := UserCalculatedColumn{ID: "pa", DisplayName: "PA", Fn: CALC_FN_SUM, Operands: []string{CalcRef("pa")}}
	if _, err := ComputeCalculatedColumn(pa, saleCtx(pa).ForRow(0)); !errors.Is(err, ErrFormulaCycle) {
		t.Errorf("operand self-reference: err = %v, want ErrFormulaCycle", err)
	}

	// Two SIBLING references to the same column are not a cycle: the visiting set
	// tracks the chain above, not everything seen.
	m := marginCol()
	twice := UserCalculatedColumn{
		ID: "t1", DisplayName: "Twice", Fn: CALC_FN_CUSTOM, Formula: "[Margin] + [Margin]",
	}
	if got, err := ComputeCalculatedColumn(twice, saleCtx(m, twice).ForRow(0)); err != nil || !nearly(got, 80) {
		t.Errorf("[Margin] + [Margin] = %v (err %v), want 80", got, err)
	}
}

func TestFormulaUnknownColumn(t *testing.T) {
	ctx := saleCtx().ForRow(0)
	for _, src := range []string{"[Nope]", "SUM({Nope})", "{Nope:1}", "SUM({Nope:1:2})"} {
		got, err := EvalFormula(src, ctx)
		if !errors.Is(err, ErrUnknownColumn) {
			t.Errorf("%s: err = %v, want ErrUnknownColumn", src, err)
		}
		if !math.IsNaN(got) {
			t.Errorf("%s = %v, want NaN", src, got)
		}
	}

	// A column that exists but holds nothing numeric is NOT an unknown column.
	if _, err := EvalFormula("[Label]", ctx); err != nil {
		t.Errorf("[Label]: unexpected error: %v", err)
	}
}

func TestFormulaDivisionByZero(t *testing.T) {
	ctx := saleCtx().ForRow(0)
	for _, src := range []string{
		"1 / 0",
		"5 % 0",
		"MOD(5, 0)",
		"[Revenue] / ([Cost] - [Cost])",
		"[Revenue] / ([Revenue] - [Revenue])",
	} {
		got, err := EvalFormula(src, ctx)
		if !errors.Is(err, ErrDivideByZero) {
			t.Errorf("%s: err = %v, want ErrDivideByZero", src, err)
		}
		if !math.IsNaN(got) {
			t.Errorf("%s = %v, want NaN (never Inf)", src, got)
		}
	}

	// The predefined divide is the same: NaN, never an infinity.
	if got := ApplyCalcFunction(CALC_FN_DIVIDE, []float64{1, 0}); !math.IsNaN(got) {
		t.Errorf("divide by zero = %v, want NaN", got)
	}
}

func TestFormulaMalformedInput(t *testing.T) {
	tests := []struct {
		src  string
		want error
	}{
		{"", ErrEmptyFormula},
		{"   ", ErrEmptyFormula},
		{"1 +", ErrFormulaSyntax},
		{"(1", ErrFormulaSyntax},
		{"1)", ErrFormulaSyntax},
		{"1 2", ErrFormulaSyntax},
		{"[Revenue", ErrFormulaSyntax},
		{"{Revenue", ErrFormulaSyntax},
		{"1 @ 2", ErrFormulaSyntax},
		{"1.2.3", ErrFormulaSyntax}, // a malformed literal, where JS's parseFloat reads 1.2
		{"SUM(1,)", ErrFormulaSyntax},
		{"SUM(1 2)", ErrFormulaSyntax},
		{"{Revenue:}", ErrEmptyFormula}, // the index is compiled as its own formula
		{"FOO(1)", ErrUnknownFunction},
		{"NOPE", ErrUnknownName},
		{"2 * BAR", ErrUnknownName},
	}
	for _, tt := range tests {
		f, err := CompileFormula(tt.src)
		if !errors.Is(err, tt.want) {
			t.Errorf("CompileFormula(%q): err = %v, want %v", tt.src, err, tt.want)
		}
		if f != nil {
			t.Errorf("CompileFormula(%q): got a formula, want nil", tt.src)
		}
	}

	// A valid formula compiles once and evaluates many times.
	f, err := CompileFormula("[Revenue] * 2")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if f.Source() != "[Revenue] * 2" {
		t.Errorf("Source() = %q", f.Source())
	}
	ctx := saleCtx()
	for i, want := range []float64{200, 400, 600} {
		if got, _ := f.Eval(ctx.ForRow(i)); !nearly(got, want) {
			t.Errorf("row %d = %v, want %v", i, got, want)
		}
	}
}

func TestFormulaApplyCalcFunction(t *testing.T) {
	nan := math.NaN()
	tests := []struct {
		fn       CalculatedFunction
		operands []float64
		want     float64
	}{
		// The aggregates skip NaN operands (a blank cell is not a zero).
		{CALC_FN_SUM, []float64{1, 2, 3}, 6},
		{CALC_FN_SUM, []float64{1, nan, 3}, 4},
		{CALC_FN_SUM, []float64{nan}, nan},
		{CALC_FN_SUM, nil, nan},
		{CALC_FN_AVERAGE, []float64{1, 2, 3, 10}, 4},
		{CALC_FN_AVERAGE, []float64{2, nan, 4}, 3}, // the NaN is not counted in the divisor
		{CALC_FN_MEDIAN, []float64{3, 1, 2}, 2},
		{CALC_FN_MEDIAN, []float64{4, 1, 3, 2}, 2.5},
		{CALC_FN_MEDIAN, []float64{nan}, nan},
		{CALC_FN_MODE, []float64{1, 2, 2, 3}, 2},
		{CALC_FN_MODE, []float64{1, 2, 3}, nan}, // nothing repeats
		{CALC_FN_MODE, nil, nan},
		{CALC_FN_MIN, []float64{3, 1, 2}, 1},
		{CALC_FN_MIN, []float64{nan, 5}, 5},
		{CALC_FN_MAX, []float64{3, 1, 2}, 3},
		{CALC_FN_COUNT, []float64{1, nan, 3}, 2},
		{CALC_FN_COUNT, nil, 0}, // the one aggregate that is 0 rather than NaN
		{CALC_FN_COUNT, []float64{nan}, 0},

		// The arithmetic ones propagate NaN: a row missing an operand shows nothing
		// rather than a plausible-looking wrong number.
		{CALC_FN_SUBTRACT, []float64{10, 3, 2}, 5},
		{CALC_FN_SUBTRACT, []float64{10, nan}, nan},
		{CALC_FN_MULTIPLY, []float64{2, 3, 4}, 24},
		{CALC_FN_MULTIPLY, []float64{2, nan}, nan},
		{CALC_FN_DIVIDE, []float64{100, 5, 2}, 10},
		{CALC_FN_DIVIDE, []float64{100, 0}, nan},
		{CALC_FN_DIVIDE, []float64{100, nan}, nan},
		{CALC_FN_DIVIDE, []float64{100}, 100}, // nothing to divide by
	}
	for _, tt := range tests {
		if got := ApplyCalcFunction(tt.fn, tt.operands); !nearly(got, tt.want) {
			t.Errorf("%s(%v) = %v, want %v", tt.fn, tt.operands, got, tt.want)
		}
	}
}

func TestFormulaPredefinedCalcColumns(t *testing.T) {
	// The ten predefined functions, each over the same two operand columns
	// (Revenue 100 / Cost 60 on row 0) so the arithmetic is easy to read.
	tests := []struct {
		fn   CalculatedFunction
		want float64
	}{
		{CALC_FN_SUM, 160},
		{CALC_FN_SUBTRACT, 40},
		{CALC_FN_MULTIPLY, 6000},
		{CALC_FN_DIVIDE, 100.0 / 60},
		{CALC_FN_AVERAGE, 80},
		{CALC_FN_MEDIAN, 80},
		{CALC_FN_MODE, math.NaN()}, // 100 and 60 each appear once
		{CALC_FN_MIN, 60},
		{CALC_FN_MAX, 100},
		{CALC_FN_COUNT, 2},
	}
	ctx := saleCtx().ForRow(0)
	for _, tt := range tests {
		uc := UserCalculatedColumn{
			ID: "x", DisplayName: "X", Fn: tt.fn,
			Operands: []string{"Revenue", "Cost"},
		}
		got, err := ComputeCalculatedColumn(uc, ctx)
		if err != nil {
			t.Errorf("%s: unexpected error: %v", tt.fn, err)
			continue
		}
		if !nearly(got, tt.want) {
			t.Errorf("%s = %v, want %v", tt.fn, got, tt.want)
		}
	}

	// A non-numeric operand: the aggregates skip it, the arithmetic ones give up.
	sum := UserCalculatedColumn{ID: "s", DisplayName: "S", Fn: CALC_FN_SUM, Operands: []string{"Revenue", "Label"}}
	if got, _ := ComputeCalculatedColumn(sum, ctx); !nearly(got, 100) {
		t.Errorf("sum with a blank operand = %v, want 100", got)
	}
	sub := UserCalculatedColumn{ID: "d", DisplayName: "D", Fn: CALC_FN_SUBTRACT, Operands: []string{"Revenue", "Label"}}
	if got, _ := ComputeCalculatedColumn(sub, ctx); !math.IsNaN(got) {
		t.Errorf("subtract with a blank operand = %v, want NaN", got)
	}
}

func TestFormulaSummaryRows(t *testing.T) {
	margin := marginCol()
	ctx := saleCtx(margin)

	tests := []struct {
		name string
		row  UserSummaryRow
		want string
	}{
		{"custom total", UserSummaryRow{
			Label: "Total", Fn: CALC_FN_CUSTOM, Formula: "SUM({Revenue})", DataType: CALC_TYPE_MONEY,
		}, "$600.00"},
		{"custom margin over a calc column", UserSummaryRow{
			Label: "Margin", Fn: CALC_FN_CUSTOM, Formula: "SUM({Margin})", DataType: CALC_TYPE_MONEY,
		}, "$290.00"},
		{"custom ratio", UserSummaryRow{
			Label: "Margin %", Fn: CALC_FN_CUSTOM,
			Formula: "SUM({Margin}) / SUM({Revenue}) * 100", DataType: CALC_TYPE_PERCENT,
		}, "48.33%"},
		{"basic sum", UserSummaryRow{
			Label: "Revenue", Fn: CALC_FN_SUM, Operands: []string{"Revenue"}, DataType: CALC_TYPE_MONEY,
		}, "$600.00"},
		{"basic average", UserSummaryRow{
			Label: "Qty", Fn: CALC_FN_AVERAGE, Operands: []string{"Qty"}, DataType: CALC_TYPE_DECIMAL,
		}, "3.33"},
		{"basic count", UserSummaryRow{
			Label: "Rows", Fn: CALC_FN_COUNT, Operands: []string{"Revenue"}, DataType: CALC_TYPE_INTEGER,
		}, "3"},
		{"basic over a calc column", UserSummaryRow{
			Label: "Margin", Fn: CALC_FN_MAX, Operands: []string{CalcRef("m1")}, DataType: CALC_TYPE_MONEY,
		}, "$200.00"},
		// A summary has no current row, so a cell reference and ROW() are NaN and
		// the whole line is the empty value.
		{"cell reference", UserSummaryRow{
			Label: "Nope", Fn: CALC_FN_CUSTOM, Formula: "[Revenue] * 2",
		}, CalcEmptyValue},
		{"row reference", UserSummaryRow{
			Label: "Nope", Fn: CALC_FN_CUSTOM, Formula: "ROW()",
		}, CalcEmptyValue},
		{"broken formula", UserSummaryRow{
			Label: "Nope", Fn: CALC_FN_CUSTOM, Formula: "SUM({",
		}, CalcEmptyValue},
		{"no operand", UserSummaryRow{
			Label: "Nope", Fn: CALC_FN_SUM,
		}, CalcEmptyValue},
	}
	for _, tt := range tests {
		if got := FormatSummaryRow(tt.row, ctx); got != tt.want {
			t.Errorf("%s: got %q, want %q", tt.name, got, tt.want)
		}
	}
}

func TestFormulaFormatCalcResult(t *testing.T) {
	p := CalcPrecision
	tests := []struct {
		name      string
		result    float64
		dataType  CalculatedDataType
		precision *int
		want      string
	}{
		{"money", 1234.5, CALC_TYPE_MONEY, nil, "$1,234.50"},
		{"money precision", 1234.4, CALC_TYPE_MONEY, p(0), "$1,234"},
		// The "$" leads and the sign follows it. Ugly, but it is what the TSX emits
		// (prefix + "$" + text), and a calculated column has no accounting format.
		{"money negative", -99.5, CALC_TYPE_MONEY, nil, "$-99.50"},
		{"decimal", 1234.5678, CALC_TYPE_DECIMAL, nil, "1,234.57"},
		{"decimal precision", 1234.5678, CALC_TYPE_DECIMAL, p(3), "1,234.568"},
		{"integer", 1234.6, CALC_TYPE_INTEGER, nil, "1,235"},
		{"integer rounds down", 1234.4, CALC_TYPE_INTEGER, nil, "1,234"},
		{"integer ignores precision", 1234.6, CALC_TYPE_INTEGER, p(2), "1,235"},
		{"percent", 12.3456, CALC_TYPE_PERCENT, nil, "12.35%"},
		{"percent precision", 12.3456, CALC_TYPE_PERCENT, p(0), "12%"},
		{"percent has no grouping", 1234.5, CALC_TYPE_PERCENT, p(1), "1234.5%"},
		{"number", 1234.5, CALC_TYPE_NUMBER, nil, "1,234.5"},
		{"number precision", 1234.5, CALC_TYPE_NUMBER, p(2), "1,234.50"},
		{"number precision zero", 1234.4, CALC_TYPE_NUMBER, p(0), "1,234"},
		{"plain", 1234.5, CALC_TYPE_PLAIN, nil, "1234.5"},
		{"plain precision", 1234.56, CALC_TYPE_PLAIN, p(1), "1234.6"},
		{"unset data type is plain", 1234.5, "", nil, "1234.5"},

		// Not a finite number -> the empty value.
		{"NaN", math.NaN(), CALC_TYPE_MONEY, nil, CalcEmptyValue},
		{"+Inf", math.Inf(1), CALC_TYPE_MONEY, nil, CalcEmptyValue},
		{"-Inf", math.Inf(-1), CALC_TYPE_PLAIN, nil, CalcEmptyValue},

		// Negative zero is never rendered: rounded to the display precision, a tiny
		// negative like SIN(2*PI) would otherwise show as "-0" / "-0.00".
		{"negative zero plain", math.Copysign(0, -1), CALC_TYPE_PLAIN, nil, "0"},
		{"negative zero plain precision", -1e-16, CALC_TYPE_PLAIN, p(0), "0"},
		{"negative zero percent", -0.0001, CALC_TYPE_PERCENT, p(2), "0.00%"},
		{"negative zero decimal", -0.0001, CALC_TYPE_DECIMAL, nil, "0.00"},
		{"negative zero money", -0.0001, CALC_TYPE_MONEY, nil, "$0.00"},
		{"negative zero integer", -0.4, CALC_TYPE_INTEGER, nil, "0"},
		{"negative zero number", -1e-16, CALC_TYPE_NUMBER, p(2), "0.00"},
		// The rule is about the RENDERED text, not the value: a real negative that
		// rounds away to zero at the display precision also loses its sign...
		{"a negative that rounds to zero", -0.004, CALC_TYPE_PERCENT, p(2), "0.00%"},
		// ... while one that survives the rounding keeps it.
		{"a negative that survives", -0.006, CALC_TYPE_PERCENT, p(2), "-0.01%"},
	}
	for _, tt := range tests {
		got := FormatCalcResult(tt.result, tt.dataType, tt.precision, "", "", "")
		if got != tt.want {
			t.Errorf("%s: got %q, want %q", tt.name, got, tt.want)
		}
	}

	// prefix/suffix wrap the formatted text, outside the "$" and the "%".
	if got := FormatCalcResult(12.5, CALC_TYPE_MONEY, nil, "~", " ea", ""); got != "~$12.50 ea" {
		t.Errorf("prefix/suffix: got %q", got)
	}
	if got := FormatCalcResult(math.NaN(), CALC_TYPE_MONEY, nil, "~", " ea", "n/a"); got != "n/a" {
		t.Errorf("empty value: got %q, want %q (prefix/suffix do not apply)", got, "n/a")
	}
}

// The negative zero the suppression exists for, arrived at the way a user would.
func TestFormulaNegativeZeroEndToEnd(t *testing.T) {
	// SIN(2*PI) is about -2.4e-16: the classic way to grow a "-0.00".
	n, err := EvalFormula("SIN(2 * PI)", nil)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if n >= 0 {
		t.Fatalf("SIN(2*PI) = %v, want a tiny negative", n)
	}
	for _, dt := range []CalculatedDataType{CALC_TYPE_MONEY, CALC_TYPE_DECIMAL, CALC_TYPE_PERCENT, CALC_TYPE_NUMBER, CALC_TYPE_INTEGER, CALC_TYPE_PLAIN} {
		got := FormatCalcResult(n, dt, CalcPrecision(2), "", "", "")
		if got != "" && got[0] == '-' {
			t.Errorf("%s: got %q, want no leading '-'", dt, got)
		}
	}
}

func TestFormulaFormatCalculatedColumn(t *testing.T) {
	margin := marginCol() // money
	ctx := saleCtx(margin)
	want := []string{"$40.00", "$50.00", "$200.00"}
	for i, w := range want {
		if got := FormatCalculatedColumn(margin, ctx.ForRow(i)); got != w {
			t.Errorf("row %d: got %q, want %q", i, got, w)
		}
	}

	// A broken formula renders the empty value rather than failing the render.
	broken := UserCalculatedColumn{ID: "z", DisplayName: "Z", Fn: CALC_FN_CUSTOM, Formula: "1 +"}
	if got := FormatCalculatedColumn(broken, saleCtx(broken).ForRow(0)); got != CalcEmptyValue {
		t.Errorf("broken formula: got %q, want %q", got, CalcEmptyValue)
	}
}

func TestFormulaToCalcNumber(t *testing.T) {
	tests := []struct {
		in   any
		want float64
	}{
		{42, 42},
		{42.5, 42.5},
		{"42.5", 42.5},
		{"$1,234.56", 1234.56}, // "$", "," and "%" are stripped
		{"12%", 12},
		{"  7 ", 7},
		{"-3.5", -3.5},
		{"1e3", 1000},
		{"12abc", 12}, // JS parseFloat reads the leading number
		{"abc", math.NaN()},
		{"", math.NaN()},
		{nil, math.NaN()},
		{true, math.NaN()},
	}
	for _, tt := range tests {
		if got := ToCalcNumber(tt.in); !nearly(got, tt.want) {
			t.Errorf("ToCalcNumber(%#v) = %v, want %v", tt.in, got, tt.want)
		}
	}
}

// A custom FieldReader is how a formula reaches a value that is not a plain field
// — the FieldReader abstraction the rest of the pipeline already uses.
func TestFormulaCustomFieldReader(t *testing.T) {
	read := func(row any, field string) any {
		if field == "Doubled" {
			return row.(sale).Revenue * 2
		}
		return DefaultFieldReader(row, field)
	}
	cols := append(saleColumns(), AutoTableColumn{DisplayName: "Doubled", SortIdentifier: "Doubled"})
	ctx := NewCalcContext(saleRows(), cols, nil, read)

	if got := evalOK(t, "[Doubled] - [Revenue]", ctx.ForRow(2)); !nearly(got, 300) {
		t.Errorf("got %v, want 300", got)
	}
	if got := evalOK(t, "SUM({Doubled})", ctx.ForRow(0)); !nearly(got, 1200) {
		t.Errorf("got %v, want 1200", got)
	}
}

// Rows may be maps, not just structs -- DefaultFieldReader handles both.
func TestFormulaMapRows(t *testing.T) {
	rows := []any{
		map[string]any{"amount": "$1,000.00"},
		map[string]any{"amount": "$2,500.50"},
	}
	cols := []AutoTableColumn{{DisplayName: "Amount", SortIdentifier: "amount"}}
	ctx := NewCalcContext(rows, cols, nil, nil)

	if got := evalOK(t, "SUM({Amount})", ctx); !nearly(got, 3500.5) {
		t.Errorf("SUM({Amount}) = %v, want 3500.5", got)
	}
	if got := evalOK(t, "[Amount]", ctx.ForRow(1)); !nearly(got, 2500.5) {
		t.Errorf("[Amount] = %v, want 2500.5", got)
	}
}

func TestFormulaEmptyRowSet(t *testing.T) {
	ctx := NewCalcContext(nil, saleColumns(), nil, nil)
	tests := []struct {
		src  string
		want float64
	}{
		{"SUM({Revenue})", math.NaN()}, // nothing to sum
		{"COUNT({Revenue})", 0},
		{"{Revenue:1}", math.NaN()},
		{"COUNT({Revenue:1:3})", 0},
	}
	for _, tt := range tests {
		if got := evalOK(t, tt.src, ctx); !nearly(got, tt.want) {
			t.Errorf("%s = %v, want %v", tt.src, got, tt.want)
		}
	}
}

// ==========================================================================
// Export: CSV, PDF, print
// ==========================================================================

type exportRow struct {
	Name   string
	Status string
	Amount float64
	Note   string
}

// exportCols exercises every way a column can (or cannot) produce an export value:
// an explicit CSVValue, a fallback to the field named by SortIdentifier, a column
// that opts out, and a column that opts in but has no way to produce anything.
func exportCols() []AutoTableColumn {
	return []AutoTableColumn{
		{DisplayName: "Name", SortIdentifier: "Name", CSV: true,
			CSVValue: func(r any) string { return r.(exportRow).Name }},
		{DisplayName: "Status", SortIdentifier: "Status", CSV: true}, // fallback to the field
		{DisplayName: "Amount", SortIdentifier: "Amount", CSV: true, DisplayPosition: COL_POS_RIGHT,
			CSVValue: func(r any) string { return strconv.FormatFloat(r.(exportRow).Amount, 'f', 2, 64) }},
		{DisplayName: "Actions", CSV: false, // not exported
			CSVValue: func(any) string { return "never" }},
		{DisplayName: "Orphan", CSV: true}, // opted in, but no value source: dropped
	}
}

func exportRows() []any {
	return []any{
		exportRow{Name: "Ada", Status: "active", Amount: 1234.5, Note: "x"},
		exportRow{Name: "Bob", Status: "inactive", Amount: -99, Note: "y"},
	}
}

// parseCSV reads the export back with the stdlib reader — which is the real
// assertion about escaping: if the quoting is wrong, this either errors or gives
// back the wrong fields.
func parseCSV(t *testing.T, data []byte) [][]string {
	t.Helper()
	recs, err := csv.NewReader(bytes.NewReader(data)).ReadAll()
	if err != nil {
		t.Fatalf("the export does not parse as CSV: %v\n%s", err, data)
	}
	return recs
}

func TestExportCSVColumnSelection(t *testing.T) {
	recs := parseCSV(t, ExportCSV(exportCols(), exportRows(), DefaultFieldReader))

	want := [][]string{
		{"Name", "Status", "Amount"},
		{"Ada", "active", "1234.50"},
		{"Bob", "inactive", "-99.00"},
	}
	if len(recs) != len(want) {
		t.Fatalf("got %d records, want %d: %q", len(recs), len(want), recs)
	}
	for i := range want {
		if strings.Join(recs[i], "|") != strings.Join(want[i], "|") {
			t.Errorf("record %d = %q, want %q", i, recs[i], want[i])
		}
	}
}

// The Status column has no CSVValue: its text comes from the field named by
// SortIdentifier, read through the FieldReader.
func TestExportCSVFallsBackToSortIdentifier(t *testing.T) {
	cols := []AutoTableColumn{{DisplayName: "Status", SortIdentifier: "Status", CSV: true}}
	recs := parseCSV(t, ExportCSV(cols, exportRows(), DefaultFieldReader))
	if recs[1][0] != "active" || recs[2][0] != "inactive" {
		t.Fatalf("fallback read the wrong field: %q", recs)
	}

	// A nil reader must still work — it defaults, exactly as the pipeline does.
	recs = parseCSV(t, ExportCSV(cols, exportRows(), nil))
	if recs[1][0] != "active" {
		t.Fatalf("nil FieldReader did not default: %q", recs)
	}
}

func TestExportCSVEscaping(t *testing.T) {
	cols := []AutoTableColumn{
		{DisplayName: "He, llo", SortIdentifier: "Name", CSV: true,
			CSVValue: func(r any) string { return r.(exportRow).Name }},
	}
	rows := []any{
		exportRow{Name: `a,b`},
		exportRow{Name: `say "hi"`},
		exportRow{Name: "line1\nline2"},
		exportRow{Name: `mixed "q", and,`},
	}

	data := ExportCSV(cols, rows, DefaultFieldReader)
	raw := string(data)

	// The literal bytes: a comma-bearing field is quoted, an embedded quote doubled.
	if !strings.Contains(raw, `"He, llo"`) {
		t.Errorf("a header with a comma was not quoted:\n%s", raw)
	}
	if !strings.Contains(raw, `"say ""hi"""`) {
		t.Errorf("an embedded quote was not doubled:\n%s", raw)
	}

	// And the round trip: the values come back byte-identical, newline and all.
	recs := parseCSV(t, data)
	want := []string{`a,b`, `say "hi"`, "line1\nline2", `mixed "q", and,`}
	if len(recs) != len(want)+1 {
		t.Fatalf("got %d records, want %d: %q", len(recs), len(want)+1, recs)
	}
	for i, w := range want {
		if recs[i+1][0] != w {
			t.Errorf("row %d round-tripped as %q, want %q", i, recs[i+1][0], w)
		}
	}
}

// A cell beginning with =, +, -, @ is a FORMULA to a spreadsheet. It gets a
// leading quote — unless it is merely a negative number, which must survive
// untouched or every financial export is wrong.
func TestCSVFormulaInjectionGuard(t *testing.T) {
	cases := []struct{ in, want string }{
		{`=cmd|'/c calc'!A1`, `'=cmd|'/c calc'!A1`},
		{`=1+1`, `'=1+1`},
		{`@SUM(A1)`, `'@SUM(A1)`},
		{`+cmd`, `'+cmd`},
		{"-hyphen-lead", "'-hyphen-lead"},
		{"\tsneaky", "'\tsneaky"},
		{"\rsneaky", "'\rsneaky"},
		// A phone number IS guarded: it is not a number, and Excel would try to
		// evaluate a leading +. The quote is what keeps it displaying as typed.
		{"+1 (555) 010-9999", "'+1 (555) 010-9999"},

		// Numbers, however dressed, are left alone — this is the carve-out that
		// keeps a money column from being mangled into text.
		{"-99", "-99"},
		{"-1,234.50", "-1,234.50"},
		{"-$1,234.50", "-$1,234.50"},
		{"-12.5%", "-12.5%"},
		{"+42", "+42"},
		{"-1.5e3", "-1.5e3"},

		// And so is anything that never started with a sigil.
		{"Ada", "Ada"},
		{"(1,234.50)", "(1,234.50)"},
		{"", ""},
	}
	for _, c := range cases {
		if got := csvSafe(c.in); got != c.want {
			t.Errorf("csvSafe(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

// …and it survives the actual export, quoting and all.
func TestExportCSVAppliesTheGuard(t *testing.T) {
	cols := []AutoTableColumn{
		{DisplayName: "V", SortIdentifier: "Name", CSV: true,
			CSVValue: func(r any) string { return r.(exportRow).Name }},
	}
	rows := []any{exportRow{Name: `=cmd|'/c calc'!A1`}, exportRow{Name: "-1,234.50"}}
	recs := parseCSV(t, ExportCSV(cols, rows, DefaultFieldReader))

	if recs[1][0] != `'=cmd|'/c calc'!A1` {
		t.Errorf("the formula was not defused: %q", recs[1][0])
	}
	if recs[2][0] != "-1,234.50" {
		t.Errorf("a negative number was mangled: %q", recs[2][0])
	}
}

func TestExportCSVNothingToExport(t *testing.T) {
	if got := ExportCSV(exportCols(), nil, DefaultFieldReader); got != nil {
		t.Errorf("no rows should export nothing, got %q", got)
	}
	noCSV := []AutoTableColumn{{DisplayName: "X"}, {DisplayName: "Y", CSV: true}} // Y has no value source
	if got := ExportCSV(noCSV, exportRows(), DefaultFieldReader); got != nil {
		t.Errorf("no exportable columns should export nothing, got %q", got)
	}
}

func TestWithExt(t *testing.T) {
	for _, c := range []struct{ in, want string }{
		{"report", "report.csv"},
		{"report.csv", "report.csv"},
		{"REPORT.CSV", "REPORT.CSV"},
		{"", "export.csv"},
	} {
		if got := withExt(c.in, ".csv"); got != c.want {
			t.Errorf("withExt(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

// ---- PDF -----------------------------------------------------------------

func manyRows(n int) []any {
	rows := make([]any, n)
	for i := range rows {
		rows[i] = exportRow{
			Name:   "Row " + strconv.Itoa(i),
			Status: "active",
			Amount: float64(i) * 1.5,
		}
	}
	return rows
}

func fixedHeader() AutoTablePDFHeader {
	return AutoTablePDFHeader{
		Title:    "Quarterly Report",
		Subtitle: "All accounts",
		ShowDate: true,
		Date:     time.Date(2026, 7, 13, 0, 0, 0, 0, time.UTC),
	}
}

func TestExportPDFIsAValidDocument(t *testing.T) {
	data := ExportPDF(exportCols(), exportRows(), DefaultFieldReader, fixedHeader())
	if data == nil {
		t.Fatal("no PDF produced")
	}
	info := mustCheckPDF(t, data)
	if info.pageObjs != 1 {
		t.Fatalf("two rows should fit on one page, got %d", info.pageObjs)
	}

	// The header block, the column headers, the data, and the footer are all there.
	for _, want := range []string{
		"(Quarterly Report)", "(All accounts)", "(July 13, 2026)",
		"(Name)", "(Status)", "(Amount)",
		"(Ada)", "(inactive)", "(1234.50)",
		"(Page 1 of 1)",
	} {
		if !bytes.Contains(data, []byte(want)) {
			t.Errorf("the PDF is missing %s", want)
		}
	}
	// The column that opted out must not appear.
	if bytes.Contains(data, []byte("(never)")) || bytes.Contains(data, []byte("(Actions)")) {
		t.Error("a non-CSV column leaked into the PDF")
	}
}

func TestExportPDFPaginatesAndRepeatsTheHeaderRow(t *testing.T) {
	const n = 200
	data := ExportPDF(exportCols(), manyRows(n), DefaultFieldReader, fixedHeader())
	info := mustCheckPDF(t, data)

	if info.pageObjs < 2 {
		t.Fatalf("%d rows landed on %d page(s); the table is not paginating", n, info.pageObjs)
	}

	// The whole point of a repeated header row: it appears once per page.
	if got := bytes.Count(data, []byte("(Status)")); got != info.pageObjs {
		t.Errorf("the header row appears on %d of %d pages", got, info.pageObjs)
	}
	// Every row made it, exactly once.
	for _, i := range []int{0, 1, n / 2, n - 1} {
		want := []byte("(Row " + strconv.Itoa(i) + ")")
		if got := bytes.Count(data, want); got != 1 {
			t.Errorf("row %d appears %d times, want 1", i, got)
		}
	}
	// And each page is footed with its own number.
	for i := 1; i <= info.pageObjs; i++ {
		want := []byte("(Page " + strconv.Itoa(i) + " of " + strconv.Itoa(info.pageObjs) + ")")
		if !bytes.Contains(data, want) {
			t.Errorf("missing footer %s", want)
		}
	}
}

// The page break must happen at a specific row, not "eventually": one row past
// the capacity of page 1 must open page 2, and not one row earlier.
func TestExportPDFBreaksAtTheRightRow(t *testing.T) {
	pages := func(n int) int {
		data := ExportPDF(exportCols(), manyRows(n), DefaultFieldReader, fixedHeader())
		return mustCheckPDF(t, data).pageObjs
	}

	// Find the last row count that still fits on one page.
	capacity := 0
	for n := 1; n <= 60; n++ {
		if pages(n) > 1 {
			capacity = n - 1
			break
		}
	}
	if capacity == 0 {
		t.Fatal("never found the first-page capacity (does it paginate at all?)")
	}
	if got := pages(capacity); got != 1 {
		t.Fatalf("%d rows (the computed capacity) took %d pages", capacity, got)
	}
	if got := pages(capacity + 1); got != 2 {
		t.Fatalf("%d rows took %d pages, want exactly 2", capacity+1, got)
	}

	// Landscape Letter: 612pt tall, 40pt margins, a title block and a 22pt column
	// header on page 1, 18pt rows. Roughly two dozen rows — a sanity check that the
	// capacity is in the right universe, so a layout bug that fits one row per page
	// (or a thousand) is caught.
	if capacity < 15 || capacity > 30 {
		t.Errorf("first-page capacity is %d rows, which is not plausible for Letter landscape", capacity)
	}
}

func TestExportPDFPortraitIsNarrower(t *testing.T) {
	h := fixedHeader()
	h.Orientation = PDF_ORIENTATION_PORTRAIT
	data := ExportPDF(exportCols(), exportRows(), DefaultFieldReader, h)
	mustCheckPDF(t, data)
	if !bytes.Contains(data, []byte("/MediaBox [0 0 612 792]")) {
		t.Error("portrait orientation did not reach the MediaBox")
	}
}

func TestExportPDFSummariesAndBelowTable(t *testing.T) {
	h := fixedHeader()
	h.Summaries = []AutoTablePDFSummary{{Label: "Total", Value: "1,135.50"}}

	var got PDFBelowTableContext
	called := 0
	h.BelowTable = func(ctx PDFBelowTableContext) {
		called++
		got = ctx
		ctx.PDF.Text(ctx.Margin, ctx.Y-20, "below-the-table", PDFTextStyle{Size: 9, Color: ctx.Muted})
		ctx.AddPage()
		ctx.PDF.Text(ctx.Margin, 100, "on-a-fresh-page", PDFTextStyle{Size: 9, Color: ctx.Text})
	}

	data := ExportPDF(exportCols(), exportRows(), DefaultFieldReader, h)
	info := mustCheckPDF(t, data)

	if called != 1 {
		t.Fatalf("BelowTable ran %d times, want 1", called)
	}
	if got.PDF == nil || got.ContentWidth <= 0 || got.PageWidth != 792 {
		t.Fatalf("BelowTable got a malformed context: %+v", got)
	}
	for _, want := range []string{"(Total)", "(1,135.50)", "(below-the-table)", "(on-a-fresh-page)"} {
		if !bytes.Contains(data, []byte(want)) {
			t.Errorf("the PDF is missing %s", want)
		}
	}
	// The page BelowTable added is footed too, and the totals agree.
	if info.pageObjs != 2 {
		t.Fatalf("page count = %d, want 2 (BelowTable added one)", info.pageObjs)
	}
	if !bytes.Contains(data, []byte("(Page 2 of 2)")) {
		t.Error("the page added by BelowTable was not footed")
	}
}

func TestExportPDFNothingToExport(t *testing.T) {
	if got := ExportPDF(exportCols(), nil, DefaultFieldReader, AutoTablePDFHeader{}); got != nil {
		t.Error("no rows should export no PDF")
	}
	if got := ExportPDF(nil, exportRows(), DefaultFieldReader, AutoTablePDFHeader{}); got != nil {
		t.Error("no columns should export no PDF")
	}
}

// A long value must not run past its column: it is truncated to fit, measured.
func TestExportPDFTruncatesToTheColumn(t *testing.T) {
	cols := []AutoTableColumn{
		{DisplayName: "A", SortIdentifier: "Name", CSV: true,
			CSVValue: func(r any) string { return r.(exportRow).Name }},
		{DisplayName: "B", SortIdentifier: "Status", CSV: true},
	}
	long := strings.Repeat("wide ", 400)
	data := ExportPDF(cols, []any{exportRow{Name: long, Status: "ok"}}, DefaultFieldReader, AutoTablePDFHeader{})
	mustCheckPDF(t, data)
	if !bytes.Contains(data, []byte(`\205)`)) { // the ellipsis, WinAnsi 0x85 = \205
		t.Error("an over-wide cell was not ellipsized")
	}
	if bytes.Contains(data, []byte(long)) {
		t.Error("the full over-wide value was drawn")
	}
}

// ---- the controller's half ------------------------------------------------

// An export writes what the FILTER selected — every matching row, across all
// pages — not the page the user happens to be looking at.
func TestAutoTableStateExportsEveryFilteredRowNotJustThePage(t *testing.T) {
	cols := []AutoTableColumn{
		{DisplayName: "Name", SortIdentifier: "Name", CSV: true,
			CSVValue: func(r any) string { return r.(exportRow).Name }},
		{DisplayName: "Status", SortIdentifier: "Status", CSV: true},
	}
	rows := make([]any, 40)
	for i := range rows {
		status := "active"
		if i%2 == 1 {
			status = "inactive"
		}
		rows[i] = exportRow{Name: "Row " + strconv.Itoa(i), Status: status}
	}

	s := NewAutoTableState(cols, AutoTableStateOptions{PerPage: 5})
	s.SetRows(rows)
	s.SetSearchValue("Status", "active", true) // 20 of the 40
	page, _ := s.Process()

	if len(page) != 5 {
		t.Fatalf("the page holds %d rows, want 5", len(page))
	}

	recs := parseCSV(t, s.ExportCSVBytes())
	if len(recs) != 21 { // header + 20 matching rows
		t.Fatalf("the CSV has %d records, want 21 (header + every filtered row)", len(recs))
	}
	for _, r := range recs[1:] {
		if r[1] != "active" {
			t.Fatalf("an unfiltered row reached the export: %q", r)
		}
	}

	pdf := s.ExportPDFBytes(fixedHeader())
	mustCheckPDF(t, pdf)
	if !bytes.Contains(pdf, []byte("(Row 38)")) { // the last match, well past page 1
		t.Error("the PDF export stopped at the current page")
	}
}
