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";

// 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 word-chars are followed by a newline.
    // If there's no newline (e.g. ```pythonprint(...)) treat everything as code.
    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();
        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>
  );
}

// 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
    // Heartbeat: the parent's watchdog needs a message even when nothing is
    // changing, or an idle-but-alive frame reads the same as a hung one.
    setInterval(post, 1000);
  });
})();
</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.
 */
async function buildSrcDoc(lang, value) {
  const entry = PREVIEW_LANGS[lang];
  if (!entry) return { doc: null, error: `No preview for '${lang}'.` };

  let body;
  try {
    body = await 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;

// A frame that never posts again — a synchronous `while(true)` in the user's
// own script, or a runaway re-render loop the bootstrap's own coalescing
// can't outpace — has nothing else to signal it. Silence past this long since
// mount (or since the last message) is treated as hung and the frame is torn
// down; the bootstrap's 1s heartbeat means a merely-idle-but-alive frame never
// gets close to this.
const _WATCHDOG_MS = 6000;

// 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 [doc, setDoc] = useState("");
  const [buildError, setBuildError] = useState("");
  const [height, setHeight] = useState(240);
  const [hung, setHung] = useState(false);
  const frameRef = useRef(null);
  const heightRef = useRef(240);  // mirrors `height` so the listener needn't re-subscribe
  const stepsRef = useRef(0);
  const lastMsgRef = useRef(0);  // set for real by the watchdog effect below

  // 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;
      lastMsgRef.current = Date.now();

      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);
  }, []);

  // Watchdog: a frame that goes silent past _WATCHDOG_MS — most likely a
  // synchronous infinite loop in the model's own script, which blocks even
  // the bootstrap's heartbeat from ever running — gets torn down rather than
  // left spinning. Checked on an interval rather than a single timeout so a
  // message arriving late (slow compile, heavy first paint) keeps resetting
  // the clock instead of tripping early.
  useEffect(() => {
    lastMsgRef.current = Date.now();
    const id = setInterval(() => {
      if (Date.now() - lastMsgRef.current > _WATCHDOG_MS) {
        setHung(true);
        clearInterval(id);
      }
    }, 1000);
    return () => clearInterval(id);
  }, [lang, value]);

  useEffect(() => {
    let current = true;
    setDoc("");
    setBuildError("");
    buildSrcDoc(lang, value).then((result) => {
      if (!current) return;
      setDoc(result.doc || "");
      setBuildError(result.error || "");
    });
    return () => { current = false; };
  }, [lang, value]);

  // 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. A hung
  // frame tears down the same way: dropping frameUrl unmounts the iframe,
  // which is what actually stops a runaway script from holding the tab.
  const frameUrl = doc && !hung ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
  const shown = hung
    ? "Preview stopped responding (likely an infinite loop) and was stopped."
    : 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;
}
