/**
 * Browser runtime for the Remark42 comments widget — the only part of the plugin
 * a visitor pays for (phase 001, step 06).
 *
 * Responsibilities:
 * - Ships site-wide as an `afterDOMLoaded` script string and stays a hard no-op
 *   on pages without a comments container: no gate observer, no watchdog timer,
 *   no network. The only unconditional cost is three `document`-level event
 *   subscriptions, which hold no DOM references.
 * - Loads Remark42's `embed.mjs` at most once per page load, gated by an
 *   IntersectionObserver (~600px rootMargin) so nothing is requested until the
 *   container nears the viewport (D5). The module auto-mounts on import; the
 *   gated import only ever happens with `#remark42` confirmed present, so the
 *   loader's "Can't find root node" throw is unreachable by construction.
 * - Owns `window.remark_config` deliberately: the per-page config is re-read
 *   from `script#remark42-config` on every navigation (inline scripts do not
 *   re-execute after micromorph patches the body) and written into the global
 *   before every mount — the loader's `createInstance` validates `site_id`
 *   against the global, not against its argument (v1.18.0 fixture).
 * - Keeps exactly one live widget instance: torn down on `prenav` (before
 *   micromorph patches the body) and remounted on `nav` through the persisted
 *   `window.REMARK42.createInstance` handle — a local call, never a re-download.
 * - Matches the widget's theme to `saved-theme` at every mount by injecting
 *   `theme` into the config copy handed to the loader — the iframe is then
 *   created with the correct `colorScheme` and query param, no flash — and
 *   follows `themechange` (dispatched on `document` by
 *   `@quartz-community/darkmode`; it does not fire on initial load) for the
 *   module's lifetime.
 * - Surfaces failure inside the container only: a watchdog replaces the
 *   placeholder with a failure note when the widget never reports `inited`,
 *   and an import rejection (offline/blocked) fails fast the same way. The
 *   failed instance is destroyed; there is no retry control — the reader
 *   reloads the page (locked at step 06).
 *
 * Reveal detection uses a MutationObserver on the container rather than
 * filtering cross-origin `message` events: the loader wipes every non-iframe
 * container child once the widget reports `inited`, so "only the iframe
 * remains" is a parent-side, DOM-only success signal with no fragile
 * `event.source` identity comparison.
 *
 * The file name is fixed by `build.mjs` (pass-1 entry point); like `index.tsx`
 * it is a build entry, not a barrel.
 */
 
// === Local Constants ===============================================
 
/** DOM id of the comments container — forced by the loader (step-05 contract). */
const CONTAINER_ID = "remark42"
/** DOM id of the inline config script the runtime re-reads on every navigation. */
const CONFIG_SCRIPT_ID = "remark42-config"
/** Viewport gate: start loading when the container is this close to the viewport (D5). */
const GATE_ROOT_MARGIN = "600px"
/** Watchdog budget per mount; the loader's own reveal fallback fires at 5s, so this must exceed it. */
const WATCHDOG_TIMEOUT_MS = 15_000
/** Reader-facing failure note; recovery is a page reload, by locked decision. */
const FAILURE_MESSAGE = "Comments could not be loaded. Reload the page to try again."
 
// === Local Classes =================================================
 
/**
 * Owns all mutable runtime state for one page load: the module import, the live
 * widget instance, the gate observer, the reveal watcher and the watchdog. One
 * instance exists per page load (the script body runs exactly once); all route
 * work arrives through the `nav`/`prenav` document events.
 */
class Remark42Runtime {
	/** In-flight or resolved first import — also the double-import guard. The module persists for the page's lifetime. */
	private load: Promise<unknown> | null = null
	/** Teardown handle of the currently live widget instance, or null when nothing is mounted. */
	private liveInstance: Remark42Instance | null = null
	/** Gate observer armed for the current container; null when not waiting on the viewport. */
	private gateObserver: IntersectionObserver | null = null
	/** Watchdog timer id for the current mount; null when not armed. */
	private watchdog: number | null = null
	/** MutationObserver watching the current container for the `inited` reveal; null when not armed. */
	private revealObserver: MutationObserver | null = null
	/** Monotonic mount generation — lets stale continuations of superseded mounts exit silently. */
	private generation = 0
	/** Resolves once the first import's auto-run has left its handles on the global (fixture: `REMARK42::ready`). */
	private autoRunReady: Promise<Remark42Global | null> | null = null
 
