// The judgement calls the runtime driver has to make, with the world removed —
// PG Task 2 of the bundled-PostgreSQL milestone
// (docs/todo/2026-08-08-bundled-postgres-decisions.md).
//
// runtime-fetch.ts and runtime.ts are the impure half of localdb: they download,
// hash, unpack, spawn and kill. Everything in THIS file is the reasoning those
// two do, expressed as functions over plain values — "is this runtime complete",
// "what does this postmaster.pid say", "is that lock stale or is a server really
// running", "what does 12,700,000 of 25,915,316 bytes look like to a human".
//
// The split earns its keep on exactly one of those: the stale-lock verdict. A
// hard power-off leaves a postmaster.pid behind, which the decisions document
// calls out as trap #2 — ORDINARY, not exceptional. The recovery is to delete a
// lock file and start a database server, and the failure mode of getting it
// wrong is two postmasters writing one data directory, which is how a set of
// books gets shredded. That decision has to be assertable for every combination
// of (file present/absent/truncated) x (pid alive/dead/reused/unknown) x (port
// answering or not), and none of those combinations can be produced on demand by
// actually power-cycling a machine mid-test. So the facts come IN as arguments
// and the verdict comes OUT as a value, and the test suite can walk the whole
// grid in milliseconds.
//
// PURE. No fs, no net, no child_process, no process.platform.

import { type PlatformId } from "./platform.js";
import { type PostgresBinary } from "./paths.js";
import { type InstanceDocument, type InstanceReadResult } from "./instance.js";
import { checkClusterVersion } from "./version.js";

// ---------------------------------------------------------------------------
// the error type
// ---------------------------------------------------------------------------

/**
 * Every failure the runtime driver raises, with a machine-readable `code`
 * beside the English.
 *
 * The `message` is ALWAYS a complete, plain-English sentence fit to show a
 * business owner (CLAUDE.md §9: "Errors in English"). `detail` is the technical
 * companion — a stderr tail, a path, a digest — kept in a separate field
 * precisely so that no caller can accidentally render it as if it were the
 * explanation. "ENOENT" is not a message.
 *
 * `code` exists for the launcher (PG Task 3), which has to react differently to
 * "this platform has no download yet" (print and exit calmly) and "your books
 * were written by a newer version" (print and exit LOUDLY), and must not do that
 * by matching on prose that a copy-edit will change.
 *
 * NOTHING PUT IN EITHER FIELD MAY CONTAIN THE GENERATED SUPERUSER PASSWORD. It
 * lives in one 0600 file and nowhere else — not in .env, not in a log line, and
 * above all not in an error string that a user pastes into a support email.
 * Connection strings go through instance.ts's redactConnectionUrl() first.
 */
export type LocalDbErrorCode =
  // --- platform / layout ---
  | "unsupported-platform"
  | "no-app-data-root"
  // --- fetching the runtime ---
  | "runtime-unavailable"
  | "tar-unavailable"
  | "download-failed"
  | "checksum-mismatch"
  | "extract-failed"
  | "runtime-incomplete"
  // --- the cluster ---
  | "instance-unreadable"
  | "cluster-missing"
  | "cluster-unrecorded"
  | "cluster-version-mismatch"
  | "secret-unreadable"
  | "initdb-failed"
  // --- lifecycle ---
  | "not-initialised"
  | "lock-undecidable"
  /** Our port answers, but the data directory has no postmaster.pid — so it is
   *  not our cluster. A second installation, or somebody's own PostgreSQL. */
  | "port-in-use"
  | "start-failed"
  | "stop-failed";

export class LocalDbError extends Error {
  readonly code: LocalDbErrorCode;
  /** Technical context for the log. Never the user-facing explanation. */
  readonly detail: string | undefined;

  constructor(code: LocalDbErrorCode, message: string, detail?: string) {
    super(message);
    this.name = "LocalDbError";
    this.code = code;
    this.detail = detail;
  }
}

// ---------------------------------------------------------------------------
// timing budgets
// ---------------------------------------------------------------------------

