// Tests for backups (Task 7 of docs/superpowers/plans/2026-07-31-local-app.md,
// plus the follow-up review fixes: collision-proof/serialized runBackup,
// filename-ordered retention, and a plainer message for Windows' abnormal
// exit code):
//
//  - pure filename/retention logic (selectFilesToDelete, isBackupFilename,
//    backupFilename, parseBackupFilenameTimestamp, makeTmpPath,
//    formatDumpFailureMessage) — no I/O at all.
//  - lastBackupInfo/countBackups against a real temp directory with touched
//    files (mtimes set explicitly via utimes, so ordering is deterministic
//    regardless of how fast the filesystem's clock resolution is) —
//    including the case where a file's mtime disagrees with its filename.
//  - resolveDumpCommand's fallback order (bundled pg_dump, then one on PATH),
//    with checkExecutable/existsSync/bundledPgDumpPath injected so the test
//    never depends on what's actually installed on the machine running the
//    suite, and buildDumpArgs' single command shape.
//  - runBackup's in-process concurrency guard, using a fast fake "pg_dump"
//    (commandOverride) so this doesn't need a real Postgres to run on every
//    `npm test`.
//  - a LIVE smoke test that runs one real backup against the dev DB
//    (server/src/backup.ts's own connection resolution / .env), guarded by
//    an env flag so a CI box or a machine with no PostgreSQL doesn't fail
//    `npm test` — see BACKUP_LIVE_TEST below.

