// Getting the PostgreSQL binaries onto this machine — PG Task 2 of the
// bundled-PostgreSQL milestone (docs/todo/2026-08-08-bundled-postgres-decisions.md).
//
// One exported function, ensureRuntime(), with one promise: when it returns
// without throwing, `layout.binDir` holds all nine PostgreSQL executables and
// they came from an archive whose SHA-256 matched the manifest. Everything else
// in this file is in service of that promise being true even when the first run
// is interrupted, which it will be — a 25 MB download on a hotel wifi, a laptop
// lid closing, a Ctrl+C.
//
// Four things here are deliberate and would each be a bug if reversed:
//
//  1. THE FAST PATH DOES NOT HASH ANYTHING. Every launch after the first lists
//     one directory and returns. Re-verifying 120 MB of extracted binaries on
//     each start would add seconds to every launch to defend against a threat
//     (someone tampering with %LOCALAPPDATA% between runs) that a hash we also
//     store in the same place does not actually defend against.
//
//  2. THE CHECKSUM IS VERIFIED BEFORE THE ARCHIVE IS UNPACKED, never after.
//     Once bytes are unpacked into the runtime directory they are one
//     `spawn()` away from being executed with the user's privileges, and this
//     archive becomes the database engine holding somebody's books. There is no
//     "extract it and check afterwards" version of that sentence that is safe.
//
//  3. EXTRACTION GOES TO A TEMPORARY DIRECTORY AND IS RENAMED INTO PLACE. A
//     half-extracted runtime that looks present is strictly worse than an absent
//     one: absent gets re-downloaded, half-present gets spawned and fails with
//     a missing-DLL dialog. Rename within one parent directory is atomic, so
//     the runtime directory only ever exists complete.
//
//  4. NO NPM DEPENDENCY. Extraction is the operating system's own bsdtar —
//     `%SystemRoot%\System32\tar.exe` on Windows 10 1803+, `/usr/bin/tar` on
//     macOS. Resolved to an ABSOLUTE path, because a bare `tar` on a developer's
//     PATH is very often Git-for-Windows' GNU tar, which cannot read what we
//     ship and whose failure looks exactly like a corrupt download.
//     scripts/stage-pg-runtime.ts makes the same resolution for the same reason;
//     CLAUDE.md §2's "ask before adding any dependency" is satisfied by not
//     adding one.

import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { createWriteStream, existsSync } from "node:fs";
import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { randomUUID } from "node:crypto";

import { type PlatformId } from "./platform.js";
import { type LocalDbLayout } from "./paths.js";
import { checksumMatches, resolveArtifact, type ArtifactEntry, type RuntimeArtifact } from "./manifest.js";
import {
  LocalDbError,
  formatBytes,
  formatDownloadProgress,
  missingBinaries,
} from "./runtime-facts.js";

// ---------------------------------------------------------------------------
// logging
// ---------------------------------------------------------------------------

/** The runtime driver never calls console directly in its own logic — the same
 *  arrangement server/src/backup.ts uses for BackupLogger, so a test can assert
 *  what a user would have been told without a captured stdout, and so the
 *  launcher (PG Task 3) can route these lines wherever it renders progress. */
export interface RuntimeLogger {
  info: (msg: string) => void;
  warn: (msg: string) => void;
}

export const consoleRuntimeLogger: RuntimeLogger = {
  info: (msg) => console.log(`[postgres] ${msg}`),
  warn: (msg) => console.warn(`[postgres] ${msg}`),
};

// ---------------------------------------------------------------------------
// bsdtar
// ---------------------------------------------------------------------------

/** Only the variable this module reads, declared rather than reached for —
 *  same discipline as paths.ts's PathEnv. */
export interface TarEnv {
  SystemRoot?: string | undefined;
}

/**
 * The absolute path to the OS's own bsdtar for `platform`.
 *
 * Windows 10 1803 and later ship bsdtar (with liblzma, so `.tar.xz` works) at
 * `%SystemRoot%\System32\tar.exe`; macOS ships it at `/usr/bin/tar`. There is no
 * PATH lookup and no fallback, on purpose — see this file's header, point 4.
 *
 * Unlike scripts/stage-pg-runtime.ts, this does NOT shell out to `tar --version`
 * to confirm the banner says bsdtar. That check exists on the maintainer's
 * machine because that script both reads a `.zip` and writes `.xz`, and finding
 * out after a 307 MB download is expensive. Here the path is fixed by the
 * operating system, an extra process spawn on every first run buys nothing, and
 * a wrong tar fails immediately and visibly at the extract step with its own
 * output attached.
 */
