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

type AxeViolationLike = {
  id: string;
  impact: string | null | undefined;
  help: string;
  nodes: unknown[];
};

export function summarizeAxeViolations(violations: AxeViolationLike[]) {
  const summaries = new Map<string, { id: string; impact: string | null | undefined; nodes: number; help: string }>();

  for (const violation of violations) {
    const key = `${violation.id} ${violation.impact ?? ""} ${violation.help}`;
    const existing = summaries.get(key);
    if (existing) {
      existing.nodes += violation.nodes.length;
      continue;
    }
    summaries.set(key, {
      id: violation.id,
      impact: violation.impact,
      nodes: violation.nodes.length,
      help: violation.help,
    });
  }

  return [...summaries.values()];
}

type AxeNodeLike = { html?: string };

/**
 * Drop `scrollable-region-focusable` findings whose target is an ARIA menu
 * (Radix DropdownMenu content). At narrow widths a tall menu becomes
 * scrollable, and axe wants the scroll container itself focusable — but menus
 * are operated by roving arrow-key focus over their items, not by tabbing to
 * the container (Radix fixes the content at tabindex=-1 via its focus scope),
 * so the rule is a false positive here. Genuine scroll regions (tables, code
 * blocks) are not `role="menu"` and stay enforced.
 */
export function filterMenuScrollFalsePositives<T extends { id: string; nodes: AxeNodeLike[] }>(violations: T[]): T[] {
  return violations
    .map((violation) => {
      if (violation.id !== "scrollable-region-focusable") return violation;
      const nodes = violation.nodes.filter((node) => !/role="menu"|data-radix-menu-content/u.test(node.html ?? ""));
      return { ...violation, nodes };
    })
    .filter((violation) => violation.nodes.length > 0);
}

export function isBlockingAxeImpact(impact: string | null | undefined, env: NodeJS.ProcessEnv = process.env): boolean {
  if (impact === "critical" || impact === "serious") return true;
  return env.E2E_A11Y_STRICT === "1" && impact === "moderate";
}

/**
 * Let any in-flight CSS transitions/animations finish before scanning. Elements
 * caught mid-transition (a button fading in during an SPA navigation, a
 * hover-reveal control) render their text/background at an intermediate opacity,
 * which axe reports as a transient color-contrast violation that does not exist
 * in the settled UI. Awaiting the Web Animations `finished` promises settles
 * them precisely, without mutating the DOM (a global override style would alter
 * the timing of later interactions in the same test, e.g. a Radix close
 * animation whose onCloseAutoFocus restores focus to its trigger). A 400ms cap
 * keeps indefinite animations (spinners) from blocking.
 */
async function settleAnimations(page: Page): Promise<void> {
  await (page as { evaluate: (fn: () => Promise<void>) => Promise<void> })
    .evaluate(async () => {
      const getAnimations = (document as Document & { getAnimations?: () => Animation[] }).getAnimations;
      if (typeof getAnimations !== "function") return;
      const running = getAnimations.call(document).filter((animation) => animation.playState === "running");
      await Promise.race([
        Promise.all(running.map((animation) => animation.finished.catch(() => undefined))),
        new Promise<void>((resolve) => setTimeout(resolve, 400)),
      ]);
    })
    .catch(() => undefined);
}

/**
 * Run axe-core against the current page and assert that no violations
 * with impact "critical" or "serious" were detected. Set
 * E2E_A11Y_STRICT=1 in staging/readiness lanes to also block "moderate"
 * violations. Other impacts are surfaced as console warnings but do not fail
 * the default local gate.
 */
export async function expectNoCriticalA11yViolations(page: Page): Promise<void> {
  // Wait for the global navigation FloatingLoader to clear before scanning. It
  // fades in via a CSS transition while an SPA navigation/submission settles,
  // and that fade often starts right as the scan begins — axe then reads its
  // text at a partial opacity (a muted color blended toward the background),
  // reporting a transient color-contrast violation that does not exist once the
  // loader is fully shown or gone. The loader is the only `.pointer-events-auto`
  // node inside the `aria-live="assertive"` overlay; await its disappearance
  // (bounded, so a still-running navigation never hangs the scan).
  await page
    .locator('[aria-live="assertive"] .pointer-events-auto')
    .first()
    .waitFor({ state: "hidden", timeout: 6_000 })
    .catch(() => undefined);

  await settleAnimations(page);

  const builder = new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]);

  const analysis = await builder.analyze();
  const results = { violations: filterMenuScrollFalsePositives(analysis.violations) };
  const blocking = results.violations.filter((v) => isBlockingAxeImpact(v.impact));

  if (results.violations.length > 0) {
    const summary = summarizeAxeViolations(results.violations);
    // eslint-disable-next-line no-console
    console.warn("[axe] violations detected:", JSON.stringify(summary, null, 2));
  }

  const blockingLabel = process.env.E2E_A11Y_STRICT === "1" ? "critical/serious/moderate" : "critical/serious";
  expect(blocking, `Found ${blocking.length} blocking a11y violations (${blockingLabel}): ${blocking.map((v) => v.id).join(", ")}`).toEqual([]);
}
