package webui

import (
	"strconv"
	"strings"
	"testing"

	"kjol/vdom"
)

// fakeEvent is a vdom.Event with no DOM behind it — enough to drive Floating's
// close handlers natively. Target() is nil, which the outside-click test reads (quite
// correctly) as "this click was not inside the panel".
type fakeEvent struct{ key string }

func (fakeEvent) PreventDefault()       {}
func (fakeEvent) StopPropagation()      {}
func (fakeEvent) Value() string         { return "" }
func (fakeEvent) Checked() bool         { return false }
func (e fakeEvent) Key() string         { return e.key }
func (fakeEvent) ClientX() int          { return 0 }
func (fakeEvent) ClientY() int          { return 0 }
func (fakeEvent) Target() any           { return nil }
func (fakeEvent) SetData(_, _ string)   {}
func (fakeEvent) GetData(string) string { return "" }

var _ vdom.Event = fakeEvent{}

func dropdownOptions() []FormSelectOption {
	return []FormSelectOption{
		{Value: "eng", Label: "Engineering"},
		{Value: "res", Label: "Research"},
		{Value: "net", Label: "Networking"},
	}
}

// ---- closing ----

// The previous port took Open/OnToggle from the caller and had NO outside-click and
// NO Escape: once open, a dropdown stayed open until you clicked its trigger again.
// Both now come from Floating.

func TestMultiSelectClosesOnOutsideClick(t *testing.T) {
	m := NewMultiSelect(DropdownOptions{})
	m.Open()
	if !m.IsOpen() {
		t.Fatal("did not open")
	}

	m.f.onOutside(fakeEvent{}) // a mousedown that landed outside the panel
	if m.IsOpen() {
		t.Error("a multi-select must close on an outside click")
	}
}

func TestMultiSelectClosesOnEscape(t *testing.T) {
	m := NewMultiSelect(DropdownOptions{})
	m.Open()

	m.f.onKeydown(fakeEvent{key: vdom.KEY_ESCAPE})
	if m.IsOpen() {
		t.Error("a multi-select must close on Escape")
	}
}

func TestComboboxClosesOnOutsideClickAndEscape(t *testing.T) {
	c := NewCombobox(DropdownOptions{})

	c.Open()
	c.f.onOutside(fakeEvent{})
	if c.IsOpen() {
		t.Error("a combobox must close on an outside click")
	}

	c.Open()
	c.f.onKeydown(fakeEvent{key: vdom.KEY_ESCAPE})
	if c.IsOpen() {
		t.Error("a combobox must close on Escape")
	}
}

// Another key must NOT close it — otherwise typing in the search box would dismiss
// the thing you are searching.
func TestDropdownIgnoresOtherKeys(t *testing.T) {
	m := NewMultiSelect(DropdownOptions{})
	m.Open()
	m.f.onKeydown(fakeEvent{key: "a"})
	if !m.IsOpen() {
		t.Error("an ordinary keypress closed the dropdown")
	}
}

// ---- where they differ, deliberately ----

// A combobox picks ONE thing, so choosing is the end of the interaction: it closes.
func TestComboboxClosesWhenAnOptionIsChosen(t *testing.T) {
	c := NewCombobox(DropdownOptions{})
	got := ""
	c.Open()

	// Render, then invoke the option's click handler the way the DOM would.
	node := c.Render(FormComboboxProps{
		Options:  dropdownOptions(),
		OnChange: func(v string) { got = v },
	})
	clickOption(t, node, "Research")

	if got != "res" {
		t.Errorf("OnChange got %q, want \"res\"", got)
	}
	if c.IsOpen() {
		t.Error("a combobox should close once an option is chosen")
	}
}

// A multi-select picks SEVERAL, so choosing is NOT the end: closing the panel after
// every tick would make it unusable. This is the difference the two have to keep.
func TestMultiSelectStaysOpenWhenAnOptionIsChosen(t *testing.T) {
	m := NewMultiSelect(DropdownOptions{})
	var got []string
	m.Open()

	node := m.Render(FormMultiSelectProps{
		Options:  dropdownOptions(),
		Value:    []string{},
		OnChange: func(v []string) { got = v },
	})
	clickOption(t, node, "Research")

	if len(got) != 1 || got[0] != "res" {
		t.Errorf("OnChange got %v, want [res]", got)
	}
	if !m.IsOpen() {
		t.Error("a multi-select must STAY OPEN when an option is ticked — you are mid-selection")
	}
}

