import React, { type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { compileJsx, evaluateComponent } from "./component-runtime";
import * as Components from "./components";
import { createToolsProxy, type ToolCallRequest } from "./tools-proxy";

type ParentRequestPayload = {
  type: "executor.toolCall";
  path: string[];
  args: unknown[];
  role?: string;
};

type ParentResponse = {
  type: "executor.response";
  requestId: number;
  token: string;
  ok: boolean;
  value?: unknown;
  error?: string;
};

type RenderMessage = {
  type: "executor.render";
  token: string;
  code: string;
  theme?: unknown;
  /**
   * Whether the embedding host can store a preview snapshot.
   *
   * Only the console can: it has an endpoint to PUT one to. Inside a real MCP
   * client (Claude and friends) the capture message would travel to a host that
   * has nowhere to put it, so the work is simply not done there. The flag comes
   * from the host rather than being inferred, because "who is embedding me" is
   * not something this frame can know.
   */
  capturePreview?: unknown;
  /**
   * Whether the shell has a bounded viewport for this artifact to fill.
   *
   * Told to the frame rather than inferred: this document cannot see the MCP
   * host context, and its own height is whatever the shell gave it, so there is
   * nothing here to measure. See `./display-mode`.
   */
  fill?: unknown;
};

type ThemeMessage = {
  type: "executor.theme";
  token: string;
  theme?: unknown;
};

/** A mode change arriving after this frame has already rendered. */
type FillMessage = {
  type: "executor.fill";
  token: string;
  fill?: unknown;
};

type InboundMessage = ParentResponse | RenderMessage | ThemeMessage | FillMessage;

const token = document.querySelector<HTMLMetaElement>(
  "meta[name='executor-render-token']",
)?.content;

if (!token) {
  throw new Error("Missing renderer token.");
}

const pending = new Map<
  number,
  {
    resolve: (value: unknown) => void;
    reject: (reason: Error) => void;
  }
>();

let nextRequestId = 0;
let root: ReturnType<typeof createRoot> | null = null;

const blockedNetwork = (name: string) => () => {
  throw new Error(`${name} is disabled in generated UI. Use tools.* via useQuery/useMutation.`);
};

Object.assign(globalThis, {
  fetch: blockedNetwork("fetch"),
  XMLHttpRequest: blockedNetwork("XMLHttpRequest"),
  WebSocket: blockedNetwork("WebSocket"),
  EventSource: blockedNetwork("EventSource"),
  Worker: blockedNetwork("Worker"),
  SharedWorker: blockedNetwork("SharedWorker"),
});

// Grab these before any model-written code runs. The saved postMessage and the
// private nonce mean generated code can't fake or snoop on our open-link
// messages, even if it later overwrites `parent.postMessage`.
const postParentMessage = window.parent.postMessage.bind(window.parent);
const openLinkNonce = Array.from(crypto.getRandomValues(new Uint32Array(4))).join("-");

const sendParent = (message: Record<string, unknown>) => {
  postParentMessage({ ...message, token }, "*");
};

// The sandbox can't open popups on its own (no `allow-popups`, on purpose), so
// a plain `<a target="_blank">` click does nothing. We hand those clicks to the
// host, which opens the link for us. Normal same-page links are left alone.
document.addEventListener("click", (event) => {
  // Only real user clicks count. Generated code can call `element.click()`, but
  // it can't forge a trusted event, so those are ignored here.
  if (!event.isTrusted || event.defaultPrevented || event.button !== 0) return;
  const target = event.target;
  if (!(target instanceof Element)) return;
  const anchor = target.closest<HTMLAnchorElement>("a[href]");
  if (!anchor || anchor.target.toLowerCase() !== "_blank" || anchor.href === "") return;

  event.preventDefault();
  sendParent({ type: "executor.openLink", url: anchor.href, openLinkNonce });
});

const requestParent = (message: ParentRequestPayload): Promise<unknown> => {
  const requestId = ++nextRequestId;
  return new Promise((resolve, reject) => {
    pending.set(requestId, { resolve, reject });
    sendParent({ ...message, requestId });
  });
};

/** The shell's transport for the shared `tools` proxy: every call becomes a
 *  `postMessage` to the parent frame, settled when the shell answers. */
const requestParentToolCall = (request: ToolCallRequest): Promise<unknown> =>
  requestParent({
    type: "executor.toolCall",
    path: [...request.path],
    args: [...request.args],
    ...(request.role === undefined ? {} : { role: request.role }),
  });

function makeQueryClient(): QueryClient {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        refetchOnWindowFocus: false,
      },
      mutations: {
        retry: false,
      },
    },
  });
}

let queryClient: QueryClient = makeQueryClient();

const applyTheme = (theme: unknown) => {
  if (theme === "dark" || theme === "light") {
    document.documentElement.classList.toggle("dark", theme === "dark");
  }
};

