// What the app store can install, and what installing each one actually requires.
//
// This is the catalogue, not the state — `sidecar_installs` records what the owner has done, this
// records what is on offer. Everything the installer branches on lives here so the installer itself has
// no per-sidecar knowledge, the same restraint `create-proxy.ts` keeps on the proxy side.
//
// ── The entries are derived from a fact, not invented ──
//
// The fourteen below are exactly the processes in `ecosystem.config.cjs` that the light profile
// excludes. That is not a coincidence to be maintained by hand: `catalogue.test.ts` asserts it, so a
// sidecar added to the estate and forgotten here fails a test rather than being quietly uninstallable.
//
// Light itself (officer, the agent, the anthropic proxy, opencode, pty, gitea) is not in the catalogue.
// Those are the baseline — chat, terminal, file browser — and there is no meaningful "uninstall chat".

/**
 * How an install can be satisfied. A sidecar may support more than one, and the prompt is the fork:
 * "do you already have one, or shall we start one for you?"
 */
export type InstallMode =
  /** Point at an instance the user already runs, here or elsewhere. We provision nothing. */
  | 'existing'
  /** Render our compose template and start it. The connection is then known without asking. */
  | 'provisioned'
  /** Nothing to reach. Credentials or local configuration only. */
  | 'config';

/**
 * One field the installer asks for before it can finish.
 *
 * A `url` placeholder should show a REMOTE example. The instance may be on another host, behind a
 * reverse proxy, on a non-standard port, or all three — a placeholder reading `http://localhost:8096`
 * quietly teaches that it must be local and on the usual port, which is wrong often enough to matter.
 */
export type ConfigField = {
  key: string;
  label: string;
  /** `secret` is written encrypted and never read back to the client. */
  type: 'url' | 'text' | 'secret';
  required: boolean;
  placeholder?: string;
  help?: string;
};

/**
 * How a sidecar presents itself in the app: the dock tile and the routes it owns.
 *
 * Carried by the sidecar rather than hardcoded in the shell, because a sidecar that ships from its own
 * repository has to be able to say what it looks like. `ALL_DOCK_ITEMS` in Dock.tsx is the current
 * hardcoded list; the intent is that it becomes derived from installed manifests, so a feature appears
 * in the dock when it is installed and leaves when it is removed, with nothing in the shell to update.
 *
 * The icon is a NAME, not an imported component. A manifest has to survive being JSON from
 * marketplace.officer.dev, and a lucide import cannot. `resolveIcon` already maps names to glyphs for
 * exactly this reason.
 */
export type UiManifest = {
  /** Dock label. Often shorter than the catalogue `label` — "Video" for Jellyfin. */
  name: string;
  /** A lucide icon name, resolved at render. */
  icon?: string;
  /**
   * An image asset instead of a glyph, for a service with its own mark. Real PNGs are coming, and this
   * is the field they arrive in.
   *
   * `/slskd.png` works today because it sits in the platform's `public/`. A marketplace plugin cannot
   * put a file there, so one thing has to be decided before third-party icons ship: whether the bytes
   * are fetched from the marketplace (simple, but the dock loses its icons offline), served by us from
   * the sidecar's own directory (works offline, needs a route and a cache header), or inlined as a data
   * URI (no fetch, but every manifest carries it and it is unpleasant at 512px).
   *
   * Whichever wins, this stays a STRING the renderer resolves — never an import — for the same reason
   * `icon` is a name: a manifest has to survive being JSON.
   */
  image?: string;
  /** Tile colour. */
  color: string;
  /** Where the dock tile goes, and the prefix the route guard matches. */
  rootRoute: string;
  /**
   * Every frontend route this sidecar owns, `rootRoute` included.
   *
   * Separate from `rootRoute` because a feature can own more than one path — the capability registry
   * already lists `/caldav` and `/dav` together — and the guard needs all of them while the dock needs
   * exactly one.
   */
  routes: string[];
  /**
   * Additional dock tiles, for the rare sidecar that presents as more than one thing.
   *
   * CalDAV is the only case today: one sidecar, but Calendar and Contacts are separate features to
   * anyone using them, and collapsing them into one tile to keep the model tidy would make the app
   * worse. Every route here must still appear in `routes`.
   */
  extraTiles?: Array<{ name: string; icon?: string; image?: string; color: string; route: string }>;
};

