import { listPluginInstalls, type PluginInstall } from 'officerdb';
import type { MountedPlugin } from '../hono';
import { rebuildHonoApp } from '../hono';
import { setPluginPermissions, type Capability } from '../capabilities/registry';
import { discoverPlugins } from './discover';
import { mountPrefix, type DiscoveredPlugin } from './manifest';
import { generatePluginsModule, rebuildFrontend } from './generate';

// Turning what is on disk plus what is in the database into a mounted application.
//
// Three states, and they are genuinely different questions:
//
//   on disk     the directory exists          — `discoverPlugins`
//   installed   a `plugin_installs` row       — the owner asked for it
//   enabled     that row says so              — and has not since turned it off
//
// Only the third mounts. A plugin a developer is writing sits in the tree unmounted; a disabled plugin
// keeps every table and row it owns and simply stops answering.

/** A plugin, with whatever the database knows about it. `install` is null when nobody has installed it. */
export type PluginState = {
  plugin: DiscoveredPlugin;
  install: PluginInstall | null;
  /** The manifest on disk moved after it was installed — normal while developing, worth being able to see. */
  outdated: boolean;
};

export type PluginsSnapshot = {
  states: PluginState[];
  /** Directories that look like plugins and could not be read. Rendered, never thrown — see `discover.ts`. */
  broken: { appName: string; error: string }[];
};

/**
 * What is on disk, joined to what is installed.
 *
 * An install row with no directory is DROPPED rather than reported: it means the code was removed from
 * the tree while the row stayed, and there is nothing to mount, describe or offer. The row is left in the
 * database on purpose — deleting it here would turn "somebody moved the checkout" into silent data loss.
 */
export async function snapshotPlugins(): Promise<PluginsSnapshot> {
  const [{ plugins, broken }, installs] = await Promise.all([discoverPlugins(), listPluginInstalls()]);
  const byName = new Map(installs.map((row) => [row.appName, row]));

  const states = plugins.map((plugin) => {
    const install = byName.get(plugin.appName) ?? null;
    return { plugin, install, outdated: !!install && install.version !== plugin.manifest.version };
  });

  return { states, broken };
}

/**
 * Load a plugin's backend router.
 *
 * `api/router.ts` must export `router`. Anything else — a default export, a factory, a bare Hono — is
 * refused by name rather than mounted wrong: a plugin whose routes silently do not exist is far harder to
 * diagnose than one that refuses to install.
 */
export async function loadPluginRouter(plugin: DiscoveredPlugin): Promise<MountedPlugin | null> {
  if (!plugin.api) return null;

  const module = (await import(plugin.api)) as { router?: unknown };
  const router = module.router;
  if (!router || typeof (router as { fetch?: unknown }).fetch !== 'function') {
    throw new Error(`${plugin.appName}: api/router.ts must export \`router\` (a Hono router)`);
  }

  return { prefix: mountPrefix(plugin), router: router as MountedPlugin['router'] };
}

/** The plugins that should be mounted right now: installed, enabled, and carrying an `api/router.ts`. */
export async function mountablePlugins(snapshot: PluginsSnapshot): Promise<MountedPlugin[]> {
  const mounted: MountedPlugin[] = [];
  for (const { plugin, install } of snapshot.states) {
    if (!install?.enabled || !plugin.api) continue;
    try {
      const entry = await loadPluginRouter(plugin);
      if (entry) mounted.push(entry);
    } catch (err) {
      // One plugin that will not load must not take the other nine down with it, and must not stop the
      // platform booting. It stays unmounted and says why.
      console.error(`[plugins] ${plugin.appName} not mounted:`, err instanceof Error ? err.message : err);
    }
  }
  return mounted;
}

/**
 * Turn a plugin's declared permissions into the entries the platform's own registry understands.
 *
 * ALWAYS `app`, because a plugin permission is always grantable: the same read/write/none per role the
 * platform's own permissions get, on the same page. The restricted levels stay the platform's to assign
 * and a plugin cannot name any of them — its manifest has no field for one, so the escalation question is
 * removed rather than answered.
 *
 * The FIRST permission gates the plugin's whole surface. Later ones are registered as grantable keys that
 * claim no path — a plugin wanting genuinely separate surfaces needs sub-path claims in the manifest, and
 * that can be added when something needs it rather than guessed at now.
 */
