package webui

import (
	"reflect"
	"strings"
	"testing"
)

// Calculated columns, and the distinction this originally got wrong:
//
//   - a calculated COLUMN with a predefined function combines its operand columns
//     ACROSS THE ROW — sum over [Revenue, Cost] is revenue + cost, per row;
//   - a SUMMARY ROW aggregates ONE operand column DOWN the table.
//
// And operands are column KEYS (a column's SortIdentifier), not display names —
// formulas are the ones that use display names, as [Gross revenue].

type calcRec struct {
	Name    string
	Revenue string
	Cost    string
}

// The display names deliberately differ from the field names, so anything that
// confuses a display name for a key fails loudly instead of passing by luck.
func newCalcTable() *AutoTableState {
	s := NewAutoTableState([]AutoTableColumn{
		{Key: "name", DisplayName: "Client", SortIdentifier: "Name", CSV: true},
		{Key: "revenue", DisplayName: "Gross revenue", SortIdentifier: "Revenue", CSV: true},
		{Key: "cost", DisplayName: "Direct cost", SortIdentifier: "Cost", CSV: true},
	}, AutoTableStateOptions{})
	s.SetRows([]any{
		calcRec{"a", "100", "40"},
		calcRec{"b", "200", "50"},
	})
	return s
}

// calcValues formats a calculated column for every row, the way the table does.
func calcValues(t *testing.T, s *AutoTableState, id string) []string {
	t.Helper()
	s.Render()
	rows := s.FilteredRows()
	ctx := NewCalcContext(rows, s.Columns(), s.Calculated(), nil)

	var uc UserCalculatedColumn
	for _, c := range s.Calculated() {
		if c.ID == id {
			uc = c
		}
	}
	out := make([]string, len(rows))
	for i := range rows {
		out[i] = FormatCalculatedColumn(uc, ctx.ForRow(i))
	}
	return out
}

// ---- the model ----

// This is the whole ballgame: sum over Revenue and Cost is 140 and 250 — the row
// sums — NOT the column totals 300 and 90.
func TestCalculatedColumnCombinesOperandsAcrossTheRow(t *testing.T) {
	s := newCalcTable()
	s.AddCalculated(UserCalculatedColumn{
		ID: "total", DisplayName: "Total", Fn: CALC_FN_SUM,
		Operands: []string{"Revenue", "Cost"}, // KEYS, not display names
		DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
	})
	if got, want := calcValues(t, s, "total"), []string{"140", "250"}; !reflect.DeepEqual(got, want) {
		t.Errorf("sum across the row = %v, want %v (a down-column sum would give 300/90)", got, want)
	}
}

// subtract and divide are binary AND ORDERED: swapping the operands changes the
// answer, so the order the user picked has to survive.
func TestBinaryCalculatedColumnsRespectOperandOrder(t *testing.T) {
	s := newCalcTable()
	s.AddCalculated(UserCalculatedColumn{
		ID: "profit", DisplayName: "Profit", Fn: CALC_FN_SUBTRACT,
		Operands: []string{"Revenue", "Cost"},
		DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
	})
	if got, want := calcValues(t, s, "profit"), []string{"60", "150"}; !reflect.DeepEqual(got, want) {
		t.Errorf("Revenue - Cost = %v, want %v", got, want)
	}

	// Reversed, it must become Cost - Revenue.
	s.AddCalculated(UserCalculatedColumn{
		ID: "profit", DisplayName: "Profit", Fn: CALC_FN_SUBTRACT,
		Operands: []string{"Cost", "Revenue"},
		DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
	})
	if got, want := calcValues(t, s, "profit"), []string{"-60", "-150"}; !reflect.DeepEqual(got, want) {
		t.Errorf("Cost - Revenue = %v, want %v (operand order was lost)", got, want)
	}
}