import { describe, it, expect, afterAll } from "vitest";
import { mkdtemp, rm, writeFile, utimes, readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { gunzipSync } from "node:zlib";
import { readFile } from "node:fs/promises";
import "dotenv/config";
import {
  selectFilesToDelete,
  isBackupFilename,
  backupFilename,
  parseBackupFilenameTimestamp,
  makeTmpPath,
  formatDumpFailureMessage,
  lastBackupInfo,
  countBackups,
  resolveDumpCommand,
  buildDumpArgs,
  runBackup,
  BACKUP_ALREADY_RUNNING_MESSAGE,
  type BackupFileStat,
  type RunBackupResult,
} from "../../server/src/backup.js";

// ---------------------------------------------------------------------------
// pure logic
// ---------------------------------------------------------------------------

describe("isBackupFilename", () => {
  it("matches our exact naming convention", () => {
    expect(isBackupFilename("accounting-20260731-143022.sql.gz")).toBe(true);
  });
  it("rejects anything else in the directory (the .tmp file, unrelated files, other extensions)", () => {
    expect(isBackupFilename("accounting-20260731-143022.sql.gz.tmp")).toBe(false);
    expect(isBackupFilename("accounting-20260731-143022.sql")).toBe(false);
    expect(isBackupFilename("readme.txt")).toBe(false);
    expect(isBackupFilename("Accounting-20260731-143022.sql.gz")).toBe(false); // case-sensitive prefix
    expect(isBackupFilename("accounting-2026731-143022.sql.gz")).toBe(false); // wrong digit count
  });
});

describe("backupFilename", () => {
  it("renders a fixed local timestamp with zero-padding", () => {
    const d = new Date(2026, 6, 31, 9, 5, 3); // 2026-07-31 09:05:03 local
    expect(backupFilename(d)).toBe("accounting-20260731-090503.sql.gz");
  });
});

describe("parseBackupFilenameTimestamp", () => {
  it("round-trips with backupFilename", () => {
    const d = new Date(2026, 6, 31, 9, 5, 3);
    expect(parseBackupFilenameTimestamp(backupFilename(d))).toBe(d.getTime());
  });

  it("returns null for a name that doesn't match our convention", () => {
    expect(parseBackupFilenameTimestamp("notes.txt")).toBeNull();
    expect(parseBackupFilenameTimestamp("accounting-20260731-090503.sql.gz.tmp")).toBeNull();
  });
});

describe("makeTmpPath", () => {
  it("is collision-proof — two calls for the identical final path never collide (FIX 1a)", () => {
    const finalPath = "C:\\backups\\accounting-20260731-090503.sql.gz";
    const a = makeTmpPath(finalPath);
    const b = makeTmpPath(finalPath);
    expect(a).not.toBe(b);
    expect(a.startsWith(finalPath)).toBe(true);
    expect(a.endsWith(".tmp")).toBe(true);
    expect(a).toContain(String(process.pid));
  });
});

describe("formatDumpFailureMessage", () => {
  it("gives a plain-English message for Windows' abnormal-termination exit code (FIX 4)", () => {
    expect(formatDumpFailureMessage(4294967295, "")).toBe(
      "Backup failed: pg_dump was interrupted before finishing (exit code 4294967295). Nothing was saved.",
    );
  });

  it("reports an ordinary non-zero exit code plainly, with stderr details when present", () => {
    expect(formatDumpFailureMessage(1, "connection refused")).toBe(
      "Backup failed: pg_dump exited with code 1. Details: connection refused. Nothing was saved.",
    );
  });

  it("handles a null exit code (the process never reported one)", () => {
    expect(formatDumpFailureMessage(null, "")).toBe(
      "Backup failed: pg_dump exited with code unknown. Nothing was saved.",
    );
  });
});

describe("selectFilesToDelete", () => {
  function files(...names: string[]): BackupFileStat[] {
    return names.map((name, i) => ({ name, mtimeMs: i * 1000 })); // ascending = oldest first
  }

  it("deletes nothing when at or under the keep count", () => {
    expect(selectFilesToDelete(files("a", "b", "c"), 3)).toEqual([]);
    expect(selectFilesToDelete(files("a", "b"), 5)).toEqual([]);
    expect(selectFilesToDelete([], 30)).toEqual([]);
  });

  it("deletes exactly the oldest N beyond keep, by mtime", () => {
    const result = selectFilesToDelete(files("oldest", "middle", "newest"), 2);
    expect(result).toEqual(["oldest"]);
  });

  it("deletes the oldest several when far over the keep count", () => {
    const result = selectFilesToDelete(files("f1", "f2", "f3", "f4", "f5"), 2);
    expect(result).toEqual(["f1", "f2", "f3"]);
  });

  it("is unaffected by input order — sorts by mtime, not array position", () => {
    const shuffled: BackupFileStat[] = [
      { name: "newest", mtimeMs: 3000 },
      { name: "oldest", mtimeMs: 1000 },
      { name: "middle", mtimeMs: 2000 },
    ];
    expect(selectFilesToDelete(shuffled, 2)).toEqual(["oldest"]);
  });

  it("breaks mtime ties by filename (deterministic under coarse mtime resolution)", () => {
    const tied: BackupFileStat[] = [
      { name: "accounting-20260731-000003.sql.gz", mtimeMs: 5000 },
      { name: "accounting-20260731-000001.sql.gz", mtimeMs: 5000 },
      { name: "accounting-20260731-000002.sql.gz", mtimeMs: 5000 },
    ];
    // keep 1 -> delete the two oldest by name
    expect(selectFilesToDelete(tied, 1)).toEqual([
      "accounting-20260731-000001.sql.gz",
      "accounting-20260731-000002.sql.gz",
    ]);
  });

  // FIX 3: a backup file copied in from elsewhere (another machine's backup
  // folder, a cloud-storage restore, etc.) keeps its ORIGINAL filename —
  // and therefore its original, genuinely-old timestamp — but gets a
  // BRAND NEW mtime from the copy operation itself. Ordering by mtime
  // would treat that stale file as the newest thing in the directory and
  // delete the real, current backups out from under the retention window.
  // Ordering by the timestamp embedded in the filename gets this right.
  it("orders by the parsed FILENAME timestamp, not mtime — a copied-in stale file with a fresh mtime is still recognized as old", () => {
    const inverted: BackupFileStat[] = [
      { name: "accounting-20260101-010101.sql.gz", mtimeMs: 9_000_000 }, // old name, freshest mtime (just copied in)
      { name: "accounting-20260601-020202.sql.gz", mtimeMs: 1_000_000 }, // middle name, stalest mtime
      { name: "accounting-20261201-030303.sql.gz", mtimeMs: 5_000_000 }, // newest name, middling mtime
    ];
    // keep 1 -> must delete the two OLDEST BY NAME (Jan, Jun) and keep
    // December, even though December's mtime is neither the largest nor
    // the smallest of the three.
    expect(selectFilesToDelete(inverted, 1)).toEqual([
      "accounting-20260101-010101.sql.gz",
      "accounting-20260601-020202.sql.gz",
    ]);
  });
});

// ---------------------------------------------------------------------------
// lastBackupInfo / countBackups against a real temp directory
// ---------------------------------------------------------------------------

describe("lastBackupInfo / countBackups", () => {
  it("returns null and 0 for a directory that doesn't exist", async () => {
    const missing = path.join(tmpdir(), `backup-test-missing-${Date.now()}`);
    expect(await lastBackupInfo(missing)).toBeNull();
    expect(await countBackups(missing)).toBe(0);
  });

  it("finds the newest matching file by its filename timestamp, ignoring non-matching files", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "backup-test-"));
    try {
      const older = path.join(dir, "accounting-20260101-010101.sql.gz");
      const newer = path.join(dir, "accounting-20260601-020202.sql.gz");
      const unrelated = path.join(dir, "notes.txt");
      const partial = path.join(dir, "accounting-20260901-030303.sql.gz.tmp");
      await writeFile(older, "old dump");
      await writeFile(newer, "new dump");
      await writeFile(unrelated, "ignore me");
      await writeFile(partial, "in progress");

      // Set explicit, well-separated mtimes (matching the filenames' own
      // ordering here) so this test's baseline case doesn't depend on
      // filesystem timestamp resolution or write ordering — the inversion
      // case right below covers filename-vs-mtime disagreement.
      const oldTime = new Date(2026, 0, 1);
      const newTime = new Date(2026, 5, 1);
      await utimes(older, oldTime, oldTime);
      await utimes(newer, newTime, newTime);

      const last = await lastBackupInfo(dir);
      expect(last).not.toBeNull();
      expect(last!.file).toBe("accounting-20260601-020202.sql.gz");
      // .mtime now reports the PARSED FILENAME time (2026-06-01 02:02:02),
      // not the literal utimes()-set value (2026-06-01 00:00:00) — see the
      // inversion test right below for why that's the correct semantics.
      expect(last!.mtime.getTime()).toBe(new Date(2026, 5, 1, 2, 2, 2).getTime());

      // Only the two real .sql.gz files count — not notes.txt, not the .tmp.
      expect(await countBackups(dir)).toBe(2);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });

  // FIX 3 — see the identical scenario in the selectFilesToDelete describe
  // above: a file's mtime can lie (e.g. it was copied in from elsewhere),
  // but its filename can't. "Last backup" must track the filename, not
  // whichever file the filesystem happens to report as most recently
  // touched.
  it("prefers the filename timestamp over mtime — a copied-in stale file with a fresh mtime is still recognized as old", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "backup-test-"));
    try {
      const trueOlderByName = path.join(dir, "accounting-20260101-010101.sql.gz");
      const trueNewerByName = path.join(dir, "accounting-20260601-020202.sql.gz");
      await writeFile(trueOlderByName, "old dump");
      await writeFile(trueNewerByName, "new dump");

      // Deliberately invert mtimes relative to the filenames: the
      // OLDER-named file gets the FRESHER mtime (as if just copied onto
      // this machine), and the NEWER-named file gets a stale mtime.
      const freshMtime = new Date(2026, 6, 1); // after both filename dates
      const staleMtime = new Date(2020, 0, 1); // before both filename dates
      await utimes(trueOlderByName, freshMtime, freshMtime);
      await utimes(trueNewerByName, staleMtime, staleMtime);

      const last = await lastBackupInfo(dir);
      expect(last).not.toBeNull();
      expect(last!.file).toBe("accounting-20260601-020202.sql.gz"); // the newer-NAMED file, not the fresher-mtime one
      expect(last!.mtime.getTime()).toBe(new Date(2026, 5, 1, 2, 2, 2).getTime()); // the parsed FILENAME time
      expect(await countBackups(dir)).toBe(2);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });
});

