// Package app holds the kjol-web site's Go/WASM pages and components as
// standalone, platform-neutral functions (SSR on the server, hydrate on the
// client). UI is built from the kjol webui kit + Tailwind utility classes.
//
// Directives (processed by kjol/cmd/wasmgen at build time):
//
//	//gowasm:page <path> [static] [layout=<name>]  a route (static => SSR'd)
//	//gowasm:layout <name>                          a func(Deps, *VNode) *VNode wrapper
//	//gowasm:server                                 (see server_counter.go) a server component
package app

//go:generate go run kjol/cmd/wasmgen .

import (
	"strconv"
	"strings"

	. "kjol/vdom"

	ui "kjol/webui"
)

// Deps are the client-only capabilities, injected so pages stay neutral.
type Deps struct {
	Path     func() string
	Navigate func(string)
}

// Theme is the site-wide theme controller. One per site, created once — the switch in
// the header and the class on <html> have to be the same object, or the button and the
// page disagree about what theme you are in.
//
// The client calls Theme.Init() after mounting (see wasm/main.go); on the server it is
// inert, and the document's boot script has already put the right class on <html>.
var Theme = ui.NewTheme()

func itoa(n int) string { return strconv.Itoa(n) }

// Layout wraps a page's content with shared chrome (declared with //gowasm:layout,
// selected per route via `layout=`; the generated LayoutFor dispatches by name).
type Layout func(d Deps, content *VNode) *VNode

// Shell renders the current route's page inside its declared layout.
func Shell(d Deps, routes map[string]func() *VNode) *VNode {
	path := d.Path()
	var content *VNode
	if page := routes[path]; page != nil {
		content = page()
	} else {
		content = notFound(path)
	}
	return LayoutFor(d, path, content)
}

func notFound(path string) *VNode {
	return Div(Attr("class", "py-10"),
		H2(Attr("class", "text-xl font-semibold text-ink mb-2"), Text("Page not found")),
		P(Attr("class", "text-ink-muted"), Text("No route matches "+path+".")),
	)
}

// --- layouts (Tailwind chrome) -------------------------------------------

// wordmark is the brand lockup, shared by both layouts so they cannot drift.
//
// The boat is the point of the name: kjøl is Norwegian for KEEL — the spine of a hull,
// the thing every other part is built onto. Which is what this library is meant to be
// for the applications that share it.
func wordmark(d Deps, href string) *VNode {
	// The lockup names the LAYER you are standing in, not the site. On the front page
	// that is Kjøl itself; inside /wasm it is Kjøl Wasm Web; inside /c it is Kjøl C —
	// Wordmark, not Name, because up here "C" alone names a language rather than the thing
	// you are reading. A wordmark that says the same thing everywhere is one more thing the
	// reader has to keep track of himself.
	name, sub := "Kjøl", "a shared base layer"
	if l := CurrentLayer(d.Path()); l != nil {
		name, sub = l.Wordmark(), l.Sub
	}

	return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
		// text-white, not text-surface: the flag is the same in both themes, so the boat on
		// top of it has to be too. text-surface inverts to near-black in dark mode, which
		// would hide the boat against the navy cross. The flag itself carries a dark scrim
		// (see .flag-no) so this plain white boat reads without a shadow of its own.
		Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default flag-no text-white"),
			ui.IconInline("sailboat", 17, "")),
		Span(Attr("class", "flex items-baseline gap-1.5"),
			Span(Attr("class", "text-lg font-semibold tracking-tight text-text-heading"), Text(name)),
			Span(Attr("class", "text-sm text-ink-faint"), Text(sub)),
		),
	)
}

