// Host-side wrapper around a single sandbox iframe (one per plugin/background,
// plus one per slot mount). Owns the iframe lifecycle and the postMessage RPC.
//
// Origin model: the iframe is `sandbox="allow-scripts"` with no
// `allow-same-origin`, so its origin is opaque ("null"). We can't pin on
// `event.origin`; instead, every inbound message is gated on
// `event.source === iframe.contentWindow`. The iframe's runtime pins the
// parent on the first inbound message.

import type { InstalledPlugin, SlotName } from '../plugin-types';
import { dispatchApiCall } from './host-api';
import { SANDBOX_PATH } from './protocol';
import type {
  SandboxToHost, HostToSandbox, InitMsg, InitPayload,
} from './protocol';

// ─── Callback marshalling ────────────────────────────────────

/**
 * Walks an object graph and replaces any function values with
 * `{ __pluginCallback: id }` markers, registering each function in `table` so
 * the iframe can call back later via 'callback-invoke'. Non-plain values
 * (functions on prototype, DOM nodes, etc.) are dropped.
 */
function encodeCallbacks(
  value: unknown,
  table: Map<string, (...args: unknown[]) => unknown>,
  depth = 0,
): unknown {
  if (depth > 6) return null; // hard cap to avoid pathological graphs
  if (value === null || value === undefined) return value;
  const t = typeof value;
  if (t === 'function') {
    const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
    table.set(id, value as (...args: unknown[]) => unknown);
    return { __pluginCallback: id };
  }
  if (t !== 'object') return value;
  if (Array.isArray(value)) {
    return value.map((v) => encodeCallbacks(v, table, depth + 1));
  }
  // Plain object — copy own enumerable keys.
  const out: Record<string, unknown> = {};
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
    out[k] = encodeCallbacks(v, table, depth + 1);
  }
  return out;
}

// ─── Public option types ─────────────────────────────────────

export interface BackgroundOptions {
  plugin: InstalledPlugin;
  code: string;
  locale: string;
  /** Where the hidden iframe should attach. Defaults to document.body. */
  hostContainer?: HTMLElement;
}

export interface SlotOptions {
  plugin: InstalledPlugin;
  slot: SlotName;
  code: string;
  locale: string;
  extraProps: Record<string, unknown>;
  /** Container element the visible slot iframe is mounted into. */
  hostContainer: HTMLElement;
  /** Called whenever the sandbox reports a new content height. */
  onResize: (height: number) => void;
}

export interface InitDoneInfo {
  hooks: string[];
  slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
  shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>;
}

// ─── Sandbox instance ────────────────────────────────────────

export class SandboxInstance {
  readonly iframe: HTMLIFrameElement;
  readonly pluginId: string;
  readonly mode: 'background' | 'slot';

  readyPromise: Promise<void>;
  initPromise: Promise<InitDoneInfo>;

  private resolveReady!: () => void;
  private resolveInit!: (info: InitDoneInfo) => void;
  private rejectInit!: (err: Error) => void;
  private listener: (ev: MessageEvent) => void;
  private destroyed = false;

  private pendingHookInvokes = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
  private pendingShouldShow = new Map<string, (show: boolean) => void>();
  /** Host-side function references the sandbox can call back via 'callback-invoke'. */
  private callbackTable = new Map<string, (...args: unknown[]) => unknown>();