/** How long to wait for a freshly spawned postmaster to start accepting
 *  connections before giving up.
 *
 *  THIRTY SECONDS, MEASURED, NOT GUESSED. The 2026-08-08 spike timed a real
 *  16.10 cluster on a warm machine at **6.3 seconds** from spawn to
 *  `pg_isready` exit 0. A five-second budget — the number everybody reaches for
 *  — would have failed every single launch on the machine this was developed on,
 *  and a cold cache, a virus scanner reading 120 MB of freshly unpacked
 *  executables, or a first start after `initdb` are all slower than warm. The
 *  cost of this number being too large is a slower error on a genuinely broken
 *  install; the cost of it being too small is an app that says the database is
 *  broken when it is merely starting. */
export const READY_TIMEOUT_MS = 30_000;

/** How often to poll `pg_isready` inside that budget. Each poll is a process
 *  spawn (~10 ms), so 400 ms is ~75 spawns in the worst case and about 16 in the
 *  measured case — cheap enough not to matter, frequent enough that a fast start
 *  is not padded by a lazy poll interval. */
export const READY_POLL_INTERVAL_MS = 400;

/** `pg_ctl stop -m fast -t <this>`. Matches READY_TIMEOUT_MS deliberately: a
 *  shutdown that has to flush a large checkpoint is the mirror image of a
 *  startup that has to replay one. */
export const STOP_TIMEOUT_SECONDS = 30;

// ---------------------------------------------------------------------------
// "is the runtime actually here"
// ---------------------------------------------------------------------------

/**
 * Every executable a complete runtime must contain, as a value rather than a
 * type.
 *
 * Written as a total Record over PostgresBinary and then reduced to its keys, so
 * that adding a name to the PostgresBinary union without adding it here is a
 * COMPILE error. That is not ceremony: the reason this list exists at all is
 * that the first candidate runtime source (zonky.io's embedded-postgres) shipped
 * three of these nine and nothing about the archive announced it — no pg_dump,
 * which server/src/backup.ts runs on every launch, and no pg_restore, which
 * RESTORE.md is entirely about. A missing tool is discovered on the day you need
 * it, which for a backup tool is the worst possible day.
 *
 * `scripts/stage-pg-runtime.ts` asserts the same nine on the maintainer's
 * machine before publishing. This is the second gate, on the user's machine,
 * against the archive that actually arrived.
 */
const REQUIRED_BINARY_SET: Record<PostgresBinary, true> = {
  postgres: true,
  initdb: true,
  pg_ctl: true,
  pg_isready: true,
  pg_dump: true,
  pg_dumpall: true,
  pg_restore: true,
  pg_upgrade: true,
  psql: true,
};

export const REQUIRED_BINARIES: readonly PostgresBinary[] =
  Object.keys(REQUIRED_BINARY_SET) as PostgresBinary[];

/** The on-disk filename of one bundled executable — `postgres.exe` on Windows,
 *  `postgres` on macOS. Keyed off the TARGET platform, never the host, for the
 *  same reason paths.ts joins with path.win32 / path.posix explicitly. */
export function binaryFileName(platform: PlatformId, name: PostgresBinary): string {
  return platform === "win32-x64" ? `${name}.exe` : name;
}

/**
 * Which of the nine required executables are NOT among `presentFileNames`.
 *
 * Takes the directory listing rather than doing nine `stat` calls, because the
 * hot path is "the runtime is already here" — every single launch after the
 * first — and that path must cost one `readdir`, not nine stats and certainly
 * not a re-hash of 120 MB. An empty list means "complete, do nothing".
 *
 * Comparison is case-insensitive on Windows only. NTFS is case-insensitive, so
 * a `POSTGRES.EXE` produced by some archiving tool in between is genuinely the
 * file we are looking for; on a case-sensitive filesystem it genuinely is not,
 * and pretending otherwise would produce a "present" verdict followed by an
 * ENOENT at spawn time.
 */
export function missingBinaries(
  platform: PlatformId, presentFileNames: Iterable<string>,
): PostgresBinary[] {
  const caseInsensitive = platform === "win32-x64";
  const present = new Set<string>();
  for (const name of presentFileNames) {
    present.add(caseInsensitive ? name.toLowerCase() : name);
  }
  return REQUIRED_BINARIES.filter((binary) => {
    const wanted = binaryFileName(platform, binary);
    return !present.has(caseInsensitive ? wanted.toLowerCase() : wanted);
  });
}

