/**
 * 2FA encrypt-at-rest proof (AS/PLATFORM/2026/008 sequencing item #1;
 * remediates F-W3b-2 / W4a leg-E HIGH: TOTP secrets stored PLAINTEXT-at-rest).
 *
 * Substantiates the fix in server/lib/two-factor-auth.ts: the TOTP seed and the
 * active backup codes are now AES-256-GCM encrypted (via encryptionService,
 * storage format byte-identical to server/lib/wave5-services.ts) before they
 * reach the totp_secrets table, and decrypted on read. Decrypt fails closed
 * (throws) so a plaintext / legacy row is never treated as a usable secret
 * (Rule 14).
 *
 * This drives the REAL exported functions (initializeTwoFactor / enableTwoFactor
 * / verifyTwoFactor / regenerateBackupCodes) — the same code the login route
 * (routes.ts:1162) and the /api/2fa/* routes call — not a hand-rolled twin
 * (route-driven proof harness discipline).
 *
 * ROLE AXIS: N/A. totp_secrets is keyed by user_id (text PK), has NO tenant_id,
 * and is NOT RLS-enrolled (pg_class.relrowsecurity='f', verified). This is an
 * encrypt-at-rest property (ciphertext-in-column + in-process AES-GCM
 * round-trip), NOT a tenant-isolation RLS property — so "prove as aegis_app"
 * is deliberately not applicable here. The owner connection (DATABASE_URL) is
 * used to read RAW column bytes (to confirm ciphertext, not plaintext, is what
 * actually landed), to seed the two legacy-plaintext NEG fixtures, and to
 * clean up + verify 0 residual.
 *
 * Paired POS/NEG (Rule 18):
 *   POS-1  encrypt-on-write: raw encrypted_secret / backup_codes columns are
 *          ciphertext (isEncrypted), NOT the returned plaintext; decrypt
 *          round-trips to the exact returned values.
 *   POS-2  decrypt-on-read: a freshly-generated TOTP token enables + verifies
 *          (proves the stored ciphertext decrypts to a working seed).
 *   POS-3  backup-code consume: a returned backup code authenticates, decrements
 *          remaining 10->9, moves to used_backup_codes (plaintext), re-stores
 *          the survivors still as ciphertext; the SAME code is single-use (2nd
 *          attempt denied).
 *   NEG-1  legacy plaintext SEED row -> verifyTwoFactor does NOT authenticate
 *          (decrypt fails closed) — Rule 14.
 *   NEG-2  legacy plaintext BACKUP-CODES row -> backup path does NOT
 *          authenticate (decrypt fails closed) — Rule 14.
 *
 * Requires: DATABASE_URL + SESSION_SECRET (encryptionService dev key source).
 * Run: cd /home/runner/workspace && timeout 120 npx tsx scripts/vf-2fa-encrypt-at-rest-proof.ts
 */
import pg from "pg";
import { createHmac } from "crypto";

if (!process.env.DATABASE_URL) {
  console.error("FATAL: DATABASE_URL is not set.");
  process.exit(1);
}
if (!process.env.SESSION_SECRET && !process.env.ENCRYPTION_KEY) {
  console.error("FATAL: neither ENCRYPTION_KEY nor SESSION_SECRET is set (encryptionService cannot key).");
  process.exit(1);
}

const { Pool } = pg;
const owner = new Pool({ connectionString: process.env.DATABASE_URL });

const PREFIX = `vf-2fa-${Date.now()}`;
const U_POS = `${PREFIX}-pos`;
const U_NEG_SEED = `${PREFIX}-neg-seed`;
const U_NEG_BACKUP = `${PREFIX}-neg-backup`;
const ALL_USERS = [U_POS, U_NEG_SEED, U_NEG_BACKUP];

let pass = 0;
let fail = 0;
function check(name: string, cond: boolean, detail?: string) {
  if (cond) {
    pass++;
    console.log(`  PASS  ${name}${detail ? ` — ${detail}` : ""}`);
  } else {
    fail++;
    console.error(`  FAIL  ${name}${detail ? ` — ${detail}` : ""}`);
  }
}

