// instance.json — the record of the cluster this machine created, and the
// connection strings derived from it. PG Task 1, implementing the `instance.json`
// line of the layout in docs/todo/2026-08-08-bundled-postgres-decisions.md
// ("port, versions, created") and its "everything derives the connection string
// from there; nothing hardcodes it twice" rule.
//
// This is the most safety-critical file in the localdb module, because this
// document is what tells a later run of the app where the user's books are. So:
//
//  * The validator is ALL-OR-NOTHING. A file that is half-written (a power cut
//    between open() and the final flush), hand-edited, or written by a build
//    that stored something different reads as "no usable instance", never as a
//    document with some fields filled in and the rest defaulted. Defaulting a
//    missing port to 0 would produce a connection string that fails with a
//    socket error nobody can trace back to a truncated JSON file.
//  * "No usable instance" is reported as TWO reasons, `absent` and `malformed`,
//    not one. The task brief asks that a partially-written file "read as
//    absent"; it means "must not read as port 0", and that is satisfied either
//    way. Collapsing them, though, would let a CORRUPTED instance.json beside a
//    real data directory look exactly like a first run — and the first-run path
//    ends in `initdb`. Keeping the reasons apart is what lets the caller stop
//    and say "something is wrong with your setup" instead of quietly creating a
//    second, empty set of books. More information, never less.
//  * Connection URLs are built by URL-ENCODING each component, never by
//    concatenating a password into a template. The superuser password is
//    generated from a random alphabet (secret.ts); the moment anyone widens
//    that alphabet, or a future app-role password comes from somewhere else, a
//    concatenated `@` or `:` silently reinterprets the whole URL as a different
//    host. Encoding at the boundary means that can never be a live question.
//
// PURE: takes the file's TEXT, returns a value. Reading and writing the file is
// PG Task 2's job.

import { type PlatformId, isPlatformId } from "./platform.js";
import { parsePostgresMajor } from "./version.js";

/** Bumped only when the shape changes incompatibly. An unrecognised
 *  formatVersion is `malformed`, not "try to read it anyway" — see the
 *  newer-cluster reasoning in version.ts; this is the same hazard one level up. */
export const INSTANCE_FORMAT_VERSION = 1;

/** 54329, not 5432. A developer's own PostgreSQL must not collide with ours,
 *  and — just as important — we must not collide with THEIRS: binding 5432
 *  first would break their unrelated work with an error that points at their
 *  software, not ours. Recorded in instance.json so the port is a fact about
 *  this install rather than a constant repeated in three places. */
export const DEFAULT_PORT = 54329;

/** Loopback only. `listen_addresses=127.0.0.1` and no Unix socket directory is
 *  the whole security posture of a local-first product: nothing outside this
 *  machine can reach the database, so there is no network surface to secure. */
export const LOOPBACK_HOST = "127.0.0.1";

/** The superuser role initdb creates. */
export const SUPERUSER_ROLE = "postgres";
/** The RLS-bound role every request goes through (server/src/db.ts). */
export const APP_ROLE = "app";
/** The application database. */
export const APP_DATABASE = "accounting";
/** The database you connect to in order to CREATE the application database —
 *  it always exists and is never the one being created. */
export const MAINTENANCE_DATABASE = "postgres";

export interface InstanceDocument {
  formatVersion: number;
  /** Full version of the runtime that created the cluster, e.g. "16.10". */
  postgresVersion: string;
  /** Major of the cluster on disk, e.g. 16. Stored explicitly rather than
   *  re-derived on read: it is the field the version check depends on, and a
   *  value written once at initdb time is a stronger record of what actually
   *  happened than a value recomputed later from a string. */
  postgresMajor: number;
  /** Which runtime artifact was unpacked. Catches the case where a data
   *  directory is carried between machines (a restored user profile, a synced
   *  folder) onto a platform whose runtime cannot read it. */
  platform: PlatformId;
  port: number;
  /** ISO 8601. Diagnostics and support only — nothing branches on it. */
  createdAt: string;
  /** The Balanced Sum version that ran initdb. Diagnostics only, for the same
   *  reason: when someone reports a broken cluster, "created by 1.0.3" is the
   *  first useful question answered. */
  createdByAppVersion: string;
}