export function resolveTarExecutable(platform: PlatformId, env: TarEnv): string {
  if (platform === "win32-x64") {
    const systemRoot = env.SystemRoot?.trim() || "C:\\Windows";
    const tarExe = path.win32.join(systemRoot, "System32", "tar.exe");
    if (!existsSync(tarExe)) {
      throw new LocalDbError(
        "tar-unavailable",
        "Balanced Sum needs a Windows tool called tar to unpack the database it downloaded, and it isn't on this " +
        "computer. Windows 10 (version 1803, from 2018) and later include it; older versions of Windows don't. " +
        "Balanced Sum needs a newer Windows than this one.",
        `expected bsdtar at ${tarExe}`,
      );
    }
    return tarExe;
  }

  const tar = "/usr/bin/tar";
  if (!existsSync(tar)) {
    throw new LocalDbError(
      "tar-unavailable",
      "Balanced Sum needs the built-in macOS tar tool to unpack the database it downloaded, and it isn't where " +
      "macOS normally keeps it. Something has been removed from this Mac's system folders.",
      `expected bsdtar at ${tar}`,
    );
  }
  return tar;
}

/** Runs bsdtar and resolves its exit code plus whatever it said. tar.exe is a
 *  real executable taking argv, so it is spawned with an args array and no
 *  shell — none of the `.cmd`-wrapper hazard that forces a shell on npm/npx
 *  (see scripts/app.ts's run() comment). */
