/**
 * Plugin Host — Web Worker sandbox for Electrobun plugins.
 *
 * Each plugin runs in a dedicated Web Worker with no DOM/IPC access.
 * Communication: Main <-> Worker via postMessage.
 * Permission-gated API (messages, events, notifications, palette).
 * On unload, Worker is terminated — all plugin state destroyed.
 */

import { appRpc } from './rpc.ts';

// ── Types ────────────────────────────────────────────────────────────────────

export interface PluginMeta {
  id: string;
  name: string;
  version: string;
  description: string;
  main: string;
  permissions: string[];
  /** Feature 9: Allowed network origins for fetch-like operations. */
  allowedOrigins?: string[];
  /** Feature 9: Max runtime in seconds (CPU time quota). Default 30. */
  maxRuntime?: number;
  /** Feature 9: Max memory display hint (bytes, informational only). */
  maxMemory?: number;
}

interface LoadedPlugin {
  meta: PluginMeta;
  worker: Worker;
  callbacks: Map<string, () => void>;
  eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }>;
  cleanup: () => void;
}

type PluginCommandCallback = () => void;

// ── State ────────────────────────────────────────────────────────────────────

const loadedPlugins = new Map<string, LoadedPlugin>();

// External command/event registries (set by plugin-store)
let commandRegistry: ((pluginId: string, label: string, callback: PluginCommandCallback) => void) | null = null;
let commandRemover: ((pluginId: string) => void) | null = null;
let eventBus: { on: (event: string, handler: (data: unknown) => void) => void; off: (event: string, handler: (data: unknown) => void) => void } | null = null;

/** Wire up external registries (called by plugin-store on init). */
export function setPluginRegistries(opts: {
  addCommand: (pluginId: string, label: string, cb: PluginCommandCallback) => void;
  removeCommands: (pluginId: string) => void;
  eventBus: { on: (e: string, h: (d: unknown) => void) => void; off: (e: string, h: (d: unknown) => void) => void };
}): void {
  commandRegistry = opts.addCommand;
  commandRemover = opts.removeCommands;
  eventBus = opts.eventBus;
}

// ── Worker script builder ────────────────────────────────────────────────────

function buildWorkerScript(): string {
  return `
"use strict";

const _callbacks = new Map();
let _callbackId = 0;
function _nextCbId() { return '__cb_' + (++_callbackId); }

const _pending = new Map();
let _rpcId = 0;
function _rpc(method, args) {
  return new Promise((resolve, reject) => {
    const id = '__rpc_' + (++_rpcId);
    _pending.set(id, { resolve, reject });
    self.postMessage({ type: 'rpc', id, method, args });
  });
}

self.onmessage = function(e) {
  const msg = e.data;

  if (msg.type === 'init') {
    const permissions = msg.permissions || [];
    const meta = msg.meta;
    const api = { meta: Object.freeze(meta) };

    if (permissions.includes('palette')) {
      api.palette = {
        registerCommand(label, callback) {
          if (typeof label !== 'string' || !label.trim()) throw new Error('Command label must be non-empty string');
          if (typeof callback !== 'function') throw new Error('Command callback must be a function');
          const cbId = _nextCbId();
          _callbacks.set(cbId, callback);
          self.postMessage({ type: 'palette-register', label, callbackId: cbId });
        },
      };
    }

    if (permissions.includes('notifications')) {
      api.notifications = {
        show(message) {
          self.postMessage({ type: 'notification', message: String(message) });
        },
      };
    }

    if (permissions.includes('messages')) {
      api.messages = {
        list() { return _rpc('messages.list', {}); },
      };
    }

    if (permissions.includes('network')) {
      api.network = {
        fetch(url, options) {
          // Check against allowedOrigins
          const allowedOrigins = msg.allowedOrigins || [];
          if (allowedOrigins.length > 0) {
            try {
              const parsed = new URL(url);
              const origin = parsed.origin;
              if (!allowedOrigins.some(o => origin === o || parsed.hostname.endsWith(o))) {
                return Promise.reject(new Error('Origin not in allowedOrigins: ' + origin));
              }
            } catch (e) {
              return Promise.reject(new Error('Invalid URL: ' + url));
            }
          }
          return _rpc('network.fetch', { url, options });
        },
      };
    }

    if (permissions.includes('events')) {
      api.events = {
        on(event, callback) {
          if (typeof event !== 'string' || typeof callback !== 'function') {
            throw new Error('events.on requires (string, function)');
          }
          const cbId = _nextCbId();
          _callbacks.set(cbId, callback);
          self.postMessage({ type: 'event-on', event, callbackId: cbId });
        },
        off(event) {
          self.postMessage({ type: 'event-off', event });
        },
      };
    }

    Object.freeze(api);

    try {
      const fn = (0, eval)('(function(agor) { "use strict"; ' + msg.code + '\\n})');
      fn(api);
      self.postMessage({ type: 'loaded' });
    } catch (err) {
      self.postMessage({ type: 'error', message: String(err) });
    }
  }

  if (msg.type === 'invoke-callback') {
    const cb = _callbacks.get(msg.callbackId);
    if (cb) {
      try { cb(msg.data); }
      catch (err) { self.postMessage({ type: 'callback-error', callbackId: msg.callbackId, message: String(err) }); }
    }
  }

  if (msg.type === 'rpc-result') {
    const pending = _pending.get(msg.id);
    if (pending) {
      _pending.delete(msg.id);
      if (msg.error) pending.reject(new Error(msg.error));
      else pending.resolve(msg.result);
    }
  }
};
`;
}

