//go:build js && wasm

// Package wasmruntime is the wasm client runtime for the neutral vdom package: it
// reconciles vdom.VNode trees into the real DOM (fresh mount or hydration of
// server-rendered DOM), and drives re-renders when signals change.
package wasmruntime

import (
	"syscall/js"

	"kjol/vdom"
)

var document = js.Global().Get("document")

// nodeRT is the per-node bookkeeping stored in VNode.Runtime (wasm only).
type nodeRT struct {
	dom     js.Value
	jsFuncs map[string]js.Func
	refs    map[string]*handlerRef
	// portal is the body-level container holding a TagPortal node's children. The
	// `dom` field is the hidden in-flow placeholder that marks its slot in the tree.
	portal js.Value
}

type handlerRef struct{ fn func(vdom.Event) }

func rt(n *vdom.VNode) *nodeRT {
	if n.Runtime == nil {
		n.Runtime = &nodeRT{jsFuncs: map[string]js.Func{}, refs: map[string]*handlerRef{}}
	}
	return n.Runtime.(*nodeRT)
}

// clientEvent adapts a DOM event to vdom.Event.
type clientEvent struct{ js js.Value }

func (e clientEvent) PreventDefault()  { e.js.Call("preventDefault") }
func (e clientEvent) StopPropagation() { e.js.Call("stopPropagation") }

func (e clientEvent) Value() string {
	t := e.js.Get("target")
	if !t.Truthy() {
		return ""
	}
	v := t.Get("value")
	if !v.Truthy() {
		return "" // e.g. a button has no value
	}
	return v.String()
}

func (e clientEvent) Checked() bool {
	t := e.js.Get("target")
	if !t.Truthy() {
		return false
	}
	return t.Get("checked").Truthy()
}

func (e clientEvent) Key() string {
	k := e.js.Get("key")
	if !k.Truthy() {
		return ""
	}
	return k.String()
}

func (e clientEvent) ClientX() int { return coord(e.js, "clientX") }
func (e clientEvent) ClientY() int { return coord(e.js, "clientY") }

func coord(ev js.Value, prop string) int {
	v := ev.Get(prop)
	if v.Type() != js.TypeNumber {
		return 0
	}
	return v.Int()
}

func (e clientEvent) Target() any {
	t := e.js.Get("target")
	if !t.Truthy() {
		return nil
	}
	return t
}

func (e clientEvent) SetData(format, data string) {
	if dt := e.js.Get("dataTransfer"); dt.Truthy() {
		dt.Call("setData", format, data)
	}
}

func (e clientEvent) GetData(format string) string {
	dt := e.js.Get("dataTransfer")
	if !dt.Truthy() {
		return ""
	}
	return dt.Call("getData", format).String()
}

func one(n *vdom.VNode) []*vdom.VNode {
	if n == nil {
		return nil
	}
	return []*vdom.VNode{n}
}

// ---- fresh create + diff ----

// SVG lives in its own XML namespace, and an element's namespace is fixed at
// CREATION — you cannot fix it afterwards with an attribute.
//
// document.createElement("svg") does NOT make an SVG element; it makes an
// HTMLUnknownElement that happens to be spelled "svg". It has no geometry, it
// renders nothing, and its children are inert. Everything still *looks* right in the
// DOM inspector, which is what makes this so easy to miss.
//
// The reason it only broke on NAVIGATION: a server-rendered page's icons come from
// the HTML parser, which handles <svg> as foreign content and gets the namespace
// right, and hydration merely adopts those nodes. Only elements the client CREATES —
// i.e. everything rendered after the first paint — went through createElement and
// came out inert. Hence: icons fine on load, gone as soon as you navigate.
const svgNamespace = "http://www.w3.org/2000/svg"

// elementNS returns the namespace an element and its subtree belong to. Nested
// <svg> switches into the SVG namespace; <foreignObject> switches back to HTML.
func elementNS(tag, parentNS string) string {
	switch tag {
	case "svg":
		return svgNamespace
	case "foreignObject":
		return "" // HTML content inside SVG
	}
	return parentNS
}

func createElement(tag, ns string) js.Value {
	if ns == "" {
		return document.Call("createElement", tag)
	}
	return document.Call("createElementNS", ns, tag)
}

// createDOM builds a node. ns is the namespace inherited from the parent element:
// "" for ordinary HTML, svgNamespace inside an <svg>.
func createDOM(n *vdom.VNode, ns string) js.Value {
	if n.Tag == "" {
		d := document.Call("createTextNode", n.Text)
		rt(n).dom = d
		vdom.SetRefNode(n.Ref, d)
		return d
	}
	if n.Tag == vdom.TagPortal {
		return createPortal(n)
	}

	ns = elementNS(n.Tag, ns)
	el := createElement(n.Tag, ns)
	rt(n).dom = el
	vdom.SetRefNode(n.Ref, el)
	for k, v := range n.Attrs {
		el.Call("setAttribute", k, v)
	}
	for k, v := range n.Props {
		setProp(el, k, v)
	}
	for name, h := range n.Events {
		addListener(n, name, h)
	}
	if n.HTML != "" {
		el.Set("innerHTML", n.HTML)
		return el
	}
	for _, c := range n.Children {
		el.Call("appendChild", createDOM(c, ns))
	}
	return el
}