// PublicLayout is deliberately plain: a line of navigation, a column of content, a line
// of footer. No hero, no glow, no full-bleed anything.
//
// The grid stays, faintly, because it is the one piece of decoration that is not trying
// to sell you something — it is texture, and it costs nothing to read past.
//
//gowasm:layout public
func PublicLayout(d Deps, content *VNode) *VNode {
	return Div(Attr("class", "relative min-h-screen"),
		// Behind everything, masked to fade out down the page. aria-hidden +
		// pointer-events-none because it is decoration: not tabbable, not clickable, not
		// read aloud.
		Div(Attr("class", "pointer-events-none fixed inset-0 -z-10 bg-grid grid-fade"), Attr("aria-hidden", "true")),

		// The nav, the content and the footer are ONE column, and the way to get that is for
		// all three to be built the same way: gutter on the outside, measure on the inside.
		//
		//	<div class="px-4">  <div class="mx-auto max-w-3xl">  …
		//
		// This used to be `mx-auto max-w-3xl px-4` on the nav's inner div — measure and gutter
		// on the SAME element. On a wide screen the gutter has nothing to do (the centring has
		// already pushed the box in much further), so all it did was inset the nav's contents
		// by another 16px: the wordmark sat a finger's width to the right of the headline
		// underneath it. Close enough to look like a mistake, far enough to see.
		//
		// The footer was worse — it was max-w-2xl, a different measure entirely.
		Nav(Attr("class", "site-nav border-b border-line"),
			Div(Attr("class", "px-4"),
				Div(Attr("class", "mx-auto flex max-w-3xl items-center gap-2 py-4"),
					wordmark(d, "/"),
					Div(Attr("class", "ml-auto flex items-center gap-1"),
						layersMenu(d),
						compositionsMenu(d),
						Ul(Attr("class", "flex items-center gap-1"),
							navItem(d, "/about", "About", false),
							Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
						),
					)))),

		Main(Attr("class", "px-4 py-14"), content),

		Footer(Attr("class", "px-4 pb-14"),
			Div(Attr("class", "mx-auto max-w-3xl"),
				P(Attr("class", "text-sm text-ink-faint"),
					Text("Kjøl is a shared base layer, factored out of several applications so they stay in "+
						"sync. It is Norwegian for keel.")),
			),
		),
		ui.ModalHost(),
	)
}

// wideRoutes get a roomier container. A table with a dozen columns, a drag handle
// and three calculated columns has no business being squeezed into a reading-width
// column; prose pages still are.
var wideRoutes = map[string]bool{"/wasm/components": true}

// AppLayout is the DOCUMENTATION shell: a sidebar of sections on the left, the page on
// the right. The app routes are the framework's docs — each one explains a capability,
// shows the Go that implements it, and then runs that Go on the page — so they are
// framed like documentation rather than like a demo carousel.
//
//gowasm:layout app
func AppLayout(d Deps, content *VNode) *VNode {
	// The content column is wide, and the PROSE inside it is what gets held to a reading
	// measure (see prose()). Constraining the whole column to reading width instead left
	// code blocks, demos and reference tables cramped into a third of the screen with a
	// desert to the right of them — the text was comfortable and everything else paid
	// for it.
	width := "max-w-6xl"
	if wideRoutes[d.Path()] {
		// The table's own chrome is the demo; a measure would hide the column management
		// that is the whole point of it.
		width = "max-w-none"
	}

	return Div(Attr("class", "min-h-screen bg-surface"),
		Nav(Attr("class", "app-nav sticky top-0 z-20 border-b border-line bg-surface/90 backdrop-blur"),
			Div(Attr("class", "mx-auto flex max-w-[110rem] items-center gap-3 px-6 py-3"),
				wordmark(d, "/"),
				Span(Attr("class", "rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint"), Text("Docs")),
				Div(Attr("class", "ml-auto flex items-center gap-2"),
					layersMenu(d),
					compositionsMenu(d),
					Ul(Attr("class", "flex items-center gap-2"),
						navItem(d, "/", "Home", false),
						Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
					),
				),
			)),

		Div(Attr("class", "mx-auto flex max-w-[110rem] gap-8 px-6"),
			docsSidebar(d),
			Main(Attr("class", "min-w-0 flex-1 py-10"),
				Div(Attr("class", width), content),
			),
		),

		// The host for webui.OpenModal — content opened imperatively, by code that
		// owns no component in the tree, is portaled out of here. Render it ONCE,
		// near the root. It is an empty portal when nothing is open.
		ui.ModalHost(),
	)
}

// docsSidebar is the section list. Sticky, so it stays put while a long page scrolls —
// on a documentation site the nav is how you know where you are, and a nav that scrolls
// away leaves you nowhere.
// sidebarNav is the sidebar's contents, which depend on WHICH LAYER you are reading.
//
// AppLayout is shared by every documentation page in this binary, and those pages are no
// longer all about the same thing: /wasm/* documents the Go→WebAssembly engine, /c
// documents the C base layer. A sidebar listing the engine's chapters while you are
// reading about arenas would be worse than no sidebar at all.
func sidebarNav(path string) []docsGroup {
	switch {
	case path == "/c" || strings.HasPrefix(path, "/c/"):
		return cNav()
	case path == "/go" || strings.HasPrefix(path, "/go/"):
		return goNav()
	default:
		return docsNav()
	}
}

