import { beforeEach, describe, expect, test } from "vitest";
import type { GenericEndpointContext } from "better-auth";
import { eq } from "drizzle-orm";
import { db } from "~/server/db/db";
import { account, invitation, member, organization, ssoProvider, usersTable, verification } from "~/server/db/schema";
import { ssoIntegration } from "../sso.integration";
import { ensureDefaultOrg } from "~/server/lib/auth/helpers/create-default-org";
import { SSO_INVITATION_INTENT_COOKIE, ssoService } from "../sso.service";

function createMockSsoCallbackContext(providerId: string, cookie?: string): GenericEndpointContext {
	const headers = new Headers();
	if (cookie) {
		headers.set("cookie", cookie);
	}

	return {
		path: `/sso/callback/${providerId}`,
		body: {},
		query: {},
		headers,
		request: new Request(`http://localhost:3000/sso/callback/${providerId}`, { headers }),
		params: { providerId },
		method: "POST",
		context: {} as GenericEndpointContext["context"],
	} as unknown as GenericEndpointContext;
}

function randomId() {
	return Bun.randomUUIDv7();
}

function randomSlug(prefix: string) {
	return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
}

async function createUser(email: string, username: string) {
	const userId = randomId();
	await db.insert(usersTable).values({
		id: userId,
		email,
		name: username,
		username,
	});
	return userId;
}

async function createUserWithCredentialAccount(email: string, username: string) {
	const userId = await createUser(email, username);

	await db.insert(account).values({
		id: randomId(),
		accountId: username,
		providerId: "credential",
		userId,
		password: "test-password-hash",
		createdAt: new Date(),
		updatedAt: new Date(),
	});

	return userId;
}