export type InstanceReadResult =
  | { ok: true; instance: InstanceDocument }
  | {
      ok: false;
      /** `absent` — there is no file (or it is empty). A genuine first run,
       *  ASSUMING the caller has also confirmed the data directory isn't there.
       *  `malformed` — there IS a file and it cannot be trusted. Never a first
       *  run; stop and tell the user. */
      reason: "absent" | "malformed";
      message: string;
      /** What specifically failed, for the log — never shown to the user. */
      detail?: string;
    };

/** Builds the document that gets written immediately after a successful initdb.
 *  Pure so the exact bytes about to be written can be asserted in a test rather
 *  than inspected on a real machine after the fact. */
export function buildInstanceDocument(input: {
  postgresVersion: string;
  platform: PlatformId;
  port: number;
  createdAt: Date;
  appVersion: string;
}): InstanceDocument {
  const major = parsePostgresMajor(input.postgresVersion);
  if (major === null) {
    // Not a user-facing error: reaching here means the caller passed a version
    // string that resolveLayout() would already have rejected, i.e. a bug.
    throw new Error(`buildInstanceDocument: can't read a major version out of "${input.postgresVersion}"`);
  }
  return {
    formatVersion: INSTANCE_FORMAT_VERSION,
    postgresVersion: input.postgresVersion,
    postgresMajor: major,
    platform: input.platform,
    port: input.port,
    createdAt: input.createdAt.toISOString(),
    createdByAppVersion: input.appVersion,
  };
}

/** The exact text to write. Pretty-printed with a trailing newline because a
 *  human WILL open this file when something goes wrong, and it is small enough
 *  that compactness buys nothing. */
export function serializeInstanceDocument(doc: InstanceDocument): string {
  return `${JSON.stringify(doc, null, 2)}\n`;
}

function isNonEmptyString(v: unknown): v is string {
  return typeof v === "string" && v.trim() !== "";
}

/** A port must be an integer in the usable range. 0 is rejected explicitly and
 *  by name: it is the value a truncated or partially-defaulted read produces,
 *  and to the operating system it means "assign me any free port" — the one
 *  answer that is worse than an error, because it succeeds. */
function isValidPort(v: unknown): v is number {
  return typeof v === "number" && Number.isInteger(v) && v > 0 && v <= 65535;
}

/**
 * Parses and validates the contents of instance.json.
 *
 * Pass `null`/`undefined` for "the file does not exist" — the caller does the
 * stat, this function does the judging, and neither has to know about the
 * other's failure modes.
 *
 * Unknown extra properties are ALLOWED through (ignored). A newer build may add
 * a field; rejecting the file for that would turn "you're running an older
 * version" into "your books are corrupt", which is both false and terrifying.
 * The version mismatch is version.ts's job to report, and it reports it well.
 */