/**
 * Bind this document to its frame, so an artifact's own `h-full` means something.
 *
 * This is the LAST link in the height chain — page content area -> outer frame ->
 * shell document -> this srcdoc frame -> `#root` -> the artifact's root element.
 * The `html.artifact-fill` rule in `theme.css` gives `html`, `body` and `#root` a
 * definite `height: 100%`; without it every one of them is `auto` and a
 * percentage height inside the artifact has no parent height to resolve against.
 *
 * `.artifact-root` gets `display: flex` with it, so the artifact's own root
 * element is a flex child that can actually be told to fill — an `h-full` child
 * of a plain block box would still only be as tall as the box's content.
 */
const applyFill = (fill: unknown) => {
  document.documentElement.classList.toggle("artifact-fill", fill === true);
};

/**
 * Tell the shell, once, that this frame has actually painted something.
 *
 * A host holding a single loading surface across the whole open — the console
 * does — needs to know when to take it down, and every cheaper proxy is a
 * different fact:
 *
 *  - The `executor.render` message only says the code was DELIVERED. Compiling
 *    it (Babel, then the in-frame Tailwind JIT) is the slow part, and revealing
 *    the frame here shows a blank white box for as long as that takes.
 *  - The first `executor.renderer.size` is not it either. The `ResizeObserver`
 *    below fires its initial callback on `observe()`, against an empty body,
 *    BEFORE any render message can arrive — so the earliest size report is a
 *    height of zero for a document with nothing in it.
 *
 * So the signal is emitted from inside the React tree, where "committed" is not
 * a guess: the effect runs after React has committed the tree, and the
 * animation frame after it puts the message one paint later still. That is the
 * first instant at which a host swapping to this frame swaps to real pixels.
 *
 * It fires for the ERROR surfaces too, because `renderError` renders through
 * the same path. That is deliberate — the question a host is asking is "should
 * the user be looking at the frame instead of my skeleton", and a failed
 * artifact answers yes. A signal that only fired on success would hide every
 * failure under a loading state forever.
 */
function RenderSignal(props: { readonly children: ReactNode }) {
  React.useEffect(() => {
    const frame = requestAnimationFrame(() => {
      sendParent({ type: "executor.renderer.rendered" });
    });
    return () => cancelAnimationFrame(frame);
  }, []);
  return <>{props.children}</>;
}

/**
 * Mount into the shell-owned container.
 *
 * `.artifact-root` carries the artifact's outer padding and max width, so every
 * artifact is framed identically whether or not its author thought about it —
 * and the skill can tell the model NOT to add a page-level `p-6`, because
 * doubling it is the visible failure. Applied here rather than in the srcDoc's
 * markup so the class travels with whatever renders, including the error paths.
 */
const renderNode = (node: ReactNode) => {
  const mount = document.getElementById("root");
  if (!mount) return;
  mount.classList.add("artifact-root");
  root ??= createRoot(mount);
  // The signal wraps the tree rather than following `root.render(...)` as a
  // callback: `render` is not synchronous under a concurrent root, so a message
  // posted after the call can beat the commit it is meant to announce. A
  // `key` per mount is not needed — the effect belongs to this root's lifetime,
  // and a re-render into the same root is the same artifact still on screen.
  root.render(
    <RenderSignal>
      <Components.TooltipProvider>{node}</Components.TooltipProvider>
    </RenderSignal>,
  );
};

const renderError = (title: string, message: string) => {
  renderNode(<Components.ArtifactError title={title} error={message} />);
  sendParent({ type: "executor.renderer.error", message });
};

class ErrorBoundary extends React.Component<{ children: ReactNode }, { error: Error | null }> {
  constructor(props: { children: ReactNode }) {
    super(props);
    this.state = { error: null };
  }

  static getDerivedStateFromError(error: Error) {
    return { error };
  }

  override render() {
    if (this.state.error) {
      // The stack is deliberately dropped. Whoever is looking at an artifact
      // did not write it, so a trace is noise to them and the real audience —
      // the model — gets the message back over `executor.renderer.error`.
      return (
        <Components.ArtifactError
          title="This artifact stopped rendering"
          error={this.state.error}
          hint="The component threw while rendering. Ask the agent to fix it, or reopen the artifact."
        />
      );
    }
    return this.props.children;
  }
}

// ---------------------------------------------------------------------------
// Preview capture — the artifact as it actually looks, once it has data.
// ---------------------------------------------------------------------------

/** How long the render must be quiet before it counts as settled. */
const SETTLE_QUIET_MS = 2000;

/** A ceiling on waiting for quiet, for an artifact that polls forever. */
const SETTLE_DEADLINE_MS = 15_000;

/** Cap on the captured markup, mirroring the endpoint's own limit. */
const CAPTURE_LIMIT = 512 * 1024;

/** At most one capture per render: the first settled picture is the artifact. */
let capturedThisRender = false;

