'use client';

// Runtime that boots inside the null-origin plugin sandbox iframe.
//
// Lifecycle:
//   1. Iframe loads → posts 'sandbox-ready' to parent (targetOrigin '*' is OK;
//      the message carries no secrets, and the parent's first inbound message
//      gives us the origin to pin for everything that follows).
//   2. Parent posts 'init' with the bundle code + manifest + mode/slot.
//   3. We evaluate the bundle in a `new Function` scope with React/ReactDOM
//      injected as globals; the bundle is CommonJS-style (`module.exports = {
//      slots, hooks, activate }`). ES-module syntax inside the bundle is a
//      build-time concern handled by the plugin's bundler.
//   4. In background mode: register hook handlers and call `activate(api)`.
//      The host installs HookBus stubs and dispatches via 'hook-invoke'.
//   5. In slot mode: look up `slots[slot].component`, render it into the
//      iframe body, push height back via ResizeObserver.

import { useEffect } from 'react';
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import * as ReactJSXRuntime from 'react/jsx-runtime';
import type {
  HostToSandbox,
  SandboxToHost,
  InitPayload,
  BackgroundInit,
  SlotInit,
} from './protocol';
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
import type { SlotName } from '../plugin-types';

// ─── Module-scope state ──────────────────────────────────────

interface PluginExports {
  slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number }>;
  hooks?: Record<string, (...args: unknown[]) => unknown>;
  /**
   * Keyboard shortcut bindings. Each entry's `handler` is registered as a
   * hook named `shortcut:<id>` so the host's keydown dispatcher can fire it.
   */
  shortcuts?: Record<string, {
    keys: string;
    label: string;
    category?: string;
    handler: () => void | Promise<void>;
  }>;
  activate?: (api: unknown) => void | Promise<void> | { dispose: () => void };
  default?: unknown;
}

let parentWindow: Window | null = null;
let parentOrigin: string | null = null;
let pluginExports: PluginExports | null = null;
let mode: 'background' | 'slot' | null = null;
let slotName: SlotName | null = null;
let bootDone = false;
// Guards the initial sandbox-ready post against React strict mode's double
// useEffect invocation; the parent only needs to be pinged once per iframe.
let readyPosted = false;

const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
const pendingCallbacks = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
const hookHandlers: Record<string, (...args: unknown[]) => unknown> = {};

function sendToHost(msg: SandboxToHost): void {
  if (!parentWindow || !parentOrigin) return;
  parentWindow.postMessage(msg, parentOrigin);
}

// ─── Theme replay ────────────────────────────────────────────

const THEME_STYLE_ID = '__plugin_host_theme';

/**
 * Replay a host theme snapshot inside the iframe: inject the token + font CSS
 * and mirror the `.dark` class onto <html> so plugin styles that key off
 * `.dark` (or read `var(--color-*)`) behave like the host. Idempotent — safe
 * to call again on every 'theme-change'.
 */
function applyHostTheme(theme: ThemeSnapshot): void {
  if (typeof document === 'undefined') return;
  let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;
  if (!styleEl) {
    styleEl = document.createElement('style');
    styleEl.id = THEME_STYLE_ID;
    document.head.appendChild(styleEl);
  }
  styleEl.textContent = themeSnapshotToCSS(theme);
  document.documentElement.classList.toggle('dark', theme.dark);
}

function uid(): string {
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
}

// ─── Sandboxed API facade (calls flow to host via postMessage) ─

const DEFAULT_API_TIMEOUT_MS = 30_000;

function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise<unknown> {
  const id = uid();
  return new Promise((resolve, reject) => {
    pendingApi.set(id, { resolve, reject });
    sendToHost({ type: 'api-request', id, method, args });
    // Bounded so a hung host can't leak the promise forever. Interactive UI
    // dialogs (ui.confirm/ui.alert) pass timeoutMs <= 0 to opt out: they wait
    // for human input, the host always resolves them on confirm/cancel/close,
    // and any still-pending call dies with the iframe on teardown - so there's
    // nothing to leak, and a thinking user must not trip a 30s timeout.
    if (timeoutMs > 0 && Number.isFinite(timeoutMs)) {
      setTimeout(() => {
        const entry = pendingApi.get(id);
        if (!entry) return;
        pendingApi.delete(id);
        entry.reject(new Error(`API call ${method} timed out after ${Math.round(timeoutMs / 1000)}s`));
      }, timeoutMs);
    }
  });
}

