// Browser verification using Playwright's library, not a test-runner dependency.
// Use an existing Playwright installation; see README. App itself has no dependencies.
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import data from '../src/portfolio.mjs';
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
const started = new Date();
const startClock = performance.now();
const output = resolve(process.env.CAPTURE_DIR || 'output/playwright');
const url = process.env.PREVIEW_URL || 'http://127.0.0.1:4317';
await mkdir(output, { recursive: true });
const browser = await chromium.launch({ headless: true, ...(process.env.CHROMIUM_EXECUTABLE ? { executablePath: process.env.CHROMIUM_EXECUTABLE } : {}) });
const tests = [];
const errors = [];
const requests = new Set();
const captures = [];
const ok = (name, details = {}) => { tests.push({ name, passed: true, ...details }); console.log('PASS', name); };
function watch(page) {
  page.on('pageerror', error => errors.push(error.message));
  page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
  page.on('request', request => requests.add(request.url()));
}
async function screenshot(page, name, fullPage = true) {
  const start = performance.now();
  await page.screenshot({ path: resolve(output, name), fullPage });
  captures.push({ filename: name, seconds: +( (performance.now() - start) / 1000 ).toFixed(3) });
}
async function heading(page, title) { assert.equal(await page.locator('#reading-title').textContent(), title); }
async function dialArc(page, from, to, type = 'mouse') {
  const box = await page.locator('#section-dial').boundingBox();
  const cx = box.x + box.width / 2, cy = box.y + box.height / 2, r = box.width * .34;
  const point = angle => ({ x: cx + Math.cos(angle * Math.PI / 180) * r, y: cy + Math.sin(angle * Math.PI / 180) * r });
  if (type === 'touch') {
    const session = await page.context().newCDPSession(page);
    await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ ...point(from), id: 1 }] });
    for (let i = 1; i <= 16; i++) await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ ...point(from + (to - from) * i / 16), id: 1 }] });
    await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
    await session.detach();
  } else {
    const first = point(from); await page.mouse.move(first.x, first.y); await page.mouse.down();
    for (let i = 1; i <= 16; i++) { const p = point(from + (to - from) * i / 16); await page.mouse.move(p.x, p.y); }
    await page.mouse.up();
  }
}

