// scripts/app.ts — `npm run app`, originally Task 13 of
// docs/superpowers/plans/2026-07-31-local-app.md ("Packaging + docs + final
// verification"), rewritten for PG Task 3 of
// docs/todo/2026-08-08-bundled-postgres-decisions.md ("wire it in and delete
// Docker"). One command that takes a stranger from "unzipped the download" to
// "looking at the dashboard": make sure PostgreSQL is installed, create the
// cluster if this is a first run, start it, run migrations, build the web app
// if stale, start the production server, open the browser, and stay attached
// until Ctrl+C — at which point the database is shut down cleanly.
//
// THIS FILE IS THE FIRST-RUN EXPERIENCE. Everything before the browser opens is
// the only thing a curious business owner has to go on while deciding whether
// this was worth downloading, and on a first run it includes a ~25 MB download
// and an initdb they never asked for and have no reason to understand. So the
// rule here, and it outranks brevity: say what is about to happen, in English,
// BEFORE it happens, and never go quiet for forty seconds. CLAUDE.md §9,
// "Errors in English", applies just as much to the things that are going right.
//
// This is a foreground CLI, not a library — it is intentionally not part of
// any workspace's tsconfig `include` other than the root one (see
// tsconfig.json), and has no automated test of its own; it is covered by a
// manual end-to-end run instead (first run with nothing installed, second run
// with everything installed, stale dist, Ctrl+C mid-session).

import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import { Client } from "pg";

import {
  resolveCurrentLayout, ensureRuntime, ensureCluster, isRuntimeInstalled,
  start, stop, adminConnection, LocalDbError,
  type RuntimeLogger,
} from "../server/src/localdb/runtime.js";
import { redactConnectionUrl, MAINTENANCE_DATABASE } from "../server/src/localdb/instance.js";
import type { LocalDbLayout } from "../server/src/localdb/paths.js";

const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
dotenv.config({ path: path.join(rootDir, ".env"), quiet: true });

const PORT = 3000;
const HEALTH_URL = `http://127.0.0.1:${PORT}/api/health`;
const APP_URL = `http://localhost:${PORT}`;
const isWindows = process.platform === "win32";

function log(msg: string): void {
  console.log(`[app] ${msg}`);
}

/** A blank line and an unprefixed block, for the handful of moments that are
 *  addressed to a person rather than to whoever is reading a transcript: the
 *  first-run explanation and the "it's running" banner. The `[app]` prefix is
 *  right for a step-by-step log and wrong for a paragraph. */
function say(lines: string[]): void {
  console.log(`\n${lines.join("\n")}\n`);
}

/** A message that is already written for the person reading it — no stack
 *  trace, no wrapping, no "Error:" prefix. main()'s handler prints it as-is. */
class LaunchError extends Error {}

/**
 * Stops the launch with an English explanation.
 *
 * THROWS rather than calling process.exit(), which it used to do. Once this
 * script can start a database, an exit that bypasses main()'s handler is an
 * exit that leaves a postmaster running with nothing attached to it — the
 * failure most likely to hit this (a migration that won't apply) happens
 * strictly after the database is up. Every exit now goes through one path, and
 * that path shuts the database down first.
 */
