/**
 * Plugin bootstrap loader (ticket #43, F1, carnet §1.3/§6).
 *
 * At UI bootstrap, the app calls {@link loadPlugins} once with the catalog
 * from `PluginGateway.listRuntimeContributions()` (already filtered by the
 * backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set
 * used to build the public service facade. For each entry it dynamically imports
 * the bundle URL, validates the module shape, and calls `activate(ctx)`,
 * scoping the command/layout registries to exactly the ids declared in that
 * plugin's manifest (enforced by {@link PluginCommandRegistry}/
 * {@link PluginLayoutRegistry} themselves).
 *
 * A single plugin failing to load/activate must not break the rest of the
 * app or the other plugins — each is loaded independently and failures are
 * collected, never thrown past `loadPlugins`.
 */

import * as React from "react";
import * as ReactDom from "react-dom";
import * as ReactDomClient from "react-dom/client";
import * as ReactJsxRuntime from "react/jsx-runtime";
import * as ReactJsxDevRuntime from "react/jsx-dev-runtime";

import type { JsonValue, PluginContributionDto, PluginRuntimePlugin } from "@/domain";
import {
  PluginCommandRegistry,
  PluginLayoutRegistry,
  PluginMenuRegistry,
  PluginRuntimeRegistry,
  type Disposable,
  type LoadedPlugin,
  type PluginGatewaySet,
} from "./registry";
import { createPluginServices, type PluginServices } from "./services";

export type { PluginGatewaySet } from "./registry";

export interface IdeaPluginContext {
  pluginId: string;
  pluginDisplayName: string;
  version: string;
  logger: PluginLogger;
  subscriptions: Disposable[];
  commands: PluginCommandContext;
  layouts: PluginLayoutRegistry;
  menu: PluginMenuRegistry;
  storage: PluginStorage;
  services?: PluginServices;
}

export interface PluginStorage {
  get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined>;
  set(key: string, value: JsonValue): Promise<void>;
  delete(key: string): Promise<void>;
}

export interface PluginActivation {
  dispose?: () => void | Promise<void>;
}

export interface PluginLogger {
  debug(message: string, ...args: unknown[]): void;
  info(message: string, ...args: unknown[]): void;
  warn(message: string, ...args: unknown[]): void;
  error(message: string, ...args: unknown[]): void;
}

export interface PluginCommandContext {
  register(commandId: string, handler: (...args: unknown[]) => void | Promise<void>): Disposable;
  registerCommand(
    commandId: string,
    handler: (...args: unknown[]) => unknown | Promise<unknown>,
  ): Disposable;
}

export interface IdeaPluginModule {
  activate(ctx: IdeaPluginContext): void | PluginActivation | Promise<void | PluginActivation>;
}

export interface PluginLoadFailure {
  pluginId: string;
  reason: string;
}

export interface PluginLoadPending {
  pluginId: string;
  displayName: string;
  reason: string;
}

export interface PluginLoadResult {
  registry: PluginRuntimeRegistry;
  failures: PluginLoadFailure[];
  pending: PluginLoadPending[];
}

export interface PluginLoadOptions {
  timeoutMs?: number;
}

const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000;
let hostReactImportMapInstalled = false;

async function withTimeout<T>(
  promise: Promise<T>,
  timeoutMs: number,
  label: string,
): Promise<T> {
  let timeout: ReturnType<typeof setTimeout> | undefined;
  try {
    return await Promise.race([
      promise,
      new Promise<never>((_, reject) => {
        timeout = setTimeout(() => {
          reject(new Error(`${label} timed out after ${timeoutMs} ms`));
        }, timeoutMs);
      }),
    ]);
  } finally {
    if (timeout) clearTimeout(timeout);
  }
}

function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
  return (
    typeof mod === "object" &&
    mod !== null &&
    "activate" in mod &&
    typeof (mod as { activate: unknown }).activate === "function"
  );
}

function resolveIdeaPluginModule(mod: unknown): IdeaPluginModule | null {
  if (isIdeaPluginModule(mod)) return mod;
  const defaultExport = objectOrEmpty(mod).default;
  return isIdeaPluginModule(defaultExport) ? defaultExport : null;
}

function createPluginLogger(entry: PluginRuntimePlugin): PluginLogger {
  const prefix = `[plugin:${safePluginId(entry)}]`;
  return {
    debug: (message, ...args) => console.debug(prefix, message, ...args),
    info: (message, ...args) => console.info(prefix, message, ...args),
    warn: (message, ...args) => console.warn(prefix, message, ...args),
    error: (message, ...args) => console.error(prefix, message, ...args),
  };
}

