package bundler

// Go-native Solid codegen: JSX tree -> dom-expressions runtime output.
//
// Strategy (behavior-parity with babel-preset-solid, not byte-parity):
//   - Static element structure is baked into an HTML template string cloned at
//     runtime via _$template(); we always quote attrs and emit closing tags
//     (valid HTML the browser parses to the same DOM babel's terser template does).
//   - Dynamic children -> _$insert(parent, () => expr, marker). Every dynamic
//     expression is wrapped in a thunk: correct and (for static exprs) merely an
//     extra no-op effect. babel unwraps `f()` -> `f` as an optimization; we skip
//     that for now (behavior-identical).
//   - Dynamic attrs -> _$effect(() => _$setAttribute(el, name, expr)).
//   - Components -> _$createComponent(Tag, props) with reactive prop getters.

import (
	"fmt"
	"regexp"
	"sort"
	"strings"

	esbuild "github.com/evanw/esbuild/pkg/api"
)

type solidGen struct {
	templates []string          // template HTML strings in first-seen order
	tmplVars  []string          // parallel _tmpl$N names
	tmplByStr map[string]string // dedupe identical templates
	helpers   map[string]bool   // solid-js/web helpers used
	events    map[string]bool   // delegated event names
	source    string            // the module source, for ref binding-kind lookup
	tmplN     int
}

func newSolidGen() *solidGen {
	return &solidGen{tmplByStr: map[string]string{}, helpers: map[string]bool{}, events: map[string]bool{}}
}

func (g *solidGen) helper(name string) string {
	g.helpers[name] = true
	return "_$" + name
}

func (g *solidGen) template(html string) string {
	if v, ok := g.tmplByStr[html]; ok {
		return v
	}
	g.tmplN++
	v := "_tmpl$"
	if g.tmplN > 1 {
		v = fmt.Sprintf("_tmpl$%d", g.tmplN)
	}
	g.tmplByStr[html] = v
	g.templates = append(g.templates, html)
	g.tmplVars = append(g.tmplVars, v)
	g.helper("template")
	return v
}

// compileSolidGo compiles a TSX source to Solid dom-expressions output. In dev
// mode each component is wrapped with solid-refresh so the module is an HMR
// boundary; prod (dev=false) leaves components bare (that's what SSR renders).
func compileSolidGo(src, _ string, dev bool) (string, error) {
	stripped, err := stripTypesPreserveJSX(src)
	if err != nil {
		return "", err
	}
	refresh := false
	if dev {
		stripped, refresh = wrapComponents(stripped)
	}

	g := newSolidGen()
	g.source = stripped
	body := g.transformJSX(stripped)

	var b strings.Builder
	for _, h := range sortedKeys(g.helpers) {
		fmt.Fprintf(&b, "import { %s as _$%s } from \"solid-js/web\";\n", h, h)
	}
	if refresh {
		b.WriteString(`import { $$component as _$$component } from "solid-refresh";` + "\n")
		b.WriteString(`import { $$registry as _$$registry } from "solid-refresh";` + "\n")
		b.WriteString(`import { $$refresh as _$$refresh } from "solid-refresh";` + "\n")
		b.WriteString("const _REGISTRY = _$$registry();\n")
	}
	for i, t := range g.templates {
		fmt.Fprintf(&b, "var %s = /*#__PURE__*/_$template(`%s`);\n", g.tmplVars[i], t)
	}
	b.WriteString(body)
	if refresh {
		b.WriteString("if (import.meta.hot) { _$$refresh(\"esm\", import.meta.hot, _REGISTRY); }\n")
	}
	if len(g.events) > 0 {
		evs := sortedKeys(g.events)
		q := make([]string, len(evs))
		for i, e := range evs {
			q[i] = `"` + e + `"`
		}
		fmt.Fprintf(&b, "_$delegateEvents([%s]);\n", strings.Join(q, ", "))
	}
	return b.String(), nil
}

