// Unit tests for server/src/localdb/secret.ts (PG Task 1). The generator takes
// an injected randomBytes, so everything below is deterministic — including the
// rejection-sampling behaviour, which is otherwise unobservable.

import { describe, it, expect } from "vitest";
import {
  generateSecret,
  isWellFormedSecret,
  SECRET_ALPHABET,
  DEFAULT_SECRET_LENGTH,
  type RandomBytesFn,
} from "../../server/src/localdb/secret.js";
import { buildConnectionUrl } from "../../server/src/localdb/instance.js";

/** A randomBytes that yields a fixed byte sequence, cycling if asked for more
 *  than it holds — enough to drive the generator through a known path. */
function fixedBytes(sequence: number[]): RandomBytesFn {
  let offset = 0;
  return (size: number) => {
    const out = new Uint8Array(size);
    for (let i = 0; i < size; i++) out[i] = sequence[(offset + i) % sequence.length]!;
    offset += size;
    return out;
  };
}

describe("SECRET_ALPHABET", () => {
  it("is alphanumeric only — nothing that means something to a URL, .pgpass, or a shell", () => {
    expect(SECRET_ALPHABET).toMatch(/^[A-Za-z0-9]+$/);
    expect(SECRET_ALPHABET.length).toBe(62);
    // No duplicates: a repeated character would quietly bias the distribution.
    expect(new Set(SECRET_ALPHABET).size).toBe(SECRET_ALPHABET.length);
    // The specific characters that would break each destination.
    for (const forbidden of [":", "\\", "@", "/", "?", "#", "%", "\n", "\r", " ", "'", '"', "$", "`", "!"]) {
      expect(SECRET_ALPHABET).not.toContain(forbidden);
    }
  });
});

describe("generateSecret", () => {
  it("produces DEFAULT_SECRET_LENGTH characters from the alphabet by default", () => {
    const secret = generateSecret();
    expect(secret.length).toBe(DEFAULT_SECRET_LENGTH);
    expect(isWellFormedSecret(secret)).toBe(true);
  });

  it("honours an explicit length", () => {
    for (const n of [1, 8, 32, 64, 200]) {
      expect(generateSecret(n).length).toBe(n);
    }
  });

  it("is deterministic for a given byte stream — the whole reason it's injectable", () => {
    const bytes = [0, 1, 25, 26, 51, 52, 61];
    expect(generateSecret(7, fixedBytes(bytes))).toBe(generateSecret(7, fixedBytes(bytes)));
  });

  it("maps bytes onto the alphabet by index", () => {
    const secret = generateSecret(5, fixedBytes([0, 1, 2, 3, 4]));
    expect(secret).toBe("ABCDE");
    expect(generateSecret(3, fixedBytes([26, 27, 52]))).toBe("ab0");
  });

  it("REJECTS the biased tail of the byte range instead of folding it with a modulo", () => {
    // 248..255 would map back onto A..H under a plain `% 62`, making those
    // eight letters ~1.6% more likely than the rest. They must be skipped.
    const secret = generateSecret(3, fixedBytes([248, 0, 255, 1, 250, 2]));
    expect(secret).toBe("ABC");
  });

  it("never emits a character outside the alphabet, over the whole byte range", () => {
    const everyByte = Array.from({ length: 256 }, (_, i) => i);
    const secret = generateSecret(200, fixedBytes(everyByte));
    expect(isWellFormedSecret(secret)).toBe(true);
  });

  it("uses the whole alphabet — a generator stuck on a subset would look fine but be weak", () => {
    const seen = new Set<string>();
    for (let i = 0; i < 200; i++) {
      for (const ch of generateSecret(64)) seen.add(ch);
    }
    expect(seen.size).toBe(SECRET_ALPHABET.length);
  });

  it("produces a different password every call — 'per install' means per install", () => {
    const secrets = new Set(Array.from({ length: 100 }, () => generateSecret()));
    expect(secrets.size).toBe(100);
  });

  it("raises rather than padding when the random source gives nothing", () => {
    // Only reachable from a test (crypto.randomBytes never returns empty), but
    // a short or predictable superuser password that still "works" is the worst
    // outcome available here, so it must not be a silent fallback.
    expect(() => generateSecret(16, () => new Uint8Array(0))).toThrow(/no bytes/);
  });

  it("raises on a nonsensical length instead of returning an empty password", () => {
    for (const n of [0, -1, 1.5, Number.NaN]) {
      expect(() => generateSecret(n)).toThrow(/positive integer/);
    }
  });

  it("survives being put in a connection URL with no escaping at all", () => {
    for (let i = 0; i < 50; i++) {
      const secret = generateSecret();
      const url = buildConnectionUrl({
        user: "postgres", password: secret, host: "127.0.0.1", port: 54329, database: "accounting",
      });
      // Unchanged by percent-encoding...
      expect(url).toContain(`:${secret}@`);
      // ...and parses back to exactly the same password.
      expect(new URL(url).password).toBe(secret);
    }
  });

  it("survives a .pgpass-style line — no field separator, no escape, no newline", () => {
    for (let i = 0; i < 50; i++) {
      const secret = generateSecret();
      const line = `127.0.0.1:54329:accounting:postgres:${secret}`;
      expect(line.split(":")).toHaveLength(5);
      expect(line.split(":")[4]).toBe(secret);
      expect(line.split("\n")).toHaveLength(1);
    }
  });

  it("survives initdb --pwfile, which reads only the FIRST line", () => {
    for (let i = 0; i < 50; i++) {
      const secret = generateSecret();
      expect(`${secret}\n`.split(/\r?\n/)[0]).toBe(secret);
    }
  });
});

describe("isWellFormedSecret", () => {
  it("accepts anything generateSecret produces", () => {
    for (let i = 0; i < 50; i++) expect(isWellFormedSecret(generateSecret())).toBe(true);
  });

  it("rejects the trailing newline a text-mode read or an editor adds", () => {
    // The failure this exists to name: the file looks right, the password is
    // wrong by one invisible character, and the error says "authentication
    // failed" with no hint at all.
    expect(isWellFormedSecret("Ab3XyZ\n")).toBe(false);
    expect(isWellFormedSecret("Ab3XyZ\r\n")).toBe(false);
    expect(isWellFormedSecret(" Ab3XyZ")).toBe(false);
    expect(isWellFormedSecret("Ab3XyZ ")).toBe(false);
  });

  it("rejects an empty file and anything with a character outside the alphabet", () => {
    expect(isWellFormedSecret("")).toBe(false);
    expect(isWellFormedSecret("p@ssword")).toBe(false);
    expect(isWellFormedSecret("has:colon")).toBe(false);
  });
});