// A summary row is the other direction: one column, aggregated down the table.
func TestSummaryRowAggregatesDownTheColumn(t *testing.T) {
	s := newCalcTable()
	s.AddSummaryRow(UserSummaryRow{
		ID: "total", Label: "Total revenue", Fn: CALC_FN_SUM,
		Operands: []string{"Revenue"},
		DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
	})
	s.Render()
	ctx := NewCalcContext(s.FilteredRows(), s.Columns(), s.Calculated(), nil)
	if got := FormatSummaryRow(s.SummaryRows()[0], ctx); got != "300" {
		t.Errorf("summary = %s, want 300 (100 + 200, down the column)", got)
	}
}

// ---- the editor ----

// The Basic editor must offer operand KEYS. Offering display names works only while
// a display name happens to equal its field name — which is exactly the bug.
func TestEditorOperandOptionsAreKeysNotLabels(t *testing.T) {
	s := newCalcTable()
	opts := s.operandOptions("")

	byLabel := map[string]string{}
	for _, o := range opts {
		byLabel[o.Label] = o.Key
	}
	if byLabel["Gross revenue"] != "Revenue" {
		t.Errorf("operand for %q = %q, want the key %q", "Gross revenue", byLabel["Gross revenue"], "Revenue")
	}
	if len(opts) != 3 {
		t.Errorf("got %d operand options, want 3", len(opts))
	}
}

// A calculated column must not be offered as an operand of itself: that is a cycle,
// and the engine would refuse to evaluate it.
func TestEditorExcludesTheColumnBeingEdited(t *testing.T) {
	s := newCalcTable()
	s.AddCalculated(UserCalculatedColumn{ID: "profit", DisplayName: "Profit",
		Fn: CALC_FN_SUBTRACT, Operands: []string{"Revenue", "Cost"}})

	for _, o := range s.operandOptions("profit") {
		if o.Key == CalcRef("profit") {
			t.Error("the column being edited was offered as an operand of itself")
		}
	}
	// It IS offered when editing something else.
	found := false
	for _, o := range s.operandOptions("other") {
		if o.Key == CalcRef("profit") {
			found = true
		}
	}
	if !found {
		t.Error("an existing calculated column should be referenceable from another one")
	}
}

func TestEditorBasicModeSavesAColumn(t *testing.T) {
	s := newCalcTable()
	e := s.calcEditorState()

	e.name.Set("Total")
	e.fn.Set(string(CALC_FN_SUM))
	e.operands.Set([]string{"Revenue", "Cost"})
	e.precision.Set("0")
	if msg := s.calcValidate(e, false); msg != "" {
		t.Fatalf("valid form rejected: %s", msg)
	}
	s.saveCalc(e, false)

	if len(s.Calculated()) != 1 {
		t.Fatalf("saved %d columns, want 1", len(s.Calculated()))
	}
	uc := s.Calculated()[0]
	if uc.Fn != CALC_FN_SUM || !reflect.DeepEqual(uc.Operands, []string{"Revenue", "Cost"}) {
		t.Errorf("saved the wrong spec: %+v", uc)
	}
	if got, want := calcValues(t, s, uc.ID), []string{"140", "250"}; !reflect.DeepEqual(got, want) {
		t.Errorf("the saved column computes %v, want %v", got, want)
	}
	if e.name.Get() != "" || e.editingID.Get() != "" {
		t.Error("the form did not reset after saving")
	}
}

// Advanced mode saves fn "custom", with no stale basic spec left beside it — two
// contradictory sources of truth would be worse than either.
func TestEditorAdvancedModeSavesAFormula(t *testing.T) {
	s := newCalcTable()
	e := s.calcEditorState()

	e.name.Set("Margin")
	e.advanced.Set(true)
	// Formulas reference columns by DISPLAY name, unlike operands.
	e.formula.Set("([Gross revenue] - [Direct cost]) / [Gross revenue] * 100")
	e.precision.Set("0")
	e.operands.Set([]string{"Revenue"}) // left over from basic mode; must be dropped
	s.saveCalc(e, false)

	uc := s.Calculated()[0]
	if uc.Fn != CALC_FN_CUSTOM {
		t.Errorf("Fn = %q, want custom", uc.Fn)
	}
	if len(uc.Operands) != 0 {
		t.Errorf("a stale basic operand list survived: %v", uc.Operands)
	}
	if got, want := calcValues(t, s, uc.ID), []string{"60", "75"}; !reflect.DeepEqual(got, want) {
		t.Errorf("margin = %v, want %v", got, want)
	}
}

