// Round-6 A1 — salted KDF password hash + lazy migration.
//
// Pins the 5 behaviours called out in the dispatch:
//   1. old-format hash verifies + auto-rehashes to new on login
//   2. new-format hash round-trips
//   3. wrong password rejected (both old and new format paths)
//   4. after a successful login against a legacy hash, the legacy
//      hash NO LONGER EXISTS in the DB (single source of truth)
//   5. bootstrap admin (register flow) writes new format from row 1
//
// Plus regression pins:
//   - timing-safe verify path doesn't throw on adversarial input
//   - malformed new-format string is rejected (not silently accepted)
//
// All COMMHUB_SCRYPT_N=10 to keep the suite fast (~5ms/scrypt vs
// 50ms at production N=14).

import { describe, expect, test, beforeAll, beforeEach } from "bun:test";
import { db, hashPassword, verifyPassword } from "./db.js";
import { register, login } from "./auth.js";

// Fast scrypt for tests — 2^10 = 1024 iter is ~5ms. Production stays at N=14.
beforeAll(() => {
  process.env.COMMHUB_SCRYPT_N = "10";
});

beforeEach(() => {
  db.run("DELETE FROM users");
  db.run("DELETE FROM api_tokens");
  db.run("DELETE FROM network_members");
  db.run("DELETE FROM networks");
  db.run("DELETE FROM audit_log");
});

// Mimic the pre-A1 unsalted SHA-256 path. Used to seed legacy rows.
function legacyHash(plain: string): string {
  return new Bun.CryptoHasher("sha256").update(`anet:${plain}`).digest("hex");
}

function getStoredHash(username: string): string | null {
  const row = db.get<{ password_hash: string }>(
    "SELECT password_hash FROM users WHERE username = ?1",
    username
  );
  return row?.password_hash ?? null;
}

function insertLegacyUser(username: string, plain: string): string {
  const userId = `usr_${username}`;
  db.run(
    `INSERT INTO users (user_id, username, password_hash, role)
     VALUES (?1, ?2, ?3, 'user')`,
    [userId, username, legacyHash(plain)]
  );
  return userId;
}

describe("hashPassword — new format shape", () => {
  test("produces scrypt$N$salt$hash with non-zero salt and hash", () => {
    const stored = hashPassword("PasswordWith8+");
    const parts = stored.split("$");
    expect(parts.length).toBe(4);
    expect(parts[0]).toBe("scrypt");
    const N = parseInt(parts[1], 10);
    expect(N).toBeGreaterThanOrEqual(8);
    expect(N).toBeLessThanOrEqual(20);
    expect(Buffer.from(parts[2], "base64").length).toBeGreaterThan(0);
    expect(Buffer.from(parts[3], "base64").length).toBe(64); // SCRYPT_KEYLEN
  });

  test("salt + hash differ on every call (no static derivation)", () => {
    const a = hashPassword("same");
    const b = hashPassword("same");
    const c = hashPassword("same");
    expect(a).not.toBe(b);
    expect(b).not.toBe(c);
    expect(a).not.toBe(c);
  });
});

describe("verifyPassword — new format round-trip", () => {
  test("hashPassword + verifyPassword round-trips, needsRehash=false", () => {
    const stored = hashPassword("MyPassw0rd!");
    const v = verifyPassword("MyPassw0rd!", stored);
    expect(v.ok).toBe(true);
    expect(v.needsRehash).toBe(false);
  });

  test("wrong password rejected, needsRehash=false", () => {
    const stored = hashPassword("MyPassw0rd!");
    const v = verifyPassword("wrong", stored);
    expect(v.ok).toBe(false);
    expect(v.needsRehash).toBe(false);
  });

  test("malformed new-format strings rejected (not silently passed)", () => {
    // Each of these tries to abuse the parser. None should yield ok=true.
    const bad = [
      "scrypt$$salt$hash",                       // missing N
      "scrypt$14$$hash",                         // empty salt
      "scrypt$14$c2FsdA==$",                     // empty hash
      "scrypt$abc$c2FsdA==$aGFzaA==",            // non-numeric N
      "scrypt$14$c2FsdA==$aGFzaA==$extra",       // 5 parts
      "scrypt$14$c2FsdA==",                      // 3 parts
      "argon2$1$c2FsdA==$aGFzaA==",              // unknown scheme
      "scrypt$5$c2FsdA==$aGFzaA==",              // N below floor (8)
      "scrypt$30$c2FsdA==$aGFzaA==",             // N above ceiling (20)
    ];
    for (const s of bad) {
      const v = verifyPassword("anything", s);
      expect(v.ok).toBe(false);
    }
  });
});

