import { test, expect } from '@playwright/test';

// Ops-gated admin e2e (runsheet Task 6b). Proves the happy-path 200s + island hydration + real
// Set-Cookie that the ops-free trust-boundary vitest (content-trust-boundary.test.ts) cannot:
// those tests assert the negative branches that throw before SQL; these drive the live dev server
// against a real (throwaway) Neon branch with a seeded admin.
//
// SITE_ORIGIN in .dev.vars MUST equal baseURL — same-origin CSRF gates every session mutation.

const ADMIN_EMAIL = 'admin@e2e.test';
const ADMIN_PASSWORD = 'e2e-admin-pw-9f3a2b1c';
const ORIGIN = 'http://localhost:4331';

async function signIn(page: import('@playwright/test').Page) {
  await page.goto('/admin/login');
  // Form lives inside the client:load island — getByLabel auto-waits for hydration.
  await page.getByLabel('Email').fill(ADMIN_EMAIL);
  await page.getByLabel('Password').fill(ADMIN_PASSWORD);
  await page.getByRole('button', { name: 'Sign in' }).click();
  // LoginScreen redirects to /admin on success; requireAdmin server-side then renders the list.
  await page.waitForURL('**/admin');
}

// Authenticated API create. page.request shares the browser session cookie; Origin must be set
// explicitly (session-mode CSRF). Returns the persisted entry (content.ts returns jsonOk(entry)).
async function createViaApi(
  page: import('@playwright/test').Page,
  input: { slug: string; title: string; body: string; type?: string; visibility?: string },
): Promise<{ id: string; slug: string }> {
  const res = await page.request.post('/api/admin/content', {
    headers: { origin: ORIGIN, 'content-type': 'application/json' },
    data: { type: 'post', ...input },
  });
  expect(res.ok(), `create failed: ${res.status()} ${await res.text()}`).toBeTruthy();
  return (await res.json()) as { id: string; slug: string };
}

async function publishViaApi(page: import('@playwright/test').Page, id: string): Promise<void> {
  const res = await page.request.post('/api/admin/publish', {
    headers: { origin: ORIGIN, 'content-type': 'application/json' },
    data: { id },
  });
  expect(res.ok(), `publish failed: ${res.status()} ${await res.text()}`).toBeTruthy();
}

test('full path: login → list hydrates → create draft → publish → public render', async ({ page }) => {
  // Unique per run: the throwaway branch persists state across runs, so a fixed slug would 409 on
  // the second run (slug conflict → save never redirects). Timestamp keeps the e2e re-runnable.
  const stamp = Date.now();
  const slug = `e2e-post-${stamp}`;
  const title = `E2e post ${stamp}`;
  const bodyText = `Hello from the e2e body ${stamp}`;

  await signIn(page);

  // List island hydrates: the "Content" heading is rendered by ContentListScreen only after the
  // client:load island mounts (the server shell ships a skeleton). Proves hydration, not just SSR.
  // (The branch is forked from the live default, so it already holds content — no empty-state assert.)
  await expect(page.getByRole('heading', { name: 'Content', exact: true })).toBeVisible();
  await page.goto('/admin/content/new');
  await page.waitForURL('**/admin/content/new');

  // Create a draft. Title/Slug/Type are ui-primitives Inputs; body is the ui-editor contenteditable.
  await page.getByLabel('Title').fill(title);
  await page.getByLabel('Slug').fill(slug);
  await page.getByLabel('Type').fill('post');
  const editor = page.getByLabel('Post body');
  await editor.click();
  await page.keyboard.type(bodyText);
  await page.getByRole('button', { name: 'Create post' }).click();
  await page.waitForURL('**/admin');

  // The draft now shows in the list (island re-fetched on the fresh /admin load).
  const row = page.getByRole('link', { name: title });
  await expect(row).toBeVisible();

  // Before publish, the public route must NOT leak a draft (broken-access-control floor).
  const draftRes = await page.request.get(`/post/${slug}`);
  expect(draftRes.status()).toBe(404);

  // Publish via the authenticated API (no publish UI in v0.0.3). Extract the id from the row href.
  const href = await row.getAttribute('href');
  const id = href?.split('/').pop();
  expect(id).toMatch(/^[0-9a-f-]{36}$/);
  // page.request shares the browser session cookie; Origin must be set explicitly (CSRF).
  const pubRes = await page.request.post('/api/admin/publish', {
    headers: { origin: ORIGIN, 'content-type': 'application/json' },
    data: { id },
  });
  expect(pubRes.ok()).toBeTruthy();

  // Public render: published post is now served with its title + sanitized body. Scope by role/text
  // — `astro dev` injects its own dev-toolbar <h1>/<main> overlays, so a bare locator('h1') is ambiguous.
  await page.goto(`/post/${slug}`);
  await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible();
  await expect(page.getByText(bodyText)).toBeVisible();
});