// ---- the search box actually filters ----
//
// It used to be decorative: it rendered, and filtered nothing.

func TestDropdownSearchFilters(t *testing.T) {
	m := NewMultiSelect(DropdownOptions{})
	m.Open()

	all := m.filter(dropdownOptions())
	if len(all) != 3 {
		t.Fatalf("an empty query filtered to %d options, want all 3", len(all))
	}

	m.search.Set("res")
	got := m.filter(dropdownOptions())
	if len(got) != 1 || got[0].Value != "res" {
		t.Errorf("query \"res\" matched %v, want just Research", got)
	}

	// Case-insensitive, on the label the user can actually see.
	m.search.Set("NETWORK")
	if got := m.filter(dropdownOptions()); len(got) != 1 || got[0].Value != "net" {
		t.Errorf("query \"NETWORK\" matched %v, want Networking", got)
	}
}

// Closing abandons the query — reopening to a stale filter, showing one of three
// options for no visible reason, is worse than retyping.
func TestClosingClearsTheSearch(t *testing.T) {
	m := NewMultiSelect(DropdownOptions{})
	m.Open()
	m.search.Set("res")

	m.Close()
	if m.search.Get() != "" {
		t.Errorf("search survived the close: %q", m.search.Get())
	}
}

// These open INSIDE other floatings (a filter popover, the calculated-column form).
// Without Standalone the single-open manager reads the dropdown as a rival panel and
// closes the very popover it lives in.
func TestDropdownsAreStandalone(t *testing.T) {
	m := NewMultiSelect(DropdownOptions{})
	if !m.f.opts.Standalone {
		t.Error("a dropdown must be Standalone, or opening it closes its own parent popover")
	}
}

// ---- the panel rows ----

// A multi-select row shows a CHECKBOX. Its job is to show a state you toggle, not an
// item you pick, and the tick is the only thing that says which is which.
//
// The unified dropdown dropped it: both kinds of row rendered as a bare label, so a
// multi-select gave no indication of what was already selected.
func TestMultiSelectOptionsHaveCheckboxes(t *testing.T) {
	m := NewMultiSelect(DropdownOptions{})
	m.Open()

	node := m.Render(FormMultiSelectProps{
		Options: dropdownOptions(),
		Value:   []string{"res"}, // Research is selected; the other two are not
	})

	for _, want := range []struct {
		label   string
		checked bool
	}{
		{"Engineering", false},
		{"Research", true},
		{"Networking", false},
	} {
		box := findCheckboxIn(optionRow(t, node, want.label))
		if box == nil {
			t.Errorf("option %q has no checkbox", want.label)
			continue
		}
		if got := box.Props["checked"]; got != strconv.FormatBool(want.checked) {
			t.Errorf("option %q: checked=%q, want %v", want.label, got, want.checked)
		}
	}
}

// ...and a combobox row does NOT. It picks one thing; a checkbox would promise a
// multiple selection it will not honour.
func TestComboboxOptionsHaveNoCheckboxes(t *testing.T) {
	c := NewCombobox(DropdownOptions{})
	c.Open()

	node := c.Render(FormComboboxProps{Options: dropdownOptions(), Value: "res"})
	if findCheckboxIn(optionRow(t, node, "Research")) != nil {
		t.Error("a single-select option must not render a checkbox")
	}
}

// ---- the collapsed trigger ----

// Past maxTagsBeforeCollapse the field stops showing pills and says how many are
// selected. Without this the trigger either grows without bound or silently clips the
// selection, and the user cannot tell how much they picked.
func TestMultiSelectCollapsesToACount(t *testing.T) {
	m := NewMultiSelect(DropdownOptions{})
	html := vdom.RenderHTML(m.Render(FormMultiSelectProps{
		Options:               manyOptions(6),
		Value:                 []string{"o1", "o2", "o3", "o4"}, // 4 > the default max of 3
		MaxTagsBeforeCollapse: 3,
	}))

	if !strings.Contains(html, "4 items selected") {
		t.Errorf("collapsed trigger does not say how many are selected:\n%s", html)
	}
	// The count is the SERVER's choice too — this needs no measurement, so SSR must not
	// ship a row of clipped pills that the client then has to swap out.
	if !strings.Contains(html, `class="flex flex-nowrap items-center gap-1 min-w-0 overflow-hidden hidden"`) {
		t.Errorf("the pills were not hidden server-side:\n%s", html)
	}
}

