import { createHash } from "node:crypto";
import type { Frame, Page } from "playwright";
import { sha256Canonical } from "../../../schema/src/canonical.js";
import { redactJsonPayload, type RedactionRule } from "../../../playwright/src/redaction.js";
import type { DiscoveredControl, InteractionDiscovery } from "./types.js";

type RawControl = Omit<DiscoveredControl, "boundaries">;
type AccessibilityControl = { role: string; name: string };

const interactiveRoles = new Set(["button", "checkbox", "combobox", "link", "listbox", "menuitem", "menuitemcheckbox", "menuitemradio", "option", "radio", "searchbox", "slider", "spinbutton", "switch", "tab", "textbox", "treeitem"]);

const DISCOVERY_EXCLUSION_PREDICATE_BODY = `
  const hasIndependentActivationSemantics = (candidate) => {
    const tag = candidate.tagName.toLowerCase();
    if (tag === "input" && (candidate.getAttribute("type") ?? "text").toLowerCase() === "range") return true;
    if (tag === "a" || tag === "area") return candidate.hasAttribute("href");
    if (tag === "button" || tag === "summary") return true;
    if (tag === "select" || tag === "textarea") return true;
    if (tag === "input") {
      const type = (candidate.getAttribute("type") ?? "text").toLowerCase();
      if (["button", "submit", "reset", "image", "checkbox", "radio", "file"].includes(type)) return true;
      if (!["hidden", "file", "range"].includes(type)) return true;
    }
    if (candidate.tabIndex >= 0) return true;
    const role = candidate.getAttribute("role")?.toLowerCase();
    if (role === "spinbutton") return candidate.getAttribute("aria-readonly") !== "true";
    if (role === "slider") {
      const min = Number(candidate.getAttribute("aria-valuemin"));
      const max = Number(candidate.getAttribute("aria-valuemax"));
      if (!Number.isNaN(min) && !Number.isNaN(max) && max > min) return true;
      return false;
    }
    if (role !== undefined) {
      const activationCapableRoles = new Set([
        "button", "checkbox", "combobox", "link", "listbox", "menuitem", "menuitemcheckbox", "menuitemradio",
        "option", "radio", "searchbox", "slider", "spinbutton", "switch", "tab", "textbox", "treeitem",
      ]);
      if (activationCapableRoles.has(role)) return true;
    }
    return false;
  };
  return !hasIndependentActivationSemantics(element);
`;

function evaluateDiscoveryExclusion(element: Element, predicateBody: string): boolean {
  // predicateBody is a module-level literal compiled once for both the Node and the
  // page realm; no caller-supplied source reaches it.
  // eslint-disable-next-line @typescript-eslint/no-implied-eval
  return (new Function("element", predicateBody) as (el: Element) => boolean)(element);
}

function compare(left: string, right: string): number {
  return left < right ? -1 : left > right ? 1 : 0;
}

function digest(value: string): string {
  return createHash("sha256").update(value).digest("hex");
}

function controlKey(control: Omit<DiscoveredControl, "boundaries">): string {
  return JSON.stringify([control.kind, control.stateSchema, control.nameClass, control.landmark, control.subtreeFingerprint]);
}

function parseAccessibilitySnapshot(snapshot: string): AccessibilityControl[] {
  return snapshot.split("\n").flatMap((line) => {
    const match = /^\s*-\s+([a-z][a-z0-9-]*)(?:\s+"([^"]*)")?/i.exec(line);
    const role = match?.[1]?.toLowerCase();
    if (role === undefined || !interactiveRoles.has(role)) return [];
    return [{ role, name: match?.[2] ?? "" }];
  });
}

