// Turns user settings into axe run options, and axe results into our issues.
import { contrastRatio, floor2, parseColor, requiredRatio, suggestForeground, toHex } from "./contrast.js";
import { ruleText } from "./rules.js";
import type { AxeNode, AxeResultsLike, AxeRule, ContrastDetail, Impact, Issue, Settings, WcagLevel } from "./types.js";
import { criteriaFromTags, levelFromTags, levelTags } from "./wcag.js";

export const DEFAULT_SETTINGS: Settings = { level: "AA", bestPractice: false };

export const SNIPPET_MAX = 300;

export function axeRunOptions(settings: Settings): {
  runOnly: { type: "tag"; values: string[] };
  resultTypes: ["violations"];
} {
  const values = levelTags(settings.level);
  if (settings.bestPractice) values.push("best-practice");
  return { runOnly: { type: "tag", values }, resultTypes: ["violations"] };
}

export function mapAxeResults(results: AxeResultsLike): Issue[] {
  return results.violations.flatMap((rule) => rule.nodes.map((node) => toIssue(rule, node)));
}

function toIssue(rule: AxeRule, node: AxeNode): Issue {
  const selector = node.target.map((part) => (Array.isArray(part) ? part.join(" >>> ") : part)).join(" >>> ");
  const issue: Issue = {
    key: issueKey(rule.id, selector),
    ruleId: rule.id,
    impact: node.impact ?? rule.impact ?? "moderate",
    level: levelFromTags(rule.tags),
    criteria: criteriaFromTags(rule.tags),
    text: ruleText(rule),
    selector,
    snippet: truncate(node.html, SNIPPET_MAX),
    helpUrl: rule.helpUrl,
  };
  if (node.failureSummary) issue.summary = node.failureSummary;
  if (rule.id === "color-contrast" || rule.id === "color-contrast-enhanced") {
    const contrast = contrastDetail(node, rule.id === "color-contrast-enhanced" ? "AAA" : "AA");
    if (contrast) issue.contrast = contrast;
  }
  return issue;
}

/** Cuts to at most `max` characters, never inside a surrogate pair, and marks the cut with an ellipsis. */
export function truncate(text: string, max: number): string {
  if (text.length <= max) return text;
  let end = max - 1;
  const last = text.charCodeAt(end - 1);
  if (last >= 0xd800 && last <= 0xdbff) end--;
  return `${text.slice(0, end)}…`;
}

/**
 * Stable fingerprint of rule and element: two 32-bit FNV-1a hashes with different offsets, as 16 hex characters.
 * Saved runs compare by this key, so the algorithm must never change.
 */
export function issueKey(ruleId: string, selector: string): string {
  const input = `${ruleId}\u0000${selector}`;
  let a = 0x811c9dc5;
  let b = 0x050c5d1f;
  for (let i = 0; i < input.length; i++) {
    const c = input.charCodeAt(i);
    a = Math.imul(a ^ c, 0x01000193);
    b = Math.imul(b ^ c, 0x01000193);
  }
  return (a >>> 0).toString(16).padStart(8, "0") + (b >>> 0).toString(16).padStart(8, "0");
}

interface ContrastData {
  fgColor?: unknown;
  bgColor?: unknown;
  contrastRatio?: unknown;
  expectedContrastRatio?: unknown;
  fontSize?: unknown;
  fontWeight?: unknown;
}

function contrastDetail(node: AxeNode, level: WcagLevel): ContrastDetail | undefined {
  const check = [...node.any, ...node.all, ...node.none].find(
    (c) => (c.id === "color-contrast" || c.id === "color-contrast-enhanced") && typeof c.data === "object" && c.data,
  );
  const data = (check?.data ?? {}) as ContrastData;
  if (typeof data.fgColor !== "string" || typeof data.bgColor !== "string") return undefined;
  const fg = parseColor(data.fgColor);
  const bg = parseColor(data.bgColor);
  if (!fg || !bg) return undefined;
  const fontSize = typeof data.fontSize === "string" ? data.fontSize : undefined;
  const fontWeight = typeof data.fontWeight === "string" ? data.fontWeight : undefined;
  const required = parseRatio(data.expectedContrastRatio) ?? requiredForFont(fontSize, fontWeight, level);
  const ratio =
    typeof data.contrastRatio === "number" && Number.isFinite(data.contrastRatio)
      ? floor2(data.contrastRatio)
      : contrastRatio(data.fgColor, data.bgColor);
  const detail: ContrastDetail = {
    foreground: toHex(fg),
    background: toHex(bg),
    ratio,
    required,
    suggestion: suggestForeground(data.fgColor, data.bgColor, required),
  };
  if (fontSize !== undefined) detail.fontSize = fontSize;
  if (fontWeight !== undefined) detail.fontWeight = fontWeight;
  return detail;
}

/** axe states the expected ratio as "4.5:1". */
function parseRatio(value: unknown): number | undefined {
  if (typeof value === "number") return value;
  if (typeof value !== "string") return undefined;
  const n = Number.parseFloat(value);
  return Number.isFinite(n) && n > 0 ? n : undefined;
}

/** axe states the font size as "12.0pt (16px)" and the weight as "normal" or "bold". */
function requiredForFont(fontSize: string | undefined, fontWeight: string | undefined, level: WcagLevel): number {
  const px = /([\d.]+)px/.exec(fontSize ?? "");
  const pt = /([\d.]+)pt/.exec(fontSize ?? "");
  const size = px ? Number(px[1]) : pt ? (Number(pt[1]) * 4) / 3 : 16;
  const weight = Number(fontWeight);
  const bold = fontWeight === "bold" || fontWeight === "bolder" || weight >= 700;
  return requiredRatio(size, bold, level);
}

export function countByImpact(issues: readonly Issue[]): Record<Impact, number> {
  const counts: Record<Impact, number> = { critical: 0, serious: 0, moderate: 0, minor: 0 };
  for (const issue of issues) counts[issue.impact]++;
  return counts;
}