// wrapComponents rewrites each top-level component declaration into a
// solid-refresh registry entry: `function A(){…}` → `const A =
// _$$component(_REGISTRY, "A", function A(){…});`. A declaration is a component
// if it's a PascalCase function (declaration or arrow/function const) whose body
// contains JSX. Returns the rewritten source and whether any component was found.
func wrapComponents(src string) (string, bool) {
	var out strings.Builder
	found := false
	for _, ch := range segmentTopLevel(src) {
		if rw, ok := rewrapComponent(ch); ok {
			found = true
			out.WriteString(rw)
		} else {
			out.WriteString(ch)
		}
	}
	return out.String(), found
}

var (
	reCompFunc  = regexp.MustCompile(`^(export\s+)?(default\s+)?function\s+([A-Z][\w$]*)`)
	reCompConst = regexp.MustCompile(`^(export\s+)?(?:const|let|var)\s+([A-Z][\w$]*)\s*=\s*`)
)

func rewrapComponent(chunk string) (string, bool) {
	trimmed := strings.TrimSpace(chunk)
	// A top-level `function Foo` is a component when its body contains JSX.
	if m := reCompFunc.FindStringSubmatch(trimmed); m != nil {
		if !containsJSX(trimmed) {
			return chunk, false
		}
		name := m[3]
		fn := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(trimmed, m[1]), m[2]))
		wrap := fmt.Sprintf(`_$$component(_REGISTRY, %q, %s)`, name, fn)
		switch {
		case m[2] != "": // export default function
			return fmt.Sprintf("const %s = %s;\nexport default %s;\n", name, wrap, name), true
		case m[1] != "": // export function
			return fmt.Sprintf("export const %s = %s;\n", name, wrap), true
		default:
			return fmt.Sprintf("const %s = %s;\n", name, wrap), true
		}
	}
	// A PascalCase const is a component when its initializer is a function
	// (inline JSX) OR a factory call, e.g. `const AlertGreen = makeAlert("green")`.
	if m := reCompConst.FindStringSubmatch(trimmed); m != nil {
		name := m[2]
		expr := strings.TrimSuffix(strings.TrimSpace(trimmed[len(m[0]):]), ";")
		if !containsJSX(trimmed) && !isComponentInitializer(expr) {
			return chunk, false
		}
		wrap := fmt.Sprintf(`_$$component(_REGISTRY, %q, %s)`, name, expr)
		prefix := ""
		if m[1] != "" {
			prefix = "export "
		}
		return fmt.Sprintf("%sconst %s = %s;\n", prefix, name, wrap), true
	}
	return chunk, false
}

var reCompInit = regexp.MustCompile(`^[A-Za-z_$][\w$.]*\s*(\(|=>)`)

// isComponentInitializer reports whether a const initializer is a function or a
// factory call producing a component (arrow, function expr, `id(...)`, `id => …`).
// Plain values ({…}, "…", [ … ], numbers) are excluded.
func isComponentInitializer(e string) bool {
	e = strings.TrimSpace(e)
	return strings.HasPrefix(e, "(") || strings.HasPrefix(e, "function ") ||
		strings.HasPrefix(e, "async ") || reCompInit.MatchString(e)
}

// containsJSX reports whether src has a JSX element in expression position,
// skipping strings/comments so a `<` inside them isn't counted.
func containsJSX(src string) bool {
	n := len(src)
	state := stNormal
	var prevSig byte
	for i := 0; i < n; i++ {
		c := src[i]
		switch state {
		case stNormal:
			switch c {
			case '/':
				if i+1 < n && src[i+1] == '/' {
					state = stLineComment
					continue
				}
				if i+1 < n && src[i+1] == '*' {
					state = stBlockComment
					continue
				}
				prevSig = c
			case '\'':
				state = stSingle
			case '"':
				state = stDouble
			case '`':
				state = stTemplate
			case '<':
				if regexAllowed(src, i, prevSig) && i+1 < n && (isASCIILetter(src[i+1]) || src[i+1] == '>') {
					return true
				}
				prevSig = c
			default:
				if !isSpace(c) {
					prevSig = c
				}
			}
		case stLineComment:
			if c == '\n' {
				state = stNormal
			}
		case stBlockComment:
			if c == '*' && i+1 < n && src[i+1] == '/' {
				state = stNormal
				i++
			}
		case stSingle:
			if c == '\\' {
				i++
			} else if c == '\'' {
				state = stNormal
				prevSig = c
			}
		case stDouble:
			if c == '\\' {
				i++
			} else if c == '"' {
				state = stNormal
				prevSig = c
			}
		case stTemplate:
			if c == '\\' {
				i++
			} else if c == '`' {
				state = stNormal
				prevSig = c
			}
		}
	}
	return false
}

