import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";

import { PAGES } from "./routes";

/**
 * Wait for the entrance animations to finish before measuring anything.
 *
 * axe checks contrast against what is actually painted, and this site paints
 * its headings and labels in from opacity 0. Analysing while that is still
 * running reports the half-faded frames as contrast failures — a true
 * measurement of a state no reader ever stops on, and the reason this suite
 * failed on whichever page happened to be slowest in a given run rather than
 * on any page in particular.
 *
 * Both halves of the condition carry weight. An armed element proves the
 * client code has run, so the audit cannot slip in before the animation even
 * starts; no running animation proves it has ended. A page with no motion at
 * all settles by timeout and is audited as it stands.
 */
async function settled(page: Page) {
  await page
    .waitForFunction(
      () =>
        document.querySelector('[data-armed="true"], [data-enter="in"]') !== null &&
        document.getAnimations().every((animation) => animation.playState !== "running"),
      undefined,
      { timeout: 8000 },
    )
    .catch(() => {});
}

/**
 * Blueprint 17 rule 9: axe clean on every route, full keyboard operation,
 * visible focus, 44 px touch targets, 4.5:1 text contrast.
 */

test.describe("axe", () => {
  for (const page of PAGES) {
    test(`${page.name} — no accessibility violations`, async ({ page: browserPage }) => {
      await browserPage.goto(page.path);
      await settled(browserPage);

      const results = await new AxeBuilder({ page: browserPage })
        .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
        .analyze();

      const summary = results.violations.map((violation) => ({
        id: violation.id,
        impact: violation.impact,
        nodes: violation.nodes.slice(0, 8).map((node) => node.target.join(" ")),
      }));

      expect(summary, `${page.path} has axe violations`).toEqual([]);
    });
  }
});

test.describe("keyboard", () => {
  test("the skip link is the first stop and reaches the main content", async ({ page }) => {
    await page.goto("/sq");

    await page.keyboard.press("Tab");
    const skip = page.locator(".skip-link");
    await expect(skip).toBeFocused();
    await expect(skip).toBeVisible();

    await page.keyboard.press("Enter");
    expect(new URL(page.url()).hash).toBe("#main");
  });

  test("every interactive element shows a visible focus ring", async ({ page }) => {
    await page.goto("/sq/kontakt");

    const hasOutline = await page.evaluate(() => {
      const field = document.querySelector<HTMLElement>("#demo-name");
      if (!field) return false;
      field.focus();
      const style = getComputedStyle(field, ":focus-visible");
      return style.outlineStyle !== "none" || style.outlineWidth !== "0px";
    });
    expect(hasOutline).toBe(true);
  });
});

test.describe("touch targets", () => {
  test.skip(({ viewport }) => (viewport?.width ?? 0) > 767, "mobile only");

  test("controls are at least 44 px on a phone", async ({ page }) => {
    await page.goto("/sq/kontakt");

    const small = await page.evaluate(() => {
      const selectors = "a[href], button, input, select, textarea";
      return [...document.querySelectorAll<HTMLElement>(selectors)]
        .filter((el) => {
          const rect = el.getBoundingClientRect();
          if (rect.width === 0 || rect.height === 0) return false;
          // The honeypot is hidden from every visitor, so it is not a target.
          if (el.closest('[aria-hidden="true"]')) return false;
          // Links inside a paragraph of prose are exempt: they are text.
          if (el.tagName === "A" && el.closest("p, address, figcaption, li")) return false;
          return rect.height < 44;
        })
        .slice(0, 6)
        .map((el) => `${el.tagName}.${String(el.className).slice(0, 40)}`);
    });

    expect(small).toEqual([]);
  });
});