// createPortal builds the two halves of a TagPortal node: a hidden placeholder
// that holds its slot in the tree (so sibling indexes — and hydration — still line
// up), and a container appended to document.body that the children actually render
// into. Returning the placeholder is what keeps the caller's appendChild correct.
func createPortal(n *vdom.VNode) js.Value {
	r := rt(n)

	placeholder := document.Call("createElement", "div")
	placeholder.Call("setAttribute", "data-portal", "")
	placeholder.Get("style").Call("setProperty", "display", "none")
	r.dom = placeholder

	r.portal = newPortalContainer()
	vdom.SetRefNode(n.Ref, r.portal)
	for _, c := range n.Children {
		// The container is a plain <div> on document.body, so its children start in
		// the HTML namespace however deeply the portal was nested.
		r.portal.Call("appendChild", createDOM(c, ""))
	}
	return placeholder
}

func newPortalContainer() js.Value {
	c := document.Call("createElement", "div")
	c.Call("setAttribute", "data-portal-container", "")
	document.Get("body").Call("appendChild", c)
	return c
}

// nsOf reads an existing element's namespace, so anything created beneath it
// inherits the right one. Derived from the live DOM rather than threaded through the
// call stack: the parent already knows the answer, and it cannot go stale.
func nsOf(el js.Value) string {
	if !el.Truthy() {
		return ""
	}
	uri := el.Get("namespaceURI")
	if uri.Truthy() && uri.String() == svgNamespace {
		return svgNamespace
	}
	return "" // HTML (or a text node / detached node)
}

// patchChildren diffs a child list against `parent`'s children.
func patchChildren(parent js.Value, old, next []*vdom.VNode) {
	ns := nsOf(parent) // read once: every child of this parent shares it
	n := max(len(old), len(next))
	for i := range n {
		var o, x *vdom.VNode
		if i < len(old) {
			o = old[i]
		}
		if i < len(next) {
			x = next[i]
		}
		patch(parent, o, x, ns)
	}
}

func patch(parent js.Value, o, x *vdom.VNode, ns string) {
	switch {
	case o == nil && x == nil:
		return
	case o == nil:
		parent.Call("appendChild", createDOM(x, ns))
	case x == nil:
		parent.Call("removeChild", rt(o).dom)
		release(o)
	case o.Tag != x.Tag:
		parent.Call("replaceChild", createDOM(x, ns), rt(o).dom)
		release(o)
	default:
		x.Runtime = o.Runtime // adopt dom + listeners
		dom := rt(x).dom
		if !dom.Truthy() {
			// o was a hydration hole (no adopted DOM node, e.g. a server/client
			// markup mismatch). Recreate this node fresh instead of calling into an
			// undefined DOM handle.
			parent.Call("appendChild", createDOM(x, ns))
			return
		}
		if x.Tag == vdom.TagPortal {
			// The placeholder stays put; the children diff against the body-level
			// container, not against `parent`.
			vdom.SetRefNode(x.Ref, rt(x).portal)
			patchChildren(rt(x).portal, o.Children, x.Children)
			return
		}
		// x is a fresh VNode each render, so re-point its ref at the adopted node.
		vdom.SetRefNode(x.Ref, dom)
		if x.Tag == "" {
			if x.Text != o.Text {
				dom.Set("nodeValue", x.Text)
			}
			return
		}
		updateAttrs(o, x)
		updateProps(o, x)
		updateEvents(o, x)
		if x.HTML != "" {
			if x.HTML != o.HTML {
				// innerHTML discards whatever DOM the old children owned, so their
				// listeners go with it.
				for _, c := range o.Children {
					release(c)
				}
				dom.Set("innerHTML", x.HTML)
			}
			return
		}
		// o was raw HTML and x isn't. A Raw node keeps its markup only in the DOM
		// (VNode.HTML, no Children), so patchChildren below would diff x's children
		// against an empty list and *append* them after markup nothing will ever
		// remove — e.g. /chart's SVG surviving a route change into /data. Clear it.
		if o.HTML != "" {
			dom.Set("innerHTML", "")
		}
		patchChildren(dom, o.Children, x.Children)
	}
}

func updateAttrs(o, x *vdom.VNode) {
	dom := rt(x).dom
	for k := range o.Attrs {
		if _, ok := x.Attrs[k]; !ok {
			dom.Call("removeAttribute", k)
		}
	}
	for k, v := range x.Attrs {
		if o.Attrs[k] != v {
			dom.Call("setAttribute", k, v)
		}
	}
}

func updateProps(o, x *vdom.VNode) {
	dom := rt(x).dom
	for k, v := range x.Props {
		if o.Props[k] != v && !propEquals(dom, k, v) {
			setProp(dom, k, v)
		}
	}
}

