//go:build js && wasm

package wasmruntime

import (
	"syscall/js"

	"kjol/wasmruntime/vdom"
)

// The browser half of the host API (see host.go for the contract and the two
// rules for callers).

var window = js.Global()

// node resolves a Ref to its live DOM handle, or an invalid js.Value if the ref
// is unattached. Every entry point below guards on this, so calling a host
// function against an unmounted ref is a silent no-op rather than a panic — the
// common case during the first render, before the reconciler has committed.
func node(r *vdom.Ref) (js.Value, bool) {
	if r == nil {
		return js.Value{}, false
	}
	n, ok := r.Node().(js.Value)
	if !ok || !n.Truthy() {
		return js.Value{}, false
	}
	return n, true
}

// ---- measurement ----

// Measure runs getBoundingClientRect on an element. Returns the zero Rect if the
// ref is not mounted — callers must check Rect.Empty() before positioning against
// it (see host.go), or the panel lands at 0,0.
func Measure(r *vdom.Ref) Rect {
	n, ok := node(r)
	if !ok {
		return Rect{}
	}
	b := n.Call("getBoundingClientRect")
	return Rect{
		X:      b.Get("left").Float(),
		Y:      b.Get("top").Float(),
		Width:  b.Get("width").Float(),
		Height: b.Get("height").Float(),
	}
}

// Viewport is window.innerWidth/innerHeight — the collision boundary for the
// floating engine.
func Viewport() Size {
	return Size{
		Width:  window.Get("innerWidth").Float(),
		Height: window.Get("innerHeight").Float(),
	}
}

// CSSVarPx reads a CSS custom property off :root and resolves it to pixels,
// handling rem (against the root font size) and px. Returns 0 if unset or
// unparseable. Used for --radius-default, so the tutorial spotlight's corners
// match the app's theme.
func CSSVarPx(name string) float64 {
	root := document.Get("documentElement")
	style := window.Call("getComputedStyle", root)
	raw := style.Call("getPropertyValue", name).String()
	return parseCSSLength(raw, style.Get("fontSize").String())
}

// ---- theme ----

// PrefersDark reports whether the OS asks for a dark colour scheme.
func PrefersDark() bool {
	mq := window.Call("matchMedia", "(prefers-color-scheme: dark)")
	return mq.Truthy() && mq.Get("matches").Bool()
}

// OnMediaChange watches a media query. The theme controller uses it to follow the OS
// while the user has not overridden it — a preference changed in the system settings
// should reach an open tab, not wait for a reload.
func OnMediaChange(query string, fn func(matches bool)) Unsub {
	mq := window.Call("matchMedia", query)
	if !mq.Truthy() {
		return func() {}
	}
	cb := js.FuncOf(func(_ js.Value, args []js.Value) any {
		matches := false
		if len(args) > 0 {
			matches = args[0].Get("matches").Bool()
		}
		fn(matches)
		return nil
	})
	mq.Call("addEventListener", "change", cb)
	return func() {
		mq.Call("removeEventListener", "change", cb)
		cb.Release()
	}
}

// SetRootClass adds or removes a class on <html>.
//
// The THEME lives there rather than on the app's root element because the page's own
// background — the thing behind everything, painted before the app mounts — is styled
// from <html>. A dark class on #app leaves a white margin around a dark page.
func SetRootClass(class string, on bool) {
	list := document.Get("documentElement").Get("classList")
	if on {
		list.Call("add", class)
		return
	}
	list.Call("remove", class)
}

// ---- frame timing + the post-render hook ----

// Now is performance.now(): milliseconds since the page began navigating, with
// sub-millisecond resolution.
//
// It is measured from the same origin the browser uses for its own timings, so a
// reading taken when the first render commits IS the time it took this app to become
// interactive — not an interval the app started and stopped itself.
func Now() float64 {
	return window.Get("performance").Call("now").Float()
}

func RAF(fn func()) int {
	var cb js.Func
	cb = js.FuncOf(func(js.Value, []js.Value) any {
		cb.Release()
		fn()
		return nil
	})
	return window.Call("requestAnimationFrame", cb).Int()
}

