import { Dataset, RequestQueue, log, playwrightUtils, CrawlingContext, PlaywrightGotoOptions, Request } from 'crawlee';
import axe, { AxeResults, ImpactValue, NodeResult, Result, resultGroups, TagValue } from 'axe-core';
import { BrowserContext, ElementHandle, Page } from 'playwright';
import {
  axeScript,
  disallowedListOfPatterns,
  guiInfoStatusTypes,
  RuleFlags,
  saflyIconSelector,
} from '../constants/constants.js';
import { consoleLogger, guiInfoLog, silentLogger } from '../logs.js';
import { enrichColorContrastDOMContext, takeScreenshotForHTMLElements } from '../screenshotFunc/htmlScreenshotFunc.js';
import { isFilePath } from '../constants/common.js';
import { extractAndGradeText } from './custom/extractAndGradeText.js';
import { ItemsInfo } from '../mergeAxeResults.js';
import { evaluateAltText } from './custom/evaluateAltText.js';
import { escapeCssSelector } from './custom/escapeCssSelector.js';
import { framesCheck } from './custom/framesCheck.js';
import { findElementByCssSelector } from './custom/findElementByCssSelector.js';
import { getAxeConfiguration } from './custom/getAxeConfiguration.js';
import { flagUnlabelledClickableElements } from './custom/flagUnlabelledClickableElements.js';
import xPathToCss from './custom/xPathToCss.js';
import type { Response as PlaywrightResponse } from 'playwright';
import fs from 'fs';
import { ensureAndInjectSafeBrowsing } from '../safeBrowsingProfile.js';
import path from 'path';

// types
interface AxeResultsWithScreenshot extends AxeResults {
  passes: ResultWithScreenshot[];
  incomplete: ResultWithScreenshot[];
  violations: ResultWithScreenshot[];
}

export interface ResultWithScreenshot extends Result {
  nodes: NodeResultWithScreenshot[];
}

export type ContrastDOMContext = {
  /** Raw computed background-image value on the element itself (empty string if none). */
  backgroundImage: string;
  /** True when the element's own background-image contains a CSS gradient. */
  hasGradient: boolean;
  /** True when the element's own background-image is a url() image (not a gradient). */
  hasBackgroundImage: boolean;
  /** True when any ancestor up to <body> has a gradient background. */
  ancestorHasGradient: boolean;
  /** True when any ancestor up to <body> has a url() image background. */
  ancestorHasBackgroundImage: boolean;
  /** True when the element or any ancestor has computed opacity < 1. */
  hasReducedOpacity: boolean;
  /** Non-null when the element's mix-blend-mode is not 'normal'. */
  mixBlendMode: string | null;
  /** Non-null when a backdrop-filter is applied to the element. */
  backdropFilter: string | null;
  /** Non-null when a CSS filter (e.g. brightness, contrast) is applied to the element. */
  filter: string | null;
};

export interface NodeResultWithScreenshot extends NodeResult {
  screenshotPath?: string;
  contrastDOMContext?: ContrastDOMContext;
}

type RuleDetails = {
  description: string;
  axeImpact: ImpactValue;
  helpUrl: string;
  conformance: TagValue[];
  totalItems: number;
  items: ItemsInfo[];
};

type ResultCategory = {
  totalItems: number;
  rules: Record<string, RuleDetails>;
};

type CustomFlowDetails = {
  pageIndex?: any;
  metadata?: any;
  pageImagePath?: any;
};

type ContrastCheckData = {
  fgColor?: string;
  bgColor?: string;
  contrastRatio?: string | number;
  fontSize?: string;
  fontWeight?: string;
  expectedContrastRatio?: string;
};

type ContrastExample = {
  fgColor: string;
  bgColor: string;
  contrastRatio: string;
  fontSize: string;
  fontWeight: string;
  expectedContrastRatio: string;
};

type FilteredResults = {
  url: string;
  pageTitle: string;
  pageIndex?: any;
  metadata?: any;
  pageImagePath?: any;
  totalItems: number;
  mustFix: ResultCategory;
  goodToFix: ResultCategory;
  needsReview: ResultCategory;
  passed: ResultCategory;
  actualUrl?: string;
  axeScanFailed?: boolean;
};

// Playwright surfaces transient teardown as errors we must NOT convert into
// axeScanFailed — rethrowing lets Crawlee's retry (maxRequestRetries) recover
// on a fresh browser/context. Long crawls hit these on browser retirement
// (retireBrowserAfterPageCount) and idle-close boundaries.
const isTransientPageTeardown = (e: unknown): boolean => {
  const msg = (e as Error)?.message ?? '';
  return /Target (page, context or browser has been closed|closed)|Execution context was destroyed|page (has been |was )closed|Browser has been closed|Navigation failed because page (was|has been) closed/i.test(
    msg,
  );
};

const truncateHtml = (html: string, maxBytes = 1024, suffix = '…'): string => {
  const encoder = new TextEncoder();
  if (encoder.encode(html).length <= maxBytes) return html;

  let left = 0;
  let right = html.length;
  let result = '';

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    const truncated = html.slice(0, mid) + suffix;
    const bytes = encoder.encode(truncated).length;

    if (bytes <= maxBytes) {
      result = truncated;
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }

  return result;
};

const formatContrastFontSize = (fontSize?: string) => {
  if (!fontSize) return 'unknown size';

  const pxMatch = fontSize.match(/\(([\d.]+px)\)/i);
  return pxMatch ? pxMatch[1] : fontSize;
};

/**
 * Parses a CSS color string into an [R, G, B] tuple (values 0–255).
 *
 * axe-core serialises colours via its internal `Color.toHexString()`, which
 * always produces lowercase 6-digit hex (#rrggbb).  The parser also accepts
 * 3-digit hex (#rgb) and functional rgb() notation for robustness.
 *
 * Returns null if the string does not match any supported format.
 */
const parseColor = (color: string): [number, number, number] | null => {
  const hex6 = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
  if (hex6) return [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];

  const hex3 = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
  if (hex3)
    return [
      parseInt(hex3[1] + hex3[1], 16),
      parseInt(hex3[2] + hex3[2], 16),
      parseInt(hex3[3] + hex3[3], 16),
    ];

  const rgb = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
  if (rgb) return [parseInt(rgb[1]), parseInt(rgb[2]), parseInt(rgb[3])];

  return null;
};

/**
 * Computes the WCAG 2.x relative luminance of an sRGB colour.
 *
 * Algorithm (WCAG 2.1 §1.4.3 / IEC 61966-2-1 sRGB standard):
 *   1. Normalise each 8-bit channel to [0, 1] by dividing by 255.
 *   2. Gamma-expand (linearise) each normalised channel:
 *        if sRGB ≤ 0.04045  →  linear = sRGB / 12.92
 *        else               →  linear = ((sRGB + 0.055) / 1.055) ^ 2.4
 *      The threshold 0.04045 and the slope 1/12.92 describe the near-black
 *      linear segment of the sRGB electro-optical transfer function (EOTF).
 *      The power 2.4 (≈ gamma 2.2) and the offset 0.055 handle the rest.
 *   3. Weight the linear channels by the CIE 1931 XYZ D65 Y-row coefficients
 *      projected onto the sRGB primaries:
 *        L = 0.2126 R_lin + 0.7152 G_lin + 0.0722 B_lin
 *      These weights reflect human eye sensitivity: the eye is most sensitive
 *      to green, moderately to red, and least to blue.
 *
 * Returns a value in [0, 1]: 0 = absolute black, 1 = perfect white.
 */
const relativeLuminance = (r: number, g: number, b: number): number => {
  const linearise = (c: number) => {
    const s = c / 255;
    return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  };
  return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
};

/**
 * Computes the WCAG 2.x contrast ratio between two relative luminances.
 *
 * Formula: (L_lighter + 0.05) / (L_darker + 0.05)
 *
 * The additive offset 0.05 models the luminance of ambient flare (reflected
 * light) assumed in a reference viewing environment.  It prevents the ratio
 * from reaching infinity for pure black and anchors the scale so that
 * white-on-black yields exactly 21:1.  The lighter luminance is always placed
 * in the numerator so the result is always ≥ 1.
 *
 * WCAG thresholds:
 *   AA  normal text  ≥ 4.5:1   |  large text ≥ 3:1
 *   AAA normal text  ≥ 7:1     |  large text ≥ 4.5:1
 */
