// Unit tests for server/src/localdb/instance.ts (PG Task 1). Pure — the
// validator is fed strings, never files.
//
// The bias of these tests is deliberate: far more of them are about REJECTING a
// bad instance.json than accepting a good one. This file decides where the
// user's books are; a half-written one that reads as "port 0" or "platform
// undefined" is how an app ends up creating a second, empty set of books beside
// the real ones.

import { describe, it, expect } from "vitest";
import {
  parseInstanceDocument,
  buildInstanceDocument,
  serializeInstanceDocument,
  buildConnectionUrl,
  adminConnectionUrl,
  appConnectionUrl,
  redactConnectionUrl,
  INSTANCE_FORMAT_VERSION,
  DEFAULT_PORT,
  LOOPBACK_HOST,
  SUPERUSER_ROLE,
  APP_ROLE,
  APP_DATABASE,
  MAINTENANCE_DATABASE,
  type InstanceDocument,
} from "../../server/src/localdb/instance.js";

const GOOD: InstanceDocument = {
  formatVersion: INSTANCE_FORMAT_VERSION,
  postgresVersion: "16.10",
  postgresMajor: 16,
  platform: "win32-x64",
  port: DEFAULT_PORT,
  createdAt: "2026-08-08T12:00:00.000Z",
  createdByAppVersion: "1.0.0",
};

/** A serialized GOOD document with one field replaced (or removed, via
 *  undefined) — the shape a corrupted or hand-edited file takes. */
function withField(key: string, value: unknown): string {
  const obj: Record<string, unknown> = { ...GOOD };
  if (value === undefined) delete obj[key];
  else obj[key] = value;
  return JSON.stringify(obj);
}

describe("buildInstanceDocument / serializeInstanceDocument", () => {
  it("records exactly what the layout section calls for: port, versions, created", () => {
    const doc = buildInstanceDocument({
      postgresVersion: "16.10",
      platform: "win32-x64",
      port: DEFAULT_PORT,
      createdAt: new Date("2026-08-08T12:00:00.000Z"),
      appVersion: "1.0.0",
    });
    expect(doc).toEqual(GOOD);
    expect(doc.postgresMajor).toBe(16);
  });

  it("round-trips through serialize -> parse unchanged", () => {
    const text = serializeInstanceDocument(GOOD);
    expect(text.endsWith("\n")).toBe(true);
    const result = parseInstanceDocument(text);
    expect(result.ok).toBe(true);
    if (result.ok) expect(result.instance).toEqual(GOOD);
  });

  it("raises on a version string it can't take a major from — a caller bug, not a user error", () => {
    expect(() => buildInstanceDocument({
      postgresVersion: "sixteen", platform: "win32-x64", port: DEFAULT_PORT,
      createdAt: new Date(), appVersion: "1.0.0",
    })).toThrow(/major version/);
  });
});

describe("parseInstanceDocument — absent", () => {
  it("treats no file, an empty file, and a whitespace-only file as absent", () => {
    for (const raw of [null, undefined, "", "   ", "\n\n"]) {
      const result = parseInstanceDocument(raw);
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.reason).toBe("absent");
    }
  });
});