	/**
	 * `nav` handler — runs on the first load and on every SPA navigation.
	 * Re-derives everything from the current DOM; never trusts prior state.
	 */
	onNav(): void {
		this.reset()
		const container = document.getElementById(CONTAINER_ID)
		const configScript = document.getElementById(CONFIG_SCRIPT_ID)
		const config = configScript ? parseConfigFromDom(configScript) : null
		if (!container || !config) {
			// No comments on this route (absent `id`, 404, bases, excalidraw) — stay a no-op.
			return
		}
		if (this.load) {
			// Module already downloaded: the reader navigated here deliberately, so
			// mount immediately instead of waiting on the viewport gate.
			void this.mount(container, config)
			return
		}
		this.armGate(container, config)
	}
 
	/** `prenav` handler — everything DOM-bound must die before micromorph patches the body. */
	onPrenav(): void {
		this.reset()
	}
 
	/** `themechange` handler — live for the module's lifetime; a no-op when nothing is mounted. */
	onThemeChange(theme: "light" | "dark"): void {
		this.liveInstance?.changeTheme(theme)
	}
 
	/**
	 * Mounts the widget into the given container with the given config.
	 * Idempotent: destroys any live instance first, so rapid navigation can
	 * never leak a second one. All failure paths degrade to the in-container
	 * failure note; the article is never touched.
	 */
	private async mount(container: HTMLElement, config: RemarkConfig): Promise<void> {
		const generation = ++this.generation
		this.destroyInstance()
		// We own this global: `createInstance` validates `site_id` against it, and
		// the first import's auto-run mounts from it. `theme` is injected into the
		// copy so the iframe is created with the correct `colorScheme` and query
		// param — no light flash on a dark site.
		const themedConfig: RemarkConfig = { ...config, theme: currentTheme() }
		setWindowRemarkConfig(themedConfig)
		// The watchdog covers the whole mount — import, deferred auto-run, reveal —
		// so it is armed before any await.
		this.armWatchdog(container, generation)
 
		// The URL must be computed, never a literal — esbuild's pass-1 bundling
		// must not try to resolve the external module (verified at task 06).
		const firstImport = this.load === null
		if (firstImport) {
			console.debug("[remark42] loading the embed module")
			const moduleUrl = `${config.host}/web/embed.mjs`
			this.load = import(moduleUrl)
		}
		try {
			await this.load
			if (generation !== this.generation) return // superseded mid-import; the newest mount owns the widget
			let remark42 = getRemark42Global()
			// On the first import the auto-run mounts from window.remark_config and
			// leaves its handles on the global — adopt them. If the module landed
			// mid-parse the auto-run deferred to DOMContentLoaded, so wait for its
			// `REMARK42::ready` signal instead of failing on absent handles.
			if (firstImport && !remark42?.destroy) {
				remark42 = await this.waitForAutoRun()
				if (generation !== this.generation) return
			}
			// Afterwards, mount explicitly through the persisted createInstance
			// handle (module code never re-executes).
			this.liveInstance = firstImport
				? adoptGlobalInstance(remark42)
				: (remark42?.createInstance(themedConfig) ?? null)
			if (!this.liveInstance) {
				throw new Error("Remark42 global is missing or malformed after import")
			}
			this.armRevealWatch(container, generation)
		} catch (error) {
			if (generation !== this.generation) return
			console.error("[remark42] mounting the widget failed:", error)
			this.destroyInstance()
			this.stopRevealWatch()
			showFailure(container)
		}
	}
 
	/**
	 * Arms the viewport gate for a container whose module is not loaded yet.
	 * The observer is created only here — a container is confirmed present —
	 * and disconnected after its first meaningful callback.
	 */
	private armGate(container: HTMLElement, config: RemarkConfig): void {
		this.gateObserver = new IntersectionObserver(
			(entries) => {
				// The observer always fires once up front with the current state —
				// for a far-below-viewport container that is isIntersecting: false.
				// Stay armed in that case: the real callback comes when the container
				// crosses the 600px margin. Disconnect only once we act on it.
				if (!entries.some((entry) => entry.isIntersecting)) return
				this.disconnectGate()
				void this.mount(container, config)
			},
			{ rootMargin: GATE_ROOT_MARGIN },
		)
		this.gateObserver.observe(container)
	}
 