function fail(msg: string): never {
  throw new LaunchError(msg);
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/** Where the database module's own progress lines go. It never calls console
 *  itself (see runtime-fetch.ts's RuntimeLogger note), so download percentages,
 *  "the database is ready in 6.3s" and the stale-lock warning all arrive here
 *  and get a prefix that says which half of the launch they came from. */
const dbLog: RuntimeLogger = {
  info: (msg) => console.log(`[database] ${msg}`),
  warn: (msg) => console.warn(`[database] ${msg}`),
};

// ---------------------------------------------------------------------------
// process helpers
// ---------------------------------------------------------------------------

/** Runs a command to completion, streaming its output straight to this
 *  console (stdio: inherit) — used for steps the user should be able to
 *  watch (migrate, build). Resolves the exit code rather than rejecting on a
 *  non-zero exit; callers decide whether that's fatal.
 *
 *  `shell: true` is required for `npm.cmd`/`npx.cmd` on Windows — Node
 *  refuses to spawn `.cmd`/`.bat` files directly since the Windows shell
 *  injection fix (CVE-2024-27980) and throws `spawn EINVAL` instead; a real
 *  `.exe` does not need it. */
function run(
  command: string, args: string[],
  opts: { cwd?: string; env?: NodeJS.ProcessEnv; shell?: boolean } = {},
): Promise<number> {
  return new Promise((resolve, reject) => {
    const child = spawn(command, args, {
      cwd: opts.cwd ?? rootDir,
      env: opts.env ?? process.env,
      stdio: "inherit",
      windowsHide: true,
      shell: opts.shell ?? false,
    });
    child.on("error", reject);
    child.on("close", (code) => resolve(code ?? 1));
  });
}

/** Runs an `npm <args>` subcommand without going through a shell. Prefers
 *  invoking the exact npm-cli.js this process was launched by — npm sets
 *  `npm_execpath` in the environment of every script it runs, and `npm run
 *  app` is the documented, only supported way to launch this script — via
 *  `node`, a real .exe taking a script-path argument: no shell, no `.cmd`
 *  wrapper, none of the spawn-EINVAL-vs-deprecation-warning trade-off that
 *  spawning `npm.cmd` directly on Windows forces (see run()'s comment).
 *  Falls back to shell:true with `npm.cmd`/`npm` only if somehow launched
 *  another way (npm_execpath unset). */
function runNpm(args: string[], env?: NodeJS.ProcessEnv): Promise<number> {
  const npmExecPath = process.env.npm_execpath;
  if (npmExecPath) {
    return run(process.execPath, [npmExecPath, ...args], { env });
  }
  const npmCli = isWindows ? "npm.cmd" : "npm";
  return run(npmCli, args, { shell: isWindows, env });
}

// ---------------------------------------------------------------------------
// Step 1: the database
//
// Two mutually exclusive modes, decided by one question: has someone set
// DATABASE_URL?
//
//   external  A developer pointed this repo at their own PostgreSQL (.env, or
//             the environment). We use it and touch nothing else — critically,
//             we do NOT download a 25 MB runtime and initdb a cluster that
//             would then sit there unused. Environment variables winning is the
//             rule everywhere else in this milestone (server/src/db.ts,
//             server/src/backup.ts); it has to mean something here too, or the
//             one command that a developer actually runs is the one that
//             ignores their configuration.
//   bundled   Everybody else, which is every real install. This is the path
//             that has to be pleasant.
// ---------------------------------------------------------------------------

/** The database the launcher, migrations and the server will all use. */
interface LaunchDatabase {
  /** Superuser connection to the application database. Carries the generated
   *  password on the bundled path — only ever passed to a child process's
   *  environment, never printed. Print `redacted`. */
  adminUrl: string;
  redacted: string;
}

function externalDatabaseUrl(): string | undefined {
  const value = process.env.DATABASE_URL?.trim();
  return value ? value : undefined;
}

async function prepareDatabase(): Promise<LaunchDatabase> {
  const external = externalDatabaseUrl();
  if (external) {
    const redacted = redactConnectionUrl(external);
    log(`Using the database you configured in DATABASE_URL (${redacted}).`);
    log("Balanced Sum's own bundled PostgreSQL is not being started — unset DATABASE_URL to use it instead.");
    return { adminUrl: external, redacted };
  }

  // resolveCurrentLayout() throws LocalDbError for an OS we publish nothing
  // for and for a Windows session that won't say where application data goes.
  // Both already carry complete English sentences; main()'s handler prints
  // them verbatim rather than wrapping them in a second explanation.
  const layout = resolveCurrentLayout();

  // Everything below announces itself BEFORE doing the slow thing. The two
  // checks that decide what to announce are cheap — one readdir and one
  // existsSync — and are worth paying so that nobody watches a silent terminal
  // wondering whether a 25 MB download has started.
  const runtimeReady = await isRuntimeInstalled(layout);
  const clusterExists = existsSync(layout.dataDir);

  if (!runtimeReady || !clusterExists) {
    say([
      "  Setting up Balanced Sum on this computer. This happens once.",
      "",
      "  Your books are kept in PostgreSQL, a database Balanced Sum installs for",
      "  you rather than asking you to install yourself.",
      ...(runtimeReady ? [] : [
        "",
        "    * Downloading it — about 25 MB, from balancedsum.com. You'll see the",
        "      progress below.",
      ]),
      ...(clusterExists ? [] : [
        "",
        "    * Creating an empty set of books for it to hold. A few seconds.",
      ]),
      "",
      "  Nothing is installed system-wide, and nothing else on this computer is",
      "  changed. It all goes in one folder:",
      `    ${layout.root}`,
      "  That folder is also where your books will live, so deleting it is both the",
      "  whole uninstall and the end of your accounting records.",
    ]);
  }

  await ensureRuntime(layout, { log: dbLog });
  await ensureCluster(layout, { log: dbLog });

  const started = await start(layout, { log: dbLog });
  // Recorded HERE rather than returned to main(), and recorded the instant the
  // postmaster is up: everything after this line can throw, and from this line
  // on there is a live database that this process is responsible for shutting
  // down. main()'s error handler reads this variable, so an adminConnection()
  // that fails must not be able to leave a server running with nothing
  // attached to it.
  //
  // Ours to stop only if we were the one that started it. If it was already
  // running, another copy of the launcher owns it and is responsible for
  // shutting it down; killing it out from under that copy would take its
  // server down with it.
  if (started.action === "started") {
    databaseToStop = layout;
  } else {
    log("The database was already running — leaving it to whoever started it when this window closes.");
  }

  const conn = await adminConnection(layout);
  return { adminUrl: conn.url, redacted: conn.redactedUrl };
}

// ---------------------------------------------------------------------------
// Step 2: the application database exists
//
// docker-compose.yml used to do this for free: POSTGRES_DB=accounting made the
// database on the container's first boot. initdb does not — it creates
// `postgres`, `template0` and `template1` and nothing else — so somebody has to
// issue the CREATE DATABASE, and the launcher is the only thing that runs
// before migrations on every path. Idempotent, so it costs one query on every
// launch after the first.
// ---------------------------------------------------------------------------

async function ensureApplicationDatabase(db: LaunchDatabase): Promise<void> {
  const parsed = new URL(db.adminUrl);
  const dbName = decodeURIComponent(parsed.pathname.replace(/^\//, ""));
  if (!dbName) {
    fail(`The database address is missing a database name (${db.redacted}). Check DATABASE_URL.`);
  }

  // Connect to the maintenance database instead: you cannot CREATE DATABASE
  // from inside the database you are creating, and `postgres` is the one that
  // always exists (localdb/instance.ts's MAINTENANCE_DATABASE).
  const maintenance = new URL(db.adminUrl);
  maintenance.pathname = `/${MAINTENANCE_DATABASE}`;

  const client = new Client({ connectionString: maintenance.toString() });
  try {
    await client.connect();
  } catch (err) {
    fail(
      `Couldn't connect to the database at ${db.redacted} — ${(err as Error).message}. ` +
      "If you set DATABASE_URL yourself, check that PostgreSQL is running and that the address is right.",
    );
  }
  try {
    const found = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
    if (found.rowCount) return;

    // CREATE DATABASE takes no parameters, so the name is quoted as an
    // identifier. It comes from a URL a developer may have written by hand,
    // which is exactly the input that deserves a whitelist rather than trust.
    // Checked BEFORE the log line, so a refusal is never preceded by an
    // announcement that we were about to do it.
    if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(dbName)) {
      fail(
        `Refusing to create a database called "${dbName}" — that name has characters in it that would have to be ` +
        "escaped into a SQL statement. Create it yourself, or use a plain name.",
      );
    }
    log(`Creating the "${dbName}" database (first run).`);
    await client.query(`CREATE DATABASE "${dbName}"`);
  } finally {
    await client.end().catch(() => {
      // Best effort; the connection is being discarded either way.
    });
  }
}

// ---------------------------------------------------------------------------
// Step 3: migrate (idempotent — safe to run every launch)
// ---------------------------------------------------------------------------

async function runMigrations(db: LaunchDatabase): Promise<void> {
  log("Bringing the database up to date (npm run migrate)...");
  // db/migrate.ts requires DATABASE_URL and reads nothing else. On the bundled
  // path that variable does not exist in this process's environment — the
  // connection was resolved from instance.json and the 0600 secret file — so it
  // is handed to the child explicitly. This is the only place the generated
  // password crosses a process boundary, and it goes into an environment block,
  // never onto a command line where the process list would show it.
  const code = await runNpm(["run", "migrate"], { ...process.env, DATABASE_URL: db.adminUrl });
  if (code !== 0) {
    fail("Migrations failed — see the output above.");
  }
}

// ---------------------------------------------------------------------------
// Step 4: build web if stale
// ---------------------------------------------------------------------------

async function newestMtimeUnder(dir: string): Promise<number> {
  let newest = 0;
  async function walk(current: string): Promise<void> {
    const entries = await readdir(current, { withFileTypes: true });
    for (const entry of entries) {
      const full = path.join(current, entry.name);
      if (entry.isDirectory()) {
        await walk(full);
      } else if (entry.isFile()) {
        const s = await stat(full);
        if (s.mtimeMs > newest) newest = s.mtimeMs;
      }
    }
  }
  await walk(dir);
  return newest;
}

async function ensureWebBuilt(): Promise<void> {
  const distIndex = path.join(rootDir, "web", "dist", "index.html");
  const srcDir = path.join(rootDir, "web", "src");

  let stale = true;
  if (existsSync(distIndex)) {
    const distMtime = (await stat(distIndex)).mtimeMs;
    const srcMtime = await newestMtimeUnder(srcDir);
    stale = srcMtime > distMtime;
  }

  if (!stale) {
    log("web/dist is up to date — skipping build.");
    return;
  }

  log("Building the web app (npm run build -w web)...");
  const code = await runNpm(["run", "build", "-w", "web"]);
  if (code !== 0) {
    fail("Building the web app failed — see the output above.");
  }
}

// ---------------------------------------------------------------------------
// Step 5: start the server, wait for health, open the browser
// ---------------------------------------------------------------------------

async function waitForHealth(timeoutMs: number): Promise<boolean> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    try {
      const res = await fetch(HEALTH_URL);
      if (res.ok) {
        const body = (await res.json()) as { ok?: boolean };
        if (body.ok) return true;
      }
    } catch {
      // Server not listening yet — keep polling.
    }
    await sleep(500);
  }
  return false;
}

