/**
 * @wrnexus/ssr — server-side rendering.
 *
 * Pages return an HTML string for the body; this module wraps that body in a
 * full document with a `<head>` built from page metadata. It is intentionally
 * isolated from any client runtime: nothing here touches the DOM or ships to
 * the browser, which keeps "server-only code" genuinely server-only.
 */

import { escapeHtml, type PageMeta, type SeoConfig } from "@wrnexus/core";

export interface ScriptAsset {
  src: string;
  /** Module scripts are the default for backward compatibility. */
  type?: "module" | "classic";
  async?: boolean;
  defer?: boolean;
  integrity?: string;
  crossOrigin?: "anonymous" | "use-credentials";
  nonce?: string;
  attributes?: Record<string, string | boolean>;
}

export type RenderScript = string | ScriptAsset;

export interface PartialPrerenderResult {
  shell: string;
  regions: Array<{ id: string; html: string }>;
}

/** Extract compiler-emitted dynamic regions into a cacheable static shell. */
export function partialPrerender(html: string, startIndex = 0): PartialPrerenderResult {
  const regions: PartialPrerenderResult["regions"] = [];
  const shell = html.replace(
    /<wrn-dynamic-region\b[^>]*>([\s\S]*?)<\/wrn-dynamic-region>/gi,
    (_whole, content: string) => {
      const id = `wrn-region-${startIndex + regions.length}`;
      regions.push({ id, html: content });
      return `<template data-wrn-dynamic-placeholder="${id}"></template>`;
    },
  );
  return { shell, regions };
}

/** Stream the static shell first, followed by inert region templates for client insertion. */
export function streamPartialDocument(
  result: PartialPrerenderResult,
  nonce?: string,
): ReadableStream<Uint8Array> {
  const encoder = new TextEncoder();
  return new ReadableStream({
    start(controller) {
      controller.enqueue(encoder.encode(result.shell));
      for (const region of result.regions) {
        controller.enqueue(
          encoder.encode(
            `<template data-wrn-dynamic-content="${region.id}">${region.html}</template><script${nonce ? ` nonce="${escapeHtml(nonce)}"` : ""}>(function(){var p=document.querySelector('template[data-wrn-dynamic-placeholder="${region.id}"]');var c=document.querySelector('template[data-wrn-dynamic-content="${region.id}"]');if(p&&c){p.replaceWith(c.content);c.remove()}})()</script>`,
          ),
        );
      }
      controller.close();
    },
  });
}

export interface RenderOptions {
  /** Page metadata for the document head. */
  meta: PageMeta;
  /** Global SEO defaults from `wrnexus.config.ts`. */
  seo?: SeoConfig;
  /** Current request URL, used to resolve canonical/Open Graph URLs. */
  url?: URL;
  /** Rendered HTML for the body (placed inside `#app`). */
  body: string;
  /**
   * URLs of `<script type="module">` tags to load (e.g. per-island chunks or
   * the reactive runtime). Only the scripts a page actually needs are passed.
   */
  scripts?: RenderScript[];
  /** Optional default document title used when meta.title is absent. */
  defaultTitle?: string;
  /** Raw HTML injected at the end of `<head>` (trusted, framework-controlled). */
  extraHead?: string;
  /** CSP nonce applied to framework-promoted `.wrn` style blocks. */
  styleNonce?: string;
  /** Raw HTML injected at the end of `<body>` (trusted, framework-controlled). */
  extraBody?: string;
  /** Attributes for the `<html>` element, e.g. ` data-theme="dark"` (trusted). */
  htmlAttrs?: string;
  /**
   * Optional application-authored full document shell. It must contain
   * `<html>`, `<head>`, and `<body>`. Framework metadata, assets, and scripts
   * are merged into it instead of wrapping the rendered body again.
   */
  documentTemplate?: string;
}

/**
 * Render a complete HTML document.
 *
 * Metadata is HTML-escaped so a malicious title/description can never break
 * out of its element or attribute.
 */