// setProp writes a live DOM property. Props travel as strings, but the property they
// land on may be a boolean — and JS reads the string "false" as TRUE, so writing
// el.checked = "false" ticks the box. Boolean props are converted before the write.
func setProp(el js.Value, k, v string) {
	if vdom.IsBoolProp(k) {
		el.Set(k, v == "true")
		return
	}
	el.Set(k, v)
}

// propEquals compares against what the DOM currently holds, in the property's own
// type. It is what lets a re-render skip touching an <input> the user is typing in.
func propEquals(el js.Value, k, v string) bool {
	cur := el.Get(k)
	if vdom.IsBoolProp(k) {
		return cur.Truthy() == (v == "true")
	}
	return cur.String() == v
}

func updateEvents(o, x *vdom.VNode) {
	r := rt(x) // same nodeRT as o (adopted above)
	for name, fn := range r.jsFuncs {
		if _, ok := x.Events[name]; !ok {
			r.dom.Call("removeEventListener", name, fn)
			fn.Release()
			delete(r.jsFuncs, name)
			delete(r.refs, name)
		}
	}
	for name, h := range x.Events {
		if ref, ok := r.refs[name]; ok {
			ref.fn = h
		} else {
			addListener(x, name, h)
		}
	}
}

func addListener(n *vdom.VNode, name string, handler func(vdom.Event)) {
	r := rt(n)
	ref := &handlerRef{fn: handler}
	fn := js.FuncOf(func(this js.Value, args []js.Value) any {
		var ev js.Value
		if len(args) > 0 {
			ev = args[0]
		}
		ref.fn(clientEvent{js: ev})
		return nil
	})
	r.dom.Call("addEventListener", name, fn)
	r.jsFuncs[name] = fn
	r.refs[name] = ref
}

func release(n *vdom.VNode) {
	if n.Runtime != nil {
		r := rt(n)
		for _, fn := range r.jsFuncs {
			fn.Release()
		}
		// A portal's children live at body level, so removing the placeholder from
		// the tree does not remove them — the container has to go explicitly, or the
		// panel outlives the component that opened it.
		if r.portal.Truthy() {
			if p := r.portal.Get("parentNode"); p.Truthy() {
				p.Call("removeChild", r.portal)
			}
		}
		detachRef(n, r)
	}
	for _, c := range n.Children {
		release(c)
	}
}

// detachRef nils out the node's Ref — but only if the ref still points at the node
// being released. On a tag change the reconciler creates the replacement *before*
// releasing the old node (replaceChild needs both), and a component holds one Ref
// across renders, so an unconditional detach here would nil the ref that createDOM
// had just pointed at the new element.
func detachRef(n *vdom.VNode, r *nodeRT) {
	if n.Ref == nil {
		return
	}
	cur, ok := n.Ref.Node().(js.Value)
	if !ok {
		return
	}
	if cur.Equal(r.dom) || (r.portal.Truthy() && cur.Equal(r.portal)) {
		vdom.SetRefNode(n.Ref, nil)
	}
}

// ---- hydration: adopt server-rendered DOM instead of creating it ----

func hydrateNode(dom js.Value, n *vdom.VNode) {
	if !dom.Truthy() {
		return // structural mismatch; leave a hole (a later re-render will fix)
	}
	rt(n).dom = dom
	vdom.SetRefNode(n.Ref, dom)
	if n.Tag == vdom.TagPortal {
		// SSR emitted the placeholder and nothing else (the server does not own
		// document.body), so adopt the placeholder but build the children fresh.
		r := rt(n)
		r.portal = newPortalContainer()
		vdom.SetRefNode(n.Ref, r.portal)
		for _, c := range n.Children {
			r.portal.Call("appendChild", createDOM(c, ""))
		}
		return
	}
	if n.Tag == "" {
		if dom.Get("nodeValue").String() != n.Text {
			dom.Set("nodeValue", n.Text)
		}
		return
	}
	for name, h := range n.Events {
		addListener(n, name, h)
	}

	// Reconcile attributes against the client's tree. Hydration ADOPTS the server's
	// DOM, so without this any disagreement between the server's HTML and the client's
	// render is baked in permanently: the DOM keeps the server's value, and no later
	// re-render fixes it either, because updateAttrs compares one client VNode against
	// the next — both of which agree with each other and disagree with the DOM.
	//
	// The client is the source of truth once it is running. It is also the only one of
	// the two that can be out of date in the other direction (a stale server binary,
	// an SSR cache), and a silently-wrong class is far worse than a redundant
	// setAttribute on first paint.
	for k, v := range n.Attrs {
		if dom.Call("getAttribute", k).String() != v {
			dom.Call("setAttribute", k, v)
		}
	}
	for k, v := range n.Props {
		setProp(dom, k, v)
	}
	if n.HTML != "" {
		return // trust server-rendered HTML
	}
	childNodes := dom.Get("childNodes")
	for i, c := range n.Children {
		hydrateNode(childNodes.Index(i), c)
	}
}