func stripTypesPreserveJSX(src string) (string, error) {
	res := esbuild.Transform(src, esbuild.TransformOptions{
		Loader:   esbuild.LoaderTSX,
		JSX:      esbuild.JSXPreserve,
		Format:   esbuild.FormatESModule,
		LogLevel: esbuild.LogLevelSilent,
	})
	if len(res.Errors) > 0 {
		return "", fmt.Errorf("%s", res.Errors[0].Text)
	}
	return string(res.Code), nil
}

// transformJSX scans JS, finds JSX in expression position, and replaces each with
// its compiled expression. Non-JSX text passes through. Same lexer as
// captureBraces so `<` inside strings/comments/regex isn't mistaken for JSX.
func (g *solidGen) transformJSX(src string) string {
	var out strings.Builder
	n := len(src)
	state := stNormal
	var tmplStack []int
	var prevSig byte
	depth := 0
	for i := 0; i < n; i++ {
		c := src[i]
		switch state {
		case stNormal:
			switch c {
			case '/':
				if i+1 < n && src[i+1] == '/' {
					state = stLineComment
					out.WriteByte(c)
					continue
				}
				if i+1 < n && src[i+1] == '*' {
					state = stBlockComment
					out.WriteByte(c)
					continue
				}
				if regexAllowed(src, i, prevSig) {
					state = stRegex
				}
				prevSig = c
			case '\'':
				state = stSingle
				prevSig = c
			case '"':
				state = stDouble
				prevSig = c
			case '`':
				state = stTemplate
				prevSig = c
			case '{', '(', '[':
				depth++
				prevSig = c
			case '}':
				if len(tmplStack) > 0 && depth == tmplStack[len(tmplStack)-1] {
					tmplStack = tmplStack[:len(tmplStack)-1]
					depth--
					state = stTemplate
				} else {
					if depth > 0 {
						depth--
					}
					prevSig = c
				}
			case ')', ']':
				if depth > 0 {
					depth--
				}
				prevSig = c
			case '<':
				if regexAllowed(src, i, prevSig) && i+1 < n && (isASCIILetter(src[i+1]) || src[i+1] == '>') {
					if node, ni, err := parseJSX(src, i); err == nil {
						out.WriteString(g.genNode(&node))
						i = ni - 1
						prevSig = '>'
						continue
					}
				}
				prevSig = c
			default:
				if !isSpace(c) {
					prevSig = c
				}
			}
		case stLineComment:
			if c == '\n' {
				state = stNormal
			}
		case stBlockComment:
			if c == '*' && i+1 < n && src[i+1] == '/' {
				state = stNormal
				out.WriteByte(c)
				i++
				out.WriteByte(src[i])
				continue
			}
		case stSingle:
			if c == '\\' {
				out.WriteByte(c)
				i++
				if i < n {
					out.WriteByte(src[i])
				}
				continue
			} else if c == '\'' {
				state = stNormal
				prevSig = c
			}
		case stDouble:
			if c == '\\' {
				out.WriteByte(c)
				i++
				if i < n {
					out.WriteByte(src[i])
				}
				continue
			} else if c == '"' {
				state = stNormal
				prevSig = c
			}
		case stTemplate:
			if c == '\\' {
				out.WriteByte(c)
				i++
				if i < n {
					out.WriteByte(src[i])
				}
				continue
			} else if c == '`' {
				state = stNormal
				prevSig = c
			} else if c == '$' && i+1 < n && src[i+1] == '{' {
				depth++
				tmplStack = append(tmplStack, depth)
				state = stNormal
				out.WriteByte(c)
				i++
				out.WriteByte(src[i])
				continue
			}
		case stRegex:
			if c == '\\' {
				out.WriteByte(c)
				i++
				if i < n {
					out.WriteByte(src[i])
				}
				continue
			} else if c == '/' {
				state = stNormal
				prevSig = c
			}
		}
		out.WriteByte(c)
	}
	return out.String()
}

