//go:build js && wasm

package wasmruntime

import (
	"strings"
	"syscall/js"
	"testing"

	"kjol/vdom"
)

// Run from kjol/go:
//
//	GOOS=js GOARCH=wasm go test -exec="node testdata/domexec.js" ./wasmruntime

// A Ref must be attached when the element is created, survive a diff that reuses
// the element, and be nil again once the element is gone — otherwise measurement
// code silently reads a stale node.
func TestRefLifecycle(t *testing.T) {
	root := document.Call("createElement", "div")
	panel := vdom.NewRef()

	if panel.Mounted() {
		t.Fatal("a fresh ref should not be mounted")
	}

	a := vdom.El("div", vdom.WithRef(panel), vdom.Attr("class", "one"))
	patchChildren(root, nil, one(a))
	if !panel.Mounted() {
		t.Fatal("ref was not attached on create")
	}
	created, _ := panel.Node().(js.Value)

	// A re-render builds a fresh VNode carrying the same *Ref; the diff adopts the
	// existing element, so the ref must still point at it.
	b := vdom.El("div", vdom.WithRef(panel), vdom.Attr("class", "two"))
	patchChildren(root, one(a), one(b))
	if !panel.Mounted() {
		t.Fatal("ref was detached by a re-render that reused the element")
	}
	if adopted, _ := panel.Node().(js.Value); !adopted.Equal(created) {
		t.Error("ref points at a different node after a reusing diff")
	}

	// Removing the element must clear the ref.
	patchChildren(root, one(b), nil)
	if panel.Mounted() {
		t.Error("ref still points at a removed element")
	}
}

// A tag change replaces the element. createDOM runs before release (replaceChild
// needs both nodes), so a naive detach-on-release would nil the ref that was just
// pointed at the NEW element.
func TestRefSurvivesTagChange(t *testing.T) {
	root := document.Call("createElement", "div")
	r := vdom.NewRef()

	a := vdom.El("div", vdom.WithRef(r))
	patchChildren(root, nil, one(a))

	b := vdom.El("span", vdom.WithRef(r))
	patchChildren(root, one(a), one(b))

	if !r.Mounted() {
		t.Fatal("ref was cleared when the element changed tag")
	}
	if got := Measure(r); got != (Rect{}) { // shim reports a zero rect by default
		_ = got
	}
	if !strings.Contains(root.Get("innerHTML").String(), "<span") {
		t.Error("replacement element was not rendered")
	}
}

// Measure must read the element's box, and must return the zero Rect (not a box at
// the origin) for an unmounted ref, so positioning code can tell "not laid out yet"
// from "genuinely at 0,0".
func TestMeasure(t *testing.T) {
	if got := Measure(vdom.NewRef()); !got.Empty() {
		t.Errorf("unmounted ref measured as %+v, want the zero Rect", got)
	}

	root := document.Call("createElement", "div")
	r := vdom.NewRef()
	patchChildren(root, nil, one(vdom.El("div", vdom.WithRef(r))))

	n, _ := r.Node().(js.Value)
	rect := n.Get("rect")
	rect.Set("left", 10)
	rect.Set("top", 20)
	rect.Set("width", 100)
	rect.Set("height", 40)

	got := Measure(r)
	want := Rect{X: 10, Y: 20, Width: 100, Height: 40}
	if got != want {
		t.Fatalf("Measure = %+v, want %+v", got, want)
	}
	if got.Right() != 110 || got.Bottom() != 60 || got.CenterX() != 60 {
		t.Errorf("derived edges wrong: right=%v bottom=%v centerX=%v", got.Right(), got.Bottom(), got.CenterX())
	}
	if got.Empty() {
		t.Error("a measured element reported Empty()")
	}
}