/**
 * Serialize the settled render as sanitizable HTML.
 *
 * ## Why this is not a raster
 *
 * The intent was pixels: SVG `foreignObject` -> `<img>` -> canvas -> PNG. That
 * cannot work here, and the reason is structural rather than incidental.
 *
 * The inner frame is a `srcdoc` iframe sandboxed WITHOUT `allow-same-origin`,
 * so it runs in an opaque origin. Every image is therefore cross-origin to it,
 * including one built from its own bytes — a 203-byte inline SVG through a blob
 * URL taints the canvas exactly as a remote JPEG would, and `toDataURL` then
 * throws `SecurityError: Tainted canvases may not be exported`. Measured, not
 * assumed: the trivial case fails identically to the real one. Nothing short of
 * granting the frame a real origin fixes it, and that origin is the entire
 * sandbox — the frame runs model-written code, so it is not negotiable.
 *
 * So the upgrade captures the settled DOM instead. That keeps what actually
 * makes the second layer worth having: the layout preview is the artifact
 * before its data arrives, and this is the artifact WITH its data — real
 * numbers, real rows, real labels — which is the difference a viewer sees on
 * the card. It gives up only the exact typeface and pixel-level fidelity.
 *
 * The markup goes through the same sanitizer as the create-time preview, so
 * nothing about how the console renders it changes.
 */
const captureSettledHtml = (target: HTMLElement): string | null => {
  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort capture, every failure degrades to "no upgrade"
  try {
    const html = target.innerHTML;
    // Bounded here as well as at the endpoint: a settled render can be far
    // larger than its loading state (every row is present now), and the whole
    // point of the cap is that the gallery stays cheap to list.
    return html.length === 0 || html.length > CAPTURE_LIMIT ? null : html;
  } catch {
    return null;
  }
};

/**
 * Wait for the render to go quiet, then capture it once.
 *
 * "Quiet" is defined against the QueryClient rather than a timer alone: as long
 * as any query is fetching, the artifact is still filling in, and a snapshot
 * then would be of a half-loaded UI. Once nothing is in flight for
 * `SETTLE_QUIET_MS`, what is on screen is what the artifact looks like.
 *
 * The deadline is the backstop for an artifact that polls on an interval and
 * therefore never goes quiet: capture the best available picture rather than
 * none at all.
 */
const captureWhenSettled = () => {
  if (capturedThisRender) return;
  const mount = document.getElementById("root");
  if (!mount) return;

  const started = Date.now();
  let quietTimer: ReturnType<typeof setTimeout> | undefined;
  let done = false;

  const finish = () => {
    if (done) return;
    done = true;
    unsubscribe();
    if (quietTimer !== undefined) clearTimeout(quietTimer);
    if (capturedThisRender) return;
    capturedThisRender = true;
    const preview = captureSettledHtml(mount);
    if (preview !== null) sendParent({ type: "executor.renderer.preview", preview });
  };

  const arm = () => {
    if (quietTimer !== undefined) clearTimeout(quietTimer);
    if (Date.now() - started > SETTLE_DEADLINE_MS) {
      finish();
      return;
    }
    quietTimer = setTimeout(() => {
      if (queryClient.isFetching() > 0) {
        arm();
        return;
      }
      finish();
    }, SETTLE_QUIET_MS);
  };

  const unsubscribe = queryClient.getQueryCache().subscribe(() => {
    if (!done) arm();
  });
  arm();
};

const renderGeneratedCode = (code: string) => {
  try {
    const compiled = compileJsx(code);
    const evalResult = evaluateComponent(compiled, createToolsProxy(requestParentToolCall));

    if ("error" in evalResult) {
      renderError("Error", evalResult.error);
      return;
    }

    sendParent({ type: "executor.renderer.config", config: evalResult.config });
    const Component = evalResult.component;
    queryClient = makeQueryClient();
    renderNode(
      <QueryClientProvider client={queryClient}>
        <ErrorBoundary>
          <Component />
        </ErrorBoundary>
      </QueryClientProvider>,
    );
  } catch (err) {
    renderError("Compilation Error", err instanceof Error ? err.message : String(err));
  }
};

window.addEventListener("message", (event: MessageEvent<InboundMessage>) => {
  if (event.source !== window.parent) return;
  const data = event.data;
  if (!data || typeof data !== "object" || data.token !== token) return;

  if (data.type === "executor.response") {
    const entry = pending.get(data.requestId);
    if (!entry) return;
    pending.delete(data.requestId);
    if (data.ok) {
      entry.resolve(data.value);
    } else {
      entry.reject(new Error(data.error ?? "Renderer request failed"));
    }
    return;
  }

  if (data.type === "executor.theme") {
    applyTheme(data.theme);
    return;
  }

  if (data.type === "executor.fill") {
    applyFill(data.fill);
    return;
  }

  if (data.type === "executor.render") {
    applyTheme(data.theme);
    applyFill(data.fill);
    // A fresh render is a fresh picture: the previous capture, if any, was of
    // different code.
    capturedThisRender = false;
    renderGeneratedCode(data.code);
    // Only where the host said it can store one. Everywhere else — every real
    // MCP client — the work is skipped entirely rather than done and dropped.
    if (data.capturePreview === true) captureWhenSettled();
  }
});

const resizeObserver = new ResizeObserver(([entry]) => {
  sendParent({
    type: "executor.renderer.size",
    height: Math.ceil(entry.contentRect.height),
  });
});

resizeObserver.observe(document.body);
sendParent({ type: "executor.renderer.ready", openLinkNonce });