  constructor(
    private plugin: InstalledPlugin,
    initPayload: InitPayload,
    hostContainer: HTMLElement,
    private slotResizeCb: ((height: number) => void) | null,
  ) {
    this.pluginId = plugin.id;
    this.mode = initPayload.mode;

    // Slot iframes get `extraProps`; encode any function values now so the
    // structured-clone send doesn't drop them.
    if (initPayload.mode === 'slot') {
      initPayload.extraProps = encodeCallbacks(initPayload.extraProps, this.callbackTable) as Record<string, unknown>;
    }

    this.readyPromise = new Promise<void>((res) => { this.resolveReady = res; });
    this.initPromise = new Promise<InitDoneInfo>((res, rej) => {
      this.resolveInit = res;
      this.rejectInit = rej;
    });

    this.iframe = document.createElement('iframe');
    this.iframe.setAttribute('sandbox', 'allow-scripts');
    this.iframe.setAttribute('referrerpolicy', 'no-referrer');
    this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`;
    this.iframe.style.border = 'none';
    this.iframe.style.display = 'block';
    if (initPayload.mode === 'background') {
      this.iframe.style.position = 'absolute';
      this.iframe.style.width = '1px';
      this.iframe.style.height = '1px';
      this.iframe.style.opacity = '0';
      this.iframe.style.pointerEvents = 'none';
      this.iframe.style.left = '-9999px';
      this.iframe.setAttribute('aria-hidden', 'true');
    } else {
      this.iframe.style.width = '100%';
      this.iframe.style.height = '0px';
    }
    this.iframe.src = SANDBOX_PATH;

    this.listener = (ev) => this.onMessage(ev);
    window.addEventListener('message', this.listener);
    hostContainer.appendChild(this.iframe);

    // Send init after the iframe runtime signals it's ready.
    this.readyPromise.then(() => {
      if (this.destroyed) return;
      const msg: InitMsg = { type: 'init', payload: initPayload };
      this.send(msg);
    });
  }

  // ─── Internal ───────────────────────────────────────────────

  private send(msg: HostToSandbox): void {
    // targetOrigin '*' is required because the iframe is opaque-origin. The
    // payload contains no host secrets — bundle code and manifest fields the
    // plugin already owns.
    this.iframe.contentWindow?.postMessage(msg, '*');
  }

  private onMessage(ev: MessageEvent): void {
    if (this.destroyed) return;
    if (ev.source !== this.iframe.contentWindow) return;
    const msg = ev.data as SandboxToHost;
    if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;

    switch (msg.type) {
      case 'sandbox-ready':
        this.resolveReady();
        return;

      case 'init-done':
        this.resolveInit({ hooks: msg.hooks, slots: msg.slots, shortcuts: msg.shortcuts ?? [] });
        return;

      case 'init-error':
        this.rejectInit(new Error(msg.error));
        return;

      case 'api-request': {
        const { id, method, args } = msg;
        void (async () => {
          try {
            const result = await dispatchApiCall(this.plugin, method, args ?? []);
            this.send({ type: 'api-response', id, ok: true, result });
          } catch (err) {
            this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) });
          }
        })();
        return;
      }

      case 'callback-invoke': {
        const { id, callbackId, args } = msg;
        const fn = this.callbackTable.get(callbackId);
        if (!fn) {
          this.send({ type: 'callback-response', id, ok: false, error: `unknown callback ${callbackId}` });
          return;
        }
        void (async () => {
          try {
            const result = await Promise.resolve(fn(...(args ?? [])));
            // Only send back primitives / plain objects; functions inside
            // results would round-trip but we don't support that yet.
            this.send({ type: 'callback-response', id, ok: true, result });
          } catch (err) {
            this.send({ type: 'callback-response', id, ok: false, error: (err as Error).message ?? String(err) });
          }
        })();
        return;
      }

      case 'hook-result': {
        const entry = this.pendingHookInvokes.get(msg.id);
        if (!entry) return;
        this.pendingHookInvokes.delete(msg.id);
        if (msg.ok) entry.resolve(msg.result);
        else entry.reject(new Error(msg.error ?? 'hook error'));
        return;
      }

      case 'slot-should-show-result': {
        const cb = this.pendingShouldShow.get(msg.id);
        if (!cb) return;
        this.pendingShouldShow.delete(msg.id);
        cb(msg.show);
        return;
      }

      case 'slot-resize':
        this.slotResizeCb?.(msg.height);
        return;
    }
  }

  // ─── Public ─────────────────────────────────────────────────

  /** Dispatch a hook handler inside the sandbox; resolves with its return value. */
  invokeHook(hookName: string, args: unknown[]): Promise<unknown> {
    if (this.destroyed) return Promise.reject(new Error('sandbox destroyed'));
    const id = uid();
    const p = new Promise<unknown>((resolve, reject) => {
      this.pendingHookInvokes.set(id, { resolve, reject });
    });
    this.send({ type: 'hook-invoke', id, hookName, args });
    return p;
  }

  /** Ask the background instance whether a slot should mount for this context. */
  evaluateShouldShow(slot: SlotName, context: unknown): Promise<boolean> {
    if (this.destroyed) return Promise.resolve(false);
    const id = uid();
    const p = new Promise<boolean>((resolve) => {
      this.pendingShouldShow.set(id, resolve);
    });
    this.send({ type: 'slot-should-show', id, slot, context });
    return p;
  }

  setLocale(locale: string): void {
    if (this.destroyed) return;
    this.send({ type: 'locale-change', locale });
  }

  updateProps(props: Record<string, unknown>): void {
    if (this.destroyed) return;
    // Stale references would leak if we kept growing the table without
    // bound; for now we let it grow until destroy(). A future refinement
    // could diff old vs new props and drop entries no longer referenced.
    const encoded = encodeCallbacks(props, this.callbackTable) as Record<string, unknown>;
    this.send({ type: 'props-update', props: encoded });
  }

  destroy(): void {
    if (this.destroyed) return;
    this.destroyed = true;
    window.removeEventListener('message', this.listener);
    this.iframe.remove();
    for (const { reject } of this.pendingHookInvokes.values()) {
      reject(new Error('sandbox destroyed'));
    }
    this.pendingHookInvokes.clear();
    this.pendingShouldShow.clear();
    this.callbackTable.clear();
  }
}

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

// ─── Factory helpers ─────────────────────────────────────────

export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstance {
  const payload: InitPayload = {
    mode: 'background',
    pluginId: opts.plugin.id,
    manifest: {
      id: opts.plugin.id,
      version: opts.plugin.version,
      permissions: opts.plugin.permissions,
      settings: { ...opts.plugin.settings },
      locales: opts.plugin.locales,
      httpOrigins: opts.plugin.httpOrigins,
    },
    code: opts.code,
    locale: opts.locale,
  };
  return new SandboxInstance(
    opts.plugin,
    payload,
    opts.hostContainer ?? document.body,
    null,
  );
}

export function createSlotInstance(opts: SlotOptions): SandboxInstance {
  const payload: InitPayload = {
    mode: 'slot',
    pluginId: opts.plugin.id,
    slot: opts.slot,
    code: opts.code,
    manifest: {
      id: opts.plugin.id,
      version: opts.plugin.version,
      permissions: opts.plugin.permissions,
      settings: { ...opts.plugin.settings },
      locales: opts.plugin.locales,
      httpOrigins: opts.plugin.httpOrigins,
    },
    extraProps: opts.extraProps,
    locale: opts.locale,
  };
  return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
}
