// What a plugin declares about itself, and what the tree declares for it.
//
// ── The manifest holds only what a directory listing cannot say ──
//
// Everything structural is convention, and presence is the declaration: `sidecar/index.ts` means there is
// a sidecar, `api/router.ts` means there are routes, `db/schema.ts` means there are tables, `web/` means
// there is a frontend. The manifest carries the residue — an identity fact, or something a human chose.
//
// That is why there is no `sidecar`, `schema` or `frontend` field here, and no dock or title field either:
// the tile is `{ label, icon, color, to: mountPrefix() }` and the title is `label`, all of which are
// already below. Writing them twice could only ever drift.
//
// The reasoning behind every decision here is recorded in plugins/offscale/PLUGIN.md — the worked
// example, kept next to the plugin it produced rather than in the platform's docs.

/**
 * A permission the plugin adds to the platform's permission system.
 *
 * Called `permissions`, and that word is used throughout the plugin system deliberately. The other one
 * already means three different things here — the permission registry, the file-based item store under
 * `$OFFICER_ROOT/capabilities`, and the routing keys a sidecar registers with — and a fourth meaning
 * would be one too many. Nothing in this system uses it.
 */
export type PluginPermission = {
  /** Stable identifier, stored as the grant's subject. Renaming one is a data change. */
  key: string;
  label: string;
  description: string;
  // There is deliberately NO `ownerOnly`, and no kind of any sort.
  //
  // Every plugin permission is grantable, per role, at read or write — the same configuration the
  // platform's own permissions get, on the same page, with no special cases to learn. A plugin that
  // wanted to be owner-only would be a plugin the owner cannot delegate, and "which members may reach
  // this" is a decision belonging to whoever runs the server rather than whoever wrote the code.
  //
  // Finer visibility — whose rows a member sees, what a read means for this plugin's data — is the
  // PLUGIN's business and lives in its own queries. The platform's answer is uniform: read, write, or
  // nothing. See plugins/offscale/PLUGIN.md.
  /**
   * Requests that look like writes and are not — `POST /ssh-test` probes, `POST /policy/assist` proposes
   * a document and never saves one. Without declaring them, a read-level account meets what reads as a
   * broken feature where a withheld permission should be.
   */
  readOnlyWrites?: string[];
};

/** The package managers the platform knows how to drive. Mirrors `PM` in machine-setup's lib/base.sh. */
export type PackageManager = 'apt' | 'pacman' | 'dnf' | 'brew';

/**
 * A host binary the plugin shells out to, and how to get it.
 *
 * ── Why a BINARY plus per-manager package names, and not a package name ──
 *
 * Offscale needed nothing. Music needs `ffmpeg` and `ffprobe`, and without them its indexer does not
 * fail — it writes a complete library where every track is a bare filename with no cover, and reports
 * success. A refusal wearing the costume of a normal result, which is the failure shape this codebase
 * keeps finding. So a plugin has to be able to say what it needs, and the platform has to check.
 *
 * The shape is taken from `scripts/setup-old/setup.sh`, which had already solved this:
 *
 *     if has <binary>; then skip; else
 *       case $PM in apt) …; pacman) …; brew) skip "built-in" ;; esac
 *     fi
 *
 * Probing the BINARY rather than the package is what makes that `brew) built-in` case free: if it is on
 * PATH we never look at `packages` at all, so a manager needing no package simply has no entry.
 *
 * And the names are per manager rather than canonical-with-overrides, deliberately. `packages.sh` says
 * why, having tried the alternative: `build-essential`/`base-devel`, `fd`/`fd-find`, and some things are
 * not a package elsewhere at all. A translation table hides both behind indirection; a per-manager map
 * says what each system actually gets.
 */
export type OsDependency = {
  /** Looked up on PATH. The name the plugin's code actually spawns, not a package. */
  binary: string;
  /** Shown to the owner before they install. What breaks without it, in their words not a package's. */
  reason: string;
  /**
   * Package to install per manager. A manager left out means "cannot be installed automatically here" —
   * which for a binary that is present anyway is correct and silent, and otherwise becomes an instruction
   * to the owner rather than a guess.
   */
  packages: Partial<Record<PackageManager, string>>;
};