// RFC6238 TOTP generator mirroring server/lib/two-factor-auth.ts CONFIG
// (sha1 / 6 digits / 30s period). Test-only: produces a valid current token
// from the PLAINTEXT secret returned by initializeTwoFactor, so that driving
// enableTwoFactor/verifyTwoFactor proves the STORED ciphertext decrypts back to
// that exact working seed.
function base32Decode(encoded: string): Buffer {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  const lookup: Record<string, number> = {};
  for (let i = 0; i < alphabet.length; i++) lookup[alphabet[i]] = i;
  const cleaned = encoded.replace(/=+$/, "").toUpperCase();
  const out: number[] = [];
  let bits = 0, value = 0;
  for (const ch of cleaned) {
    if (lookup[ch] === undefined) continue;
    value = (value << 5) | lookup[ch];
    bits += 5;
    if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; }
  }
  return Buffer.from(out);
}
function genTOTP(secret: string, offset = 0): string {
  let counter = Math.floor(Math.floor(Date.now() / 1000) / 30) + offset;
  const buf = Buffer.alloc(8);
  for (let i = 7; i >= 0; i--) { buf[i] = counter & 0xff; counter = Math.floor(counter / 256); }
  const hash = createHmac("sha1", base32Decode(secret)).update(buf).digest();
  const o = hash[hash.length - 1] & 0x0f;
  const bin = ((hash[o] & 0x7f) << 24) | ((hash[o + 1] & 0xff) << 16) | ((hash[o + 2] & 0xff) << 8) | (hash[o + 3] & 0xff);
  return (bin % 1_000_000).toString().padStart(6, "0");
}

async function rawRow(userId: string) {
  const r = await owner.query(
    "SELECT encrypted_secret, backup_codes, used_backup_codes, enabled FROM totp_secrets WHERE user_id=$1",
    [userId],
  );
  return r.rows[0];
}

async function cleanup() {
  await owner.query(`DELETE FROM totp_secrets WHERE user_id = ANY($1::text[])`, [ALL_USERS]);
}