export function renderDocument(opts: RenderOptions): string {
  const seo = mergeSeo(opts.seo, opts.meta, opts.defaultTitle, opts.url);
  const title = escapeHtml(seo.title);
  const scripts = normalizeScripts(opts.scripts ?? []);

  const sourceDocument = opts.documentTemplate?.trim();
  const extracted = extractWrnexusStyles(sourceDocument ?? opts.body, opts.styleNonce);
  const renderedBody = sourceDocument ? opts.body : extracted.html;
  const renderedDocument = sourceDocument ? extracted.html : undefined;

  const seoTags = renderSeoTags(seo);
  const preloadTags = scripts
    .filter((script) => (script.type ?? "module") === "module")
    .map((script) => `\n    <link rel="modulepreload" href="${escapeHtml(script.src)}" />`)
    .join("");
  const scriptTags = scripts.map((script) => `\n    ${renderScriptTag(script)}`).join("");

  const globalHead = opts.extraHead ? `\n    ${opts.extraHead}` : "";
  const localStyles = extracted.styles ? `\n    ${extracted.styles}` : "";
  const extraBody = opts.extraBody ? `\n    ${opts.extraBody}` : "";

  const htmlAttrs = /(?:^|\s)lang\s*=/.test(opts.htmlAttrs ?? "")
    ? (opts.htmlAttrs ?? "")
    : `${opts.htmlAttrs ?? ""} lang="en"`;

  if (renderedDocument) {
    let document = renderedDocument;
    if (!/^<!doctype\s+html>/i.test(document)) document = `<!doctype html>\n${document}`;
    document = document.replace(/<html([^>]*)>/i, (_match, existing: string) => {
      const language = /(?:^|\s)lang\s*=/.test(`${existing}${opts.htmlAttrs ?? ""}`)
        ? ""
        : ' lang="en"';
      return `<html${existing}${opts.htmlAttrs ?? ""}${language}>`;
    });
    const headContent = `
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" href="/favicon.ico" />
    <title>${title}</title>${seoTags}${preloadTags}${globalHead}`;
    document = document.replace(/<head([^>]*)>/i, `<head$1>${headContent}`);
    document = appendBeforeClosingTag(document, "head", localStyles);
    document = document.replace(/<\/body>/i, `${scriptTags}${extraBody}\n  </body>`);
    return `${document}\n`;
  }

  return `<!doctype html>
<html${htmlAttrs}>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" href="/favicon.ico" />
    <title>${title}</title>${seoTags}${preloadTags}${globalHead}${localStyles}
  </head>
  <body>
    <div id="app">${renderedBody}</div>${scriptTags}${extraBody}
  </body>
</html>
`;
}

interface ExtractedStyle {
  id: string;
  owner: string;
  kind: "layout" | "page" | "component";
  css: string;
  order: number;
}

interface ExtractedWrnexusStyles {
  html: string;
  styles: string;
}

const WRNEXUS_STYLE_RE = /<style\b([^>]*)>([\s\S]*?)<\/style\s*>/gi;

function appendBeforeClosingTag(document: string, tag: string, html: string): string {
  if (!html) return document;
  const closing = new RegExp(`</${tag}\\s*>`, "i");
  return closing.test(document)
    ? document.replace(closing, `${html}\n  </${tag}>`)
    : `${document}${html}`;
}

function readHtmlAttribute(attributes: string, name: string): string | undefined {
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const match = attributes.match(
    new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"),
  );
  return match?.[1] ?? match?.[2] ?? match?.[3];
}

function stableStyleHash(value: string): string {
  let hash = 0x811c9dc5;
  for (let index = 0; index < value.length; index++) {
    hash ^= value.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193);
  }
  return (hash >>> 0).toString(36);
}

function normalizeStyleKind(value: string | undefined): ExtractedStyle["kind"] {
  return value === "layout" || value === "page" ? value : "component";
}

function styleKindOrder(kind: ExtractedStyle["kind"]): number {
  return kind === "layout" ? 0 : kind === "page" ? 1 : 2;
}

function escapeStyleAttribute(value: string): string {
  return escapeHtml(value);
}

/**
 * Promote compiler-emitted `.wrn` style blocks out of rendered markup and into
 * the document head. They are emitted after global stylesheets, deduplicated by
 * stable id, ordered layout -> page -> component, and nonce-tagged for CSP.
 */
