/**
 * tokens.spec.js — server-backed Playwright tests for /.tokens.
 *
 * Starts a real zddc-server master via tests/lib/server.mjs, drives a
 * Chromium browser at the live URL with X-Auth-Request-Email injected
 * (mimicking what an authenticating reverse proxy would do upstream),
 * and exercises the create / list / revoke flow end-to-end through the
 * inline JS on the page.
 *
 * Why a server-backed harness vs file:// — /.tokens is a server-rendered
 * page that fetches /.api/tokens, and the JSON API authenticates via
 * the email middleware. Reproducing that with file:// + mocks misses
 * the whole contract under test. This is also the seed of a more
 * general "drive the master in a browser" pattern that future UI
 * debugging (the conflict-UI in phase 5, etc.) will reuse.
 */
import { test, expect } from '@playwright/test';
import { startMaster } from './lib/server.js';

test.describe('/.tokens self-service token UI', () => {
    let server;
    const TEST_EMAIL = 'alice@example.com';

    test.beforeAll(async () => {
        server = await startMaster({ adminEmail: TEST_EMAIL });
    });

    test.afterAll(async () => {
        if (server) await server.stop();
    });

    // Inject X-Auth-Request-Email on every request from this Playwright
    // browser context. In production the upstream auth proxy adds this
    // after authenticating the user; in the test we set it directly so
    // the master sees a consistent caller without standing up a real
    // OIDC stack.
    test.use({
        extraHTTPHeaders: {
            'X-Auth-Request-Email': 'alice@example.com',
        },
    });

    test('anonymous → 401', async ({ request }) => {
        // Override the per-test header by sending without auth.
        const r = await request.get(`${server.baseURL}/.tokens`, {
            headers: { 'X-Auth-Request-Email': '' },
        });
        expect(r.status()).toBe(401);
    });

    test('authenticated GET /.tokens renders the page with email', async ({ page }) => {
        await page.goto(`${server.baseURL}/.tokens`);
        await expect(page.locator('h1')).toHaveText(/API tokens/i);
        await expect(page.locator('.who')).toContainText(TEST_EMAIL);
        await expect(page.locator('#create')).toBeVisible();
        await expect(page.locator('#tokens')).toBeVisible();
    });

    test('GET /.api/tokens initially returns empty list', async ({ request }) => {
        const r = await request.get(`${server.baseURL}/.api/tokens`);
        expect(r.status()).toBe(200);
        const list = await r.json();
        expect(Array.isArray(list)).toBe(true);
        expect(list.filter(t => t.email === TEST_EMAIL)).toEqual([]);
    });

    test('create token via the page → plaintext shown once → list contains the new entry', async ({ page }) => {
        await page.goto(`${server.baseURL}/.tokens`);

        // Wait for the inline JS's initial refresh() so we know the
        // table is populated (or shows "No tokens issued yet.").
        await expect(page.locator('#tokens tbody')).not.toBeEmpty();

        // Fill the form and submit.
        const description = `playwright-${Date.now()}`;
        await page.fill('#desc', description);
        await page.click('button[type="submit"]');

        // The plaintext token appears in #created div.token-secret —
        // shown exactly once per the API contract.
        const secret = page.locator('#created .token-secret');
        await expect(secret).toBeVisible();
        const plaintext = (await secret.textContent()).trim();
        expect(plaintext.length).toBeGreaterThan(20);
        expect(plaintext).not.toContain('<');
        expect(plaintext).not.toContain('"');

        // The token appears in the table.
        const row = page.locator('#tokens tbody tr', { hasText: description });
        await expect(row).toBeVisible();

        // Verify via the API too — the listed token's description matches.
        const r = await page.request.get(`${server.baseURL}/.api/tokens`);
        const list = await r.json();
        const matches = list.filter(t => t.description === description);
        expect(matches.length).toBe(1);
        expect(matches[0].email).toBe(TEST_EMAIL);

        // Revoke via the row's button. The page's confirm() dialog needs
        // to be auto-accepted.
        page.on('dialog', d => d.accept());
        await row.locator('button.danger').click();

        // Token should disappear from the table.
        await expect(page.locator('#tokens tbody tr', { hasText: description })).toHaveCount(0);

        // And from the API list.
        const after = await (await page.request.get(`${server.baseURL}/.api/tokens`)).json();
        expect(after.filter(t => t.description === description)).toEqual([]);
    });

    test('plaintext token authenticates a subsequent Bearer request', async ({ request }) => {
        // Issue a token via the API.
        const created = await request.post(`${server.baseURL}/.api/tokens`, {
            data: { description: 'bearer-roundtrip' },
            headers: { 'Content-Type': 'application/json' },
        });
        expect(created.status()).toBe(201);
        const { token } = await created.json();

        // Use the bearer in a fresh request that does NOT carry the
        // email header — the master should accept it because the token
        // resolves to alice@example.com.
        const noEmail = await request.get(`${server.baseURL}/`, {
            headers: {
                'X-Auth-Request-Email': '',
                'Authorization': `Bearer ${token}`,
                'Accept': 'application/json',
            },
        });
        expect(noEmail.status()).toBe(200);
    });

    test('invalid Bearer → 401', async ({ request }) => {
        const r = await request.get(`${server.baseURL}/`, {
            headers: {
                'X-Auth-Request-Email': '',
                'Authorization': 'Bearer not-a-real-token',
            },
        });
        expect(r.status()).toBe(401);
    });

    test('cross-user revoke returns 404 (no ownership leak)', async ({ request }) => {
        // alice creates a token.
        const r1 = await request.post(`${server.baseURL}/.api/tokens`, {
            data: { description: 'alice-only' },
            headers: { 'Content-Type': 'application/json' },
        });
        const { id } = await r1.json();

        // bob attempts to revoke. Should 404, not 403, to avoid
        // leaking the existence of alice's token.
        const r2 = await request.delete(`${server.baseURL}/.api/tokens/${id}`, {
            headers: { 'X-Auth-Request-Email': 'bob@example.com' },
        });
        expect(r2.status()).toBe(404);

        // alice's token should still validate.
        const list = await (await request.get(`${server.baseURL}/.api/tokens`)).json();
        expect(list.find(t => t.id === id)).toBeTruthy();
    });

    test('XSS guard: description with HTML special chars is escaped on render', async ({ page }) => {
        const xssDesc = `<img src=x onerror="window.__xss=1">`;
        await page.goto(`${server.baseURL}/.tokens`);
        await page.fill('#desc', xssDesc);
        await page.click('button[type="submit"]');
        // Wait for the row to appear in the table.
        await expect(page.locator('#tokens tbody')).toContainText('<img');
        // The literal <img> tag should NOT have been parsed as HTML —
        // window.__xss must remain undefined.
        const xssFired = await page.evaluate(() => window.__xss === 1);
        expect(xssFired).toBe(false);
        // And the on-disk text content of the cell should contain the
        // literal angle brackets, proving they were escaped.
        const rowText = await page.locator('#tokens tbody tr', { hasText: 'img src' }).textContent();
        expect(rowText).toContain('<img');
    });
});
