import { describe, expect, it } from 'bun:test';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { CATALOGUE, byId } from './catalogue';
import { CAPABILITIES } from '../capabilities/registry';

// The catalogue is a hand-written list describing machinery that lives elsewhere, which is the shape of
// thing that rots silently. These tests pin it to what it claims to agree with.
//
// The intent is that adding a sidecar to the estate and forgetting the app store FAILS HERE, rather than
// the sidecar being quietly uninstallable and nobody noticing for a release.
//
// ── What changed on 2026-08-13 ──
//
// Two of these tests derived the catalogue from `ecosystem.config.cjs` minus `ecosystem.light.config.cjs`
// — "the app store offers exactly what light leaves out". That was the right check while those files
// existed, and they do not any more: the ecosystem file is GENERATED at setup, describes only the six
// core processes, and is not in git. There is no longer a list of every possible process to subtract
// from, which is the point — a plugin's process is described when it is installed, not before.
//
// So the derivation is gone and two weaker but still real checks replace it: the store must not offer a
// CORE process, and every process it names must have a sidecar directory to run. The second is the
// stronger of the two — it catches the typo the old test caught, without needing a manifest of
// everything.

// The processes a core install runs, mirroring CORE_PROCESSES in
// scripts/setup/officer-setup/lib/services.sh. Duplicated deliberately: the generator is shell and this
// is a test, and the alternative is the test reading a file the repository does not contain.
const CORE = ['officer', 'officer-anthropic-proxy', 'officer-claude-code', 'officer-opencode', 'officer-pty'];

describe('the catalogue against the real estate', () => {
  it('does not offer to install the baseline', () => {
    // "Uninstall chat" is not a thing the store should be able to express.
    for (const entry of CATALOGUE) expect(CORE).not.toContain(entry.process);
  });

  it('names a process that has a sidecar to run', () => {
    // A typo here would install nothing and report success. Every process is `officer-<dir>` under
    // src/servers/sidecar/, which is what the generated ecosystem entry would point `args` at.
    for (const entry of CATALOGUE) {
      const dir = entry.process.replace(/^officer-/, '');
      expect(existsSync(join(import.meta.dir, '../sidecar', dir))).toBe(true);
    }
  });
});

describe('entries are internally coherent', () => {
  it('has unique ids', () => {
    const ids = CATALOGUE.map((e) => e.id);
    expect(new Set(ids).size).toBe(ids.length);
  });

  it('declares a compose template if and only if it can provision one', () => {
    // Both directions matter: a provisioning entry with no template fails at install time, and a
    // template on a non-provisioning entry is dead weight nobody will notice is unused.
    for (const entry of CATALOGUE) {
      if (entry.modes.includes('provisioned')) expect(entry.composeTemplate).toBeTruthy();
      else expect(entry.composeTemplate).toBeUndefined();
    }
  });

  it('asks for connection details exactly when it points at something existing', () => {
    for (const entry of CATALOGUE) {
      if (entry.modes.includes('existing')) expect(entry.existingFields?.length).toBeGreaterThan(0);
      else expect(entry.existingFields).toBeUndefined();
    }
  });

  it('requires a url when pointing at an existing instance', () => {
    // Without one there is nothing to connect to, and the `existing` mode is meaningless.
    for (const entry of CATALOGUE) {
      if (!entry.modes.includes('existing')) continue;
      const url = entry.existingFields?.find((f) => f.key === 'url');
      expect(url?.required).toBe(true);
    }
  });

  it('declares at least one mode', () => {
    for (const entry of CATALOGUE) expect(entry.modes.length).toBeGreaterThan(0);
  });
});

describe('capabilities it claims to back', () => {
  it('names a capability that exists, or explicitly none', () => {
    // `null` is a real answer — notify has no surface of its own — but a WRONG key would silently
    // detach the store entry from the permission that governs the feature.
    const keys = new Set(CAPABILITIES.map((c) => c.key));
    for (const entry of CATALOGUE) {
      if (entry.capability === null) continue;
      expect(keys).toContain(entry.capability);
    }
  });
});

describe('byId', () => {
  it('finds a known entry and returns undefined for anything else', () => {
    expect(byId('photos')?.process).toBe('officer-photos');
    expect(byId('not-a-sidecar')).toBeUndefined();
  });
});