export function extractWrnexusStyles(html: string, nonce?: string): ExtractedWrnexusStyles {
  const found: ExtractedStyle[] = [];
  let sourceOrder = 0;

  const cleaned = html.replace(
    WRNEXUS_STYLE_RE,
    (whole: string, attributes: string, css: string) => {
      const legacyOwner = readHtmlAttribute(attributes, "data-wrnexus-style");
      const explicitId = readHtmlAttribute(attributes, "data-wrnexus-style-id");
      if (!legacyOwner && !explicitId) return whole;

      const owner =
        readHtmlAttribute(attributes, "data-wrnexus-style-owner") ?? legacyOwner ?? "anonymous";
      const kind = normalizeStyleKind(readHtmlAttribute(attributes, "data-wrnexus-style-kind"));
      const id = explicitId ?? `wrn-${kind}-${stableStyleHash(`${kind}:${owner}`)}`;

      found.push({ id, owner, kind, css: css.trim(), order: sourceOrder++ });
      return "";
    },
  );

  const byId = new Map<string, ExtractedStyle>();
  for (const style of found) {
    const current = byId.get(style.id);
    if (!current) {
      byId.set(style.id, style);
      continue;
    }
    // Repeated mounts of one component carry identical CSS. Keep the latest
    // body only when it differs while preserving the original cascade position.
    if (current.css !== style.css) byId.set(style.id, { ...style, order: current.order });
  }

  const nonceAttribute = nonce ? ` nonce="${escapeStyleAttribute(nonce)}"` : "";
  const styles = [...byId.values()]
    .sort((left, right) => {
      const kind = styleKindOrder(left.kind) - styleKindOrder(right.kind);
      return kind || left.order - right.order;
    })
    .map(
      (style) =>
        `<style data-wrnexus-style-id="${escapeStyleAttribute(style.id)}" data-wrnexus-style-owner="${escapeStyleAttribute(style.owner)}" data-wrnexus-style-kind="${style.kind}"${nonceAttribute}>\n${style.css.replace(/<\/style/gi, "<\\/style")}\n</style>`,
    )
    .join("\n    ");

  return { html: cleaned, styles };
}

function normalizeScripts(scripts: readonly RenderScript[]): ScriptAsset[] {
  const bySrc = new Map<string, ScriptAsset>();
  for (const script of scripts) {
    const normalized: ScriptAsset = typeof script === "string" ? { src: script } : script;
    if (!normalized.src || bySrc.has(normalized.src)) continue;
    bySrc.set(normalized.src, normalized);
  }
  return [...bySrc.values()];
}

function renderScriptTag(script: ScriptAsset): string {
  const type = script.type ?? "module";
  const attrs: string[] = [];
  if (type === "module") attrs.push('type="module"');
  if (script.async) attrs.push("async");
  if (script.defer || (type === "classic" && !script.async)) attrs.push("defer");
  attrs.push(`src="${escapeHtml(script.src)}"`);
  if (script.integrity) attrs.push(`integrity="${escapeHtml(script.integrity)}"`);
  if (script.crossOrigin) attrs.push(`crossorigin="${escapeHtml(script.crossOrigin)}"`);
  if (script.nonce) attrs.push(`nonce="${escapeHtml(script.nonce)}"`);
  const reserved = new Set(["src", "type", "async", "defer", "integrity", "crossorigin", "nonce"]);
  for (const [name, value] of Object.entries(script.attributes ?? {})) {
    if (
      reserved.has(name.toLowerCase()) ||
      !/^[A-Za-z_:][A-Za-z0-9:._-]*$/.test(name) ||
      value === false
    ) {
      continue;
    }
    attrs.push(value === true ? name : `${name}="${escapeHtml(value)}"`);
  }
  return `<script ${attrs.join(" ")}></script>`;
}

interface ResolvedSeo {
  title: string;
  description?: string;
  canonical?: string;
  robots?: string;
  keywords?: string;
  image?: string;
  siteName?: string;
  type: string;
  locale?: string;
  twitterCard: string;
  twitterSite?: string;
  themeColor?: string;
}

function mergeSeo(
  globalSeo: SeoConfig | undefined,
  pageSeo: PageMeta,
  defaultTitle: string | undefined,
  url: URL | undefined,
): ResolvedSeo {
  const pageTitle = pageSeo.title;
  const baseTitle = pageTitle ?? globalSeo?.title ?? defaultTitle ?? "WrNexus";
  const titleTemplate = pageTitle ? globalSeo?.titleTemplate : undefined;
  const title = titleTemplate?.includes("%s") ? titleTemplate.replace("%s", baseTitle) : baseTitle;

  const canonical = resolveSeoUrl(
    pageSeo.canonical ??
      globalSeo?.canonical ??
      (globalSeo?.canonicalBase && url ? url.pathname : undefined),
    pageSeo.canonicalBase ?? globalSeo?.canonicalBase,
    url,
  );
  const image = resolveSeoUrl(
    pageSeo.image ?? globalSeo?.image,
    pageSeo.canonicalBase ?? globalSeo?.canonicalBase,
    url,
  );
  const keywords = pageSeo.keywords ?? globalSeo?.keywords;

  return {
    title,
    description: pageSeo.description ?? globalSeo?.description,
    canonical,
    robots: pageSeo.robots ?? globalSeo?.robots,
    keywords: Array.isArray(keywords) ? keywords.join(", ") : keywords,
    image,
    siteName: pageSeo.siteName ?? globalSeo?.siteName,
    type: pageSeo.type ?? globalSeo?.type ?? "website",
    locale: pageSeo.locale ?? globalSeo?.locale,
    twitterCard: pageSeo.twitterCard ?? globalSeo?.twitterCard ?? "summary",
    twitterSite: pageSeo.twitterSite ?? globalSeo?.twitterSite,
    themeColor: pageSeo.themeColor ?? globalSeo?.themeColor,
  };
}