// compileExpr recompiles any JSX nested inside an opaque expression string.
func (g *solidGen) compileExpr(expr string) string {
	if !strings.Contains(expr, "<") {
		return expr
	}
	return g.transformJSX(expr)
}

// genNode compiles a top-level JSX node into a JS expression.
func (g *solidGen) genNode(node *jsxNode) string {
	switch node.kind {
	case jsxElement:
		return g.genElement(node)
	case jsxComponent:
		return g.genComponent(node)
	case jsxFragment:
		return g.genFragment(node)
	case jsxExpr:
		return g.compileExpr(node.expr)
	case jsxText:
		return jsStringLit(collapseText(node.text))
	}
	return "null"
}

// iife accumulates the navigation var decls and operation statements for one
// element's cloned template.
type iife struct {
	g     *solidGen
	elN   int
	decls []string
	ops   []string
}

func (c *iife) nextEl() string {
	c.elN++
	if c.elN == 1 {
		return "_el$"
	}
	return fmt.Sprintf("_el$%d", c.elN)
}

func (g *solidGen) genElement(node *jsxNode) string {
	annotateMarkers(node)
	tv := g.template(buildTemplate(node))

	c := &iife{g: g}
	root := c.nextEl()
	c.decls = append(c.decls, root+" = "+tv+"()")
	c.walk(node, root)

	if len(c.ops) == 0 {
		return tv + "()"
	}
	var b strings.Builder
	b.WriteString("(() => { var " + strings.Join(c.decls, ", ") + "; ")
	for _, op := range c.ops {
		b.WriteString(op + "; ")
	}
	b.WriteString("return " + root + "; })()")
	return b.String()
}

// walk emits attribute/event ops for node (referenced by ref) and recurses into
// children, navigating to those that need a DOM reference.
func (c *iife) walk(node *jsxNode, ref string) {
	if hasSpread(node) {
		c.spread(ref, node) // spread subsumes every attr; none are baked
	} else {
		for _, a := range node.attrs {
			c.attr(ref, a)
		}
	}
	// A 2-arg _$insert(parent, value) REPLACES all of parent's content, so it's
	// only valid when the dynamic child is parent's sole content. With static or
	// multiple children, every dynamic child needs a 3-arg insert whose marker is
	// the next DOM node (or null to append without clearing).
	contentCount := contentChildCount(node)
	childRefs := c.assignChildRefs(node, ref)
	for i := range node.children {
		ch := &node.children[i]
		switch ch.kind {
		case jsxElement:
			if r := childRefs[i]; r != "" {
				c.walk(ch, r)
			}
		case jsxExpr, jsxComponent:
			if ch.kind == jsxExpr && exprIsEmpty(ch.expr) {
				continue // {/* comment */} / empty expression: nothing to insert
			}
			val := c.g.genNode(ch)
			if ch.kind == jsxExpr {
				val = wrapReactive(val)
			}
			if contentCount == 1 {
				c.ops = append(c.ops, fmt.Sprintf("%s(%s, %s)", c.g.helper("insert"), ref, val))
			} else {
				marker := "null"
				if ch.marker {
					marker = childRefs[i]
				}
				c.ops = append(c.ops, fmt.Sprintf("%s(%s, %s, %s)", c.g.helper("insert"), ref, val, marker))
			}
		}
	}
}

func contentChildCount(node *jsxNode) int {
	count := 0
	for i := range node.children {
		if renderedChild(&node.children[i]) {
			count++
		}
	}
	return count
}

// renderedChild reports whether a child produces content — false for
// whitespace-only text and empty/comment-only JSX expressions ({/* ... */}).
func renderedChild(ch *jsxNode) bool {
	switch ch.kind {
	case jsxText:
		return collapseText(ch.text) != ""
	case jsxExpr:
		return !exprIsEmpty(ch.expr)
	case jsxElement, jsxComponent:
		return true
	}
	return false
}