describe("ssoIntegration.resolveOrgMembership", () => {
	beforeEach(async () => {
		await db.delete(member);
		await db.delete(account);
		await db.delete(invitation);
		await db.delete(ssoProvider);
		await db.delete(verification);
		await db.delete(organization);
		await db.delete(usersTable);
	});

	test("creates invited membership from SSO callback request context", async () => {
		const invitedUserId = await createUser("invited@example.com", randomSlug("invited"));
		const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
		const organizationId = randomId();

		await db.insert(organization).values({
			id: organizationId,
			name: "Acme",
			slug: randomSlug("acme"),
			createdAt: new Date(),
		});

		await db.insert(ssoProvider).values({
			id: randomId(),
			providerId: "oidc-acme",
			organizationId,
			userId: inviterId,
			issuer: "https://issuer.example.com",
			domain: "example.com",
		});

		await db.insert(invitation).values({
			id: randomId(),
			organizationId,
			email: "invited@example.com",
			role: "member",
			status: "pending",
			expiresAt: new Date(Date.now() + 60 * 60 * 1000),
			createdAt: new Date(),
			inviterId,
		});

		const ctx = createMockSsoCallbackContext("oidc-acme");
		const membership = await ssoIntegration.resolveOrgMembership(invitedUserId, ctx);

		expect(membership).not.toBeNull();
		expect(membership?.organizationId).toBe(organizationId);
		expect(membership?.role).toBe("member");

		const updatedInvitations = await db
			.select()
			.from(invitation)
			.where(eq(invitation.organizationId, organizationId));
		const updatedInvitation = updatedInvitations.find((i) => i.email === "invited@example.com");
		expect(updatedInvitation?.status).toBe("accepted");
	});

	test("blocks SSO callback users without pending invitations", async () => {
		const userId = await createUser("new-user@example.com", randomSlug("new-user"));
		const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
		const organizationId = randomId();

		await db.insert(organization).values({
			id: organizationId,
			name: "Acme",
			slug: randomSlug("acme"),
			createdAt: new Date(),
		});

		await db.insert(ssoProvider).values({
			id: randomId(),
			providerId: "oidc-acme",
			organizationId,
			userId: inviterId,
			issuer: "https://issuer.example.com",
			domain: "example.com",
		});

		const ctx = createMockSsoCallbackContext("oidc-acme");
		await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only");
	});

	test("blocks existing users with a personal org from SSO orgs they were not invited to", async () => {
		const userId = await createUser("alice@example.com", randomSlug("alice"));
		const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));

		const personalOrgId = randomId();
		await db.insert(organization).values({
			id: personalOrgId,
			name: "Alice's Workspace",
			slug: randomSlug("alice"),
			createdAt: new Date(),
		});
		await db.insert(member).values({
			id: randomId(),
			userId,
			organizationId: personalOrgId,
			role: "owner",
			createdAt: new Date(),
		});

		const ssoOrgId = randomId();
		await db.insert(organization).values({
			id: ssoOrgId,
			name: "Acme Corp",
			slug: randomSlug("acme"),
			createdAt: new Date(),
		});
		await db.insert(ssoProvider).values({
			id: randomId(),
			providerId: "oidc-acme",
			organizationId: ssoOrgId,
			userId: inviterId,
			issuer: "https://issuer.example.com",
			domain: "example.com",
		});

		const ctx = createMockSsoCallbackContext("oidc-acme");
		await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only");
	});

	test("returns null when context is not an SSO callback", async () => {
		const userId = await createUser("local-user@example.com", randomSlug("local-user"));

		const result = await ssoIntegration.resolveOrgMembership(userId, null);
		expect(result).toBeNull();
	});

	test("blocks user whose invitation has expired", async () => {
		const userId = await createUser("expired@example.com", randomSlug("expired"));
		const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
		const organizationId = randomId();

		await db.insert(organization).values({
			id: organizationId,
			name: "Acme",
			slug: randomSlug("acme"),
			createdAt: new Date(),
		});

		await db.insert(ssoProvider).values({
			id: randomId(),
			providerId: "oidc-acme",
			organizationId,
			userId: inviterId,
			issuer: "https://issuer.example.com",
			domain: "example.com",
		});

		await db.insert(invitation).values({
			id: randomId(),
			organizationId,
			email: "expired@example.com",
			role: "member",
			status: "pending",
			expiresAt: new Date(Date.now() - 60 * 60 * 1000), // expired 1 hour ago
			createdAt: new Date(),
			inviterId,
		});

		const ctx = createMockSsoCallbackContext("oidc-acme");
		await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only");
	});

	test("blocks user whose invitation was already accepted", async () => {
		const userId = await createUser("returning@example.com", randomSlug("returning"));
		const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
		const organizationId = randomId();

		await db.insert(organization).values({
			id: organizationId,
			name: "Acme",
			slug: randomSlug("acme"),
			createdAt: new Date(),
		});

		await db.insert(ssoProvider).values({
			id: randomId(),
			providerId: "oidc-acme",
			organizationId,
			userId: inviterId,
			issuer: "https://issuer.example.com",
			domain: "example.com",
		});

		// Invitation was already consumed (user was removed from org, invitation remains accepted)
		await db.insert(invitation).values({
			id: randomId(),
			organizationId,
			email: "returning@example.com",
			role: "member",
			status: "accepted",
			expiresAt: new Date(Date.now() + 60 * 60 * 1000),
			createdAt: new Date(),
			inviterId,
		});

		const ctx = createMockSsoCallbackContext("oidc-acme");
		await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only");
	});

	test("does not grant access to org B when invitation belongs to a different org A", async () => {
		const userId = await createUser("alice@example.com", randomSlug("alice"));
		const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));

		const orgAId = randomId();
		await db.insert(organization).values({
			id: orgAId,
			name: "Org A",
			slug: randomSlug("org-a"),
			createdAt: new Date(),
		});

		const orgBId = randomId();
		await db.insert(organization).values({
			id: orgBId,
			name: "Org B",
			slug: randomSlug("org-b"),
			createdAt: new Date(),
		});

		// SSO provider belongs to org B
		await db.insert(ssoProvider).values({
			id: randomId(),
			providerId: "oidc-org-b",
			organizationId: orgBId,
			userId: inviterId,
			issuer: "https://issuer.example.com",
			domain: "example.com",
		});

		// User has a valid pending invitation, but only for org A — not org B
		await db.insert(invitation).values({
			id: randomId(),
			organizationId: orgAId,
			email: "alice@example.com",
			role: "member",
			status: "pending",
			expiresAt: new Date(Date.now() + 60 * 60 * 1000),
			createdAt: new Date(),
			inviterId,
		});

		const ctx = createMockSsoCallbackContext("oidc-org-b");
		await expect(ssoIntegration.resolveOrgMembership(userId, ctx)).rejects.toThrow("invite-only");
	});

	test("keeps invited org assignment when session context loses providerId after user creation", async () => {
		const userId = await createUser("alice@example.com", randomSlug("alice"));
		const inviterId = await createUser("inviter@example.com", randomSlug("inviter"));
		const invitedOrgId = randomId();

		await db.insert(organization).values({
			id: invitedOrgId,
			name: "Acme Corp",
			slug: randomSlug("acme"),
			createdAt: new Date(),
		});

		await db.insert(ssoProvider).values({
			id: randomId(),
			providerId: "oidc-acme",
			organizationId: invitedOrgId,
			userId: inviterId,
			issuer: "https://issuer.example.com",
			domain: "example.com",
		});

		await db.insert(invitation).values({
			id: randomId(),
			organizationId: invitedOrgId,
			email: "alice@example.com",
			role: "member",
			status: "pending",
			expiresAt: new Date(Date.now() + 60 * 60 * 1000),
			createdAt: new Date(),
			inviterId,
		});

		const user = await db.query.usersTable.findFirst({ where: { id: userId } });
		if (!user) {
			throw new Error("Expected user to exist");
		}

		await ssoIntegration.onUserCreated(user, createMockSsoCallbackContext("oidc-acme"));

		const membership = await ensureDefaultOrg(userId);

		expect(membership.organizationId).toBe(invitedOrgId);

		const [updatedInvitation] = await db
			.select()
			.from(invitation)
			.where(eq(invitation.organizationId, invitedOrgId));
		expect(updatedInvitation.status).toBe("accepted");
	});
});