func CancelRAF(id int) { window.Call("cancelAnimationFrame", id) }

// afterRender holds callbacks queued for the next render commit. See mount.go,
// which drains it once the DOM is up to date.
var afterRender []func()

// AfterRender runs fn once, right after the current render has been committed to
// the DOM. This is how a component measures something it just rendered: signal
// writes only *schedule* a render, so a ref read in the same tick is still empty.
//
//	open.Set(true)
//	wasmruntime.AfterRender(func() { reposition() }) // the panel exists by now
//
// If no render is pending (nothing changed), fn still runs — on the next frame —
// so a caller can never be stranded waiting for a commit that will not come.
func AfterRender(fn func()) {
	afterRender = append(afterRender, fn)
	if !renderScheduled {
		// No re-render coming; run on the next frame so the caller's contract
		// ("after the DOM is settled") still holds.
		RAF(flushAfterRender)
	}
}

func flushAfterRender() {
	if len(afterRender) == 0 {
		return
	}
	pending := afterRender
	afterRender = nil
	for _, fn := range pending {
		fn()
	}
}

// ---- imperative DOM writes (bypassing the vdom on purpose) ----

// SetStyle writes an inline style property directly on the element. Positioning
// goes through here rather than through a signal: a signal write re-renders the
// entire tree, and repositioning happens on every scroll and resize frame.
func SetStyle(r *vdom.Ref, prop, value string) {
	if n, ok := node(r); ok {
		n.Get("style").Call("setProperty", prop, value)
	}
}

func RemoveStyle(r *vdom.Ref, prop string) {
	if n, ok := node(r); ok {
		n.Get("style").Call("removeProperty", prop)
	}
}

// SetText / SetHTML write content imperatively. SetHTML exists for the formula
// editor's syntax-highlight overlay, which is repainted per keystroke and must not
// go through a full re-render.
func SetText(r *vdom.Ref, text string) {
	if n, ok := node(r); ok {
		n.Set("textContent", text)
	}
}

func SetHTML(r *vdom.Ref, html string) {
	if n, ok := node(r); ok {
		n.Set("innerHTML", html)
	}
}

// ---- focus, selection, scrolling ----

func Focus(r *vdom.Ref) {
	if n, ok := node(r); ok {
		n.Call("focus")
	}
}

func Blur(r *vdom.Ref) {
	if n, ok := node(r); ok {
		n.Call("blur")
	}
}

// SelectionRange / SetSelectionRange expose the caret in an <input>/<textarea> —
// what the formula editor needs to insert a function at the cursor.
func SelectionRange(r *vdom.Ref) (start, end int) {
	n, ok := node(r)
	if !ok {
		return 0, 0
	}
	return n.Get("selectionStart").Int(), n.Get("selectionEnd").Int()
}

func SetSelectionRange(r *vdom.Ref, start, end int) {
	if n, ok := node(r); ok {
		n.Call("setSelectionRange", start, end)
	}
}

func ScrollIntoView(r *vdom.Ref, smooth bool, block string) {
	n, ok := node(r)
	if !ok {
		return
	}
	behavior := "auto"
	if smooth {
		behavior = "smooth"
	}
	if block == "" {
		block = ScrollBlockCenter
	}
	opts := js.Global().Get("Object").New()
	opts.Set("behavior", behavior)
	opts.Set("block", block)
	n.Call("scrollIntoView", opts)
}

func ScrollLeft(r *vdom.Ref) float64 {
	n, ok := node(r)
	if !ok {
		return 0
	}
	return n.Get("scrollLeft").Float()
}

func SetScrollLeft(r *vdom.Ref, x float64) {
	if n, ok := node(r); ok {
		n.Set("scrollLeft", x)
	}
}