// ---------------------------------------------------------------------------
// postmaster.pid
// ---------------------------------------------------------------------------

/**
 * The fields PostgreSQL writes into `postmaster.pid`, in the order it writes
 * them. The file is positional — line 1 is the PID and line 4 is the port, as
 * verified against a live 16.10 cluster in the 2026-08-08 spike — and it is
 * PARSED here rather than guessed at, because the two facts we need from it
 * (which process, which port) are exactly the two that decide whether we are
 * about to delete a lock file out from under a running database.
 *
 * Lines 5-8 (socket directory, listen address, shared-memory key, status) are
 * captured because they cost nothing and they are the first thing anyone will
 * want when a support question arrives, but nothing branches on them.
 */
export interface PostmasterPidInfo {
  pid: number;
  dataDir: string;
  /** Unix epoch SECONDS, as PostgreSQL writes it. null if unreadable. */
  startTimeEpochSeconds: number | null;
  port: number;
  socketDir: string | null;
  listenAddress: string | null;
  shmemKey: string | null;
  /** "ready" or "starting" in a modern PostgreSQL. Advisory only. */
  status: string | null;
}

export type PostmasterPidResult =
  | { ok: true; info: PostmasterPidInfo }
  /** There is no file at all (or it is entirely whitespace). Nothing has
   *  claimed this data directory. */
  | { ok: false; reason: "absent" }
  /** There IS a file and we cannot read a PID and a port out of it. Almost
   *  always a truncation from a power cut mid-write. Deliberately NOT folded
   *  into `absent`: "no file" and "a file I can't understand" lead to different
   *  and irreversible actions further up. */
  | { ok: false; reason: "malformed"; detail: string };

function parsePositiveInt(line: string | undefined): number | null {
  if (line === undefined) return null;
  const trimmed = line.trim();
  if (!/^\d+$/.test(trimmed)) return null;
  const n = Number(trimmed);
  return Number.isSafeInteger(n) && n > 0 ? n : null;
}

/** Pass `null` for "the file does not exist" — the caller does the read, this
 *  function does the judging (the same division parseInstanceDocument uses). */
export function parsePostmasterPid(raw: string | null | undefined): PostmasterPidResult {
  if (raw === null || raw === undefined || raw.trim() === "") return { ok: false, reason: "absent" };

  // Split on either line ending. PostgreSQL writes \n even on Windows, but this
  // file gets copied, synced, and opened in Notepad by people trying to help,
  // and a \r left on the end of the PID line would turn a perfectly good lock
  // file into "malformed" — which, per the verdict table below, is a state that
  // can END IN DELETING IT. Tolerating \r costs one character of regex.
  const lines = raw.split(/\r?\n/);

  const pid = parsePositiveInt(lines[0]);
  if (pid === null) {
    return { ok: false, reason: "malformed", detail: `line 1 is not a process id: ${JSON.stringify(lines[0] ?? "")}` };
  }
  const port = parsePositiveInt(lines[3]);
  if (port === null || port > 65535) {
    return { ok: false, reason: "malformed", detail: `line 4 is not a port number: ${JSON.stringify(lines[3] ?? "")}` };
  }

  const startTime = parsePositiveInt(lines[2]);
  const optional = (i: number): string | null => {
    const v = lines[i]?.trim();
    return v ? v : null;
  };

  return {
    ok: true,
    info: {
      pid,
      dataDir: lines[1]?.trim() ?? "",
      startTimeEpochSeconds: startTime,
      port,
      socketDir: optional(4),
      listenAddress: optional(5),
      shmemKey: optional(6),
      status: optional(7),
    },
  };
}

// ---------------------------------------------------------------------------
// stale lock verdicts
// ---------------------------------------------------------------------------

