// node --experimental-strip-types --test
//
// Exercises the proof-of-possession enrollment end to end: the test plays the client, doing real
// X25519 in WebCrypto, so the DH handshake and roomId derivation are verified within one runtime.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
  authedHostRoom,
  b64Decode,
  b64urlEncode,
  deriveRoomId,
  enrollChallenge,
  enrollComplete,
  enrollProof,
} from "../src/relayEnroll.ts";

/** Minimal KV stub with the get/put/delete surface the enroll module uses. */
function kvStub() {
  const store = new Map<string, string>();
  const kv = {
    get: async (key: string) => store.get(key) ?? null,
    put: async (key: string, value: string) => {
      store.set(key, value);
    },
    delete: async (key: string) => {
      store.delete(key);
    },
  } as unknown as KVNamespace;
  return { store, kv };
}

/** A client identity: an X25519 static keypair (the Noise `s`). */
async function makeClient() {
  const kp = (await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"])) as CryptoKeyPair;
  const staticPubRaw = new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey));
  return { kp, staticPubRaw, staticKeyB64: b64urlEncode(staticPubRaw) };
}

/** Compute the client-side proof for a challenge, mirroring what the Swift host would do. */
async function clientProof(client: Awaited<ReturnType<typeof makeClient>>, ephemeralPublicKeyB64: string) {
  const ephPub = await crypto.subtle.importKey("raw", b64Decode(ephemeralPublicKeyB64), { name: "X25519" }, false, []);
  const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "X25519", public: ephPub }, client.kp.privateKey, 256));
  const proof = await enrollProof(shared, client.staticKeyB64);
  return b64urlEncode(proof);
}

async function fullEnroll(kv: KVNamespace, client: Awaited<ReturnType<typeof makeClient>>) {
  const chal = (await (await enrollChallenge(kv, { staticKey: client.staticKeyB64 })).json()) as {
    challengeId: string;
    ephemeralPublicKey: string;
  };
  const proof = await clientProof(client, chal.ephemeralPublicKey);
  const res = await enrollComplete(kv, { challengeId: chal.challengeId, proof });
  return { res, chal };
}

test("valid proof-of-possession enrolls and binds the host to its derived room", async () => {
  const { kv } = kvStub();
  const client = await makeClient();
  const { res } = await fullEnroll(kv, client);
  assert.equal(res.status, 200);
  const body = (await res.json()) as { hostId: string; hostSecret: string; roomId: string };
  assert.ok(body.hostId.length >= 32);
  assert.equal(body.hostSecret.length, 64);
  // roomId is the hex SHA-256 of the static public key — matches the Swift DeviceIdentity.hostID.
  assert.equal(body.roomId, await deriveRoomId(client.staticPubRaw));
});

test("the host credential resolves back to its bound room", async () => {
  const { kv } = kvStub();
  const client = await makeClient();
  const { res } = await fullEnroll(kv, client);
  const { hostId, hostSecret, roomId } = (await res.json()) as { hostId: string; hostSecret: string; roomId: string };

  const resolved = await authedHostRoom(kv, `${hostId}.${hostSecret}`);
  assert.deepEqual(resolved, { hostId, roomId });
  // A wrong secret resolves to nothing.
  assert.equal(await authedHostRoom(kv, `${hostId}.deadbeef`), null);
});

test("an attacker holding only the public static key cannot enroll", async () => {
  // The attacker knows the victim's public static key (it is in every pairing QR) but not the
  // private key, so it cannot complete the DH. It substitutes its own keypair for the proof.
  const { kv } = kvStub();
  const victim = await makeClient();
  const attacker = await makeClient();

  const chal = (await (await enrollChallenge(kv, { staticKey: victim.staticKeyB64 })).json()) as {
    challengeId: string;
    ephemeralPublicKey: string;
  };
  // Attacker derives the shared secret with *its* private key — wrong for the victim's static key.
  const ephPub = await crypto.subtle.importKey("raw", b64Decode(chal.ephemeralPublicKey), { name: "X25519" }, false, []);
  const wrongShared = new Uint8Array(await crypto.subtle.deriveBits({ name: "X25519", public: ephPub }, attacker.kp.privateKey, 256));
  const proof = b64urlEncode(await enrollProof(wrongShared, victim.staticKeyB64));

  const res = await enrollComplete(kv, { challengeId: chal.challengeId, proof });
  assert.equal(res.status, 401);
});

test("a challenge is single-use", async () => {
  const { kv } = kvStub();
  const client = await makeClient();
  const chal = (await (await enrollChallenge(kv, { staticKey: client.staticKeyB64 })).json()) as {
    challengeId: string;
    ephemeralPublicKey: string;
  };
  const proof = await clientProof(client, chal.ephemeralPublicKey);
  assert.equal((await enrollComplete(kv, { challengeId: chal.challengeId, proof })).status, 200);
  // Replaying the same challengeId fails — it was consumed.
  assert.equal((await enrollComplete(kv, { challengeId: chal.challengeId, proof })).status, 401);
});

test("challenge rejects a malformed static key", async () => {
  const { kv } = kvStub();
  assert.equal((await enrollChallenge(kv, { staticKey: "not-32-bytes" })).status, 422);
  assert.equal((await enrollChallenge(kv, {})).status, 422);
});

test("enroll rejects a garbage proof without a matching challenge", async () => {
  const { kv } = kvStub();
  assert.equal((await enrollComplete(kv, { challengeId: "nope", proof: "AAAA" })).status, 401);
  assert.equal((await enrollComplete(kv, {})).status, 422);
});