function openBrowser(url: string): void {
  // PowerShell's `start` / cmd's `start` both need a shell to interpret;
  // `cmd /c start "" <url>` is the version that also behaves when the URL
  // itself could be mistaken for a window title (the empty "" first arg).
  try {
    spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", windowsHide: true, detached: true }).unref();
  } catch (err) {
    log(`Couldn't open the browser automatically (${(err as Error).message}). Open ${url} yourself.`);
  }
}

async function startServerAndWait(): Promise<ChildProcess> {
  log("Starting the server (production mode, port 3000)...");
  // Invoke tsx's own CLI entry point directly with `node`, rather than
  // `npx tsx ...` — that sidesteps two Windows headaches at once: `npx.cmd`
  // needs shell:true (see run()'s comment) which wraps the server in an
  // extra cmd.exe layer, and killing that wrapper does not reliably kill
  // the real Node process underneath it (so Ctrl+C could leave an orphaned
  // server on port 3000). `node <tsx cli.mjs> <entry>` is a real .exe
  // spawning a plain script argument — no shell, no wrapper, one process to
  // kill and it's the right one.
  //
  // The database connection is NOT passed down. The server resolves it for
  // itself through server/src/db.ts, by exactly the rules this launcher used
  // (DATABASE_URL first, then the bundled instance), so it reaches the same
  // answer — and the generated superuser password stays in one child's
  // environment (migrate's) instead of two.
  const tsxCli = path.join(rootDir, "node_modules", "tsx", "dist", "cli.mjs");
  const child = spawn(process.execPath, [tsxCli, "server/src/index.ts"], {
    cwd: rootDir,
    env: { ...process.env, NODE_ENV: "production" },
    stdio: "inherit",
    windowsHide: true,
  });

  let exitedEarly = false;
  let earlyExitCode: number | null = null;
  child.on("exit", (code) => {
    exitedEarly = true;
    earlyExitCode = code;
  });

  const healthy = await waitForHealth(30_000);
  if (exitedEarly) {
    fail(`The server exited before it came up (code ${earlyExitCode ?? "unknown"}). See the output above.`);
  }
  if (!healthy) {
    child.kill();
    fail(`The server didn't answer ${HEALTH_URL} within 30 seconds. See the output above for errors.`);
  }

  log("Server is healthy.");
  return child;
}

