/**
 * accessmate.ai frontend overlay — axe-core powered, no framework.
 *
 * Two modes:
 *  1. Interactive  — slide-in panel for the current page (admin bar button)
 *  2. Auto-scan    — headless iframe scan triggered by PageScanner admin view
 *                    (activated when ?aai_scan=1 is in the URL)
 */

import axe from 'axe-core';
import { tagsForRulesets, labelsForRulesets, RULESET_PRESETS, presetValueForStandards, standardsForPresetValue, DEFAULT_RULESET_PRESET } from './lib/rulesets';

// ── Types ─────────────────────────────────────────────────────

interface OverlayConfig {
  nonce: string;
  restBase: string;
  pageUrl: string;
  defaultRulesets?: string[];
  hasProvider?: boolean;
  settingsUrl?: string;
}

interface AaiIssueNode {
  element: string;
  selector: string;
}

interface AaiIssue {
  id: string;
  rule: string;
  wcag: string;
  wcag_level: string;
  wcag_standard: string;
  severity: 'critical' | 'serious' | 'warning' | 'notice';
  title: string;
  description: string;
  element: string;
  selector: string;
  nodes: AaiIssueNode[];
  count: number;
  fixed: boolean;
  ai_fixable: boolean;
  help_url: string;  // axe helpUrl — always a valid Deque University link
  category?: 'wcag' | 'best-practice';
}

interface AaiPassedCheck {
  id: string;
  title: string;
  description: string;
  help_url: string;
  count: number;
}

interface ScanPayload {
  url: string;
  page_title: string;
  score: number;
  grade: string;
  passed: number;
  failed: number;
  issues: AaiIssue[];
  rulesets?: string[];
  passed_checks?: AaiPassedCheck[];
}

declare global {
  interface Window {
    accessmateOverlayConfig: OverlayConfig;
    accessmateOverlay: { open(): void; close(): void };
  }
}

// ── Bootstrap ─────────────────────────────────────────────────

(function () {
  const cfg: OverlayConfig = window.accessmateOverlayConfig;
  if (!cfg) return;

  // Any non-empty aai_scan value means headless iframe mode.
  // The value is used as a scanId so the parent can match the response
  // without fragile URL comparison (URLs may differ in trailing slash, etc.).
  const scanId      = new URLSearchParams(window.location.search).get('aai_scan') ?? '';
  const autoScan    = new URLSearchParams(window.location.search).get('accessmate_scan') ?? '';

  if (scanId) {
    // Headless iframe mode — scan silently, store results, signal parent.
    runAutoScan(cfg, scanId);
  } else {
    // Interactive mode — slide-in panel for the current page.
    buildOverlay(cfg);

    // If accessmate_scan param is present, auto-open and start scanning immediately.
    if (autoScan) {
      requestAnimationFrame(() => window.accessmateOverlay?.open());
    }
  }
})();

// ── axe helpers ───────────────────────────────────────────────

/**
 * Maps axe-core rule IDs to the custom issue titles used by the backend PHP
 * scanner (class-scanner.php). Keeps overlay and dashboard titles in sync.
 */
const AXE_RULE_TITLE_MAP: Record<string, string> = {
  // WCAG 1.1.1 — Alt text
  'image-alt':       'Images missing alt text attribute',
  'input-image-alt': 'Images missing alt text attribute',
  'area-alt':        'Images missing alt text attribute',
  // WCAG 4.1.2 — Form labels
  'label':           'Form inputs missing label association',
  'label-title-only':'Form inputs missing label association',
  // WCAG 2.1.1 — Keyboard accessibility
  'keyboard':        'Interactive element not keyboard accessible',
  // WCAG 3.1.1 — Language
  'html-has-lang':   'HTML lang attribute missing',
  'html-lang-valid': 'HTML lang attribute missing',
  // WCAG 2.4.7 — Focus visible
  'focus-visible':   'Focus indicator removed via CSS',
  // WCAG 1.4.3 — Colour contrast
  'color-contrast':           'Potentially insufficient colour contrast',
  'color-contrast-enhanced':  'Potentially insufficient colour contrast',
  // WCAG 2.4.6 — Headings
  'empty-heading':   'Empty heading elements found',
  'heading-order':   'Heading levels are skipped',
  // WCAG 2.4.4 — Link text
  'link-name':       'Links with non-descriptive text',
  // WCAG 4.1.2 — Button accessible name (matches backend's form-labels / 4.1.2 criterion)
  'button-name':     'Form inputs missing label association',
  // WCAG 4.1.2 — ARIA
  'aria-allowed-role':  'Invalid ARIA role attributes',
  'aria-roles':         'Invalid ARIA role attributes',
  // WCAG 1.3.1 — Tables
  'scope-attr-valid':     'Data tables missing headers or caption',
  'td-headers-attr':      'Data tables missing headers or caption',
  'th-has-data-cells':    'Data tables missing headers or caption',
  'table-duplicate-name': 'Data tables missing headers or caption',
  // WCAG 1.3.6 / 1.3.1 — Landmarks (titles match backend's sprintf format)
  'landmark-one-main':                    'Page missing landmark elements: <main>',
  'landmark-main-is-top-level':           'Page missing landmark elements: <main>',
  'landmark-no-duplicate-main':           'Page missing landmark elements: <main>',
  'landmark-banner-is-top-level':         'Page missing landmark elements: <nav>',
  'landmark-complementary-is-top-level':  'Page missing landmark elements: <nav>',
  'landmark-contentinfo-is-top-level':    'Page missing landmark elements: <nav>',
  'landmark-no-duplicate-banner':         'Page missing landmark elements: <nav>',
  'landmark-no-duplicate-contentinfo':    'Page missing landmark elements: <nav>',
  'region':                               'Page missing landmark elements: <main>',
  // WCAG 1.3.1 — Lists
  'list':            'List elements must only contain valid child elements',
  'listitem':        'List item elements must be contained in a list element',
  'definition-list': 'Definition list elements must contain valid child elements',
  'dlitem':          'Definition list items must be contained in a definition list',
};

/** Run axe-core against the whole document. */
async function runAxe(tags: string[] = tagsForRulesets(standardsForPresetValue(DEFAULT_RULESET_PRESET))): Promise<axe.AxeResults> {
  return axe.run(
    { include: [['html']], exclude: [['#wpadminbar'], ['#aai-ov-host']] },
    { runOnly: { type: 'tag', values: tags }, resultTypes: ['violations', 'passes'] },
  );
}