function runTar(tarExe: string, args: string[]): Promise<{ code: number | null; output: string }> {
  return new Promise((resolve, reject) => {
    const child = spawn(tarExe, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
    const chunks: Buffer[] = [];
    child.stdout?.on("data", (c: Buffer) => chunks.push(c));
    child.stderr?.on("data", (c: Buffer) => chunks.push(c));
    child.on("error", reject);
    child.on("close", (code) => resolve({ code, output: Buffer.concat(chunks).toString("utf8") }));
  });
}

// ---------------------------------------------------------------------------
// ensureRuntime
// ---------------------------------------------------------------------------

export type EnsureRuntimeResult =
  /** All nine executables were already there. The every-launch-after-the-first
   *  case, and it costs exactly one readdir. */
  | { action: "already-present"; runtimeDir: string }
  /** Downloaded, verified, unpacked, renamed into place. */
  | { action: "downloaded"; runtimeDir: string; bytes: number; durationMs: number }
  /** Another copy of the app finished the same download first and won the
   *  rename. Its tree was checked for completeness before this was returned —
   *  see the race note in installExtractedTree(). */
  | { action: "adopted"; runtimeDir: string };

export interface EnsureRuntimeOptions {
  log?: RuntimeLogger;
  /** Overrides the artifact table — tests point this at a local HTTP server.
   *  Real callers never pass it. */
  manifest?: Readonly<Record<PlatformId, ArtifactEntry>>;
  /** Overrides bsdtar's location. Real callers never pass it. */
  tarExecutable?: string;
  /** Only SystemRoot is read. Defaults to process.env. */
  env?: TarEnv;
  /** Injectable fetch, for tests that want to assert on a failing transfer
   *  without standing up a server that misbehaves in that exact way. */
  fetchImpl?: typeof fetch;
}

/**
 * Makes sure `layout.binDir` holds a complete, checksum-verified PostgreSQL
 * runtime, downloading and unpacking one if it does not.
 *
 * Throws LocalDbError with a plain-English message for every case a user can
 * actually reach: no artifact published for this platform (macOS today), the
 * download not answering, a checksum mismatch, bsdtar missing, an archive that
 * unpacks to an incomplete toolset.
 */
export async function ensureRuntime(
  layout: LocalDbLayout, options: EnsureRuntimeOptions = {},
): Promise<EnsureRuntimeResult> {
  const log = options.log ?? consoleRuntimeLogger;

  // --- the fast path ------------------------------------------------------
  const alreadyMissing = await listMissingBinaries(layout);
  if (alreadyMissing.length === 0) {
    return { action: "already-present", runtimeDir: layout.runtimeDir };
  }

  // --- is there anything to download for this platform? -------------------
  //
  // macOS today: `state: "not-built"`. The user gets that exact explanation and
  // no download is attempted, which is the whole reason manifest.ts makes
  // "we haven't built it" a distinct state instead of an empty URL.
  const artifactResult = resolveArtifact(layout.platform, options.manifest);
  if (!artifactResult.ok) {
    throw new LocalDbError("runtime-unavailable", artifactResult.message, `platform ${layout.platform}, ${artifactResult.reason}`);
  }
  const artifact = artifactResult.artifact;

  // A layout built for one version and an artifact carrying another would
  // unpack 16.10 into a directory named for 16.11 and record the wrong version
  // in instance.json — which is the field the major-version refusal depends on.
  // This can only happen if a caller hand-built a layout, i.e. it is a bug in
  // us, so it says so rather than pretending to be a user-facing problem.
  if (artifact.postgresVersion !== layout.postgresVersion) {
    throw new LocalDbError(
      "runtime-unavailable",
      "Balanced Sum is confused about which version of PostgreSQL it should be using, so it stopped rather than " +
      "install the wrong one. This is a problem with the app, not with anything you did.",
      `layout wants ${layout.postgresVersion}, manifest offers ${artifact.postgresVersion}`,
    );
  }

  const tarExe = options.tarExecutable ?? resolveTarExecutable(layout.platform, options.env ?? process.env);

  await mkdir(layout.runtimeRoot, { recursive: true });

  // Both temporary names carry this process's pid AND a uuid, so two copies of
  // the app doing their first run simultaneously cannot write to each other's
  // files. Same reasoning as backup.ts's makeTmpPath(); the in-process case is
  // not the interesting one.
  const scratchTag = `${process.pid}.${randomUUID()}`;
  const archivePath = path.join(layout.runtimeRoot, `download-${scratchTag}.tar.xz`);
  const stagingDir = path.join(layout.runtimeRoot, `incoming-${scratchTag}`);

  const started = Date.now();
  try {
    log.info(
      `Setting up PostgreSQL ${artifact.postgresVersion} — this happens once. ` +
      `Downloading about ${formatBytes(artifact.sizeBytes)}.`,
    );
    const bytes = await downloadAndVerify(artifact, archivePath, log, options.fetchImpl ?? fetch);

    log.info("Unpacking...");
    await extractArchive(tarExe, archivePath, stagingDir);

    // The zonky check, on the user's machine this time: an archive that unpacks
    // to eight of nine executables is not a runtime, and the missing one is
    // always discovered on the day it is needed. Checked BEFORE the rename, so
    // an incomplete tree never reaches the path everything else spawns from.
    const stagedMissing = missingBinaries(layout.platform, await readdirSafe(path.join(stagingDir, "bin")));
    if (stagedMissing.length > 0) {
      throw new LocalDbError(
        "runtime-incomplete",
        "The PostgreSQL download finished but it's missing pieces Balanced Sum needs, so it wasn't installed. " +
        "Check your internet connection and try again — if it keeps happening, get in touch.",
        `missing from the unpacked archive: ${stagedMissing.join(", ")}`,
      );
    }

    const adopted = await installExtractedTree(layout, stagingDir);
    if (adopted) {
      log.info("Another copy of Balanced Sum finished setting up PostgreSQL first — using that.");
      return { action: "adopted", runtimeDir: layout.runtimeDir };
    }

    const durationMs = Date.now() - started;
    log.info(`PostgreSQL ${artifact.postgresVersion} is ready (${Math.round(durationMs / 1000)}s).`);
    return { action: "downloaded", runtimeDir: layout.runtimeDir, bytes, durationMs };
  } finally {
    // Both of these are scoped to this process by scratchTag, so removing them
    // can never interfere with a concurrent first run. On the success path they
    // are already gone (the staging directory was renamed away); `force` makes
    // that a no-op rather than a second failure stacked on the first.
    await rm(archivePath, { force: true }).catch(() => {});
    await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
  }
}

/** The nine-executable check against whatever is on disk right now. A missing
 *  bin/ directory is not an error here — it is the ordinary first-run answer
 *  "all nine are missing". */
async function listMissingBinaries(layout: LocalDbLayout) {
  return missingBinaries(layout.platform, await readdirSafe(layout.binDir));
}

async function readdirSafe(dir: string): Promise<string[]> {
  return readdir(dir).catch(() => [] as string[]);
}

// ---------------------------------------------------------------------------
// download + verify
// ---------------------------------------------------------------------------

/** Progress is reported at most this often. The download is ~25 MB and the user
 *  is watching a terminal, so silence for more than a second or two reads as a
 *  hang; a line per chunk reads as noise. */
const PROGRESS_INTERVAL_MS = 1000;

/**
 * Streams `artifact.url` to `dest`, hashing as it goes, and throws unless the
 * digest matches the manifest exactly.
 *
 * Hashed IN THE STREAM rather than by re-reading the finished file: it is the
 * same 25 MB either way, and doing it in one pass means there is no window in
 * which a verified-but-not-yet-hashed file exists on disk. On any failure —
 * transport, truncation, digest — the file is deleted before the throw, so the
 * next attempt cannot find it and assume it is good.
 */
async function downloadAndVerify(
  artifact: RuntimeArtifact, dest: string, log: RuntimeLogger, fetchImpl: typeof fetch,
): Promise<number> {
  await rm(dest, { force: true }).catch(() => {});

  let response: Response;
  try {
    response = await fetchImpl(artifact.url);
  } catch (err) {
    throw new LocalDbError(
      "download-failed",
      "Balanced Sum couldn't download PostgreSQL, the database it keeps your books in. Check that this computer " +
      "is online and try again. The download comes from balancedsum.com — if you're on a company network, it may " +
      "need to be allowed through.",
      `${artifact.url}: ${(err as Error).message}`,
    );
  }

  if (!response.ok || !response.body) {
    throw new LocalDbError(
      "download-failed",
      "Balanced Sum couldn't download PostgreSQL, the database it keeps your books in — balancedsum.com answered, " +
      "but not with the file. Please try again in a few minutes, and get in touch if it keeps happening.",
      `${artifact.url}: HTTP ${response.status} ${response.statusText}`,
    );
  }

  // Content-Length counts the bytes ON THE WIRE. When the response is
  // content-encoded, those are not the bytes we receive: fetch decompresses
  // transparently, so `received` is the size of the ORIGINAL file while
  // Content-Length describes the compressed transfer. Comparing them is then
  // comparing two different numbers and calling the difference corruption.
  //
  // This is not hypothetical and it is not rare — it broke 100% of first
  // launches the day 2.0.0 was published, and only showed up because someone
  // ran the published zip against the real CDN instead of a local file server.
  // Netlify serves this .tar.xz with `Content-Encoding: br` to any client that
  // advertises brotli, which undici (Node's fetch) does by default. Measured:
  //
  //     Content-Length      25,918,026   (brotli-compressed)
  //     bytes received      25,915,316   (the actual tarball)
  //     sha256              matched perfectly
  //
  // The download was flawless, the digest was right, and the app told the user
  // their internet connection had dropped. A HEAD from a client that doesn't
  // ask for brotli returns the uncompressed 25,915,316, which is exactly why
  // hand-checking the URL never revealed it.
  const encoded = (response.headers.get("content-encoding") ?? "").trim() !== "";
  const declared = encoded ? 0 : Number(response.headers.get("content-length") ?? 0);
  // Content-Length when it describes what we will actually receive, the
  // manifest's approximate size otherwise (good enough for a percentage, and
  // never a gate — see RuntimeArtifact.sizeBytes).
  const total = declared > 0 ? declared : artifact.sizeBytes;

  const hash = createHash("sha256");
  let received = 0;
  let lastReport = Date.now();

  const source = Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]);
  source.on("data", (chunk: Buffer) => {
    hash.update(chunk);
    received += chunk.length;
    if (Date.now() - lastReport >= PROGRESS_INTERVAL_MS) {
      lastReport = Date.now();
      log.info(`  ${formatDownloadProgress(received, total)}`);
    }
  });

  try {
    await pipeline(source, createWriteStream(dest));
  } catch (err) {
    await rm(dest, { force: true }).catch(() => {});
    throw new LocalDbError(
      "download-failed",
      "The PostgreSQL download stopped part way through. Check this computer's internet connection and try again " +
      "— nothing was installed.",
      `${artifact.url}: ${(err as Error).message}`,
    );
  }

  // A transfer that ends early without erroring is a real outcome (a proxy
  // closing a connection), and it produces a file that would fail the digest
  // check anyway — but with a message about hex strings instead of the truth.
  //
  // `declared` is deliberately 0 for a content-encoded response, which turns
  // this check off rather than making it lie. Nothing is lost: the SHA-256
  // below is the actual gate and catches a truncated file with certainty. All
  // this check ever bought was a kinder message, and a kind message that fires
  // on a perfect download is worse than a blunt one that doesn't.
  if (declared > 0 && received !== declared) {
    await rm(dest, { force: true }).catch(() => {});
    throw new LocalDbError(
      "download-failed",
      "The PostgreSQL download stopped part way through. Check this computer's internet connection and try again " +
      "— nothing was installed.",
      `${artifact.url}: received ${received} bytes, Content-Length said ${declared}`,
    );
  }

  const actual = hash.digest("hex");
  if (!checksumMatches(artifact.sha256, actual)) {
    await rm(dest, { force: true }).catch(() => {});
    // This is the gate the whole download exists behind. The file is discarded
    // rather than kept "in case it's fine", and the message says outright that
    // nothing was installed, because the alternative to stopping here is running
    // an unverified binary as the database that holds someone's books.
    throw new LocalDbError(
      "checksum-mismatch",
      "The PostgreSQL download didn't arrive intact — what came down isn't the file Balanced Sum expected, so it " +
      "was thrown away and nothing was installed. This is usually a broken download or a network that modifies " +
      "traffic. Try again; if it keeps happening, get in touch before using this computer for your books.",
      `${artifact.url}: expected sha256 ${artifact.sha256}, got ${actual} (${received} bytes)`,
    );
  }

  log.info(`Downloaded ${formatBytes(received)} and verified its checksum.`);
  return received;
}