	/** Tears down everything tied to the current DOM and invalidates in-flight mounts. */
	private reset(): void {
		this.generation++
		this.destroyInstance()
		this.stopRevealWatch()
		this.disconnectGate()
	}
 
	/** Waits for the first import's auto-run; resolves immediately if it already ran. */
	private waitForAutoRun(): Promise<Remark42Global | null> {
		const existing = getRemark42Global()
		if (existing?.destroy) return Promise.resolve(existing)
		this.autoRunReady ??= new Promise((resolve) => {
			window.addEventListener("REMARK42::ready", () => resolve(getRemark42Global()), { once: true })
		})
		return this.autoRunReady
	}
 
	/** Arms the watchdog for a fresh mount; covers import, deferred auto-run and reveal. */
	private armWatchdog(container: HTMLElement, generation: number): void {
		this.watchdog = window.setTimeout(() => {
			this.watchdog = null
			if (generation !== this.generation) return
			console.error(
				"[remark42] widget did not reveal within",
				WATCHDOG_TIMEOUT_MS,
				"ms — showing the failure note",
			)
			this.destroyInstance()
			this.stopRevealWatch()
			showFailure(container)
		}, WATCHDOG_TIMEOUT_MS)
	}
 
	/** Arms the reveal watcher for a mounted instance. */
	private armRevealWatch(container: HTMLElement, generation: number): void {
		this.revealObserver = new MutationObserver(() => {
			if (generation !== this.generation) {
				this.stopRevealWatch()
				return
			}
			if (widgetRevealed(container)) this.stopRevealWatch()
		})
		this.revealObserver.observe(container, { childList: true })
	}
 
	/** Clears the watchdog and the reveal watcher; safe to call when neither is armed. */
	private stopRevealWatch(): void {
		if (this.watchdog !== null) {
			window.clearTimeout(this.watchdog)
			this.watchdog = null
		}
		this.revealObserver?.disconnect()
		this.revealObserver = null
	}
 
	/** Disconnects the gate observer; safe to call when none is armed. */
	private disconnectGate(): void {
		this.gateObserver?.disconnect()
		this.gateObserver = null
	}
 
	/**
	 * Destroys the live widget instance, if any, and clears the loader's
	 * instance handles off the global so it only ever describes a live widget.
	 * Also catches an orphaned auto-run instance from an in-flight first mount
	 * whose continuation went stale: its handles sit on the global, unowned.
	 */
	private destroyInstance(): void {
		const instance = this.liveInstance
		this.liveInstance = null
		instance?.destroy()
		const remark42 = getRemark42Global()
		if (!instance && remark42?.destroy) remark42.destroy()
		if (remark42) {
			delete remark42.destroy
			delete remark42.changeTheme
		}
	}
}
 
// === Local Functions ===============================================
 
/**
 * Extracts and validates the fresh per-page config from the inline script's
 * text. The script body is `var remark_config = {…}` — JSON produced by the
 * build, so slicing the outer braces and parsing is exact, not heuristic.
 * Returns null on any malformed content; the caller treats that as "no
 * comments on this page" (a build-time guarantee makes this a guard, not a
 * expected path).
 */
function parseConfigFromDom(configScript: Element): RemarkConfig | null {
	const text = configScript.textContent ?? ""
	const start = text.indexOf("{")
	const end = text.lastIndexOf("}")
	if (start === -1 || end <= start) return null
	try {
		const parsed: unknown = JSON.parse(text.slice(start, end + 1))
		return isRemarkConfig(parsed) ? parsed : null
	} catch {
		return null
	}
}
 
/** Runtime shape check for the config coming out of the DOM. */
function isRemarkConfig(value: unknown): value is RemarkConfig {
	if (typeof value !== "object" || value === null) return false
	const record = value as Record<string, unknown>
	return (
		typeof record.host === "string" &&
		record.host.length > 0 &&
		typeof record.site_id === "string" &&
		record.site_id.length > 0
	)
}
 