function invokeHostCallback(callbackId: string, args: unknown[]): Promise<unknown> {
  const id = uid();
  return new Promise((resolve, reject) => {
    pendingCallbacks.set(id, { resolve, reject });
    sendToHost({ type: 'callback-invoke', id, callbackId, args });
    setTimeout(() => {
      const entry = pendingCallbacks.get(id);
      if (!entry) return;
      pendingCallbacks.delete(id);
      entry.reject(new Error('host callback timed out after 30s'));
    }, 30_000);
  });
}

/**
 * Walks an object graph received from the host and rehydrates
 * `{ __pluginCallback: id }` markers into stub functions that round-trip via
 * the 'callback-invoke' RPC. Mirrors `encodeCallbacks` in host-bridge.ts.
 */
function decodeCallbacks(value: unknown, depth = 0): unknown {
  if (depth > 6) return null;
  if (value === null || value === undefined) return value;
  const t = typeof value;
  if (t !== 'object') return value;
  if (Array.isArray(value)) return value.map((v) => decodeCallbacks(v, depth + 1));
  const obj = value as Record<string, unknown>;
  if (typeof obj.__pluginCallback === 'string') {
    const cbId = obj.__pluginCallback;
    return (...args: unknown[]) => invokeHostCallback(cbId, args);
  }
  const out: Record<string, unknown> = {};
  for (const [k, v] of Object.entries(obj)) {
    out[k] = decodeCallbacks(v, depth + 1);
  }
  return out;
}

type PluginManifest = BackgroundInit['manifest'];

function buildPluginApi(manifest: PluginManifest) {
  return {
    plugin: {
      id: manifest.id,
      version: manifest.version,
      settings: { ...manifest.settings },
    },
    storage: {
      get: (key: string) => callApi('storage.get', [key]),
      set: (key: string, value: unknown) => callApi('storage.set', [key, value]),
      remove: (key: string) => callApi('storage.remove', [key]),
      keys: () => callApi('storage.keys', []),
    },
    http: {
      post: (path: string, body: Record<string, unknown>) => callApi('http.post', [path, body]),
      fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]),
    },
    toast: {
      success: (m: string) => { void callApi('toast.success', [m]); },
      error: (m: string) => { void callApi('toast.error', [m]); },
      info: (m: string) => { void callApi('toast.info', [m]); },
      warning: (m: string) => { void callApi('toast.warning', [m]); },
    },
    ui: {
      /** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise.
       *  No timeout - it waits for the user's choice. */
      confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
        callApi('ui.confirm', [opts], 0) as Promise<boolean>,
      /** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
      alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
        callApi('ui.alert', [opts], 0) as Promise<void>,
      /** Opens an http/https URL in a new tab via host `window.open`. */
      openExternalUrl: (url: string, target?: string) =>
        callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
    },
    admin: {
      getConfig: (key: string) => callApi('admin.getConfig', [key]),
      getAllConfig: () => callApi('admin.getAllConfig', []),
      setConfig: (key: string, v: unknown) => callApi('admin.setConfig', [key, v]),
      deleteConfig: (key: string) => callApi('admin.deleteConfig', [key]),
    },
    log: {
      debug: (...a: unknown[]) => console.debug(`[plugin:${manifest.id}]`, ...a),
      info:  (...a: unknown[]) => console.info(`[plugin:${manifest.id}]`, ...a),
      warn:  (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
      error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a),
    },
    // Localization for plugins. The host pushes the active locale (init +
    // 'locale-change'); `t` resolves a key against the plugin's declared
    // `locales` map (manifest.locales), falling back to English then the key
    // itself, with optional {placeholder} interpolation.
    i18n: {
      get locale(): string {
        return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
      },
      t(key: string, vars?: Record<string, string | number>): string {
        const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
        const tables = manifest.locales || {};
        let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key;
        if (vars) {
          for (const [k, v] of Object.entries(vars)) {
            out = out.split('{' + k + '}').join(String(v));
          }
        }
        return out;
      },
    },
  };
}

// ─── Bundle evaluation ───────────────────────────────────────

/**
 * Resolve a bundler-emitted `require(name)` call inside the sandbox. Plugin
 * bundlers should be configured to externalise React; the runtime provides
 * those modules here. Anything else is refused - the sandbox has no Node-
 * compatible module resolution and we don't want plugins probing globals.
 *
 * The host injects the per-plugin API as `@plugin-host`, so plugin code can
 * `const api = require('@plugin-host')` in both background and slot modes.
 */
function makePluginRequire(api: ReturnType<typeof buildPluginApi> | null): (name: string) => unknown {
  const known: Record<string, unknown> = {
    'react': React,
    'react-dom': ReactDOM,
    'react-dom/client': ReactDOM,
    'react/jsx-runtime': ReactJSXRuntime,
    'react/jsx-dev-runtime': ReactJSXRuntime,
  };
  if (api) known['@plugin-host'] = api;
  return (name: string) => {
    if (Object.prototype.hasOwnProperty.call(known, name)) return known[name];
    throw new Error(`Plugin sandbox: module "${name}" is not available. Externalise it in your bundler or ship it bundled.`);
  };
}

