import { useEffect, useRef, useState } from "react";

// Which languages get a live sandboxed preview (RenderBlock) instead of a plain
// syntax block (CodeBlock), and how each becomes a document body, lives in
// ./preview/languages.js. A language like `js` is deliberately absent —
// auto-executing bare script isn't this feature's job (see RenderBlock's doc
// comment for the sandboxing model).
import { PREVIEW_LANGS, RENDERABLE_LANGS } from "./preview/languages.js";
// The execution track's display half: a ```nexus-run fence is a run result
// (source + captured output), not a program to execute here. See run-langs.js.
import { RUN_FENCE_LANG, parseRunResult } from "./preview/run-langs.js";
// Self-modification's display half: a ```nexus-edit fence is the result of an
// already-applied, already-approved edit_source/edit_playbook/edit_settings
// call. See self-edit-langs.js.
import { EDIT_FENCE_LANG, parseEditResult } from "./preview/self-edit-langs.js";
import { diffLines, DIFF_LINE_COLOR, keyValueDiffLines } from "./preview/diff-view.js";

// Parse content into an array of {type, value, lang, streaming} blocks.
// Handles:
//   - ```lang\ncode``` (well-formed)
//   - ```code```       (no language, no newline — model formatting bug)
//   - ```lang\ncode    (unclosed — streaming in progress)
function parseBlocks(content) {
  const blocks = [];
  let i = 0;

  while (i < content.length) {
    const fenceStart = content.indexOf("```", i);

    if (fenceStart === -1) {
      if (i < content.length) blocks.push({ type: "text", value: content.slice(i) });
      break;
    }

    if (fenceStart > i) blocks.push({ type: "text", value: content.slice(i, fenceStart) });

    let j = fenceStart + 3;
    let lang = "";

    // Language specifier is valid only when tag-chars are followed by a newline.
    // If there's no newline (e.g. ```pythonprint(...)) treat everything as code.
    // Hyphens count: `nexus-run` is a tag this file dispatches on, and real
    // languages spell themselves that way too (objective-c, c-sharp).
    const langMatch = content.slice(j).match(/^([\w-]+)(\r?\n)/);
    if (langMatch) {
      lang = langMatch[1];
      j += langMatch[0].length;
    } else if (/^\r?\n/.test(content.slice(j))) {
      j += content[j] === "\r" ? 2 : 1;
    }
    // else: no newline — leave j as-is, lang stays ""

    const closeIdx = content.indexOf("```", j);
    if (closeIdx === -1) {
      blocks.push({ type: "code", lang, value: content.slice(j), streaming: true });
      i = content.length;
    } else {
      blocks.push({ type: "code", lang, value: content.slice(j, closeIdx).replace(/\n$/, ""), streaming: false });
      i = closeIdx + 3;
    }
  }

  return blocks;
}

export function Markdown({ content }) {
  if (!content) return null;
  const blocks = parseBlocks(content);
  return (
    <div style={{ lineHeight: "1.6" }}>
      {blocks.map((block, i) => {
        if (block.type !== "code") return <TextBlock key={i} text={block.value} />;
        const lang = (block.lang || "").toLowerCase();
        if (lang === RUN_FENCE_LANG) {
          // A half-streamed envelope is not parseable JSON, so the block shows
          // as code until the fence closes and then becomes the run panel.
          const run = block.streaming ? null : parseRunResult(block.value);
          if (run) return <RunBlock key={i} run={run} />;
        }
        if (lang === EDIT_FENCE_LANG) {
          const edit = block.streaming ? null : parseEditResult(block.value);
          if (edit) return <EditBlock key={i} edit={edit} />;
        }
        return RENDERABLE_LANGS.has(lang)
          ? <RenderBlock key={i} lang={lang} value={block.value} streaming={block.streaming} />
          : <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />;
      })}
    </div>
  );
}