/**
 * What is running under the PID `postmaster.pid` names.
 *
 *   postgres        A live process, and it is a PostgreSQL server. Hands off.
 *   other-process   A live process that is NOT postgres. The operating system
 *                   recycled the PID after our server died — routine on Windows,
 *                   where PIDs are handed out from a small pool and reused
 *                   within minutes. This is a STALE lock wearing a live PID, and
 *                   it is the case a naive `process.kill(pid, 0)` check gets
 *                   catastrophically wrong in the safe direction (refusing to
 *                   start forever) and a naive "just delete it" gets wrong in
 *                   the unsafe one.
 *   none            No process with that PID. The plain stale case.
 *   unknown         We could not find out — the process lister failed, or the
 *                   process belongs to another user and the OS will not say what
 *                   it is. Never treated as `none`.
 */
export type ProcessOwner = "postgres" | "other-process" | "none" | "unknown";

/**
 * Turns "is it alive" + "what is its image name" into a ProcessOwner.
 *
 * The name test is a prefix match on the basename because the answer differs by
 * platform and by how the process was started: `postgres.exe` from tasklist on
 * Windows, `postgres` from `ps -o comm=` on macOS, and occasionally a full path.
 * Matching a prefix of the basename covers all three without matching, say,
 * `pgAdmin4.exe`.
 */
export function classifyProcessOwner(alive: boolean, imageName: string | null): ProcessOwner {
  if (!alive) return "none";
  if (imageName === null) return "unknown";
  const base = imageName.trim().toLowerCase().replace(/^.*[\\/]/, "");
  return base.startsWith("postgres") ? "postgres" : "other-process";
}

export interface LockFacts {
  /** What `postmaster.pid` says, or that there isn't one. */
  pidFile: PostmasterPidResult;
  /** What is running under the PID that file names. Ignored when there is no
   *  usable PID — pass "unknown". */
  owner: ProcessOwner;
  /** Did anything answer on the port (pg_isready exit 0)? This is the
   *  independent second opinion: a process table can lie to us about a process
   *  we do not own, but a server that answers a startup packet is unambiguously
   *  a running server. */
  portAnswering: boolean;
}

export type LockVerdict =
  /** No lock file. Start freely. */
  | { state: "no-lock" }
  /** A PostgreSQL server owns this data directory right now. Do not start a
   *  second one, do not touch the lock file. */
  | { state: "running"; pid: number | null; port: number | null }
  /** The lock file is a leftover. Safe to delete and start. `why` is written to
   *  be shown to a business owner verbatim. */
  | { state: "stale"; pid: number | null; why: string }
  /** We cannot tell. REFUSE, and say so — this is the one branch where doing
   *  nothing is correct, because both available actions (start anyway, delete
   *  the lock) can destroy a database if the guess is wrong. */
  | { state: "unknown"; message: string };

/**
 * Decides whether a `postmaster.pid` represents a running server or a leftover.
 *
 * The asymmetry to keep in mind while reading this: being WRONG about "stale"
 * means deleting the lock of a live server and starting a second postmaster over
 * the same files, which corrupts a set of books. Being wrong about "running"
 * means the app refuses to start until the user reboots. Those are not remotely
 * the same size of mistake, so every ambiguous case resolves towards "running"
 * or "unknown", never towards "stale".
 *
 * (PostgreSQL has a second, independent guard underneath this one — the server
 * also refuses to start when it finds a live shared-memory segment for the same
 * data directory, and the spike confirmed a second postmaster exits with
 * `FATAL: lock file "postmaster.pid" already exists`. That is a backstop, not
 * a licence: it only fires while the lock file still exists, which is precisely
 * what this function may decide to remove.)
 */