/** Reads `window.REMARK42` with a runtime shape check — no blind casts. */
function getRemark42Global(): Remark42Global | null {
	// The double cast is the deliberate boundary: `window` carries no index
	// signature, and the runtime shape check below — not the cast — establishes
	// the value's shape.
	const candidate: unknown = (window as unknown as Record<string, unknown>)["REMARK42"]
	if (typeof candidate !== "object" || candidate === null) return null
	const record = candidate as Record<string, unknown>
	if (typeof record.createInstance !== "function") return null
	return candidate as Remark42Global
}
 
/** Writes the fresh per-page config into the global the loader reads. */
function setWindowRemarkConfig(config: RemarkConfig): void {
	// Same boundary as `getRemark42Global`: the write target is validated by
	// the consumer (`createInstance`'s own `site_id` check), not by this cast.
	;(window as unknown as Record<string, unknown>)["remark_config"] = config
}
 
/**
 * Adopts the handles the auto-run mount left on the global after the first
 * import. Both are always set by a successful `createInstance` (fixture);
 * null means an unexpected module state and is treated as a mount failure.
 */
function adoptGlobalInstance(remark42: Remark42Global | null): Remark42Instance | null {
	if (!remark42) return null
	if (typeof remark42.destroy !== "function" || typeof remark42.changeTheme !== "function") return null
	return { destroy: remark42.destroy, changeTheme: remark42.changeTheme }
}
 
/** Current site theme; `saved-theme` is set before after-DOM scripts run (darkmode's beforeDOMLoaded). */
function currentTheme(): "light" | "dark" {
	return document.documentElement.getAttribute("saved-theme") === "dark" ? "dark" : "light"
}
 
/**
 * Success signal for the reveal watcher: the loader has reported `inited` —
 * it appended the iframe and wiped every non-iframe container child.
 */
function widgetRevealed(container: HTMLElement): boolean {
	const iframe = container.querySelector(":scope > iframe[data-remark42-iframe]")
	if (!iframe) return false
	for (const child of container.children) {
		if (child !== iframe) return false
	}
	return true
}
 
/**
 * Runtime guard for the `themechange` event: the darkmode package dispatches a
 * `CustomEvent<{ theme: "light" | "dark" }>` on `document`, but this module
 * deliberately does not rely on the types package's global augmentation, so
 * the listener sees a plain `Event`. The guard establishes the shape before
 * `detail` is read.
 */
function hasThemeDetail(event: Event): event is CustomEvent<{ theme: "light" | "dark" }> {
	const detail: unknown = (event as { detail?: unknown }).detail
	if (typeof detail !== "object" || detail === null) return false
	const theme = (detail as { theme?: unknown }).theme
	return theme === "light" || theme === "dark"
}
 
/** Replaces the container's content with the failure note; never touches anything outside it. */
function showFailure(container: HTMLElement): void {
	const note = document.createElement("p")
	note.setAttribute("role", "status")
	note.textContent = FAILURE_MESSAGE
	container.replaceChildren(note)
}
 
// === Local Types ===================================================
 
/** Shape of the inline `remark_config` object the component renders into `script#remark42-config`. */
type RemarkConfig = {
	host: string
	site_id: string
	[key: string]: unknown
}
 
/** The subset of `window.REMARK42` the runtime relies on (v1.18.0 fixture). */
type Remark42Global = {
	createInstance: (config: RemarkConfig) => Remark42Instance
	/** Present while an instance is live; the loader deletes both on its own destroy. */
	destroy?: () => void
	changeTheme?: (theme: string) => void
}
 
/** Handle for one live widget instance — returned by `createInstance`, adopted from the global after the auto-run. */
type Remark42Instance = {
	changeTheme: (theme: string) => void
	destroy: () => void
}
 
// === Entry Point ===================================================
 
const remark42Runtime = new Remark42Runtime()
document.addEventListener("nav", () => remark42Runtime.onNav())
document.addEventListener("prenav", () => remark42Runtime.onPrenav())
document.addEventListener("themechange", (event) => {
	// Not a theme event, or a theme value we do not recognise — ignore.
	if (hasThemeDetail(event)) remark42Runtime.onThemeChange(event.detail.theme)
})
 
export {}