// exprIsEmpty reports whether a JSX expression is empty or only comments — a
// no-op like {} or {/* note */}, which Solid drops.
func exprIsEmpty(expr string) bool {
	e := strings.TrimSpace(expr)
	for e != "" {
		if strings.HasPrefix(e, "/*") {
			if i := strings.Index(e, "*/"); i >= 0 {
				e = strings.TrimSpace(e[i+2:])
				continue
			}
		}
		if strings.HasPrefix(e, "//") {
			if i := strings.IndexByte(e, '\n'); i >= 0 {
				e = strings.TrimSpace(e[i+1:])
				continue
			}
			e = ""
		}
		break
	}
	return e == ""
}

// assignChildRefs walks the DOM child sequence (static children + markers),
// assigning a nav var to each one that needs a reference, and returns a map from
// child index to its var ("" if none). Vars chain via firstChild/nextSibling.
func (c *iife) assignChildRefs(node *jsxNode, parentRef string) map[int]string {
	refs := map[int]string{}
	// Which children need a ref?
	need := func(i int, ch *jsxNode) bool {
		switch ch.kind {
		case jsxElement:
			return elementNeedsRef(ch)
		case jsxExpr, jsxComponent:
			return ch.marker // marker node is referenced as the insert anchor
		}
		return false
	}
	// Find the last DOM-producing child that needs a ref (waypoint boundary).
	last := -1
	for i := range node.children {
		ch := &node.children[i]
		if isDOMChild(ch) && need(i, ch) {
			last = i
		}
	}
	if last < 0 {
		return refs
	}
	var prev string
	for i := 0; i <= last; i++ {
		ch := &node.children[i]
		if !isDOMChild(ch) {
			continue // dynamic child without a marker contributes no DOM node
		}
		var nav string
		if prev == "" {
			nav = parentRef + ".firstChild"
		} else {
			nav = prev + ".nextSibling"
		}
		v := c.nextEl()
		c.decls = append(c.decls, v+" = "+nav)
		refs[i] = v
		prev = v
	}
	return refs
}

func (c *iife) attr(ref string, a jsxAttr) {
	switch a.kind {
	case attrStatic:
		// baked into the template (nothing at runtime)
	case attrExpr:
		expr := c.g.compileExpr(a.expr)
		switch {
		case isEventAttr(a.name):
			ev := strings.ToLower(a.name[2:])
			c.ops = append(c.ops, fmt.Sprintf("%s.addEventListener(%q, %s)", ref, ev, expr))
		case a.name == "ref":
			// A ref that's a function/const (callback) or an inline function must
			// only call _$use — emitting the `expr = el` assignment branch would be
			// a static "assign to const" error even though it's dead at runtime.
			// A mutable let/var (or member) gets the runtime typeof dispatch so the
			// element can be assigned to it.
			if isLValueExpr(expr) && !c.g.isConstBinding(expr) {
				c.ops = append(c.ops, fmt.Sprintf("typeof %s === \"function\" ? %s(%s, %s) : %s = %s",
					expr, c.g.helper("use"), expr, ref, expr, ref))
			} else {
				c.ops = append(c.ops, fmt.Sprintf("%s(%s, %s)", c.g.helper("use"), expr, ref))
			}
		case a.name == "style":
			// style accepts a string OR an object ({top: …}); _$style applies
			// both (setProperty per key for objects), diffing against the previous
			// value. Generic setAttribute would stringify an object to
			// "[object Object]" and break positioning.
			c.ops = append(c.ops, fmt.Sprintf("%s((_p$) => %s(%s, %s, _p$))",
				c.g.helper("effect"), c.g.helper("style"), ref, expr))
		case a.name == "classList":
			c.ops = append(c.ops, fmt.Sprintf("%s((_p$) => %s(%s, %s, _p$))",
				c.g.helper("effect"), c.g.helper("classList"), ref, expr))
		case contentProps[a.name]:
			// innerHTML/textContent/innerText are DOM properties, not reflected
			// attributes — setAttribute would silently no-op (e.g. an <svg>'s
			// innerHTML icon content would never appear). Assign the property.
			c.ops = append(c.ops, fmt.Sprintf("%s(() => %s.%s = %s)",
				c.g.helper("effect"), ref, a.name, expr))
		default:
			c.ops = append(c.ops, fmt.Sprintf("%s(() => %s(%s, %q, %s))",
				c.g.helper("effect"), c.g.helper("setAttribute"), ref, a.name, expr))
		}
	}
}