function describeElement(element: Element, input: { frameIndex: number; role?: string; source: string }): Omit<RawControl, "subtreeFingerprint"> & { subtreeFingerprint: string } {
  const nameClassFor = (value: string): string => value.trim().toLocaleLowerCase("en-US").replace(/\d+/g, "#").replace(/\s+/g, " ");
  const nativeRole = (candidate: Element): string | null => {
    const tag = candidate.tagName.toLowerCase();
    if (tag === "a" || tag === "area") return candidate.hasAttribute("href") ? "link" : null;
    if (tag === "button" || tag === "summary") return "button";
    if (tag === "select") return "combobox";
    if (tag === "textarea") return "textbox";
    if (tag !== "input") return null;
    const type = (candidate.getAttribute("type") ?? "text").toLowerCase();
    if (["button", "submit", "reset", "image"].includes(type)) return "button";
    if (type === "checkbox" || type === "radio" || type === "range") return type === "range" ? "slider" : type;
    return ["hidden", "file"].includes(type) ? null : type === "search" ? "searchbox" : "textbox";
  };
  const pathFor = (candidate: Element): string => {
    const parts: string[] = [];
    let current: Element | null = candidate;
    while (current !== null) {
      const parent: Element | null = current.parentElement;
      parts.push(`${current.tagName.toLowerCase()}:${String(parent === null ? 0 : Array.from(parent.children).indexOf(current))}`);
      const root = current.getRootNode();
      if (root instanceof ShadowRoot) { current = root.host; parts.push("#shadow"); } else current = parent;
    }
    return `frame:${String(input.frameIndex)}:${parts.reverse().join("/")}`;
  };
  const landmarkFor = (candidate: Element): string => {
    const landmarks: Record<string, string> = { main: "main", nav: "navigation", header: "banner", footer: "contentinfo", aside: "complementary", form: "form", search: "search" };
    let current: Element | null = candidate;
    while (current !== null) {
      const landmark = current.getAttribute("role") ?? landmarks[current.tagName.toLowerCase()];
      if (landmark !== undefined && ["main", "navigation", "banner", "contentinfo", "complementary", "form", "search", "region"].includes(landmark)) return `${landmark}:${current.getAttribute("aria-label") ?? ""}`;
      const root = current.getRootNode();
      current = root instanceof ShadowRoot ? root.host : current.parentElement;
    }
    return "document";
  };
  const subtree = (candidate: Element): string => `${candidate.tagName.toLowerCase()}[${candidate.getAttribute("role") ?? ""}](${Array.from(candidate.children).map(subtree).join(",")})`;
  const native = nativeRole(element);
  const role = element.getAttribute("role")?.toLowerCase() ?? native ?? input.role ?? "generic";
  const labelledBy = element.getAttribute("aria-labelledby");
  const labelledText = labelledBy?.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? "").join(" ").trim();
  const name = labelledText || element.getAttribute("aria-label") || (element instanceof HTMLInputElement ? element.value || element.placeholder : "") || element.textContent || "";
  const stateSchema = [...Array.from(element.attributes).map((attribute) => attribute.name).filter((name) => name === "disabled" || name === "checked" || name === "formnovalidate" || name === "required" || name === "readonly" || name === "selected" || name.startsWith("aria-")), ...(element.closest("form")?.noValidate === true ? ["form-novalidate"] : []), ...(element instanceof HTMLInputElement ? ["input-type"] : [])].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
  const sources = new Set<DiscoveredControl["sources"][number]>([input.source as DiscoveredControl["sources"][number]]);
  if (element.getRootNode() instanceof ShadowRoot) sources.add("shadow");
  return { kind: native ?? role, role, stateSchema, nameClass: nameClassFor(name), accessibleName: name, landmark: landmarkFor(element), subtreeFingerprint: subtree(element), disabled: element.matches(":disabled") || element.getAttribute("aria-disabled") === "true", current: element.getAttribute("aria-current") !== null || element.getAttribute("aria-selected") === "true", expanded: element.getAttribute("aria-expanded") === "true", sameOriginLink: native === "link" && new URL(element.getAttribute("href") ?? "", location.href).origin === location.origin, validates: element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement ? element.willValidate : true, ...(element.hasAttribute("aria-pressed") ? { ariaPressed: element.getAttribute("aria-pressed") ?? "" } : {}), sources: [...sources].sort((left, right) => left < right ? -1 : left > right ? 1 : 0), path: pathFor(element) };
}

