/**
 * I-series: invitation acceptance UI (Fase 7.2).
 *
 * Ana (admin of the seed collective) generates an invitation link in
 * /collective/manage; Eva (non-member) accepts it. The test uses TWO browser
 * contexts — one per user — to keep sessions isolated and to capture the
 * token produced by the UI without cross-contaminating localStorage.
 */
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
import { resetEva, seedExpiredInvitation, seedUsedInvitation, countMembership } from '../fixtures/db.js';
import { closePool, sql, COLLECTIVE_ID } from '@colectivo/test-utils';

test.describe.configure({ mode: 'serial' });

test.describe('Invitations', () => {
	test.beforeEach(async () => {
		await resetEva();
	});

	test.afterAll(async () => {
		await resetEva();
		// Wipe invitations Ana created during the suite so the DB is clean for
		// downstream suites.
		await sql('DELETE FROM public.collective_invitations WHERE collective_id = $1', [
			COLLECTIVE_ID
		]);
		await closePool();
	});

	test('I-01: Ana generates an invitation link with a valid token', async ({ page }) => {
		await loginAs(page, USERS.ana);
		await page.goto('/collective/manage');
		await expect(page.getByTestId(`role-select-${USERS.borja.id}`)).toBeVisible({
			timeout: 15_000
		});
		await page.getByTestId('generate-invite').click();
		const link = page.getByTestId('invite-link');
		await expect(link).toBeVisible({ timeout: 5_000 });

		const url = (await link.textContent())?.trim() ?? '';
		expect(url).toMatch(/\/invitation\/[0-9a-f-]{36}$/i);
		const token = url.split('/').pop()!;

		// DB row reflects an active (not-yet-used, not-expired) invitation.
		const row = await sql(
			`SELECT role, accepted_at, expires_at > now() AS active
			 FROM public.collective_invitations WHERE token = $1`,
			[token]
		);
		expect(row.rowCount).toBe(1);
		expect(row.rows[0].accepted_at).toBeNull();
		expect(row.rows[0].active).toBe(true);
	});

	test('I-02: logged-out Eva opens invite → login → accept → becomes member', async ({
		browser
	}) => {
		// Ana: generate the token in one context.
		const anaCtx = await browser.newContext();
		const anaPage = await anaCtx.newPage();
		await loginAs(anaPage, USERS.ana);
		await anaPage.goto('/collective/manage');
		await anaPage.getByTestId('generate-invite').click();
		const url = (await anaPage.getByTestId('invite-link').textContent())?.trim() ?? '';
		const token = url.split('/').pop()!;
		await anaCtx.close();

		// Eva: logged-out context, hit the invitation URL directly.
		const evaCtx = await browser.newContext();
		const evaPage = await evaCtx.newPage();
		// The invitation page stores the token in sessionStorage and kicks off
		// Keycloak login. After login Eva lands back on the app; the token is
		// consumed by the /invitation/[token] page on next navigation.
		await evaPage.goto(`/invitation/${token}`);
		// Keycloak login form:
		await evaPage.waitForURL(/\/realms\/colectivo\/protocol\/openid-connect\/auth/, {
			timeout: 15_000
		});
		await evaPage.fill('#username', USERS.eva.email);
		await evaPage.fill('#password', USERS.eva.password);
		await evaPage.click('#kc-login');

		// Back in the app. There are two UX paths here depending on whether
		// sessionStorage carried the token across the redirect; either lands on
		// /invitation/[token] with the accept button visible, or on / which
		// redirects to /onboarding (no collective yet). Be liberal in what we
		// accept: navigate back to the token page if we drifted.
		// Wait for auth-token to land, then wait for the root layout's post-login
		// redirect to route us back to /invitation/<token>. If for some reason we
		// drift elsewhere (race on localStorage presence vs. goto firing), force-
		// navigate back.
		await evaPage.waitForFunction(
			() =>
				Object.keys(localStorage).some(
					(k) => k.startsWith('sb-') && k.endsWith('-auth-token')
				),
			null,
			{ timeout: 20_000 }
		);
		await evaPage.waitForURL(new RegExp(`/invitation/${token}`), { timeout: 10_000 }).catch(async () => {
			await evaPage.goto(`/invitation/${token}`);
		});

		// The page flips through loading → ready once it sees an authenticated session.
		const acceptBtn = evaPage.getByTestId('invitation-accept');
		await expect(acceptBtn).toBeVisible({ timeout: 15_000 });
		await acceptBtn.click();
		await expect(evaPage).toHaveURL(/\/lists$/, { timeout: 15_000 });

		// Membership landed in the DB.
		expect(await countMembership(COLLECTIVE_ID, USERS.eva.id)).toBe(1);
		await evaCtx.close();
	});

	test('I-03: logged-in Eva accepts a fresh invite in one click', async ({ browser }) => {
		const anaCtx = await browser.newContext();
		const anaPage = await anaCtx.newPage();
		await loginAs(anaPage, USERS.ana);
		await anaPage.goto('/collective/manage');
		await anaPage.getByTestId('generate-invite').click();
		const url = (await anaPage.getByTestId('invite-link').textContent())?.trim() ?? '';
		const token = url.split('/').pop()!;
		await anaCtx.close();

		const evaCtx = await browser.newContext();
		const evaPage = await evaCtx.newPage();
		// Log Eva in first (she has no collective, so she lands on /onboarding).
		await loginAs(evaPage, USERS.eva, { waitForCollective: false });
		await evaPage.goto(`/invitation/${token}`);
		await evaPage.getByTestId('invitation-accept').click();
		await expect(evaPage).toHaveURL(/\/lists$/, { timeout: 10_000 });
		expect(await countMembership(COLLECTIVE_ID, USERS.eva.id)).toBe(1);
		await evaCtx.close();
	});

	test('I-04: expired invitation shows the expired state, no membership', async ({ page }) => {
		const token = await seedExpiredInvitation();

		await loginAs(page, USERS.eva, { waitForCollective: false });
		await page.goto(`/invitation/${token}`);

		// The page initially shows the accept button — the server-side RPC is
		// the one that detects the expiration. Click accept, then assert the
		// error state.
		await page.getByTestId('invitation-accept').click();
		await expect(page.getByTestId('invitation-error')).toBeVisible({ timeout: 10_000 });
		const errKey = await page.getByTestId('invitation-error').getAttribute('data-error-key');
		expect(errKey).toBe('expired');
		expect(await countMembership(COLLECTIVE_ID, USERS.eva.id)).toBe(0);
	});

	test('I-05: already-used invitation shows the used state, no extra membership', async ({
		page
	}) => {
		const token = await seedUsedInvitation();

		await loginAs(page, USERS.eva, { waitForCollective: false });
		await page.goto(`/invitation/${token}`);

		await page.getByTestId('invitation-accept').click();
		await expect(page.getByTestId('invitation-error')).toBeVisible({ timeout: 10_000 });
		const errKey = await page.getByTestId('invitation-error').getAttribute('data-error-key');
		expect(errKey).toBe('already_used');
		expect(await countMembership(COLLECTIVE_ID, USERS.eva.id)).toBe(0);
	});
});