// ---------------------------------------------------------------------------
// resolveDumpCommand — fallback order, fully mocked (never touches the
// real PATH or filesystem, so this test's outcome doesn't depend on what's
// actually installed on the machine running the suite).
//
// Two candidates now, not three: the pg_dump inside the runtime we install
// (docs/todo/2026-08-08-bundled-postgres-decisions.md), then one on PATH for a
// developer running against their own PostgreSQL. The `docker exec` route and
// the Docker Desktop path probe are gone with the container.
// ---------------------------------------------------------------------------

const FAKE_BUNDLED_PG_DUMP = "C:\\fake\\BalancedSum\\runtime\\postgresql-16.10-win32-x64\\bin\\pg_dump.exe";

describe("resolveDumpCommand", () => {
  it("prefers the bundled pg_dump when the runtime is installed", async () => {
    const result = await resolveDumpCommand({
      // Deliberately ALSO on PATH: the bundled binary matches the version of
      // the cluster holding the books, and pg_dump refuses to dump a server
      // newer than itself, so an unknown PATH binary must never win.
      checkExecutable: async () => true,
      existsSync: (p) => p === FAKE_BUNDLED_PG_DUMP,
      bundledPgDumpPath: FAKE_BUNDLED_PG_DUMP,
    });
    expect(result).toEqual({ kind: "bundled", executable: FAKE_BUNDLED_PG_DUMP });
  });

  it("falls back to a pg_dump on PATH when the bundled runtime isn't installed", async () => {
    const result = await resolveDumpCommand({
      checkExecutable: async (cmd) => cmd === "pg_dump",
      existsSync: () => false, // the bundled path is named, but nothing is there
      bundledPgDumpPath: FAKE_BUNDLED_PG_DUMP,
    });
    expect(result).toEqual({ kind: "path", executable: "pg_dump" });
  });

  it("falls back to PATH on a machine that can't even name a bundled path", async () => {
    // resolveCurrentLayout() throws on an unsupported platform, or on Windows
    // with no LOCALAPPDATA; that resolves to `null` rather than an error,
    // because a pg_dump on PATH can still do the job.
    const result = await resolveDumpCommand({
      checkExecutable: async (cmd) => cmd === "pg_dump",
      existsSync: () => true, // must not be consulted for a null path
      bundledPgDumpPath: null,
    });
    expect(result).toEqual({ kind: "path", executable: "pg_dump" });
  });

  it("throws a loud English error when nothing is available", async () => {
    await expect(
      resolveDumpCommand({
        checkExecutable: async () => false,
        existsSync: () => false,
        bundledPgDumpPath: FAKE_BUNDLED_PG_DUMP,
      }),
    ).rejects.toThrow(/Can't back up your books/);
  });
});

// ---------------------------------------------------------------------------
// buildDumpArgs — one shape for both kinds. The docker branch used to take the
// URL apart into `-U <user> <dbname>` and hand the rest to the container's
// environment; a real pg_dump takes the whole connection URI as its dbname
// argument, so nothing is parsed and nothing is dropped on the floor (port and
// password used to be, which only worked because the container supplied them).
// ---------------------------------------------------------------------------

describe("buildDumpArgs", () => {
  const url = "postgres://postgres:s3cr3t@127.0.0.1:54329/accounting";

  it("passes the whole connection URI to the bundled executable", () => {
    expect(buildDumpArgs({ kind: "bundled", executable: FAKE_BUNDLED_PG_DUMP }, url)).toEqual({
      command: FAKE_BUNDLED_PG_DUMP,
      args: [url],
    });
  });

  it("passes the whole connection URI to a pg_dump on PATH too", () => {
    expect(buildDumpArgs({ kind: "path", executable: "pg_dump" }, url)).toEqual({
      command: "pg_dump",
      args: [url],
    });
  });
});

// ---------------------------------------------------------------------------
// runBackup: concurrency guard (FIX 1b) — a fast, deterministic fake
// "pg_dump" via commandOverride, so this exercises the real spawn/gzip/
// rename/retention machinery without needing a live Postgres on every
// `npm test` run. The fake writes high-entropy (random) bytes so the
// gzip output reliably clears MIN_BACKUP_BYTES — repeated/predictable text
// compresses far too well to reach 1KB.
// ---------------------------------------------------------------------------

function fakeDumpCommandOverride(): { command: string; args: string[] } {
  const script =
    "const crypto=require('crypto');"
    + "process.stdout.write('-- fake dump\\nCREATE TABLE journal_entries (id int);\\n'"
    + "+crypto.randomBytes(4096).toString('base64'));";
  return { command: process.execPath, args: ["-e", script] };
}

describe("runBackup: concurrency guard", () => {
  it("serializes two concurrent calls — exactly one succeeds, the other rejects with the English 'already running' message", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "backup-concurrency-test-"));
    try {
      const settled = await Promise.allSettled([
        runBackup({ dir, commandOverride: fakeDumpCommandOverride() }),
        runBackup({ dir, commandOverride: fakeDumpCommandOverride() }),
      ]);

      const fulfilled = settled.filter(
        (r): r is PromiseFulfilledResult<RunBackupResult> => r.status === "fulfilled",
      );
      const rejected = settled.filter((r): r is PromiseRejectedResult => r.status === "rejected");

      expect(fulfilled).toHaveLength(1);
      expect(rejected).toHaveLength(1);
      expect((rejected[0]!.reason as Error).message).toBe(BACKUP_ALREADY_RUNNING_MESSAGE);

      // The one that succeeded produced a genuinely valid, non-trivial gzip.
      const result = fulfilled[0]!.value;
      expect(result.bytes).toBeGreaterThan(1024);
      const gz = await readFile(result.file);
      const sql = gunzipSync(gz).toString("utf8");
      expect(sql).toContain("CREATE TABLE");

      // No leftover .tmp file from either attempt — the rejected call never
      // even reached the filesystem, and the fulfilled one renamed cleanly.
      const entries = await readdir(dir);
      expect(entries.some((n) => n.endsWith(".tmp"))).toBe(false);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });

  it("releases the guard after finishing — a second, SEQUENTIAL backup succeeds", async () => {
    const dir = await mkdtemp(path.join(tmpdir(), "backup-concurrency-test-"));
    try {
      const first = await runBackup({
        dir, commandOverride: fakeDumpCommandOverride(), now: () => new Date(2026, 0, 1, 1, 1, 1),
      });
      const second = await runBackup({
        dir, commandOverride: fakeDumpCommandOverride(), now: () => new Date(2026, 0, 1, 1, 1, 2),
      });
      expect(first.file).not.toBe(second.file);
      expect(await countBackups(dir)).toBe(2);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });
});