describe("verifyPassword — legacy format compatibility (A1's load-bearing claim)", () => {
  test("legacy unsalted SHA-256 hash verifies", () => {
    const plain = "OldUserPassw0rd";
    const stored = legacyHash(plain);
    const v = verifyPassword(plain, stored);
    expect(v.ok).toBe(true);
    expect(v.needsRehash).toBe(true); // ← KEY: caller MUST rehash
  });

  test("legacy wrong-password rejected, needsRehash=false", () => {
    const stored = legacyHash("OldUserPassw0rd");
    const v = verifyPassword("wrong", stored);
    expect(v.ok).toBe(false);
    expect(v.needsRehash).toBe(false);
  });

  test("legacy hash with wrong length rejected (not crash)", () => {
    const v = verifyPassword("anything", "deadbeef"); // 8 hex chars, not 64
    expect(v.ok).toBe(false);
    expect(v.needsRehash).toBe(false);
  });
});

describe("login() — lazy migration on success", () => {
  test("legacy user logs in successfully AND is auto-upgraded to new format", () => {
    insertLegacyUser("legacy-alice", "AliceP@ss123");

    // Sanity: the seeded hash IS bare-hex sha256 (no $).
    const before = getStoredHash("legacy-alice")!;
    expect(before).not.toContain("$");
    expect(before).toHaveLength(64);

    const result = login("legacy-alice", "AliceP@ss123");
    expect(result.ok).toBe(true);

    // The load-bearing assertion: after login, the legacy hash is GONE.
    const after = getStoredHash("legacy-alice")!;
    expect(after).toContain("$");
    expect(after.startsWith("scrypt$")).toBe(true);
    expect(after).not.toBe(before);
  });

  test("legacy user wrong-password fails AND legacy hash is preserved", () => {
    insertLegacyUser("legacy-bob", "BobP@ss123");
    const before = getStoredHash("legacy-bob")!;

    const result = login("legacy-bob", "wrong");
    expect(result.ok).toBe(false);

    // Failed login MUST NOT rehash (would leak info about format).
    const after = getStoredHash("legacy-bob")!;
    expect(after).toBe(before);
    expect(after).not.toContain("$");
  });

  test("new-format user logs in without rehash (already current)", () => {
    // First login auto-migrates; second login should not rewrite again.
    insertLegacyUser("legacy-charlie", "CharlieP@ss");
    const r1 = login("legacy-charlie", "CharlieP@ss");
    expect(r1.ok).toBe(true);
    const afterFirst = getStoredHash("legacy-charlie")!;

    const r2 = login("legacy-charlie", "CharlieP@ss");
    expect(r2.ok).toBe(true);
    const afterSecond = getStoredHash("legacy-charlie")!;

    // Same hash on consecutive logins — no spurious rewrites.
    expect(afterSecond).toBe(afterFirst);
  });
});