// SetStyle writes straight to the DOM, deliberately bypassing the vdom — that is
// what keeps per-frame repositioning from re-rendering the whole app.
func TestSetStyleBypassesVDOM(t *testing.T) {
	root := document.Call("createElement", "div")
	r := vdom.NewRef()
	patchChildren(root, nil, one(vdom.El("div", vdom.WithRef(r))))

	SetStyle(r, "top", "42px")
	SetStyle(r, "left", "7px")
	if got := root.Get("innerHTML").String(); !strings.Contains(got, "left: 7px; top: 42px") {
		t.Fatalf("styles not written: %s", got)
	}
	RemoveStyle(r, "top")
	if got := root.Get("innerHTML").String(); strings.Contains(got, "top:") {
		t.Errorf("style not removed: %s", got)
	}
	SetStyle(vdom.NewRef(), "top", "1px") // unmounted: must not panic
}

// The whole point of the portal: children mount into document.body, NOT into the
// parent — so a panel can escape an ancestor's overflow:hidden / transform.
func TestPortalMountsToBody(t *testing.T) {
	root := document.Call("createElement", "div")
	body := document.Get("body")
	before := body.Get("childNodes").Get("length").Int()

	tree := vdom.El("div", vdom.Attr("class", "clipped"),
		vdom.Portal(vdom.El("div", vdom.Attr("class", "panel"), vdom.Text("floating"))),
	)
	patchChildren(root, nil, one(tree))

	if got := root.Get("innerHTML").String(); strings.Contains(got, "floating") {
		t.Errorf("portal content rendered in-flow instead of at body level:\n%s", got)
	}
	if got := root.Get("innerHTML").String(); !strings.Contains(got, "data-portal") {
		t.Errorf("portal placeholder missing from the tree (sibling indexes will drift):\n%s", got)
	}
	if body.Get("childNodes").Get("length").Int() != before+1 {
		t.Fatal("portal container was not appended to body")
	}
	container := body.Get("childNodes").Index(before)
	if got := container.Get("innerHTML").String(); !strings.Contains(got, "floating") {
		t.Fatalf("portal children not in the body container: %s", got)
	}

	// Unmounting the portal must take the body-level container with it, or the
	// panel outlives the component that opened it.
	patchChildren(root, one(tree), nil)
	if body.Get("childNodes").Get("length").Int() != before {
		t.Error("portal container leaked into body after unmount")
	}
}

// A portal's children must diff against the body container across re-renders, not
// be recreated or appended to the placeholder.
func TestPortalPatchesChildrenInPlace(t *testing.T) {
	root := document.Call("createElement", "div")
	body := document.Get("body")
	before := body.Get("childNodes").Get("length").Int()

	a := vdom.El("div", vdom.Portal(vdom.El("p", vdom.Text("first"))))
	patchChildren(root, nil, one(a))
	container := body.Get("childNodes").Index(before)

	b := vdom.El("div", vdom.Portal(vdom.El("p", vdom.Text("second"))))
	patchChildren(root, one(a), one(b))

	if body.Get("childNodes").Get("length").Int() != before+1 {
		t.Fatal("re-render created a second portal container")
	}
	got := container.Get("innerHTML").String()
	if !strings.Contains(got, "second") || strings.Contains(got, "first") {
		t.Errorf("portal children not patched in place: %s", got)
	}
	patchChildren(root, one(b), nil)
}

// Outside-click detection is built on these two.
func TestContainsAndClosestAttr(t *testing.T) {
	root := document.Call("createElement", "div")
	panel := vdom.NewRef()
	inner := vdom.NewRef()

	tree := vdom.El("div", vdom.Attr("data-floating-id", "menu-1"), vdom.WithRef(panel),
		vdom.El("button", vdom.WithRef(inner), vdom.Text("item")),
	)
	patchChildren(root, nil, one(tree))

	target := inner.Node()
	if !Contains(panel, target) {
		t.Error("Contains missed a descendant")
	}
	other := vdom.NewRef()
	patchChildren(root, nil, one(vdom.El("div", vdom.WithRef(other))))
	if Contains(other, target) {
		t.Error("Contains matched an unrelated element")
	}

	id, ok := ClosestAttr(target, "[data-floating-id]", "data-floating-id")
	if !ok || id != "menu-1" {
		t.Errorf("ClosestAttr = %q, %v; want \"menu-1\", true", id, ok)
	}
	if _, ok := ClosestAttr(nil, "[data-floating-id]", "data-floating-id"); ok {
		t.Error("ClosestAttr should report not-found for a nil target")
	}
}
