// SSR (part of package bundler): server-renders the public-page Solid components
// to HTML strings by running the (unmodified) client Solid runtime inside a goja
// JS engine against a minimal Go-backed DOM (dom.js).  Components are authored in
// JSX/TSX and compiled to optimized Solid output at build time by the Go-native
// compiler (compile_solid.go).  The compiled code mounts via solid-js/web's
// render into the DOM shim and __serialize walks the shim tree to HTML.
//
// The rendered HTML is the component's initial markup (data-free at build time, or
// rendered with injected __SERVER_DATA__ for ISR); the browser bundle re-renders it
// on load (Solid re-render takeover).  The runtime ISR entry (RenderBundleWithData)
// is imported by the server; the build-time entries are used by the bundler.
//
//	-mta
package bundler

import (
	"crypto/sha256"
	_ "embed"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"path/filepath"
	"strings"

	"github.com/dop251/goja"
	esbuild "github.com/evanw/esbuild/pkg/api"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

//go:embed js/ssr/dom.js
var domJS string

// preludeJS installs the browser globals Solid's client runtime reaches for
// that don't exist in goja.  They are deliberately inert: no timer or
// microtask fires during the synchronous render, and fetch never resolves —
// so a component's onMount data load can't run, leaving exactly the
// pre-hydration skeleton we want to serialize.
const preludeJS = `
var console = {
	log:   function () { __log.apply(null, ["log"].concat(Array.prototype.slice.call(arguments))); },
	info:  function () { __log.apply(null, ["info"].concat(Array.prototype.slice.call(arguments))); },
	warn:  function () { __log.apply(null, ["warn"].concat(Array.prototype.slice.call(arguments))); },
	error: function () { __log.apply(null, ["error"].concat(Array.prototype.slice.call(arguments))); },
	debug: function () {},
};
globalThis.queueMicrotask = function (cb) { (globalThis.__mt || (globalThis.__mt = [])).push(cb); };
globalThis.setTimeout = function () { return 0; };
globalThis.clearTimeout = function () {};
globalThis.setInterval = function () { return 0; };
globalThis.clearInterval = function () {};
globalThis.requestAnimationFrame = function () { return 0; };
globalThis.cancelAnimationFrame = function () {};
globalThis.fetch = function () {
	return Promise.resolve({ ok: false, status: 0, json: function () { return Promise.resolve(null); }, text: function () { return Promise.resolve(""); } });
};
globalThis._$HY = { events: [], completed: (typeof WeakSet !== "undefined" ? new WeakSet() : null), r: {}, done: true, fe: function () {} };
// Marker so page components can render a lightweight skeleton during SSR and
// defer heavy, browser-only UI (charts, PDF, icon fonts) to the client takeover.
globalThis.__SSR__ = true;
// Minimal Intl shim: goja has no Intl, but some modules construct formatters at
// import time. The shim just needs to not throw; real formatting happens on the
// client. format() returns the stringified input.
if (typeof Intl === "undefined") {
	globalThis.Intl = {
		NumberFormat: function () { return { format: function (n) { return String(n); }, formatToParts: function (n) { return [{ type: "literal", value: String(n) }]; } }; },
		DateTimeFormat: function () { return { format: function (d) { return String(d); } }; },
	};
}
`

// Engine is a single goja runtime with the DOM shim installed.  It is NOT
// safe for concurrent use; the eventual handler keeps a pool of these.
type Engine struct {
	vm *goja.Runtime
}

// New builds a runtime, installs the Go bridges (__parseHTML, __log), runs
// the prelude and the DOM shim, and returns a ready engine.
func New() (*Engine, error) {
	vm := goja.New()

	if err := vm.Set("__parseHTML", parseHTMLToJSON); err != nil {
		return nil, err
	}
	if err := vm.Set("__log", func(args ...interface{}) {
		fmt.Println(append([]interface{}{"[ssr]"}, args...)...)
	}); err != nil {
		return nil, err
	}

	if _, err := vm.RunString(preludeJS); err != nil {
		return nil, fmt.Errorf("prelude: %w", err)
	}
	if _, err := vm.RunString(domJS); err != nil {
		return nil, fmt.Errorf("dom shim: %w", err)
	}

	return &Engine{vm: vm}, nil
}

// LoadBundle evaluates a bundled Solid entry (see BundleEntry).  The entry is
// expected to define globalThis.__render.
func (e *Engine) LoadBundle(js string) error {
	_, err := e.vm.RunString(js)
	return err
}

// Render invokes the entry's __render() and returns the serialized HTML.
func (e *Engine) Render() (string, error) {
	v, err := e.vm.RunString("__render()")
	if err != nil {
		return "", err
	}
	return v.String(), nil
}

// SetServerData installs the page's per-render data as globalThis.__SERVER_DATA__,
// which components read (via the frontend serverData() accessor) to render with
// real data instead of a skeleton.  dataJSON must be a JSON document — it's valid
// JS, wrapped in parens so an object literal parses as an expression.  Used by the
// ISR path (request-time render-with-data, cached); the static skeleton bake sets
// nothing, so components fall back to placeholders there.
func (e *Engine) SetServerData(dataJSON string) error {
	if dataJSON == "" {
		return nil
	}
	_, err := e.vm.RunString("globalThis.__SERVER_DATA__ = (" + dataJSON + ");")
	return err
}

// BundleEntry bundles an inline JS entry into a single IIFE that goja can run,
// resolving bare solid-js* specifiers to the vendored runtime files under
// projectRoot/wwwroot/vendor.  Any .tsx/.jsx pulled into the graph is Solid-
// compiled by the Go-native compiler (Plugin).  The generated entry itself is
// plain JS — it uses solid-js/web's createComponent rather than JSX — so it needs
// no transform.
func BundleEntry(entrySource, projectRoot string) (string, error) {
	js, _, err := bundleEntry(entrySource, projectRoot, false)
	return js, err
}

// ssrStubModule is the inert CommonJS module the ssr-stub plugin loads for every
// browser-only library under SSR. A permissive Proxy satisfies any named or
// default import and any no-op property access, call, construction, or assignment,
// so it works for any package without knowing its shape.
const ssrStubModule = `
var handler = {
  get: function (_t, p) { return p === "__esModule" ? true : stub; },
  apply: function () { return stub; },
  construct: function () { return stub; },
  set: function () { return true; },
};
var stub = new Proxy(function () {}, handler);
module.exports = stub;
`

// bundleEntry is the shared esbuild pass behind BundleEntry.  When withMeta is
// set it also returns the esbuild metafile JSON, whose "inputs" map lets the
// bundler discover (and fingerprint) the source files a page pulled in, for
// build-time change detection.  Computing the metafile is skipped otherwise.
func bundleEntry(entrySource, projectRoot string, withMeta bool) (js, metafile string, err error) {
	// esbuild's Alias targets and ResolveDir must be absolute: a relative
	// target like "wwwroot/vendor/solid-js.js" (no leading "./") is read as a
	// bare package specifier and fails to resolve.  Callers pass "." (the
	// server's cwd), so absolutize here.
	absRoot, err := filepath.Abs(projectRoot)
	if err != nil {
		return "", "", fmt.Errorf("resolve project root: %w", err)
	}
	// Absolute vendor roots (kjol runtime first, then app vendor) so SSR resolves
	// the SAME solid-js the client bundle does.
	var absVendorDirs []string
	for _, d := range vendorDirs() {
		if filepath.IsAbs(d) {
			absVendorDirs = append(absVendorDirs, d)
		} else {
			absVendorDirs = append(absVendorDirs, filepath.Join(absRoot, d))
		}
	}

	// vendor.json pins each vendored package's exact entrypoint file (see
	// vendor_plugins.go) because a package's own `exports`/`main`/`module` fields
	// mis-resolve under NodePaths resolution — e.g. solid-js/web's `module` field
	// points at dist/server.js (the seroval-based SSR build), not the DOM dev
	// build. The client bundle avoids that via vendorManifestPlugin; SSR needs the
	// same pin for the solid-js family it resolves for real (see stubPlugin below).
	// loadVendorManifest returns absolute entrypoint paths.
	vendorEntrypoints, err := loadVendorManifest(absVendorDirs)
	if err != nil {
		return "", "", fmt.Errorf("loading vendor manifest: %w", err)
	}

	// The public pages are Solid/TSX. The .tsx files are Solid-compiled by the
	// Go-native compiler (Plugin); the compiled output +
	// the tagged-template pages import from solid-js/web. Resolve the solid runtime
	// from frontend/vendor (dev DOM builds — SSR renders into a DOM shim, not
	// renderToString), pinning bare solid-js* specifiers to their vendor.json
	// entrypoint (same single source of truth as the client bundle) and falling
	// back to NodePaths for any solid-js* subpath vendor.json doesn't list. Every
	// OTHER bare import (fontawesome, pdf-lib, pdfjs-dist, chart.js, @solidjs/router,
	// …) resolves to an inert stub: public pages skip their heavy UI under SSR
	// (globalThis.__SSR__), so the stub only needs to satisfy the graph. Generating
	// the stub means no hardcoded list and no stub files to maintain.
	stubPlugin := esbuild.Plugin{
		Name: "ssr-stub",
		Setup: func(build esbuild.PluginBuild) {
			build.OnResolve(esbuild.OnResolveOptions{Filter: `^[^./]`}, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
				if args.Kind == esbuild.ResolveEntryPoint || filepath.IsAbs(args.Path) || strings.HasPrefix(args.Path, ".") {
					return esbuild.OnResolveResult{}, nil
				}
				if strings.HasPrefix(args.Path, "solid-js") {
					if target, ok := vendorEntrypoints[args.Path]; ok {
						return esbuild.OnResolveResult{Path: target}, nil // pinned dev entrypoint (absolute)
					}
					return esbuild.OnResolveResult{}, nil // unpinned subpath -> real solid runtime via NodePaths
				}
				return esbuild.OnResolveResult{Path: args.Path, Namespace: "ssr-stub"}, nil
			})
			build.OnLoad(esbuild.OnLoadOptions{Filter: `.*`, Namespace: "ssr-stub"}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
				contents := ssrStubModule
				loader := esbuild.LoaderJS
				return esbuild.OnLoadResult{Contents: &contents, Loader: loader}, nil
			})
		},
	}
	// aliasPlugin resolves @ui/@kjol/@appgen into the kjol web tree first; the Go
	// Solid compiler (Plugin) compiles .tsx before the catch-all stub sees any of
	// its imports.
	plugins := []esbuild.Plugin{aliasPlugin(), Plugin(), stubPlugin}

	result := esbuild.Build(esbuild.BuildOptions{
		Stdin: &esbuild.StdinOptions{
			Contents:   entrySource,
			ResolveDir: absRoot,
			Sourcefile: "ssr-entry.js",
			Loader:     esbuild.LoaderJS,
		},
		Bundle:     true,
		Format:     esbuild.FormatIIFE,
		Target:     esbuild.ES2017,
		Platform:   esbuild.PlatformBrowser,
		Conditions: []string{"development"},
		NodePaths:  absVendorDirs,
		Plugins:    plugins,
		LogLevel:   esbuild.LogLevelSilent,
		Write:      false,
		Metafile:   withMeta,
	})
	if len(result.Errors) > 0 {
		msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{})
		return "", "", fmt.Errorf("esbuild: %s", strings.Join(msgs, "\n"))
	}
	if len(result.OutputFiles) == 0 {
		return "", "", fmt.Errorf("esbuild produced no output")
	}
	return string(result.OutputFiles[0].Contents), result.Metafile, nil
}