describe("ssoIntegration.canLinkSsoAccount", () => {
	beforeEach(async () => {
		await db.delete(member);
		await db.delete(account);
		await db.delete(invitation);
		await db.delete(ssoProvider);
		await db.delete(verification);
		await db.delete(organization);
		await db.delete(usersTable);
	});

	async function setupOrgWithProvider(providerId: string, autoLinkMatchingEmails = false) {
		const orgId = randomId();
		const ownerId = await createUser(`owner-${randomSlug("u")}@example.com`, randomSlug("owner"));
		await db
			.insert(organization)
			.values({ id: orgId, name: "Acme", slug: randomSlug("acme"), createdAt: new Date() });
		await db.insert(ssoProvider).values({
			id: randomId(),
			providerId,
			organizationId: orgId,
			userId: ownerId,
			issuer: "https://issuer.example.com",
			domain: "example.com",
			autoLinkMatchingEmails,
		});
		return { orgId, ownerId };
	}

	const autoLinkingCases = [
		{ autoLinkingEnabled: false, autoLinkingLabel: "disabled" },
		{ autoLinkingEnabled: true, autoLinkingLabel: "enabled" },
	] as const;

	test("allows linking for a user who is already a member of the org", async () => {
		const { orgId } = await setupOrgWithProvider("oidc-acme");
		const userId = await createUserWithCredentialAccount("alice@example.com", randomSlug("alice"));
		await db.insert(member).values({
			id: randomId(),
			userId,
			organizationId: orgId,
			role: "member",
			createdAt: new Date(),
		});

		const allowed = await ssoIntegration.canLinkSsoAccount(userId, "oidc-acme", null);

		expect(allowed).toBe(true);
	});

	test.each(autoLinkingCases)(
		"allows linking for a new SSO user with a valid pending invitation (auto-linking $autoLinkingLabel)",
		async ({ autoLinkingEnabled }) => {
			const providerId = `oidc-acme-${autoLinkingEnabled ? "on" : "off"}`;
			const { orgId, ownerId } = await setupOrgWithProvider(providerId, autoLinkingEnabled);
			const userId = await createUser("alice@example.com", randomSlug("alice"));
			await db.insert(invitation).values({
				id: randomId(),
				organizationId: orgId,
				email: "alice@example.com",
				role: "member",
				status: "pending",
				expiresAt: new Date(Date.now() + 60 * 60 * 1000),
				createdAt: new Date(),
				inviterId: ownerId,
			});

			const allowed = await ssoIntegration.canLinkSsoAccount(userId, providerId, null);

			expect(allowed).toBe(true);
		},
	);

	test.each(autoLinkingCases)(
		"blocks linking for an existing user account with a pending invitation when auto-linking is $autoLinkingLabel",
		async ({ autoLinkingEnabled }) => {
			const providerId = `oidc-acme-${autoLinkingEnabled ? "on" : "off"}`;
			const { orgId, ownerId } = await setupOrgWithProvider(providerId, autoLinkingEnabled);
			const userId = await createUserWithCredentialAccount("alice@example.com", randomSlug("alice"));
			await db.insert(invitation).values({
				id: randomId(),
				organizationId: orgId,
				email: "alice@example.com",
				role: "member",
				status: "pending",
				expiresAt: new Date(Date.now() + 60 * 60 * 1000),
				createdAt: new Date(),
				inviterId: ownerId,
			});

			const allowed = await ssoIntegration.canLinkSsoAccount(
				userId,
				providerId,
				createMockSsoCallbackContext(providerId),
			);

			expect(allowed).toBe(false);
		},
	);

	test("allows an existing account to link only through a matching SSO invitation intent", async () => {
		const { orgId, ownerId } = await setupOrgWithProvider("oidc-primary", false);
		await db.insert(ssoProvider).values({
			id: randomId(),
			providerId: "oidc-backup",
			organizationId: orgId,
			userId: ownerId,
			issuer: "https://backup-issuer.example.com",
			domain: "example.com",
			autoLinkMatchingEmails: false,
		});
		const userId = await createUserWithCredentialAccount("alice@example.com", randomSlug("alice"));
		const invitationId = randomId();
		await db.insert(invitation).values({
			id: invitationId,
			organizationId: orgId,
			email: "alice@example.com",
			role: "member",
			status: "pending",
			expiresAt: new Date(Date.now() + 60 * 60 * 1000),
			createdAt: new Date(),
			inviterId: ownerId,
		});
		const token = await ssoService.createInvitationSsoIntent({
			userId,
			invitationId,
			providerId: "oidc-backup",
			organizationId: orgId,
			email: "alice@example.com",
		});
		const cookie = `${SSO_INVITATION_INTENT_COOKIE}=${token}`;

		await expect(
			ssoIntegration.canLinkSsoAccount(
				userId,
				"oidc-primary",
				createMockSsoCallbackContext("oidc-primary", cookie),
			),
		).resolves.toBe(false);
		await expect(
			ssoIntegration.canLinkSsoAccount(
				userId,
				"oidc-backup",
				createMockSsoCallbackContext("oidc-backup", cookie),
			),
		).resolves.toBe(true);
	});

	test("blocks linking for a user with no membership and no invitation in the org", async () => {
		await setupOrgWithProvider("oidc-acme");
		const aliceId = await createUserWithCredentialAccount("alice@example.com", randomSlug("alice"));

		const allowed = await ssoIntegration.canLinkSsoAccount(
			aliceId,
			"oidc-acme",
			createMockSsoCallbackContext("oidc-acme"),
		);

		expect(allowed).toBe(false);
	});

	test("blocks linking when the user only has an invitation to a different org", async () => {
		const { ownerId } = await setupOrgWithProvider("oidc-acme");
		const otherOrgId = randomId();
		await db
			.insert(organization)
			.values({ id: otherOrgId, name: "Other", slug: randomSlug("other"), createdAt: new Date() });
		const aliceId = await createUserWithCredentialAccount("alice@example.com", randomSlug("alice"));
		await db.insert(invitation).values({
			id: randomId(),
			organizationId: otherOrgId,
			email: "alice@example.com",
			role: "member",
			status: "pending",
			expiresAt: new Date(Date.now() + 60 * 60 * 1000),
			createdAt: new Date(),
			inviterId: ownerId,
		});

		const allowed = await ssoIntegration.canLinkSsoAccount(
			aliceId,
			"oidc-acme",
			createMockSsoCallbackContext("oidc-acme"),
		);

		expect(allowed).toBe(false);
	});

	test("blocks linking when the user's invitation to the org has expired", async () => {
		const { orgId, ownerId } = await setupOrgWithProvider("oidc-acme");
		const aliceId = await createUserWithCredentialAccount("alice@example.com", randomSlug("alice"));
		await db.insert(invitation).values({
			id: randomId(),
			organizationId: orgId,
			email: "alice@example.com",
			role: "member",
			status: "pending",
			expiresAt: new Date(Date.now() - 60 * 60 * 1000), // expired
			createdAt: new Date(),
			inviterId: ownerId,
		});

		const allowed = await ssoIntegration.canLinkSsoAccount(
			aliceId,
			"oidc-acme",
			createMockSsoCallbackContext("oidc-acme"),
		);

		expect(allowed).toBe(false);
	});
});