// The operand rules belong to the model, not the form.
func TestEditorValidation(t *testing.T) {
	cases := []struct {
		name  string
		setup func(*calcEditor)
		want  string
	}{
		{"no name", func(e *calcEditor) { e.operands.Set([]string{"Revenue"}) }, "Enter a name."},
		{"no operands", func(e *calcEditor) { e.name.Set("X") }, "Pick at least one column."},
		{"binary needs two", func(e *calcEditor) {
			e.name.Set("X")
			e.fn.Set(string(CALC_FN_SUBTRACT))
			e.operands.Set([]string{"Revenue"})
		}, "Pick both columns."},
		{"empty formula", func(e *calcEditor) {
			e.name.Set("X")
			e.advanced.Set(true)
		}, "Enter a formula."},
		{"ok", func(e *calcEditor) {
			e.name.Set("X")
			e.operands.Set([]string{"Revenue", "Cost"})
		}, ""},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			s := newCalcTable()
			e := s.calcEditorState()
			c.setup(e)
			if got := s.calcValidate(e, false); got != c.want {
				t.Errorf("calcValidate = %q, want %q", got, c.want)
			}
		})
	}

	// A formula that does not compile reports the compiler's own error, and does not
	// save: it would add a column of dashes and leave the user with no idea why.
	s := newCalcTable()
	e := s.calcEditorState()
	e.name.Set("X")
	e.advanced.Set(true)
	e.formula.Set("[Gross revenue] * ")
	if s.calcValidate(e, false) == "" {
		t.Error("an unparseable formula passed validation")
	}
	s.saveCalc(e, false)
	if len(s.Calculated()) != 0 {
		t.Error("an invalid formula was saved anyway")
	}
	if e.errorMsg.Get() == "" {
		t.Error("saving an invalid form set no error message")
	}
}

// Editing loads the existing spec and REPLACES it, rather than adding a second one.
func TestEditorEditAndRemove(t *testing.T) {
	s := newCalcTable()
	e := s.calcEditorState()

	e.name.Set("Total")
	e.operands.Set([]string{"Revenue", "Cost"})
	s.saveCalc(e, false)
	id := s.Calculated()[0].ID

	s.loadColumn(e, s.Calculated()[0])
	if e.editingID.Get() != id || e.advanced.Get() {
		t.Fatalf("loadColumn did not restore basic mode: id=%q advanced=%v", e.editingID.Get(), e.advanced.Get())
	}
	if !reflect.DeepEqual(e.operands.Get(), []string{"Revenue", "Cost"}) {
		t.Errorf("operands not loaded: %v", e.operands.Get())
	}

	e.fn.Set(string(CALC_FN_SUBTRACT))
	s.saveCalc(e, false)
	if len(s.Calculated()) != 1 {
		t.Fatalf("editing added a duplicate: %d columns", len(s.Calculated()))
	}
	if s.Calculated()[0].Fn != CALC_FN_SUBTRACT {
		t.Errorf("the edit was not applied: %+v", s.Calculated()[0])
	}

	s.RemoveCalculated(id)
	if len(s.Calculated()) != 0 {
		t.Error("RemoveCalculated left the column behind")
	}
}

// Deleting a column the table is SORTED BY must drop the sort — a sort pointing at a
// column that no longer exists silently stops sorting.
func TestRemoveCalculatedDropsItsSort(t *testing.T) {
	s := newCalcTable()
	s.AddCalculated(UserCalculatedColumn{ID: "profit", DisplayName: "Profit",
		Fn: CALC_FN_SUBTRACT, Operands: []string{"Revenue", "Cost"}})

	s.ToggleSort(CalcRef("profit"))
	if s.OrderBy().Identifier != CalcRef("profit") {
		t.Fatal("could not sort by the calculated column")
	}
	s.RemoveCalculated("profit")
	if s.OrderBy().Identifier != "" {
		t.Errorf("sort still points at the deleted column: %q", s.OrderBy().Identifier)
	}
}