/** Transform axe violations into our simplified Issue format. */
function axeToIssues(violations: axe.Result[], category: 'wcag' | 'best-practice' = 'wcag'): AaiIssue[] {
  const aiFixableRules = new Set([
    'image-alt', 'label', 'color-contrast', 'link-name',
    'button-name', 'input-image-alt', 'area-alt',
  ]);

  return violations.map((v) => {
    // Extract WCAG number from tags like 'wcag143' → '1.4.3'
    const wcagTag = v.tags.find((t) => /^wcag\d{3,}$/.test(t));
    let wcag = '';
    if (wcagTag) {
      const m = wcagTag.match(/^wcag(\d)(\d)(\d+)$/);
      if (m) wcag = `${m[1]}.${m[2]}.${m[3]}`;
    }

    // Extract highest conformance level from tags like wcag2a, wcag2aa, wcag21aa, wcag2aaa.
    const levelTag =
      v.tags.find((t) => /^wcag\d*aaa$/.test(t)) ??
      v.tags.find((t) => /^wcag\d*aa$/.test(t)) ??
      v.tags.find((t) => /^wcag\d*a$/.test(t));
    const wcag_level = levelTag ? (levelTag.match(/a+$/) ?? [''])[0].toUpperCase() : '';

    // Extract WCAG standard version+level, e.g. 'wcag21aa' → '2.1 AA', 'wcag2a' → '2.0 A'.
    let wcag_standard = '';
    if (levelTag) {
      const lm = levelTag.match(/^wcag(\d+)(a+)$/);
      if (lm) {
        const digits = lm[1]; // '2', '21', '22'
        const level  = lm[2].toUpperCase();
        const version = digits === '2' ? '2.0' : digits === '21' ? '2.1' : digits === '22' ? '2.2' : digits;
        wcag_standard = `${version} ${level}`;
      }
    }

    let severity: AaiIssue['severity'] =
      v.impact === 'critical' ? 'critical' :
      v.impact === 'serious'  ? 'serious'  :
      v.impact === 'moderate' ? 'warning'  : 'notice';

    // Best-practice-only axe rules (not mapped to any WCAG success criterion) can
    // still carry axe-core's own 'serious'/'critical' impact (e.g. tabindex, accesskeys) —
    // cap those at 'warning' since Best Practice should never rank above a WCAG issue.
    if ((v.tags ?? []).includes('best-practice') && (severity === 'critical' || severity === 'serious')) {
      severity = 'warning';
    }

    const firstNode = v.nodes[0];
    const nodes = v.nodes.map((n) => ({
      element:  n.html ?? '',
      selector: n.target?.join(', ') ?? '',
    }));

    return {
      id:          `${v.id}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
      rule:        v.id,
      wcag,
      wcag_level,
      wcag_standard,
      severity,
      title:       AXE_RULE_TITLE_MAP[v.id] ?? v.help,
      description: v.description,
      element:     firstNode?.html ?? '',
      selector:    firstNode?.target?.join(', ') ?? '',
      nodes,
      count:       v.nodes.length,
      fixed:       false,
      ai_fixable:  aiFixableRules.has(v.id),
      help_url:    v.helpUrl ?? '',
      category,
    };
  });
}

/** Transform axe passes into a lightweight summary for storage (no per-node detail). */
function axePassesToChecks(passes: axe.Result[]): AaiPassedCheck[] {
  return passes.map((p) => ({
    id:          p.id,
    title:       p.help,
    description: p.description,
    help_url:    p.helpUrl ?? '',
    count:       p.nodes.length,
  }));
}

function calcScore(issues: AaiIssue[], passed: number): number {
  const weights: Record<string, number> = { critical: 3, serious: 2, warning: 1.5, notice: 1 };
  const weightedFailed = issues.reduce((sum, i) => sum + (weights[i.severity] ?? 1), 0);
  const total = passed + weightedFailed;
  if (total === 0) return 100;
  return Math.round((passed / total) * 100);
}

function calcGrade(score: number): string {
  return score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 55 ? 'C' : score >= 35 ? 'D' : 'F';
}

function scoreColor(s: number): string {
  return s >= 90 ? '#22c55e' : s >= 75 ? '#84cc16' : s >= 55 ? '#f59e0b' : s >= 35 ? '#f97316' : '#ef4444';
}

/** POST computed scan payload to the /axe-scan REST endpoint.
 *  Returns true if saved, false + limitReached flag if free-plan page cap hit. */
async function storeScan(cfg: OverlayConfig, payload: ScanPayload): Promise<{ saved: boolean; limitReached: boolean }> {
  const res = await fetch(`${cfg.restBase}/axe-scan`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
    body: JSON.stringify(payload),
  });
  if (!res.ok) {
    const data: Record<string, unknown> = await res.json().catch(() => ({}));
    return { saved: false, limitReached: data.limit_reached === true };
  }
  return { saved: true, limitReached: false };
}

// ── Auto-scan mode (iframe) ───────────────────────────────────

async function runAutoScan(cfg: OverlayConfig, scanId: string): Promise<void> {
  // Wait for full page render before running axe.
  await new Promise<void>((resolve) => {
    if (document.readyState === 'complete') resolve();
    else window.addEventListener('load', () => resolve(), { once: true });
  });

  // Small delay so JS-rendered content settles.
  await new Promise((r) => setTimeout(r, 200));

  // Honour the ruleset passed via URL param (set by PageScanner when opening this iframe).
  const rulesetParam = new URLSearchParams(window.location.search).get('aai_ruleset') ?? '';
  const autoRulesets = RULESET_PRESETS.some((p) => p.value === rulesetParam)
    ? standardsForPresetValue(rulesetParam)
    : (cfg.defaultRulesets?.length ? cfg.defaultRulesets : standardsForPresetValue(DEFAULT_RULESET_PRESET));

  try {
    const results = await runAxe(tagsForRulesets(autoRulesets));
    const isBP    = (v: axe.Result) => (v.tags ?? []).includes('best-practice');
    const wcagViolations = autoRulesets.length === 1 && autoRulesets[0] === 'best-practice'
      ? results.violations
      : results.violations.filter(v => !isBP(v));
    const bpViolations = autoRulesets.length === 1 && autoRulesets[0] === 'best-practice'
      ? []
      : results.violations.filter(v => isBP(v));
    // Tag as 'wcag' even when the sole active ruleset is best-practice — these are
    // being treated as primary issues (see wcagViolations above), so the stored
    // category must match, or Dashboard/Report/ScanSlideIn would hide them from
    // severity tabs and misfile them under "Best practices" after the fact.
    const wcagIssues = axeToIssues(wcagViolations, 'wcag');
    const bpIssues   = axeToIssues(bpViolations, 'best-practice');
    const score   = calcScore(wcagIssues, results.passes.length);
    const grade   = calcGrade(score);
    const allIssues = [...wcagIssues, ...bpIssues];
    const payload: ScanPayload = {
      url:        cfg.pageUrl,
      page_title: document.title,
      score,
      grade,
      passed: results.passes.length,
      // "failed" counts WCAG + best-practice together, matching the "All" tab total
      // shown everywhere else (live panel, ScanSlideIn) — keeps Dashboard/Report
      // Issues counts consistent with what a rescan actually shows.
      failed: allIssues.length,
      issues: allIssues,
      rulesets: autoRulesets,
      passed_checks: axePassesToChecks(results.passes),
    };

    await storeScan(cfg, payload).catch(() => ({ saved: false, limitReached: false }));

    window.parent.postMessage(
      { type: 'aai-scan-complete', scanId, url: cfg.pageUrl, score, grade, failed: allIssues.length },
      window.location.origin,
    );
  } catch (err: unknown) {
    window.parent.postMessage(
      { type: 'aai-scan-error', scanId, url: cfg.pageUrl, error: String(err) },
      window.location.origin,
    );
  }
}

// ── Interactive overlay UI ────────────────────────────────────

// ── Element highlight helpers ─────────────────────────────────

function injectHighlightStyle(): void {
  if (document.getElementById('aai-hl-style')) return;
  const s = document.createElement('style');
  s.id = 'aai-hl-style';
  s.textContent =
    '.aai-hl{outline:3px solid #3858e9!important;outline-offset:3px!important;' +
    'box-shadow:0 0 0 8px rgba(56,88,233,.18)!important;scroll-margin-top:80px!important}' +
    '@keyframes aai-hl-pulse{0%{box-shadow:0 0 0 4px rgba(56,88,233,.6)!important}' +
    '100%{box-shadow:0 0 0 18px rgba(56,88,233,0)!important}}' +
    '.aai-hl--pulse{animation:aai-hl-pulse .7s ease-out!important}';
  document.head.appendChild(s);
}

let _hlEl: Element | null = null;
let _hlTimer = 0;

function highlightEl(selector: string, scroll = false): void {
  clearHl();
  if (!selector) return;
  try {
    const el = document.querySelector(selector);
    if (!el) return;
    el.classList.add('aai-hl');
    _hlEl = el;
    if (scroll) {
      el.scrollIntoView({ behavior: 'smooth', block: 'center' });
      el.classList.remove('aai-hl--pulse');
      void (el as HTMLElement).offsetWidth;
      el.classList.add('aai-hl--pulse');
      _hlTimer = window.setTimeout(() => el.classList.remove('aai-hl--pulse'), 800);
    }
  } catch { /* invalid selector — ignore */ }
}

function clearHl(): void {
  clearTimeout(_hlTimer);
  if (_hlEl) { _hlEl.classList.remove('aai-hl', 'aai-hl--pulse'); _hlEl = null; }
}

// ── Overlay UI ────────────────────────────────────────────────

function buildOverlay(cfg: OverlayConfig): void {
  const PANEL_W = 380;
  const adminBarH = (document.getElementById('wpadminbar') as HTMLElement | null)?.offsetHeight ?? 0;

  injectHighlightStyle();

  // Smooth page-push transition — applied once to html element
  document.documentElement.style.transition = `margin-right .28s cubic-bezier(.4,0,.2,1)`;

  // ── Shadow host ───────────────────────────────────────────────
  const host = document.createElement('div');
  host.id = 'aai-ov-host';
  host.style.cssText = `position:fixed;top:${adminBarH}px;right:0;width:${PANEL_W}px;height:calc(100dvh - ${adminBarH}px);z-index:2147483648;pointer-events:none`;
  document.body.appendChild(host);
  const shadow = host.attachShadow({ mode: 'open' });

  // ── Styles ─────────────────────────────────────────────────
  const style = document.createElement('style');
  style.textContent = [
    ':host{display:block}',
    '*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}',
    'button{all:unset;display:inline-flex;align-items:center;cursor:pointer;letter-spacing:normal;line-height:normal;text-transform:none;word-spacing:normal;font:inherit}',
    'a{all:unset;cursor:pointer}',
    /* Panel fills host — host is fixed, panel slides in from right */
    '#aai-ov{position:absolute;inset:0;background:#fff;display:flex;flex-direction:column;pointer-events:none;',
    'box-shadow:-4px 0 28px rgba(0,0,0,.18);',
    'transform:translateX(100%);transition:transform .28s cubic-bezier(.4,0,.2,1);',
    'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;color:#111827;',
    'letter-spacing:normal;word-spacing:normal;text-transform:none;line-height:1.5}',
    '#aai-ov.open{transform:translateX(0);pointer-events:auto}',
    '#aai-ov-hd{padding:14px 18px;background:#3858e9;color:#fff;display:flex;justify-content:space-between;align-items:center;flex-shrink:0}',
    '#aai-ov-hd h2{margin:0;font-size:14px;font-weight:600;display:flex;align-items:center;gap:6px}',
    '#aai-ov-cls{color:#fff;font-size:22px;line-height:1;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:4px}',
    '#aai-ov-cls:hover{background:rgba(255,255,255,.15)}',
    '#aai-ov-body{flex:1;overflow:hidden;position:relative}',
    '#aai-ov-ft{padding:10px 16px;padding-bottom:max(10px,env(safe-area-inset-bottom));border-top:1px solid #e5e7eb;flex-shrink:0}',
    /* list / detail view panes — slide transition */
    '#aai-ov-list-view{position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;padding:14px 16px;',
    'transform:translateX(0);transition:transform .25s cubic-bezier(.4,0,.2,1)}',
    '#aai-ov-list-view.slide-out{transform:translateX(-100%)}',
    '#aai-ov-detail-view{position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;padding:14px 16px;',
    'transform:translateX(100%);transition:transform .25s cubic-bezier(.4,0,.2,1)}',
    '#aai-ov-detail-view.slide-in{transform:translateX(0)}',
    /* back button */
    '#aai-ov-back{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:#6b7280;margin-bottom:14px;cursor:pointer;padding:4px 0}',
    '#aai-ov-back:hover{color:#3858e9}',
    /* detail badge row */
    '.aai-ov-d-badge-row{display:flex;align-items:center;gap:8px;margin-bottom:10px}',
    '.aai-ov-d-badge{display:inline-block;padding:2px 10px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase}',
    '.aai-ov-d-badge.critical{background:#fee2e2;color:#dc2626}',
    '.aai-ov-d-badge.serious{background:#ffedd5;color:#ea580c}',
    '.aai-ov-d-badge.warning{background:#fef3c7;color:#d97706}',
    '.aai-ov-d-badge.notice{background:#dbeafe;color:#2563eb}',
    '.aai-ov-d-badge.best-practice{background:#fef3c7;color:#92400e}',
    '.aai-ov-d-wcag{font-size:11px;color:#6b7280;background:#f3f4f6;padding:2px 8px;border-radius:10px}',
    /* detail body */
    '#aai-ov-detail-title{font-size:15px;font-weight:700;line-height:1.3;margin-bottom:8px;color:#111827}',
    '#aai-ov-detail-desc{font-size:13px;color:#374151;line-height:1.55;margin-bottom:14px}',
    '.aai-ov-d-section{margin-bottom:12px}',
    '.aai-ov-d-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#9ca3af;margin-bottom:4px}',
    '.aai-ov-d-code{display:block;background:#f3f4f6;padding:6px 8px;border-radius:4px;font-size:12px;font-family:ui-monospace,SFMono-Regular,monospace;word-break:break-all;color:#374151}',
    '.aai-ov-d-pre{display:block;background:#1e1e2e;color:#cdd6f4;padding:8px 10px;border-radius:6px;font-size:11px;font-family:ui-monospace,SFMono-Regular,monospace;overflow-x:auto;white-space:pre-wrap;word-break:break-word;margin:0}',
    '#aai-ov-detail-learn{display:inline-block;font-size:12px;color:#3858e9;margin-bottom:14px}',
    '#aai-ov-detail-learn:hover{text-decoration:underline}',
    '.aai-ov-d-actions{display:flex;gap:8px;padding-top:14px;border-top:1px solid #e5e7eb;flex-wrap:wrap}',
    '#aai-ov-detail-fix{padding:8px 14px;background:#3858e9;color:#fff;border-radius:6px;font-size:13px;font-weight:500;transition:background .15s}',
    '#aai-ov-detail-fix:hover{background:#2642c7}',
    '#aai-ov-detail-fix:disabled{opacity:.6;cursor:not-allowed}',
    '#aai-ov-detail-explain{padding:8px 14px;background:#fff;color:#3858e9;border:1.5px solid #3858e9;border-radius:6px;font-size:13px;font-weight:500;transition:all .15s}',
    '#aai-ov-detail-explain:hover{background:#eef2ff}',
    '#aai-ov-detail-explain:disabled{opacity:.6;cursor:not-allowed}',
    '#aai-ov-detail-ai-out{margin-top:12px;padding:12px;background:#f8f7ff;border:1px solid #ddd6fe;border-radius:6px;font-size:12px;line-height:1.55;color:#374151}',
    '.aai-ov-ai-hint{font-size:12px;color:#6b7280;margin-top:8px}',
    '.aai-ov-ai-hint a{color:#3858e9}',
    '.aai-ov-ai-err{color:#ef4444;font-size:12px}',
    '.aai-ov-score{display:flex;align-items:center;gap:12px;margin-bottom:14px;padding:12px;background:#f9fafb;border-radius:8px}',
    '.aai-ov-grade{font-size:26px;font-weight:700;width:48px;height:48px;border-radius:50%;',
    'display:flex;align-items:center;justify-content:center;color:#fff;flex-shrink:0}',
    '.aai-ov-sv{font-size:22px;font-weight:700;line-height:1.1}',
    '.aai-ov-sv small{font-size:13px;font-weight:400;color:#6b7280}',
    '.aai-ov-sc{font-size:12px;color:#6b7280;margin-top:3px}',
    '.aai-ov-url{font-size:11px;color:#9ca3af;margin-bottom:14px;word-break:break-all}',
    /* loading state */
    '.aai-ov-loading-msg{font-size:13px;font-weight:600;color:#3858e9;margin:0;text-align:center}',
    '.aai-ov-loading-center{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px}',
    '.aai-ov-spin--lg{display:inline-block;width:28px;height:28px;border:3px solid #c7d2fe;border-top-color:#3858e9;border-radius:50%;animation:aai-spin .7s linear infinite}',
    /* card entry animation */
    '@keyframes aai-card-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}',
    '.aai-ov-issue{animation:aai-card-in .22s ease-out both}',
    '.aai-ov-pass-item{animation:aai-card-in .22s ease-out both}',
    /* issue cards — backend-matching design */
    '.aai-ov-issue{border-left:3px solid #e5e7eb;margin-bottom:8px;border-radius:8px;display:flex;align-items:center;gap:10px;padding:12px 14px;cursor:pointer;transition:background .15s}',
    '.aai-ov-issue.critical{border-color:#ef4444;background:#fff5f5}',
    '.aai-ov-issue.serious{border-color:#f97316;background:#fff7ed}',
    '.aai-ov-issue.warning{border-color:#f59e0b;background:#fffbeb}',
    '.aai-ov-issue.notice{border-color:#3b82f6;background:#eff6ff}',
    '.aai-ov-issue:hover{filter:brightness(.97)}',
    '.aai-ov-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}',
    '.aai-ov-dot.critical{background:#ef4444}',
    '.aai-ov-dot.serious{background:#f97316}',
    '.aai-ov-dot.warning{background:#f59e0b}',
    '.aai-ov-dot.notice{background:#3b82f6}',
    '.aai-ov-issue-text{flex:1;min-width:0}',
    '.aai-ov-issue-text strong{display:block;font-size:13px;font-weight:600;line-height:1.3}',
    '.aai-ov-meta{display:block;font-size:12px;color:#6b7280;margin-top:2px}',
    '.aai-ov-count{display:block;font-size:11px;color:#6b7280;margin-top:2px}',
    '.aai-ov-sev-label{display:inline-block;padding:2px 7px;border-radius:4px;font-size:11px;font-weight:700;vertical-align:middle;margin-right:2px}',
    '.aai-ov-sev-label.critical{background:#fee2e2;color:#dc2626}',
    '.aai-ov-sev-label.serious{background:#ffedd5;color:#ea580c}',
    '.aai-ov-sev-label.warning{background:#fef3c7;color:#d97706}',
    '.aai-ov-sev-label.notice{background:#dbeafe;color:#1d4ed8}',
    '.aai-ov-sev-label.best-practice{background:#fef3c7;color:#d97706}',
    '.aai-ov-details-btn{flex-shrink:0;padding:4px 12px;border:1.5px solid #3858e9;color:#3858e9;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;background:#fff;transition:all .15s;white-space:nowrap}',
    '.aai-ov-details-btn:hover{background:#3858e9;color:#fff}',
    /* filters */
    '.aai-ov-filter{display:flex;gap:6px;margin-bottom:14px;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none}',
    '.aai-ov-filter::-webkit-scrollbar{display:none}',
    '.aai-ov-filter-btn{padding:3px 10px;border:1px solid #d1d5db;border-radius:20px;background:#f3f4f6;color:#374151;font-size:12px;cursor:pointer;transition:all .15s;white-space:nowrap;flex-shrink:0}',
    '.aai-ov-filter-btn:hover{border-color:#3858e9;color:#3858e9;background:#eaedfc}',
    '.aai-ov-filter-btn.active{background:#3858e9;color:#fff;border-color:#3858e9}',
    '.aai-ov-filter-btn--pass{background:#f0fdf4;border-color:#86efac;color:#15803d}',
    '.aai-ov-filter-btn--pass:hover{background:#dcfce7;border-color:#22c55e;color:#15803d}',
    '.aai-ov-filter-btn--pass.active{background:#22c55e;border-color:#22c55e;color:#fff}',
    /* passed items */
    '.aai-ov-pass-item{animation:aai-card-in .2s ease-out both;border-left:3px solid #22c55e;margin-bottom:6px;border-radius:0 6px 6px 0;overflow:hidden;cursor:pointer}',
    '.aai-ov-pass-item-hd{display:flex;align-items:flex-start;gap:8px;padding:8px 10px;background:#f0fdf4;user-select:none}',
    '.aai-ov-pass-item-hd-text{flex:1}',
    '.aai-ov-pass-item-hd strong{display:block;font-size:12px;font-weight:600;color:#166534;line-height:1.3}',
    '.aai-ov-pass-item-meta{display:block;font-size:11px;color:#4ade80;margin-top:1px}',
    '.aai-ov-pass-item-body{display:none;padding:8px 10px;border-top:1px solid #bbf7d0;background:#fff;font-size:12px;color:#374151}',
    '.aai-ov-pass-item.open .aai-ov-pass-item-body{display:block}',
    '.aai-ov-pass-item.open .aai-ov-chevron::before{content:"▲"}',
    '.aai-ov-pass-item:not(.open) .aai-ov-chevron::before{content:"▼"}',
    /* misc */
    '.aai-ov-msg{color:#6b7280;font-size:13px;text-align:center;padding:24px 0}',
    '.aai-ov-ok{color:#22c55e;font-size:13px;text-align:center;padding:24px 0}',
    '.aai-ov-btn{display:flex;align-items:center;justify-content:center;gap:6px;padding:7px 14px;background:#3858e9;color:#fff;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500;white-space:nowrap;box-sizing:border-box;transition:background .15s}',
    '.aai-ov-btn:hover{background:#2642c7}',
    '.aai-ov-btn:disabled{opacity:.6;cursor:not-allowed}',
    '.aai-ov-pre{display:block;background:#1e1e2e;color:#cdd6f4;padding:8px 10px;border-radius:4px;font-size:11px;font-family:ui-monospace,SFMono-Regular,monospace;overflow-x:auto;white-space:pre-wrap;word-break:break-word;margin:0}',
    '@keyframes aai-spin{to{transform:rotate(360deg)}}',
    '.aai-ov-spin{display:inline-block;width:12px;height:12px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:aai-spin .7s linear infinite;vertical-align:middle;margin-right:5px}',
    '@keyframes aai-dot-pulse{0%,80%,100%{opacity:.3;transform:scale(.7)}40%{opacity:1;transform:scale(1)}}',
    '.aai-ov-dots{display:inline-flex;align-items:center;gap:3px;vertical-align:middle;margin-left:5px}',
    '.aai-ov-dots span{width:4px;height:4px;background:currentColor;border-radius:50%;animation:aai-dot-pulse 1.2s ease-in-out infinite}',
    '.aai-ov-dots span:nth-child(2){animation-delay:.2s}',
    '.aai-ov-dots span:nth-child(3){animation-delay:.4s}',
    '.aai-ov-ruleset-wrap{display:flex;align-items:center;gap:8px}',
    '.aai-ov-ruleset-select{flex:1;padding:6px 10px;border:1.5px solid #d1d5db;border-radius:6px;font-size:12px;color:#374151;background:#fff;cursor:pointer;box-sizing:border-box}',
    '.aai-ov-ruleset-select:focus{outline:2px solid #3858e9;border-color:#3858e9}',
    '.aai-ov-ruleset-tag{display:inline-block;padding:2px 8px;background:#eef2ff;color:#3858e9;border-radius:10px;font-size:11px;font-weight:600;margin-top:3px}',
    '.aai-ov-fresh-tag{display:inline-block;margin-left:6px;padding:2px 8px;background:#f0fdf4;color:#15803d;border-radius:10px;font-size:11px;font-weight:600;margin-top:3px;animation:aai-fresh-fade 3s ease forwards}',
    '@keyframes aai-fresh-fade{0%,70%{opacity:1}100%{opacity:0}}',
    /* affected-elements accordion */
    '.aai-ov-node-list{display:flex;flex-direction:column;gap:6px;margin-top:6px}',
    '.aai-ov-node-item{border:1.5px solid #e5e7eb;border-radius:8px;overflow:hidden;transition:border-color .15s}',
    '.aai-ov-node-item.open{border-color:var(--nc,#ef4444)}',
    '.aai-ov-node-hd{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;background:#fff;user-select:none}',
    '.aai-ov-node-hd:hover{background:#fafafa}',
    '.aai-ov-node-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;background:var(--nc,#ef4444)}',
    '.aai-ov-node-sel{flex:1;font-size:12px;font-family:ui-monospace,SFMono-Regular,monospace;color:#374151;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}',
    '.aai-ov-node-chevron{font-size:13px;color:#9ca3af;flex-shrink:0;transition:transform .2s;line-height:1}',
    '.aai-ov-node-item.open .aai-ov-node-chevron{transform:rotate(90deg)}',
    '.aai-ov-node-body{display:none;border-top:1px solid #e5e7eb}',
    '.aai-ov-node-item.open .aai-ov-node-body{display:block}',
    '#aai-ov-tip{display:none;position:fixed;z-index:10002;background:#1e1e2e;color:#cdd6f4;padding:8px 12px;border-radius:6px;font-size:11px;font-family:ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;word-break:break-all;max-width:300px;box-shadow:0 4px 16px rgba(0,0,0,.4);pointer-events:none;line-height:1.5;border:1px solid rgba(255,255,255,.08)}',
    /* best practices section */
    '.aai-ov-bp-section{margin-top:20px}',
    '.aai-ov-bp-hd{display:flex;align-items:center;gap:8px;margin-bottom:10px}',
    '.aai-ov-bp-title{font-size:13px;font-weight:700;color:#111827}',
    '.aai-ov-badge-new{display:inline-block;padding:1px 8px;border-radius:10px;font-size:10px;font-weight:700;background:#ede9fe;color:#7c3aed;letter-spacing:.03em}',
    '.aai-ov-issue.best-practice{border-color:#f59e0b;background:#fffbeb}',
    '.aai-ov-dot.best-practice{background:#f59e0b}',
  ].join('');
  shadow.appendChild(style);

  // ── Markup ─────────────────────────────────────────────────
  const panel = document.createElement('div');
  panel.id = 'aai-ov';
  panel.setAttribute('role', 'dialog');
  panel.setAttribute('aria-label', 'Page accessibility scan');
  panel.setAttribute('aria-modal', 'true');
  panel.innerHTML =
    '<div id="aai-ov-hd">' +
      '<h2>' +
        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 451 451" width="18" height="18" aria-hidden="true" style="flex-shrink:0"><path d="M293.129 113.443L295.479 119.968C298.089 127.21 303.792 132.913 311.034 135.522L317.559 137.872C318.147 138.086 318.147 138.92 317.559 139.13L311.034 141.48C303.792 144.09 298.089 149.792 295.479 157.034L293.129 163.559C292.916 164.147 292.082 164.147 291.871 163.559L289.521 157.034C286.911 149.792 281.208 144.09 273.966 141.48L267.441 139.13C266.853 138.917 266.853 138.083 267.441 137.872L273.966 135.522C281.208 132.913 286.911 127.21 289.521 119.968L291.871 113.443C292.082 112.852 292.916 112.852 293.129 113.443Z" fill="currentColor"/><path d="M322.321 97.2275L323.52 100.553C324.851 104.243 327.758 107.15 331.449 108.481L334.775 109.68C335.075 109.789 335.075 110.213 334.775 110.322L331.449 111.521C327.758 112.852 324.851 115.759 323.52 119.449L322.321 122.775C322.212 123.075 321.788 123.075 321.679 122.775L320.48 119.449C319.149 115.759 316.242 112.852 312.551 111.521L309.225 110.322C308.925 110.213 308.925 109.789 309.225 109.68L312.551 108.481C316.242 107.15 319.149 104.243 320.48 100.553L321.679 97.2275C321.788 96.9242 322.215 96.9242 322.321 97.2275Z" fill="currentColor"/><path d="M322.321 153.217L323.52 156.414C324.851 159.963 327.758 162.758 331.449 164.039L334.775 165.191C335.075 165.296 335.075 165.704 334.775 165.809L331.449 166.961C327.758 168.242 324.851 171.037 323.52 174.586L322.321 177.783C322.212 178.072 321.788 178.072 321.679 177.783L320.48 174.586C319.149 171.037 316.242 168.242 312.551 166.961L309.225 165.809C308.925 165.704 308.925 165.296 309.225 165.191L312.551 164.039C316.242 162.758 319.149 159.963 320.48 156.414L321.679 153.217C321.788 152.928 322.215 152.928 322.321 153.217Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M223 176.105C238.779 176.105 251.571 163.322 251.571 147.553C251.571 131.784 238.779 119 223 119C207.221 119 194.429 131.784 194.429 147.552C194.429 163.322 207.221 176.105 223 176.105ZM248.304 328.815L228.202 284.621C226.172 280.158 219.828 280.158 217.798 284.621L197.696 328.815C195.706 333.191 191.341 336 186.531 336C178.792 336 172.987 328.924 174.505 321.34L191.974 263.456C193.436 256.15 199.176 229.558 183.49 215.716L149.142 196.647C145.351 194.542 143 190.549 143 186.215C143 178.287 150.594 172.563 158.222 174.741L208.816 183.306C218.086 185.953 227.914 185.953 237.184 183.306L287.778 174.741C295.407 172.563 303 178.287 303 186.215C303 190.549 300.649 194.542 296.858 196.647L262.51 215.716C246.824 226.618 252.563 256.15 254.026 263.456L271.495 321.34C273.013 328.924 267.208 336 259.469 336C254.659 336 250.294 333.191 248.304 328.815Z" fill="currentColor"/><path d="M225.5 0C101.152 0 0 101.152 0 225.5C0 349.848 101.152 451 225.5 451C349.848 451 451 349.848 451 225.5C451 101.152 349.848 0 225.5 0ZM225.5 37.5833C329.516 37.5833 413.417 121.484 413.417 225.5C413.417 329.516 329.516 413.417 225.5 413.417C121.484 413.417 37.5833 329.516 37.5833 225.5C37.5833 121.484 121.484 37.5833 225.5 37.5833Z" fill="currentColor"/></svg>' +
        'AccessMate Scan' +
      '</h2>' +
      '<button id="aai-ov-cls" aria-label="Close panel">&times;</button>' +
    '</div>' +
    '<div id="aai-ov-body">' +
      /* List view — empty until replaced with results after scan */
      '<div id="aai-ov-list-view"></div>' +
      /* Detail view — shown when Details button clicked, hidden by default */
      '<div id="aai-ov-detail-view">' +
        '<button id="aai-ov-back">' +
          '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M10 3L5 8l5 5"/></svg>' +
          'Back to issues' +
        '</button>' +
        '<div class="aai-ov-d-badge-row">' +
          '<span class="aai-ov-d-badge" id="aai-ov-detail-badge"></span>' +
          '<span class="aai-ov-d-wcag" id="aai-ov-detail-wcag"></span>' +
        '</div>' +
        '<h3 id="aai-ov-detail-title"></h3>' +
        '<p id="aai-ov-detail-desc"></p>' +
        '<div class="aai-ov-d-section" id="aai-ov-detail-el-sec">' +
          '<div id="aai-ov-detail-el"></div>' +
        '</div>' +
        '<div class="aai-ov-d-section" id="aai-ov-detail-sel-sec">' +
          '<div class="aai-ov-d-label">Selector</div>' +
          '<code class="aai-ov-d-code" id="aai-ov-detail-sel"></code>' +
        '</div>' +
        '<div class="aai-ov-d-section">' +
          '<div class="aai-ov-d-label">Instances</div>' +
          '<span id="aai-ov-detail-count" style="font-size:13px"></span>' +
        '</div>' +
        '<a id="aai-ov-detail-learn" href="#" target="_blank" rel="noopener noreferrer" style="display:none"></a>' +
        '<div class="aai-ov-d-actions">' +
          '<button id="aai-ov-detail-fix">Generate fix with AI</button>' +
          '<button id="aai-ov-detail-explain">Explain</button>' +
        '</div>' +
        '<p id="aai-ov-ai-hint" class="aai-ov-ai-hint" style="display:none"></p>' +
        '<div id="aai-ov-detail-ai-out" style="display:none"></div>' +
      '</div>' +
    '</div>' +
    '<div id="aai-ov-ft">' +
      '<div class="aai-ov-ruleset-wrap">' +
        '<select class="aai-ov-ruleset-select" id="aai-ov-ruleset">' +
          RULESET_PRESETS.map((p) => '<option value="' + p.value + '">' + p.label + '</option>').join('') +
        '</select>' +
        '<button class="aai-ov-btn" id="aai-ov-scan">Scan for issues</button>' +
      '</div>' +
    '</div>';
  shadow.appendChild(panel);

  // Tooltip lives outside the transformed panel so position:fixed works against the viewport.
  const tipEl = document.createElement('div');
  tipEl.id = 'aai-ov-tip';
  shadow.appendChild(tipEl);

  const listView      = shadow.querySelector<HTMLElement>('#aai-ov-list-view')!;
  const detailView    = shadow.querySelector<HTMLElement>('#aai-ov-detail-view')!;
  const btn           = shadow.querySelector<HTMLButtonElement>('#aai-ov-scan')!;
  const close         = shadow.querySelector<HTMLButtonElement>('#aai-ov-cls')!;
  const rulesetSelect = shadow.querySelector<HTMLSelectElement>('#aai-ov-ruleset')!;
  const tip           = shadow.querySelector<HTMLDivElement>('#aai-ov-tip')!;

  // ── Selector tooltip (hover over truncated node header) ───────
  detailView.addEventListener('mouseover', (e) => {
    const hd = (e.target as Element).closest<HTMLElement>('.aai-ov-node-hd');
    if (!hd) return;
    const full = hd.closest<HTMLElement>('.aai-ov-node-item')?.dataset.full ?? '';
    if (!full) return;
    tip.textContent = full;
    tip.style.display = 'block';
  });
  detailView.addEventListener('mousemove', (e) => {
    if (tip.style.display === 'none') return;
    const me = e as MouseEvent;
    const tw = tip.offsetWidth, th = tip.offsetHeight;
    let x = me.clientX + 16, y = me.clientY + 16;
    if (x + tw > window.innerWidth  - 8) x = me.clientX - tw - 8;
    if (y + th > window.innerHeight - 8) y = me.clientY - th - 8;
    tip.style.left = x + 'px';
    tip.style.top  = y + 'px';
  });
  detailView.addEventListener('mouseout', (e) => {
    if ((e.target as Element).closest('.aai-ov-node-hd')) tip.style.display = 'none';
  });

  // ── AI provider gating — mirrors the admin dashboard's disabled state ──
  const hasProvider = cfg.hasProvider === true;
  const aiTitle = hasProvider ? '' : 'Configure an AI provider in Settings';
  shadow.querySelector<HTMLButtonElement>('#aai-ov-detail-fix')!.title     = aiTitle;
  shadow.querySelector<HTMLButtonElement>('#aai-ov-detail-explain')!.title = aiTitle;
  const aiHint = shadow.querySelector<HTMLElement>('#aai-ov-ai-hint')!;
  if (!hasProvider) {
    aiHint.style.display = 'block';
    aiHint.innerHTML = cfg.settingsUrl
      ? `<a href="${esc(cfg.settingsUrl)}" target="_blank" rel="noopener noreferrer">Configure an AI provider in Settings</a> to enable fix suggestions.`
      : 'Configure an AI provider in Settings to enable fix suggestions.';
  }

  // ── Panel control ──────────────────────────────────────────
  let scanStarted  = false;

  // Initialise from URL param (set by PageScanner scan link) or saved default, falling back to WCAG 2.2 AA.
  const rulesetFromUrl = new URLSearchParams(window.location.search).get('aai_ruleset') ?? '';
  let activeRulesets: string[] = RULESET_PRESETS.some((p) => p.value === rulesetFromUrl)
    ? standardsForPresetValue(rulesetFromUrl)
    : (cfg.defaultRulesets?.length ? cfg.defaultRulesets : standardsForPresetValue(DEFAULT_RULESET_PRESET));
  rulesetSelect.value = presetValueForStandards(activeRulesets);

  rulesetSelect.addEventListener('change', () => {
    activeRulesets = standardsForPresetValue(rulesetSelect.value);
    // Only update the pending selection here — the scan itself only runs when
    // the user explicitly clicks the Scan/Rescan button.
  });

  function openPanel(): void {
    document.documentElement.style.marginRight = PANEL_W + 'px';
    panel.classList.add('open');
    close.focus();
    if (!scanStarted) { scanStarted = true; setTimeout(triggerScan, 50); }
  }
  function closePanel(): void {
    document.documentElement.style.marginRight = '';
    panel.classList.remove('open');
    clearHl();
  }

  close.addEventListener('click', closePanel);
  document.addEventListener('keydown', (e: KeyboardEvent) => {
    if (e.key === 'Escape' && panel.classList.contains('open')) closePanel();
  });

  // ── Scan state ────────────────────────────────────────────
  let lastIssues: AaiIssue[]         = [];
  let lastBestPractices: AaiIssue[]  = [];
  let lastPasses: axe.Result[]       = [];
  let activeFilter = 'all';

  // ── Detail view logic ─────────────────────────────────────
  let currentIssue: AaiIssue | null = null;

  function openDetail(iss: AaiIssue): void {
    currentIssue = iss;

    const badge = shadow.querySelector<HTMLElement>('#aai-ov-detail-badge')!;
    badge.textContent = iss.severity;
    badge.className   = `aai-ov-d-badge ${iss.severity}`;

    const wcagEl = shadow.querySelector<HTMLElement>('#aai-ov-detail-wcag')!;
    wcagEl.textContent = [
      iss.wcag_standard ? `WCAG ${iss.wcag_standard}` : '',
      iss.wcag ? `Success Criterion ${iss.wcag}` : '',
    ].filter(Boolean).join(' · ');
    wcagEl.style.display = (iss.wcag_standard || iss.wcag) ? '' : 'none';

    shadow.querySelector<HTMLElement>('#aai-ov-detail-title')!.textContent = iss.title;
    shadow.querySelector<HTMLElement>('#aai-ov-detail-desc')!.textContent  = iss.description;

    const elSec = shadow.querySelector<HTMLElement>('#aai-ov-detail-el-sec')!;
    const allNodes = iss.nodes.length > 0 ? iss.nodes : (iss.element ? [{ element: iss.element, selector: iss.selector }] : []);
    const nodeColor = iss.severity === 'critical' ? '#ef4444' : iss.severity === 'serious' ? '#f97316' : iss.severity === 'warning' ? '#f59e0b' : '#3b82f6';
    if (allNodes.length > 0) {
      const label = allNodes.length > 1 ? `Affected elements (${allNodes.length})` : 'Affected element';
      shadow.querySelector<HTMLElement>('#aai-ov-detail-el')!.innerHTML =
        `<p style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#9ca3af;margin:0 0 6px">${label}</p>` +
        `<div class="aai-ov-node-list">` +
        allNodes.map((n, i) => {
          const displayLabel = esc(n.selector || n.element.slice(0, 60));
          const fullLabel    = n.selector || n.element;
          return (
            `<div class="aai-ov-node-item${i === 0 ? ' open' : ''}" data-ni="${i}" data-selector="${esc(n.selector)}" data-full="${esc(fullLabel)}" style="--nc:${nodeColor}">` +
              `<div class="aai-ov-node-hd">` +
                `<span class="aai-ov-node-dot"></span>` +
                `<span class="aai-ov-node-sel">${displayLabel}</span>` +
                `<span class="aai-ov-node-chevron">&#x203A;</span>` +
              `</div>` +
              `<div class="aai-ov-node-body">` +
                `<pre style="margin:0;padding:10px 12px;background:#f3f4f6;font-size:11px;font-family:ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;word-break:break-word;color:#374151;line-height:1.5"><code>${esc(n.element)}</code></pre>` +
              `</div>` +
            `</div>`
          );
        }).join('') +
        `</div>`;
      elSec.style.display = '';
    } else { elSec.style.display = 'none'; }

    const selSec = shadow.querySelector<HTMLElement>('#aai-ov-detail-sel-sec')!;
    selSec.style.display = 'none';

    shadow.querySelector<HTMLElement>('#aai-ov-detail-count')!.textContent =
      `${iss.count} element${iss.count !== 1 ? 's' : ''}`;

    const learn = shadow.querySelector<HTMLAnchorElement>('#aai-ov-detail-learn')!;
    if (iss.help_url) {
      learn.href = iss.help_url;
      learn.textContent = (iss.wcag ? `SC ${iss.wcag} — ` : '') + 'View guidance ↗';
      learn.style.display = 'inline-block';
    } else { learn.style.display = 'none'; }

    // Reset AI section
    const aiOut = shadow.querySelector<HTMLElement>('#aai-ov-detail-ai-out')!;
    aiOut.style.display = 'none';
    aiOut.innerHTML = '';
    const fixBtn     = shadow.querySelector<HTMLButtonElement>('#aai-ov-detail-fix')!;
    const explainBtn = shadow.querySelector<HTMLButtonElement>('#aai-ov-detail-explain')!;
    fixBtn.innerHTML = 'Generate fix with AI';
    explainBtn.innerHTML = 'Explain';
    fixBtn.disabled = explainBtn.disabled = !hasProvider;

    // Switch views with slide animation
    listView.classList.add('slide-out');
    detailView.classList.add('slide-in');
    detailView.scrollTop = 0;

    // Highlight the first node by default
    const firstSelector = allNodes[0]?.selector ?? iss.selector;
    if (firstSelector) highlightEl(firstSelector, true);
  }

  function closeDetail(): void {
    listView.classList.remove('slide-out');
    detailView.classList.remove('slide-in');
    clearHl();
  }

  async function callDetailAI(task: 'fix' | 'explain'): Promise<void> {
    const iss = currentIssue;
    if (!iss) return;
    const fixBtn     = shadow.querySelector<HTMLButtonElement>('#aai-ov-detail-fix')!;
    const explainBtn = shadow.querySelector<HTMLButtonElement>('#aai-ov-detail-explain')!;
    const aiOut      = shadow.querySelector<HTMLElement>('#aai-ov-detail-ai-out')!;
    fixBtn.disabled = explainBtn.disabled = true;
    if (task === 'fix') fixBtn.innerHTML     = '<span class="aai-ov-spin"></span>Thinking…';
    else                explainBtn.innerHTML = '<span class="aai-ov-spin"></span>Working…';
    aiOut.style.display = 'none';
    aiOut.innerHTML = '';
    try {
      const res  = await fetch(`${cfg.restBase}/ai/proxy`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
        body: JSON.stringify({ task, params: { title: iss.title, wcag: iss.wcag, html: iss.element, context: iss.description } }),
      });
      const data = await res.json() as { text?: string; error?: string };
      aiOut.innerHTML = data.error
        ? `<span class="aai-ov-ai-err">${esc(data.error)}</span>`
        : ovMd(data.text ?? '');
      aiOut.style.display = 'block';
    } catch (err) {
      aiOut.innerHTML = `<span class="aai-ov-ai-err">${esc(String(err))}</span>`;
      aiOut.style.display = 'block';
    } finally {
      fixBtn.disabled = explainBtn.disabled = !hasProvider;
      fixBtn.innerHTML     = 'Generate fix with AI';
      explainBtn.innerHTML = 'Explain';
    }
  }

  shadow.querySelector('#aai-ov-back')!.addEventListener('click', closeDetail);
  shadow.querySelector('#aai-ov-detail-fix')!.addEventListener('click',     () => callDetailAI('fix'));
  shadow.querySelector('#aai-ov-detail-explain')!.addEventListener('click', () => callDetailAI('explain'));

  // Accordion toggle for affected-elements nodes — also highlights the element on page
  detailView.addEventListener('click', (e: Event) => {
    const hd = (e.target as Element).closest<HTMLElement>('.aai-ov-node-hd');
    if (!hd) return;
    const item = hd.closest<HTMLElement>('.aai-ov-node-item');
    if (!item) return;
    const list = item.closest<HTMLElement>('.aai-ov-node-list');
    const isOpen = item.classList.contains('open');
    list?.querySelectorAll('.aai-ov-node-item').forEach((el) => el.classList.remove('open'));
    if (!isOpen) {
      item.classList.add('open');
      const selector = item.dataset.selector ?? '';
      if (selector) highlightEl(selector, true);
    } else {
      clearHl();
    }
  });

  // ── Render helpers ────────────────────────────────────────
  function esc(s: string): string {
    return String(s)
      .replace(/&/g, '&amp;').replace(/</g, '&lt;')
      .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  }

  /** Minimal markdown → HTML for AI output in the overlay. */
  function ovMd(text: string): string {
    const blocks: string[] = [];
    const wp = text.replace(/```([^\n]*)\n?([\s\S]*?)```/g, (_m, _l, code: string) => {
      const i = blocks.length;
      blocks.push(`<pre class="aai-ov-pre"><code>${esc(code.replace(/\n$/, ''))}</code></pre>`);
      return `\x00C${i}\x00`;
    });
    const inl = (s: string) => s
      .replace(/`([^`\n]+)`/g, '<code style="background:#1e1e2e;color:#cdd6f4;padding:1px 4px;border-radius:3px;font-size:12px">$1</code>')
      .replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
      .replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
    const out: string[] = [];
    for (const block of wp.split(/\n{2,}/)) {
      const b = block.trim();
      if (!b) continue;
      const cp = b.match(/^\x00C(\d+)\x00$/);
      if (cp) { out.push(blocks[+cp[1]]); continue; }
      const h3 = b.match(/^###\s+(.+)/); if (h3) { out.push(`<p style="font-weight:700;font-size:13px;margin:8px 0 4px">${inl(esc(h3[1]))}</p>`); continue; }
      const h2 = b.match(/^##\s+(.+)/);  if (h2) { out.push(`<p style="font-weight:700;font-size:14px;margin:8px 0 4px">${inl(esc(h2[1]))}</p>`); continue; }
      const h1 = b.match(/^#\s+(.+)/);   if (h1) { out.push(`<p style="font-weight:700;font-size:15px;margin:8px 0 4px">${inl(esc(h1[1]))}</p>`); continue; }
      const lines = b.split('\n');
      const parts: string[] = [];
      let lt: 'ul' | 'ol' | null = null;
      const cl = () => { if (lt) { parts.push(lt === 'ul' ? '</ul>' : '</ol>'); lt = null; } };
      for (const line of lines) {
        const t = line.trim();
        const icp = t.match(/^\x00C(\d+)\x00$/);
        if (icp) { cl(); parts.push(blocks[+icp[1]]); continue; }
        if (/^[-*+]\s/.test(t)) {
          if (lt !== 'ul') { cl(); parts.push('<ul style="margin:4px 0 4px 16px;padding:0">'); lt = 'ul'; }
          parts.push(`<li style="margin:2px 0">${inl(esc(t.replace(/^[-*+]\s+/, '')))}</li>`);
        } else if (/^\d+\.\s/.test(t)) {
          if (lt !== 'ol') { cl(); parts.push('<ol style="margin:4px 0 4px 16px;padding:0">'); lt = 'ol'; }
          parts.push(`<li style="margin:2px 0">${inl(esc(t.replace(/^\d+\.\s+/, '')))}</li>`);
        } else { cl(); if (t) parts.push(`<p style="margin:0 0 6px">${inl(esc(t))}</p>`); }
      }
      cl();
      out.push(parts.join(''));
    }
    return out.join('');
  }

  function buildCard(iss: AaiIssue, idx: number): string {
    const sev = esc(iss.severity);
    const sevWord = iss.severity.charAt(0).toUpperCase() + iss.severity.slice(1);
    const metaParts = [
      `<span class="aai-ov-sev-label ${sev}">${sevWord}</span>`,
      iss.wcag_standard ? `WCAG ${esc(iss.wcag_standard)}` : '',
      iss.wcag ? `SC ${esc(iss.wcag)}` : '',
    ].filter(Boolean).join(' · ');
    return (
      `<div class="aai-ov-issue ${sev}" data-selector="${esc(iss.selector)}" data-idx="${idx}">` +
        `<div class="aai-ov-issue-text">` +
          `<strong>${esc(iss.title)}</strong>` +
          `<span class="aai-ov-meta">${metaParts}</span>` +
          `<span class="aai-ov-count">${iss.count} element${iss.count !== 1 ? 's' : ''}</span>` +
        `</div>` +
        `<button class="aai-ov-details-btn" data-idx="${idx}">Details</button>` +
      `</div>`
    );
  }

  function buildBpCard(iss: AaiIssue, idx: number): string {
    return (
      `<div class="aai-ov-issue best-practice" data-selector="${esc(iss.selector)}" data-bp-idx="${idx}">` +
        `<div class="aai-ov-issue-text">` +
          `<strong>${esc(iss.title)}</strong>` +
          `<span class="aai-ov-meta"><span class="aai-ov-sev-label best-practice">Best Practice</span></span>` +
          `<span class="aai-ov-count">${iss.count} element${iss.count !== 1 ? 's' : ''}</span>` +
        `</div>` +
        `<button class="aai-ov-details-btn" data-bp-idx="${idx}">Details</button>` +
      `</div>`
    );
  }

  function buildPassCard(pass: axe.Result): string {
    const count = pass.nodes.length;
    const bodyParts = [`<p style="margin:0 0 6px">${esc(pass.description)}</p>`];
    if (pass.helpUrl) bodyParts.push(
      `<a style="font-size:12px;color:#3858e9;cursor:pointer" href="${esc(pass.helpUrl)}" target="_blank" rel="noopener noreferrer">Learn more ↗</a>`,
    );
    return (
      `<div class="aai-ov-pass-item">` +
        `<div class="aai-ov-pass-item-hd">` +
          `<div class="aai-ov-pass-item-hd-text">` +
            `<strong>${esc(pass.help)}</strong>` +
            `<span class="aai-ov-pass-item-meta">${count} element${count !== 1 ? 's' : ''} checked</span>` +
          `</div>` +
          `<span class="aai-ov-chevron" style="font-size:10px;color:#4ade80;padding-top:3px;flex-shrink:0"></span>` +
        `</div>` +
        `<div class="aai-ov-pass-item-body">${bodyParts.join('')}</div>` +
      `</div>`
    );
  }

  function staggerCards(container: Element): void {
    container.querySelectorAll<HTMLElement>('.aai-ov-issue,.aai-ov-pass-item').forEach((card, i) => {
      card.style.animationDelay = `${i * 35}ms`;
    });
  }

  function renderIssueList(): void {
    const list = shadow.querySelector<HTMLElement>('#aai-ov-issue-list');
    if (!list) return;
    if (activeFilter === 'passed') {
      list.innerHTML = lastPasses.length === 0
        ? '<p class="aai-ov-ok">&#10003; All automated checks passed.</p>'
        : lastPasses.map(buildPassCard).join('');
      staggerCards(list);
      return;
    }
    // Severity filter tabs only show WCAG issues — best-practice items stay in the "All" tab.
    const filtered = activeFilter === 'all'
      ? lastIssues.map((iss, i) => ({ iss, i }))
      : lastIssues.map((iss, i) => ({ iss, i })).filter(({ iss }) => iss.severity === activeFilter);
    const filteredBP = activeFilter === 'all'
      ? lastBestPractices.map((bp, i) => ({ bp, i }))
      : [];
    // Only claim "no issues" when there are none in either list — otherwise the
    // best-practice cards rendered just below would contradict the message.
    list.innerHTML = filtered.length === 0 && filteredBP.length === 0
      ? '<p class="aai-ov-ok">&#10003; No issues in this category!</p>'
      : filtered.map(({ iss, i }) => buildCard(iss, i)).join('');
    staggerCards(list);
    const bpSection = shadow.querySelector<HTMLElement>('.aai-ov-bp-section');
    const bpList    = shadow.querySelector<HTMLElement>('#aai-ov-bp-list');
    if (bpSection) bpSection.style.display = filteredBP.length === 0 ? 'none' : '';
    if (bpList) { bpList.innerHTML = filteredBP.map(({ bp, i }) => buildBpCard(bp, i)).join(''); staggerCards(bpList); }
  }

  function renderResults(issues: AaiIssue[], bestPractices: AaiIssue[], score: number, grade: string): void {
    const color    = scoreColor(score);
    const critical = issues.filter((i) => i.severity === 'critical').length;
    const serious  = issues.filter((i) => i.severity === 'serious').length;
    const warning  = issues.filter((i) => i.severity === 'warning').length;
    const notice   = issues.filter((i) => i.severity === 'notice').length;

    const initialList = issues.length === 0 && bestPractices.length === 0
      ? '<p class="aai-ov-ok">&#10003; No issues detected</p><p class="aai-ov-ok" style="font-size:12px;padding-top:0">Some issues may require manual testing.</p>'
      : issues.map((iss, i) => buildCard(iss, i)).join('');

    const bpSection = bestPractices.length === 0 ? '' :
      `<div class="aai-ov-bp-section">` +
        `<div class="aai-ov-bp-hd">` +
          `<span class="aai-ov-bp-title">Best practices</span>` +
        `</div>` +
        `<div id="aai-ov-bp-list">${bestPractices.map((bp, i) => buildBpCard(bp, i)).join('')}</div>` +
      `</div>`;

    listView.innerHTML =
      `<div class="aai-ov-score">` +
        `<div class="aai-ov-grade" style="background:${color}">${esc(grade)}</div>` +
        `<div>` +
          `<div class="aai-ov-sv">${score}<small>/100</small></div>` +
          `<div class="aai-ov-sc">${issues.length + bestPractices.length} issue${issues.length + bestPractices.length !== 1 ? 's' : ''} &middot; ${lastPasses.length} passed</div>` +
          `<span class="aai-ov-ruleset-tag">${esc(labelsForRulesets(activeRulesets) || 'WCAG 2.2 AA')}</span>` +
          `<span class="aai-ov-fresh-tag">&#10003; Just scanned</span>` +
        `</div>` +
      `</div>` +
      `<div class="aai-ov-url">${esc(cfg.pageUrl)}</div>` +
      `<div class="aai-ov-filter">` +
        `<button class="aai-ov-filter-btn active" data-filter="all">All (${issues.length + bestPractices.length})</button>` +
        (critical ? `<button class="aai-ov-filter-btn" data-filter="critical">Critical (${critical})</button>` : '') +
        (serious  ? `<button class="aai-ov-filter-btn" data-filter="serious">Serious (${serious})</button>` : '') +
        (warning  ? `<button class="aai-ov-filter-btn" data-filter="warning">Warning (${warning})</button>` : '') +
        (notice   ? `<button class="aai-ov-filter-btn" data-filter="notice">Notice (${notice})</button>` : '') +
        `<button class="aai-ov-filter-btn aai-ov-filter-btn--pass" data-filter="passed">Passed (${lastPasses.length})</button>` +
      `</div>` +
      `<div id="aai-ov-issue-list">${initialList}</div>` +
      bpSection;

    const listEl = shadow.querySelector<HTMLElement>('#aai-ov-issue-list');
    if (listEl) staggerCards(listEl);
    const bpListEl = shadow.querySelector<HTMLElement>('#aai-ov-bp-list');
    if (bpListEl) staggerCards(bpListEl);
  }

  // ── Event delegation on listView (registered once) ────────
  // Filter tabs
  listView.addEventListener('click', (e: Event) => {
    const fbtn = (e.target as Element).closest<HTMLElement>('.aai-ov-filter-btn');
    if (fbtn) {
      listView.querySelectorAll('.aai-ov-filter-btn').forEach((b) => b.classList.remove('active'));
      fbtn.classList.add('active');
      activeFilter = fbtn.dataset.filter ?? 'all';
      renderIssueList();
      return;
    }
    // Pass item accordion
    const passHd = (e.target as Element).closest<HTMLElement>('.aai-ov-pass-item-hd');
    if (passHd) { passHd.closest('.aai-ov-pass-item')?.classList.toggle('open'); return; }
    // Details button → open detail view
    const detailsBtn = (e.target as Element).closest<HTMLElement>('.aai-ov-details-btn');
    if (detailsBtn) {
      e.stopPropagation();
      if (detailsBtn.dataset.bpIdx !== undefined) {
        const bpIdx = Number(detailsBtn.dataset.bpIdx);
        if (lastBestPractices[bpIdx]) openDetail(lastBestPractices[bpIdx]);
      } else {
        const idx = Number(detailsBtn.dataset.idx);
        if (lastIssues[idx]) openDetail(lastIssues[idx]);
      }
      return;
    }
    // Card click (not Details button) → scroll to + highlight element
    const card = (e.target as Element).closest<HTMLElement>('.aai-ov-issue[data-selector]');
    if (card) highlightEl(card.dataset.selector ?? '', true);
  });


  // ── Scan trigger ──────────────────────────────────────────
  async function triggerScan(): Promise<void> {
    btn.disabled = true;
    btn.textContent = 'Loading…';
    // Reset to list view during scan
    listView.classList.remove('slide-out');
    detailView.classList.remove('slide-in');

    listView.innerHTML =
      `<div class="aai-ov-loading-center">` +
        `<span class="aai-ov-spin--lg" aria-hidden="true"></span>` +
        `<p class="aai-ov-loading-msg">Loading…</p>` +
      `</div>`;

    // Keep the skeleton on screen for a minimum stretch so fast scans (which can
    // finish in well under 100ms) still read as a visible refresh rather than a
    // silent no-op — otherwise it's unclear whether the results just updated.
    const MIN_LOADING_MS = 1200;
    const scanStartedAt  = Date.now();

    try {
      const [results] = await Promise.all([runAxe(tagsForRulesets(activeRulesets))]);
      const isBP = (v: axe.Result) => (v.tags ?? []).includes('best-practice');
      if (activeRulesets.length === 1 && activeRulesets[0] === 'best-practice') {
        // Running best-practice rules only: treat all violations as primary issues so
        // the score, count, and admin panel are consistent (no separate BP section).
        // Tag as 'wcag' (not 'best-practice') so Dashboard/Report/ScanSlideIn — which
        // bucket purely on the stored category — stay consistent with this panel.
        lastIssues        = axeToIssues(results.violations, 'wcag');
        lastBestPractices = [];
      } else {
        lastIssues        = axeToIssues(results.violations.filter(v => !isBP(v)), 'wcag');
        lastBestPractices = axeToIssues(results.violations.filter(v => isBP(v)), 'best-practice');
      }
      lastPasses   = results.passes;
      activeFilter = 'all';
      const score  = calcScore(lastIssues, results.passes.length);
      const grade  = calcGrade(score);

      const elapsed = Date.now() - scanStartedAt;
      if (elapsed < MIN_LOADING_MS) await new Promise((r) => setTimeout(r, MIN_LOADING_MS - elapsed));

      renderResults(lastIssues, lastBestPractices, score, grade);
      const storeResult = await storeScan(cfg, {
        url: cfg.pageUrl, page_title: document.title, score, grade,
        // "failed" counts WCAG + best-practice together, matching the "All" tab total
        // shown everywhere else (live panel, ScanSlideIn) — keeps Dashboard/Report
        // Issues counts consistent with what a rescan actually shows.
        passed: results.passes.length, failed: lastIssues.length + lastBestPractices.length,
        issues: [...lastIssues, ...lastBestPractices],
        rulesets: activeRulesets,
        passed_checks: axePassesToChecks(results.passes),
      }).catch(() => ({ saved: false, limitReached: false }));
      if (storeResult.limitReached) {
        const notice = document.createElement('p');
        notice.className = 'aai-ov-msg';
        notice.style.cssText = 'color:#f59e0b;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;padding:8px 12px;margin:12px 0 0;font-size:12px';
        notice.textContent = 'Free plan limit reached — results not saved. Upgrade to Pro to track more than 25 pages.';
        listView.prepend(notice);
      }
    } catch (err: unknown) {
      const msg = err instanceof Error ? err.message : 'axe-core failed';
      listView.innerHTML = `<p class="aai-ov-msg" style="color:#ef4444">Error: ${esc(msg)}</p>`;
    } finally {
      btn.disabled = false;
      btn.textContent = 'Rescan';
    }
  }

  btn.addEventListener('click', triggerScan);

  // ── Public API ────────────────────────────────────────────
  window.accessmateOverlay = { open: openPanel, close: closePanel };

  function hookAdminBar(): void {
    const node = document.getElementById('wp-admin-bar-accessmate-scan');
    if (node) {
      // Target the <a> directly so preventDefault stops navigation before the browser acts on href="#"
      const anchor = node.querySelector<HTMLElement>('a.ab-item') ?? node;
      anchor.addEventListener('click', (e) => { e.preventDefault(); openPanel(); });
    }
    // Mobile fallback button — visible only when WP hides the admin bar on small screens.
    const mobileBtn = document.getElementById('accessmate-mobile-btn');
    if (mobileBtn) {
      mobileBtn.addEventListener('click', (e) => { e.preventDefault(); openPanel(); });
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', hookAdminBar);
  } else {
    hookAdminBar();
  }
}