function evaluateBundle(code: string, api: ReturnType<typeof buildPluginApi> | null): PluginExports {
  const mod: { exports: PluginExports } = { exports: {} };
  const requireShim = makePluginRequire(api);
  let fn: (...args: unknown[]) => void;
  try {
    fn = new Function(
      'module', 'exports', 'require', 'React', 'ReactDOM', 'JsxRuntime', 'console',
      code,
    ) as (...args: unknown[]) => void;
  } catch (err) {
    throw new Error(`Bundle parse error: ${(err as Error).message}`);
  }
  try {
    fn(mod, mod.exports, requireShim, React, ReactDOM, ReactJSXRuntime, console);
  } catch (err) {
    throw new Error(`Bundle evaluation threw: ${(err as Error).message}`);
  }
  const exports = (mod.exports?.default ?? mod.exports) as PluginExports;
  if (!exports || typeof exports !== 'object') {
    throw new Error('Bundle did not produce module.exports object');
  }
  return exports;
}

// ─── Init flow ───────────────────────────────────────────────

async function bootBackground(payload: BackgroundInit): Promise<void> {
  const api = buildPluginApi(payload.manifest);
  const exports = evaluateBundle(payload.code, api);
  pluginExports = exports;

  // Register hooks (each value must be a function).
  const hookNames: string[] = [];
  const hooks = exports.hooks ?? {};
  for (const [name, handler] of Object.entries(hooks)) {
    if (typeof handler === 'function') {
      hookHandlers[name] = handler;
      hookNames.push(name);
    }
  }

  // Enumerate slot offers.
  const slotInfo: Array<{ name: SlotName; hasShouldShow: boolean; order: number }> = [];
  const slots = exports.slots ?? {};
  for (const [name, def] of Object.entries(slots)) {
    if (def && typeof def.component === 'function') {
      slotInfo.push({
        name: name as SlotName,
        hasShouldShow: typeof def.shouldShow === 'function',
        order: typeof def.order === 'number' ? def.order : 100,
      });
    }
  }

  // Shortcuts: register each handler as a 'shortcut:<id>' hook so the host's
  // global keydown dispatcher can invoke it.
  const shortcutInfo: Array<{ id: string; keys: string; label: string; category?: string }> = [];
  const shortcuts = exports.shortcuts ?? {};
  for (const [id, def] of Object.entries(shortcuts)) {
    if (!def || typeof def.handler !== 'function' || typeof def.keys !== 'string') continue;
    hookHandlers[`shortcut:${id}`] = def.handler as (...args: unknown[]) => unknown;
    hookNames.push(`shortcut:${id}`);
    shortcutInfo.push({
      id,
      keys: def.keys,
      label: typeof def.label === 'string' ? def.label : id,
      category: typeof def.category === 'string' ? def.category : undefined,
    });
  }

  // Side effects.
  if (typeof exports.activate === 'function') {
    await Promise.resolve(exports.activate(api));
  }

  sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo, shortcuts: shortcutInfo });
}

function bootSlot(payload: SlotInit): void {
  // Replay the host theme before first paint so the slot never flashes the UA
  // default serif font or a light-on-light/dark mismatch.
  applyHostTheme(payload.theme);

  const api = buildPluginApi(payload.manifest);
  const exports = evaluateBundle(payload.code, api);
  pluginExports = exports;
  slotName = payload.slot;

  const slotDef = exports.slots?.[payload.slot];
  if (!slotDef || typeof slotDef.component !== 'function') {
    throw new Error(`Plugin "${payload.pluginId}" does not export slots["${payload.slot}"].component`);
  }

  const rootEl = document.getElementById('plugin-sandbox-root');
  if (!rootEl) throw new Error('Sandbox root element missing');

  let currentProps = decodeCallbacks(payload.extraProps) as Record<string, unknown>;
  const Component = slotDef.component;

  // A trivial pub/sub so host-pushed `props-update` messages re-render the
  // slot tree without tearing down the iframe.
  const propsListeners = new Set<(p: Record<string, unknown>) => void>();
  slotPropsUpdater = (next) => {
    currentProps = decodeCallbacks(next) as Record<string, unknown>;
    for (const l of propsListeners) {
      try { l(currentProps); } catch { /* ignore */ }
    }
  };

  const SlotShell = () => {
    const wrapRef = React.useRef<HTMLDivElement>(null);
    const [props, setProps] = React.useState(currentProps);
    React.useEffect(() => {
      propsListeners.add(setProps);
      return () => { propsListeners.delete(setProps); };
    }, []);
    React.useEffect(() => {
      if (!wrapRef.current) return;
      let lastHeight = -1;
      const ro = new ResizeObserver((entries) => {
        for (const entry of entries) {
          const h = Math.ceil(entry.contentRect.height);
          if (h !== lastHeight) {
            lastHeight = h;
            sendToHost({ type: 'slot-resize', height: h });
          }
        }
      });
      ro.observe(wrapRef.current);
      return () => ro.disconnect();
    }, []);
    return React.createElement('div', { ref: wrapRef }, React.createElement(Component, props));
  };

  const reactRoot = ReactDOM.createRoot(rootEl);
  reactRoot.render(React.createElement(SlotShell));
  sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] });
}

