import { describe, expect, test } from "bun:test";
import { authorize } from "./claims.ts";
import { generate } from "./generate.ts";
import { load, loadPublic, sign, verify } from "./key.ts";
import { findKey, loadSet, verifyWith } from "./set.ts";

// Tests that the JS @moq/token package can load keys generated by the Rust
// moq-token crate and verify tokens signed by Rust, and vice versa.

describe("Rust-generated fixtures", () => {
	// cargo run --bin moq-token -- generate --out /tmp/rust-hs256.jwk
	const RUST_HS256_KEY = JSON.stringify({
		alg: "HS256",
		key_ops: ["verify", "sign"],
		kty: "oct",
		k: "jvS2B_SKkvhtObaiKg9n2vq_Iqdus-nVCVVYBWf0BQA",
		kid: "d55b1f13e6bda281",
	});

	// cargo run --bin moq-token -- sign --key /tmp/rust-hs256.jwk --root demo --publish alice --subscribe bob
	const RUST_HS256_TOKEN =
		"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6ImQ1NWIxZjEzZTZiZGEyODEifQ.eyJyb290IjoiZGVtbyIsInB1dCI6WyJhbGljZSJdLCJnZXQiOlsiYm9iIl19.eyOkZtTtj_MDkLF4Eu11cygb7B_6DyP-6e0TtwVE4UE";

	// A token scoped to the root path. Rust skips serializing an empty `root`, so the
	// claims arrive with no `root` field at all.
	// cargo run --bin moq-token -- sign --key /tmp/rust-hs256.jwk --publish alice --subscribe bob
	const RUST_HS256_TOKEN_EMPTY_ROOT =
		"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6ImQ1NWIxZjEzZTZiZGEyODEifQ.eyJwdXQiOlsiYWxpY2UiXSwiZ2V0IjpbImJvYiJdfQ.AFQ0rWFDFhOMumIItAaVjg5O6e-55D9f73aw-0s46ks";

	// cargo run --bin moq-token -- generate --algorithm EdDSA --out /tmp/rust-eddsa-private.jwk --public /tmp/rust-eddsa-public.jwk
	const RUST_EDDSA_PRIVATE_KEY = JSON.stringify({
		alg: "EdDSA",
		key_ops: ["sign", "verify"],
		kty: "OKP",
		crv: "Ed25519",
		x: "S9n3tEG677fSNpa0Ftf46OPBte5y1pTwvm4fPkEw_Z8",
		d: "EhFsuE3CFkJoQpZCT2Wwr6YF7ZwjsaEIgbwO4Uc4b5U",
		kid: "dfab9113ea85dc27",
	});

	const RUST_EDDSA_PUBLIC_KEY = JSON.stringify({
		alg: "EdDSA",
		key_ops: ["verify"],
		kty: "OKP",
		crv: "Ed25519",
		x: "S9n3tEG677fSNpa0Ftf46OPBte5y1pTwvm4fPkEw_Z8",
		kid: "dfab9113ea85dc27",
	});

	// cargo run --bin moq-token -- sign --key /tmp/rust-eddsa-private.jwk --root room --publish stream1 --publish stream2 --subscribe feed1
	const RUST_EDDSA_TOKEN =
		"eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRmYWI5MTEzZWE4NWRjMjcifQ.eyJyb290Ijoicm9vbSIsInB1dCI6WyJzdHJlYW0xIiwic3RyZWFtMiJdLCJnZXQiOlsiZmVlZDEiXX0.jezhAgTHQHdYkc15CBZP8zPKKJ0z5oVRwON4wPHeQ6xvIhoM5IkCs_4YRCEoG74t99pdVTQrwAMNrlfKm2O6DA";

	test("load Rust HS256 key", () => {
		const key = load(RUST_HS256_KEY);
		expect(key.alg).toBe("HS256");
		expect(key.kid).toBe("d55b1f13e6bda281");
		expect(key.kty).toBe("oct");
	});

	test("verify Rust HS256 token", async () => {
		const key = load(RUST_HS256_KEY);
		const claims = await verify(key, RUST_HS256_TOKEN);
		expect(claims.root).toBe("demo");
		expect(claims.put).toEqual(["alice"]);
		expect(claims.get).toEqual(["bob"]);
	});

	test("verify Rust HS256 token scoped to the root path", async () => {
		const key = load(RUST_HS256_KEY);
		const claims = await verify(key, RUST_HS256_TOKEN_EMPTY_ROOT);
		expect(claims.root).toBe("");
		expect(claims.put).toEqual(["alice"]);
		expect(claims.get).toEqual(["bob"]);

		// An empty root grants its prefixes wherever the connection lands.
		expect(authorize(claims, "alice")).toEqual({ subscribe: [], publish: [""] });
	});

	test("sign with Rust HS256 key and verify roundtrip", async () => {
		const key = load(RUST_HS256_KEY);
		const token = await sign(key, { root: "js-test", put: ["pub1"], get: ["sub1"] });
		const claims = await verify(key, token);
		expect(claims.root).toBe("js-test");
		expect(claims.put).toEqual(["pub1"]);
		expect(claims.get).toEqual(["sub1"]);
	});

	test("load Rust EdDSA keys", () => {
		const privateKey = load(RUST_EDDSA_PRIVATE_KEY);
		expect(privateKey.alg).toBe("EdDSA");
		expect(privateKey.kty).toBe("OKP");

		const publicKey = loadPublic(RUST_EDDSA_PUBLIC_KEY);
		expect(publicKey.alg).toBe("EdDSA");
		expect(publicKey.kty).toBe("OKP");
	});

	test("sign with Rust EdDSA private key and verify with public key", async () => {
		const privateKey = load(RUST_EDDSA_PRIVATE_KEY);
		const publicKey = loadPublic(RUST_EDDSA_PUBLIC_KEY);
		const token = await sign(privateKey, { root: "js-eddsa", put: ["test"] });
		const claims = await verify(publicKey, token);
		expect(claims.root).toBe("js-eddsa");
		expect(claims.put).toEqual(["test"]);
	});

	test("verify Rust EdDSA token with public key", async () => {
		const key = loadPublic(RUST_EDDSA_PUBLIC_KEY);
		const claims = await verify(key, RUST_EDDSA_TOKEN);
		expect(claims.root).toBe("room");
		expect(claims.put).toEqual(["stream1", "stream2"]);
		expect(claims.get).toEqual(["feed1"]);
	});

	// cargo run --bin moq-token -- generate --root project --publish live --subscribe watch --out /tmp/scoped.jwk
	const RUST_SCOPED_KEY = JSON.stringify({
		alg: "HS256",
		key_ops: ["verify", "sign"],
		kty: "oct",
		k: "pLDMYsYrp6vZLQbefVzHXN6P4wc1ADTLn4L7xzi1Qm4",
		kid: "e396f70014db9fe9",
		scope: { root: "project", put: ["live"], get: ["watch"] },
	});

	// cargo run --bin moq-token -- sign --key /tmp/scoped.jwk --root project --publish live/room --subscribe watch
	const RUST_SCOPED_TOKEN =
		"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6ImUzOTZmNzAwMTRkYjlmZTkifQ.eyJyb290IjoicHJvamVjdCIsInB1dCI6WyJsaXZlL3Jvb20iXSwiZ2V0IjpbIndhdGNoIl19.24Mxox6iQeFniEppRb1gWOO7iOTIsYlQRAjnOq6A2vk";

	test("load a Rust key's scope", () => {
		const key = load(RUST_SCOPED_KEY);
		expect(key.scope).toEqual({ root: "project", put: ["live"], get: ["watch"] });
	});

	test("verify a Rust token signed within its key scope", async () => {
		const key = load(RUST_SCOPED_KEY);
		const claims = await verify(key, RUST_SCOPED_TOKEN);
		expect(claims.put).toEqual(["live/room"]);
		expect(claims.get).toEqual(["watch"]);
	});

	test("JS enforces the same scope Rust does", async () => {
		const key = load(RUST_SCOPED_KEY);

		// Rust rejects this exact pair with "token capabilities exceed the key scope":
		// cargo run --bin moq-token -- sign --key /tmp/scoped.jwk --root project --publish other
		await expect(sign(key, { root: "project", put: ["other"] })).rejects.toThrow("exceed the key scope");

		// ...and accepts the grants Rust signed into RUST_SCOPED_TOKEN. The encoding
		// is not compared: JS orders the header differently and stamps `iat`.
		const token = await sign(key, { root: "project", put: ["live/room"], get: ["watch"] });
		const claims = await verify(key, token);
		expect(claims.put).toEqual(["live/room"]);
		expect(claims.get).toEqual(["watch"]);
	});
});