export function judgePostmasterLock(facts: LockFacts): LockVerdict {
  const { pidFile, owner, portAnswering } = facts;

  if (!pidFile.ok && pidFile.reason === "absent") {
    // No lock file, but something is answering on the port anyway. That is not
    // our cluster — it is somebody else's PostgreSQL (or anything at all)
    // holding the port we want. Reported as `running` with no pid so the caller
    // stops rather than spawning a server that will die on bind, and so the
    // message it prints can name the port instead of a process.
    if (portAnswering) {
      return {
        state: "running",
        pid: null,
        port: null,
      };
    }
    return { state: "no-lock" };
  }

  if (!pidFile.ok) {
    // Truncated or otherwise unreadable — a power cut caught PostgreSQL between
    // opening this file and finishing the write.
    //
    // `owner` is documented as meaningless here (there is no PID to have
    // inspected, so the caller passes "unknown"), but it is still consulted:
    // belt and braces on the one branch where a wrong answer deletes a live
    // server's lock file. If a caller ever DOES have grounds to say a postgres
    // process is alive, that outranks anything inferred from a damaged file.
    if (portAnswering || owner === "postgres") {
      return {
        state: "unknown",
        message:
          "Balanced Sum can't tell whether the database is already running on this computer. Something is " +
          "answering on the database's port, but the file that records which program it is has been damaged. " +
          "Restart the computer and open Balanced Sum again — nothing has been changed.",
      };
    }
    // Nothing is answering and the file is damaged. A `postmaster.pid` is
    // written by a LIVE server, so a half-written one means that server died
    // where it stood; combined with a silent port, "leftover" is the only
    // reading left. This is the single place this function moves towards
    // "stale" on incomplete information, and the port check is what makes it
    // defensible.
    return {
      state: "stale",
      pid: null,
      why:
        "Balanced Sum found a leftover file from a database that was shut down abruptly — usually a power cut " +
        "or a forced restart. Nothing is running, so it cleaned it up and carried on.",
    };
  }

  const { pid, port } = pidFile.info;

  switch (owner) {
    case "postgres":
      // A live PostgreSQL under that exact PID. Whether it is ANSWERING yet is
      // irrelevant — a server replaying WAL after a crash can take minutes
      // before it accepts connections, and it is very much running.
      return { state: "running", pid, port };

    case "other-process":
      return {
        state: "stale",
        pid,
        why:
          "Balanced Sum found a leftover file from a database that was shut down abruptly. The program it points " +
          "at is no longer the database, so it cleaned it up and carried on.",
      };

    case "none":
      if (portAnswering) {
        // The recorded process is gone but the port is live. Either a second
        // copy of the app started its own server (and rewrote this file between
        // our read and our check), or an unrelated PostgreSQL owns the port.
        // Both mean: do not delete anything.
        return { state: "running", pid: null, port };
      }
      return {
        state: "stale",
        pid,
        why:
          "Balanced Sum found a leftover file from a database that was shut down abruptly — usually a power cut " +
          "or a forced restart. Nothing is running, so it cleaned it up and carried on.",
      };

    case "unknown":
    default:
      if (portAnswering) return { state: "running", pid, port };
      return {
        state: "unknown",
        message:
          "Balanced Sum can't tell whether the database is already running on this computer, and it won't guess — " +
          "starting a second copy could damage your books. Restart the computer and open Balanced Sum again. " +
          "Nothing has been changed.",
      };
  }
}

// ---------------------------------------------------------------------------
// reading a process's name out of the OS's own process lister
// ---------------------------------------------------------------------------

/**
 * The image name from one line of `tasklist /FI "PID eq N" /NH /FO CSV`.
 *
 * Windows has no equivalent of reading /proc, and `process.kill(pid, 0)` answers
 * "does a process exist" without answering "is it MY process" — which is the
 * question that matters, because Windows recycles PIDs from a small pool within
 * minutes. So the image name gets read out of tasklist's CSV, whose first field
 * is the executable name in quotes.
 *
 * When nothing matches, tasklist prints a prose line ("INFO: No tasks are
 * running which match the specified criteria.") on stdout and exits 0. That is
 * NOT an image name, and returning it would classify the PID as
 * `other-process` — which is a STALE verdict, i.e. permission to delete a lock
 * file. So anything that isn't a quoted CSV field returns null, and null means
 * "unknown", which refuses. Getting this backwards is a data-loss bug, not a
 * cosmetic one.
 */
export function parseTasklistImageName(output: string): string | null {
  for (const line of output.split(/\r?\n/)) {
    const m = /^"([^"]+)"\s*,/.exec(line.trim());
    if (m && m[1] && m[1].trim() !== "") return m[1];
  }
  return null;
}

/** The command name from `ps -o comm= -p N` on macOS — a bare line, often a
 *  full path. Empty output means no such process; ps also exits non-zero then,
 *  and the caller uses both signals. */
