import { createRouter } from '../../create-router';
import * as errors from '../../custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { mountPrefix } from '../../plugins/manifest';
import { snapshotPlugins } from '../../plugins/mount';
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
import {
  installPlugin,
  pluginProcessStatus,
  setPluginRunning,
  uninstallPlugin,
  type PluginActionResult,
} from '../../plugins/install';

// /api/plugins — what is on this machine, what is installed, and the four verbs that change it.
//
// Owner only, in its own right. Installing a plugin mounts routes and (later) starts a process, which is
// an administrative act however many members share the server. The capability layer covers it too; this
// is the belt to that braces, the same shape `/api/app-store` uses.
//
// ── This is not the app store ──
//
// The app store installs SIDECARS from a compiled-in catalogue, provisioning containers and asking the
// user questions. This installs PLUGINS from the tree, and asks nothing: put the code there, push the
// schema, mount the routes. The two coexist until the app store is rebuilt on this.

export const pluginsRouter = createRouter();

pluginsRouter.use(async (ctx, next) => {
  if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Plugins are owner-only');
  return next();
});

/**
 * GET /api/plugins — every plugin in the tree, with what the database knows about each.
 *
 * Reports `broken` alongside rather than failing: a directory with an unreadable manifest is something to
 * show the owner, and refusing the whole list because one plugin is malformed would hide the nine that
 * are fine.
 */
pluginsRouter.get('/', async (ctx) => {
  const { states, broken } = await snapshotPlugins();
  // Asked per plugin rather than once, because `pm2 jlist` is a fork and most plugins have no sidecar to
  // ask about. A plugin that is installed and enabled but whose process is not online is the state worth
  // rendering differently — it is the difference between "off" and "broken".
  const statuses = await Promise.all(states.map(({ plugin }) => pluginProcessStatus(plugin)));
  // Synchronous and cheap — `Bun.which` per declared binary, and most plugins declare none. The one
  // genuinely slow call in here is the `sudo -n` probe, which only runs when something is missing.
  const dependencyReports = states.map(({ plugin }) => {
    const report = reportDependencies(plugin);
    return {
      manager: report.manager,
      items: report.dependencies,
      satisfied: report.missing.length === 0,
      canInstall: report.canInstall,
      blockedReason: report.blockedReason,
      packagesToInstall: report.packagesToInstall,
      manualHint: manualInstallHint(report.manager, report.packagesToInstall),
    };
  });
  return ctx.json({
    plugins: states.map(({ plugin, install, outdated }, i) => ({
      appName: plugin.appName,
      prefix: mountPrefix(plugin),
      label: plugin.manifest.label,
      summary: plugin.manifest.summary,
      icon: plugin.manifest.icon,
      color: plugin.manifest.color,
      publisher: plugin.manifest.publisher,
      version: plugin.manifest.version,
      platform: plugin.manifest.platform,
      permissions: plugin.manifest.permissions,
      // What the tree declared. The UI shows these so "installed but does nothing" is legible.
      has: {
        api: !!plugin.api,
        schema: !!plugin.schema,
        sidecar: !!plugin.sidecar,
        web: !!plugin.web,
      },
      installed: !!install,
      enabled: install?.enabled ?? false,
      installedVersion: install?.version ?? null,
      outdated,
      processStatus: statuses[i] ?? null,
      // Probed live, against this machine's PATH, on every read.
      //
      // Deliberately here rather than only inside the installer: the owner decides whether to let the
      // server run `apt-get install` as root, and that decision needs the answer BEFORE the button, not
      // in the log afterwards. It also lets them install the packages by hand first and watch this flip
      // to present, which is the escape hatch for a machine with no passwordless sudo.
      dependencies: dependencyReports[i]!,
    })),
    broken,
  });
});

/**
 * The four verbs. Each returns the ordered list of what actually happened, rather than a bare `ok` —
 * "installed" and "installed but the sidecar would not start" are different outcomes and the second is
 * the one worth reading. See `plugins/install.ts` for why the order inside each is what it is.
 */
pluginsRouter.post('/:appName/install', async (ctx) => {
  const result = await installPlugin(ctx.req.param('appName'));
  return ctx.json(result, result.ok ? 200 : 400);
});

pluginsRouter.post('/:appName/uninstall', async (ctx) => {
  const result = await uninstallPlugin(ctx.req.param('appName'));
  return ctx.json(result, result.ok ? 200 : 400);
});

pluginsRouter.post('/:appName/enable', async (ctx) => {
  const result = await setPluginRunning(ctx.req.param('appName'), true);
  return ctx.json(result, result.ok ? 200 : 400);
});

pluginsRouter.post('/:appName/disable', async (ctx) => {
  const result = await setPluginRunning(ctx.req.param('appName'), false);
  return ctx.json(result, result.ok ? 200 : 400);
});

// ── The same four verbs, streamed ──
//
// An install writes an ecosystem entry, starts a process and rebuilds the router. Collecting all of that
// and answering once means a spinner that stops, with no way to tell "started the sidecar" from "could
// not". Streaming each step as it completes turns the same work into something you can watch, and — more
// usefully — leaves the log on screen naming the step that failed.
//
// POST rather than GET, so `EventSource` cannot be used: it sends no `Authorization` header, and these
// routes are owner-only. The client reads the body and parses frames itself, which is exactly what
// `useCompanionLogStream` already does for the headscale container logs.

const VERBS = {
  install: (appName: string, onStep: (s: string) => Promise<void>) => installPlugin(appName, onStep),
  uninstall: (appName: string, onStep: (s: string) => Promise<void>) => uninstallPlugin(appName, onStep),
  enable: (appName: string, onStep: (s: string) => Promise<void>) => setPluginRunning(appName, true, onStep),
  disable: (appName: string, onStep: (s: string) => Promise<void>) => setPluginRunning(appName, false, onStep),
} as const;

type Verb = keyof typeof VERBS;
const isVerb = (v: string): v is Verb => v in VERBS;

pluginsRouter.post('/:appName/:verb/stream', async (ctx) => {
  const appName = ctx.req.param('appName');
  const verb = ctx.req.param('verb');
  if (!isVerb(verb)) throw errors.NOT_FOUND(`Unknown action: ${verb}`);

  const encoder = new TextEncoder();
  const frame = (event: string, data: unknown) => encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);

  const stream = new ReadableStream({
    async start(controller) {
      const send = (event: string, data: unknown) => {
        // The client may have navigated away mid-install. The work continues — it is the server's job
        // now — but writing to a closed stream would throw and abort it halfway.
        try {
          controller.enqueue(frame(event, data));
        } catch {
          /* client gone */
        }
      };

      let result: PluginActionResult;
      try {
        result = await VERBS[verb](appName, async (step) => send('step', { step }));
      } catch (err) {
        result = { ok: false, appName, steps: [], error: err instanceof Error ? err.message : String(err) };
      }
      send('done', result);
      try {
        controller.close();
      } catch {
        /* already closed */
      }
    },
  });

  return new Response(stream, {
    headers: {
      'content-type': 'text/event-stream',
      'cache-control': 'no-cache',
      // Through nginx as well as our own proxy chain: without it a buffering hop holds every frame until
      // the response ends, which is precisely the behaviour this endpoint exists to avoid.
      'x-accel-buffering': 'no',
    },
  });
});
