import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { PLATFORM_DIR } from '../data-path';
import type { DiscoveredPlugin } from './manifest';

// Giving a plugin's sidecar an entry in the PM2 ecosystem file, and taking it away again.
//
// ── The hole this closes ──
//
// `app-store/pm2.ts` has carried this since 2026-08-13:
//
//   "[open] As of 2026-08-13 it contains the SIX core processes and nothing else, so
//    `pm2 start ecosystem.config.cjs --only officer-jellyfin` finds no such app and does nothing.
//    Installing a plugin has to append its entry here before starting it — that is the plugin
//    system's job and it is not built."
//
// This is that job. It is why nothing in the app-store catalogue installs end to end today: the
// container comes up, the connection is written, the icon publishes, and the sidecar never starts.
//
// ── Why the file is read back rather than regenerated ──
//
// The CORE entries are written by `officer-setup` from a shell array (`services.sh`), so the platform
// cannot regenerate the whole file — it does not know what the core list is, and duplicating it here
// would be a second copy to drift. Instead the file is `require`d (it is CommonJS, deliberately, because
// package.json says `"type": "module"` and PM2 `require`s the config), its `apps` array is edited, and it
// is written back. Whatever officer-setup put there survives untouched.
//
// The header above `module.exports` is preserved verbatim — officer-setup's is the better one, and losing
// it to a plugin install would be a poor trade.

const ECOSYSTEM_PATH = join(PLATFORM_DIR, 'ecosystem.config.cjs');

/** One PM2 app entry. Matches what `services.sh` emits for the core processes. */
type Pm2App = { name: string; script: string; args?: string; cwd: string; watch: boolean };

/**
 * The header to write when there is no file to take one from.
 *
 * Normally the EXISTING header is preserved verbatim — officer-setup's explains why `cwd` is pinned on
 * every app and what happens when it is wrong, and that is worth more than anything restated here. This
 * is only the fallback for a file that does not exist yet.
 */
const FALLBACK_HEADER = `// Generated. Not in git, and not meant to be — it describes THIS install.
// Core processes come from officer-setup (scripts/setup/officer-setup/lib/services.sh); plugin processes
// are added and removed by the plugin installer (servers/plugins/ecosystem.ts).
`;

/** The PM2 process name for a plugin's sidecar. One rule, so nothing has to look it up. */
export const pluginProcessName = (appName: string): string => `officer-${appName}`;

/** Read the current apps array. An unreadable or missing file is an empty one — the caller decides. */
export function readEcosystemApps(): Pm2App[] {
  if (!existsSync(ECOSYSTEM_PATH)) return [];
  try {
    // A plain parse rather than `require`: the file is generated and its shape is known, and requiring it
    // would cache the module so a second read in the same process returned a stale array.
    const source = readFileSync(ECOSYSTEM_PATH, 'utf-8');
    const start = source.indexOf('[');
    const end = source.lastIndexOf(']');
    if (start < 0 || end < 0) return [];
    // The generated file is JS object literals, not JSON — keys are bare and strings are single-quoted.
    // Evaluating it is safe in the way that matters: it is a file this process wrote, sitting inside the
    // install root, and anything able to edit it can already edit the server's own source.
    const apps = new Function(`return ${source.slice(start, end + 1)}`)() as Pm2App[];
    return Array.isArray(apps) ? apps : [];
  } catch {
    return [];
  }
}

/**
 * Rewrite the apps array, keeping everything above `module.exports` exactly as it was.
 *
 * The header is preserved rather than regenerated because officer-setup's is the better one — it explains
 * that Bun auto-loads .env from the working directory, and that data-path derives the install root from
 * its PARENT, so a wrong `cwd` silently relocates the whole install rather than failing. Losing that to a
 * plugin install would be a poor trade.
 */
function writeEcosystemApps(apps: Pm2App[]): void {
  const existing = existsSync(ECOSYSTEM_PATH) ? readFileSync(ECOSYSTEM_PATH, 'utf-8') : '';
  const marker = existing.indexOf('module.exports');
  const header = marker > 0 ? existing.slice(0, marker) : FALLBACK_HEADER;

  const lines = apps.map(
    (app) =>
      `    { name: '${app.name}', script: '${app.script}'` +
      (app.args ? `, args: '${app.args}'` : '') +
      `, cwd: '${app.cwd}', watch: ${app.watch} },`,
  );
  writeFileSync(ECOSYSTEM_PATH, `${header}module.exports = {\n  apps: [\n${lines.join('\n')}\n  ],\n};\n`);
}

/**
 * Ensure the plugin's sidecar has an entry, replacing one that is already there.
 *
 * Idempotent, because re-installing is how a plugin is upgraded and the script path may have moved.
 * Returns false when the plugin has no sidecar at all, which is not a failure — most plugins won't.
 */
export function addPluginToEcosystem(plugin: DiscoveredPlugin): boolean {
  if (!plugin.sidecar) return false;

  const name = pluginProcessName(plugin.appName);
  // Relative to the platform, because `cwd` is pinned to it and an absolute path would break the moment
  // the install root moved — which is exactly what happened to this machine two days ago.
  const script = plugin.sidecar.script.startsWith(PLATFORM_DIR)
    ? plugin.sidecar.script.slice(PLATFORM_DIR.length + 1)
    : plugin.sidecar.script;

  const entry: Pm2App =
    plugin.sidecar.runtime === 'node'
      ? { name, script: 'node', args: script, cwd: PLATFORM_DIR, watch: false }
      : { name, script: 'bun', args: `run ${script}`, cwd: PLATFORM_DIR, watch: false };

  const apps = readEcosystemApps().filter((a) => a.name !== name);
  apps.push(entry);
  writeEcosystemApps(apps);
  return true;
}

/** Drop the plugin's entry. Safe to call when it was never there. */
export function removePluginFromEcosystem(appName: string): void {
  const name = pluginProcessName(appName);
  const apps = readEcosystemApps();
  const remaining = apps.filter((a) => a.name !== name);
  if (remaining.length !== apps.length) writeEcosystemApps(remaining);
}
