// Unit tests for server/src/localdb/runtime-facts.ts (PG Task 2) — the
// judgement calls the runtime driver makes, with the world removed.
//
// Nothing here spawns a process, opens a socket or touches a disk. That is the
// point of the module and it is what lets this file walk the whole
// (lock file) x (process) x (port) grid, including the combinations you cannot
// produce on demand without actually cutting the power to a machine mid-write.

import { describe, it, expect } from "vitest";

import {
  REQUIRED_BINARIES,
  READY_TIMEOUT_MS,
  READY_POLL_INTERVAL_MS,
  binaryFileName,
  missingBinaries,
  parsePostmasterPid,
  classifyProcessOwner,
  judgePostmasterLock,
  parseTasklistImageName,
  parsePsCommand,
  planCluster,
  formatBytes,
  formatDownloadProgress,
  summariseLogTail,
  LocalDbError,
  type PostmasterPidResult,
} from "../../server/src/localdb/runtime-facts.js";
import {
  buildInstanceDocument, parseInstanceDocument, serializeInstanceDocument,
  type InstanceReadResult,
} from "../../server/src/localdb/instance.js";

// ---------------------------------------------------------------------------
// the toolset check
// ---------------------------------------------------------------------------

describe("REQUIRED_BINARIES", () => {
  it("names all nine tools, including the three zonky.io's build was missing", () => {
    expect(REQUIRED_BINARIES).toHaveLength(9);
    // The reason the whole EnterpriseDB route was chosen. backup.ts runs
    // pg_dump on every launch and RESTORE.md is entirely about pg_restore; a
    // runtime without them is not a runtime, it is a time bomb.
    expect(REQUIRED_BINARIES).toContain("pg_dump");
    expect(REQUIRED_BINARIES).toContain("pg_restore");
    expect(REQUIRED_BINARIES).toContain("pg_dumpall");
    // And the four the driver itself spawns.
    expect(REQUIRED_BINARIES).toContain("postgres");
    expect(REQUIRED_BINARIES).toContain("initdb");
    expect(REQUIRED_BINARIES).toContain("pg_ctl");
    expect(REQUIRED_BINARIES).toContain("pg_isready");
  });
});

describe("binaryFileName", () => {
  it("appends .exe on Windows and nothing on macOS", () => {
    expect(binaryFileName("win32-x64", "postgres")).toBe("postgres.exe");
    expect(binaryFileName("darwin-arm64", "postgres")).toBe("postgres");
    expect(binaryFileName("darwin-x64", "pg_isready")).toBe("pg_isready");
  });
});

describe("missingBinaries", () => {
  const windowsComplete = REQUIRED_BINARIES.map((b) => `${b}.exe`);

  it("reports every binary missing for an empty (or absent) directory", () => {
    expect(missingBinaries("win32-x64", [])).toHaveLength(9);
  });

  it("reports nothing missing for a complete tree — the every-launch fast path", () => {
    expect(missingBinaries("win32-x64", windowsComplete)).toEqual([]);
    expect(missingBinaries("darwin-arm64", [...REQUIRED_BINARIES])).toEqual([]);
  });

  it("ignores the extra files a real bin/ is full of", () => {
    // The real EnterpriseDB bin/ carries ~60 files: DLLs, createdb, ecpg…
    expect(missingBinaries("win32-x64", [
      ...windowsComplete, "libcrypto-3-x64.dll", "createdb.exe", "icuuc67.dll",
    ])).toEqual([]);
  });

  it("names exactly what is absent, not just 'something'", () => {
    const partial = windowsComplete.filter((f) => f !== "pg_dump.exe" && f !== "psql.exe");
    expect(missingBinaries("win32-x64", partial).sort()).toEqual(["pg_dump", "psql"]);
  });

  it("is case-insensitive on Windows and case-SENSITIVE on macOS", () => {
    const shouted = windowsComplete.map((f) => f.toUpperCase());
    // NTFS is case-insensitive, so POSTGRES.EXE genuinely is the file.
    expect(missingBinaries("win32-x64", shouted)).toEqual([]);
    // A case-sensitive filesystem genuinely does not have it, and claiming
    // otherwise would turn "present" into an ENOENT at spawn time.
    expect(missingBinaries("darwin-arm64", REQUIRED_BINARIES.map((b) => b.toUpperCase())))
      .toHaveLength(9);
  });
});

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

