/**
 * Re-locatable ref descriptors.
 *
 * A snapshot is captured when a chat message is sent; the AI's
 * `point` directive arrives seconds later. In between, React re-renders
 * the page — the chat panel expands, content streams in, lists update —
 * and React recreates DOM nodes. Any `HTMLElement` reference frozen at
 * capture time is then detached (`isConnected === false`) and useless.
 *
 * So a ref must NOT be stored as a node pointer. It is stored as a
 * `RefLocator`: a description rich enough to FIND the element again in
 * whatever DOM exists at resolve time. `resolveLocator` runs a live
 * query against the current `document` — it never dereferences a stale
 * pointer.
 */

import { accessibleName } from '../capture/accessible-name';
import { tagName } from '../capture/dom-utils';
import type { CSTInteractiveRole } from '../cst/types';

/**
 * A description that can re-find an interactive element in the live DOM.
 *
 * `role` + `name` is the primary locator: the AI cited an element by the
 * accessible name the CST gave it, so "the visible `link` named 'Browse
 * Catalog'" survives node-identity changes and even position shifts.
 * `selector` is a structural tiebreaker used to disambiguate when
 * several elements share the same role + name.
 */
export interface RefLocator {
  /** Interactive role, as classified by the CST walk. */
  role: CSTInteractiveRole;
  /** Accessible name — the label the AI was shown for this element. */
  name: string;
  /** lowercased tag name, used to filter candidates. */
  tag: string;
  /**
   * A CSS selector that re-finds the element. Built from a stable
   * attribute (`id` / `data-testid` / `name`) when one exists, else a
   * structural `nth-of-type` path from the nearest id'd ancestor or
   * `<body>`. A tiebreaker, not the source of truth — React can
   * recreate nodes and shift positions, breaking the path.
   */
  selector: string;
}

/** Attributes that, when present, give a stable unique-ish selector. */
const STABLE_ATTRS = ['data-testid', 'id', 'name'] as const;

/** True when a selector matches exactly one element in `document`. */
function isUnique(selector: string): boolean {
  try {
    return document.querySelectorAll(selector).length === 1;
  } catch {
    return false;
  }
}

/** Escape a value for use inside a CSS attribute selector. */
function cssEscape(value: string): string {
  if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
    return CSS.escape(value);
  }
  return value.replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`);
}

/**
 * Build a structural `nth-of-type` path from the nearest id'd ancestor
 * (or `<body>`) down to `el`. Survives re-renders that recreate nodes
 * but keep the same tag structure and ordering — the common React case.
 */
function structuralPath(el: HTMLElement): string {
  const parts: string[] = [];
  let node: Element | null = el;
  while (node && node !== document.body && node !== document.documentElement) {
    const id = node.getAttribute('id');
    if (id) {
      parts.unshift(`#${cssEscape(id)}`);
      return parts.join(' > ');
    }
    const tag = tagName(node);
    const parent: Element | null = node.parentElement;
    if (!parent) {
      parts.unshift(tag);
      break;
    }
    let index = 1;
    for (const sib of Array.from(parent.children)) {
      if (sib === node) break;
      if (tagName(sib) === tag) index += 1;
    }
    parts.unshift(`${tag}:nth-of-type(${index})`);
    node = parent;
  }
  return parts.length ? `body > ${parts.join(' > ')}` : 'body';
}

/**
 * Compute a re-locatable descriptor for an interactive element at
 * capture time. `role` is already known from classification; `name` is
 * derived with the same accessible-name logic the CST walk uses, so the
 * locator's name matches exactly what the AI was shown.
 */
export function computeLocator(
  el: HTMLElement,
  role: CSTInteractiveRole,
): RefLocator {
  let selector = '';
  for (const attr of STABLE_ATTRS) {
    const raw = el.getAttribute(attr);
    if (!raw) continue;
    const candidate =
      attr === 'id'
        ? `#${cssEscape(raw)}`
        : `${tagName(el)}[${attr}="${cssEscape(raw)}"]`;
    if (isUnique(candidate)) {
      selector = candidate;
      break;
    }
  }
  if (!selector) selector = structuralPath(el);

  return {
    role,
    name: accessibleName(el),
    tag: tagName(el),
    selector,
  };
}

/** True when an element is rendered (has layout) — prefers visible hits. */
function isRendered(el: HTMLElement): boolean {
  if (!el.isConnected) return false;
  // offsetParent is null for display:none; width/height covers fixed
  // and body-level elements that legitimately have no offsetParent.
  return (
    el.offsetParent !== null ||
    el.getClientRects().length > 0
  );
}

/**
 * Find a connected element matching a locator against the live DOM.
 *
 * Resolution order:
 *  1. the structural / attribute selector — if it yields exactly one
 *     connected element, trust it;
 *  2. the role + accessible name — scan candidate tags, match the live
 *     accessible name, prefer a rendered element. This is the resilient
 *     path: it does not depend on node identity or position.
 *  3. give up — the element is genuinely gone (stale ref).
 */
export function resolveLocator(locator: RefLocator): HTMLElement | null {
  // 1. Selector — a precise hit when structure held.
  try {
    const hits = document.querySelectorAll<HTMLElement>(locator.selector);
    if (hits.length === 1 && hits[0].isConnected) return hits[0];
  } catch {
    // Malformed selector — fall through to the role+name match.
  }

  // 2. Role + accessible name — robust to node recreation.
  const candidates = collectByRoleName(locator);
  if (candidates.length === 0) return null;
  return candidates.find(isRendered) ?? candidates[0];
}

/** Tags worth scanning for a given role when matching by role + name. */
function tagsForRole(role: CSTInteractiveRole): string[] {
  switch (role) {
    case 'link':
      return ['a'];
    case 'button':
      return ['button', 'a', 'input'];
    case 'textbox':
    case 'searchbox':
      return ['input', 'textarea'];
    case 'checkbox':
    case 'radio':
    case 'slider':
    case 'spinbutton':
      return ['input'];
    case 'select':
    case 'combobox':
      return ['select'];
    case 'textarea':
      return ['textarea'];
    default:
      return ['button', 'a', 'input'];
  }
}

/**
 * Collect connected elements whose role + accessible name match the
 * locator. Also scans `[role=...]` so ARIA-roled elements are found.
 */
function collectByRoleName(locator: RefLocator): HTMLElement[] {
  const seen = new Set<HTMLElement>();
  const selectors = [
    ...tagsForRole(locator.role),
    `[role="${locator.role}"]`,
  ];
  const out: HTMLElement[] = [];
  for (const sel of selectors) {
    let nodes: NodeListOf<HTMLElement>;
    try {
      nodes = document.querySelectorAll<HTMLElement>(sel);
    } catch {
      continue;
    }
    for (const node of Array.from(nodes)) {
      if (seen.has(node) || !node.isConnected) continue;
      seen.add(node);
      if (accessibleName(node) === locator.name) out.push(node);
    }
  }
  return out;
}