func docsSidebar(d Deps) *VNode {
	mods := []Mod{Attr("class", "sticky top-[3.75rem] hidden h-[calc(100vh-3.75rem)] w-56 shrink-0 overflow-y-auto py-10 lg:block")}
	for _, g := range sidebarNav(d.Path()) {
		items := []Mod{Attr("class", "mt-2 space-y-0.5")}
		for _, it := range g.Items {
			items = append(items, Li(sidebarLink(d, it)))
		}
		mods = append(mods,
			Div(Attr("class", "mb-6"),
				P(Attr("class", "px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint"), Text(g.Title)),
				Ul(items...),
			),
		)
	}
	return El("aside", mods...)
}

// No icon: the sidebar is a list of words, and a glyph on every row is noise the reader has
// to look past to read the label. The label is the navigation. (docsItem still carries an
// Icon — it is used on the /docs index cards, where a larger tile earns one.)
func sidebarLink(d Deps, it docsItem) *VNode {
	base, frag, isAnchor := strings.Cut(it.Path, "#")

	cls := "block rounded-default px-2 py-1.5 text-sm no-underline text-ink-soft hover:bg-surface-raised hover:text-ink"

	// A section link is NEVER "active", and that is deliberate. It cannot be: it would
	// have to know which section you had scrolled to, which means measuring all fifteen of
	// them on every scroll frame, and the only way to act on the answer is a signal write
	// — which re-renders this entire page. Sixty times a second, to move a highlight.
	//
	// (Marking them active by PAGE instead lights up all fifteen at once, which is worse
	// than no highlight: it tells you nothing and looks broken.)
	active := !isAnchor && d.Path() == it.Path
	if active {
		cls = "active block rounded-default px-2 py-1.5 text-sm no-underline bg-primary-subtle font-medium text-accent"
	}

	click := navigate(d, it.Path)
	if isAnchor {
		click = navigateAnchor(d, base, frag)
	}

	return A(Attr("class", cls), Attr("href", it.Path), click,
		Text(it.Label),
	)
}

// navItem is a nav link with an active state; dark switches to on-dark colors.
func navItem(d Deps, path, label string, dark bool) *VNode {
	active := d.Path() == path
	var cls string
	switch {
	case dark && active:
		cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-white/10 text-white"
	case dark:
		cls = "rounded-default px-3 py-1.5 text-sm font-medium text-ink-faint hover:bg-white/5 hover:text-white"
	case active:
		cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-surface-raised text-ink"
	default:
		cls = "rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft hover:bg-surface-raised hover:text-ink"
	}
	return Li(A(Attr("class", cls+" no-underline"), Attr("href", path), navigate(d, path), Text(label)))
}

// navigate intercepts a link click for client-side SPA navigation (Navigate is
// nil on the server, so the anchor falls back to a normal navigation).
func navigate(d Deps, path string) Mod {
	return OnEvent(EVENT_CLICK, func(e Event) {
		if d.Navigate != nil {
			e.PreventDefault()
			d.Navigate(path)
		}
	})
}

// Counter is a presentational client component; state is owned by the caller.
func Counter(label string, count *Signal[int]) *VNode {
	return Div(Attr("class", "counter flex items-center gap-3 rounded-default border border-line bg-surface px-4 py-3 shadow-xs"),
		Span(Attr("class", "font-medium text-ink-soft"), Text(label+": ")),
		Strong(Attr("class", "badge inline-flex min-w-8 items-center justify-center rounded-full bg-primary px-2.5 py-0.5 text-sm font-semibold text-white"), Text(itoa(count.Get()))),
		Div(Attr("class", "ml-auto flex gap-1"),
			ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "−", OnClick: func() { count.Update(func(v int) int { return v - 1 }) }}),
			ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "+", OnClick: func() { count.Update(func(v int) int { return v + 1 }) }}),
		),
	)
}

// ---- landing ------------------------------------------------------------