// spread routes every attribute of an element with a {...} through
// _$spread(el, _$mergeProps(...), isSVG=false, skipChildren=true), preserving
// source order so later props override earlier ones (matching babel).
func (c *iife) spread(ref string, node *jsxNode) {
	var args []string
	var obj []string
	flush := func() {
		if len(obj) > 0 {
			args = append(args, "{ "+strings.Join(obj, ", ")+" }")
			obj = nil
		}
	}
	for _, a := range node.attrs {
		switch a.kind {
		case attrSpread:
			flush()
			args = append(args, c.g.compileExpr(a.expr))
		case attrStatic:
			if a.boolt {
				obj = append(obj, fmt.Sprintf("%s: true", propKey(a.name)))
			} else {
				obj = append(obj, fmt.Sprintf("%s: %s", propKey(a.name), jsStringLit(a.value)))
			}
		case attrExpr:
			obj = append(obj, fmt.Sprintf("get %s() { return %s; }", propKey(a.name), c.g.compileExpr(a.expr)))
		}
	}
	flush()
	props := args[0]
	if len(args) > 1 {
		props = fmt.Sprintf("%s(%s)", c.g.helper("mergeProps"), strings.Join(args, ", "))
	}
	c.ops = append(c.ops, fmt.Sprintf("%s(%s, %s, false, true)", c.g.helper("spread"), ref, props))
}

func hasSpread(node *jsxNode) bool {
	for _, a := range node.attrs {
		if a.kind == attrSpread {
			return true
		}
	}
	return false
}

var reLValue = regexp.MustCompile(`^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$`)

func isLValueExpr(e string) bool { return reLValue.MatchString(strings.TrimSpace(e)) }

var reSimpleIdent = regexp.MustCompile(`^[A-Za-z_$][\w$]*$`)

// isConstBinding reports whether a simple-identifier ref target is a
// const/function (a ref callback that must never be assigned to). Only an
// explicit `let`/`var` declaration in the source makes it assignable; everything
// else (const, function, param, unknown) is treated as a callback — the safe
// default, since emitting an assignment to a const is a hard build error while
// use-only is always valid. Member expressions are assignable (return false).
func (g *solidGen) isConstBinding(name string) bool {
	name = strings.TrimSpace(name)
	if !reSimpleIdent.MatchString(name) {
		return false
	}
	if regexp.MustCompile(`\b(?:let|var)\s+`+regexp.QuoteMeta(name)+`\b`).MatchString(g.source) {
		return false
	}
	return true
}

func (g *solidGen) genComponent(node *jsxNode) string {
	tag := node.tag
	props := g.componentProps(node)
	return fmt.Sprintf("%s(%s, %s)", g.helper("createComponent"), tag, props)
}

// componentProps builds the props object: static attrs as plain values, dynamic
// attrs as reactive getters, and children as a `children` prop.
func (g *solidGen) componentProps(node *jsxNode) string {
	var parts []string
	for _, a := range node.attrs {
		switch a.kind {
		case attrStatic:
			if a.boolt {
				parts = append(parts, fmt.Sprintf("%s: true", propKey(a.name)))
			} else {
				parts = append(parts, fmt.Sprintf("%s: %s", propKey(a.name), jsStringLit(a.value)))
			}
		case attrExpr:
			parts = append(parts, fmt.Sprintf("get %s() { return %s; }", propKey(a.name), g.compileExpr(a.expr)))
		case attrSpread:
			// milestone 3 (mergeProps)
		}
	}
	if ch := g.childrenProp(node); ch != "" {
		parts = append(parts, ch)
	}
	if len(parts) == 0 {
		return "{}"
	}
	return "{ " + strings.Join(parts, ", ") + " }"
}