let workerBlobUrl: string | null = null;
function getWorkerBlobUrl(): string {
  if (!workerBlobUrl) {
    const blob = new Blob([buildWorkerScript()], { type: 'application/javascript' });
    workerBlobUrl = URL.createObjectURL(blob);
  }
  return workerBlobUrl;
}

// ── Public API ───────────────────────────────────────────────────────────────

/**
 * Load and execute a plugin in a Web Worker sandbox.
 * Reads plugin code via RPC from Bun process.
 */
export async function loadPlugin(meta: PluginMeta): Promise<void> {
  if (loadedPlugins.has(meta.id)) {
    console.warn(`Plugin '${meta.id}' is already loaded`);
    return;
  }

  // Validate permissions
  const validPerms = new Set(['palette', 'notifications', 'messages', 'events', 'network']);
  for (const p of meta.permissions) {
    if (!validPerms.has(p)) {
      throw new Error(`Plugin '${meta.id}' requests unknown permission: ${p}`);
    }
  }

  // Feature 9: Validate allowedOrigins
  const maxRuntime = (meta.maxRuntime ?? 30) * 1000; // default 30s, convert to ms

  // Read plugin code via RPC
  let code: string;
  try {
    const res = await appRpc.request['plugin.readFile']({ pluginId: meta.id, filePath: meta.main });
    if (!res.ok) throw new Error(res.error ?? 'Failed to read plugin file');
    code = res.content;
  } catch (e) {
    throw new Error(`Failed to read plugin '${meta.id}' entry '${meta.main}': ${e}`);
  }

  const worker = new Worker(getWorkerBlobUrl(), { type: 'classic' });
  const callbacks = new Map<string, () => void>();
  const eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }> = [];

  await new Promise<void>((resolve, reject) => {
    worker.onmessage = (e) => {
      const msg = e.data;

      switch (msg.type) {
        case 'loaded':
          resolve();
          break;

        case 'error':
          commandRemover?.(meta.id);
          for (const sub of eventSubscriptions) eventBus?.off(sub.event, sub.handler);
          worker.terminate();
          reject(new Error(`Plugin '${meta.id}' failed: ${msg.message}`));
          break;

        case 'palette-register': {
          const cbId = msg.callbackId as string;
          const invoke = () => worker.postMessage({ type: 'invoke-callback', callbackId: cbId });
          callbacks.set(cbId, invoke);
          commandRegistry?.(meta.id, msg.label, invoke);
          break;
        }

        case 'notification':
          console.log(`[plugin:${meta.id}] notification:`, msg.message);
          break;

        case 'event-on': {
          const cbId = msg.callbackId as string;
          const handler = (data: unknown) => {
            worker.postMessage({ type: 'invoke-callback', callbackId: cbId, data });
          };
          eventSubscriptions.push({ event: msg.event, handler });
          eventBus?.on(msg.event, handler);
          break;
        }

        case 'event-off': {
          const idx = eventSubscriptions.findIndex(s => s.event === msg.event);
          if (idx >= 0) {
            eventBus?.off(eventSubscriptions[idx].event, eventSubscriptions[idx].handler);
            eventSubscriptions.splice(idx, 1);
          }
          break;
        }

        case 'callback-error':
          console.error(`Plugin '${meta.id}' callback error:`, msg.message);
          break;
      }
    };

    worker.onerror = (err) => reject(new Error(`Plugin '${meta.id}' worker error: ${err.message}`));

    worker.postMessage({
      type: 'init',
      code,
      permissions: meta.permissions,
      allowedOrigins: meta.allowedOrigins ?? [],
      meta: { id: meta.id, name: meta.name, version: meta.version, description: meta.description },
    });
  });

  // Feature 9: maxRuntime — terminate Worker after timeout
  let runtimeTimer: ReturnType<typeof setTimeout> | null = null;
  if (maxRuntime > 0) {
    runtimeTimer = setTimeout(() => {
      console.warn(`Plugin '${meta.id}' exceeded maxRuntime (${maxRuntime}ms), terminating`);
      unloadPlugin(meta.id);
    }, maxRuntime);
  }

  const cleanup = () => {
    if (runtimeTimer) clearTimeout(runtimeTimer);
    commandRemover?.(meta.id);
    for (const sub of eventSubscriptions) eventBus?.off(sub.event, sub.handler);
    eventSubscriptions.length = 0;
    callbacks.clear();
    worker.terminate();
  };

  loadedPlugins.set(meta.id, { meta, worker, callbacks, eventSubscriptions, cleanup });
}

/** Unload a plugin. */
export function unloadPlugin(id: string): void {
  const plugin = loadedPlugins.get(id);
  if (!plugin) return;
  plugin.cleanup();
  loadedPlugins.delete(id);
}

/** Get all loaded plugin metas. */
export function getLoadedPlugins(): PluginMeta[] {
  return Array.from(loadedPlugins.values()).map(p => p.meta);
}

/** Unload all plugins. */
export function unloadAllPlugins(): void {
  for (const [id] of loadedPlugins) unloadPlugin(id);
}