// OverflowsX reports whether an element's content is wider than the box it is
// clipped to — i.e. something is hidden.
//
// It compares scrollWidth (the full content) against clientWidth (the visible content
// box). Measure/getBoundingClientRect is the WRONG comparison here: it reports the
// element's own border box, which is by definition the size it was clipped to, so it
// can never reveal an overflow.
//
// A detached or display:none element has no layout and reports 0/0; that is not an
// overflow, so it answers false rather than a misleading true.
func OverflowsX(r *vdom.Ref) bool {
	n, ok := node(r)
	if !ok {
		return false
	}
	client := n.Get("clientWidth").Float()
	if client == 0 {
		return false
	}
	// Sub-pixel layout means scrollWidth can exceed clientWidth by a hair on content
	// that visually fits. Round up to whole pixels before believing it.
	return n.Get("scrollWidth").Float() > client+1
}

// ---- hit testing (outside-click) ----

// Contains reports whether target lies inside r's subtree. target is an
// Event.Target(). This is how a floating panel decides a click was "outside".
func Contains(r *vdom.Ref, target any) bool {
	n, ok := node(r)
	if !ok {
		return false
	}
	t, ok := target.(js.Value)
	if !ok || !t.Truthy() {
		return false
	}
	return n.Call("contains", t).Bool()
}

// ClosestAttr walks up from target to the nearest ancestor matching selector and
// returns that element's attr. It returns the attribute *value* rather than a
// handle on purpose: the floating layer identifies panels by string id, so Go can
// compare against its own open-order stack without needing JS object identity.
func ClosestAttr(target any, selector, attr string) (string, bool) {
	t, ok := target.(js.Value)
	if !ok || !t.Truthy() {
		return "", false
	}
	// target may be a text node (clicks land on text); closest() is an Element
	// method, so climb to the nearest element first.
	if t.Get("nodeType").Int() != 1 {
		t = t.Get("parentElement")
		if !t.Truthy() {
			return "", false
		}
	}
	el := t.Call("closest", selector)
	if !el.Truthy() {
		return "", false
	}
	v := el.Call("getAttribute", attr)
	if !v.Truthy() {
		return "", false
	}
	return v.String(), true
}

// QuerySelector finds an existing element anywhere in the document — for the
// tutorial, whose steps target app elements it does not own.
func QuerySelector(selector string) *vdom.Ref {
	r := vdom.NewRef()
	if el := document.Call("querySelector", selector); el.Truthy() {
		vdom.SetRefNode(r, el)
	}
	return r
}

// ---- global listeners ----

// OnWindow installs a window-level listener and returns its remover.
//
// capture matters: `scroll` does not bubble, so a capture-phase listener on
// window is the only way to hear scrolling inside a nested scroll container — a
// floating panel anchored to a row in a scrollable table depends on it.
func OnWindow(event string, capture bool, fn func(vdom.Event)) Unsub {
	return listen(window, event, capture, fn)
}

// OnDocument installs a document-level listener and returns its remover. Used for
// outside-click (mousedown, which fires before focus moves) and Escape (keydown).
func OnDocument(event string, capture bool, fn func(vdom.Event)) Unsub {
	return listen(document, event, capture, fn)
}

func listen(target js.Value, event string, capture bool, fn func(vdom.Event)) Unsub {
	cb := js.FuncOf(func(_ js.Value, args []js.Value) any {
		var ev js.Value
		if len(args) > 0 {
			ev = args[0]
		}
		fn(clientEvent{js: ev})
		return nil
	})
	target.Call("addEventListener", event, cb, capture)
	return func() {
		target.Call("removeEventListener", event, cb, capture)
		cb.Release()
	}
}

// ObserveResize fires fn whenever the element's own size changes. This is the fix
// for a real gap in the original TSX kit, which repositioned floating panels only
// on scroll and window resize — so a panel whose *content* grew (an async-loaded
// list, a filtered dropdown) stayed at its stale position.
func ObserveResize(r *vdom.Ref, fn func()) Unsub {
	n, ok := node(r)
	if !ok {
		return func() {}
	}
	ctor := window.Get("ResizeObserver")
	if !ctor.Truthy() {
		return func() {}
	}
	cb := js.FuncOf(func(js.Value, []js.Value) any { fn(); return nil })
	obs := ctor.New(cb)
	obs.Call("observe", n)
	return func() {
		obs.Call("disconnect")
		cb.Release()
	}
}