// childrenProp builds a component's `children` prop entry. JSX children are
// wrapped in a `get children()` accessor so they evaluate lazily inside the
// parent component's execution — essential for context providers (children must
// run after the parent sets context) and for correct reactivity. Static text has
// no such need and is passed as a plain value (matching babel).
func (g *solidGen) childrenProp(node *jsxNode) string {
	var kids []*jsxNode
	for i := range node.children {
		ch := &node.children[i]
		if !renderedChild(ch) {
			continue // drop whitespace-only text and empty expressions
		}
		kids = append(kids, ch)
	}
	switch len(kids) {
	case 0:
		return ""
	case 1:
		if kids[0].kind == jsxText {
			return "children: " + jsStringLit(collapseText(kids[0].text))
		}
		return "get children() { return " + g.genNode(kids[0]) + "; }"
	default:
		parts := make([]string, len(kids))
		for i, k := range kids {
			parts[i] = g.genNode(k)
		}
		return "get children() { return [" + strings.Join(parts, ", ") + "]; }"
	}
}

func (g *solidGen) genFragment(node *jsxNode) string {
	var kids []*jsxNode
	for i := range node.children {
		ch := &node.children[i]
		if ch.kind == jsxText && strings.TrimSpace(ch.text) == "" {
			continue
		}
		kids = append(kids, ch)
	}
	if len(kids) == 1 {
		return g.genNode(kids[0])
	}
	parts := make([]string, len(kids))
	for i, k := range kids {
		if k.kind == jsxExpr {
			parts[i] = fmt.Sprintf("%s(() => %s)", g.helper("memo"), g.compileExpr(k.expr))
		} else {
			parts[i] = g.genNode(k)
		}
	}
	return "[" + strings.Join(parts, ", ") + "]"
}

// ---- template building -----------------------------------------------------

func buildTemplate(node *jsxNode) string {
	var sb strings.Builder
	writeTemplate(node, &sb)
	return sb.String()
}

func writeTemplate(node *jsxNode, sb *strings.Builder) {
	sb.WriteString("<" + node.tag)
	if !hasSpread(node) { // with a spread, all attrs are applied at runtime
		for _, a := range node.attrs {
			if a.kind == attrStatic {
				if a.boolt {
					sb.WriteString(" " + a.name)
				} else {
					sb.WriteString(" " + a.name + `="` + a.value + `"`)
				}
			}
		}
	}
	sb.WriteString(">")
	for i := range node.children {
		ch := &node.children[i]
		switch ch.kind {
		case jsxText:
			sb.WriteString(escapeTemplateText(collapseText(ch.text)))
		case jsxElement:
			writeTemplate(ch, sb)
		case jsxExpr, jsxComponent:
			if ch.marker {
				sb.WriteString("<!>")
			}
		}
	}
	sb.WriteString("</" + node.tag + ">")
}

// annotateMarkers sets node.marker on dynamic children that need a `<!>` anchor:
// those with a DOM-producing sibling after them. Recurses into element children.
func annotateMarkers(node *jsxNode) {
	after := false
	for i := len(node.children) - 1; i >= 0; i-- {
		ch := &node.children[i]
		switch ch.kind {
		case jsxText:
			if collapseText(ch.text) != "" {
				after = true
			}
		case jsxElement:
			after = true
			annotateMarkers(ch)
		case jsxExpr, jsxComponent:
			if ch.kind == jsxExpr && exprIsEmpty(ch.expr) {
				continue // empty/comment expression produces nothing
			}
			if after {
				ch.marker = true // the <!> it emits is itself a DOM node
			}
		}
	}
}

// ---- reactivity + helpers --------------------------------------------------

var reSimpleCall = regexp.MustCompile(`^[A-Za-z_$][\w$]*\(\)$`)

// wrapReactive wraps a child expression so _$insert treats it reactively. Bare
// accessor calls stay as-is via a thunk; correctness over babel's unwrap opt.
func wrapReactive(expr string) string {
	e := strings.TrimSpace(expr)
	if isStaticLiteral(e) {
		return e
	}
	return "() => " + e
}

func isStaticLiteral(e string) bool {
	if e == "true" || e == "false" || e == "null" || e == "undefined" {
		return true
	}
	if len(e) >= 2 && (e[0] == '"' || e[0] == '\'' || e[0] == '`') {
		return true
	}
	// number
	allNum := true
	for i := 0; i < len(e); i++ {
		if !(e[i] >= '0' && e[i] <= '9' || e[i] == '.') {
			allNum = false
			break
		}
	}
	return allNum && e != ""
}