// engineCacheVersion is bumped by hand when the SSR engine changes in a way
// that affects rendered output but isn't captured by the dom.js/prelude source
// below (e.g. a change in serialization in renderer.go).
//
//	"2" — public pages migrated from solid-js/html to React/TSX; SSR now renders
//	      via react-dom/server renderToString instead of the DOM-shim serializer.
//	"3" — public pages migrated to Solid JSX/TSX (compiled via the in-package Solid JSX compiler, jsx.go);
//	      SSR back on the DOM-shim + serialize path, React prelude shims removed.
const engineCacheVersion = "3"

// EngineHash fingerprints the SSR engine — the DOM shim, the prelude, and a
// manual version.  The bundler folds it into its public-page render cache so an
// engine change invalidates every cached page (their input files wouldn't have
// changed, but their rendered output would).
func EngineHash() string {
	h := sha256.New()
	h.Write([]byte(engineCacheVersion))
	h.Write([]byte{0})
	h.Write([]byte(preludeJS))
	h.Write([]byte{0})
	h.Write([]byte(domJS))
	return hex.EncodeToString(h.Sum(nil))
}

// ---- HTML parse bridge (x/net/html) ------------------------------------

// jnode is the compact JSON shape dom.js rebuilds shim nodes from.
type jnode struct {
	T  string            `json:"t"`            // "e" element, "t" text, "c" comment
	N  string            `json:"n,omitempty"`  // element tag name
	NS string            `json:"ns,omitempty"` // "svg" / "math" for foreign content
	A  map[string]string `json:"a,omitempty"`  // attributes
	C  []*jnode          `json:"c,omitempty"`  // children
	D  string            `json:"d,omitempty"`  // text / comment data
}