// The landing page is a column of plain text and two lists.
//
// It used to carry the Wasm Web engine's own highlights: the two-runtime demo, a list of
// SSR/hydration/server-component features, the build transcript. All of it was true, and
// none of it belonged HERE — the front page is kjøl's, and kjøl is not the Go/WebAssembly
// engine any more than it is the C arena allocator. A reader landing on it should learn
// what the thing IS, not be pitched one of its five parts.
//
// So the demo moved to /wasm, where it is the first thing that section shows, and the
// front page says what is actually true of the whole: here are the languages, here are the
// frameworks assembled out of them, go and read one.
//
//gowasm:page / static layout=public
func HomePage(d Deps) func() *VNode {
	return func() *VNode {
		return Div(Attr("class", "mx-auto max-w-3xl"),
			H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"),
				Text("Kjøl")),
			P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
				Text("A shared base layer, factored out of several applications so they stay in sync. "+
					"Kjøl is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")),
			P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
				Text("It is not one library. It is a set of them, in several languages, and a couple of "+
					"frameworks assembled out of those. Each one is documented here, and every page of that "+
					"documentation runs the code it documents.")),

			// ---- layers: the languages ----
			H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Layers")),
			P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
				Text("What Kjøl is written in, and what it gives you in each. A layer is a directory of "+
					"code you can use on its own — the Go base does not know the C one exists.")),
			layerGrid(Languages()),

			// ---- compositions: the frameworks ----
			H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Compositions")),
			P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
				Text("What the layers become when they are assembled into something that does a job. A "+
					"composition is not another language: Kjøl Wasm Web is Go all the way down, and Kjøl JS "+
					"Web is TypeScript compiled by a Go toolchain. These are the two you can click into.")),
			layerGrid(Compositions()),

			// ---- close ----
			P(Attr("class", "mt-12 border-t border-line pt-6 leading-relaxed text-ink-soft"),
				Text("There is not a screenshot of a component anywhere on this site. Every example is the "+
					"real thing, running — which is the only way a documentation page can tell you when it "+
					"has gone stale. "),
				A(Attr("class", "text-accent underline underline-offset-4"),
					Attr("href", "/about"), navigate(d, "/about"), Text("Why this exists")),
				Text("."),
			),
		)
	}
}

// item is one bullet.
func item(text string) *VNode {
	return Li(Attr("class", "flex gap-2.5"),
		Span(Attr("class", "select-none text-ink-faint"), Text("—")),
		Span(Text(text)),
	)
}

// paneLabel captions one half of the two-runtime demo.
func paneLabel(title string) *VNode {
	return Div(Attr("class", "border-b border-line px-4 py-2"),
		Span(Attr("class", "font-mono text-[11px] uppercase tracking-widest text-ink-faint"), Text(title)),
	)
}

// hydrationNote is the page's one measurement, written as a sentence rather than
// displayed on a dashboard. It is a fact about this page, not a boast about the library,
// and it reads better as the former.
func hydrationNote(ms float64) string {
	if ms == 0 {
		return "This page was rendered by Go on the server. WebAssembly is still loading."
	}
	return "This page was rendered by Go on the server; WebAssembly took over " +
		strconv.FormatFloat(ms, 'f', 0, 64) + " ms later."
}

// prettyHTML puts each element of a rendered tree on its own line. The markup shown is
// otherwise byte-for-byte what RenderHTML produced — long class lists and all, because
// tidying them for the demo would make the pane a lie.
func prettyHTML(s string) string {
	return strings.ReplaceAll(s, "><", ">\n<")
}

// The real transcript. It is on the front page, so it is the first thing anybody copies —
// which makes it the first thing to notice when it goes stale.
const buildTranscript = `$ go run ./server -build
==> generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)
==> compiling Tailwind CSS -> wwwroot/app.css
==> compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)
==> bundling the Solid app -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild)
==> copying Go's wasm_exec.js shim into wwwroot/

$ go run ./server
serving "./wwwroot" on http://localhost:8085`

// ---- about --------------------------------------------------------------