// ---------------------------------------------------------------------------
// LIVE smoke test — runs a REAL backup against the dev DB via the real
// resolveDumpCommand() and the real connection resolution. Guarded so
// environments without a running Postgres (CI, a fresh clone before the first
// `npm run app`) don't fail `npm test`: set BACKUP_LIVE_TEST=1 to opt in.
// ---------------------------------------------------------------------------

const LIVE = process.env.BACKUP_LIVE_TEST === "1";
const liveDescribe = LIVE ? describe : describe.skip;
let liveTempDir: string | undefined;

liveDescribe("runBackup: LIVE smoke test against the dev database", () => {
  afterAll(async () => {
    if (liveTempDir) await rm(liveTempDir, { recursive: true, force: true });
  });

  it("produces a real, non-trivial, valid gzip of the dev database", async () => {
    liveTempDir = await mkdtemp(path.join(tmpdir(), "backup-live-test-"));

    const result = await runBackup({ dir: liveTempDir });

    expect(result.bytes).toBeGreaterThan(10 * 1024); // > 10KB
    expect(result.file).toMatch(/accounting-\d{8}-\d{6}\.sql\.gz$/);

    const entries = await readdir(liveTempDir);
    expect(entries).toContain(path.basename(result.file));
    expect(entries.some((n) => n.endsWith(".tmp"))).toBe(false); // no leftover partial file

    const gz = await readFile(result.file);
    const sql = gunzipSync(gz).toString("utf8");
    expect(sql).toContain("CREATE TABLE");
    expect(sql).toContain("journal_entries");
  }, 30_000);
});