describe("register() — new users always start in new format (bootstrap admin)", () => {
  test("freshly-registered user has scrypt$ format from row 1", () => {
    const r = register("dave", "DaveBootstr@p", "dave@example.com", "Dave");
    expect(r.ok).toBe(true);

    const stored = getStoredHash("dave")!;
    expect(stored.startsWith("scrypt$")).toBe(true);
  });

  test("registered user can log in (smoke for the whole pipeline)", () => {
    register("eve", "EveP@ssword99", undefined, "Eve");
    const login_r = login("eve", "EveP@ssword99");
    expect(login_r.ok).toBe(true);
  });

  test("registered user wrong-password fails (smoke for new-format reject)", () => {
    register("frank", "FrankP@ssword99", undefined, "Frank");
    const login_r = login("frank", "wrong");
    expect(login_r.ok).toBe(false);
  });
});

describe("login — username enumeration timing-oracle close (round-6 A1 hardening)", () => {
  // Independent pre-review on #285: post-A1 the user-exists +
  // wrong-password path runs ~50ms scrypt, the user-not-found path
  // returns sub-ms — that's a web-measurable oracle for "is this
  // username registered". Fix: run a throwaway verifyPassword
  // against a module-constant dummy hash on the not-found branch
  // so wall-clock cost is comparable on both paths.
  //
  // The test asserts ORDER-OF-MAGNITUDE timing parity, not exact
  // equality — scrypt timings have natural jitter and we don't
  // want a flaky test that hammers CI on busy hosts. The check
  // is "both paths are slow" (both > some floor), proving the
  // dummy-verify codepath actually runs on the not-found branch.
  test("not-found user takes scrypt-class time (not sub-ms early-return)", () => {
    register("real-user-z", "RealUserZ123");

    // Warm the JIT + paging on both paths so the first call's
    // amortized cost doesn't skew the comparison.
    login("real-user-z", "wrong");
    login("does-not-exist-warmup", "wrong");

    // Sample multiple times and take the median so single-call
    // jitter doesn't dominate.
    const sample = (fn: () => void): number => {
      const N = 5;
      const xs: number[] = [];
      for (let i = 0; i < N; i++) {
        const start = performance.now();
        fn();
        xs.push(performance.now() - start);
      }
      xs.sort((a, b) => a - b);
      return xs[Math.floor(N / 2)];
    };

    const tExists = sample(() => { login("real-user-z", "wrong"); });
    const tMissing = sample(() => { login("nobody-here-" + Math.random(), "wrong"); });

    // The fix's load-bearing claim: BOTH paths run scrypt. At test
    // N=10 (~5ms per scrypt), an early-return path would be < 1ms.
    // Loose lower bound of 1ms catches an "early return" regression
    // without being flaky on slow CI runners.
    expect(tExists).toBeGreaterThan(1);
    expect(tMissing).toBeGreaterThan(1);

    // Ratio: both paths should be the same order of magnitude.
    // 5x is a generous bound to avoid CI flakiness from
    // co-scheduled scrypt workloads or paging. Pre-fix this would
    // be 50x+ trivially.
    const ratio = Math.max(tExists, tMissing) / Math.max(0.1, Math.min(tExists, tMissing));
    expect(ratio).toBeLessThan(5);
  });

  test("not-found user still returns the generic error string (no info leak)", () => {
    register("user-exists", "ExistsP@ss1");

    const a = login("user-exists", "wrong");
    const b = login("user-does-not-exist", "wrong");

    expect(a.ok).toBe(false);
    expect(b.ok).toBe(false);
    // Both must return EXACTLY the same error string. If they
    // diverge, the username-enumeration oracle reopens at the
    // string-comparison layer regardless of timing.
    expect(a.error).toBe(b.error);
    expect(a.error).toBe("invalid username or password");
  });
});