export type CatalogueEntry = {
  /** Stable id. Matches `sidecar_installs.sidecar_id` and `service_connections.service` where both exist. */
  id: string;
  /** How it appears in the app. Absent for a sidecar with no UI of its own, like notify. */
  ui?: UiManifest;
  /** The PM2 process to start and stop. Must exist in ecosystem.config.cjs. */
  process: string;
  label: string;
  /** One line, shown in the store listing. */
  summary: string;
  /** Which of the three shapes this sidecar supports, in the order the UI should offer them. */
  modes: InstallMode[];
  /**
   * The capability this sidecar backs, from `capabilities/registry.ts`. Null where the sidecar has no
   * user-facing surface of its own (notify produces notifications for other features).
   *
   * This is also the key the sidecar's dock manifest is filtered by, which is why it is one value and not a
   * list — a tile belongs to one feature.
   */
  capability: string | null;
  /**
   * Other capabilities that stop working when this sidecar is absent, for availability only.
   *
   * Headscale is the case: one sidecar serves both `headscale` (administering the tailnet, owner-only) and
   * `vpn` (a member enrolling their own device). With only `capability` to go on, `vpn` was never subtracted,
   * so the Permissions screen offered it on a machine with no Headscale at all — a grant that would have
   * produced a refusal the owner could not account for.
   */
  alsoServes?: string[];
  /**
   * Asked when the user picks `existing`. Skipped entirely for `provisioned`, where we already know the
   * answers because we wrote the compose file.
   */
  existingFields?: ConfigField[];
  /** Asked for `config` installs, which have no instance to point at. */
  configFields?: ConfigField[];
  /** Name of the compose template under `app-store/templates/`. Required iff `modes` includes 'provisioned'. */
  composeTemplate?: string;
  /**
   * Whether members get their own account on this service, and how far that can be automated.
   *
   *   'accounts' — an admin API can create the user AND mint a credential, so provisioning is fully
   *                transparent: the member simply finds the feature working. Immich, Jellyfin, Gitea,
   *                Memos.
   *   'invite'   — an account can be created but a usable credential cannot, and that is a property of
   *                the service rather than a gap in ours. Vaultwarden is end-to-end encrypted: the
   *                master password derives the encryption key, so a credential we could mint would mean
   *                a vault we could read. The member is invited and sets their own password.
   *   'none'     — a single-tenant daemon with no user concept. Transmission, slskd. Access is mediated
   *                entirely by Officer, which is already how it works.
   *
   * Read at two moments, not one: when the service is installed (for every member who already exists)
   * and when a member is added (for every service already installed). Only handling the first is the
   * classic thing that works on day one and silently rots.
   */
  members: 'accounts' | 'invite' | 'none';
  /**
   * A host requirement that is NOT derivable from `modes`. Shown instead of the install button rather
   * than failing halfway through.
   *
   * Docker deliberately does not appear here: needing it is exactly "this entry can provision", which
   * `modes` already says. `preflight.needsDocker` derives it, so the two cannot disagree.
   */
  requires?: 'linux-display';
};