func TestEditorSavesASummaryRow(t *testing.T) {
	s := newCalcTable()
	e := s.calcEditorState()

	e.view.Set("summary")
	e.name.Set("Total revenue")
	e.fn.Set(string(CALC_FN_SUM))
	e.operands.Set([]string{"Revenue"})
	e.precision.Set("0")
	s.saveCalc(e, true)

	if len(s.SummaryRows()) != 1 || len(s.Calculated()) != 0 {
		t.Fatalf("summary=%d calculated=%d, want 1 and 0", len(s.SummaryRows()), len(s.Calculated()))
	}
	s.Render()
	ctx := NewCalcContext(s.FilteredRows(), s.Columns(), s.Calculated(), nil)
	if got := FormatSummaryRow(s.SummaryRows()[0], ctx); got != "300" {
		t.Errorf("summary = %s, want 300", got)
	}
}

// A calculated column may reference another one — the engine resolves _calc_<id>
// through the same operand path.
func TestCalculatedColumnCanReferenceAnother(t *testing.T) {
	s := newCalcTable()
	s.AddCalculated(UserCalculatedColumn{
		ID: "profit", DisplayName: "Profit", Fn: CALC_FN_SUBTRACT,
		Operands: []string{"Revenue", "Cost"},
		DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
	})
	s.AddCalculated(UserCalculatedColumn{
		ID: "double", DisplayName: "Double profit", Fn: CALC_FN_SUM,
		Operands: []string{CalcRef("profit"), CalcRef("profit")},
		DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
	})
	if got, want := calcValues(t, s, "double"), []string{"120", "300"}; !reflect.DeepEqual(got, want) {
		t.Errorf("calc-in-calc = %v, want %v", got, want)
	}
}

// ---- syntax highlighting ----

// The highlighter is LEXICAL, not a parse: it has to colour a half-typed formula
// that does not compile yet, which is exactly when the colours earn their keep.
func TestHighlightFormula(t *testing.T) {
	cases := []struct {
		name string
		src  string
		want []string // fragments that must appear
	}{
		{"cell ref", "[Revenue]", []string{`<span class="` + fmlCellClass + `">[Revenue]</span>`}},
		{"column ref", "{Revenue}", []string{`<span class="` + fmlColumnClass + `">{Revenue}</span>`}},
		{"nested braces stay one span", "{Revenue:1:ROW()}",
			[]string{`<span class="` + fmlColumnClass + `">{Revenue:1:ROW()}</span>`}},
		{"function", "SUM(",
			[]string{`<span class="` + fmlFuncClass + `">SUM</span>`}},
		{"function with a space before the paren", "SUM (",
			[]string{`<span class="` + fmlFuncClass + `">SUM</span>`}},
		{"number", "12.5", []string{`<span class="` + fmlNumberClass + `">12.5</span>`}},
		{"constant", "PI", []string{`<span class="` + fmlNumberClass + `">PI</span>`}},
		{"operators", "1 + 2", []string{`<span class="` + fmlOpClass + `">+</span>`}},
		{"a bare word is not coloured", "Revenue", []string{"Revenue"}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := HighlightFormula(c.src)
			for _, want := range c.want {
				if !strings.Contains(got, want) {
					t.Errorf("HighlightFormula(%q) = %s\nmissing %s", c.src, got, want)
				}
			}
		})
	}
}

// It runs on every keystroke, so it must survive a formula mid-type: an unclosed
// bracket, a trailing operator, a lone brace.
func TestHighlightFormulaToleratesHalfTypedInput(t *testing.T) {
	for _, src := range []string{"[Reven", "{Rev", "SUM({Revenue}", "1 +", "((", "[", "{", ""} {
		if got := HighlightFormula(src); got == "" && src != "" {
			t.Errorf("HighlightFormula(%q) produced nothing", src)
		}
	}
}

// The overlay is injected as raw HTML, so a column name containing markup must not
// escape into the DOM.
func TestHighlightFormulaEscapesHTML(t *testing.T) {
	got := HighlightFormula(`[<script>alert(1)</script>]`)
	if strings.Contains(got, "<script>") {
		t.Errorf("HighlightFormula did not escape markup: %s", got)
	}
	if !strings.Contains(got, "&lt;script&gt;") {
		t.Errorf("expected escaped markup, got: %s", got)
	}
}