function pluginPermissions(state: PluginState): Capability[] {
  const { plugin } = state;
  const prefix = mountPrefix(plugin);

  return plugin.manifest.permissions.map((permission, index) => ({
    key: permission.key,
    label: permission.label,
    description: permission.description,
    kind: 'app' as const,
    // Only the first claims the routes; see above.
    api: index === 0 ? [prefix] : [],
    routes: index === 0 ? [prefix] : [],
    ...(permission.readOnlyWrites ? { readOnlyWrites: permission.readOnlyWrites } : {}),
  }));
}

/**
 * Rebuild the application from the current state of disk and database.
 *
 * This is the whole of install, uninstall, enable and disable as far as ROUTING is concerned — each of
 * those writes a row and then calls this. Hono cannot add a route to a live app and cannot remove one at
 * all, so nothing is mutated: a fresh app is built and `honoServer` is reassigned. `server.tsx` serves it
 * through a closure, which is what makes the reassignment take effect.
 */
export type RefreshResult = {
  mounted: string[];
  broken: string[];
  rejected: string[];
  /** Absent when the frontend did not need rebuilding, or when `skipFrontend` was asked for. */
  frontend?: { ok: boolean; ms: number; error?: string };
};

export async function refreshPluginMounts(options: { skipFrontend?: boolean } = {}): Promise<RefreshResult> {
  const snapshot = await snapshotPlugins();

  // Permissions BEFORE routes. The permission gate runs ahead of every router, so a route mounted without
  // its permission registered is a route the gate cannot resolve — and an unresolvable path is denied,
  // which would make a freshly installed plugin 403 until something else happened to refresh.
  const enabled = snapshot.states.filter((s) => s.install?.enabled);
  const { rejected } = setPluginPermissions(enabled.flatMap(pluginPermissions));
  if (rejected.length) {
    console.error(`[plugins] permission keys refused (already taken by the platform): ${rejected.join(', ')}`);
  }

  const mounted = await mountablePlugins(snapshot);
  rebuildHonoApp(mounted);

  // The frontend half. `Plugins.gen.tsx` is what the bundler reads, so it is written first and the SPA is
  // rebuilt from it — ~1s for a 25MB bundle, which is why an install can do this rather than ask for a
  // restart. Skipped at boot, where the build already happened and repeating it would delay `serve()`.
  let frontend: RefreshResult['frontend'];
  if (!options.skipFrontend) {
    generatePluginsModule(snapshot.states);
    const built = await rebuildFrontend();
    frontend = { ok: built.ok, ms: built.ms, ...(built.error ? { error: built.error } : {}) };
    if (!built.ok) console.error('[plugins] frontend rebuild failed:', built.error);
  }

  return {
    mounted: mounted.map((m) => m.prefix),
    broken: snapshot.broken.map((b) => b.appName),
    rejected,
    ...(frontend ? { frontend } : {}),
  };
}

/**
 * Dock tiles for installed, enabled plugins, in the shape the shell already renders
 * (`dockItemsFromPlugins`).
 *
 * Presentation comes from the manifest and the route from `mountPrefix`, so there is one source for both
 * and nothing to keep in step. `capability` is the plugin's first permission when it has one, which is
 * what lets the self endpoint filter the tile out for an account that cannot reach the screen —
 * a member must not be handed the manifest of a feature they may not use, even to hide it.
 *
 * Built here rather than baked into the generated frontend module on purpose: WHO sees a tile is a
 * runtime question, because a grant takes effect on the next request rather than the next build.
 */
export async function pluginDockManifests(): Promise<
  Array<{
    sidecarId: string;
    capability: string | null;
    name: string;
    icon?: string;
    color: string;
    rootRoute: string;
    routes: string[];
  }>
> {
  const { states } = await snapshotPlugins();
  return states
    .filter((s) => s.install?.enabled && s.plugin.web)
    .map(({ plugin }) => {
      const prefix = mountPrefix(plugin);
      return {
        sidecarId: plugin.appName,
        capability: plugin.manifest.permissions[0]?.key ?? null,
        name: plugin.manifest.label,
        icon: plugin.manifest.icon,
        color: plugin.manifest.color,
        rootRoute: prefix,
        routes: [prefix],
      };
    });
}