describe('member provisioning is declared for every entry', () => {
  it('declares how members get access', () => {
    // Undeclared would silently mean "no account for anyone", which is invisible until a member
    // reports that a feature the owner can see does nothing for them.
    for (const entry of CATALOGUE) expect(['accounts', 'invite', 'none']).toContain(entry.members);
  });

  it('never promises accounts for a single-tenant daemon', () => {
    // Transmission and slskd have no user concept; claiming otherwise would make the installer try to
    // create accounts against an API that does not exist.
    for (const id of ['transmission', 'slskd']) expect(byId(id)!.members).toBe('none');
  });

  it('marks the vault as invite-only, because it cannot be otherwise', () => {
    // Vaultwarden derives its encryption key from the master password. A credential we could mint is a
    // vault we could read, so 'accounts' here would be a security defect rather than a feature.
    expect(byId('vault')!.members).toBe('invite');
  });
});

describe('the UI manifest each sidecar carries', () => {
  it('declares one, unless it has no interface of its own', () => {
    // notify is the only exception: it produces notifications FOR other features and has no screen.
    for (const entry of CATALOGUE) {
      if (entry.id === 'notify') expect(entry.ui).toBeUndefined();
      else expect(entry.ui).toBeDefined();
    }
  });

  it('names an icon rather than importing one', () => {
    // A manifest has to survive being JSON from marketplace.officer.dev. A lucide component cannot make
    // that trip; a name can, and resolveIcon already maps names to glyphs.
    for (const entry of CATALOGUE) {
      if (!entry.ui) continue;
      const hasGlyph = typeof entry.ui.icon === 'string';
      const hasImage = typeof entry.ui.image === 'string';
      expect(hasGlyph || hasImage).toBe(true);
    }
  });

  it('includes its rootRoute among its routes', () => {
    // The dock links to rootRoute and the guard matches `routes`. If the root is missing from the list,
    // the tile appears and leads somewhere the guard refuses.
    for (const entry of CATALOGUE) {
      if (!entry.ui) continue;
      expect(entry.ui.routes).toContain(entry.ui.rootRoute);
    }
  });

  it('claims routes that the capability registry agrees it owns', () => {
    // The manifest drives the dock; the registry drives the server-side guard. If they disagree, a tile
    // appears for a route the account is refused — or worse, a route is guarded by nothing.
    const byKey = new Map(CAPABILITIES.map((c) => [c.key, c]));
    for (const entry of CATALOGUE) {
      if (!entry.ui || !entry.capability) continue;
      const declared = byKey.get(entry.capability)?.routes ?? [];
      for (const route of entry.ui.routes) expect(declared).toContain(route);
    }
  });

  it('does not have two sidecars claiming the same root route', () => {
    const roots = CATALOGUE.filter((e) => e.ui).map((e) => e.ui!.rootRoute);
    expect(new Set(roots).size).toBe(roots.length);
  });
});

describe('the "do you already have one?" question', () => {
  it('is always offered by anything that can provision', () => {
    // The rule: a sidecar that would start a container must first let the user point at one they
    // already run. Offering only 'provisioned' means someone with a working Immich gets a second one,
    // and only finds out when two libraries disagree.
    //
    // Enforced here rather than trusted to the UI, because a new entry added later would otherwise
    // silently skip the question.
    for (const entry of CATALOGUE) {
      if (!entry.modes.includes('provisioned')) continue;
      expect(entry.modes).toContain('existing');
    }
  });

  it('offers "existing" first, so the prompt leads with it', () => {
    // `modes` is the order the UI presents them in. Leading with "provision one for me" invites a
    // second instance from someone who already has one.
    for (const entry of CATALOGUE) {
      if (!entry.modes.includes('existing')) continue;
      expect(entry.modes[0]).toBe('existing');
    }
  });

  it('asks for a URL wherever an existing instance can be named', () => {
    // Without one there is nothing to connect to, and "I already have it" cannot be answered.
    for (const entry of CATALOGUE) {
      if (!entry.modes.includes('existing')) continue;
      expect(entry.existingFields?.some((f) => f.key === 'url' && f.required)).toBe(true);
    }
  });
});