describe("login — timing parity across all 3 failure paths (round-6 round-2)", () => {
  // Independent reviewer's round-2 catch on #285: the round-1 dummy-
  // scrypt fix only equalized the !user (missing) path. legacy-user-
  // wrong-password still ran SHA-256 only (sub-ms) while new-format-
  // user-wrong-password ran scrypt (~50ms). That ~50x gap let an
  // attacker distinguish "this username exists on a legacy hash" from
  // "this username has been migrated" or "doesn't exist". Fix: burn
  // an equal-time scrypt on the legacy-wrong-password path so all
  // three failure paths cost SHA-256 + 1 scrypt.
  //
  // The test asserts ORDER-OF-MAGNITUDE timing parity (max/min ratio
  // < 5x) — generous to avoid CI flake but pre-fix would be 50x+.
  test("all 3 wrong-password paths take comparable scrypt-class time", () => {
    // Setup: one user with new-format hash, one with legacy SHA-256
    // seeded directly.
    register("new-user", "NewUserP@ss1");

    const legacyUserId = "usr_legacy_oracle";
    const legacyHashed = new Bun.CryptoHasher("sha256").update("anet:LegacyP@ss1").digest("hex");
    db.run(
      `INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, ?2, ?3, 'user')`,
      [legacyUserId, "legacy-user", legacyHashed]
    );

    // Warm JIT + scrypt code paths
    login("new-user", "wrong");
    login("legacy-user", "wrong");
    login("nobody-here-warmup", "wrong");

    const sample = (fn: () => void): number => {
      const N = 5;
      const xs: number[] = [];
      for (let i = 0; i < N; i++) {
        const start = performance.now();
        fn();
        xs.push(performance.now() - start);
      }
      xs.sort((a, b) => a - b);
      return xs[Math.floor(N / 2)];
    };

    const tNewWrong = sample(() => { login("new-user", "wrong"); });
    const tLegacyWrong = sample(() => { login("legacy-user", "wrong"); });
    const tMissing = sample(() => { login("nobody-here-" + Math.random(), "wrong"); });

    // All three must run scrypt (> 1ms floor — early returns are sub-ms)
    expect(tNewWrong).toBeGreaterThan(1);
    expect(tLegacyWrong).toBeGreaterThan(1);
    expect(tMissing).toBeGreaterThan(1);

    // Order-of-magnitude parity. Pre-fix tLegacyWrong vs tNewWrong
    // would be 50x+. Generous 5x cap absorbs CI scheduling jitter.
    const maxT = Math.max(tNewWrong, tLegacyWrong, tMissing);
    const minT = Math.max(0.1, Math.min(tNewWrong, tLegacyWrong, tMissing));
    expect(maxT / minT).toBeLessThan(5);
  });

  test("legacy-wrong-password returns the same generic error string as the other paths", () => {
    const legacyUserId = "usr_legacy_str";
    const legacyHashed = new Bun.CryptoHasher("sha256").update("anet:Pw1").digest("hex");
    db.run(
      `INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, ?2, ?3, 'user')`,
      [legacyUserId, "legacy-str", legacyHashed]
    );
    register("new-str", "NewP@ss1");

    const a = login("legacy-str", "wrong");
    const b = login("new-str", "wrong");
    const c = login("does-not-exist", "wrong");

    expect(a.ok).toBe(false);
    expect(b.ok).toBe(false);
    expect(c.ok).toBe(false);
    // All three must say EXACTLY the same thing or the oracle reopens
    // at the response-body layer.
    expect(a.error).toBe("invalid username or password");
    expect(b.error).toBe(a.error);
    expect(c.error).toBe(a.error);
  });
});

describe("ordering invariant — single login transition (round-6 spec)", () => {
  // The dispatch's load-bearing post-condition: "post-upgrade 旧串
  // 不再存在 in DB". One concise pin.
  test("after first login, legacy hash is provably absent from the row", () => {
    const plain = "TransitionPw1";
    insertLegacyUser("legacy-tx", plain);
    const legacy = legacyHash(plain);

    login("legacy-tx", plain);

    const after = getStoredHash("legacy-tx")!;
    expect(after).not.toBe(legacy);          // not the same string
    expect(after.startsWith("scrypt$")).toBe(true);
    // The legacy string is exactly 64 hex chars. The new string is
    // markedly longer (scrypt$N$salt$hash). Verify length disjoint
    // so any future regression that re-stores a 64-char bare hex
    // would be caught.
    expect(after.length).toBeGreaterThan(64);
  });
});