export function parseInstanceDocument(raw: string | null | undefined): InstanceReadResult {
  const absent: InstanceReadResult = {
    ok: false,
    reason: "absent",
    message: "No database has been set up on this computer yet.",
  };

  if (raw === null || raw === undefined || raw.trim() === "") return absent;

  const malformed = (detail: string): InstanceReadResult => ({
    ok: false,
    reason: "malformed",
    message:
      "Balanced Sum found a setup file for your database on this computer, but it couldn't read it. Your books " +
      "themselves haven't been touched. Restore from a backup, or get in touch — don't reinstall over the top, " +
      "because that could create a second, empty set of books alongside the real ones.",
    detail,
  });

  let parsed: unknown;
  try {
    parsed = JSON.parse(raw);
  } catch (err) {
    return malformed(`instance.json is not valid JSON: ${(err as Error).message}`);
  }

  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
    return malformed("instance.json is not a JSON object");
  }
  const o = parsed as Record<string, unknown>;

  if (o.formatVersion !== INSTANCE_FORMAT_VERSION) {
    return malformed(`instance.json formatVersion is ${String(o.formatVersion)}, expected ${INSTANCE_FORMAT_VERSION}`);
  }
  if (!isNonEmptyString(o.postgresVersion)) {
    return malformed("instance.json has no usable postgresVersion");
  }
  const derivedMajor = parsePostgresMajor(o.postgresVersion);
  if (derivedMajor === null) {
    return malformed(`instance.json postgresVersion "${o.postgresVersion}" has no major version in it`);
  }
  // The two version fields must agree. They can only disagree if the file was
  // hand-edited or written by something that isn't us, and in either case the
  // question "which one decides whether this cluster is readable" has no safe
  // answer — so refuse rather than pick.
  if (o.postgresMajor !== derivedMajor) {
    return malformed(
      `instance.json postgresMajor (${String(o.postgresMajor)}) disagrees with postgresVersion ` +
      `("${o.postgresVersion}" implies ${derivedMajor})`,
    );
  }
  if (!isPlatformId(o.platform)) {
    return malformed(`instance.json platform "${String(o.platform)}" isn't one we recognise`);
  }
  if (!isValidPort(o.port)) {
    return malformed(`instance.json port ${String(o.port)} isn't a usable port number`);
  }
  if (!isNonEmptyString(o.createdAt) || Number.isNaN(Date.parse(o.createdAt))) {
    return malformed(`instance.json createdAt "${String(o.createdAt)}" isn't a readable date`);
  }
  if (!isNonEmptyString(o.createdByAppVersion)) {
    return malformed("instance.json has no usable createdByAppVersion");
  }

  return {
    ok: true,
    instance: {
      formatVersion: INSTANCE_FORMAT_VERSION,
      postgresVersion: o.postgresVersion,
      postgresMajor: derivedMajor,
      platform: o.platform,
      port: o.port,
      createdAt: o.createdAt,
      createdByAppVersion: o.createdByAppVersion,
    },
  };
}

// ---------------------------------------------------------------------------
// connection URLs
// ---------------------------------------------------------------------------

export interface ConnectionParts {
  user: string;
  password: string;
  host: string;
  port: number;
  database: string;
}

/**
 * Builds a `postgres://` URL with every user-supplied component percent-encoded
 * individually.
 *
 * Not `new URL()` with `.password = ...`: the WHATWG URL parser normalises,
 * re-encodes, and in some cases silently accepts input that changes what the
 * URL means, and reading a password back out of it is not round-trip safe.
 * Not a template literal either — see the file header. encodeURIComponent is
 * conservative (it escapes everything outside the unreserved set plus a handful
 * of sub-delimiters, all of which are legal in userinfo and a path segment), so
 * the output is always a valid URL and always decodes back to the exact input.
 */
export function buildConnectionUrl(parts: ConnectionParts): string {
  const user = encodeURIComponent(parts.user);
  const password = encodeURIComponent(parts.password);
  const database = encodeURIComponent(parts.database);
  return `postgres://${user}:${password}@${parts.host}:${parts.port}/${database}`;
}

/** The superuser connection — migrations, company creation, pg_dump. Defaults
 *  to the application database; pass MAINTENANCE_DATABASE when the application
 *  database is the thing being created. */
export function adminConnectionUrl(
  instance: InstanceDocument, password: string, database: string = APP_DATABASE,
): string {
  return buildConnectionUrl({
    user: SUPERUSER_ROLE, password, host: LOOPBACK_HOST, port: instance.port, database,
  });
}

/** The RLS-bound `app` role connection — every request that touches
 *  tenant-scoped data (CLAUDE.md §4, server/src/db.ts's appPool). */
export function appConnectionUrl(
  instance: InstanceDocument, password: string, database: string = APP_DATABASE,
): string {
  return buildConnectionUrl({
    user: APP_ROLE, password, host: LOOPBACK_HOST, port: instance.port, database,
  });
}

/** Replaces the password in a `postgres://` URL with `***` so a connection
 *  string can appear in a log line or an error message. The generated superuser
 *  password must never be written anywhere except its 0600 file — not in .env,
 *  not in git, and above all not in a log a user will paste into a support
 *  email. Anything that doesn't parse as a URL with userinfo comes back
 *  unchanged, which is safe here because a string with no `user:pass@` has no
 *  password in it to leak. */
export function redactConnectionUrl(url: string): string {
  return url.replace(/^([a-z][a-z0-9+.-]*:\/\/[^:/?#@]*):[^@]*@/i, "$1:***@");
}
