/**
 * layout.spec.ts — TEST-01
 *
 * Cross-route structural quality-bar assertions (UI-SPEC Rules 1-4):
 *   Rule 1: Touch-target minimum ≥44×44px (FAB ≥56×56px)
 *   Rule 2: No horizontal overflow (scrollWidth ≤ clientWidth)
 *   Rule 3: Critical elements visible and in-viewport on initial load
 *   Rule 4: Accessible names on all interactive elements (role+name locators)
 *
 * Runs on all three device profiles automatically (playwright.config.ts matrix):
 *   iphone:  iPhone 14 / WebKit / 390×844
 *   pixel:   Pixel 7 / Chromium / 412×915
 *   desktop: Desktop Chrome / Chromium / 1280×720 (mobile-only geometry tests skipped)
 *
 * STRICT-MODE NOTE:
 *   On mobile viewports (≤767px), AppNav renders PhoneNav as a <header> element
 *   (NOT a nav landmark) — it does NOT expose a navigation landmark. BottomTabBar
 *   renders the sole <nav aria-label="Main navigation"> on mobile. There is no
 *   strict-mode collision on these profiles.
 *   The DesktopNav <nav aria-label="Main navigation"> is only rendered at ≥768px
 *   and is not visible on either test profile.
 *
 * Run:
 *   pnpm --filter @familysync/pwa test:e2e
 *   pnpm --filter @familysync/pwa exec playwright test --project=pixel layout.spec.ts
 */
import { test, expect } from '@playwright/test';

// ── Rule 1 + Rule 3 + Rule 4: BottomTabBar tap targets, visibility, accessible names ──

test.describe('Rule 1/3/4 — BottomTabBar tap targets and in-viewport position', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/calendar');
  });

  test('BottomTabBar navigation landmark is visible (Rule 4 — accessible name)', async ({
    page,
  }) => {
    // On mobile profiles the sole navigation landmark is the BottomTabBar nav.
    // getByRole succeeds ↔ accessible name exists — doubles as Rule 4 gate.
    await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
  });

  test('Calendar tab meets 44×44px touch-target minimum (Rule 1)', async ({ page }) => {
    // Scope to the navigation landmark to stay robust if desktop nav ever appears.
    const nav = page.getByRole('navigation', { name: 'Main navigation' });
    const calTab = nav.getByRole('link', { name: 'Calendar' });
    const box = await calTab.boundingBox();
    expect(box, 'Calendar tab bounding box must not be null').not.toBeNull();
    expect(box!.width, 'Calendar tab width ≥ 44px').toBeGreaterThanOrEqual(44);
    expect(box!.height, 'Calendar tab height ≥ 44px').toBeGreaterThanOrEqual(44);
  });

  test('Lists tab meets 44×44px touch-target minimum (Rule 1)', async ({ page }) => {
    const nav = page.getByRole('navigation', { name: 'Main navigation' });
    const listsTab = nav.getByRole('link', { name: 'Lists' });
    const box = await listsTab.boundingBox();
    expect(box, 'Lists tab bounding box must not be null').not.toBeNull();
    expect(box!.width, 'Lists tab width ≥ 44px').toBeGreaterThanOrEqual(44);
    expect(box!.height, 'Lists tab height ≥ 44px').toBeGreaterThanOrEqual(44);
  });

  test('BottomTabBar is fully in-viewport (Rule 3 — safe-area-inset)', async ({
    page,
  }, testInfo) => {
    test.skip(
      testInfo.project.name === 'desktop',
      'On desktop the Main navigation landmark resolves to the DesktopNav sidebar; the safe-area-inset bottom-edge assertion is semantically wrong for a sidebar (BottomTabBar returns null at ≥768px)',
    );
    // The bar uses env(safe-area-inset-bottom, 0px). In emulation there is no
    // safe-area-inset, so the bar's bottom edge must be ≤ viewport height.
    const nav = page.getByRole('navigation', { name: 'Main navigation' });
    await expect(nav).toBeVisible();
    const box = await nav.boundingBox();
    expect(box, 'BottomTabBar bounding box must not be null').not.toBeNull();
    const viewportHeight = page.viewportSize()!.height;
    expect(
      box!.y + box!.height,
      `BottomTabBar bottom edge (${box!.y + box!.height}) must be ≤ viewport height (${viewportHeight})`,
    ).toBeLessThanOrEqual(viewportHeight);
  });

  test('PhoneNav header is visible (Rule 3)', async ({ page }) => {
    // PhoneNav renders a <header> with exact text "FamilySync" (not a nav landmark).
    // Use exact:true to avoid matching the "Install FamilySync" install-prompt text.
    await expect(page.getByText('FamilySync', { exact: true })).toBeVisible();
  });

  test('PhoneNav settings button meets 44×44px touch-target minimum (Rule 1)', async ({ page }) => {
    // aria-label: "${displayName} — open settings" (AppNav.tsx PhoneNav)
    const settingsBtn = page.getByRole('button', { name: /open settings/i });
    const box = await settingsBtn.boundingBox();
    expect(box, 'Settings button bounding box must not be null').not.toBeNull();
    expect(box!.width, 'Settings button width ≥ 44px').toBeGreaterThanOrEqual(44);
    expect(box!.height, 'Settings button height ≥ 44px').toBeGreaterThanOrEqual(44);
  });

  test('New Event FAB meets 56×56px touch-target minimum (Rule 1)', async ({ page }, testInfo) => {
    test.skip(
      testInfo.project.name === 'desktop',
      'On desktop getByRole(button, New Event) resolves to the toolbar button (CalendarShell.tsx:436-457), not the 56×56 FAB; FAB geometry assertion does not apply to the toolbar button',
    );
    // Phone-only FAB — aria-label="New Event", fixed 56×56px (CalendarShell.tsx)
    const fab = page.getByRole('button', { name: 'New Event' });
    const box = await fab.boundingBox();
    expect(box, 'New Event FAB bounding box must not be null').not.toBeNull();
    expect(box!.width, 'New Event FAB width ≥ 56px').toBeGreaterThanOrEqual(56);
    expect(box!.height, 'New Event FAB height ≥ 56px').toBeGreaterThanOrEqual(56);
  });

  // D-04 parity: desktop toolbar "New Event" button meets ≥44px minimum (Rule 1)
  // CalendarShell.tsx:443 sets minHeight:'44px' on the desktop toolbar button.
  // Guarded to run only on desktop — the FAB test above covers mobile profiles.
  test('New Event toolbar button meets 44px touch-target minimum on desktop (Rule 1 — D-04 parity)', async ({
    page,
  }, testInfo) => {
    test.skip(
      testInfo.project.name !== 'desktop',
      'Desktop parity assertion — toolbar button only renders at ≥768px (Desktop Chrome); FAB test covers iphone/pixel',
    );
    const toolbarBtn = page.getByRole('button', { name: 'New Event' });
    const box = await toolbarBtn.boundingBox();
    expect(box, 'New Event toolbar button bounding box must not be null').not.toBeNull();
    expect(
      box!.height,
      'New Event toolbar button height ≥ 44px (Rule 1 desktop parity)',
    ).toBeGreaterThanOrEqual(44);
  });
});