const wcagContrastRatio = (l1: number, l2: number): number => {
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
};

/**
 * Converts an sRGB colour to HSL cylindrical coordinates.
 *   H (hue)        ∈ [0°, 360°)
 *   S (saturation) ∈ [0, 1]
 *   L (lightness)  ∈ [0, 1]
 *
 * We work in HSL because adjusting L alone changes perceived brightness while
 * preserving the hue and saturation of the original brand colour — the
 * smallest perceptible change needed to satisfy the contrast requirement.
 * Achromatic colours (R = G = B) return H = 0, S = 0.
 */
const rgbToHsl = (r: number, g: number, b: number): [number, number, number] => {
  const R = r / 255,
    G = g / 255,
    B = b / 255;
  const max = Math.max(R, G, B),
    min = Math.min(R, G, B);
  const l = (max + min) / 2;
  if (max === min) return [0, 0, l];
  const d = max - min;
  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
  let h: number;
  if (max === R) h = ((G - B) / d + (G < B ? 6 : 0)) / 6;
  else if (max === G) h = ((B - R) / d + 2) / 6;
  else h = ((R - G) / d + 4) / 6;
  return [h * 360, s, l];
};

/**
 * Converts HSL back to sRGB (inverse of rgbToHsl).
 *
 * Uses the standard two-step piecewise-linear hue reconstruction:
 *   q = L < 0.5 ? L(1+S) : L+S−L·S   (upper chroma boundary)
 *   p = 2L − q                         (lower chroma boundary)
 * Each R, G, B channel is obtained by evaluating a piecewise hue function
 * with offsets of +1/3 (120°) and −1/3 (−120°) for R and B respectively.
 */
const hslToRgb = (h: number, s: number, l: number): [number, number, number] => {
  h = h / 360;
  if (s === 0) {
    const v = Math.round(l * 255);
    return [v, v, v];
  }
  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
  const p = 2 * l - q;
  const hue2rgb = (p: number, q: number, t: number): number => {
    if (t < 0) t += 1;
    if (t > 1) t -= 1;
    if (t < 1 / 6) return p + (q - p) * 6 * t;
    if (t < 1 / 2) return q;
    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
    return p;
  };
  return [
    Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
    Math.round(hue2rgb(p, q, h) * 255),
    Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
  ];
};