function CodeBlock({ lang, value, streaming }) {
  const [copied, setCopied] = useState(false);

  const copy = () => {
    navigator.clipboard.writeText(value.trimEnd()).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    });
  };

  return (
    <div style={{
      background: "#0d0d0d",
      border: "1px solid #2a2a2a",
      borderRadius: "6px",
      margin: "0.5rem 0",
      overflow: "hidden",
    }}>
      <div style={{
        display: "flex",
        justifyContent: "space-between",
        alignItems: "center",
        padding: "0.3rem 0.75rem",
        background: "#161616",
        borderBottom: "1px solid #2a2a2a",
      }}>
        <span style={{ fontSize: "0.75rem", color: "#666", fontFamily: "monospace" }}>
          {lang || "code"}
          {streaming && <span style={{ color: "#444", marginLeft: "0.5rem" }}>●</span>}
        </span>
        {!streaming && (
          <button onClick={copy} style={{
            background: "transparent",
            border: "none",
            color: copied ? "#4caf50" : "#555",
            cursor: "pointer",
            fontSize: "0.75rem",
            padding: "0.1rem 0.3rem",
          }}>
            {copied ? "Copied!" : "Copy"}
          </button>
        )}
      </div>
      <pre style={{
        padding: "0.75rem 1rem",
        overflowX: "auto",
        fontSize: "0.85rem",
        lineHeight: "1.5",
        margin: 0,
        fontFamily: "monospace",
      }}>
        <code>{value.trimEnd()}</code>
      </pre>
    </div>
  );
}