describe("Rust-generated JWK Set", () => {
	// A JWK Set written by the Rust `KeySet::to_str()`, holding two HS256 keys.
	// cargo run --example _fixture   (see the moq-token crate)
	const RUST_JWKS = JSON.stringify({
		keys: [
			{
				alg: "HS256",
				key_ops: ["sign", "verify"],
				kty: "oct",
				k: "jvS2B_SKkvhtObaiKg9n2vq_Iqdus-nVCVVYBWf0BQA",
				kid: "d55b1f13e6bda281",
			},
			{
				alg: "HS256",
				key_ops: ["verify", "sign"],
				kty: "oct",
				k: "Fp8kipWUJeUFqeSqWym_tRC_tyI8z-QpqopIGrbrD68",
				kid: "second-key",
			},
		],
	});

	// Signed by the Rust `KeySet::sign()`, so the kid header selects the first key.
	const RUST_JWKS_TOKEN =
		"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6ImQ1NWIxZjEzZTZiZGEyODEifQ.eyJyb290IjoiZGVtbyIsInB1dCI6WyJhbGljZSJdLCJnZXQiOlsiYm9iIl19.eyOkZtTtj_MDkLF4Eu11cygb7B_6DyP-6e0TtwVE4UE";

	test("load a Rust JWKS", () => {
		const set = loadSet(RUST_JWKS);
		expect(set.keys.length).toBe(2);
		expect(findKey(set, "d55b1f13e6bda281")?.alg).toBe("HS256");
		expect(findKey(set, "second-key")?.alg).toBe("HS256");
	});

	test("verify a Rust KeySet-signed token, selecting the key by kid", async () => {
		const set = loadSet(RUST_JWKS);
		const claims = await verifyWith(set, RUST_JWKS_TOKEN);
		expect(claims.root).toBe("demo");
		expect(claims.put).toEqual(["alice"]);
		expect(claims.get).toEqual(["bob"]);
	});

	test("a token whose kid is absent from the Rust JWKS is rejected", async () => {
		const set = loadSet(RUST_JWKS);
		const stranger = await generate("HS256", "not-in-the-set");
		const token = await sign(stranger, { root: "demo", put: ["alice"] });
		await expect(verifyWith(set, token)).rejects.toThrow(/Cannot find key with kid not-in-the-set/);
	});
});