test('visibility flip: a published post is hidden from the public route when set private', async ({ page }) => {
  const stamp = Date.now();
  const slug = `e2e-vis-${stamp}`;
  await signIn(page);

  const { id } = await createViaApi(page, { slug, title: `Vis ${stamp}`, body: '<p>visible body</p>', visibility: 'public' });
  await publishViaApi(page, id);

  // published + public → publicly visible (the public route uses NO viewer, so the admin cookie is irrelevant).
  expect((await page.request.get(`/post/${slug}`)).status()).toBe(200);

  const flip = await page.request.post('/api/admin/visibility', {
    headers: { origin: ORIGIN, 'content-type': 'application/json' },
    data: { id, visibility: 'private' },
  });
  expect(flip.ok(), `flip failed: ${flip.status()} ${await flip.text()}`).toBeTruthy();

  // private → the anonymous public predicate (published AND public) excludes it → 404, no body leak.
  expect((await page.request.get(`/post/${slug}`)).status()).toBe(404);
});

test('edit by id: saving an edit updates the same entry, never duplicates it', async ({ page }) => {
  const stamp = Date.now();
  const slug = `e2e-edit-${stamp}`;
  const original = `Original ${stamp}`;
  const edited = `Edited ${stamp}`;
  await signIn(page);

  const { id } = await createViaApi(page, { slug, title: original, body: '<p>body</p>' });

  // Edit through the UI: the route loads the entry server-side (getById, admin actor) → form prefills.
  await page.goto(`/admin/content/${id}`);
  const titleInput = page.getByLabel('Title');
  await expect(titleInput).toHaveValue(original);
  await titleInput.fill(edited);
  await page.getByRole('button', { name: 'Save changes' }).click();
  await page.waitForURL('**/admin');

  // Exactly one entry carries this slug, with the new title and the SAME id — update keys on id, not slug.
  const { entries } = (await (await page.request.get('/api/admin/content')).json()) as {
    entries: { id: string; slug: string; title: string }[];
  };
  const matches = entries.filter((e) => e.slug === slug);
  expect(matches).toHaveLength(1);
  expect(matches[0]?.title).toBe(edited);
  expect(matches[0]?.id).toBe(id);
});

test('sanitize round-trip: script + onerror are stripped from the public render', async ({ page }) => {
  const stamp = Date.now();
  const slug = `e2e-xss-${stamp}`;
  const marker = `safe-text-${stamp}`;
  await signIn(page);

  // put() runs sanitizeContentBody server-side; the public route emits the stored body via set:html.
  const dirty = `<p>${marker}</p><script>alert('xss')</script><img src=x onerror="alert(1)">`;
  const { id } = await createViaApi(page, { slug, title: `Xss ${stamp}`, body: dirty });
  await publishViaApi(page, id);

  const res = await page.request.get(`/post/${slug}`);
  expect(res.status()).toBe(200);
  const html = await res.text();
  // Scope to the post body <main> — `astro dev` injects its own <script> tags in <head> (vite client,
  // dev toolbar), so asserting against the whole document would false-positive on those.
  const mainHtml = html.match(/<main[^>]*>([\s\S]*?)<\/main>/i)?.[1] ?? '';
  expect(mainHtml).toContain(marker); // safe content survives
  expect(mainHtml).not.toContain('<script'); // injected script element stripped by sanitizeContentBody
  expect(mainHtml.toLowerCase()).not.toContain('onerror'); // event-handler attribute stripped
});

test('refresh clear-path: an invalid mod_refresh yields a real Set-Cookie clearing BOTH cookies', async ({ request }) => {
  // No session + garbage refresh → engine.refresh returns null → resolveSession clears BOTH cookies
  // on the REAL Astro response, then throws InvalidSessionError. /me maps that to 200 + null (UX probe),
  // but the clear Set-Cookie rides along. This is the empirical proof the fake-sink vitest cannot give
  // (runsheet spec (d)): that Set-Cookie Max-Age=0 actually reaches the browser, breaking the 401-loop.
  const res = await request.get('/api/admin/me', {
    headers: { cookie: 'mod_refresh=garbage-not-a-real-token' },
  });
  const setCookies = res.headersArray().filter((h) => h.name.toLowerCase() === 'set-cookie');
  const joined = setCookies.map((h) => h.value).join('\n');
  expect(joined).toContain('mod_session=');
  expect(joined).toContain('mod_refresh=');
  expect(joined.toLowerCase()).toMatch(/max-age=0|expires=/); // both cleared, not set
});
