import { createHash, generateKeyPairSync, sign } from "node:crypto";

import { createAssertionSignatureMessage } from "@oslojs/webauthn";
import { describe, it, expect, vi } from "vitest";

import type { AuthAdapter, Credential } from "../types.js";
import { authenticateWithPasskey, PasskeyAuthenticationError } from "./authenticate.js";
import type { ChallengeStore } from "./types.js";

const credential: Credential = {
	id: "registered-credential",
	userId: "user_1",
	publicKey: new Uint8Array(),
	counter: 0,
	deviceType: "singleDevice",
	backedUp: false,
	transports: [],
	name: null,
	createdAt: new Date(),
	lastUsedAt: new Date(),
};

const config = {
	rpName: "Test Site",
	rpId: "localhost",
	origins: ["http://localhost:4321"],
};

function createAdapter(): AuthAdapter {
	return {
		getCredentialById: vi.fn(async () => credential),
		updateCredentialCounter: vi.fn(async () => undefined),
		getUserById: vi.fn(async () => null),
	} as unknown as AuthAdapter;
}

function createChallengeStore(): ChallengeStore {
	return {
		set: vi.fn(async () => undefined),
		get: vi.fn(async () => null),
		delete: vi.fn(async () => undefined),
	};
}

function base64url(bytes: Uint8Array): string {
	return Buffer.from(bytes).toString("base64url");
}

function createValidAssertion(opts: { rpId?: string; origin?: string } = {}) {
	const rpId = opts.rpId ?? config.rpId;
	const origin = opts.origin ?? config.origins[0];
	if (!origin) throw new Error("origin must be defined for createValidAssertion");
	const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
	const jwk = publicKey.export({ format: "jwk" });
	if (typeof jwk.x !== "string" || typeof jwk.y !== "string") {
		throw new Error("Failed to export test public key");
	}

	const publicKeyBytes = Buffer.concat([
		Buffer.from([0x04]),
		Buffer.from(jwk.x, "base64url"),
		Buffer.from(jwk.y, "base64url"),
	]);
	const challenge = base64url(Buffer.from("test-challenge"));
	const clientDataJSON = Buffer.from(
		JSON.stringify({
			type: "webauthn.get",
			challenge,
			origin,
		}),
	);
	const rpIdHash = createHash("sha256").update(rpId).digest();
	const signatureCounter = Buffer.alloc(4);
	signatureCounter.writeUInt32BE(1);
	const authenticatorData = Buffer.concat([rpIdHash, Buffer.from([0x01]), signatureCounter]);
	const signatureMessage = createAssertionSignatureMessage(authenticatorData, clientDataJSON);
	const signatureBytes = sign("sha256", signatureMessage, privateKey);

	return {
		credential: {
			...credential,
			publicKey: new Uint8Array(publicKeyBytes),
		},
		response: {
			id: credential.id,
			rawId: credential.id,
			type: "public-key" as const,
			response: {
				clientDataJSON: base64url(clientDataJSON),
				authenticatorData: base64url(authenticatorData),
				signature: base64url(signatureBytes),
			},
		},
		challengeStore: {
			set: vi.fn(async () => undefined),
			get: vi.fn(async () => ({ type: "authentication" as const, expiresAt: Date.now() + 60_000 })),
			delete: vi.fn(async () => undefined),
		} satisfies ChallengeStore,
	};
}

describe("authenticateWithPasskey", () => {
	it("throws a typed passkey auth error for malformed assertion payloads", async () => {
		try {
			await authenticateWithPasskey(
				config,
				createAdapter(),
				{
					id: "registered-credential",
					rawId: "registered-credential",
					type: "public-key",
					response: {
						clientDataJSON: "AA",
						authenticatorData: "AA",
						signature: "AA",
					},
				},
				createChallengeStore(),
			);
			expect.fail("Expected passkey authentication to fail");
		} catch (error) {
			expect(error).toBeInstanceOf(PasskeyAuthenticationError);
			expect(error).toMatchObject({ code: "invalid_response" });
		}
	});

	it("throws a typed passkey auth error when a credential has no user", async () => {
		const { credential: validCredential, response, challengeStore } = createValidAssertion();
		const adapter = {
			getCredentialById: vi.fn(async () => validCredential),
			updateCredentialCounter: vi.fn(async () => undefined),
			getUserById: vi.fn(async () => null),
		} as unknown as AuthAdapter;

		try {
			await authenticateWithPasskey(config, adapter, response, challengeStore);
			expect.fail("Expected passkey authentication to fail");
		} catch (error) {
			expect(error).toBeInstanceOf(PasskeyAuthenticationError);
			expect(error).toMatchObject({ code: "user_not_found" });
		}
	});

	it("rejects an origin that is not in the accepted list", async () => {
		// Single-origin config; assertion arrives from a different subdomain.
		const singleOriginConfig = {
			rpName: "Test Site",
			rpId: "example.com",
			origins: ["https://example.com"],
		};
		const {
			credential: validCredential,
			response,
			challengeStore,
		} = createValidAssertion({
			rpId: "example.com",
			origin: "https://preview.example.com",
		});
		const adapter = {
			getCredentialById: vi.fn(async () => validCredential),
			updateCredentialCounter: vi.fn(async () => undefined),
			getUserById: vi.fn(async () => ({ id: "user_1" })),
		} as unknown as AuthAdapter;

		try {
			await authenticateWithPasskey(singleOriginConfig, adapter, response, challengeStore);
			expect.fail("Expected origin rejection");
		} catch (error) {
			expect(error).toBeInstanceOf(PasskeyAuthenticationError);
			expect(error).toMatchObject({ code: "invalid_origin" });
			expect((error as PasskeyAuthenticationError).message).toContain(
				"https://preview.example.com",
			);
		}
	});

	it("accepts an assertion from a subdomain when its origin is listed under a shared rpId", async () => {
		// Reproduces emdash-cms/emdash#393 follow-up: apex + preview share rpId,
		// passkey was bound to the apex but the user is hitting preview.
		const multiOriginConfig = {
			rpName: "Test Site",
			rpId: "example.com",
			origins: ["https://example.com", "https://preview.example.com"],
		};
		const {
			credential: validCredential,
			response,
			challengeStore,
		} = createValidAssertion({
			rpId: "example.com",
			origin: "https://preview.example.com",
		});
		const adapter = {
			getCredentialById: vi.fn(async () => validCredential),
			updateCredentialCounter: vi.fn(async () => undefined),
			getUserById: vi.fn(async () => ({
				id: "user_1",
				email: "u@example.com",
				name: null,
				role: "admin",
			})),
		} as unknown as AuthAdapter;

		// Should not throw — origin is in the accepted list.
		const user = await authenticateWithPasskey(
			multiOriginConfig,
			adapter,
			response,
			challengeStore,
		);
		expect(user).toMatchObject({ id: "user_1" });
	});
});