let failure;
try {
  const desktop = await browser.newPage({ viewport: { width: 1440, height: 1080 }, deviceScaleFactor: 1 }); watch(desktop);
  const response = await desktop.goto(url); assert.equal(response.status(), 200);
  await heading(desktop, data.ui.workHeading);
  await screenshot(desktop, 'desktop-work.png');
  assert.equal(await desktop.locator('[data-project-id]').getAttribute('data-project-id'), data.projects[0].id);
  ok('Desktop initial portfolio and real screenshot', { viewport: '1440 × 1080' });

  // Real Tab navigation and the skip link must focus the reading heading.
  await desktop.keyboard.press('Tab');
  assert.equal(await desktop.locator(':focus').getAttribute('class'), 'skip-link');
  await desktop.keyboard.press('Enter');
  assert.equal(await desktop.locator(':focus').getAttribute('id'), 'reading-title');
  await desktop.goto(url);
  const tabStops = [];
  for (let i = 0; i < 12; i++) {
    await desktop.keyboard.press('Tab');
    tabStops.push(await desktop.locator(':focus').evaluate(el => ({ tag: el.tagName, id: el.id, label: el.getAttribute('aria-label') || el.textContent.trim() })));
  }
  assert(tabStops.some(stop => stop.id === 'section-dial'));
  ok('Tab order, skip link, and keyboard reachability', { tabStops });

  const dial = desktop.locator('#section-dial');
  await dial.focus();
  const outline = await desktop.locator('.dial').evaluate(el => ({ style: getComputedStyle(el).outlineStyle, width: getComputedStyle(el).outlineWidth }));
  assert.equal(outline.style, 'solid'); assert.equal(outline.width, '3px');
  await screenshot(desktop, 'desktop-keyboard-focus.png');
  const originalMap = await desktop.locator('.chart-grid').evaluate(el => getComputedStyle(el).transform);
  await dial.press('ArrowRight');
  assert.equal(await dial.getAttribute('aria-valuetext'), 'About');
  await heading(desktop, data.ui.workHeading);
  await desktop.waitForTimeout(700);
  const movedMap = await desktop.locator('.chart-grid').evaluate(el => getComputedStyle(el).transform);
  assert.notEqual(movedMap, originalMap);
  assert.equal(await desktop.locator('[data-star="0"]').getAttribute('aria-pressed'), 'true');
  await dial.press('Enter');
  await heading(desktop, data.ui.aboutHeading);
  assert.equal(await desktop.locator(':focus').getAttribute('id'), 'reading-title');
  assert.equal(await desktop.locator('.about-content>p').count(), 4);
  await screenshot(desktop, 'desktop-about.png');
  ok('Dial arrows preview a section, move the constellation, retain the highlighted project, and Enter opens with focus');
  await dial.focus(); await dial.press('End'); await dial.press('Enter');
  await heading(desktop, data.ui.contactHeading);
  assert.equal(await desktop.locator('.contact-address>p').textContent(), data.identity.contact);
  assert.equal(await desktop.locator('form,a[href^="mailto:"]').count(), 0);
  await screenshot(desktop, 'desktop-contact.png');
  ok('Contact is clearly fictional, readable, and has no form or message action');

  await desktop.locator('[data-section="0"]').click();
  for (let i = 0; i < 3; i++) {
    await desktop.locator(`[data-project="${i}"]`).click();
    assert.equal(await desktop.locator('[data-project-id]').getAttribute('data-project-id'), data.projects[i].id);
    assert.equal(await desktop.locator('.project-description').textContent(), data.projects[i].description);
    assert.equal(await desktop.locator(`[data-star="${i}"]`).getAttribute('aria-pressed'), 'true');
    assert.equal(await desktop.locator(':focus').getAttribute('data-project'), String(i));
    if (i) await screenshot(desktop, `desktop-project-${i + 1}.png`);
  }
  await desktop.locator('[data-section="1"]').click();
  await desktop.locator('[data-section="0"]').click();
  assert.equal(await desktop.locator('[data-project-id]').getAttribute('data-project-id'), data.projects[2].id);
  await desktop.locator('[data-star="0"]').click();
  assert.equal(await desktop.locator('[data-project-id]').getAttribute('data-project-id'), data.projects[0].id);
  ok('All project buttons and star selection work; project choice survives section navigation');

  await dialArc(desktop, -90, 30);
  assert.equal(await dial.getAttribute('aria-valuetext'), 'About');
  await desktop.locator('#open-section').click(); await heading(desktop, data.ui.aboutHeading);
  await desktop.locator('#next-section').click();
  assert.equal(await dial.getAttribute('aria-valuetext'), 'Contact');
  await desktop.locator('#open-section').click(); await heading(desktop, data.ui.contactHeading);
  await desktop.locator('#next-section').click(); await desktop.locator('#open-section').click();
  await heading(desktop, data.ui.workHeading);
  // The drag crosses atan2's +180 / -180 seam by 20°, not 340°.
  await dialArc(desktop, 170, 190);
  assert.equal(await dial.getAttribute('aria-valuetext'), 'Work');
  const box = await dial.boundingBox();
  await desktop.mouse.move(box.x + box.width / 2, box.y + 10); await desktop.mouse.down();
  await desktop.mouse.move(box.x + box.width + 80, box.y + box.height + 40); await desktop.mouse.up();
  assert.equal(await desktop.locator('.instrument').evaluate(el => el.classList.contains('is-turning')), false);
  ok('Pointer rotation, both tactile buttons, angle seam, and release outside the dial');

  const phone = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, isMobile: true, hasTouch: true }); watch(phone);
  await phone.goto(url);
  const layout = await phone.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth, instrumentHeight: document.querySelector('.instrument').getBoundingClientRect().height, descriptionTop: document.querySelector('.project-description').getBoundingClientRect().top, descriptionBottom: document.querySelector('.project-description').getBoundingClientRect().bottom }));
  assert.equal(layout.width, layout.scrollWidth); assert(layout.instrumentHeight < 230); assert(layout.descriptionBottom < 844);
  await screenshot(phone, 'phone-work.png'); await screenshot(phone, 'phone-first-viewport.png', false);
  const primaryTouchTargets = await phone.locator('.section-button,.tactile-button,.project-choice,#section-dial').evaluateAll(elements => elements.map(el => ({ label: el.getAttribute('aria-label') || el.textContent.trim(), width: el.getBoundingClientRect().width, height: el.getBoundingClientRect().height })));
  assert(primaryTouchTargets.every(t => t.width >= 44 && t.height >= 44));
  await dialArc(phone, -90, 30, 'touch');
  assert.equal(await phone.locator('#section-dial').getAttribute('aria-valuetext'), 'About');
  await phone.locator('#open-section').tap(); await heading(phone, data.ui.aboutHeading);
  await phone.locator('[data-section="2"]').tap(); await heading(phone, data.ui.contactHeading);
  await screenshot(phone, 'phone-contact.png');
  await phone.locator('[data-section="0"]').tap();
  for(let i = 0; i < 3; i++) { await phone.locator(`[data-project="${i}"]`).tap(); assert.equal(await phone.locator('[data-project-id]').getAttribute('data-project-id'), data.projects[i].id); }
  ok('390 × 844 phone: complete description in initial viewport, ≥44px primary targets, real emulated touch drag/taps', { layout, primaryTouchTargets });

  const responsive = [];
  for (const width of [320, 768, 1024]) {
    await phone.setViewportSize({ width, height: 900 }); await phone.goto(url);
    const measured = await phone.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth }));
    assert.equal(measured.width, measured.scrollWidth); responsive.push(measured);
  }
  // 720 CSS px at 2× DPR models the available layout width of 200% desktop browser zoom.
  const zoom = await browser.newPage({ viewport: { width: 720, height: 540 }, deviceScaleFactor: 2 }); watch(zoom);
  await zoom.goto(url);
  assert(await zoom.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
  await zoom.locator('[data-section="2"]').click(); await heading(zoom, data.ui.contactHeading);
  ok('320 / 768 / 1024 responsive overflow; 200% browser-zoom-equivalent reflow and navigation', { responsive, zoomNote: 'Equivalent CSS layout width and DPR, not a native browser toolbar zoom or text-only enlargement test.' });

  const reduced = await browser.newPage({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, reducedMotion: 'reduce' }); watch(reduced);
  await reduced.goto(url);
  await reduced.locator('#section-dial').focus(); await reduced.locator('#section-dial').press('ArrowRight');
  const motion = await reduced.locator('.chart-grid').evaluate(el => ({ transform: getComputedStyle(el).transform, transition: getComputedStyle(el).transitionDuration, animation: getComputedStyle(el).animationName }));
  assert.equal(motion.transform, 'none'); assert.equal(motion.transition, '0s'); assert.equal(motion.animation, 'none');
  assert.equal(await reduced.locator('.dial-metal').evaluate(el => getComputedStyle(el).transitionDuration), '0s');
  await reduced.locator('#section-dial').press('Enter'); await heading(reduced, data.ui.aboutHeading);
  await screenshot(reduced, 'phone-reduced-motion-about.png');
  ok('Reduced motion removes map movement and transitions; section selection and opening remain usable', { motion });

  const plain = await browser.newPage({ viewport: { width: 900, height: 1000 }, javaScriptEnabled: false }); watch(plain);
  await plain.goto(`${url}/text.html`);
  const content = await plain.locator('body').textContent();
  for (const text of [data.identity.name, data.identity.introduction, data.identity.contact, data.identity.fictionalNote, data.contact.paragraph, ...data.about.paragraphs, ...data.about.principles.flatMap(p => [p.title, p.text]), ...data.projects.flatMap(p => [p.title, p.description, p.detail, p.role, p.format, p.annotation])]) assert(content.includes(text), `Missing plain text: ${text}`);
  assert.equal(await plain.locator('script').count(), 0);
  await screenshot(plain, 'plain-text.png');
  await plain.goto(url);
  assert.equal(await plain.getByRole('link', { name: data.ui.plainView }).count(), 1);
  await plain.getByRole('link', { name: data.ui.plainView }).click();
  assert((await plain.locator('h1').textContent()).includes(data.identity.name));
  ok('Complete plain-view content parity and usable no-JavaScript fallback');

  assert.equal(errors.length, 0, JSON.stringify(errors));
  assert([...requests].every(request => new URL(request).origin === new URL(url).origin));
  ok('No browser console/runtime errors; all website requests stay on localhost', { requestCount: requests.size });
} catch (error) {
  failure = error;
  console.error(error);
} finally {
  const report = { startedUTC: started.toISOString(), endedUTC: new Date().toISOString(), elapsedSeconds: +((performance.now() - startClock) / 1000).toFixed(3), browser: browser.version(), tests, errors, requests: [...requests], captures, failure: failure ? failure.message : null, limitations: ['Chromium desktop and phone emulation; no physical phone, Safari, or Firefox.', 'No assistive-technology user session or formal WCAG certification.', 'Illustrative graphics do not establish scientific or mechanical accuracy.'] };
  await writeFile(resolve(output, 'verification.json'), JSON.stringify(report, null, 2) + '\n');
  await browser.close();
}
if (failure) process.exitCode = 1;