describe("parseInstanceDocument — malformed", () => {
  it("rejects a truncated file rather than reading it as a partial document", () => {
    // The exact failure the layout is designed around: a power cut between
    // open() and flush. Every prefix of a valid document must be rejected.
    const full = serializeInstanceDocument(GOOD);
    for (let cut = 1; cut < full.length - 1; cut++) {
      const result = parseInstanceDocument(full.slice(0, cut));
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.reason).toBe("malformed");
    }
  });

  it("rejects a port of 0 by name — the value a defaulted read produces, and the OS ACCEPTS it", () => {
    const result = parseInstanceDocument(withField("port", 0));
    expect(result.ok).toBe(false);
    if (result.ok) return;
    expect(result.reason).toBe("malformed");
    expect(result.detail).toContain("port");
  });

  it("rejects every other unusable port", () => {
    for (const port of [-1, 65536, 1.5, "54329", null, undefined, Number.NaN]) {
      const result = parseInstanceDocument(withField("port", port));
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.reason).toBe("malformed");
    }
  });

  it("rejects invalid JSON, and arrays and scalars that happen to parse", () => {
    for (const raw of ["{", "not json", "[]", '"16.10"', "42", "null"]) {
      const result = parseInstanceDocument(raw);
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.reason).toBe("malformed");
    }
  });

  it("rejects a missing or unexpected formatVersion instead of reading it anyway", () => {
    for (const v of [undefined, 0, 2, "1", null]) {
      const result = parseInstanceDocument(withField("formatVersion", v));
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.reason).toBe("malformed");
    }
  });

  it("rejects a platform id it doesn't recognise", () => {
    for (const p of [undefined, "", "linux-x64", "win32", 16, null]) {
      const result = parseInstanceDocument(withField("platform", p));
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.reason).toBe("malformed");
    }
  });

  it("rejects a postgresMajor that disagrees with postgresVersion", () => {
    // Hand-edited or written by something that isn't us. There is no safe way
    // to pick a winner, so it refuses instead of picking.
    const result = parseInstanceDocument(withField("postgresMajor", 17));
    expect(result.ok).toBe(false);
    if (result.ok) return;
    expect(result.reason).toBe("malformed");
    expect(result.detail).toContain("disagrees");
  });

  it("rejects an unusable postgresVersion, createdAt or createdByAppVersion", () => {
    for (const [key, value] of [
      ["postgresVersion", undefined], ["postgresVersion", ""], ["postgresVersion", "sixteen"],
      ["createdAt", undefined], ["createdAt", ""], ["createdAt", "not a date"], ["createdAt", 1700000000],
      ["createdByAppVersion", undefined], ["createdByAppVersion", "  "],
    ] as const) {
      const result = parseInstanceDocument(withField(key, value));
      expect(result.ok).toBe(false);
      if (!result.ok) expect(result.reason).toBe("malformed");
    }
  });

  it("never returns a partially-populated document on ANY rejection", () => {
    // The single property this whole module exists for.
    for (const raw of ["{", "{}", withField("port", undefined), withField("platform", undefined), "[]"]) {
      const result = parseInstanceDocument(raw);
      expect(result.ok).toBe(false);
      expect(result).not.toHaveProperty("instance");
    }
  });

  it("keeps 'malformed' distinct from 'absent' so a corrupt file can't look like a first run", () => {
    const absent = parseInstanceDocument(null);
    const corrupt = parseInstanceDocument("{ \"formatVersion\": 1, \"por");
    expect(absent.ok).toBe(false);
    expect(corrupt.ok).toBe(false);
    if (absent.ok || corrupt.ok) return;
    expect(absent.reason).not.toBe(corrupt.reason);
    // The corrupt message must steer AWAY from reinstalling over the top,
    // which is the instinct that creates a second set of books.
    expect(corrupt.message.toLowerCase()).toContain("backup");
  });
});

describe("parseInstanceDocument — forward compatibility", () => {
  it("ignores extra properties a newer build may have added", () => {
    const raw = JSON.stringify({ ...GOOD, somethingNew: { nested: true }, anotherField: 7 });
    const result = parseInstanceDocument(raw);
    expect(result.ok).toBe(true);
    // ...and does not carry them through, so nothing downstream can start
    // depending on a field this build doesn't understand.
    if (result.ok) expect(result.instance).toEqual(GOOD);
  });
});