// ---------------------------------------------------------------------------
// shutting the database down
//
// REACHED FROM THREE DIRECTIONS, which is why it is a memoized promise and not
// a function that calls stop():
//
//   1. the normal path — the server child exited, main() falls through;
//   2. Ctrl+C — the console signal reaches the whole process group, the server
//      child dies, and (1) happens a moment later;
//   3. main() throwing after the database was already started (a failed
//      migration is the realistic one).
//
// (2) and (3) can overlap, and pg_ctl stop run twice concurrently is a second
// invocation that finds no server and reports failure — noise at exactly the
// moment the user is trying to read why something went wrong. Awaiting the same
// promise makes the second and third callers wait for the first one's answer
// instead of racing it.
// ---------------------------------------------------------------------------

let databaseToStop: LocalDbLayout | null = null;
let stopPromise: Promise<void> | null = null;

function stopDatabaseOnce(): Promise<void> {
  const layout = databaseToStop;
  if (!layout) return Promise.resolve();
  if (!stopPromise) {
    stopPromise = stop(layout, { log: dbLog })
      .then(() => undefined)
      .catch((err: unknown) => {
        // Never rethrown. This runs while the process is on its way out, and a
        // failure to stop is not a reason to replace the exit code (or the
        // error) the user actually needs to see. stop() already explains, in
        // English, that leaving it running is safe.
        const message = err instanceof LocalDbError ? err.message : (err as Error).message;
        console.error(`\n[database] ${message}\n`);
      });
  }
  return stopPromise;
}

// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------

async function main(): Promise<void> {
  const db = await prepareDatabase();
  await ensureApplicationDatabase(db);
  await runMigrations(db);
  await ensureWebBuilt();
  const server = await startServerAndWait();

  openBrowser(APP_URL);
  say([
    `  Balanced Sum is running at ${APP_URL}`,
    "  Press Ctrl+C in this window to close it and shut the database down.",
  ]);

  // Best-effort SIGINT forwarding: on Windows, a console Ctrl+C is usually
  // already delivered to every process attached to the same console (child
  // included, since it inherits stdio), so this is a safety net for the
  // cases it isn't (e.g. a detached child, or a signal delivered only to
  // the parent) rather than the primary mechanism.
  //
  // It deliberately does NOT stop the database or exit the process itself.
  // Killing the server is enough: its exit resolves the promise below, and the
  // single shutdown sequence at the bottom of this function runs for a Ctrl+C
  // exactly as it does for a server that quit on its own. Two exit paths is how
  // one of them ends up skipping the database shutdown.
  const stopServer = () => {
    try {
      server.kill("SIGINT");
    } catch {
      // Already gone — nothing to do.
    }
  };
  process.on("SIGINT", stopServer);
  process.on("SIGTERM", stopServer);

  const exitCode = await new Promise<number>((resolve) => {
    server.on("exit", (code) => resolve(code ?? 0));
  });

  await stopDatabaseOnce();
  process.exit(exitCode);
}

main().catch(async (err: unknown) => {
  // The database may already be up — a failed migration or a failed web build
  // leaves a perfectly healthy postmaster running with nothing attached to it.
  // Shut it down before reporting, so a failed launch does not leave a process
  // behind that the user has no way to find.
  await stopDatabaseOnce();

  // LocalDbError's `message` is written for the person reading it, and its
  // `detail` is written for whoever they send it to. Both, in that order,
  // rather than a stack trace. LaunchError is this file's own already-English
  // message. Anything else is a genuine surprise and gets printed raw.
  if (err instanceof LocalDbError) {
    console.error(`\n[app] ${err.message}\n`);
    if (err.detail) console.error(`[app] (details: ${err.detail})\n`);
  } else if (err instanceof LaunchError) {
    console.error(`\n[app] ${err.message}\n`);
  } else {
    console.error(`\n[app] ${(err as Error).message ?? String(err)}\n`);
  }
  process.exit(1);
});