// ---- timers ----

// SetTimeout schedules fn. The hover bridge (moving the cursor from a trigger onto
// its panel across a gap without the panel vanishing) is built on these.
func SetTimeout(ms int, fn func()) int {
	var cb js.Func
	cb = js.FuncOf(func(js.Value, []js.Value) any {
		cb.Release()
		fn()
		return nil
	})
	return window.Call("setTimeout", cb, ms).Int()
}

func ClearTimeout(id int) {
	if id != 0 {
		window.Call("clearTimeout", id)
	}
}

// ---- localStorage ----

// StorageGet/Set/Remove wrap localStorage. Every call is guarded: localStorage
// throws in private-mode Safari and when storage is full, and a table remembering
// its column widths is never worth taking the app down for.
func StorageGet(key string) (string, bool) {
	ls := window.Get("localStorage")
	if !ls.Truthy() {
		return "", false
	}
	v := tryCall(ls, "getItem", key)
	if !v.Truthy() {
		return "", false
	}
	return v.String(), true
}

func StorageSet(key, value string) {
	if ls := window.Get("localStorage"); ls.Truthy() {
		tryCall(ls, "setItem", key, value)
	}
}

func StorageRemove(key string) {
	if ls := window.Get("localStorage"); ls.Truthy() {
		tryCall(ls, "removeItem", key)
	}
}

// tryCall invokes a JS method, swallowing a thrown exception (js.Value.Call panics
// on throw) and returning undefined instead.
func tryCall(recv js.Value, method string, args ...any) (out js.Value) {
	defer func() {
		if recover() != nil {
			out = js.Undefined()
		}
	}()
	return recv.Call(method, args...)
}

// ---- getting bytes out of wasm ----

// Download hands bytes to the browser as a file: Blob -> object URL -> a synthetic
// <a download> click -> revoke. This is the only way out of wasm for a generated
// CSV or PDF.
func Download(filename, mime string, data []byte) {
	url := blobURL(mime, data)
	defer window.Get("URL").Call("revokeObjectURL", url)

	a := document.Call("createElement", "a")
	a.Set("href", url)
	a.Set("download", filename)
	document.Get("body").Call("appendChild", a)
	a.Call("click")
	document.Get("body").Call("removeChild", a)
}

// Print loads bytes into a hidden iframe and opens the browser's print dialog on
// it — how a generated PDF gets printed without first being saved.
func Print(mime string, data []byte) {
	url := blobURL(mime, data)

	frame := document.Call("createElement", "iframe")
	frame.Get("style").Call("setProperty", "display", "none")
	frame.Set("src", url)

	var onload js.Func
	onload = js.FuncOf(func(js.Value, []js.Value) any {
		defer onload.Release()
		w := frame.Get("contentWindow")
		if w.Truthy() {
			w.Call("focus")
			w.Call("print")
		}
		// The iframe must outlive the dialog, so the URL is revoked on a delay
		// rather than immediately: revoking it while the dialog is open blanks the
		// preview in Chrome.
		SetTimeout(60_000, func() {
			window.Get("URL").Call("revokeObjectURL", url)
			if frame.Get("parentNode").Truthy() {
				document.Get("body").Call("removeChild", frame)
			}
		})
		return nil
	})
	frame.Set("onload", onload)
	document.Get("body").Call("appendChild", frame)
}

func blobURL(mime string, data []byte) string {
	buf := js.Global().Get("Uint8Array").New(len(data))
	js.CopyBytesToJS(buf, data)
	parts := js.Global().Get("Array").New()
	parts.Call("push", buf)
	opts := js.Global().Get("Object").New()
	opts.Set("type", mime)
	blob := js.Global().Get("Blob").New(parts, opts)
	return window.Get("URL").Call("createObjectURL", blob).String()
}