async function main() {
  // Import the REAL module under test (same singleton encryptionService as the app).
  const tfa = await import("../server/lib/two-factor-auth");
  const { encryptionService } = await import("../server/lib/encryption-service");

  await cleanup(); // start from a known-empty state for our synthetic users

  // ───────────────────────────────────────────────────────────────────────────
  console.log("\n[POS-1] encrypt-on-write — raw columns are ciphertext, not plaintext");
  const setup = await tfa.initializeTwoFactor(U_POS, "vf-2fa-user");
  const row1 = await rawRow(U_POS);
  check("POS-1a row persisted", !!row1, row1 ? "row present" : "MISSING");
  check("POS-1b encrypted_secret != returned plaintext base32", row1.encrypted_secret !== setup.secret);
  check("POS-1c encrypted_secret is AES-GCM ciphertext (iv:tag:ct)", encryptionService.isEncrypted(row1.encrypted_secret));
  check("POS-1d encrypted_secret decrypts back to the returned secret", encryptionService.decrypt(row1.encrypted_secret) === setup.secret);
  const storedCodes = JSON.parse(row1.backup_codes) as string[];
  check("POS-1e backup_codes is an array of 10", Array.isArray(storedCodes) && storedCodes.length === 10, `len=${storedCodes.length}`);
  const everyCiphertext = storedCodes.every((c) => encryptionService.isEncrypted(c));
  check("POS-1f every stored backup code is ciphertext", everyCiphertext);
  const noPlaintextLeak = storedCodes.every((c) => !setup.backupCodes.includes(c));
  check("POS-1g no plaintext backup code appears in the column", noPlaintextLeak);
  const decAll = storedCodes.map((c) => encryptionService.decrypt(c));
  const roundTrips = JSON.stringify(decAll.slice().sort()) === JSON.stringify(setup.backupCodes.slice().sort());
  check("POS-1h decrypt(stored backup codes) == returned plaintext set", roundTrips);
  check("POS-1i used_backup_codes is plaintext empty array", row1.used_backup_codes === "[]");

  // ───────────────────────────────────────────────────────────────────────────
  console.log("\n[POS-2] decrypt-on-read — enable + verify with a live TOTP token");
  const tok = genTOTP(setup.secret);
  const enabled = await tfa.enableTwoFactor(U_POS, tok);
  check("POS-2a enableTwoFactor(valid TOTP) === true", enabled === true);
  const ver = await tfa.verifyTwoFactor(U_POS, genTOTP(setup.secret));
  check("POS-2b verifyTwoFactor(valid TOTP) success via totp", ver.success === true && ver.method === "totp", JSON.stringify(ver));
  const verBad = await tfa.verifyTwoFactor(U_POS, "000000" === tok ? "111111" : "000000");
  check("POS-2c verifyTwoFactor(wrong token) denied (paired NEG)", verBad.success === false, JSON.stringify(verBad));

  // ───────────────────────────────────────────────────────────────────────────
  console.log("\n[POS-3] backup-code consume — single-use, survivors stay ciphertext");
  const code = setup.backupCodes[0];
  const useBackup = await tfa.verifyTwoFactor(U_POS, code);
  check("POS-3a verifyTwoFactor(backup code) success via backup", useBackup.success === true && useBackup.method === "backup", JSON.stringify(useBackup));
  check("POS-3b remainingBackupCodes decremented 10->9", useBackup.remainingBackupCodes === 9, `remaining=${useBackup.remainingBackupCodes}`);
  const row3 = await rawRow(U_POS);
  const survivors = JSON.parse(row3.backup_codes) as string[];
  check("POS-3c 9 survivors persisted", survivors.length === 9, `len=${survivors.length}`);
  check("POS-3d survivors still all ciphertext", survivors.every((c) => encryptionService.isEncrypted(c)));
  const used = JSON.parse(row3.used_backup_codes) as string[];
  check("POS-3e consumed code recorded in used_backup_codes (plaintext)", used.includes(code), JSON.stringify(used));
  const reuse = await tfa.verifyTwoFactor(U_POS, code);
  check("POS-3f same backup code is single-use (2nd attempt denied)", reuse.success === false, JSON.stringify(reuse));

  // regenerate -> still ciphertext (covers regenerateBackupCodes write site)
  const regen = await tfa.regenerateBackupCodes(U_POS);
  check("POS-3g regenerateBackupCodes returns 10 fresh codes", !!regen && regen.length === 10);
  const row3b = await rawRow(U_POS);
  const regenStored = JSON.parse(row3b.backup_codes) as string[];
  check("POS-3h regenerated codes stored as ciphertext", regenStored.length === 10 && regenStored.every((c) => encryptionService.isEncrypted(c)));
  check("POS-3i regenerate resets used_backup_codes to empty", row3b.used_backup_codes === "[]");

  // ───────────────────────────────────────────────────────────────────────────
  console.log("\n[NEG-1] legacy PLAINTEXT seed row -> fail-closed (Rule 14)");
  const legacySecret = "JBSWY3DPEHPK3PXP"; // a valid base32 seed stored PLAINTEXT (pre-fix shape)
  await owner.query(
    `INSERT INTO totp_secrets (user_id, encrypted_secret, enabled, backup_codes, used_backup_codes)
     VALUES ($1, $2, true, '[]', '[]')`,
    [U_NEG_SEED, legacySecret],
  );
  const legacyTok = genTOTP(legacySecret); // a token that WOULD be valid if the plaintext were honored
  let neg1Denied = false;
  try {
    const r = await tfa.verifyTwoFactor(U_NEG_SEED, legacyTok);
    neg1Denied = r.success === false; // graceful-deny also acceptable as fail-closed
  } catch {
    neg1Denied = true; // throw-on-decrypt == fail-closed (a plaintext row is never usable)
  }
  check("NEG-1 plaintext-seed row never authenticates (decrypt fails closed)", neg1Denied);

  // ───────────────────────────────────────────────────────────────────────────
  console.log("\n[NEG-2] legacy PLAINTEXT backup-codes row -> fail-closed (Rule 14)");
  const validEncSeed = encryptionService.encrypt("KRSXG5CTMVRXEZLU"); // valid encrypted seed
  const plaintextCodesJson = JSON.stringify(["ABCD-1234", "EFGH-5678"]); // legacy plaintext codes
  await owner.query(
    `INSERT INTO totp_secrets (user_id, encrypted_secret, enabled, backup_codes, used_backup_codes)
     VALUES ($1, $2, true, $3, '[]')`,
    [U_NEG_BACKUP, validEncSeed, plaintextCodesJson],
  );
  let neg2Denied = false;
  try {
    // "ABCD-1234" is NOT a valid TOTP for the seed, so it falls through to the
    // backup path, which must fail-closed on the plaintext (non-ciphertext) codes.
    const r = await tfa.verifyTwoFactor(U_NEG_BACKUP, "ABCD-1234");
    neg2Denied = r.success === false;
  } catch {
    neg2Denied = true;
  }
  check("NEG-2 plaintext-backup-codes row never authenticates (decrypt fails closed)", neg2Denied);

  // ───────────────────────────────────────────────────────────────────────────
  console.log("\n[CLEANUP] remove synthetic rows + verify 0 residual");
  await cleanup();
  const residual = await owner.query(`SELECT count(*)::int AS n FROM totp_secrets WHERE user_id = ANY($1::text[])`, [ALL_USERS]);
  check("CLEANUP 0 residual synthetic rows", residual.rows[0].n === 0, `residual=${residual.rows[0].n}`);

  console.log(`\n──────── RESULT: ${pass} PASS / ${fail} FAIL ────────`);
  await owner.end();
  process.exit(fail === 0 ? 0 : 1);
}

main().catch(async (e) => {
  console.error("HARNESS ERROR:", e);
  try { await cleanup(); await owner.end(); } catch {}
  process.exit(1);
});