export function parsePsCommand(output: string): string | null {
  const first = output.split(/\r?\n/).map((l) => l.trim()).find((l) => l !== "");
  return first ?? null;
}

// ---------------------------------------------------------------------------
// what to do about the cluster on disk
// ---------------------------------------------------------------------------

export interface ClusterFacts {
  /** The major version read from `data/pgNN/PG_VERSION`, or null when there is
   *  no cluster there at all.
   *
   *  PG_VERSION, not instance.json, is the authority on what the cluster IS.
   *  instance.json is our own record of what we did; PG_VERSION is PostgreSQL's
   *  record of what it made, it is the file the server itself checks on
   *  startup, and it cannot be out of step with the data beside it. When the two
   *  disagree, the JSON file is the one that is wrong. */
  clusterMajor: number | null;
  /** instance.json, already parsed by instance.ts. */
  instance: InstanceReadResult;
  /** Is there a secret file holding something shaped like a password we
   *  generated? Without it the superuser password is unrecoverable and no
   *  amount of repair gets a connection. */
  secretUsable: boolean;
  /** The major this build of Balanced Sum bundles. */
  shippedMajor: number;
}

export type ClusterPlan =
  /** Nothing on disk. Run initdb. */
  | { action: "initdb"; why: string }
  /** A healthy cluster with a matching record. Start it. */
  | { action: "use"; instance: InstanceDocument }
  /** A healthy cluster whose instance.json is missing, but everything needed to
   *  restate it truthfully is still on disk. See the note in planCluster(). */
  | { action: "rebuild-instance"; clusterMajor: number; why: string }
  /** Stop. `message` is fit to show a business owner as-is. */
  | { action: "refuse"; code: LocalDbErrorCode; message: string; detail?: string };

/**
 * Decides what to do with whatever is (or isn't) in the data directory.
 *
 * The single rule this table exists to enforce: NEVER run initdb next to books
 * that already exist. Every "I'm not sure" path leads to `refuse`, because the
 * alternative — creating a fresh, empty cluster beside a real one — presents the
 * user with an empty chart of accounts and no indication that their data is
 * still sitting on disk twenty directories away. That is the worst outcome
 * available in this whole module, and it is worse than refusing to start.
 *
 * `rebuild-instance` is the one repair permitted, and it is narrow: the cluster
 * is there, PG_VERSION says it is the major we ship, and the secret file is
 * still readable — so the only thing missing is a small JSON file we wrote
 * ourselves, and every field in it can be restated from evidence (major from
 * PG_VERSION, platform from the machine, port from our own constant). Refusing
 * instead would mean a user who deleted one file they didn't recognise loses
 * access to their books permanently, which is not a defensible trade when
 * nothing about the repair is a guess.
 */