function createCommandContext(commands: PluginCommandRegistry): PluginCommandContext {
  return {
    register: (commandId, handler) => commands.register(commandId, handler),
    registerCommand: (commandId, handler) =>
      commands.register(commandId, async (...args) => {
        await handler(...args);
      }),
  };
}

function arrayOrEmpty<T>(value: unknown): T[] {
  return Array.isArray(value) ? (value as T[]) : [];
}

function objectOrEmpty(value: unknown): Record<string, unknown> {
  return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
}

function nonEmptyString(value: unknown): string | undefined {
  return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}

function installHostReactImportMap(): void {
  if (hostReactImportMapInstalled) {
    hostReactImportMapInstalled = true;
    return;
  }

  const globalHost = globalThis as unknown as {
    __IDEA_PLUGIN_HOST_REACT__?: typeof React;
    __IDEA_PLUGIN_HOST_REACT_DOM__?: typeof ReactDom;
    __IDEA_PLUGIN_HOST_REACT_DOM_CLIENT__?: typeof ReactDomClient;
    __IDEA_PLUGIN_HOST_REACT_JSX_RUNTIME__?: typeof ReactJsxRuntime;
    __IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__?: typeof ReactJsxDevRuntime;
  };
  globalHost.__IDEA_PLUGIN_HOST_REACT__ = React;
  globalHost.__IDEA_PLUGIN_HOST_REACT_DOM__ = ReactDom;
  globalHost.__IDEA_PLUGIN_HOST_REACT_DOM_CLIENT__ = ReactDomClient;
  globalHost.__IDEA_PLUGIN_HOST_REACT_JSX_RUNTIME__ = ReactJsxRuntime;
  globalHost.__IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__ = ReactJsxDevRuntime;
  hostReactImportMapInstalled = true;
}

function safePluginId(entry: unknown): string {
  return nonEmptyString(objectOrEmpty(entry).id) ?? "<unknown-plugin>";
}

function commandIdsFromContributes(contributes: PluginContributionDto): Set<string> {
  return new Set<string>(
    contributes.menuItems.flatMap<string>((item) => {
      const command = nonEmptyString(objectOrEmpty(item).command);
      return command ? [command] : [];
    }),
  );
}

function layoutTypesFromContributes(contributes: PluginContributionDto): Set<string> {
  return new Set<string>(
    contributes.layouts.flatMap<string>((layout) => {
      const type = nonEmptyString(objectOrEmpty(layout).type);
      return type ? [type] : [];
    }),
  );
}

function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto {
  const contributes = objectOrEmpty(entry.contributes) as Partial<PluginContributionDto>;
  return {
    menus: arrayOrEmpty(contributes?.menus),
    menuItems: arrayOrEmpty(contributes?.menuItems),
    layouts: arrayOrEmpty(contributes?.layouts),
    mcpServers: arrayOrEmpty(contributes?.mcpServers),
  };
}

function hasCapability(entry: PluginRuntimePlugin, capability: string): boolean {
  return arrayOrEmpty<string>(objectOrEmpty(entry).capabilities).includes(capability);
}

function activationScope(entry: PluginRuntimePlugin): "app" | "project" {
  return objectOrEmpty(entry).activationScope === "project" ? "project" : "app";
}

function pendingForProjectFocus(entry: PluginRuntimePlugin): PluginLoadPending {
  const entryObject = objectOrEmpty(entry);
  const pluginId = safePluginId(entry);
  return {
    pluginId,
    displayName: nonEmptyString(entryObject.displayName) ?? pluginId,
    reason: "En attente d'un projet actif.",
  };
}

async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise<void> {
  try {
    await activation?.dispose?.();
  } catch {
    /* best-effort */
  }

  for (const disposable of disposables.splice(0).reverse()) {
    try {
      disposable.dispose();
    } catch {
      /* best-effort */
    }
  }
}