// ── Rule 1/3/4 repeated on /lists ──

test.describe('Rule 1/3/4 — BottomTabBar on /lists', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/lists');
  });

  test('BottomTabBar navigation landmark is visible on /lists (Rule 4)', async ({ page }) => {
    await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
  });

  test('Calendar tab meets 44×44px on /lists (Rule 1)', async ({ page }) => {
    const nav = page.getByRole('navigation', { name: 'Main navigation' });
    const calTab = nav.getByRole('link', { name: 'Calendar' });
    const box = await calTab.boundingBox();
    expect(box).not.toBeNull();
    expect(box!.width).toBeGreaterThanOrEqual(44);
    expect(box!.height).toBeGreaterThanOrEqual(44);
  });

  test('Lists tab meets 44×44px on /lists (Rule 1)', async ({ page }) => {
    const nav = page.getByRole('navigation', { name: 'Main navigation' });
    const listsTab = nav.getByRole('link', { name: 'Lists' });
    const box = await listsTab.boundingBox();
    expect(box).not.toBeNull();
    expect(box!.width).toBeGreaterThanOrEqual(44);
    expect(box!.height).toBeGreaterThanOrEqual(44);
  });

  test('BottomTabBar is fully in-viewport on /lists (Rule 3)', async ({ page }, testInfo) => {
    test.skip(
      testInfo.project.name === 'desktop',
      'On desktop the Main navigation landmark resolves to the DesktopNav sidebar; the safe-area-inset bottom-edge assertion is semantically wrong for a sidebar (BottomTabBar returns null at ≥768px)',
    );
    const nav = page.getByRole('navigation', { name: 'Main navigation' });
    await expect(nav).toBeVisible();
    const box = await nav.boundingBox();
    expect(box).not.toBeNull();
    const viewportHeight = page.viewportSize()!.height;
    expect(box!.y + box!.height).toBeLessThanOrEqual(viewportHeight);
  });
});

// ── Rule 2: No horizontal overflow ──