// One is one ITEM, not one items.
func TestCollapsedCountIsPluralised(t *testing.T) {
	if got := itemsSelected(1); got != "1 item selected" {
		t.Errorf("itemsSelected(1) = %q", got)
	}
	if got := itemsSelected(4); got != "4 items selected" {
		t.Errorf("itemsSelected(4) = %q", got)
	}
}

// Under the limit, the pills show and the summary is the hidden one. Both are always in
// the DOM: which is visible is settled by a measurement after render (collapseFace),
// and that measurement needs something to measure.
func TestMultiSelectShowsPillsWhenTheyFit(t *testing.T) {
	m := NewMultiSelect(DropdownOptions{})
	html := vdom.RenderHTML(m.Render(FormMultiSelectProps{
		Options:               dropdownOptions(),
		Value:                 []string{"eng", "res"},
		MaxTagsBeforeCollapse: 3,
	}))

	if !strings.Contains(html, "Engineering") || !strings.Contains(html, "Research") {
		t.Errorf("the selected pills are missing:\n%s", html)
	}
	if !strings.Contains(html, `class="truncate hidden"`) {
		t.Errorf("the count summary should be the hidden face here:\n%s", html)
	}
	// It must be RENDERED though, or there is nothing for collapseFace to reveal.
	if !strings.Contains(html, "2 items selected") {
		t.Errorf("the count summary was not rendered at all:\n%s", html)
	}
}

// The pills must lay out on ONE line and clip. With flex-wrap they wrap instead, so
// scrollWidth never exceeds clientWidth, and the overflow collapse can never fire.
func TestPillsAreNowrapSoOverflowIsMeasurable(t *testing.T) {
	m := NewMultiSelect(DropdownOptions{})
	html := vdom.RenderHTML(m.Render(FormMultiSelectProps{
		Options: dropdownOptions(),
		Value:   []string{"eng"},
	}))
	if strings.Contains(html, "flex-wrap") {
		t.Error("the pill row wraps; overflow would never be detected")
	}
	if !strings.Contains(html, "flex-nowrap") || !strings.Contains(html, "overflow-hidden") {
		t.Errorf("the pill row must be flex-nowrap + overflow-hidden:\n%s", html)
	}
}

func manyOptions(n int) []FormSelectOption {
	out := make([]FormSelectOption, n)
	for i := range out {
		id := strconv.Itoa(i + 1)
		out[i] = FormSelectOption{Value: "o" + id, Label: "Option " + id}
	}
	return out
}

// optionRow returns the panel row whose label is label.
func optionRow(t *testing.T, n *vdom.VNode, label string) *vdom.VNode {
	t.Helper()
	if row := findRow(n, label); row != nil {
		return row
	}
	t.Fatalf("no option row labelled %q", label)
	return nil
}

func findRow(n *vdom.VNode, label string) *vdom.VNode {
	if n == nil {
		return nil
	}
	if n.Tag == "button" && n.Attrs["role"] == "option" && nodeText(n) == label {
		return n
	}
	for _, c := range n.Children {
		if row := findRow(c, label); row != nil {
			return row
		}
	}
	return nil
}

func findCheckboxIn(n *vdom.VNode) *vdom.VNode {
	if n == nil {
		return nil
	}
	if n.Tag == "input" && n.Attrs["type"] == "checkbox" {
		return n
	}
	for _, c := range n.Children {
		if box := findCheckboxIn(c); box != nil {
			return box
		}
	}
	return nil
}

// clickOption finds an option button by its label and invokes its click handler.
func clickOption(t *testing.T, n *vdom.VNode, label string) {
	t.Helper()
	if !findAndClick(n, label) {
		t.Fatalf("no clickable option labelled %q in the rendered dropdown", label)
	}
}

func findAndClick(n *vdom.VNode, label string) bool {
	if n == nil {
		return false
	}
	if n.Tag == "button" && nodeText(n) == label {
		if h := n.Events[vdom.EVENT_CLICK]; h != nil {
			h(fakeEvent{})
			return true
		}
	}
	for _, c := range n.Children {
		if findAndClick(c, label) {
			return true
		}
	}
	return false
}

func nodeText(n *vdom.VNode) string {
	if n.Tag == "" {
		return n.Text
	}
	var b strings.Builder
	for _, c := range n.Children {
		b.WriteString(nodeText(c))
	}
	return b.String()
}