// A finished run: the source that was executed and what it printed, in one
// block with an Output/Code toggle.
//
// Nothing executes in the browser here, which is the whole difference from
// RenderBlock below. The program already ran on the host (synapse/code_run.py)
// under the user's per-call approval; by the time this renders, the result is
// history. So there is no iframe, no CSP and no sandbox in this component - the
// only untrusted thing present is *text*, and React escapes it.
//
// Output and stderr are shown together rather than on separate tabs: a program
// that printed three lines and then panicked is telling one story, and splitting
// it hides which half the reader needs. Exit code sits in the header because a
// silent non-zero exit is otherwise invisible.
function RunBlock({ run }) {
  const [tab, setTab] = useState("output");
  const [copied, setCopied] = useState(false);

  const copy = () => {
    navigator.clipboard.writeText(run.source.trimEnd()).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    });
  };

  const failed = run.exitCode !== null && run.exitCode !== 0;
  const empty = !run.stdout.trim() && !run.stderr.trim();

  return (
    <div style={{
      background: "#0d0d0d",
      border: "1px solid #2a2a2a",
      borderRadius: "6px",
      margin: "0.5rem 0",
      overflow: "hidden",
    }}>
      <div style={{
        display: "flex",
        justifyContent: "space-between",
        alignItems: "center",
        padding: "0.3rem 0.75rem",
        background: "#161616",
        borderBottom: "1px solid #2a2a2a",
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
          <TabButton active={tab === "output"} onClick={() => setTab("output")}>
            Output
          </TabButton>
          <TabButton active={tab === "code"} onClick={() => setTab("code")}>
            Code
          </TabButton>
          <span style={{ fontSize: "0.7rem", color: "#555", fontFamily: "monospace", marginLeft: "0.25rem" }}>
            {run.label}
          </span>
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
          {run.exitCode !== null && (
            <span style={{
              fontSize: "0.7rem",
              fontFamily: "monospace",
              color: failed ? "#ff8a80" : "#4caf50",
            }}>
              exit {run.exitCode}
            </span>
          )}
          <button onClick={copy} style={_chromeButtonStyle(copied ? "#4caf50" : "#555")}>
            {copied ? "Copied!" : "Copy"}
          </button>
        </div>
      </div>
      {tab === "output" ? (
        <div style={{
          padding: "0.75rem 1rem",
          fontSize: "0.85rem",
          lineHeight: "1.5",
          fontFamily: "monospace",
          maxHeight: "24rem",
          overflow: "auto",
        }}>
          {empty && (
            <span style={{ color: "#555" }}>
              (the program printed nothing)
            </span>
          )}
          {run.stdout && (
            <pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word", color: "#ddd" }}>
              {run.stdout.replace(/\n$/, "")}
            </pre>
          )}
          {run.stderr && (
            <pre style={{
              margin: run.stdout ? "0.5rem 0 0" : 0,
              whiteSpace: "pre-wrap",
              wordBreak: "break-word",
              color: "#ff8a80",
            }}>
              {run.stderr.replace(/\n$/, "")}
            </pre>
          )}
        </div>
      ) : (
        <pre style={{
          padding: "0.75rem 1rem",
          overflowX: "auto",
          fontSize: "0.85rem",
          lineHeight: "1.5",
          margin: 0,
          fontFamily: "monospace",
        }}>
          <code>{run.source.trimEnd()}</code>
        </pre>
      )}
    </div>
  );
}

// A finished self-edit: the diff/change that was actually applied, plus (for
// edit_source) the git commit it landed as. Like RunBlock, nothing executes
// here — the edit already happened on the backend under approval, and by the
// time this renders it's history. Styled the same way as CodeBlock/RunBlock
// for visual consistency, with diff lines colored via the shared
// diff-view.js vocabulary rather than a syntax-highlighting library.
function EditBlock({ edit }) {
  const [copied, setCopied] = useState(false);

  const copyText =
    edit.kind === "source" ? edit.diff
    : edit.kind === "playbook" ? edit.instructions
    : JSON.stringify(edit.applied, null, 2);

  const copy = () => {
    navigator.clipboard.writeText((copyText || "").trimEnd()).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    });
  };

  const title =
    edit.kind === "source" ? `edited ${edit.path}`
    : edit.kind === "playbook" ? `playbook: ${edit.title || edit.id}`
    : "settings changed";

  const lines =
    edit.kind === "source" ? diffLines(edit.diff)
    : edit.kind === "settings"
      ? keyValueDiffLines(
          Object.fromEntries(Object.entries(edit.applied).map(([k, v]) => [k, v.before])),
          Object.fromEntries(Object.entries(edit.applied).map(([k, v]) => [k, v.after])),
          Object.keys(edit.applied),
        )
      : [];

  return (
    <div style={{
      background: "#0d0d0d",
      border: "1px solid #2a2a2a",
      borderRadius: "6px",
      margin: "0.5rem 0",
      overflow: "hidden",
    }}>
      <div style={{
        display: "flex",
        justifyContent: "space-between",
        alignItems: "center",
        padding: "0.3rem 0.75rem",
        background: "#161616",
        borderBottom: "1px solid #2a2a2a",
      }}>
        <span style={{ fontSize: "0.75rem", color: "#888", fontFamily: "monospace" }}>
          {title}
        </span>
        <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
          {edit.commit && (
            <span style={{ fontSize: "0.7rem", fontFamily: "monospace", color: "#7a92a8" }}>
              commit {edit.commit}
            </span>
          )}
          <button onClick={copy} style={_chromeButtonStyle(copied ? "#4caf50" : "#555")}>
            {copied ? "Copied!" : "Copy"}
          </button>
        </div>
      </div>
      {edit.kind === "playbook" && edit.isMainPlaybook && (
        <div style={{
          padding: "0.4rem 0.75rem", background: "#2a2418", color: "#e8c65a",
          fontSize: "0.8rem", fontWeight: 600, borderBottom: "1px solid #2a2a2a",
        }}>
          ⚠ this is now the active system prompt
        </div>
      )}
      {(edit.kind === "settings") && (edit.policyChange || edit.systemPromptChange) && (
        <div style={{
          padding: "0.4rem 0.75rem", background: "#2a2418", color: "#e8c65a",
          fontSize: "0.8rem", fontWeight: 600, borderBottom: "1px solid #2a2a2a",
        }}>
          ⚠ {edit.policyChange && "changed the tool-approval policy"}
          {edit.policyChange && edit.systemPromptChange && " and "}
          {edit.systemPromptChange && "changed the fallback system prompt"}
        </div>
      )}
      {edit.kind === "playbook" ? (
        <pre style={{
          padding: "0.75rem 1rem", overflowX: "auto", fontSize: "0.85rem",
          lineHeight: "1.5", margin: 0, fontFamily: "monospace", color: "#ccc",
        }}>
          <code>{(edit.instructions || "").trimEnd()}</code>
        </pre>
      ) : (
        <pre style={{
          padding: "0.75rem 1rem", overflowX: "auto", fontSize: "0.85rem",
          lineHeight: "1.5", margin: 0, fontFamily: "monospace",
          maxHeight: "24rem", overflowY: "auto",
        }}>
          {lines.map((l, i) => (
            <div key={i} style={{ color: DIFF_LINE_COLOR[l.kind], whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
              {l.text || " "}
            </div>
          ))}
        </pre>
      )}
    </div>
  );
}

// Content-Security-Policy for the rendered preview. Together with the iframe's
// `sandbox` attribute below, this is the entire trust boundary for model-
// authored HTML/SVG, so it stays conservative rather than convenient:
//   - script-src/style-src 'unsafe-inline'   inline <script>/<style> in the
//     fence run (that's the whole point - charts, small interactive demos),
//     but nothing else is allowed to load.
//   - img-src/font-src data:                embedded (base64) images/fonts
//     work; remote https:// ones silently fail to load, on purpose.
//   - connect-src 'none'                     no fetch/XHR/WebSocket out - a
//     model-authored block can't phone home or probe the LAN.
//   - default-src 'none'                     blanket deny for everything else
//     (frames, media, workers, ...) not explicitly allowed above.
//   - base-uri 'none'                        base-uri does NOT fall back to
//     default-src, so it has to be named explicitly or a <base> tag would slip
//     through the blanket deny above.
const _RENDER_CSP =
  "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
  "img-src data:; font-src data:; connect-src 'none'; frame-src 'none'; " +
  "form-action 'none'; base-uri 'none';";

// Injected ahead of the model's markup in every preview document, so it is
// installed before that markup's own scripts can throw. The literal
// `</script>` below is safe unescaped because this module is emitted as an
// external .js asset - it is never inlined into index.html, where the HTML
// parser would end the surrounding script tag early.
//
// postMessage is the one channel an opaque-origin sandboxed frame still has to
// the parent, and this is the entire protocol over it: one message shape,
// outbound only, carrying a content height and an error string. Nothing flows
// the other way. The parent treats both fields as untrusted data - the height
// is clamped and the message is rendered as text, never as markup - because
// they were produced by the same code the sandbox exists to contain.
//
// Without this the frame is silent: a preview whose script throws just renders
// blank, which is why the server-side validator in synapse/tools.py has to
// guess at runtime failures it can't observe.
const _PREVIEW_BOOTSTRAP = `<script>
(function () {
  var observers = [];
  // Measure the body box, never documentElement: <html>'s scrollHeight is at
  // least the viewport, i.e. at least whatever height the parent just applied,
  // so feeding it back would make every preview climb to the cap. body height
  // is auto, so its scrollHeight tracks content alone; its own margins sit
  // outside that box and have to be added back by hand.
  var measure = function () {
    var b = document.body;
    if (!b) return 0;
    var cs = getComputedStyle(b);
    return b.scrollHeight
      + (parseFloat(cs.marginTop) || 0)
      + (parseFloat(cs.marginBottom) || 0);
  };
  // The first error is remembered and re-sent with every later message. A
  // document can throw while parsing, before the parent has attached its
  // listener, and a dropped error leaves a blank frame with no explanation -
  // the exact failure this bootstrap exists to prevent. Re-sending costs
  // nothing: the parent setting the same string twice is a no-op.
  var firstErr = "";
  var post = function (err) {
    if (err && !firstErr) firstErr = String(err).slice(0, 500);
    try {
      parent.postMessage({ __nexusPreview: 1, h: measure(), err: firstErr }, "*");
    } catch (e) { /* parent went away - nothing to report to */ }
  };

  // Coalesce bursts: one re-render can fire many mutations.
  var pending = 0;
  var soon = function () {
    if (pending) return;
    pending = setTimeout(function () { pending = 0; post(); }, 50);
  };
  window.onerror = function (msg, src, line, col, err) {
    // Line numbers are document-relative; the user reads them against their own
    // source in the Code tab. Subtract everything above it: the shell, this
    // bootstrap, and for JSX the inlined view library and import stubs.
    // (No backticks anywhere in here - this whole script is a template literal.)
    var off = (window.__previewLineOffset | 0);
    var n = line - off;
    // Walk the stack for the innermost frame that lands in the user's own code.
    // The top frame is often shell: a component that throws while rendering is
    // caught and rethrown by the view library, and a stubbed import throws from
    // the stub. Both sit above the user's first line, so they subtract to less
    // than 1 and the next frame down is the one worth reporting.
    if (err && err.stack) {
      var re = /:(\\d+):\\d+/g, m;
      while ((m = re.exec(String(err.stack)))) {
        var cand = (+m[1]) - off;
        if (cand >= 1) { n = cand; break; }
      }
    }
    post(n >= 1 ? msg + "  (line " + n + ")" : msg);
    return false;
  };
  window.addEventListener("unhandledrejection", function (e) {
    var r = e.reason;
    post("Unhandled promise rejection: " + ((r && r.message) || r));
  });
  window.addEventListener("load", function () {
    post();
    // Two observers, because neither covers the other's case. A
    // MutationObserver catches content and inline-style changes - what a
    // component re-render does - and runs off the microtask queue. A
    // ResizeObserver catches size changes with no DOM change behind them, such
    // as a CSS transition or a media query, but is delivered as part of the
    // rendering lifecycle, so a frame that is never composited never gets one.
    // The references are held so neither is collected while still observing.
    if (window.MutationObserver && document.body) {
      observers.push(new MutationObserver(soon));
      observers[observers.length - 1].observe(document.body, {
        childList: true, subtree: true, attributes: true, characterData: true
      });
    }
    if (window.ResizeObserver && document.body) {
      observers.push(new ResizeObserver(soon));
      observers[observers.length - 1].observe(document.body);
    }
    setTimeout(post, 300); // late paints: fonts, async draws, first rAF frame
  });
})();
</script>`;

// Substituted with the real line offset once the document is assembled and its
// shell can be measured. Sits on one line so replacing it can't shift any.
const _OFFSET_TOKEN = "__PREVIEW_LINE_OFFSET__";

/**
 * Build the sandboxed document for a fence. Returns {doc, error}: a language
 * whose source doesn't parse (JSX, today) has no document to show, and the
 * caller renders the message instead of a frame.
 *
 * The shell - charset, CSP, bootstrap - is identical for every language; only
 * the body differs, so only that part goes through the registry. Nothing about
 * the sandboxing is per-language and shouldn't be: SVG can carry <script> and
 * event-handler attributes exactly like HTML can, and transformed JSX is just
 * more script. Every language is contained the same way.
 */
function buildSrcDoc(lang, value) {
  const entry = PREVIEW_LANGS[lang];
  if (!entry) return { doc: null, error: `No preview for '${lang}'.` };

  let body;
  try {
    body = entry.toBody(value);
  } catch (e) {
    return { doc: null, error: e && e.message ? e.message : String(e) };
  }

  const head =
    "<!doctype html><html><head><meta charset=\"utf-8\">" +
    `<meta http-equiv="Content-Security-Policy" content="${_RENDER_CSP}">` +
    `<script>window.__previewLineOffset=${_OFFSET_TOKEN};</script>` +
    _PREVIEW_BOOTSTRAP +
    "</head><body style=\"margin:0\">";

  // Lines of shell above the user's own code: the document head, plus whatever
  // the language puts in the body ahead of it (the Preact build, for JSX).
  const offset = (head.match(/\n/g) || []).length + body.userOffset;

  return {
    doc: (head + body.html + "</body></html>").replace(_OFFSET_TOKEN, String(offset)),
    error: "",
  };
}

// Auto-height bounds. The frame is sized from content, and content sized in
// viewport/percentage units is therefore sized from the frame - a body with its
// own margin makes that loop grow by the margin on every pass. Measuring the
// body box rather than documentElement is what actually settles that loop;
// _MAX_PREVIEW_H then caps anything still climbing within a few iterations.
//
// _MAX_H_STEPS is only a last resort against a document that oscillates
// forever, so it is generous: an interactive component legitimately changes
// height on every click, and a tight budget would freeze the frame mid-session
// at whatever size it happened to reach.
const _MIN_PREVIEW_H = 160;
const _MAX_PREVIEW_H = 720;
const _MAX_H_STEPS = 60;

// Live preview for a renderable fenced block: a Preview/Code toggle rendered
// via a sandboxed iframe whose document is an encoded data: URL.
//
// Trust boundary: `sandbox="allow-scripts"` — deliberately without
// allow-same-origin, allow-forms, allow-popups, or allow-top-navigation. No
// allow-same-origin forces the iframe onto an opaque origin, which is what
// actually matters here: even the inline scripts the CSP allows to run can't
// read this app's cookies/localStorage, can't call its API (no credentialed
// or same-origin fetch is possible), and can't reach `window.parent`. The CSP
// above blocks resource and script-initiated network access. The embedding
// document's `frame-src data:` policy in index.html closes a separate CSP gap:
// a child is otherwise allowed to navigate its own browsing context to a URL.
// The initial data: document is allowed and inherits the parent policy, while
// an http(s) navigation is rejected before its request is sent. Nothing here
// substitutes for a general code-execution sandbox (Docker, WASM, etc.);
// model-authored code runs only inside the browser's sandboxed frame.
function RenderBlock({ lang, value, streaming }) {
  const [tab, setTab] = useState("preview");
  const [expanded, setExpanded] = useState(false);
  const [copied, setCopied] = useState(false);

  const copy = () => {
    navigator.clipboard.writeText(value.trimEnd()).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    });
  };

  // Don't preview a block whose fence hasn't closed yet - it's incomplete
  // markup by definition, and re-pointing an iframe at a half-formed
  // document on every streamed token is both wasteful and flickery. Code view
  // already has its own streaming indicator (the same dot CodeBlock uses).
  const showPreview = tab === "preview" && !streaming;

  return (
    <div style={{
      background: "#0d0d0d",
      border: "1px solid #2a2a2a",
      borderRadius: "6px",
      margin: "0.5rem 0",
      overflow: "hidden",
    }}>
      <div style={{
        display: "flex",
        justifyContent: "space-between",
        alignItems: "center",
        padding: "0.3rem 0.75rem",
        background: "#161616",
        borderBottom: "1px solid #2a2a2a",
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
          <TabButton active={tab === "preview"} disabled={streaming} onClick={() => setTab("preview")}>
            Preview
          </TabButton>
          <TabButton active={tab === "code"} onClick={() => setTab("code")}>
            Code
          </TabButton>
          <span style={{ fontSize: "0.7rem", color: "#555", fontFamily: "monospace", marginLeft: "0.25rem" }}>
            {lang}
            {streaming && <span style={{ color: "#444", marginLeft: "0.4rem" }}>●</span>}
          </span>
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
          {showPreview && (
            <button onClick={() => setExpanded((e) => !e)} style={_chromeButtonStyle("#555")}>
              {expanded ? "Collapse" : "Expand"}
            </button>
          )}
          {!streaming && (
            <button onClick={copy} style={_chromeButtonStyle(copied ? "#4caf50" : "#555")}>
              {copied ? "Copied!" : "Copy"}
            </button>
          )}
        </div>
      </div>
      {showPreview ? (
        // Keyed by the markup: new markup is a new document, so remounting is
        // what resets the reported error and measured height. No reset effect.
        <PreviewFrame key={`${lang}:${value}`} lang={lang} value={value} expanded={expanded} />
      ) : (
        <pre style={{
          padding: "0.75rem 1rem",
          overflowX: "auto",
          fontSize: "0.85rem",
          lineHeight: "1.5",
          margin: 0,
          fontFamily: "monospace",
        }}>
          <code>{value.trimEnd()}</code>
        </pre>
      )}
    </div>
  );
}

// The sandboxed frame plus the two things it reports back: its content height
// and its first uncaught error. Split out of RenderBlock so the caller can key
// it by markup - a fresh document then gets fresh state by remounting.
function PreviewFrame({ lang, value, expanded }) {
  const [error, setError] = useState("");
  const [height, setHeight] = useState(240);
  const frameRef = useRef(null);
  const heightRef = useRef(240);  // mirrors `height` so the listener needn't re-subscribe
  const stepsRef = useRef(0);

  // Receive the bootstrap's reports. The frame is on an opaque origin, so
  // e.origin is the string "null" and proves nothing - identify the sender by
  // its window instead, which content inside the sandbox cannot forge.
  useEffect(() => {
    const onMessage = (e) => {
      if (!frameRef.current || e.source !== frameRef.current.contentWindow) return;
      const data = e.data;
      if (!data || data.__nexusPreview !== 1) return;

      if (typeof data.err === "string" && data.err) setError(data.err);

      if (typeof data.h === "number" && Number.isFinite(data.h) && stepsRef.current < _MAX_H_STEPS) {
        const next = Math.min(_MAX_PREVIEW_H, Math.max(_MIN_PREVIEW_H, Math.round(data.h)));
        if (Math.abs(next - heightRef.current) >= 8) {
          heightRef.current = next;
          stepsRef.current += 1;
          setHeight(next);
        }
      }
    };
    window.addEventListener("message", onMessage);
    return () => window.removeEventListener("message", onMessage);
  }, []);

  // A build failure (JSX that doesn't parse) has no document to show at all, so
  // the message stands in for the frame rather than sitting under it.
  const { doc, error: buildError } = buildSrcDoc(lang, value);
  const frameUrl = doc ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
  const shown = buildError || error;

  return (
    <>
      {frameUrl && (
        <iframe
          ref={frameRef}
          title="rendered output"
          sandbox="allow-scripts"
          src={frameUrl}
          style={{
            width: "100%",
            height: expanded ? "70vh" : `${height}px`,
            border: "none",
            background: "#fff",
            display: "block",
          }}
        />
      )}
      {shown && (
        <div style={{
          background: "#2a1414",
          borderTop: "1px solid #4a2020",
          color: "#ff8a80",
          fontFamily: "monospace",
          fontSize: "0.75rem",
          padding: "0.4rem 0.75rem",
          // Text from inside the sandbox: rendered as a string, and wrapped
          // rather than allowed to stretch the block.
          whiteSpace: "pre-wrap",
          wordBreak: "break-word",
        }}>
          {shown}
        </div>
      )}
    </>
  );
}

function _chromeButtonStyle(color) {
  return {
    background: "transparent",
    border: "none",
    color,
    cursor: "pointer",
    fontSize: "0.75rem",
    padding: "0.1rem 0.3rem",
  };
}

function TabButton({ active, disabled, onClick, children }) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      style={{
        background: active ? "#262626" : "transparent",
        border: "none",
        borderRadius: "4px",
        color: disabled ? "#3a3a3a" : active ? "#eee" : "#888",
        cursor: disabled ? "default" : "pointer",
        fontSize: "0.75rem",
        padding: "0.15rem 0.5rem",
      }}
    >
      {children}
    </button>
  );
}