test.describe('Rule 2 — No horizontal overflow', () => {
  test('no overflow on /calendar', async ({ page }) => {
    await page.goto('/calendar');
    const overflow = await page.evaluate(() => ({
      scrollWidth: document.documentElement.scrollWidth,
      clientWidth: document.documentElement.clientWidth,
    }));
    expect(
      overflow.scrollWidth,
      `scrollWidth (${overflow.scrollWidth}) must be ≤ clientWidth (${overflow.clientWidth}) on /calendar`,
    ).toBeLessThanOrEqual(overflow.clientWidth);
  });

  test('no overflow on /lists', async ({ page }) => {
    await page.goto('/lists');
    const overflow = await page.evaluate(() => ({
      scrollWidth: document.documentElement.scrollWidth,
      clientWidth: document.documentElement.clientWidth,
    }));
    expect(
      overflow.scrollWidth,
      `scrollWidth (${overflow.scrollWidth}) must be ≤ clientWidth (${overflow.clientWidth}) on /lists`,
    ).toBeLessThanOrEqual(overflow.clientWidth);
  });
});

// ── Harness self-validation — injected-defect proofs (TEST-01 acceptance bar) ──
//
// Each test is a PASSING test that proves the assertion would have failed under a
// deliberately injected defect and recovers once the injection is removed.
// The suite stays green; the proofs demonstrate the harness measures rendered
// geometry rather than CSS source values.

test.describe('harness self-validation — injected defects', () => {
  test('Rule 1 proof: tap-target assertion fails under 20px injection, passes after removal', async ({
    page,
  }) => {
    await page.goto('/calendar');

    // Confirm the nav is visible before injection
    const nav = page.getByRole('navigation', { name: 'Main navigation' });
    await expect(nav).toBeVisible();

    // INJECT: force BottomTabBar links to 20px height — simulates a broken tap target
    const styleHandle = await page.addStyleTag({
      content:
        'nav[aria-label="Main navigation"] a { min-height: 20px !important; height: 20px !important; max-height: 20px !important; }',
    });

    // Measure WHILE injected — must be < 44px to prove the assertion tracks geometry
    const calTab = nav.getByRole('link', { name: 'Calendar' });
    const boxWithDefect = await calTab.boundingBox();
    expect(
      boxWithDefect,
      'Calendar tab bounding box must not be null even with defect injected',
    ).not.toBeNull();
    expect(
      boxWithDefect!.height,
      'Height must be < 44px with 20px injection (proving measurement tracks rendered geometry)',
    ).toBeLessThan(44);

    // REMOVE the injected style by deleting the <style> element via evaluate
    // (styleHandle.evaluate(el => el.remove()) — no page reload), then re-measure — must be ≥ 44px again
    await styleHandle.evaluate((el) => (el as Element).remove());
    const boxAfterRemoval = await calTab.boundingBox();
    expect(
      boxAfterRemoval,
      'Calendar tab bounding box must not be null after defect removal',
    ).not.toBeNull();
    expect(
      boxAfterRemoval!.height,
      'Height must be ≥ 44px after defect style is removed',
    ).toBeGreaterThanOrEqual(44);
  });

  test('Rule 2 proof: overflow assertion fails under 2000px injection, passes after removal', async ({
    page,
  }) => {
    await page.goto('/calendar');

    // Confirm baseline — no overflow before injection
    const baseOverflow = await page.evaluate(() => ({
      scrollWidth: document.documentElement.scrollWidth,
      clientWidth: document.documentElement.clientWidth,
    }));
    expect(baseOverflow.scrollWidth).toBeLessThanOrEqual(baseOverflow.clientWidth);

    // INJECT: force body width to 2000px — simulates Schedule-X overflow defect
    const styleHandle = await page.addStyleTag({
      content: 'body { width: 2000px !important; }',
    });

    // Measure WHILE injected — scrollWidth must exceed clientWidth
    const overflowWithDefect = await page.evaluate(() => ({
      scrollWidth: document.documentElement.scrollWidth,
      clientWidth: document.documentElement.clientWidth,
    }));
    expect(
      overflowWithDefect.scrollWidth,
      `scrollWidth (${overflowWithDefect.scrollWidth}) must be > clientWidth (${overflowWithDefect.clientWidth}) with 2000px injection (proving overflow detection works)`,
    ).toBeGreaterThan(overflowWithDefect.clientWidth);

    // REMOVE the injected style by deleting the <style> element via evaluate
    // (styleHandle.evaluate(el => el.remove()) — no page reload) — overflow must clear
    await styleHandle.evaluate((el) => (el as Element).remove());
    const overflowAfterRemoval = await page.evaluate(() => ({
      scrollWidth: document.documentElement.scrollWidth,
      clientWidth: document.documentElement.clientWidth,
    }));
    expect(
      overflowAfterRemoval.scrollWidth,
      'scrollWidth must be ≤ clientWidth after 2000px injection is removed',
    ).toBeLessThanOrEqual(overflowAfterRemoval.clientWidth);
  });
});