//gowasm:page /about static layout=public
func AboutPage(d Deps) func() *VNode {
	return func() *VNode {
		return Div(Attr("class", "mx-auto max-w-3xl py-4"),
			P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text("About")),
			H1(Attr("class", "mt-2 text-4xl font-semibold tracking-tight text-text-heading"), Text("Why this exists")),

			P(Attr("class", "mt-6 text-lg leading-relaxed text-ink-soft"),
				Text("Kjøl is a shared base layer, factored out of several applications so they stay in sync. "+
					"(Kjøl is Norwegian for KEEL: the spine of a hull, the thing every other part is built "+
					"onto.) The applications had drifted: the same table, the same forms, the same charts, "+
					"each subtly different in each app, each fixed twice.")),

			P(Attr("class", "mt-4 leading-relaxed text-ink-soft"),
				Text("The UI kit began as Solid.js components, and it still is — that is Kjøl JS Web, and it "+
					"is what those applications run today. Kjøl Wasm Web is the same kit written a second time "+
					"in Go and compiled to WebAssembly: the same components, the same Tailwind, no JavaScript "+
					"build at all. One language across the server and the browser, and a table you could share "+
					"with a native app, because it is a Go function rather than a JSX file.")),

			P(Attr("class", "mt-4 leading-relaxed text-ink-soft"),
				Text("Neither of them is Kjøl. They are two compositions of it — two uses of the layers "+
					"underneath, which are just directories of Go, TypeScript, C and Jai. The front page lists "+
					"both, and does not argue for either.")),

			H2(Attr("class", "mt-12 text-2xl font-semibold tracking-tight text-text-heading"), Text("The rules it keeps")),
			Div(Attr("class", "mt-6 space-y-4"),
				principle("The framework never imports application code",
					"Where kjol needs something app-specific, the app injects it — an interface, a registration "+
						"call, a config struct. The dependency only ever points one way."),
				principle("Standard library only",
					"vdom, the reconciler, the component kit, the Tailwind compiler, the PDF writer: no "+
						"third-party Go packages. A dependency in the engine is a dependency in every app that "+
						"consumes it."),
				principle("The same code on both sides",
					"A component that cannot render on the server is a component that cannot be server-rendered. "+
						"The browser APIs components need are dual-build: real under WebAssembly, no-ops "+
						"natively — so one component measures the DOM and still SSRs."),
			),

			Div(Attr("class", "mt-12 rounded-default border border-primary-border bg-primary-subtle p-5"),
				P(Attr("class", "font-semibold text-text-heading"), Text("This page is the proof, not a claim about it")),
				P(Attr("class", "mt-1 leading-relaxed text-ink-soft"),
					Text("Its HTML was rendered by Go on the server, and the same Go is running in your browser "+
						"now. View the source: the markup arrived complete.")),
			),
		)
	}
}

func principle(title, body string) *VNode {
	return Div(Attr("class", "border-l-2 border-line pl-4"),
		H3(Attr("class", "font-semibold text-text-heading"), Text(title)),
		P(Attr("class", "mt-1 leading-relaxed text-ink-soft"), Text(body)),
	)
}

// ---- server components --------------------------------------------------

//gowasm:page /wasm/server layout=app
func ServerPage(d Deps) func() *VNode {
	// ServerCounter is a server component — calling it is just like calling any
	// component. On the client this resolves to a generated stub that mounts it
	// over /rsc; on the server it's the real function.
	counter := ServerCounter()

	return func() *VNode {
		return docPage("Rendering", "Server components",
			"A server component's code and state never reach the browser. Mark a function with "+
				"//gowasm:server and the codegen replaces it, on the client, with a stub that renders it "+
				"over an HTTP round-trip — so calling one looks exactly like calling any other component.",

			docSection("declaring", "Declaring one",
				prose("The directive is the whole API. The function stays an ordinary component: it takes "+
					"whatever it needs, and returns a VNode tree."),
				code("app/server_counter.go", serverSnippet),
				note("Why the state stays put",
					"The counter's value lives in a map on the server, keyed by instance. Nothing about it is "+
						"shipped to the client — the browser holds an id and a rendered fragment, and every "+
						"click asks the server what the next fragment should be."),
			),

			docSection("try-it", "Try it",
				prose("Each click below is a POST to /rsc. The server runs the component again and returns the "+
					"new markup, which is merged into the DOM in place — the page is not reloaded and nothing "+
					"else on it is re-rendered."),
				demo("A counter whose state lives on the server", counter()),
			),

			docSection("when", "When to reach for one",
				prose("When the component needs something the browser must not have: a database handle, a "+
					"secret, a large dataset you do not want to ship. The cost is a round-trip per interaction, "+
					"so it is the wrong tool for anything that has to feel instant."),
				apiTable(
					apiRow{"//gowasm:server", "Marks a component as server-side. The codegen writes a client stub in its place."},
					apiRow{"POST /rsc", "The endpoint the stub calls. Registered by the dev server; wire it into your own server with rsc.Handler."},
					apiRow{"rsc.Handler", "The http.HandlerFunc that runs the component and returns its rendered fragment."},
				),
			),
		)
	}
}

const serverSnippet = `//gowasm:server
func ServerCounter() func() *VNode {
    id := newInstanceID()          // this state never leaves the server

    return func() *VNode {
        return Div(
            Span(Text("count: "+itoa(counts[id]))),
            Button(
                On(EVENT_CLICK, func() { counts[id]++ }),   // runs SERVER-side
                Text("+1"),
            ),
        )
    }
}`