function TextBlock({ text }) {
  const lines = text.split("\n");
  const elements = [];
  let i = 0;

  while (i < lines.length) {
    const line = lines[i];

    const hMatch = line.match(/^(#{1,3})\s+(.+)/);
    if (hMatch) {
      const level = hMatch[1].length;
      const sizes = { 1: "1.2rem", 2: "1.05rem", 3: "0.95rem" };
      elements.push(
        <div key={i} style={{ fontWeight: "700", fontSize: sizes[level], margin: "0.6rem 0 0.2rem", color: "#fff" }}>
          {inlineMarkdown(hMatch[2])}
        </div>
      );
      i++; continue;
    }

    if (/^[-*]\s+/.test(line)) {
      const items = [];
      while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
        items.push(<li key={i}>{inlineMarkdown(lines[i].replace(/^[-*]\s+/, ""))}</li>);
        i++;
      }
      elements.push(<ul key={`ul-${i}`} style={{ paddingLeft: "1.25rem", margin: "0.25rem 0" }}>{items}</ul>);
      continue;
    }

    if (/^\d+\.\s+/.test(line)) {
      const items = [];
      while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
        items.push(<li key={i}>{inlineMarkdown(lines[i].replace(/^\d+\.\s+/, ""))}</li>);
        i++;
      }
      elements.push(<ol key={`ol-${i}`} style={{ paddingLeft: "1.25rem", margin: "0.25rem 0" }}>{items}</ol>);
      continue;
    }

    if (/^---+$/.test(line.trim())) {
      elements.push(<hr key={i} style={{ border: "none", borderTop: "1px solid #333", margin: "0.5rem 0" }} />);
      i++; continue;
    }

    if (line.trim() === "") {
      elements.push(<div key={i} style={{ height: "0.4rem" }} />);
      i++; continue;
    }

    elements.push(<div key={i}>{inlineMarkdown(line)}</div>);
    i++;
  }

  return <>{elements}</>;
}

function inlineMarkdown(text) {
  const parts = [];
  const re = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g;
  let last = 0;
  let m;

  while ((m = re.exec(text)) !== null) {
    if (m.index > last) parts.push(text.slice(last, m.index));
    if (m[2] !== undefined) {
      parts.push(<strong key={m.index}>{m[2]}</strong>);
    } else if (m[3] !== undefined) {
      parts.push(<em key={m.index}>{m[3]}</em>);
    } else if (m[4] !== undefined) {
      parts.push(
        <code key={m.index} style={{
          background: "#1e1e1e",
          border: "1px solid #333",
          borderRadius: "3px",
          padding: "0.1rem 0.35rem",
          fontSize: "0.85em",
          fontFamily: "monospace",
        }}>
          {m[4]}
        </code>
      );
    }
    last = m.index + m[0].length;
  }

  if (last < text.length) parts.push(text.slice(last));
  return parts.length === 1 && typeof parts[0] === "string" ? parts[0] : parts;
}