async function pageControls(target: Page | Frame, frameIndex: number): Promise<RawControl[]> {
  const controls = await target.evaluate(({ frameIndex, roles, predicateBody }) => {
    const shouldExcludeNonInteractiveDescendant = (element: Element): boolean =>
      // eslint-disable-next-line @typescript-eslint/no-implied-eval
      (new Function("element", predicateBody) as (el: Element) => boolean)(element);
    const nativeRole = (element: Element): string | null => {
      const tag = element.tagName.toLowerCase();
      if (tag === "a" || tag === "area") return element.hasAttribute("href") ? "link" : null;
      if (tag === "button" || tag === "summary") return "button";
      if (tag === "select") return "combobox";
      if (tag === "textarea") return "textbox";
      if (tag !== "input") return null;
      const type = (element.getAttribute("type") ?? "text").toLowerCase();
      if (["button", "submit", "reset", "image"].includes(type)) return "button";
      if (type === "checkbox" || type === "radio" || type === "range") return type === "range" ? "slider" : type;
      return ["hidden", "file"].includes(type) ? null : type === "search" ? "searchbox" : "textbox";
    };
    const pathFor = (element: Element): string => {
      const parts: string[] = [];
      let current: Element | null = element;
      while (current !== null) {
        const parent: Element | null = current.parentElement;
        parts.push(`${current.tagName.toLowerCase()}:${String(parent === null ? 0 : Array.from(parent.children).indexOf(current))}`);
        const root = current.getRootNode();
        if (root instanceof ShadowRoot) { current = root.host; parts.push("#shadow"); } else current = parent;
      }
      return `frame:${String(frameIndex)}:${parts.reverse().join("/")}`;
    };
    const landmarkFor = (element: Element): string => {
      const landmarks: Record<string, string> = { main: "main", nav: "navigation", header: "banner", footer: "contentinfo", aside: "complementary", form: "form", search: "search" };
      let current: Element | null = element;
      while (current !== null) {
        const landmark = current.getAttribute("role") ?? landmarks[current.tagName.toLowerCase()];
        if (landmark !== undefined && ["main", "navigation", "banner", "contentinfo", "complementary", "form", "search", "region"].includes(landmark)) return `${landmark}:${current.getAttribute("aria-label") ?? ""}`;
        const root = current.getRootNode();
        current = root instanceof ShadowRoot ? root.host : current.parentElement;
      }
      return "document";
    };
    const subtree = (element: Element): string => `${element.tagName.toLowerCase()}[${element.getAttribute("role") ?? ""}](${Array.from(element.children).map(subtree).join(",")})`;
    const result: Array<Record<string, unknown>> = [];
    const visit = (root: Document | ShadowRoot, shadow: boolean): void => {
      for (const element of Array.from(root.querySelectorAll("*"))) {
        if (shouldExcludeNonInteractiveDescendant(element)) continue;
        const native = nativeRole(element);
        const role = element.getAttribute("role")?.toLowerCase() ?? native;
        if (role !== null && (native !== null || roles.includes(role))) {
          const stateSchema = [...Array.from(element.attributes).map((attribute) => attribute.name).filter((name) => name === "disabled" || name === "checked" || name === "formnovalidate" || name === "required" || name === "readonly" || name === "selected" || name.startsWith("aria-")), ...(element.closest("form")?.noValidate === true ? ["form-novalidate"] : []), ...(element instanceof HTMLInputElement ? ["input-type"] : [])].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
          const labelledBy = element.getAttribute("aria-labelledby");
          const labelledText = labelledBy?.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? "").join(" ").trim();
          const name = labelledText || element.getAttribute("aria-label") || (element instanceof HTMLInputElement ? element.value || element.placeholder : "") || element.textContent || "";
          result.push({ kind: native ?? role, role, stateSchema, nameClass: name.trim().toLocaleLowerCase("en-US").replace(/\d+/g, "#").replace(/\s+/g, " "), accessibleName: name, landmark: landmarkFor(element), subtreeFingerprint: subtree(element), disabled: element.matches(":disabled") || element.getAttribute("aria-disabled") === "true", current: element.getAttribute("aria-current") !== null || element.getAttribute("aria-selected") === "true", expanded: element.getAttribute("aria-expanded") === "true", sameOriginLink: native === "link" && new URL(element.getAttribute("href") ?? "", location.href).origin === location.origin, validates: element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement ? element.willValidate : true, ...(element.hasAttribute("aria-pressed") ? { ariaPressed: element.getAttribute("aria-pressed") ?? "" } : {}), sources: [native === null ? "accessibility" : "native", ...(shadow ? ["shadow"] : [])], path: pathFor(element) });
        }
        if (element.shadowRoot !== null) visit(element.shadowRoot, true);
      }
    };
    visit(document, false);
    return result;
  }, { frameIndex, roles: [...interactiveRoles], predicateBody: DISCOVERY_EXCLUSION_PREDICATE_BODY });
  return controls.map((control) => ({ ...control, subtreeFingerprint: digest(String(control.subtreeFingerprint)) } as RawControl));
}