// Populated by bootSlot - receives `props-update` messages.
let slotPropsUpdater: ((next: Record<string, unknown>) => void) | null = null;

async function handleInit(payload: InitPayload): Promise<void> {
  if (bootDone) return;
  bootDone = true;
  mode = payload.mode;
  // Make the active locale available to plugin code (api.i18n) right away -
  // not only after the first 'locale-change' push.
  (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale;
  try {
    if (payload.mode === 'background') {
      await bootBackground(payload);
    } else {
      bootSlot(payload);
    }
  } catch (err) {
    sendToHost({ type: 'init-error', error: (err as Error).message ?? String(err) });
  }
}

// ─── Host message handler ────────────────────────────────────

function handleHostMessage(ev: MessageEvent): void {
  // First inbound message pins source + origin. Reject everything else.
  if (!parentWindow) {
    if (!ev.source || ev.source === window) return;
    parentWindow = ev.source as Window;
    parentOrigin = ev.origin || null;
  }
  if (ev.source !== parentWindow) return;
  if (parentOrigin && ev.origin !== parentOrigin) return;

  const msg = ev.data as HostToSandbox;
  if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;

  switch (msg.type) {
    case 'init':
      void handleInit(msg.payload);
      break;

    case 'api-response': {
      const pending = pendingApi.get(msg.id);
      if (!pending) return;
      pendingApi.delete(msg.id);
      if (msg.ok) pending.resolve(msg.result);
      else pending.reject(new Error(msg.error ?? 'api error'));
      break;
    }

    case 'callback-response': {
      const pending = pendingCallbacks.get(msg.id);
      if (!pending) return;
      pendingCallbacks.delete(msg.id);
      if (msg.ok) pending.resolve(msg.result);
      else pending.reject(new Error(msg.error ?? 'callback error'));
      break;
    }

    case 'hook-invoke': {
      const handler = hookHandlers[msg.hookName];
      if (!handler) {
        sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: `no handler for ${msg.hookName}` });
        return;
      }
      try {
        const result = handler(...(msg.args ?? []));
        Promise.resolve(result).then(
          (v) => sendToHost({ type: 'hook-result', id: msg.id, ok: true, result: v }),
          (e) => sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: (e as Error).message ?? String(e) }),
        );
      } catch (err) {
        sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: (err as Error).message });
      }
      break;
    }

    case 'slot-should-show': {
      // Resolved by the background instance for any slot it offers.
      const slotDef = pluginExports?.slots?.[msg.slot];
      let show = true;
      try {
        if (slotDef && typeof slotDef.shouldShow === 'function') {
          show = !!slotDef.shouldShow(msg.context);
        }
      } catch {
        show = false;
      }
      sendToHost({ type: 'slot-should-show-result', id: msg.id, show });
      break;
    }

    case 'locale-change':
      (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
      break;

    case 'theme-change':
      applyHostTheme(msg.theme);
      break;

    case 'props-update':
      slotPropsUpdater?.(msg.props ?? {});
      break;
  }
}

// ─── React entry ─────────────────────────────────────────────

export function SandboxRuntime(): React.JSX.Element {
  useEffect(() => {
    window.addEventListener('message', handleHostMessage);
    // Initial ping. We don't know parent origin yet, so '*' is required.
    // Guard at module scope so React strict mode's double-invoke doesn't
    // re-post (and so a re-post can't race with the parent's init reply).
    if (!readyPosted && window.parent && window.parent !== window) {
      readyPosted = true;
      window.parent.postMessage({ type: 'sandbox-ready' } satisfies SandboxToHost, '*');
    }
    return () => {
      window.removeEventListener('message', handleHostMessage);
    };
  }, []);
  return <div id="plugin-sandbox-root" />;
}

// Suppress unused-variable warning when `mode` is only read for debugging.
void mode;
void slotName;