// ---------------------------------------------------------------------------
// extract + install
// ---------------------------------------------------------------------------

/** Unpacks the verified archive into an empty staging directory. The tarball is
 *  built by `npm run pg:stage` with bin/, lib/ and share/ at its root, so there
 *  is no --strip-components here; if that ever changes, the nine-executable
 *  check immediately after is what notices. */
async function extractArchive(tarExe: string, archivePath: string, stagingDir: string): Promise<void> {
  await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
  await mkdir(stagingDir, { recursive: true });

  let result: { code: number | null; output: string };
  try {
    result = await runTar(tarExe, ["-xf", archivePath, "-C", stagingDir]);
  } catch (err) {
    throw new LocalDbError(
      "extract-failed",
      "Balanced Sum downloaded PostgreSQL but couldn't unpack it — the Windows tool it uses to do that wouldn't " +
      "run. Nothing was installed.",
      `${tarExe}: ${(err as Error).message}`,
    );
  }

  if (result.code !== 0) {
    throw new LocalDbError(
      "extract-failed",
      "Balanced Sum downloaded PostgreSQL but couldn't unpack it. Nothing was installed — try again, and get in " +
      "touch if it keeps happening.",
      `${tarExe} exited ${result.code}: ${result.output.trim()}`,
    );
  }
}