async function controlsFromAccessibility(target: Page | Frame, frameIndex: number): Promise<RawControl[]> {
  const entries = parseAccessibilitySnapshot(await target.locator("body").ariaSnapshot());
  const controls = await Promise.all(entries.map(async (entry) => {
    const name = entry.name.length === 0 ? undefined : new RegExp(`^${entry.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i");
    const locators = await target.getByRole(entry.role as never, name === undefined ? {} : { name }).all();
    const eligible = await Promise.all(locators.map(async (locator) => {
      const excluded = await locator.evaluate(evaluateDiscoveryExclusion, DISCOVERY_EXCLUSION_PREDICATE_BODY);
      return excluded ? undefined : locator;
    }));
    return Promise.all(eligible.filter((locator): locator is NonNullable<typeof locator> => locator !== undefined).map((locator) => locator.evaluate(describeElement, { frameIndex, role: entry.role, source: "accessibility" })));
  }));
  return controls.flat().map((control) => ({ ...control, subtreeFingerprint: digest(control.subtreeFingerprint) }));
}

async function tabControls(page: Page, limit: number): Promise<RawControl[]> {
  const controls: RawControl[] = [];
  const paths = new Set<string>();
  await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
  for (let index = 0; index < limit; index += 1) {
    await page.keyboard.press("Tab");
    const locator = page.locator(":focus");
    if (await locator.count() === 0) break;
    const excluded = await locator.first().evaluate(evaluateDiscoveryExclusion, DISCOVERY_EXCLUSION_PREDICATE_BODY);
    if (excluded) continue;
    const control = await locator.first().evaluate(describeElement, { frameIndex: 0, source: "tab" });
    if (paths.has(control.path)) break;
    paths.add(control.path);
    controls.push({ ...control, subtreeFingerprint: digest(control.subtreeFingerprint) });
  }
  return controls;
}

async function frameIdentity(frame: Frame, page: Page, cache: Map<Frame, Promise<string>>): Promise<string> {
  const current = cache.get(frame);
  if (current !== undefined) return current;
  const identity = (async () => {
    if (frame === page.mainFrame()) return "main";
    const element = await frame.frameElement();
    const parent = frame.parentFrame();
    const parentIdentity = parent === null ? "main" : await frameIdentity(parent, page, cache);
    const path = await element.evaluate((candidate) => {
      const parts: string[] = [];
      let current: Element | null = candidate as Element;
      while (current !== null) {
        const parent: Element | null = current.parentElement;
        parts.push(`${current.tagName.toLowerCase()}:${String(parent === null ? 0 : Array.from(parent.children).indexOf(current))}`);
        current = parent;
      }
      return parts.reverse().join("/");
    });
    return `${parentIdentity}/${path}`;
  })();
  cache.set(frame, identity);
  return identity;
}

export async function discoverInteractionControls(page: Page, redactionRules: RedactionRule[] = []): Promise<InteractionDiscovery> {
  const origin = new URL(page.url()).origin;
  const eligible = page.frames().filter((frame) => frame === page.mainFrame() || frame.url().startsWith("about:srcdoc") || (frame.url().startsWith("http") && new URL(frame.url()).origin === origin));
  const identities = new Map<Frame, Promise<string>>();
  const frames = (await Promise.all(eligible.map(async (frame) => ({ frame, identity: await frameIdentity(frame, page, identities) })))).sort((left, right) => compare(left.identity, right.identity));
  const raw = (await Promise.all(frames.map(async ({ frame }, index) => [
    ...await pageControls(frame, index),
    ...await controlsFromAccessibility(frame, index),
  ]))).flat();
  const tabbed = await tabControls(page, raw.length + 1);
  const grouped = new Map<string, RawControl[]>();
  for (const control of [...raw, ...tabbed]) {
    const sources = new Set(control.sources);
    if (!control.path.startsWith("frame:0:")) sources.add("frame");
    const discovered = { ...control, sources: [...sources].sort(compare) };
    const key = controlKey(discovered);
    grouped.set(key, [...(grouped.get(key) ?? []), discovered]);
  }
  const selected: DiscoveredControl[] = [];
  const groups = [...grouped.values()].filter((group): group is [RawControl, ...RawControl[]] => group.length > 0);
  for (const group of groups.sort((left, right) => compare(controlKey(left[0]), controlKey(right[0])))) {
    const byPath = new Map<string, RawControl>();
    for (const control of group) {
      const existing = byPath.get(control.path);
      byPath.set(control.path, existing === undefined ? control : { ...existing, sources: [...new Set([...existing.sources, ...control.sources])].sort(compare) });
    }
    const sorted = [...byPath.values()].sort((left, right) => compare(left.path, right.path));
    const selections = new Map<string, Set<DiscoveredControl["boundaries"][number]>>();
    const mark = (control: RawControl | undefined, boundary: DiscoveredControl["boundaries"][number]) => { if (control !== undefined) selections.set(control.path, new Set([...(selections.get(control.path) ?? []), boundary])); };
    mark(sorted[0], "representative"); mark(sorted[0], "first"); mark(sorted.at(-1), "last"); mark(sorted.find((control) => control.current), "current"); mark(sorted.find((control) => control.expanded), "expanded"); mark(sorted.find((control) => control.disabled), "disabled");
    for (const control of sorted) {
      const boundaries = selections.get(control.path);
      if (boundaries === undefined) continue;
      const redacted = JSON.parse(redactJsonPayload({ accessibleName: control.accessibleName ?? "" }, redactionRules).json) as { accessibleName: string };
      selected.push({ kind: control.kind, role: control.role, stateSchema: control.stateSchema, nameClass: control.nameClass, accessibleName: redacted.accessibleName, landmark: control.landmark, subtreeFingerprint: control.subtreeFingerprint, disabled: control.disabled, current: control.current, expanded: control.expanded, sameOriginLink: control.sameOriginLink, validates: control.validates, ...(control.ariaPressed === undefined ? {} : { ariaPressed: control.ariaPressed }), path: control.path, sources: control.sources, boundaries: [...boundaries].sort(compare) });
    }
  }
  const controls = selected.sort((left, right) => compare(controlKey(left), controlKey(right)) || compare(left.boundaries.join(","), right.boundaries.join(",")));
  return {
    controls,
    digest: sha256Canonical(controls.map((control) => {
      const { accessibleName, ...identity } = control;
      void accessibleName;
      return identity;
    })),
  };
}
