/**
 * Two-factor authentication (TOTP) E2E tests.
 *
 * These tests run serially and share mutable state (the enrolment secret and
 * the freshly issued backup codes). The chain is:
 *   1. Enrol via the Account section, capture secret and backup codes from the
 *      network responses so we do not have to scrape the DOM.
 *   2. Log out, log back in, satisfy the TOTP challenge, land on the dashboard.
 *   3. Log out, log back in, satisfy the challenge with a backup code,
 *      re-use the same backup code and confirm the second attempt fails.
 *   4. Disable 2FA to leave the dev DB in a clean state for the next run.
 *
 * If a previous run aborted mid-way, the test user may already have MFA on.
 * Run `node backend/dist/cli/resetMfa.js <username>` or wipe the dev DB first.
 */
import { test, expect, Page } from '@playwright/test';
import { loginAs, totpNow, TEST_USERNAME, TEST_PASSWORD, isDashboard } from './helpers';

async function logout(page: Page) {
  await page.getByRole('button', { name: /profile/i }).click();
  await page.getByRole('button', { name: /log out/i }).click();
  // The MfaChallenge / Login screen has no dashboard indicator.
  await expect.poll(async () => isDashboard(page), { timeout: 5_000 }).toBe(false);
}

async function openAccountSettings(page: Page) {
  await page.getByRole('button', { name: /profile/i }).click();
  await page.getByRole('button', { name: 'Settings', exact: true }).click();
  await expect(page.getByRole('heading', { name: /^Password$/i })).toBeVisible();
}

/** Fill a login form (no MFA branch). */
async function fillLoginForm(page: Page, username: string, password: string) {
  await page.locator('#username').fill(username);
  await page.locator('#password').fill(password);
  await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
}