// contentProps are DOM properties (not reflected attributes) that must be set by
// direct property assignment rather than setAttribute.
var contentProps = map[string]bool{"innerHTML": true, "textContent": true, "innerText": true}

func isEventAttr(name string) bool {
	// An event handler is `on` followed by the event name; casing after `on` is
	// irrelevant (onClick and onclick both mean click) — the name is lowercased
	// before addEventListener. This matches solid-js/html's runtime, which keys
	// off the `on` prefix alone, so .tsx and html`` templates agree. Require a
	// letter so namespaced forms (on:click / oncapture:) fall through untouched.
	return len(name) > 2 && name[0] == 'o' && name[1] == 'n' &&
		((name[2] >= 'A' && name[2] <= 'Z') || (name[2] >= 'a' && name[2] <= 'z'))
}

// elementNeedsRef reports whether a static element needs a nav var: it has
// runtime ops (dynamic attrs/events/ref) or dynamic children (inserts).
func elementNeedsRef(node *jsxNode) bool {
	for _, a := range node.attrs {
		if a.kind != attrStatic {
			return true
		}
	}
	for i := range node.children {
		ch := &node.children[i]
		switch ch.kind {
		case jsxExpr, jsxComponent:
			return true
		case jsxElement:
			if elementNeedsRef(ch) {
				return true
			}
		}
	}
	return false
}

func isDOMChild(ch *jsxNode) bool {
	switch ch.kind {
	case jsxText:
		return collapseText(ch.text) != ""
	case jsxElement:
		return true
	case jsxExpr, jsxComponent:
		return ch.marker
	}
	return false
}

// collapseText applies JSX whitespace normalization: lines are trimmed and
// joined by a single space; text that is only whitespace-with-newline vanishes.
func collapseText(s string) string {
	if strings.TrimSpace(s) == "" {
		if strings.ContainsAny(s, "\n") {
			return ""
		}
		return s // significant single-line whitespace (e.g. "a {x} b")
	}
	if !strings.ContainsAny(s, "\n") {
		return s
	}
	lines := strings.Split(s, "\n")
	var kept []string
	for _, l := range lines {
		l = strings.Trim(l, " \t\r")
		if l != "" {
			kept = append(kept, l)
		}
	}
	return strings.Join(kept, " ")
}

func escapeTemplateText(s string) string {
	// Templates are raw HTML; escape only the backtick that would end the JS
	// template literal and `${` interpolation.
	s = strings.ReplaceAll(s, "\\", "\\\\")
	s = strings.ReplaceAll(s, "`", "\\`")
	s = strings.ReplaceAll(s, "${", "\\${")
	return s
}

func propKey(name string) string {
	if name == "class" {
		return `"class"`
	}
	for i := 0; i < len(name); i++ {
		if !(isASCIILetter(name[i]) || name[i] == '_' || name[i] == '$' || (i > 0 && name[i] >= '0' && name[i] <= '9')) {
			return jsStringLit(name)
		}
	}
	return name
}

func jsStringLit(s string) string {
	var b strings.Builder
	b.WriteByte('"')
	for _, r := range s {
		switch r {
		case '"':
			b.WriteString(`\"`)
		case '\\':
			b.WriteString(`\\`)
		case '\n':
			b.WriteString(`\n`)
		default:
			b.WriteRune(r)
		}
	}
	b.WriteByte('"')
	return b.String()
}

// validateJS checks code with esbuild and returns the first error. It uses Build
// (Bundle:false, so imports are left unresolved) rather than Transform because
// Build runs semantic checks Transform skips — notably assign-to-const, which a
// mis-generated ref would produce and which only surfaces in the real bundle.
func validateJS(code string) error {
	res := esbuild.Build(esbuild.BuildOptions{
		Stdin:    &esbuild.StdinOptions{Contents: code, Loader: esbuild.LoaderJS},
		Bundle:   false,
		Write:    false,
		LogLevel: esbuild.LogLevelSilent,
	})
	if len(res.Errors) > 0 {
		return fmt.Errorf("%s", res.Errors[0].Text)
	}
	return nil
}

func sortedKeys(m map[string]bool) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}