function renderSeoTags(seo: ResolvedSeo): string {
  const tags: string[] = [];

  if (seo.description) tags.push(meta("description", seo.description));
  if (seo.robots) tags.push(meta("robots", seo.robots));
  if (seo.keywords) tags.push(meta("keywords", seo.keywords));
  if (seo.themeColor) tags.push(meta("theme-color", seo.themeColor));
  if (seo.canonical) tags.push(`<link rel="canonical" href="${escapeHtml(seo.canonical)}" />`);

  tags.push(property("og:title", seo.title));
  if (seo.description) tags.push(property("og:description", seo.description));
  tags.push(property("og:type", seo.type));
  if (seo.canonical) tags.push(property("og:url", seo.canonical));
  if (seo.siteName) tags.push(property("og:site_name", seo.siteName));
  if (seo.locale) tags.push(property("og:locale", seo.locale));
  if (seo.image) tags.push(property("og:image", seo.image));

  tags.push(meta("twitter:card", seo.twitterCard));
  tags.push(meta("twitter:title", seo.title));
  if (seo.description) tags.push(meta("twitter:description", seo.description));
  if (seo.image) tags.push(meta("twitter:image", seo.image));
  if (seo.twitterSite) tags.push(meta("twitter:site", seo.twitterSite));

  return tags.length ? `\n    ${tags.join("\n    ")}` : "";
}

function meta(name: string, content: string): string {
  return `<meta name="${escapeHtml(name)}" content="${escapeHtml(content)}" />`;
}

function property(name: string, content: string): string {
  return `<meta property="${escapeHtml(name)}" content="${escapeHtml(content)}" />`;
}

function resolveSeoUrl(
  value: string | undefined,
  base: string | undefined,
  currentUrl: URL | undefined,
): string | undefined {
  if (!value) return undefined;
  try {
    const origin = base ?? (currentUrl ? currentUrl.origin : undefined);
    return origin ? new URL(value, origin).toString() : value;
  } catch {
    return value;
  }
}

export interface StreamRenderOptions extends Omit<RenderOptions, "body"> {
  body: string | Promise<string> | AsyncIterable<string>;
}

function isAsyncIterable(value: unknown): value is AsyncIterable<string> {
  return (
    typeof value === "object" &&
    value !== null &&
    Symbol.asyncIterator in value &&
    typeof (value as AsyncIterable<string>)[Symbol.asyncIterator] === "function"
  );
}

async function* bodyChunks(body: StreamRenderOptions["body"]): AsyncIterable<string> {
  if (typeof body === "string") {
    yield body;
    return;
  }
  if (isAsyncIterable(body)) {
    yield* body;
    return;
  }
  yield await body;
}

/**
 * Stream a complete document while preserving the exact head/body contract of
 * `renderDocument`. Async iterables can flush a shell, primary content, and
 * slower fragments without buffering the entire route.
 */
export function renderDocumentStream(opts: StreamRenderOptions): ReadableStream<Uint8Array> {
  const marker = "<!--__WRNEXUS_STREAM_BODY__-->";
  const document = renderDocument({ ...opts, body: marker });
  const [prefix, suffix] = document.split(marker);
  const encoder = new TextEncoder();

  return new ReadableStream<Uint8Array>({
    async start(controller) {
      try {
        controller.enqueue(encoder.encode(prefix ?? ""));
        for await (const chunk of bodyChunks(opts.body)) controller.enqueue(encoder.encode(chunk));
        controller.enqueue(encoder.encode(suffix ?? ""));
        controller.close();
      } catch (error) {
        controller.error(error);
      }
    },
  });
}

export function streamDocumentResponse(
  opts: StreamRenderOptions,
  init: ResponseInit = {},
): Response {
  const headers = new Headers(init.headers);
  if (!headers.has("content-type")) headers.set("content-type", "text/html; charset=utf-8");
  return new Response(renderDocumentStream(opts), { ...init, headers });
}

export * from "./rpc.ts";
export * from "./store-context.ts";