async function loadOne(
  entry: PluginRuntimePlugin,
  gateways: PluginGatewaySet,
  options: Required<PluginLoadOptions>,
): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> {
  const entryObject = objectOrEmpty(entry);
  const pluginId = safePluginId(entry);
  const displayName = nonEmptyString(entryObject.displayName) ?? pluginId;
  const version = nonEmptyString(entryObject.version) ?? "";
  let activation: void | PluginActivation = undefined;
  const subscriptions: Disposable[] = [];
  try {
    // The bundle URL is a plugin-scoped, content-hashed local protocol URL
    // served by the backend (carnet §1.3) — never a disk path or arbitrary
    // remote URL, and the content hash busts the module cache after updates.
    const bundleUrl = nonEmptyString(entryObject.bundleUrl);
    if (!bundleUrl) {
      throw new Error("missing plugin bundle URL");
    }

    installHostReactImportMap();
    const mod = resolveIdeaPluginModule(
      await withTimeout(
        import(/* @vite-ignore */ bundleUrl),
        options.timeoutMs,
        "importing plugin bundle",
      ),
    );
    if (!mod) {
      return {
        failure: {
          pluginId,
          reason: `bundle does not export an "activate(ctx)" function`,
        },
      };
    }

    const contributes = normalizeContributes(entry);
    const declaredCommandIds = commandIdsFromContributes(contributes);
    const declaredLayoutTypes = layoutTypesFromContributes(contributes);
    const commands = new PluginCommandRegistry(pluginId, declaredCommandIds);
    const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes);
    const menu = new PluginMenuRegistry(pluginId);
    const storage = createPluginStorage(gateways, pluginId);

    const ctx: IdeaPluginContext = {
      pluginId,
      pluginDisplayName: displayName,
      version,
      logger: createPluginLogger(entry),
      subscriptions,
      commands: createCommandContext(commands),
      layouts,
      menu,
      storage,
    };
    if (hasCapability(entry, "tooling") || hasCapability(entry, "ui")) {
      ctx.services = createPluginServices(gateways, {
        pluginId,
        declaredLayoutTypes,
      });
    }

    activation = await withTimeout(
      Promise.resolve(mod.activate(ctx)),
      options.timeoutMs,
      "activating plugin",
    );

    const plugin: LoadedPlugin = {
      pluginId,
      displayName,
      contributes,
      commands,
      layouts,
      menu,
      dispose: async () => {
        // Best-effort, full-trust (carnet §1.3) — a broken `dispose()` or
        // subscription must not prevent removing the plugin from the registry.
        await disposeAll(subscriptions, activation);
      },
    };
    return { plugin };
  } catch (e) {
    await disposeAll(subscriptions, activation);
    return {
      failure: {
        pluginId,
        reason: e instanceof Error ? e.message : String(e),
      },
    };
  }
}

function createPluginStorage(gateways: PluginGatewaySet, pluginId: string): PluginStorage {
  return {
    async get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined> {
      const value = await gateways.pluginStorage.get({ pluginId, key });
      return value === null ? undefined : (value as T);
    },
    async set(key, value) {
      await gateways.pluginStorage.set({ pluginId, key, value });
    },
    async delete(key) {
      await gateways.pluginStorage.delete({ pluginId, key });
    },
  };
}

/**
 * Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}.
 * Called once at app bootstrap (and never again in the same session — carnet
 * §1.3: disable/uninstall only masks contributions in-session, the actual
 * unload happens on next restart via a fresh `loadPlugins` call).
 */
export async function loadPlugins(
  catalogPlugins: PluginRuntimePlugin[],
  gateways: PluginGatewaySet,
  options: PluginLoadOptions = {},
): Promise<PluginLoadResult> {
  const registry = new PluginRuntimeRegistry();
  const failures: PluginLoadFailure[] = [];
  const pending: PluginLoadPending[] = [];
  const resolvedOptions: Required<PluginLoadOptions> = {
    timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS,
  };

  const entries = Array.isArray(catalogPlugins) ? catalogPlugins : [];
  const focus = await gateways.focusedProject?.getFocusedProject?.();
  const loadableEntries = entries.filter((entry) => {
    if (activationScope(entry) !== "project" || focus) return true;
    pending.push(pendingForProjectFocus(entry));
    return false;
  });
  const results = await Promise.all(
    loadableEntries.map((entry) => loadOne(entry, gateways, resolvedOptions)),
  );
  for (const result of results) {
    if ("failure" in result) {
      failures.push(result.failure);
      console.warn(
        `[plugins] load failed plugin=${result.failure.pluginId}: ${result.failure.reason}`,
      );
    } else {
      registry.add(result.plugin);
    }
  }

  if (entries.length > 0) {
    console.info(
      `[plugins] load complete loaded=${registry.list().length} failed=${failures.length} pending=${pending.length}`,
    );
  }

  return { registry, failures, pending };
}