test.describe.serial('Two-factor authentication', () => {
  let secret = '';
  let backupCodes: string[] = [];

  // Safety net: if any test above fails, Playwright skips the rest of the
  // serial block, so the "disable 2FA" test never runs and the shared test
  // user stays MFA-enabled in the dev DB. That wrecks every subsequent spec
  // (nodes, stacks, screenshots) because their loginAs helper does not know
  // about the challenge screen. afterAll always runs, so we clear MFA here
  // via the API using whatever enrolment state we captured.
  test.afterAll(async ({ request }) => {
    if (!secret || backupCodes.length < 2) return;
    try {
      // Use backup codes for both steps: they are single-use and sidestep
      // the TOTP replay blacklist, so we do not need to reason about which
      // 30-second window we are currently in.
      const loginBackup = backupCodes[backupCodes.length - 2];
      const disableBackup = backupCodes[backupCodes.length - 1];
      await request.post('/api/auth/login', {
        data: { username: TEST_USERNAME, password: TEST_PASSWORD },
      });
      const loginRes = await request.post('/api/auth/login/mfa', {
        data: { code: loginBackup, isBackupCode: true },
      });
      if (!loginRes.ok()) return;
      await request.post('/api/auth/mfa/disable', {
        data: { code: disableBackup, isBackupCode: true },
      });
    } catch {
      // Best effort; if this fails the next full-suite run will need a
      // manual DB wipe or CLI reset.
    }
  });

  test('enrol from Account settings captures secret and backup codes', async ({ page }) => {
    await loginAs(page, TEST_USERNAME, TEST_PASSWORD);
    await openAccountSettings(page);

    // Capture the raw base32 secret from the enroll/start response so we
    // do not need to strip formatting spaces off the DOM value.
    const startPromise = page.waitForResponse(
      (r) => r.url().includes('/api/auth/mfa/enroll/start') && r.status() === 200,
    );
    await page.getByRole('button', { name: /Set up 2FA/i }).click();
    const startRes = await startPromise;
    const startBody = await startRes.json();
    secret = startBody.secret;
    expect(secret).toMatch(/^[A-Z2-7]+$/); // base32 alphabet

    // Step 1 (QR) -> Continue
    await page.getByRole('button', { name: /^Continue$/ }).click();

    // Step 2 (Confirm): enter a fresh TOTP. The confirm step auto-submits on
    // the sixth digit, so no explicit click is required. Capture the backup
    // codes from the response.
    const confirmPromise = page.waitForResponse(
      (r) => r.url().includes('/api/auth/mfa/enroll/confirm') && r.status() === 200,
    );
    await page.locator('#mfa-confirm-code').fill(totpNow(secret));
    const confirmRes = await confirmPromise;
    const confirmBody = await confirmRes.json();
    backupCodes = confirmBody.backupCodes;
    expect(backupCodes.length).toBe(10);

    // Step 3 (Backup codes) -> acknowledge.
    await page.getByRole('button', { name: /^Done$/ }).click();

    // Section kicker flips to 'enabled' and the field shows 'enrolled'.
    await expect(page.getByText('enrolled')).toBeVisible();
  });

  test('low backup codes warning renders when <=2 codes remain', async ({ page }) => {
    // Mock the status endpoint so we can exercise the warning branch without
    // racing backup-code consumption in this serial suite. The UI only cares
    // about the fields on the response, so this is a pure rendering check.
    await page.route('**/api/auth/mfa/status', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ enabled: true, backupCodesRemaining: 1, sso_enforce_mfa: false }),
      });
    });

    // The test user has MFA on, so loginAs is not usable. Drive the challenge
    // manually with a backup code so we do not race the TOTP replay blacklist
    // against the next test's fresh code in the same 30-second window.
    await page.goto('/');
    await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
    await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
    await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
    await page.getByRole('button', { name: /Use backup code/i }).click();
    await page.locator('#mfa-backup').fill(backupCodes[5]);
    await page.getByRole('button', { name: /^Verify$/ }).click();
    await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);

    await openAccountSettings(page);
    // SettingsField body renders "{n} remaining"; helper renders "Running low. Regenerate a fresh set."
    await expect(page.getByText('1 remaining')).toBeVisible();
    await expect(page.getByText(/regenerate a fresh set/i)).toBeVisible();

    // Now exercise the exhausted branch (0 codes): the dedicated warning card.
    await page.unroute('**/api/auth/mfa/status');
    await page.route('**/api/auth/mfa/status', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ enabled: true, backupCodesRemaining: 0, sso_enforce_mfa: false }),
      });
    });

    // Navigate away first so AccountSection unmounts and refetches on the
    // next open (same-URL navigation in the route-based design does not
    // trigger a remount, so Escape alone is not enough).
    await page.goto('/');
    await openAccountSettings(page);
    // Verify the zero-codes warning card is rendered in the DOM.
    // The callout is below the Disable 2FA section, scrolled out of the
    // Radix ScrollArea's clipping root on a standard 1280x720 viewport.
    // toBeAttached confirms the component responded to backupCodesRemaining:0
    // without requiring the element to be in the visible scroll position.
    await expect(page.getByText(/No backup codes left/i)).toBeAttached({ timeout: 10_000 });
    await expect(page.getByText(/recovery needs an administrator/i)).toBeAttached();

    await page.unroute('**/api/auth/mfa/status');
  });

  test('typing a 6-digit TOTP auto-submits and reaches the dashboard', async ({ page }) => {
    // Fresh page lands on the login screen; password passes but the MFA
    // challenge appears because test #1 enrolled the user. Entering the 6th
    // digit must auto-submit the form without the user clicking "Verify".
    await page.goto('/');
    await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
    await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
    await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();

    // fill() emits the final value in a single onChange, which at length === 6
    // schedules a submit via requestAnimationFrame. No explicit click.
    await page.locator('#mfa-otp').fill(totpNow(secret));

    await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
  });

  test('backup code entered without the dash still succeeds', async ({ page }) => {
    // The backup-code input accepts any paste form; the client normalises to
    // 10 alphanumeric characters before sending. Consumes backupCodes[4].
    await page.goto('/');
    await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });

    const raw = backupCodes[4].replace('-', '');
    expect(raw).toMatch(/^[A-Z0-9]{10}$/);

    await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
    await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
    await page.getByRole('button', { name: /Use backup code/i }).click();
    await page.locator('#mfa-backup').fill(raw);
    await page.getByRole('button', { name: /^Verify$/ }).click();
    await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
  });

  test('backup code works once and cannot be replayed', async ({ page }) => {
    await page.goto('/');
    await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });

    const code = backupCodes[0];
    expect(code).toBeTruthy();

    // First use: should succeed.
    await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
    await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
    await page.getByRole('button', { name: /Use backup code/i }).click();
    await page.locator('#mfa-backup').fill(code);
    await page.getByRole('button', { name: /^Verify$/ }).click();
    await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);

    // Log out and try the same backup code again: should fail.
    await logout(page);
    await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
    await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
    await page.getByRole('button', { name: /Use backup code/i }).click();
    await page.locator('#mfa-backup').fill(code);
    await page.getByRole('button', { name: /^Verify$/ }).click();

    // Error should be visible and we should still be on the challenge screen.
    await expect(page.locator('.text-destructive')).toBeVisible();
    expect(await isDashboard(page)).toBe(false);

    // Recover using a fresh backup code. Using a TOTP here races the
    // 30-second window against the one test #2 consumed, which the server
    // (correctly) rejects as a replay when the boundary falls the wrong
    // way. Backup codes are single-use and sidestep that blacklist.
    await page.locator('#mfa-backup').clear();
    await page.locator('#mfa-backup').fill(backupCodes[1]);
    await page.getByRole('button', { name: /^Verify$/ }).click();
    await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
  });

  test('disable 2FA with a valid code removes the challenge on next login', async ({ page }) => {
    // loginAs does not understand the MFA challenge screen, so drive the
    // login manually. Use a backup code for both the challenge and the
    // disable step so we do not race the TOTP replay blacklist against
    // codes consumed by earlier tests in this serial block.
    await page.goto('/');
    await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
    await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
    await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
    await page.getByRole('button', { name: /Use backup code/i }).click();
    await page.locator('#mfa-backup').fill(backupCodes[2]);
    await page.getByRole('button', { name: /^Verify$/ }).click();
    await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);

    await openAccountSettings(page);
    await page.getByRole('button', { name: /Disable 2FA/i }).click();
    await page.getByRole('button', { name: /Use backup code/i }).click();
    await page.locator('#mfa-disable-backup').fill(backupCodes[3]);
    await page.getByRole('button', { name: /^Disable$/ }).click();

    // Card flips back to the "Set up 2FA" call to action.
    await expect(page.getByRole('button', { name: /Set up 2FA/i })).toBeVisible();

    // Close settings, log out, log back in without the MFA challenge.
    await page.keyboard.press('Escape').catch(() => {});
    await logout(page);
    await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
    await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
  });
});