// A real one, byte for byte, off the cluster started during the 2026-08-08
// verification run. Line 1 is the pid, line 4 is the port.
const REAL_PID_FILE = [
  "38960",
  "C:/Users/adama/AppData/Local/Temp/bsum-e2e-EHIpsg/BalancedSum/data/pg16",
  "1786243743",
  "63721",
  "",
  "127.0.0.1",
  "  4242001         42",
  "ready   ",
  "",
].join("\n");

describe("parsePostmasterPid", () => {
  it("reads the pid off line 1 and the port off line 4 of a real file", () => {
    const result = parsePostmasterPid(REAL_PID_FILE);
    expect(result.ok).toBe(true);
    if (!result.ok) return;
    expect(result.info.pid).toBe(38960);
    expect(result.info.port).toBe(63721);
    expect(result.info.dataDir).toContain("data/pg16");
    expect(result.info.startTimeEpochSeconds).toBe(1786243743);
    expect(result.info.listenAddress).toBe("127.0.0.1");
    expect(result.info.status).toBe("ready");
  });

  it("treats no file and an empty file as 'absent', never as pid 0", () => {
    expect(parsePostmasterPid(null)).toEqual({ ok: false, reason: "absent" });
    expect(parsePostmasterPid(undefined)).toEqual({ ok: false, reason: "absent" });
    expect(parsePostmasterPid("   \n\n ")).toEqual({ ok: false, reason: "absent" });
  });

  it("tolerates CRLF — a lock file that has been through a text-mode tool", () => {
    const result = parsePostmasterPid(REAL_PID_FILE.replace(/\n/g, "\r\n"));
    expect(result.ok).toBe(true);
    if (!result.ok) return;
    expect(result.info.pid).toBe(38960);
    expect(result.info.port).toBe(63721);
  });

  it("calls a truncated file malformed rather than guessing at it", () => {
    // The power-cut shape: the pid line made it, the rest did not.
    const result = parsePostmasterPid("38960\nC:/data/pg16\n");
    expect(result.ok).toBe(false);
    if (result.ok) return;
    expect(result.reason).toBe("malformed");
  });

  it("rejects a non-numeric pid and an out-of-range port", () => {
    expect(parsePostmasterPid("not-a-pid\nd\n1\n5432\n").ok).toBe(false);
    expect(parsePostmasterPid("1\nd\n1\n99999\n").ok).toBe(false);
    expect(parsePostmasterPid("0\nd\n1\n5432\n").ok).toBe(false);
    expect(parsePostmasterPid("-5\nd\n1\n5432\n").ok).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// who owns the pid
// ---------------------------------------------------------------------------

describe("classifyProcessOwner", () => {
  it("recognises postgres by any of the names the OS tools report", () => {
    expect(classifyProcessOwner(true, "postgres.exe")).toBe("postgres");
    expect(classifyProcessOwner(true, "postgres")).toBe("postgres");
    expect(classifyProcessOwner(true, "/opt/pg/bin/postgres")).toBe("postgres");
    expect(classifyProcessOwner(true, "C:\\x\\bin\\postgres.exe")).toBe("postgres");
  });

  it("calls a recycled pid what it is — a live process that is not our server", () => {
    expect(classifyProcessOwner(true, "chrome.exe")).toBe("other-process");
    // Close, but not the server. Matching too loosely here would classify a
    // stranger as our postmaster and refuse to start forever.
    expect(classifyProcessOwner(true, "pgAdmin4.exe")).toBe("other-process");
  });

  it("says 'unknown' when the OS won't name the process, never 'none'", () => {
    // "none" is permission to delete a lock file. An unnameable process must
    // never buy that permission.
    expect(classifyProcessOwner(true, null)).toBe("unknown");
    expect(classifyProcessOwner(false, null)).toBe("none");
  });
});

describe("parseTasklistImageName", () => {
  it("pulls the image name out of tasklist's CSV", () => {
    expect(parseTasklistImageName('"postgres.exe","38960","Console","1","24,180 K"'))
      .toBe("postgres.exe");
  });

  it("returns null for tasklist's no-match prose, which is NOT an image name", () => {
    // If this ever returned the sentence, classifyProcessOwner would call it
    // "other-process" — a stale verdict — and a running database's lock file
    // would be deleted out from under it.
    expect(parseTasklistImageName("INFO: No tasks are running which match the specified criteria."))
      .toBeNull();
    expect(parseTasklistImageName("")).toBeNull();
  });
});

describe("parsePsCommand", () => {
  it("reads the bare command line ps prints, and null for nothing", () => {
    expect(parsePsCommand("/usr/local/pg/bin/postgres\n")).toBe("/usr/local/pg/bin/postgres");
    expect(parsePsCommand("\n \n")).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// the stale-lock grid
// ---------------------------------------------------------------------------

describe("judgePostmasterLock", () => {
  const live: PostmasterPidResult = parsePostmasterPid(REAL_PID_FILE);
  const absent: PostmasterPidResult = { ok: false, reason: "absent" };
  const truncated: PostmasterPidResult = parsePostmasterPid("38960\nC:/data\n");

  it("no lock file and a silent port: start freely", () => {
    expect(judgePostmasterLock({ pidFile: absent, owner: "unknown", portAnswering: false }))
      .toEqual({ state: "no-lock" });
  });

  it("no lock file but SOMETHING on the port: stop — that is not our cluster", () => {
    const verdict = judgePostmasterLock({ pidFile: absent, owner: "unknown", portAnswering: true });
    expect(verdict.state).toBe("running");
  });

  it("a live postgres owns it: running, even before it accepts connections", () => {
    // A server replaying WAL after a crash rejects connections for as long as
    // the replay takes. It is very much running.
    expect(judgePostmasterLock({ pidFile: live, owner: "postgres", portAnswering: false }))
      .toEqual({ state: "running", pid: 38960, port: 63721 });
    expect(judgePostmasterLock({ pidFile: live, owner: "postgres", portAnswering: true }))
      .toEqual({ state: "running", pid: 38960, port: 63721 });
  });

  it("the pid is gone and the port is silent: stale, and says so in English", () => {
    const verdict = judgePostmasterLock({ pidFile: live, owner: "none", portAnswering: false });
    expect(verdict.state).toBe("stale");
    if (verdict.state !== "stale") return;
    expect(verdict.pid).toBe(38960);
    expect(verdict.why).toMatch(/leftover/i);
    // Never asks a business owner to go and delete a file they've never heard of.
    expect(verdict.why).not.toMatch(/postmaster\.pid|delete/i);
  });

  it("the pid was RECYCLED by another program: stale, not 'running'", () => {
    // Windows hands pids out of a small pool. A naive process.kill(pid, 0)
    // check calls this "alive" and refuses to start forever.
    const verdict = judgePostmasterLock({ pidFile: live, owner: "other-process", portAnswering: false });
    expect(verdict.state).toBe("stale");
  });

  it("the pid is gone but the port answers: running — do not delete anything", () => {
    // Someone else's PostgreSQL, or a second copy of the app that rewrote the
    // file between our read and our check.
    const verdict = judgePostmasterLock({ pidFile: live, owner: "none", portAnswering: true });
    expect(verdict.state).toBe("running");
  });

  it("the OS won't say what the process is and the port is silent: REFUSE", () => {
    const verdict = judgePostmasterLock({ pidFile: live, owner: "unknown", portAnswering: false });
    expect(verdict.state).toBe("unknown");
    if (verdict.state !== "unknown") return;
    expect(verdict.message).toMatch(/won't guess|can't tell/i);
  });

  it("a truncated lock file with a live port refuses; with a silent port it recovers", () => {
    expect(judgePostmasterLock({ pidFile: truncated, owner: "unknown", portAnswering: true }).state)
      .toBe("unknown");
    // postmaster.pid is written by a LIVE server, so a half-written one means
    // that server died where it stood. Nothing answering confirms it.
    expect(judgePostmasterLock({ pidFile: truncated, owner: "unknown", portAnswering: false }).state)
      .toBe("stale");
  });

  it("never returns 'stale' while a postgres process is alive, in ANY combination", () => {
    // The load-bearing invariant of this whole module: "stale" is permission to
    // delete a lock file and start a second postmaster over the same data
    // directory, which corrupts a set of books.
    for (const pidFile of [live, absent, truncated]) {
      for (const portAnswering of [true, false]) {
        const verdict = judgePostmasterLock({ pidFile, owner: "postgres", portAnswering });
        expect(verdict.state).not.toBe("stale");
      }
    }
  });
});

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

function instanceRead(overrides: { postgresVersion?: string; port?: number } = {}): InstanceReadResult {
  const doc = buildInstanceDocument({
    postgresVersion: overrides.postgresVersion ?? "16.10",
    platform: "win32-x64",
    port: overrides.port ?? 54329,
    createdAt: new Date("2026-08-08T12:00:00Z"),
    appVersion: "1.0.0",
  });
  return parseInstanceDocument(serializeInstanceDocument(doc));
}

const noInstance: InstanceReadResult = parseInstanceDocument(null);
const brokenInstance: InstanceReadResult = parseInstanceDocument("{ this is not json");

describe("planCluster", () => {
  it("runs initdb only when there is no cluster AND no record of one", () => {
    const plan = planCluster({
      clusterMajor: null, instance: noInstance, secretUsable: false, shippedMajor: 16,
    });
    expect(plan.action).toBe("initdb");
  });

  it("REFUSES to initdb beside a cluster that is already there", () => {
    // The worst outcome in the module: an empty chart of accounts with the real
    // books still on disk and nothing saying so.
    const plan = planCluster({
      clusterMajor: 16, instance: noInstance, secretUsable: false, shippedMajor: 16,
    });
    expect(plan.action).toBe("refuse");
    if (plan.action !== "refuse") return;
    expect(plan.code).toBe("cluster-unrecorded");
    expect(plan.message).toMatch(/still there/i);
  });

  it("uses an existing cluster whose record agrees with the disk", () => {
    const plan = planCluster({
      clusterMajor: 16, instance: instanceRead(), secretUsable: true, shippedMajor: 16,
    });
    expect(plan.action).toBe("use");
  });

  it("refuses a NEWER cluster and says an older copy can't read it", () => {
    const plan = planCluster({
      clusterMajor: 17, instance: instanceRead({ postgresVersion: "17.2" }),
      secretUsable: true, shippedMajor: 16,
    });
    expect(plan.action).toBe("refuse");
    if (plan.action !== "refuse") return;
    expect(plan.code).toBe("cluster-version-mismatch");
    expect(plan.message).toMatch(/NEWER version/);
    expect(plan.message).toMatch(/nothing has been changed/i);
  });

  it("refuses an OLDER cluster too — there is no tested migration path yet", () => {
    const plan = planCluster({
      clusterMajor: 15, instance: instanceRead({ postgresVersion: "15.6" }),
      secretUsable: true, shippedMajor: 16,
    });
    expect(plan.action).toBe("refuse");
    if (plan.action !== "refuse") return;
    expect(plan.code).toBe("cluster-version-mismatch");
  });

  it("checks the version BEFORE trusting instance.json for anything", () => {
    // A PG17 data directory is unreadable to a PG16 build no matter what our
    // own JSON file claims, so the version refusal must win over the
    // record-disagrees-with-disk refusal.
    const plan = planCluster({
      clusterMajor: 17, instance: instanceRead({ postgresVersion: "16.10" }),
      secretUsable: true, shippedMajor: 16,
    });
    expect(plan.action).toBe("refuse");
    if (plan.action !== "refuse") return;
    expect(plan.code).toBe("cluster-version-mismatch");
  });

  it("refuses when instance.json describes a different cluster than PG_VERSION does", () => {
    // The disk says PostgreSQL 16 and our own record says 15. One of them is
    // wrong, there is no safe way to pick, so neither is used — and crucially
    // this does NOT fall through to initdb.
    const plan = planCluster({
      clusterMajor: 16, instance: instanceRead({ postgresVersion: "15.6" }),
      secretUsable: true, shippedMajor: 16,
    });
    expect(plan.action).toBe("refuse");
    if (plan.action !== "refuse") return;
    expect(plan.code).toBe("instance-unreadable");
    expect(plan.message).toMatch(/describes a different one/i);
    expect(plan.detail).toContain("PG_VERSION");
  });

  it("refuses when instance.json records a database whose folder is gone", () => {
    const plan = planCluster({
      clusterMajor: null, instance: instanceRead(), secretUsable: true, shippedMajor: 16,
    });
    expect(plan.action).toBe("refuse");
    if (plan.action !== "refuse") return;
    expect(plan.code).toBe("cluster-missing");
    expect(plan.message).toMatch(/won't create a new, empty one/i);
  });

  it("refuses on a malformed instance.json rather than treating it as a first run", () => {
    for (const clusterMajor of [null, 16]) {
      const plan = planCluster({
        clusterMajor, instance: brokenInstance, secretUsable: true, shippedMajor: 16,
      });
      expect(plan.action).toBe("refuse");
      if (plan.action !== "refuse") return;
      expect(plan.code).toBe("instance-unreadable");
    }
  });

  it("rebuilds a missing instance.json when the cluster and its password are both intact", () => {
    // The one repair permitted, and every field of it is evidenced: the major
    // from PG_VERSION, the platform from the runtime, the port from our own
    // constant. Refusing instead would mean a user who deleted one unfamiliar
    // file loses their books permanently.
    const plan = planCluster({
      clusterMajor: 16, instance: noInstance, secretUsable: true, shippedMajor: 16,
    });
    expect(plan.action).toBe("rebuild-instance");
    if (plan.action !== "rebuild-instance") return;
    expect(plan.clusterMajor).toBe(16);
  });

  it("never returns initdb when a cluster exists, in any combination", () => {
    for (const instance of [noInstance, brokenInstance, instanceRead(), instanceRead({ postgresVersion: "15.6" })]) {
      for (const secretUsable of [true, false]) {
        for (const clusterMajor of [15, 16, 17]) {
          const plan = planCluster({ clusterMajor, instance, secretUsable, shippedMajor: 16 });
          expect(plan.action).not.toBe("initdb");
        }
      }
    }
  });
});

// ---------------------------------------------------------------------------
// what the user reads
// ---------------------------------------------------------------------------

describe("formatBytes / formatDownloadProgress", () => {
  it("matches the units the release script printed when the tarball was published", () => {
    expect(formatBytes(25_915_316)).toBe("24.7 MB");
    expect(formatBytes(0)).toBe("0 KB");
    expect(formatBytes(4096)).toBe("4 KB");
  });

  it("shows a percentage when the total is known", () => {
    expect(formatDownloadProgress(12_957_658, 25_915_316)).toBe("12.4 MB of 24.7 MB (50%)");
    expect(formatDownloadProgress(25_915_316, 25_915_316)).toBe("24.7 MB of 24.7 MB (100%)");
  });

  it("never fabricates a percentage when the server sent no length", () => {
    // A chunked response has no Content-Length. The bytes are still true.
    expect(formatDownloadProgress(12_957_658, null)).toBe("12.4 MB");
    expect(formatDownloadProgress(12_957_658, 0)).toBe("12.4 MB");
  });
});

describe("summariseLogTail", () => {
  it("keeps the last few real lines — where the answer always is", () => {
    // The exact shape a second postmaster produces, captured on 2026-08-08.
    const log = [
      "2026-08-08 19:49:26.294 PDT [9148] FATAL:  lock file \"postmaster.pid\" already exists",
      "2026-08-08 19:49:26.294 PDT [9148] HINT:  Is another postmaster (PID 42756) running?",
      "",
    ].join("\n");
    const summary = summariseLogTail(log);
    expect(summary).toContain("lock file");
    expect(summary).toContain("HINT");
    expect(summary.split("\n")).toHaveLength(2);
  });

  it("caps the tail so one failure cannot become a screenful", () => {
    const log = Array.from({ length: 500 }, (_, i) => `line ${i}`).join("\n");
    expect(summariseLogTail(log, 6).split("\n")).toHaveLength(6);
    expect(summariseLogTail(log, 6)).toContain("line 499");
  });

  it("returns an empty string for an empty log rather than a stray newline", () => {
    expect(summariseLogTail("")).toBe("");
    expect(summariseLogTail("\n\n  \n")).toBe("");
  });
});

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

describe("LocalDbError", () => {
  it("keeps the English and the technical detail in separate fields", () => {
    // So that no caller can render "ENOENT" where an explanation belongs.
    const err = new LocalDbError("start-failed", "The database didn't start.", "exit 1");
    expect(err).toBeInstanceOf(Error);
    expect(err.code).toBe("start-failed");
    expect(err.message).toBe("The database didn't start.");
    expect(err.detail).toBe("exit 1");
  });
});

describe("timing budgets", () => {
  it("gives startup a budget the measured 6.3s start comfortably fits inside", () => {
    // The 2026-08-08 spike timed a real 16.10 cluster at 6.3s warm; the
    // verification run on the finished driver saw 3.3-3.6s. A five-second
    // budget would have failed on the machine this was written on.
    expect(READY_TIMEOUT_MS).toBeGreaterThanOrEqual(30_000);
    expect(READY_POLL_INTERVAL_MS).toBeLessThanOrEqual(1000);
  });
});