describe("connection URLs", () => {
  it("uses the recorded port and loopback only — never 5432, never a hostname", () => {
    const url = adminConnectionUrl(GOOD, "hunter2");
    expect(url).toBe(`postgres://postgres:hunter2@127.0.0.1:${DEFAULT_PORT}/accounting`);
    // Not 5432 — and note that a naive substring check passes by accident here,
    // since "54329" contains "5432". Parse the port instead.
    expect(new URL(url).port).not.toBe("5432");
    expect(url).toContain(LOOPBACK_HOST);
  });

  it("builds the two roles the app actually uses", () => {
    expect(adminConnectionUrl(GOOD, "p")).toContain(`//${SUPERUSER_ROLE}:`);
    expect(appConnectionUrl(GOOD, "p")).toContain(`//${APP_ROLE}:`);
    expect(adminConnectionUrl(GOOD, "p").endsWith(`/${APP_DATABASE}`)).toBe(true);
    expect(adminConnectionUrl(GOOD, "p", MAINTENANCE_DATABASE).endsWith("/postgres")).toBe(true);
  });

  it("follows a port change in instance.json without anything else being edited", () => {
    const moved: InstanceDocument = { ...GOOD, port: 55000 };
    expect(adminConnectionUrl(moved, "p")).toContain(":55000/");
    expect(appConnectionUrl(moved, "p")).toContain(":55000/");
  });

  it("percent-encodes the password rather than concatenating it", () => {
    // The characters that would otherwise re-point the URL at a different host,
    // a different database, or nothing at all.
    const nasty = "p@ss:w/rd?#[]%&= ";
    const url = buildConnectionUrl({
      user: "app", password: nasty, host: LOOPBACK_HOST, port: DEFAULT_PORT, database: APP_DATABASE,
    });
    expect(url).not.toContain("p@ss");
    // It must still be a URL, it must still point at loopback, and the password
    // must decode back to exactly what went in.
    const parsed = new URL(url);
    expect(parsed.hostname).toBe(LOOPBACK_HOST);
    expect(parsed.port).toBe(String(DEFAULT_PORT));
    expect(decodeURIComponent(parsed.password)).toBe(nasty);
    expect(decodeURIComponent(parsed.username)).toBe("app");
    expect(decodeURIComponent(parsed.pathname.slice(1))).toBe(APP_DATABASE);
  });

  it("percent-encodes the user and database too", () => {
    const url = buildConnectionUrl({
      user: "odd user", password: "p", host: LOOPBACK_HOST, port: 1, database: "my db",
    });
    expect(url).toBe("postgres://odd%20user:p@127.0.0.1:1/my%20db");
    const parsed = new URL(url);
    expect(decodeURIComponent(parsed.username)).toBe("odd user");
    expect(decodeURIComponent(parsed.pathname.slice(1))).toBe("my db");
  });

  it("round-trips a generated-style password unchanged (the alphabet needs no escaping)", () => {
    const generated = "Ab3XyZ90qWeRtYuIoPaSdFgHjKlZxCvB";
    const url = appConnectionUrl(GOOD, generated);
    expect(url).toContain(`:${generated}@`);
    expect(new URL(url).password).toBe(generated);
  });
});

describe("redactConnectionUrl", () => {
  it("removes the password so a connection string can appear in a log", () => {
    expect(redactConnectionUrl(adminConnectionUrl(GOOD, "s3cret")))
      .toBe(`postgres://postgres:***@127.0.0.1:${DEFAULT_PORT}/accounting`);
  });

  it("removes an encoded password too — the encoded form is just as much a secret", () => {
    const url = buildConnectionUrl({
      user: "app", password: "p@ss:w/rd", host: LOOPBACK_HOST, port: DEFAULT_PORT, database: APP_DATABASE,
    });
    const redacted = redactConnectionUrl(url);
    expect(redacted).not.toContain("p%40ss");
    expect(redacted).toContain(":***@");
  });

  it("leaves a URL with no credentials alone", () => {
    expect(redactConnectionUrl("postgres://127.0.0.1:54329/accounting"))
      .toBe("postgres://127.0.0.1:54329/accounting");
    expect(redactConnectionUrl("")).toBe("");
  });
});