// parseHTMLToJSON parses an innerHTML fragment using template-content
// semantics (so <table> gets its implicit <tbody>, void elements close, and
// entities decode per the HTML5 spec) and returns it as a JSON node array.
func parseHTMLToJSON(fragment string) string {
	ctx := &html.Node{Type: html.ElementNode, DataAtom: atom.Template, Data: "template"}
	nodes, err := html.ParseFragment(strings.NewReader(fragment), ctx)
	if err != nil {
		return "[]"
	}
	roots := make([]*jnode, 0, len(nodes))
	for _, n := range nodes {
		if jn := convert(n); jn != nil {
			roots = append(roots, jn)
		}
	}
	b, err := json.Marshal(roots)
	if err != nil {
		return "[]"
	}
	return string(b)
}

func convert(n *html.Node) *jnode {
	switch n.Type {
	case html.ElementNode:
		jn := &jnode{T: "e", N: n.Data}
		if n.Namespace == "svg" || n.Namespace == "math" {
			jn.NS = n.Namespace
		}
		if len(n.Attr) > 0 {
			jn.A = make(map[string]string, len(n.Attr))
			for _, a := range n.Attr {
				key := a.Key
				if a.Namespace != "" {
					key = a.Namespace + ":" + a.Key
				}
				jn.A[key] = a.Val
			}
		}
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			if cj := convert(c); cj != nil {
				jn.C = append(jn.C, cj)
			}
		}
		return jn
	case html.TextNode:
		return &jnode{T: "t", D: n.Data}
	case html.CommentNode:
		return &jnode{T: "c", D: n.Data}
	}
	return nil
}
