// Envelope key-management (KEY_MANAGEMENT_SCOPE.md, Layer 1) — the "proven not scaffolded" bar.
//
// The load-bearing proof is ROTATE-KEK-THEN-DECRYPT-PRE-ROTATION-DATA: encrypt under KEK v{n},
// rotate the KEK (re-wrap the DEKs under v{n+1}), and still decrypt the pre-rotation ciphertext —
// the exact thing the disabled rotateEncryptionKey could NOT do (direct master-key rotation orphans
// all data, which is why it throws). Plus the paired set: round-trip, scope isolation (tenant A's DEK
// ≠ tenant B's), tamper rejection (AES-GCM auth), and the CloudKmsProvider not-wired seam.
//
// Requires DATABASE_URL (DEKs live in data_keys) + PII_AES_MASTER_KEY (the env KEK root; set a test
// value if the ambient secret is absent). Uses appPool directly (data_keys is an RLS-disabled system
// table); cleans up its own rows in `after`.

import { test, describe, before, after } from "node:test";
import assert from "node:assert/strict";

if (!process.env.PII_AES_MASTER_KEY) {
  process.env.PII_AES_MASTER_KEY = "test-pii-aes-master-key-0123456789abcdef";
}

const { appPool } = await import("../server/db.js");
const { envelopeEncrypt, envelopeDecrypt, rotateKek, getKeyManagementStatus, selfTestKeyManagement } = await import(
  "../server/lib/key-management/envelope.js"
);
const { CloudKmsProvider, EnvKeyProvider } = await import("../server/lib/key-management/key-provider.js");

const RUN = `keymgmt-test-${Date.now()}`;
const tenantA = { type: "tenant" as const, id: `${RUN}-A` };
const tenantB = { type: "tenant" as const, id: `${RUN}-B` };
const platform = { type: "platform" as const };

describe("envelope key-management — mechanism (proven not scaffolded)", () => {
  after(async () => {
    // Remove this run's DEKs + the self-test scope (leave any other rows untouched).
    await appPool.query(`DELETE FROM data_keys WHERE scope_id LIKE $1 OR scope_id = $2`, [`${RUN}-%`, "__keymgmt_selftest__"]);
  });

  test("round-trip: envelope encrypt → decrypt returns the plaintext", async () => {
    const ct = await envelopeEncrypt("the-quick-brown-fox", tenantA);
    assert.ok(ct.startsWith("ENC:v2:"), "envelope ciphertext must carry the ENC:v2 (envelope) sentinel");
    assert.notEqual(ct, "the-quick-brown-fox", "ciphertext must not equal plaintext");
    const pt = await envelopeDecrypt(ct, tenantA);
    assert.equal(pt, "the-quick-brown-fox");
  });

  test("platform scope round-trips too", async () => {
    const ct = await envelopeEncrypt("platform-secret", platform);
    assert.equal(await envelopeDecrypt(ct, platform), "platform-secret");
  });

  test("LOAD-BEARING: rotate the KEK, then still decrypt PRE-rotation data (no bulk re-encryption)", async () => {
    const plaintext = "pre-rotation-disclosure";
    const ct = await envelopeEncrypt(plaintext, tenantA);

    const before = (await getKeyManagementStatus()).currentKekVersion;
    const result = await rotateKek("test-actor");
    const afterV = (await getKeyManagementStatus()).currentKekVersion;

    assert.ok(result.rewrapped >= 1, "rotation must re-wrap at least the DEK(s) that exist");
    assert.equal(result.newKekVersion, before + 1, "the KEK version must advance by one");
    assert.equal(afterV, before + 1, "status must report the advanced KEK version");

    // The whole point: the ciphertext produced under the OLD KEK still decrypts after rotation,
    // because rotation re-wraps the DEK (unchanged) rather than re-encrypting the bulk data.
    const pt = await envelopeDecrypt(ct, tenantA);
    assert.equal(pt, plaintext, "pre-rotation ciphertext MUST still decrypt after KEK rotation");

    // And a fresh encrypt after rotation round-trips under the new KEK version.
    const ct2 = await envelopeEncrypt("post-rotation", tenantA);
    assert.equal(await envelopeDecrypt(ct2, tenantA), "post-rotation");
  });

  test("scope isolation: tenant A's ciphertext does NOT decrypt under tenant B's context", async () => {
    const ct = await envelopeEncrypt("tenant-A-only", tenantA);
    await assert.rejects(
      () => envelopeDecrypt(ct, tenantB),
      "tenant B has a different DEK — decrypt must fail (no cross-scope decryption)",
    );
  });

  test("tamper rejection: a mutated ciphertext byte fails AES-GCM authentication", async () => {
    const ct = await envelopeEncrypt("integrity-protected", tenantA);
    const parts = ct.split(":"); // ENC : v2 : {dekVersion} : {base64}
    const b64 = parts[3];
    const mid = Math.floor(b64.length / 2);
    const flipped = b64[mid] === "A" ? "B" : "A";
    const tampered = `${parts[0]}:${parts[1]}:${parts[2]}:${b64.slice(0, mid)}${flipped}${b64.slice(mid + 1)}`;
    await assert.rejects(
      () => envelopeDecrypt(tampered, tenantA),
      "a tampered ciphertext must be rejected by the GCM auth tag",
    );
  });

  test("provider seam: CloudKmsProvider is typed but not wired — it throws, not fabricates", () => {
    const kms = new CloudKmsProvider();
    assert.equal(kms.isConfigured(), false, "the cloud-KMS seam must report not-configured");
    assert.throws(() => kms.wrapDek(Buffer.alloc(32), 1), /not configured/, "wrapDek must refuse, not fake");
  });

  test("self-test: the live end-to-end proof (encrypt → rotate → decrypt pre-rotation) passes", async () => {
    const r = await selfTestKeyManagement("selftest-runner");
    assert.equal(r.ok, true, r.detail);
    assert.ok(r.kekAfter > r.kekBefore, "self-test must advance the KEK version");
    assert.ok(r.rewrapped >= 1, "self-test must re-wrap at least its own DEK");
  });

  test("EnvKeyProvider is the configured default (env KEK, dev/sandbox honest default)", () => {
    const env = new EnvKeyProvider();
    assert.equal(env.name, "env");
    assert.equal(env.isConfigured(), true, "env provider is configured when PII_AES_MASTER_KEY is set");
  });
});