describe("wrong key rejects token", () => {
	// cargo run --bin moq-token -- generate --out /tmp/rust-hs256.jwk
	const RUST_HS256_KEY = JSON.stringify({
		alg: "HS256",
		key_ops: ["verify", "sign"],
		kty: "oct",
		k: "jvS2B_SKkvhtObaiKg9n2vq_Iqdus-nVCVVYBWf0BQA",
		kid: "d55b1f13e6bda281",
	});

	// cargo run --bin moq-token -- sign --key /tmp/rust-hs256.jwk --root demo --publish alice --subscribe bob
	const RUST_HS256_TOKEN =
		"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6ImQ1NWIxZjEzZTZiZGEyODEifQ.eyJyb290IjoiZGVtbyIsInB1dCI6WyJhbGljZSJdLCJnZXQiOlsiYm9iIl19.eyOkZtTtj_MDkLF4Eu11cygb7B_6DyP-6e0TtwVE4UE";

	test("different HS256 key rejects Rust token", async () => {
		const wrongKey = await generate("HS256");
		await expect(verify(wrongKey, RUST_HS256_TOKEN)).rejects.toThrow();
	});

	test("unrelated path is not authorized by the token", async () => {
		const key = load(RUST_HS256_KEY);
		const claims = await verify(key, RUST_HS256_TOKEN);
		expect(() => authorize(claims, "wrong-root")).toThrow();
	});

	test("tampered token is rejected", async () => {
		const key = load(RUST_HS256_KEY);
		// Flip a character in the signature
		const tampered = RUST_HS256_TOKEN.slice(0, -1) + (RUST_HS256_TOKEN.at(-1) === "A" ? "B" : "A");
		await expect(verify(key, tampered)).rejects.toThrow();
	});

	test("EdDSA token rejected by HS256 key", async () => {
		const key = load(RUST_HS256_KEY);
		// cargo run --bin moq-token -- sign --key /tmp/rust-eddsa-private.jwk --root room --publish stream1 --publish stream2 --subscribe feed1
		const eddsaToken =
			"eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRmYWI5MTEzZWE4NWRjMjcifQ.eyJyb290Ijoicm9vbSIsInB1dCI6WyJzdHJlYW0xIiwic3RyZWFtMiJdLCJnZXQiOlsiZmVlZDEiXX0.jezhAgTHQHdYkc15CBZP8zPKKJ0z5oVRwON4wPHeQ6xvIhoM5IkCs_4YRCEoG74t99pdVTQrwAMNrlfKm2O6DA";
		await expect(verify(key, eddsaToken)).rejects.toThrow();
	});

	test("garbage token is rejected", async () => {
		const key = load(RUST_HS256_KEY);
		await expect(verify(key, "not-a-jwt")).rejects.toThrow();
	});
});