// ---- the chooser ----

// A calculated column and a summary row are different objects, so the editor opens
// on a chooser rather than a single form with a switch.
func TestEditorOpensOnTheChooser(t *testing.T) {
	s := newCalcTable()
	e := s.calcEditorState()
	if e.view.Get() != "menu" {
		t.Errorf("editor opened on %q, want the chooser", e.view.Get())
	}

	// The panel's content is inside a portal, whose children the SERVER does not
	// render (it does not own document.body) — so exercise the panel builders
	// directly rather than through the closed popover.
	html := renderNode(s.calcChooser(e))
	for _, want := range []string{"Calculated column", "A new column computed for each row", "Summary row", "A total or aggregate shown in the footer"} {
		if !strings.Contains(html, want) {
			t.Errorf("the chooser is missing %q:\n%s", want, html)
		}
	}

	// The column form and the summary form are different forms, not one form with a
	// switch: the column has an Align field and the summary aggregates down a column.
	col := renderNode(s.calcForm(e, false))
	if !strings.Contains(col, "Column name") || !strings.Contains(col, "Align") {
		t.Errorf("the column form is wrong:\n%s", col)
	}
	sum := renderNode(s.calcForm(e, true))
	if !strings.Contains(sum, "Label") || !strings.Contains(sum, "Aggregate down this column") {
		t.Errorf("the summary form is wrong:\n%s", sum)
	}
	if strings.Contains(sum, "Align") {
		t.Error("a footer row has no column of its own to align")
	}

	// Both offer Basic / Advanced.
	for _, want := range []string{"Basic", "Advanced"} {
		if !strings.Contains(col, want) {
			t.Errorf("the form is missing the %q mode", want)
		}
	}

	// Advanced shows the highlighted overlay and the three insert menus.
	e.advanced.Set(true)
	e.formula.Set("[Gross revenue] * 12")
	adv := renderNode(s.calcForm(e, false))
	if !strings.Contains(adv, `<span class="`+fmlCellClass+`">[Gross revenue]</span>`) {
		t.Errorf("the formula is not syntax-highlighted:\n%s", adv)
	}
	for _, want := range []string{"Column", "Function", "Constant"} {
		if !strings.Contains(adv, ">"+want+"<") {
			t.Errorf("the %q insert menu is missing", want)
		}
	}
}

// Editing an existing definition jumps straight to the right form, rather than
// making the user pick its kind again.
func TestEditingJumpsToTheRightForm(t *testing.T) {
	s := newCalcTable()
	e := s.calcEditorState()

	s.AddCalculated(UserCalculatedColumn{ID: "c", DisplayName: "C", Fn: CALC_FN_SUM, Operands: []string{"Revenue"}})
	s.AddSummaryRow(UserSummaryRow{ID: "r", Label: "R", Fn: CALC_FN_SUM, Operands: []string{"Revenue"}})

	s.loadColumn(e, s.Calculated()[0])
	if e.view.Get() != "column" {
		t.Errorf("editing a column opened view %q, want \"column\"", e.view.Get())
	}
	s.loadSummary(e, s.SummaryRows()[0])
	if e.view.Get() != "summary" {
		t.Errorf("editing a summary row opened view %q, want \"summary\"", e.view.Get())
	}

	// Closing the popover abandons the draft, so reopening starts clean rather than
	// resuming a half-written formula the user walked away from.
	e.reset()
	if e.view.Get() != "menu" || e.editingID.Get() != "" || e.formula.Get() != "" {
		t.Error("reset did not return the editor to a clean chooser")
	}
}

// ---- the function insert menu ----