/**
 * Moves the verified, complete staging tree to its final name.
 *
 * Returns true when another process got there first and we adopted its result
 * instead. That race is real and cheap to handle: two shortcuts double-clicked
 * at once, or the launcher and a CLI tool starting together. Both downloads
 * succeed independently (separate scratch names), and then one rename wins.
 * Rather than fail the loser, we re-check the directory the winner produced for
 * all nine executables — the same check we just applied to our own tree — and
 * only then call it good. If it is NOT complete, the winner is mid-flight or
 * broke, and we fall through to the error rather than adopt a partial install.
 */
async function installExtractedTree(layout: LocalDbLayout, stagingDir: string): Promise<boolean> {
  // A previous interrupted run can leave a partial runtimeDir behind. We are
  // only here because the nine-executable check on it failed, so it holds
  // nothing worth keeping and rename() cannot land on top of an existing
  // directory on Windows anyway.
  await rm(layout.runtimeDir, { recursive: true, force: true }).catch(() => {});

  try {
    await rename(stagingDir, layout.runtimeDir);
    return false;
  } catch (err) {
    const stillMissing = missingBinaries(layout.platform, await readdirSafe(layout.binDir));
    if (stillMissing.length === 0) return true;

    throw new LocalDbError(
      "extract-failed",
      "Balanced Sum unpacked PostgreSQL but couldn't move it into place. If another copy of Balanced Sum is " +
      "starting at the same time, wait for it to finish and try again.",
      `rename ${stagingDir} -> ${layout.runtimeDir}: ${(err as Error).message}`,
    );
  }
}

/** True when every required executable is present in `layout.binDir`. Exported
 *  because the launcher wants to say "PostgreSQL is installed" in a status
 *  panel without triggering a download to find out. */
export async function isRuntimeInstalled(layout: LocalDbLayout): Promise<boolean> {
  return (await listMissingBinaries(layout)).length === 0;
}

/** Bytes the installed runtime occupies, or null when it isn't installed. Used
 *  only for diagnostics; deliberately shallow (the tarball's own recorded size
 *  is not what ends up on disk, and walking 120 MB of files to print one number
 *  is not worth it). */
export async function runtimeInstalledAt(layout: LocalDbLayout): Promise<Date | null> {
  const info = await stat(layout.runtimeDir).catch(() => null);
  return info ? info.mtime : null;
}