/** Converts an [R, G, B] tuple (0–255) to a lowercase 6-digit hex string. */
const rgbToHex = (r: number, g: number, b: number): string =>
  '#' +
  [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');

/**
 * Finds the lightness-adjusted version of `adjustRgb` that is as close as
 * possible to the original colour while achieving at least `requiredRatio`
 * contrast against `fixedRgb`.
 *
 * Strategy — binary search on HSL lightness L ∈ [0, 1]:
 *   direction = 'darker':  searches L ∈ [0, original_L] for the MAXIMUM L
 *     (least dark, closest to original) at which contrast ≥ required.
 *   direction = 'lighter': searches L ∈ [original_L, 1] for the MINIMUM L
 *     (least light, closest to original) at which contrast ≥ required.
 *
 * Hue (H) and saturation (S) are held constant so the result stays as close
 * to the original brand/design colour as possible.
 *
 * 30 iterations yield a lightness precision of 1/2^30 ≈ 10⁻⁹, well below
 * the 1/255 ≈ 0.004 resolution of 8-bit channels, so the result is
 * effectively exact at 8-bit depth.
 *
 * Returns null if even the extreme value for this direction (pure black at
 * L = 0 or pure white at L = 1 for the given H and S) cannot achieve the
 * required ratio — an edge case that only arises for very low required ratios
 * or highly chromatic near-neutral colours.
 */
const findCompliantColorByLightness = (
  adjustRgb: [number, number, number],
  fixedRgb: [number, number, number],
  requiredRatio: number,
  direction: 'darker' | 'lighter',
): [number, number, number] | null => {
  const fixedLum = relativeLuminance(...fixedRgb);
  const [h, s, origHslL] = rgbToHsl(...adjustRgb);

  // Verify the extreme value in this direction is sufficient at all.
  const extremeHslL = direction === 'darker' ? 0 : 1;
  const extremeRgb = hslToRgb(h, s, extremeHslL);
  if (wcagContrastRatio(fixedLum, relativeLuminance(...extremeRgb)) < requiredRatio) {
    return null;
  }

  let lo = direction === 'darker' ? 0 : origHslL;
  let hi = direction === 'darker' ? origHslL : 1;
  let best = extremeHslL; // guaranteed-passing extreme

  for (let i = 0; i < 30; i++) {
    const mid = (lo + hi) / 2;
    const midRgb = hslToRgb(h, s, mid);
    const passes = wcagContrastRatio(fixedLum, relativeLuminance(...midRgb)) >= requiredRatio;

    if (passes) {
      best = mid; // This midpoint works; try to get even closer to the original.
      if (direction === 'darker') lo = mid; // Can we raise L further (less dark)?
      else hi = mid; // Can we lower L further (less light)?
    } else {
      if (direction === 'darker') hi = mid; // Too light; go darker.
      else lo = mid; // Too dark; go lighter.
    }
  }

  return hslToRgb(h, s, best);
};

/**
 * Builds a human-readable recommendation for a single failing contrast pair.
 *
 * WCAG 1.4.3 "Contrast (Minimum)" defines two situations based on font size
 * and weight (1 pt = 1.333 px at 96 dpi):
 *
 *   Situation A — normal text (< 18 pt non-bold  /  < 14 pt bold,
 *                               i.e. < ~24 px    /  < ~18.5 px):
 *     Required contrast ≥ 4.5:1  (G18)
 *
 *   Situation B — large text  (≥ 18 pt non-bold  /  ≥ 14 pt bold,
 *                               i.e. ≥ ~24 px    /  ≥ ~18.5 px):
 *     Required contrast ≥ 3:1    (G145)
 *
 * axe-core applies this rule upstream using:
 *   ptSize      = Math.ceil(fontSize_px × 72) / 96          // px → pt
 *   isSmallFont = (bold && ptSize < boldTextPt)              // default 14 pt
 *              || (!bold && ptSize < largeTextPt)            // default 18 pt
 *   bold        = fontWeight ≥ 700 || fontWeight === 'bold'  // boldValue = 700
 * and stores the result as `expectedContrastRatio: "4.5:1"` (Situation A) or
 * `"3:1"` (Situation B).  The `required` value used here is derived from that
 * field via `parseFloat("4.5:1") → 4.5` or `parseFloat("3:1") → 3`, so the
 * binary search automatically targets the correct threshold for each
 * combination's font size and weight — no re-classification is needed here.
 *
 * WCAG 1.4.3 exceptions (no contrast requirement — filtered by axe-core
 * before the data ever reaches this function):
 *   • Pure decoration (no informational purpose, rearrangeable/substitutable)
 *   • Inactive / disabled user-interface components
 *   • Logotypes and brand names
 *   • Text inside photographs or images with significant other visual content
 *
 * Algorithm for each failing pair:
 *   1. Parse both colours to [R, G, B] (axe-core supplies #rrggbb hex).
 *   2. Convert each colour to HSL.  Search for the nearest compliant
 *      foreground by binary-searching HSL lightness L in both the 'darker'
 *      and 'lighter' directions while keeping H and S constant (preserves
 *      hue/saturation of the original brand colour).
 *   3. Repeat step 2 for the background (holding the foreground fixed).
 *   4. For each colour role, pick whichever direction (darker/lighter)
 *      requires the smaller change in L — i.e. the least visually
 *      disruptive compliant alternative.
 *   5. Report both the adjusted foreground and the adjusted background
 *      (hex + rgb) so developers can choose whichever fits their design
 *      system.  The target ratio is included so the output is unambiguous
 *      when a page contains a mix of normal-text (4.5:1) and large-text
 *      (3:1) failures.
 *
 * Returns null if the colours cannot be parsed or no compliant alternative
 * can be found (extremely rare; only arises when the colour is already at
 * the luminance extreme for its hue/saturation).
 */
const buildContrastRecommendation = (example: ContrastExample): string | null => {
  const fgRgb = parseColor(example.fgColor);
  const bgRgb = parseColor(example.bgColor);
  if (!fgRgb || !bgRgb) return null;

  // parseFloat handles "4.5:1" → 4.5 because it stops at the non-numeric ':'.
  // The value is either 4.5 (Situation A, normal text) or 3 (Situation B,
  // large text), as determined by axe-core from the element's font metrics.
  const CONTRAST_BUFFER = 1.05;
  const required = parseFloat(example.expectedContrastRatio) * CONTRAST_BUFFER;
  if (isNaN(required)) return null;

  // Find the nearest compliant foreground (try both directions, pick closest).
  const fgDarker = findCompliantColorByLightness(fgRgb, bgRgb, required, 'darker');
  const fgLighter = findCompliantColorByLightness(fgRgb, bgRgb, required, 'lighter');
  const [, , origFgHslL] = rgbToHsl(...fgRgb);
  let recFg: [number, number, number] | null = null;
  if (fgDarker && fgLighter) {
    const [, , dL] = rgbToHsl(...fgDarker);
    const [, , lL] = rgbToHsl(...fgLighter);
    recFg = Math.abs(dL - origFgHslL) <= Math.abs(lL - origFgHslL) ? fgDarker : fgLighter;
  } else {
    recFg = fgDarker ?? fgLighter;
  }

  // Find the nearest compliant background (try both directions, pick closest).
  const bgDarker = findCompliantColorByLightness(bgRgb, fgRgb, required, 'darker');
  const bgLighter = findCompliantColorByLightness(bgRgb, fgRgb, required, 'lighter');
  const [, , origBgHslL] = rgbToHsl(...bgRgb);
  let recBg: [number, number, number] | null = null;
  if (bgDarker && bgLighter) {
    const [, , dL] = rgbToHsl(...bgDarker);
    const [, , lL] = rgbToHsl(...bgLighter);
    recBg = Math.abs(dL - origBgHslL) <= Math.abs(lL - origBgHslL) ? bgDarker : bgLighter;
  } else {
    recBg = bgDarker ?? bgLighter;
  }

  if (!recFg && !recBg) return null;

  const parts: string[] = [];
  if (recFg) {
    const [rr, gg, bb] = recFg;
    parts.push(`foreground text color to ${rgbToHex(rr, gg, bb)} (rgb(${rr}, ${gg}, ${bb}))`);
  }
  if (recBg) {
    const [rr, gg, bb] = recBg;
    parts.push(`background to ${rgbToHex(rr, gg, bb)} (rgb(${rr}, ${gg}, ${bb}))`);
  }

  // Include the target ratio in the string so the message is unambiguous when
  // a single element has a mix of normal-text (4.5:1) and large-text (3:1)
  // failing combinations with different required thresholds.
  return `${parts.join(' or ')}`;
};

/**
 * Builds the augmented issue description for a single axe-core color-contrast
 * violation node.
 *
 * ─── WCAG rules applied ──────────────────────────────────────────────────────
 *
 * WCAG 1.4.3 "Contrast (Minimum)" distinguishes two situations:
 *
 *   Situation A — normal text (< 18 pt non-bold / < 14 pt bold,
 *                               i.e. < ~24 px   / < ~18.5 px at 96 dpi):
 *     Required contrast ratio ≥ 4.5:1
 *
 *   Situation B — large text  (≥ 18 pt non-bold / ≥ 14 pt bold,
 *                               i.e. ≥ ~24 px   / ≥ ~18.5 px at 96 dpi):
 *     Required contrast ratio ≥ 3:1
 *
 * axe-core classifies each element before this function runs:
 *   ptSize      = Math.ceil(fontSize_px × 72) / 96          // px → pt
 *   isSmallFont = (bold  && ptSize < 14)                     // Situation A bold
 *              || (!bold && ptSize < 18)                     // Situation A normal
 *   bold        = fontWeight ≥ 700 || fontWeight === 'bold'
 * …and stores the result in check.data.expectedContrastRatio as "4.5:1" or "3:1".
 *
 * Exceptions (no contrast requirement — already excluded by axe-core upstream):
 *   • Pure decoration (no informational purpose)
 *   • Inactive / disabled UI components
 *   • Logotypes and brand names
 *   • Text inside photographs with significant other visual content
 *
 * ─── Function flow ───────────────────────────────────────────────────────────
 *
 *  1. Collect checks — flatten node.any, node.all, node.none into one array.
 *     Each entry may carry a ContrastCheckData payload with fgColor, bgColor,
 *     contrastRatio, fontSize, fontWeight, and expectedContrastRatio.
 *
 *  2. Deduplicate — key each combination on
 *     [fgColor, bgColor, fontSize, fontWeight, expectedContrastRatio].
 *     A single DOM node can generate multiple identical checks; the Map ensures
 *     each distinct failing pair is reported once.
 *
 *  3. Build the base message — lists every unique failing combination with its
 *     current contrast ratio and the required ratio, and instructs the developer
 *     to fix all failing text in the component, not just the first element.
 *
 *  4. Build per-combo recommendations (via buildContrastRecommendation) —
 *     for each failing pair, binary-search HSL lightness to find the nearest
 *     compliant foreground (background fixed) and the nearest compliant
 *     background (foreground fixed), both expressed as hex + rgb().  The binary
 *     search targets the combination's own expectedContrastRatio (4.5 or 3),
 *     so large-text recommendations are correctly held to the 3:1 threshold
 *     and normal-text recommendations to 4.5:1.
 *
 *  5. Concatenate — append "Recommendation: …" after the base message so the
 *     existing description is never modified, only extended.
 *
 * Returns null when no check carries usable contrast data (axe-core may omit
 * it for pseudo-element or out-of-viewport cases).
 */
const buildColorContrastMessage = (node: NodeResultWithScreenshot): string | null => {
  const checks = [...(node.any || []), ...(node.all || []), ...(node.none || [])] as Array<{
    data?: ContrastCheckData;
  }>;

  const uniqueCombos = new Map<string, ContrastExample>();

  checks.forEach(check => {
    const data = check.data || {};
    const hasContrastData =
      data.fgColor ||
      data.bgColor ||
      data.contrastRatio !== undefined ||
      data.expectedContrastRatio;

    if (!hasContrastData) return;

    if (!data.fgColor || !data.bgColor) return;

    const fgColor = data.fgColor;
    const bgColor = data.bgColor;
    const contrastRatio = String(data.contrastRatio ?? 'unknown');
    const fontSize = formatContrastFontSize(data.fontSize);
    const fontWeight = data.fontWeight || 'normal';
    const expectedContrastRatio = data.expectedContrastRatio || '4.5:1';

    const key = [fgColor, bgColor, fontSize, fontWeight, expectedContrastRatio].join('|');

    if (!uniqueCombos.has(key)) {
      uniqueCombos.set(key, {
        fgColor,
        bgColor,
        contrastRatio,
        fontSize,
        fontWeight,
        expectedContrastRatio,
      });
    }
  });

  if (!uniqueCombos.size) return null;

  const combos = [...uniqueCombos.values()];

  const examples = combos
    .map(
      example =>
        `foreground ${example.fgColor} on ${example.bgColor} at ${example.fontSize} ${example.fontWeight === 'bold' ? 'bold' : 'regular'} text`,
    )
    .join(', and ');

  const targetRatio = combos[0]?.expectedContrastRatio || '4.5:1';
  const currentRatio = combos[0]?.contrastRatio || 'unknown';

  const base = `Multiple text elements in this component fail WCAG 1.4.3 Color Contrast Minimum.\n  Normal text should meet or exceed ${targetRatio} contrast ratio against its actual background.\n  The current text contrast ratio of ${currentRatio} does not meet requirements. Failing combinations in this snippet include ${examples}.`;

  const recommendations = combos
    .map(buildContrastRecommendation)
    .filter((r): r is string => r !== null);

  const recSection =
    recommendations.length > 0
      ? `\n  Recommendation: Adjust ${recommendations.join('; ')}.`
      : '';

  const ctx = node.contrastDOMContext;
  if (!ctx) return `${base}${recSection}`;

  const notes: string[] = [];

  if (ctx.hasGradient) {
    notes.push(
      `gradient background detected (${ctx.backgroundImage}): the sampled background color represents a single point — verify contrast at every gradient stop and position where text appears, then adjust the gradient stops or add a solid color fallback behind the text`,
    );
  } else if (ctx.ancestorHasGradient) {
    notes.push(
      `an ancestor provides a gradient background: the sampled background color may not match what is visually beneath the text — verify contrast against the actual rendered gradient`,
    );
  }

  if (ctx.hasBackgroundImage) {
    notes.push(
      `background image detected: contrast cannot be fully determined from a sampled color alone — ensure text remains readable across all image content and states`,
    );
  } else if (ctx.ancestorHasBackgroundImage) {
    notes.push(
      `an ancestor has a background image: the effective background under this text may differ from the sampled value`,
    );
  }

  if (ctx.hasReducedOpacity) {
    notes.push(
      `opacity less than 1 detected on this element or an ancestor: the rendered contrast is lower than the computed color values indicate`,
    );
  }

  if (ctx.mixBlendMode) {
    notes.push(
      `mix-blend-mode: ${ctx.mixBlendMode} is applied: actual rendered colors depend on the underlying layers`,
    );
  }

  if (ctx.backdropFilter) {
    notes.push(`backdrop-filter: ${ctx.backdropFilter} is applied: the effective background appearance is modified`);
  }

  if (ctx.filter) {
    notes.push(
      `CSS filter: ${ctx.filter} is applied to this element: rendered colors may differ from computed values`,
    );
  }

  const ctxSection =
    notes.length > 0
      ? `\n  Rendering complexity: ${notes.join('; ')}.\n  The color fix recommendations above may not be accurate for this element — manual verification of the actual rendered contrast is strongly advised.`
      : '';

  return `${base}${recSection}${ctxSection}`;
};

// Enriches axe violation failureSummaries with additional DOM context gathered via Playwright,
// providing LLMs with the specific details they need to apply correct fixes.
export const enrichViolationMessages = async (results: AxeResults, page: Page): Promise<void> => {
  for (const violation of results.violations) {
    if (violation.id !== 'target-size' && violation.id !== 'valid-lang') continue;

    for (const node of violation.nodes) {
      const cssSelector =
        node.target.length === 1 && typeof node.target[0] === 'string' ? node.target[0] : null;
      if (!cssSelector) continue;

      if (violation.id === 'target-size') {
        // Axe's target-size check records the actual hit-area sub-rect it
        // evaluated (partiallyObscured/largestInnerRect cases can be much
        // smaller than the element's bounding box). Prefer those stored
        // dimensions; re-measuring the element from the DOM reports the full
        // size and misrepresents the violation.
        const axeCheck = (node.any || []).find(c => c.id === 'target-size') as
          | { data?: { width?: number; height?: number } }
          | undefined;
        const axeWidth = axeCheck?.data?.width;
        const axeHeight = axeCheck?.data?.height;

        const ctx = await page
          .evaluate((sel: string) => {
            try {
              const el = document.querySelector(sel) as HTMLElement | null;
              if (!el) return null;
              const rect = el.getBoundingClientRect();
              const computed = window.getComputedStyle(el);
              return {
                renderedWidth: Math.round(rect.width),
                renderedHeight: Math.round(rect.height),
                boxSizing: computed.boxSizing,
                inlineWidth: el.style.width || null,
                inlineHeight: el.style.height || null,
                tagName: el.tagName.toLowerCase(),
              };
            } catch {
              return null;
            }
          }, cssSelector)
          .catch(() => null);

        const width = axeWidth !== undefined ? axeWidth : ctx?.renderedWidth;
        const height = axeHeight !== undefined ? axeHeight : ctx?.renderedHeight;

        if (width === undefined || height === undefined) continue;

        const spacingMatch = node.failureSummary?.match(/diameter of (\d+)px/);
        const spacing = spacingMatch ? spacingMatch[1] : null;

        const boxSizingLabel = ctx ? ` (box-sizing: ${ctx.boxSizing})` : '';
        let message = `Insufficient target size: ${width}px by ${height}px${boxSizingLabel}.\n  Ensure it is at least 24px by 24px.`;

        if (spacing) {
          message += `\n  Target has insufficient space to its adjacent element of ${spacing}px. Ensure it has a safe clickable space of at least 24px.`;
        }

        if (
          ctx &&
          ctx.boxSizing === 'border-box' &&
          (ctx.inlineWidth !== null || ctx.inlineHeight !== null)
        ) {
          message += `\n  Current button style code snippet does not increase the hit area.\n  Remove the explicit width/height and use min-width: 24px; min-height: 24px instead.\n  Or place the visual content in a child <span> element.`;
        }

        node.failureSummary = message;
      } else if (violation.id === 'valid-lang') {
        const ctx = await page
          .evaluate((sel: string) => {
            try {
              const el = document.querySelector(sel);
              if (!el) return null;
              return {
                langValue: el.getAttribute('lang') ?? '',
                textSnippet: (el.textContent ?? '').trim().slice(0, 120),
              };
            } catch {
              return null;
            }
          }, cssSelector)
          .catch(() => null);

        if (ctx) {
          let message = `Value of lang attribute is not a valid language.\n  Use a registered IANA language code instead of "${ctx.langValue}".`;

          if (ctx.langValue.startsWith('x-')) {
            message += `\n  Axe-core valid-lang rule also rejects private-use subtags.`;
          }

          message += `\n  Identify the actual language of this text and use its registered BCP 47 code (e.g., lang="it" Italian, "es" Spanish, "fr" French, "de" German, "zh" Chinese, "ja" Japanese, "ko" Korean, "pt" Portuguese, "ar" Arabic).`;

          node.failureSummary = message;
        }
      }
    }
  }
};

export const filterAxeResults = (
  results: AxeResultsWithScreenshot,
  pageTitle: string,
  customFlowDetails?: CustomFlowDetails,
): FilteredResults => {
  const { violations, passes, incomplete, url } = results;

  let totalItems = 0;
  const mustFix: ResultCategory = { totalItems: 0, rules: {} };
  const goodToFix: ResultCategory = { totalItems: 0, rules: {} };
  const passed: ResultCategory = { totalItems: 0, rules: {} };
  const needsReview: ResultCategory = { totalItems: 0, rules: {} };

  const process = (item: ResultWithScreenshot, displayNeedsReview: boolean) => {
    const { id: rule, help: description, helpUrl, tags, nodes } = item;

    if (rule === 'frame-tested') return;

    const conformance = tags.filter(tag => tag.startsWith('wcag') || tag === 'best-practice');

    // handle rare cases where conformance level is not the first element
    const wcagRegex = /^wcag\d+a+$/;

    if (conformance[0] !== 'best-practice' && !wcagRegex.test(conformance[0])) {
      conformance.sort((a, b) => {
        if (wcagRegex.test(a) && !wcagRegex.test(b)) {
          return -1;
        }
        if (!wcagRegex.test(a) && wcagRegex.test(b)) {
          return 1;
        }
        return 0;
      });
    }

    const addTo = (category: ResultCategory, node: NodeResultWithScreenshot) => {
      const { html, failureSummary, screenshotPath, target, impact: axeImpact } = node;
      if (!(rule in category.rules)) {
        category.rules[rule] = {
          description,
          axeImpact,
          helpUrl,
          conformance,
          totalItems: 0,
          items: [],
        };
      }
      const defaultMessage = displayNeedsReview
        ? failureSummary.slice(failureSummary.indexOf('\n') + 1).trim()
        : failureSummary;
      const message =
        rule === 'color-contrast' || rule === 'color-contrast-enhanced'
          ? buildColorContrastMessage(node) || defaultMessage
          : defaultMessage;

      let finalHtml = html;
      if (html.includes('</script>')) {
        finalHtml = html.replaceAll('</script>', '&lt;/script>');
      }
      finalHtml = truncateHtml(finalHtml);

      const xpath = target.length === 1 && typeof target[0] === 'string' ? target[0] : null;

      // add in screenshot path
      category.rules[rule].items.push({
        html: finalHtml,
        message,
        screenshotPath,
        xpath: xpath || undefined,
        displayNeedsReview: displayNeedsReview || undefined,
      });
      category.rules[rule].totalItems += 1;
      category.totalItems += 1;
      totalItems += 1;
    };

    nodes.forEach(node => {
      const hasWcagA = conformance.some(tag => /^wcag\d*a$/.test(tag));
      const hasWcagAA = conformance.some(tag => /^wcag\d*aa$/.test(tag));
      // const hasWcagAAA = conformance.some(tag => /^wcag\d*aaa$/.test(tag));

      if (displayNeedsReview) {
        addTo(needsReview, node);
      } else if (hasWcagA || hasWcagAA) {
        addTo(mustFix, node);
      } else {
        addTo(goodToFix, node);
      }
    });
  };

  violations.forEach(item => process(item, false));
  incomplete.forEach(item => process(item, true));

  passes.forEach((item: Result) => {
    const { id: rule, help: description, impact: axeImpact, helpUrl, tags, nodes } = item;

    if (rule === 'frame-tested') return;

    const conformance = tags.filter(tag => tag.startsWith('wcag') || tag === 'best-practice');

    nodes.forEach(node => {
      const { html, target } = node;
      if (!(rule in passed.rules)) {
        passed.rules[rule] = {
          description,
          axeImpact,
          helpUrl,
          conformance,
          totalItems: 0,
          items: [],
        };
      }

      const finalHtml = truncateHtml(html);
      const xpath = target.length === 1 && typeof target[0] === 'string' ? target[0] : undefined;
      passed.rules[rule].items.push({ html: finalHtml, screenshotPath: '', message: '', xpath: xpath || '' });

      passed.totalItems += 1;
      passed.rules[rule].totalItems += 1;
      totalItems += 1;
    });
  });

  return {
    url,
    pageTitle: customFlowDetails ? `${customFlowDetails.pageIndex}: ${pageTitle}` : pageTitle,
    pageIndex: customFlowDetails ? customFlowDetails.pageIndex : undefined,
    metadata: customFlowDetails?.metadata
      ? `${customFlowDetails.pageIndex}: ${customFlowDetails.metadata}`
      : undefined,
    pageImagePath: customFlowDetails ? customFlowDetails.pageImagePath : undefined,
    totalItems,
    mustFix,
    goodToFix,
    needsReview,
    passed,
  };
};

export const runAxeScript = async ({
  includeScreenshots,
  page,
  randomToken,
  customFlowDetails = null,
  selectors = [],
  ruleset = [],
}: {
  includeScreenshots: boolean;
  page: Page;
  randomToken: string;
  customFlowDetails?: CustomFlowDetails;
  selectors?: string[];
  ruleset?: RuleFlags[];
}) => {
  const browserContext: BrowserContext = page.context();
  const requestUrl = page.url();

  // Some pages replace native constructors (Set, Map, WeakMap, ...) with
  // incomplete polyfills. axe-core and even Playwright's own return-value
  // serialization use these natives internally and blow up with unhelpful
  // errors like `r.has is not a function` or `i.forEach is not a function`.
  // Restore pristine constructors by stealing them from a fresh same-origin
  // iframe (whose Realm the page hasn't touched) before doing any evaluation.
  try {
    await page.evaluate(() => {
      // Probe first — only pay the iframe cost when the page has actually
      // clobbered a native collection constructor. On clean pages this is a
      // no-op that saves ~10ms per scan.
      try {
        const s = new Set();
        const m = new Map();
        const ws = new WeakSet();
        const wm = new WeakMap();
        if (
          typeof s.has === 'function' &&
          typeof s.add === 'function' &&
          typeof m.has === 'function' &&
          typeof m.get === 'function' &&
          typeof ws.has === 'function' &&
          typeof wm.has === 'function'
        ) {
          return;
        }
      } catch {
        // fall through to restore
      }
      const iframe = document.createElement('iframe');
      iframe.style.display = 'none';
      iframe.setAttribute('aria-hidden', 'true');
      iframe.setAttribute('data-oobee-realm-restore', '1');
      document.documentElement.appendChild(iframe);
      const w = iframe.contentWindow as unknown as Record<string, unknown>;
      if (!w) return;
      for (const name of [
        'Set', 'Map', 'WeakSet', 'WeakMap',
        'Array', 'Object', 'Promise', 'Symbol',
        'Number', 'String', 'Boolean', 'RegExp', 'Date',
        'JSON', 'Math',
      ]) {
        try {
          (window as unknown as Record<string, unknown>)[name] = w[name];
        } catch {}
      }
      // Intentionally leave the iframe in the DOM — removing it invalidates
      // its Realm and the constructors we just copied become dead references.
    });
  } catch {
    // Page may not be ready or the eval itself may fail on hostile pages;
    // best effort — continue with axe injection regardless.
  }

  let pageTitle: string | null = null;
  try {
    pageTitle = await page.evaluate(() => document.title);
  } catch {
    // Page may already be in a bad state; title will remain null
  }

  try {
    // Checking for DOM mutations before proceeding to scan
    await page.evaluate(() => {
      return new Promise(resolve => {
        let timeout: NodeJS.Timeout;
        let mutationCount = 0;
        const MAX_MUTATIONS = 500;
        const MAX_SAME_MUTATION_LIMIT = 10;
        const mutationHash: Record<string, number> = {};

        const observer = new MutationObserver(mutationsList => {
          clearTimeout(timeout);

          mutationCount += 1;

          if (mutationCount > MAX_MUTATIONS) {
            observer.disconnect();
            resolve('Too many mutations detected');
          }

          // To handle scenario where DOM elements are constantly changing and unable to exit
          mutationsList.forEach(mutation => {
            let mutationKey: string;

            if (mutation.target instanceof Element) {
              Array.from(mutation.target.attributes).forEach(attr => {
                mutationKey = `${mutation.target.nodeName}-${attr.name}`;

                if (mutationKey) {
                  if (!mutationHash[mutationKey]) {
                    mutationHash[mutationKey] = 1;
                  } else {
                    mutationHash[mutationKey] += 1;
                  }

                  if (mutationHash[mutationKey] >= MAX_SAME_MUTATION_LIMIT) {
                    observer.disconnect();
                    resolve(`Repeated mutation detected for ${mutationKey}`);
                  }
                }
              });
            }
          });

          timeout = setTimeout(() => {
            observer.disconnect();
            resolve('DOM stabilized after mutations.');
          }, 1000);
        });

        timeout = setTimeout(() => {
          observer.disconnect();
          resolve('No mutations detected, exit from idle state');
        }, 1000);

        observer.observe(document, { childList: true, subtree: true, attributes: true });
      });
    });
  } catch (e) {
    // do nothing, just continue
  }

  // Omit logging of browser console errors to reduce unnecessary verbosity
  /*
  page.on('console', msg => {
    const type = msg.type();
    if (type === 'error') {
      consoleLogger.error(msg.text());
    } else {
      consoleLogger.info(msg.text());
    }
  });
  */

  const disableOobee = ruleset.includes(RuleFlags.DISABLE_OOBEE);
  const enableWcagAaa = ruleset.includes(RuleFlags.ENABLE_WCAG_AAA);

  const gradingReadabilityFlag = await extractAndGradeText(page); // Ensure flag is obtained before proceeding

  try {
    await playwrightUtils.injectFile(page, axeScript);
  } catch (e) {
    if (isTransientPageTeardown(e)) {
      consoleLogger.info(
        `axe injection aborted (page/browser closed) for ${requestUrl}; letting Crawlee retry: ${(e as Error)?.message ?? e}`,
      );
      throw e;
    }
    consoleLogger.error(`axe injection failed for ${requestUrl}: ${(e as Error)?.message ?? e}`);
    return {
      url: requestUrl,
      pageTitle: pageTitle ?? requestUrl,
      totalItems: 0,
      mustFix: { totalItems: 0, rules: {} },
      goodToFix: { totalItems: 0, rules: {} },
      needsReview: { totalItems: 0, rules: {} },
      passed: { totalItems: 0, rules: {} },
      axeScanFailed: true,
    };
  }

  let results;
  try {
    results = await page.evaluate(
    async ({
      selectors,
      saflyIconSelector,
      disableOobee,
      enableWcagAaa,
      gradingReadabilityFlag,
      evaluateAltTextFunctionString,
      escapeCssSelectorFunctionString,
      framesCheckFunctionString,
      findElementByCssSelectorFunctionString,
      getAxeConfigurationFunctionString,
      flagUnlabelledClickableElementsFunctionString,
      xPathToCssFunctionString,
    }) => {
      try {
        // Load functions into the browser context
        eval(evaluateAltTextFunctionString);
        eval(escapeCssSelectorFunctionString);
        eval(framesCheckFunctionString);
        eval(findElementByCssSelectorFunctionString);
        eval(flagUnlabelledClickableElementsFunctionString);
        eval(xPathToCssFunctionString);
        eval(getAxeConfigurationFunctionString);
        // remove so that axe does not scan
        document.querySelector(saflyIconSelector)?.remove();

        const oobeeAccessibleLabelFlaggedXpaths = disableOobee
          ? []
          : (await flagUnlabelledClickableElements()).map(item => item.xpath);
        const oobeeAccessibleLabelFlaggedCssSelectors = oobeeAccessibleLabelFlaggedXpaths
          .map(xpath => {
            try {
              const cssSelector = xPathToCss(xpath);
              return cssSelector;
            } catch (e) {
              console.error('Error converting XPath to CSS: ', xpath, e);
              return '';
            }
          })
          .filter(item => item !== '');

        const axeConfig = getAxeConfiguration({
          enableWcagAaa,
          gradingReadabilityFlag,
          disableOobee,
        });

        axe.configure(axeConfig);

        // removed needsReview condition
        const defaultResultTypes: resultGroups[] = ['violations', 'passes', 'incomplete'];

        // Exclude the realm-restore iframe (see runAxeScript preamble) so it
        // doesn't inflate the "passed" tally with rules like aria-hidden-focus.
        const axeContext: any = { exclude: [['[data-oobee-realm-restore]']] };
        if (Array.isArray(selectors) && selectors.length > 0) {
          axeContext.include = selectors;
        }

        return axe
          .run(axeContext, {
            resultTypes: defaultResultTypes,
          })
          .then(async results => {
            // ==== Re-verify select violations against the live DOM ====
            //
            // Some rules produce false positives when axe evaluates an
            // element whose state hasn't finished settling (mid-hydration
            // reflow, late aria-attribute injection, CSS-variable theme
            // swap). For those rules we re-check each flagged node after a
            // microtask yield and drop findings whose failure condition no
            // longer holds. All logic below runs in the browser context.

            // One yield lets any pending microtasks (post-axe layout /
            // hydration continuations) flush before we re-inspect.
            await new Promise(resolve => setTimeout(resolve, 0));

            // ---- Shared helpers -----------------------------------------

            // Extract axe's per-node selector (first entry of node.target).
            const getSelector = (n: NodeResult): string | null => {
              const s = n.target && n.target[0];
              return typeof s === 'string' ? s : null;
            };

            // Extract check data (e.g. { minSize, messageKey }) for a given
            // check id from an axe node result.
            const getCheckData = <T>(n: NodeResult, checkId: string): T | null => {
              const c = (n.any || []).find(x => x.id === checkId) as
                | { data?: T }
                | undefined;
              return c && c.data != null ? c.data : null;
            };

            // Filter a violation's nodes with a keep predicate. If nodes
            // empty out, remove the violation entirely.
            const pruneViolation = (
              ruleId: string,
              keep: (n: NodeResult) => boolean,
            ): void => {
              const v = results.violations.find(x => x.id === ruleId);
              if (!v) return;
              v.nodes = v.nodes.filter(keep);
              if (v.nodes.length === 0) {
                results.violations = results.violations.filter(
                  x => x.id !== ruleId,
                );
              }
            };

            // Resolve a selector to an Element, suppressing invalid-selector
            // exceptions (axe can emit selectors the browser refuses).
            const resolveElement = (sel: string): Element | null => {
              try {
                return document.querySelector(sel);
              } catch {
                return null;
              }
            };

            // ---- target-size --------------------------------------------
            // SPA frameworks (notably Salesforce Lightning) can resize an
            // interactive element after axe measured boundingClientRect.
            // Drop the finding if the element now meets the rule's minSize.
            // Scoped to the "element too small" cases (no messageKey, or
            // contentOverflow — which only fires when the element itself
            // is too small); partially-obscured findings depend on
            // neighbor geometry and are left alone.
            const DEFAULT_TARGET_MIN_SIZE = 24;
            pruneViolation('target-size', node => {
              const sel = getSelector(node);
              if (!sel) return true;
              const data = getCheckData<{ minSize?: number; messageKey?: string }>(
                node,
                'target-size',
              );
              if (!data) return true;
              if (data.messageKey && data.messageKey !== 'contentOverflow') return true;
              const minSize =
                typeof data.minSize === 'number' ? data.minSize : DEFAULT_TARGET_MIN_SIZE;
              const el = resolveElement(sel);
              if (!el) return true;
              const rect = el.getBoundingClientRect();
              return !(rect.width >= minSize && rect.height >= minSize);
            });

            // ---- aria-hidden-focus --------------------------------------
            // Handle race conditions with JS that sets tabindex="-1" after
            // aria-hidden (common in carousel/slider libraries like slick).
            const FOCUSABLE_SELECTOR =
              'a[href], area[href], button:not([disabled]), ' +
              'input:not([disabled]):not([type="hidden"]), ' +
              'select:not([disabled]), textarea:not([disabled]), [tabindex]';
            pruneViolation('aria-hidden-focus', node => {
              const sel = getSelector(node);
              if (!sel) return true;
              const el = resolveElement(sel);
              if (!el) return true;
              const focusables = el.querySelectorAll(FOCUSABLE_SELECTOR);
              if (focusables.length === 0) return false;
              return Array.from(focusables).some(child => {
                const tabindex = child.getAttribute('tabindex');
                if (tabindex === null) return true;
                const parsed = parseInt(tabindex, 10);
                return Number.isNaN(parsed) || parsed >= 0;
              });
            });

            // ---- color-contrast -----------------------------------------
            // CSS-variable theme swaps and late-loading stylesheets can
            // cause text to briefly render on the wrong background. Re-run
            // axe scoped to just the flagged elements with only the
            // color-contrast rule and drop findings that no longer fail.
            // Uses axe's own logic to avoid reimplementing color
            // composition (ancestor background walk, opacity, gradients).
            const colorContrastViolation = results.violations.find(
              v => v.id === 'color-contrast',
            );
            if (colorContrastViolation && colorContrastViolation.nodes.length > 0) {
              try {
                // Map each flagged node to its live element so we can match
                // re-run results back to originals by identity — axe may
                // regenerate slightly different selectors across runs on a
                // hydrating page.
                const nodeElements = new Map<NodeResult, Element>();
                for (const node of colorContrastViolation.nodes) {
                  const sel = getSelector(node);
                  if (!sel) continue;
                  const el = resolveElement(sel);
                  if (el) nodeElements.set(node, el);
                }
                if (nodeElements.size > 0) {
                  const uniqueElements = Array.from(new Set(nodeElements.values()));
                  const reRun = await axe.run(uniqueElements, {
                    runOnly: ['color-contrast'],
                    resultTypes: ['violations'],
                  });
                  const stillFailing = new WeakSet<Element>();
                  for (const v of reRun.violations || []) {
                    for (const n of v.nodes) {
                      const s = getSelector(n as NodeResult);
                      if (!s) continue;
                      const el = resolveElement(s);
                      if (el) stillFailing.add(el);
                    }
                  }
                  colorContrastViolation.nodes = colorContrastViolation.nodes.filter(
                    n => {
                      const el = nodeElements.get(n);
                      // If we couldn't resolve the element, be conservative
                      // and keep the finding.
                      return el ? stillFailing.has(el) : true;
                    },
                  );
                  if (colorContrastViolation.nodes.length === 0) {
                    results.violations = results.violations.filter(
                      v => v.id !== 'color-contrast',
                    );
                  }
                }
              } catch {
                // Re-run failed; keep original findings to be safe.
              }
            }

            if (disableOobee) {
              return results;
            }
            // handle css id selectors that start with a digit
            const escapedCssSelectors =
              oobeeAccessibleLabelFlaggedCssSelectors.map(escapeCssSelector);

            // Add oobee violations to Axe's report
            const oobeeAccessibleLabelViolations = {
              id: 'oobee-accessible-label',
              impact: 'serious' as ImpactValue,
              tags: ['wcag2a', 'wcag211', 'wcag412'],
              description: 'Ensures clickable elements have an accessible label.',
              help: 'Clickable elements (i.e. elements with mouse-click interaction) must have accessible labels.',
              helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
              nodes: escapedCssSelectors
                .map((cssSelector: string): NodeResult => ({
                  html: findElementByCssSelector(cssSelector),
                  target: [cssSelector],
                  impact: 'serious' as ImpactValue,
                  failureSummary:
                    'Fix any of the following:\n  The clickable element does not have an accessible label.',
                  any: [
                    {
                      id: 'oobee-accessible-label',
                      data: null,
                      relatedNodes: [],
                      impact: 'serious',
                      message: 'The clickable element does not have an accessible label.',
                    },
                  ],
                  all: [],
                  none: [],
                }))
                .filter(item => item.html),
            };

            results.violations = [...results.violations, oobeeAccessibleLabelViolations];
            return results;
          })
          .catch(e => {
            console.error('Error at axe.run', e);
            throw e;
          });
      } catch (e) {
        console.error(e);
        throw e;
      }
    },
    {
      selectors,
      saflyIconSelector,
      disableOobee,
      enableWcagAaa,
      gradingReadabilityFlag,
      evaluateAltTextFunctionString: evaluateAltText.toString(),
      escapeCssSelectorFunctionString: escapeCssSelector.toString(),
      framesCheckFunctionString: framesCheck.toString(),
      findElementByCssSelectorFunctionString: findElementByCssSelector.toString(),
      getAxeConfigurationFunctionString: getAxeConfiguration.toString(),
      flagUnlabelledClickableElementsFunctionString: flagUnlabelledClickableElements.toString(),
      xPathToCssFunctionString: xPathToCss.toString(),
    },
  );
  } catch (e) {
    if (isTransientPageTeardown(e)) {
      consoleLogger.info(
        `axe.run aborted (page/browser closed) for ${requestUrl}; letting Crawlee retry: ${(e as Error)?.message ?? e}`,
      );
      throw e;
    }
    consoleLogger.error(`axe.run failed for ${requestUrl}: ${(e as Error)?.message ?? e}`);
    return {
      url: requestUrl,
      pageTitle: pageTitle ?? requestUrl,
      totalItems: 0,
      mustFix: { totalItems: 0, rules: {} },
      goodToFix: { totalItems: 0, rules: {} },
      needsReview: { totalItems: 0, rules: {} },
      passed: { totalItems: 0, rules: {} },
      axeScanFailed: true,
    };
  }

  await enrichViolationMessages(results, page);
  await enrichColorContrastDOMContext(results.violations, page);

  if (includeScreenshots) {
    results.violations = await takeScreenshotForHTMLElements(results.violations, page, randomToken);
    results.incomplete = await takeScreenshotForHTMLElements(results.incomplete, page, randomToken);
  }

  return filterAxeResults(results, pageTitle, customFlowDetails);
};

// @crawlee/memory-storage@3.18 rejects absolute paths as storage names, so callers
// must pass relative names. The actual storage location is conveyed via the
// CRAWLEE_STORAGE_DIR env var (set to getStoragePath(randomToken) at scan entry).
export const createCrawleeSubFolders = async (
  _randomToken: string,
  requestQueueName: string = 'crawlee_rq',
): Promise<{ dataset: Dataset; requestQueue: RequestQueue }> => {
  const dataset = await Dataset.open('crawlee');
  const requestQueue = await RequestQueue.open(requestQueueName);
  return { dataset, requestQueue };
};

export const preNavigationHooks = (extraHTTPHeaders: Record<string, string>) => {
  return [
    async (crawlingContext: CrawlingContext, gotoOptions: PlaywrightGotoOptions) => {
      if (extraHTTPHeaders && Object.keys(extraHTTPHeaders).length > 0) {
        crawlingContext.request.headers = extraHTTPHeaders;
      }
      // Use domcontentloaded — fires as soon as the DOM is parsed, before
      // images/stylesheets/network requests settle. This avoids indefinite
      // hangs on sites with WebSockets, analytics polling, or infinite-scroll
      // beacons that never reach networkidle. Further page stability is
      // handled by waitForPageLoaded() in each crawler's requestHandler and
      // by the DOM mutation observer in postNavigationHooks.
      if (gotoOptions) {
        gotoOptions.waitUntil = 'domcontentloaded';
        gotoOptions.timeout = 30000;
      }
    },
  ];
};

/**
 * Splits extraHTTPHeaders into auth and non-auth parts.
 * Auth headers (Authorization) must only be sent to same-origin requests to avoid CORS preflight failures.
 * Non-auth headers are safe to set globally on the browser context.
 */
export const splitAuthHeaders = (extraHTTPHeaders?: Record<string, string>) => {
  const { Authorization, ...nonAuthHeaders } = extraHTTPHeaders || {};
  return {
    authHeader: Authorization || null,
    nonAuthHeaders: Object.keys(nonAuthHeaders).length > 0 ? nonAuthHeaders : null,
    httpCredentials: (() => {
      if (!Authorization?.startsWith('Basic ')) return null;
      const decoded = Buffer.from(Authorization.slice(6), 'base64').toString();
      const colonIdx = decoded.indexOf(':');
      if (colonIdx <= 0) return null;
      return { username: decoded.slice(0, colonIdx), password: decoded.slice(colonIdx + 1) };
    })(),
  };
};

/**
 * Adds a route handler to a BrowserContext that sends the Authorization header
 * only to same-origin requests, preventing CORS preflight failures on cross-origin CDN resources.
 */
export const addAuthRouteHandler = async (
  context: BrowserContext,
  entryUrl: string,
  authHeader: string | null
) => {
  if (!authHeader) return;

  const entryOrigin = new URL(entryUrl).origin;
  await context.route('**/*', async (route, request) => {
    try {
      if (new URL(request.url()).origin === entryOrigin) {
        await route.continue({ headers: { ...request.headers(), Authorization: authHeader } });
      } else {
        await route.continue();
      }
    } catch {
      await route.continue();
    }
  });
};

export const postNavigationHooks = [
  async (_crawlingContext: CrawlingContext) => {
    guiInfoLog(guiInfoStatusTypes.COMPLETED, {});
  },
];

export const getPreLaunchHook = (userDataDirectory: string) => {
  let launchCount = 0;
  let previousPoolDir: string | null = null;

  return async (_pageId: string, launchContext: any) => {
    const fsp = await import('fs/promises').then(m => m.default);
    launchCount += 1;

    // Clean up the previous pool directory. When preLaunchHooks fires, the
    // previous browser has been retired and is finishing its last pages.
    // Schedule cleanup with enough delay for Chrome to fully exit —
    // closeInactiveBrowserAfterSecs (30s) + Windows file-lock grace.
    if (previousPoolDir) {
      const dirToClean = previousPoolDir;
      const isWin = process.platform === 'win32';
      const delay = isWin ? 40000 : 35000;
      setTimeout(async () => {
        for (let attempt = 0; attempt < 3; attempt++) {
          try {
            await fsp.rm(dirToClean, { recursive: true, force: true });
            return;
          } catch {
            await new Promise(r => setTimeout(r, 5000));
          }
        }
      }, delay);
    }

    // Every browser gets its own directory. The base userDataDirectory is
    // treated as a read-only cookie source (the pristine clone of the user's
    // profile) and is never used directly by a running browser.
    const effectiveDir = `${userDataDirectory}_pool${launchCount}`;

    await fsp.mkdir(effectiveDir, { recursive: true });

    // Copy auth-relevant files from the pristine base directory so
    // authenticated sessions are preserved across pool rotations.
    try {
      const localStateSrc = path.join(userDataDirectory, 'Local State');
      if (await fsp.stat(localStateSrc).catch(() => null)) {
        await fsp.copyFile(localStateSrc, path.join(effectiveDir, 'Local State')).catch(() => {});
      }

      const entries = await fsp.readdir(userDataDirectory, { withFileTypes: true }).catch(() => []);
      const profileDirs = (entries as any[]).filter(
        (e: any) => e.isDirectory() && /^(Default|Profile \d+)$/i.test(e.name),
      );

      for (const profile of profileDirs) {
        const srcProfile = path.join(userDataDirectory, profile.name);
        const destProfile = path.join(effectiveDir, profile.name);
        await fsp.mkdir(destProfile, { recursive: true }).catch(() => {});

        // Copy Preferences so the Safe Browsing OHTTP key (hash_real_time_ohttp_key)
        // is inherited from the base profile. injectSafeBrowsingDb() will merge
        // enabled/enhanced on top, preserving the warmed-up key.
        const prefsSrc = path.join(srcProfile, 'Preferences');
        const prefsDest = path.join(destProfile, 'Preferences');
        if (await fsp.stat(prefsSrc).catch(() => null)) {
          await fsp.copyFile(prefsSrc, prefsDest).catch(() => {});
          // Mark clean exit so Chrome doesn't show "Restore pages?" prompt.
          try {
            const raw = await fsp.readFile(prefsDest, 'utf8');
            const prefs: any = JSON.parse(raw);
            prefs.profile = { ...(prefs.profile || {}), exit_type: 'Normal', exited_cleanly: true };
            await fsp.writeFile(prefsDest, JSON.stringify(prefs));
          } catch {
            // Best effort — a corrupt Preferences file just means the prompt may show.
          }
        }

        // Cookies (macOS layout: <Profile>/Cookies)
        const cookiesSrc = path.join(srcProfile, 'Cookies');
        if (await fsp.stat(cookiesSrc).catch(() => null)) {
          await fsp.copyFile(cookiesSrc, path.join(destProfile, 'Cookies')).catch(() => {});
        }

        // Cookies (Windows layout: <Profile>/Network/Cookies)
        const networkCookiesSrc = path.join(srcProfile, 'Network', 'Cookies');
        if (await fsp.stat(networkCookiesSrc).catch(() => null)) {
          const destNetwork = path.join(destProfile, 'Network');
          await fsp.mkdir(destNetwork, { recursive: true }).catch(() => {});
          await fsp.copyFile(networkCookiesSrc, path.join(destNetwork, 'Cookies')).catch(() => {});
        }

        // Local Storage (auth tokens, session data)
        const localStorageSrc = path.join(srcProfile, 'Local Storage');
        const localStorageStat = await fsp.stat(localStorageSrc).catch(() => null);
        if (localStorageStat && localStorageStat.isDirectory()) {
          const destLocalStorage = path.join(destProfile, 'Local Storage');
          await fsp.mkdir(destLocalStorage, { recursive: true }).catch(() => {});
          const lsFiles = await fsp.readdir(localStorageSrc).catch(() => []);
          await Promise.all(
            lsFiles.map(f =>
              fsp.copyFile(
                path.join(localStorageSrc, f),
                path.join(destLocalStorage, f),
              ).catch(() => {}),
            ),
          );
        }
      }
    } catch {
      // Silent fallback: use empty profile if clone fails
    }

    // Ensure Safe Browsing DB is warmed up (first call downloads it, subsequent
    // calls are no-ops) then inject preferences into this pool browser's profile.
    await ensureAndInjectSafeBrowsing(effectiveDir);

    // Clean any stale lock files that may block browser launches on Windows
    const lockFiles = [
      path.join(effectiveDir, 'SingletonLock'),
      path.join(effectiveDir, 'SingletonSocket'),
      path.join(effectiveDir, 'SingletonCookie'),
      path.join(effectiveDir, 'lockfile'),
      path.join(effectiveDir, 'Default', 'LOCK'),
      path.join(effectiveDir, 'Default', 'Network', 'LOCK'),
    ];
    await Promise.all(lockFiles.map(f => fsp.rm(f, { force: true }).catch(() => {})));

    previousPoolDir = effectiveDir;
    // eslint-disable-next-line no-param-reassign
    launchContext.userDataDir = effectiveDir;
  };
};

export const getPostPageCloseHook = (userDataDirectory: string) => {
  return async (_pageId: string, browserController: any) => {
    if (browserController.activePages === 0) {
      const dir = browserController.launchContext?.userDataDir;
      if (dir && dir !== userDataDirectory && dir.includes('_pool')) {
        const fsp = await import('fs/promises').then(m => m.default);
        // Chrome on Windows writes optimization/segmentation data during shutdown;
        // wait longer (5s) for the process to fully release file handles.
        const isWin = process.platform === 'win32';
        const initialDelay = isWin ? 5000 : 2000;
        setTimeout(async () => {
          for (let attempt = 0; attempt < 3; attempt++) {
            try {
              await fsp.rm(dir, { recursive: true, force: true });
              return;
            } catch {
              // Retry after a short back-off (Windows file locks)
              await new Promise(r => setTimeout(r, 2000));
            }
          }
        }, initialDelay);
      }
    }
  };
};


export const failedRequestHandler = async ({ request }: { request: Request }) => {
  guiInfoLog(guiInfoStatusTypes.ERROR, { numScanned: 0, urlScanned: request.url });
  log.error(`Failed Request - ${request.url}: ${request.errorMessages}`);
};

export const isUrlPdf = (url: string) => {
  if (isFilePath(url)) {
    return /\.pdf$/i.test(url);
  }
  const parsedUrl = new URL(url);
  return /\.pdf($|\?|#)/i.test(parsedUrl.pathname) || /\.pdf($|\?|#)/i.test(parsedUrl.href);
};

export async function shouldSkipClickDueToDisallowedHref(
  page: Page,
  element: ElementHandle
): Promise<boolean> {
  return await page.evaluate(
    ({ el, disallowedPrefixes }) => {
      function isDisallowedHref(href: string | null): boolean {
        if (!href) return false;
        href = href.toLowerCase();
        return disallowedPrefixes.some((prefix: string) => href.startsWith(prefix));
      }

      const castEl = el as HTMLElement;

      // Check descendant <a href="">
      const descendants = castEl.querySelectorAll('a[href]');
      for (const a of descendants) {
        const href = a.getAttribute('href');
        if (isDisallowedHref(href)) {
          return true;
        }
      }

      // Check self and ancestors for disallowed <a>
      let current: HTMLElement | null = castEl;
      while (current) {
        if (
          current.tagName === 'A' &&
          isDisallowedHref(current.getAttribute('href'))
        ) {
          return true;
        }
        current = current.parentElement;
      }

      return false;
    },
    {
      el: element,
      disallowedPrefixes: disallowedListOfPatterns,
    }
  );
}

/**
 * Check if response should be skipped based on content headers.
 * @param response - Playwright Response object
 * @param requestUrl - Optional: request URL for logging
 * @returns true if the content should be skipped
 */
export const shouldSkipDueToUnsupportedContent = (
  response: PlaywrightResponse,
  requestUrl: string = ''
): boolean => {
  if (!response) return false;

  const headers = response.headers();
  const contentDisposition = headers['content-disposition'] || '';
  const contentType = headers['content-type'] || '';

  if (contentDisposition.includes('attachment')) {
    // consoleLogger.info(`Skipping attachment (content-disposition) at ${requestUrl}`);
    return true;
  }

  if (
    contentType.startsWith('application/') ||
    contentType.includes('octet-stream') ||
    (!contentType.startsWith('text/') && !contentType.includes('html'))
  ) {
    // consoleLogger.info(`Skipping non-processible content-type "${contentType}" at ${requestUrl}`);
    return true;
  }

  return false;
};