export function planCluster(facts: ClusterFacts): ClusterPlan {
  const { clusterMajor, instance, secretUsable, shippedMajor } = facts;

  // --- nothing on disk ----------------------------------------------------
  if (clusterMajor === null) {
    if (instance.ok) {
      // instance.json says a database was created here and the cluster is gone.
      // Something deleted the data directory. Re-running initdb would look like
      // a successful "repair" and would be the moment the books stopped
      // existing, so this stops instead.
      return {
        action: "refuse",
        code: "cluster-missing",
        message:
          "Balanced Sum has a record of a database on this computer, but the folder holding it is gone. It won't " +
          "create a new, empty one over the top — that would look like your books had simply vanished. Restore " +
          "from a backup, or get in touch.",
        detail: `instance.json records PostgreSQL ${instance.instance.postgresVersion} but the data directory has no PG_VERSION`,
      };
    }
    if (instance.reason === "malformed") {
      return {
        action: "refuse",
        code: "instance-unreadable",
        message: instance.message,
        detail: instance.detail,
      };
    }
    return { action: "initdb", why: "no cluster and no record of one — a genuine first run" };
  }

  // --- there is a cluster: is it one we can read at all? -------------------
  //
  // Asked FIRST, before instance.json is trusted for anything. A PostgreSQL 17
  // data directory is unreadable to a PostgreSQL 16 build no matter what our own
  // JSON file claims, and the refusal is the same either way.
  const versionCheck = checkClusterVersion(shippedMajor, clusterMajor);
  if (!versionCheck.canStart) {
    return {
      action: "refuse",
      code: "cluster-version-mismatch",
      message: versionCheck.message,
      detail: `cluster is PostgreSQL ${clusterMajor}, this build ships ${shippedMajor} (${versionCheck.verdict})`,
    };
  }

  if (instance.ok) {
    if (instance.instance.postgresMajor !== clusterMajor) {
      // The record and the disk disagree about what the cluster is. One of them
      // is wrong and there is no safe way to pick, so neither is used.
      return {
        action: "refuse",
        code: "instance-unreadable",
        message:
          "Balanced Sum found a database on this computer, but its setup file describes a different one. Your " +
          "books haven't been touched. Restore from a backup, or get in touch — don't reinstall over the top.",
        detail:
          `instance.json says PostgreSQL ${instance.instance.postgresMajor}, PG_VERSION says ${clusterMajor}`,
      };
    }
    return { action: "use", instance: instance.instance };
  }

  if (instance.reason === "malformed") {
    return { action: "refuse", code: "instance-unreadable", message: instance.message, detail: instance.detail };
  }

  // instance.json is absent but a readable cluster is right there.
  if (secretUsable) {
    return {
      action: "rebuild-instance",
      clusterMajor,
      why: "the cluster is intact and its password is on disk; only the small setup file was missing",
    };
  }

  return {
    action: "refuse",
    code: "cluster-unrecorded",
    message:
      "Balanced Sum found a database on this computer but not the file that holds its password, so it can't open " +
      "it — and it won't create a new, empty one over the top. Your books are still there. Restore from a backup, " +
      "or get in touch.",
    detail: `data directory holds a PostgreSQL ${clusterMajor} cluster; instance.json and the secret file are both unusable`,
  };
}

// ---------------------------------------------------------------------------
// human-readable output
// ---------------------------------------------------------------------------

const MB = 1024 * 1024;

/** "24.7 MB". Matches scripts/stage-pg-runtime.ts's `mib()` so the number a
 *  maintainer read when publishing the tarball is the number the user reads
 *  while downloading it. Under a megabyte it falls back to KB rather than
 *  printing "0.0 MB", which reads as a stalled download. */
export function formatBytes(bytes: number): string {
  if (!Number.isFinite(bytes) || bytes < 0) return "0 KB";
  if (bytes < MB) return `${Math.round(bytes / 1024)} KB`;
  return `${(bytes / MB).toFixed(1)} MB`;
}

/**
 * The one line the user sees repeatedly during the ~25 MB first-run download.
 *
 * `total` is nullable because a server can answer without a Content-Length
 * (chunked transfer). No total means no percentage and no fabricated one — the
 * bytes received are still true, and "12.4 MB" tells someone staring at a
 * terminal that progress is being made, which is the entire job of this string.
 */
export function formatDownloadProgress(received: number, total: number | null): string {
  if (total === null || !Number.isFinite(total) || total <= 0) {
    return formatBytes(received);
  }
  const pct = Math.min(100, Math.max(0, Math.round((received / total) * 100)));
  return `${formatBytes(received)} of ${formatBytes(total)} (${pct}%)`;
}

/**
 * The last few lines of the PostgreSQL log, for pasting into an error message.
 *
 * When a start fails, the answer is ALWAYS in the log — the spike's example is
 * `FATAL: lock file "postmaster.pid" already exists` / `HINT: Is another
 * postmaster (PID nnnn) running`, which explains the whole situation in two
 * lines. Throwing "postgres exited with code 1" while that sits unread in a file
 * the user has never heard of is how a five-second problem becomes a support
 * email. Blank lines are dropped and the tail is capped so a runaway log cannot
 * turn one error into a screenful.
 */
export function summariseLogTail(text: string, maxLines = 6): string {
  const lines = text
    .split(/\r?\n/)
    .map((l) => l.trimEnd())
    .filter((l) => l.trim() !== "");
  if (lines.length === 0) return "";
  return lines.slice(-maxLines).join("\n");
}