export type PluginManifest = {
  /**
   * Who published it. The ONLY input to `mountPrefix`, so first-party and third-party can never become two
   * code paths. Constant today; the seam third parties hang off later.
   */
  publisher: string;
  /** The plugin's own semver. Updates compare against this. */
  version: string;
  /** Which platform versions this build is good for. Refused at install when it does not match. */
  platform: string;

  label: string;
  summary: string;
  /** A lucide icon name, resolved at render. */
  icon: string;
  /** Tile colour. */
  color: string;

  permissions: PluginPermission[];

  /**
   * Host binaries this plugin needs. Absent means it is self-sufficient, which offscale and example are.
   *
   * Checked before anything else happens on install: present ones are never named on a command line, and
   * a missing one that cannot be installed here refuses the install rather than proceeding to a plugin
   * that runs and quietly produces nothing.
   */
  osDependencies?: OsDependency[];
};

/** What the platform knows about a plugin on disk: its manifest, plus everything the tree said. */
export type DiscoveredPlugin = {
  /**
   * THE id — route segment, table prefix, sidecar suffix, install key.
   *
   * Taken from the DIRECTORY NAME rather than declared, so the id cannot disagree with where the code
   * sits. The cost is that renaming a directory re-identifies the plugin; the benefit is that the two can
   * never drift, and a wrong table prefix is a much quieter failure than a missing directory.
   */
  appName: string;
  /** Absolute path to the plugin's directory. */
  dir: string;
  manifest: PluginManifest;

  /** `api/router.ts` — a backend router, mounted at `mountPrefix`. */
  api: string | null;
  /** `db/schema.ts` — tables, pushed on install. Every name must be prefixed `<appName>_`. */
  schema: string | null;
  /** `sidecar/index.{ts,mjs}` — a process for PM2. */
  sidecar: { script: string; runtime: 'bun' | 'node' } | null;
  /**
   * A frontend, as PANELS AND A LAYOUT — never a free-form component.
   *
   * The rule: every plugin route renders a Workspace with at least one panel. It is enforced by shape
   * rather than by review — the plugin does not get to render the screen, it contributes panels and says
   * how they are arranged, and the shell renders `WorkspaceView` around them. A plugin that wanted to draw
   * something else has nowhere to put it.
   *
   * Both files are required when `web/` exists at all. Missing either is a broken plugin, not a plugin
   * with a partial frontend.
   */
  web: { panels: string; layout: string } | null;
};

/**
 * Where a plugin's routes live, on both the API and the frontend.
 *
 * `publisher` is the only input, deliberately. First-party plugins sit at the root because Officer Dev
 * owns that namespace anyway and provenance is then legible at a glance in a log; third-party plugins sit
 * under `/p/<publisher>/`, which is what makes it impossible for any plugin to shadow a core route — and
 * therefore what lets the platform keep adding core routes forever without breaking an install.
 *
 * NOTHING else in the codebase may branch on provenance. If that difference leaks past this one function
 * — a special case in the router, a bypassed check, a different install branch — first-party and
 * third-party become two systems, and only one of them gets tested.
 */
export const FIRST_PARTY_PUBLISHER = 'officerdev';

export function mountPrefix(plugin: { appName: string; manifest: { publisher: string } }): string {
  const { appName } = plugin;
  return plugin.manifest.publisher === FIRST_PARTY_PUBLISHER
    ? `/${appName}`
    : `/p/${plugin.manifest.publisher}/${appName}`;
}

/** An app name has to be a URL segment, a SQL identifier prefix and a directory name at once. */
const APP_NAME_RE = /^[a-z][a-z0-9-]{0,38}$/;

/** Every package manager the installer can drive. Anything else in a manifest is refused by name. */
export const PACKAGE_MANAGERS: PackageManager[] = ['apt', 'pacman', 'dnf', 'brew'];

