import {
  listSidecarInstalls,
  getSidecarInstall,
  beginInstall,
  markInstalled,
  markFailed,
  markBlocked,
  recordSteps,
  setEnabled as setEnabledRow,
  removeInstall,
  type SidecarInstall,
} from 'officerdb';
import { CATALOGUE, byId, type CatalogueEntry, type InstallMode } from './catalogue';
import { runInstall, type StepName } from './installer';
import { createEffects } from './effects';
import { startProcess, stopProcess, deleteProcess, processStatus } from './pm2';
import { plannedOutcome } from './members';
import { composeUp, composeStop, composeDown } from './compose';
import { unpublishAssets } from './assets';
import { serviceDir } from './paths';

// The app store's operations, between the HTTP routes and the machinery. Routes stay about HTTP; this
// stays about what installing, enabling and uninstalling actually mean.

const PLATFORM_DIR = process.cwd();

export type StoreItem = {
  id: string;
  label: string;
  summary: string;
  modes: InstallMode[];
  members: CatalogueEntry['members'];
  /** What adding a member would do for this service, so the UI can say so before anyone commits. */
  memberOutcome: ReturnType<typeof plannedOutcome>;
  existingFields: CatalogueEntry['existingFields'];
  configFields: CatalogueEntry['configFields'];
  install: {
    status: 'not-installed' | SidecarInstall['status'];
    enabled: boolean;
    mode: string | null;
    lastError: string | null;
    completedSteps: string[];
  };
  /** PM2's own view. Included because the row saying `enabled` and the process being dead is the
   *  interesting case, and hiding it would make the store lie about what is running. */
  processStatus: string | null;
};

/** The catalogue joined to what has actually happened to each entry. */
export async function listStore(): Promise<StoreItem[]> {
  const installs = new Map((await listSidecarInstalls()).map((row) => [row.sidecarId, row]));

  return Promise.all(
    CATALOGUE.map(async (entry) => {
      const row = installs.get(entry.id);
      return {
        id: entry.id,
        label: entry.label,
        summary: entry.summary,
        modes: entry.modes,
        members: entry.members,
        memberOutcome: plannedOutcome(entry),
        existingFields: entry.existingFields,
        configFields: entry.configFields,
        install: {
          status: row?.status ?? 'not-installed',
          enabled: row?.enabled ?? false,
          mode: row?.mode ?? null,
          lastError: row?.lastError ?? null,
          completedSteps: (row?.completedSteps as string[]) ?? [],
        },
        // Only asked for things that claim to be installed: `pm2 jlist` per catalogue entry would be
        // fourteen subprocesses to render a page.
        processStatus: row ? await processStatus(entry.process, PLATFORM_DIR) : null,
      };
    }),
  );
}

export type InstallRequest = {
  sidecarId: string;
  mode: InstallMode;
  /** Answers from the form. Passed to the setup script as environment and to `connect` as values. */
  values: Record<string, string>;
  log?: (line: string) => void;
};

/**
 * Install, or resume an install that stopped.
 *
 * Resume is not a separate entry point on purpose: pressing the button again after a failure, and
 * pressing it again after supplying the API key it was waiting for, are the same action from the user's
 * side. The recorded steps decide what actually re-runs.
 */
export async function install(req: InstallRequest) {
  const entry = byId(req.sidecarId);
  if (!entry) throw new Error(`unknown sidecar: ${req.sidecarId}`);
  if (!entry.modes.includes(req.mode)) {
    throw new Error(`${entry.label} cannot be installed as '${req.mode}'`);
  }

  const row = await beginInstall(entry.id, req.mode);
  const completed = (row.completedSteps as StepName[]) ?? [];

  const outcome = await runInstall({
    entry,
    mode: req.mode,
    values: req.values,
    completed,
    effects: createEffects(),
    log: req.log,
  });

  if (outcome.status === 'installed') {
    // Recorded from the install rather than derived later: the directory is the user's and they may move
    // it, and uninstall must not guess at a path it is about to run `docker compose down` in.
    if (req.mode === 'provisioned') await recordSteps(entry.id, outcome.completed, serviceDir(entry.id));
    await markInstalled(entry.id, outcome.completed);
  } else if (outcome.status === 'blocked') {
    await markBlocked(entry.id, outcome.completed, outcome.reason);
  } else {
    await markFailed(entry.id, outcome.completed, outcome.error);
  }

  return outcome;
}

/**
 * Start or stop a sidecar AND its container, without changing what is installed.
 *
 * Order matters in both directions, and it is the opposite each way:
 *
 *   enable   container first, then the process — a sidecar that starts before its upstream exists
 *            spends its first seconds failing health checks and logging errors about a service that
 *            is merely not up yet.
 *   disable  process first, then the container — stopping the container underneath a running sidecar
 *            produces the same noise for the same reason, in reverse.
 *
 * `mode: 'existing'` has no container of ours, and `composeUp`/`composeStop` return `ran: false`
 * rather than failing: there is no compose file, which is the correct state, not an error.
 */
export async function setEnabled(sidecarId: string, enabled: boolean) {
  const entry = byId(sidecarId);
  if (!entry) throw new Error(`unknown sidecar: ${sidecarId}`);
  const row = await getSidecarInstall(sidecarId);
  if (!row) throw new Error(`${entry.label} is not installed`);

  const dir = row.composeDir ?? serviceDir(sidecarId);

  if (enabled) {
    const container = await composeUp(dir);
    if (!container.ok) throw new Error(`could not start ${entry.label}'s container: ${container.error}`);
    const process = await startProcess(entry.process, PLATFORM_DIR);
    if (!process.ok) throw new Error(process.error);
  } else {
    const process = await stopProcess(entry.process, PLATFORM_DIR);
    if (!process.ok) throw new Error(process.error);
    const container = await composeStop(dir);
    if (!container.ok) throw new Error(`could not stop ${entry.label}'s container: ${container.error}`);
  }

  await setEnabledRow(sidecarId, enabled);
  return { ok: true as const };
}

/**
 * Stop running this. Never "delete my data".
 *
 * What goes: the process (stopped, then removed from PM2), the containers (`docker compose down`, no
 * `-v`), the published assets, and the install row.
 *
 * What stays: the service directory and everything under it — configuration, databases, media — and the
 * sidecar's tables. Reinstalling later is therefore a restore rather than a fresh start.
 *
 * The assets are the only thing here that is deleted outright, and only because they are COPIES; the
 * originals live with the sidecar. Nothing a user made is in that directory.
 */
export async function uninstall(sidecarId: string) {
  const entry = byId(sidecarId);
  if (!entry) throw new Error(`unknown sidecar: ${sidecarId}`);
  const row = await getSidecarInstall(sidecarId);

  const stopped = await stopProcess(entry.process, PLATFORM_DIR);
  if (!stopped.ok) throw new Error(stopped.error);
  await deleteProcess(entry.process, PLATFORM_DIR);

  // Only for something we provisioned. An `existing` install points at a service the user runs
  // themselves, and bringing it down would stop a container Officer did not start.
  if (row?.mode === 'provisioned') {
    const dir = row.composeDir ?? serviceDir(sidecarId);
    const container = await composeDown(dir);
    if (!container.ok) throw new Error(`could not remove ${entry.label}'s containers: ${container.error}`);
  }

  await unpublishAssets(sidecarId);
  await removeInstall(sidecarId);
  return { ok: true as const };
}