export const CATALOGUE: CatalogueEntry[] = [
  // ── Point at something you already run, or let us start one ────────────────────────────────────────
  {
    id: 'photos',
    ui: { name: 'Photos', icon: 'Images', color: '#10b981', rootRoute: '/photos', routes: ['/photos'] },
    process: 'officer-photos',
    label: 'Photos',
    summary: 'Your Immich library — browse, search, upload from the phone',
    members: 'accounts',
    modes: ['existing', 'provisioned'],
    capability: 'photos',
    composeTemplate: 'immich',
    existingFields: [
      { key: 'url', label: 'Immich URL', type: 'url', required: true, placeholder: 'https://photos.example.com' },
      {
        key: 'secret',
        label: 'API key',
        type: 'secret',
        required: true,
        help: 'Immich → Account Settings → API Keys. Create it with all permissions: a scoped key returns 403 per route, which reads as a broken feature.',
      },
    ],
  },
  {
    id: 'jellyfin',
    ui: { name: 'Video', icon: 'Clapperboard', color: '#a855f7', rootRoute: '/jellyfin', routes: ['/jellyfin'] },
    process: 'officer-jellyfin',
    label: 'Jellyfin',
    summary: 'Films and shows, with a player that handles direct, HLS and progressive',
    members: 'accounts',
    modes: ['existing', 'provisioned'],
    capability: 'jellyfin',
    composeTemplate: 'jellyfin',
    existingFields: [
      { key: 'url', label: 'Jellyfin URL', type: 'url', required: true, placeholder: 'https://jellyfin.example.com' },
      { key: 'secret', label: 'Access token', type: 'secret', required: true },
    ],
  },
  {
    id: 'memos',
    ui: { name: 'Memos', icon: 'NotebookPen', color: '#eab308', rootRoute: '/memos', routes: ['/memos'] },
    process: 'officer-memos',
    label: 'Memos',
    summary: 'Quick notes, tagged and searchable',
    members: 'accounts',
    modes: ['existing', 'provisioned'],
    capability: 'memos',
    composeTemplate: 'memos',
    existingFields: [
      { key: 'url', label: 'Memos URL', type: 'url', required: true },
      { key: 'secret', label: 'Access token', type: 'secret', required: true },
    ],
  },
  {
    id: 'invoiceshelf',
    ui: { name: 'Invoices', icon: 'Receipt', color: '#0891b2', rootRoute: '/invoices', routes: ['/invoices'] },
    process: 'officer-invoiceshelf',
    label: 'Invoices',
    summary: 'InvoiceShelf — clients, estimates and invoices',
    members: 'accounts',
    modes: ['existing', 'provisioned'],
    capability: 'invoices',
    composeTemplate: 'invoiceshelf',
    existingFields: [
      { key: 'url', label: 'InvoiceShelf URL', type: 'url', required: true },
      { key: 'secret', label: 'API token', type: 'secret', required: true },
    ],
  },
  {
    id: 'vault',
    ui: { name: 'Vault', icon: 'KeyRound', color: '#175ddc', rootRoute: '/vault', routes: ['/vault'] },
    process: 'officer-vault',
    label: 'Vault',
    summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps',
    members: 'invite',
    modes: ['existing', 'provisioned'],
    // No capability entry exists for this one, and the reason is about CREDENTIALS, not routing.
    //
    // Every request still goes through us: `/api/vault` is mounted on vaultRouter and forwarded by the
    // officer-vault sidecar to Vaultwarden. The Bitwarden clients never reach Vaultwarden directly.
    //
    // What they do NOT carry is a platform JWT — they present their own Vaultwarden bearer token — so
    // `userMiddleware` would 401 them and a capability lookup would have no account to resolve. Hence
    // `/vault` sits in EXEMPT_API_PREFIXES, gated by origin scoping and Vaultwarden's own auth instead.
    // The install still governs whether the sidecar runs at all.
    capability: null,
    composeTemplate: 'vaultwarden',
    existingFields: [{ key: 'url', label: 'Vaultwarden URL', type: 'url', required: true }],
  },
  {
    id: 'transmission',
    ui: {
      name: 'Transmission',
      icon: 'ArrowDownUp',
      color: '#e11d48',
      rootRoute: '/transmission',
      routes: ['/transmission'],
    },
    process: 'officer-transmission',
    label: 'Transmission',
    summary: 'Torrents, with the daemon Officer talks to over RPC',
    members: 'none',
    modes: ['existing', 'provisioned'],
    capability: 'transmission',
    composeTemplate: 'transmission',
    existingFields: [
      {
        key: 'url',
        label: 'Transmission URL',
        type: 'url',
        required: true,
        placeholder: 'https://transmission.example.com or http://10.0.0.5:9091',
      },
      {
        key: 'path',
        label: 'RPC path',
        type: 'text',
        required: false,
        placeholder: '/transmission/rpc',
        help: 'Only differs behind a reverse proxy.',
      },
      {
        key: 'username',
        label: 'RPC username',
        type: 'text',
        required: false,
        help: 'Usually blank — Transmission is normally run with no RPC auth.',
      },
      { key: 'secret', label: 'RPC password', type: 'secret', required: false },
    ],
  },
  {
    id: 'slskd',
    ui: {
      name: 'Soulseek',
      image: '/plugins/slskd/icon.png',
      color: '#ffffff',
      rootRoute: '/soulseek',
      routes: ['/soulseek'],
    },
    process: 'officer-slskd',
    label: 'Soulseek',
    summary: 'slskd — search and download from the Soulseek network',
    members: 'none',
    modes: ['existing', 'provisioned'],
    capability: 'soulseek',
    composeTemplate: 'slskd',
    existingFields: [
      { key: 'url', label: 'slskd URL', type: 'url', required: true },
      { key: 'secret', label: 'API key', type: 'secret', required: true },
    ],
  },
  {
    id: 'caldav',
    ui: {
      name: 'Calendar',
      icon: 'CalendarDays',
      color: '#3b82f6',
      rootRoute: '/calendar',
      routes: ['/calendar', '/contacts'],
    },
    process: 'officer-caldav',
    label: 'Calendar',
    summary: 'Radicale — calendars and contacts over CalDAV/CardDAV',
    members: 'accounts',
    modes: ['existing', 'provisioned'],
    capability: 'calendar',
    composeTemplate: 'radicale',
    existingFields: [{ key: 'url', label: 'CalDAV URL', type: 'url', required: true }],
  },
  {
    id: 'gitea',
    ui: { name: 'Gitea', icon: 'GitBranch', color: '#34d399', rootRoute: '/gitea', routes: ['/gitea'] },
    process: 'officer-gitea',
    label: 'Gitea',
    summary: 'Repositories, issues and pull requests from your own Gitea account',
    // 'none' because there is nothing for the INSTALLER to provision, not because Gitea is single-tenant —
    // it is the most per-user service here. The owner's connection carries the instance URL; every member
    // adds their own access token from /gitea and acts only as themselves upstream, with Gitea's own
    // permissions as the second and real gate. A provisioner could not do that part for them without
    // holding an admin token and minting tokens on their behalf, which is more authority than this needs.
    members: 'none',
    // `existing` only, and there is no compose template. Gitea here is always something the owner already
    // runs — on this machine, on another, or hosted. Provisioning one would mean owning the migration,
    // backup and upgrade story for a service that is nobody's side feature.
    modes: ['existing'],
    capability: 'gitea',
    existingFields: [
      { key: 'url', label: 'Gitea URL', type: 'url', required: true, placeholder: 'https://gitea.example.com' },
      {
        key: 'secret',
        label: 'Your access token',
        type: 'secret',
        required: true,
        help: 'A personal access token from your own Gitea account. Members add their own from /gitea.',
      },
    ],
  },
  // Headscale was here until 2026-08-14 and is CORE now — `officer-headscale` runs in the baseline
  // profile, so it is not something you install, and `catalogue.test.ts` had been failing on exactly
  // that ("does not offer to install the baseline") since it was promoted.
  //
  // Leaving it here did more than fail a test. Availability is derived ONLY from `sidecar_installs`,
  // and a core sidecar never gets a row there — so `headscale` and `vpn` were permanently `unavailable`,
  // which withheld the dock manifest AND put /headscale into `deniedRoutes`. The screen was unreachable
  // and invisible on a server where the sidecar was up and healthy.
  //
  // Its tile is in CORE_DOCK_ITEMS instead, and servers are configured where they always really were —
  // the Servers view inside the app, which is why the `existingFields` here were a redundant second
  // place to type the same URL and API key.

  // ── Nothing to reach: configuration only ───────────────────────────────────────────────────────────
  {
    id: 'email',
    ui: { name: 'Email', icon: 'Mail', color: '#ef4444', rootRoute: '/email', routes: ['/email'] },
    process: 'officer-email',
    label: 'Email',
    summary: 'Your IMAP accounts, synced and searchable',
    members: 'none',
    modes: ['config'],
    capability: 'email',
    // Deliberately empty: accounts are added from /email, which already has a working multi-account
    // form. Duplicating it here would be a second place to maintain the same credentials.
    configFields: [],
  },
  // Music was here until 2026-08-15, when it became `plugins/music/`. Removing it was not tidying — it
  // was the headscale bug above, exactly, and it would have fired on the first install.
  //
  // `capabilityAvailability` reads `sidecar_installs`, and a PLUGIN never gets a row there: its install
  // state lives in `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
  // `/music` into `deniedRoutes` — the screen blank and the dock tile withheld on a server where the
  // plugin was installed, enabled and healthy. The same shape as headscale, found by reading that note
  // rather than by hitting it again.
  //
  // The tile now comes from `pluginDockManifests()`, and the library folder is not configured at all:
  // the sidecar reads `~/Music`. That `configFields` entry wrote a `service_connections` row nothing
  // ever read.
  //
  // `[open]` This is the "two dock sources" seam. The store keeps its own catalogue while the plugin
  // system builds tiles from manifests, and the self endpoint concatenates both. One when the store is
  // rebuilt on the plugin system.
  {
    id: 'wallet',
    ui: { name: 'Wallet', icon: 'Bitcoin', color: '#f7931a', rootRoute: '/wallet', routes: ['/wallet'] },
    process: 'officer-wallet',
    label: 'Wallet',
    summary: 'Bitcoin and Lightning, with keys held by the sidecar alone',
    members: 'none',
    modes: ['config'],
    capability: 'wallet',
    configFields: [],
  },
  {
    id: 'notify',
    process: 'officer-notify',
    label: 'Notifications',
    summary: 'Push to your phone when a job finishes or a turn needs you',
    members: 'none',
    modes: ['config'],
    capability: 'notify',
    configFields: [],
  },
  {
    id: 'vnc',
    ui: { name: 'Desktop', icon: 'MonitorSmartphone', color: '#ec4899', rootRoute: '/desktop', routes: ['/desktop'] },
    process: 'officer-vnc',
    label: 'Desktop',
    summary: 'Mirror this machine’s display in the browser',
    members: 'none',
    modes: ['config'],
    capability: 'desktop',
    // x11vnc against an Xorg display. There is nothing to mirror on a headless box or on macOS, so the
    // store should say so rather than install something that starts and immediately fails.
    requires: 'linux-display',
    configFields: [],
  },
];

export const byId = (id: string): CatalogueEntry | undefined => CATALOGUE.find((e) => e.id === id);

/** Every catalogue id, for validating a request before it reaches the installer. */
export const CATALOGUE_IDS: ReadonlySet<string> = new Set(CATALOGUE.map((e) => e.id));