/**
 * What may appear in an `osDependencies` entry — and this is the strictest rule in the file, on purpose.
 *
 * These two strings are the only values in a manifest that reach a root command line. They are passed to
 * `Bun.spawn` as an argv array and never through a shell, so a metacharacter could not be interpreted
 * even if one got here — but "could not be interpreted by the shell we happen to use today" is not a
 * property to rest a root install on. So the characters are not there to be interpreted.
 *
 * Deliberately narrower than what package managers actually accept. No `+`, no `~`, no `:` — so `apt`'s
 * `pkg:arch` qualifier and any version pin are refused. A plugin that needs one wants a conversation,
 * not a regex that already allows it.
 */
const BINARY_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
const PACKAGE_RE = /^[a-z0-9][a-z0-9.+-]{0,63}$/;

/** Reasons an `osDependencies` list is unusable. Empty when it is fine. */
function osDependencyProblems(deps: unknown): string[] {
  if (deps === undefined) return [];
  if (!Array.isArray(deps)) return ['osDependencies must be an array when present'];

  const problems: string[] = [];
  for (const [i, dep] of deps.entries()) {
    const at = `osDependencies[${i}]`;
    if (!dep || typeof dep !== 'object') {
      problems.push(`${at} must be an object`);
      continue;
    }
    const { binary, reason, packages } = dep as Partial<OsDependency>;

    if (typeof binary !== 'string' || !BINARY_RE.test(binary)) {
      problems.push(`${at}.binary must be a plain command name — letters, digits, dot, dash, underscore`);
    }
    // Required rather than optional: it is what the owner reads when deciding whether to let this
    // machine install something as root, and "ffprobe" alone does not answer that question.
    if (typeof reason !== 'string' || !reason.trim()) {
      problems.push(`${at}.reason is required — say what stops working without it`);
    }
    if (!packages || typeof packages !== 'object') {
      problems.push(`${at}.packages must be an object keyed by package manager`);
      continue;
    }
    for (const [manager, name] of Object.entries(packages)) {
      if (!PACKAGE_MANAGERS.includes(manager as PackageManager)) {
        problems.push(
          `${at}.packages has unknown package manager "${manager}" — one of ${PACKAGE_MANAGERS.join(', ')}`,
        );
        continue;
      }
      if (typeof name !== 'string' || !PACKAGE_RE.test(name)) {
        problems.push(`${at}.packages.${manager} must be a plain package name — lowercase, digits, dot, plus, dash`);
      }
    }
  }
  return problems;
}

/** A publisher shares the app name's constraints — it is a path segment too. */
const PUBLISHER_RE = /^[a-z][a-z0-9-]{0,38}$/;

/**
 * Validate a manifest read off disk. Returns the reasons it is unusable, empty when it is fine.
 *
 * Returns every problem rather than the first, because an install that fails one field at a time is an
 * install someone retries four times.
 */
export function manifestProblems(appName: string, manifest: Partial<PluginManifest> | null): string[] {
  const problems: string[] = [];
  if (!manifest) return ['no manifest, or it did not export `manifest`'];

  if (!APP_NAME_RE.test(appName)) {
    problems.push(`directory name "${appName}" must be lowercase letters, digits and dashes, starting with a letter`);
  }
  if (typeof manifest.publisher !== 'string' || !PUBLISHER_RE.test(manifest.publisher)) {
    problems.push('publisher must be lowercase letters, digits and dashes');
  }
  for (const field of ['version', 'platform', 'label', 'summary', 'icon', 'color'] as const) {
    if (typeof manifest[field] !== 'string' || !manifest[field]) problems.push(`${field} is required`);
  }
  if (!Array.isArray(manifest.permissions)) {
    problems.push('permissions must be an array (use [] when the plugin gates nothing)');
  } else {
    for (const [i, permission] of manifest.permissions.entries()) {
      if (!permission || typeof permission.key !== 'string' || !permission.key) {
        problems.push(`permissions[${i}].key is required`);
        continue;
      }
      // The key shares the platform's permission namespace, so a plugin colliding with a built-in one
      // would silently widen or narrow it. Prefixing is not enforced here — the installer checks against
      // the live registry, which is the only thing that knows what is taken.
      if (typeof permission.label !== 'string' || !permission.label) {
        problems.push(`permissions[${i}].label is required`);
      }
    }
  }
  problems.push(...osDependencyProblems(manifest.osDependencies));
  return problems;
}