// Thirty-five function names in one flat list is a wall. Grouped by what they are FOR,
// with a signature and a sentence, it is something you can find SUM in.
func TestFormulaFunctionGroups(t *testing.T) {
	want := []string{"Aggregate", "Math", "Trigonometry", "Logic", "Row"}
	if len(FormulaFunctionGroups) != len(want) {
		t.Fatalf("got %d groups, want %d", len(FormulaFunctionGroups), len(want))
	}
	for i, label := range want {
		if FormulaFunctionGroups[i].Label != label {
			t.Errorf("group %d is %q, want %q", i, FormulaFunctionGroups[i].Label, label)
		}
	}

	// Every function carries a signature and a description — they are what the menu
	// shows and what its search matches, not decoration.
	for _, g := range FormulaFunctionGroups {
		for _, f := range g.Fns {
			if f.Name == "" || f.Sig == "" || f.Desc == "" {
				t.Errorf("%s/%s is missing its signature or description: %+v", g.Label, f.Name, f)
			}
			if !strings.HasPrefix(f.Sig, f.Name+"(") {
				t.Errorf("%s's signature %q does not start with its own name", f.Name, f.Sig)
			}
		}
	}
}

// Every function the menu offers must actually EXIST in the engine — an insert menu
// that offers a function the evaluator rejects is worse than no menu.
func TestEveryOfferedFunctionEvaluates(t *testing.T) {
	// A formula per function, using its own arity.
	args := map[string]string{
		"IF": "IF(1, 2, 3)", "ATAN2": "ATAN2(1, 1)", "POWER": "POWER(2, 3)",
		"MOD": "MOD(5, 2)", "LOG": "LOG(100, 10)", "ROUND": "ROUND(1.5, 0)",
		"ROW": "ROW()", "PI": "PI()", "AND": "AND(1, 1)", "OR": "OR(1, 0)", "NOT": "NOT(0)",
	}
	for _, g := range FormulaFunctionGroups {
		for _, f := range g.Fns {
			src, ok := args[f.Name]
			if !ok {
				src = f.Name + "(1)"
			}
			if _, err := CompileFormula(src); err != nil {
				t.Errorf("the menu offers %s, but %q does not compile: %v", f.Name, src, err)
			}
		}
	}
}

// And every constant it offers must be one the evaluator knows.
func TestEveryOfferedConstantExists(t *testing.T) {
	if len(FormulaConstantOptions) != len(FormulaConstants) {
		t.Errorf("the menu offers %d constants, the engine knows %d",
			len(FormulaConstantOptions), len(FormulaConstants))
	}
	for _, c := range FormulaConstantOptions {
		if _, ok := FormulaConstants[c.Name]; !ok {
			t.Errorf("the menu offers %q, which the engine does not know", c.Name)
		}
		if c.Desc == "" {
			t.Errorf("%q has no description — the name alone says nothing", c.Name)
		}
	}
}

// The menu's search matches the DESCRIPTION too, which is the whole point of carrying
// the prose around: someone who wants a total types "total", not "SUM".
func TestFormulaMenuSearch(t *testing.T) {
	got := filterFormulaGroups("total")
	found := false
	for _, g := range got {
		for _, f := range g.Fns {
			if f.Name == "SUM" {
				found = true
			}
		}
	}
	if !found {
		t.Error(`searching "total" did not find SUM — the description is not being matched`)
	}

	// Empty groups drop out rather than leaving a bare heading behind.
	for _, g := range filterFormulaGroups("sine") {
		if len(g.Fns) == 0 {
			t.Errorf("group %q survived the filter with no functions in it", g.Label)
		}
	}

	// An empty query is everything.
	if len(filterFormulaGroups("")) != len(FormulaFunctionGroups) {
		t.Error("an empty query should show every group")
	}
	// A query matching nothing yields nothing (the menu shows its own empty state).
	if got := filterFormulaGroups("zzzz"); len(got) != 0 {
		t.Errorf("a query matching nothing returned %d groups", len(got))
	}
}

func TestFormulaFunctionNamesFlattensTheGroups(t *testing.T) {
	names := FormulaFunctionNames()
	if len(names) < 30 {
		t.Errorf("flattened to %d names, want the full set", len(names))
	}
	for _, want := range []string{"SUM", "ROUND", "ATAN2", "IF", "ROW"} {
		if !contains2(names, want) {
			t.Errorf("%s missing from the flattened names", want)
		}
	}
}
