/**
 * Mounting helpers for component tests.
 *
 * Components are mounted into a real document and queried through their
 * real (open) shadow roots — nothing here approximates the DOM, which
 * is the whole reason this tier runs in a browser.
 */
import type { LitElement } from 'lit';
import { expect } from 'vitest';

const mounted: HTMLElement[] = [];

/**
 * Create an element, apply properties, mount it and wait for it to
 * settle. Properties are set as *properties*, not attributes, so
 * non-string values survive.
 *
 * "Settle" drains the microtask queue between two renders rather than
 * awaiting one render. A component that loads from the backend in
 * `firstUpdated` is only renderable once that call resolves, and under
 * v3 a binding takes several microtasks longer to settle than v2's
 * did: the generated function goes through `Call()`, an async
 * `runtimeCallWithID`, the transport and a `CancellablePromise`, where
 * v2's `window.go` proxy resolved one promise. Tests were already
 * written as though `fixture()` meant "mounted and loaded" — it now
 * does, rather than each of them counting ticks.
 *
 * Microtasks, deliberately, and not `setTimeout`: every hop in that
 * chain is a promise, and a timer would hang forever in the suites
 * that install fake ones.
 */
export async function fixture<T extends LitElement>(
  tag: string,
  props: Record<string, unknown> = {},
): Promise<T> {
  const el = document.createElement(tag) as T;

  Object.assign(el, props);
  document.body.append(el);
  mounted.push(el);

  await el.updateComplete;

  for (let hop = 0; hop < MICROTASK_HOPS; hop += 1) {
    await Promise.resolve();
  }

  await el.updateComplete;

  return el;
}

/**
 * How many promise hops to drain before the second render. Generously
 * over the ~6 a binding actually takes, since an unused hop costs
 * nothing and a missing one is a flake.
 */
const MICROTASK_HOPS = 20;

/** Apply properties to a mounted element and wait for the re-render. */
export async function update<T extends LitElement>(
  el: T,
  props: Record<string, unknown>,
): Promise<T> {
  Object.assign(el, props);
  el.requestUpdate();
  await el.updateComplete;

  return el;
}

/** Remove everything mounted by this module. Called from setup. */
export function cleanupFixtures(): void {
  while (mounted.length > 0) mounted.pop()?.remove();
}

// ===================================================================
// SHADOW DOM QUERIES
// ===================================================================

/** Query one element inside a component's shadow root. */
export function shadow<E extends Element = Element>(
  host: Element,
  selector: string,
): E | null {
  return host.shadowRoot?.querySelector<E>(selector) ?? null;
}

/** Query all matching elements inside a component's shadow root. */
export function shadowAll<E extends Element = Element>(
  host: Element,
  selector: string,
): E[] {
  return [...(host.shadowRoot?.querySelectorAll<E>(selector) ?? [])];
}

/**
 * Query through nested shadow roots.
 *
 * A component that composes another component is still one thing to the
 * user, and to Playwright — `shadow()` stops at the first boundary,
 * which makes an assertion depend on which component happens to own the
 * markup today.
 */
export function deepShadow<E extends Element = Element>(
  root: Element,
  selector: string,
): E | null {
  const queue: Array<Element | ShadowRoot> = [root.shadowRoot ?? root];

  while (queue.length > 0) {
    const node = queue.shift()!;
    const hit = node.querySelector<E>(selector);

    if (hit) return hit;

    for (const el of node.querySelectorAll('*')) {
      if (el.shadowRoot) queue.push(el.shadowRoot);
    }
  }

  return null;
}

/** Trimmed text content of the first deep match, or null if absent. */
export function deepText(host: Element, selector: string): string | null {
  return deepShadow(host, selector)?.textContent?.trim() ?? null;
}

/** Trimmed text content of the first match, or null if absent. */
export function text(host: Element, selector: string): string | null {
  return shadow(host, selector)?.textContent?.trim() ?? null;
}

/** Trimmed text content of every match. */
export function texts(host: Element, selector: string): string[] {
  return shadowAll(host, selector).map((el) => el.textContent?.trim() ?? '');
}

/** The accessible names of every match, for assertions that mirror
 *  what a screen reader — and a Playwright selector — would see. */
export function labels(host: Element, selector: string): string[] {
  return shadowAll(host, selector).map(
    (el) => el.getAttribute('aria-label') ?? '',
  );
}

/** Click something inside a shadow root and let the update settle. */
export async function click(
  host: LitElement,
  selector: string,
): Promise<void> {
  const target = shadow<HTMLElement>(host, selector);

  if (!target) throw new Error(`no element matching ${selector}`);

  target.click();
  await host.updateComplete;
}

// ===================================================================
// VISUAL REGRESSION
// ===================================================================

/**
 * Visual regression is opt-in: `toMatchScreenshot` baselines depend on
 * font hinting and compositing, so a baseline taken on one machine
 * fails on another for reasons that have nothing to do with the
 * component. `make ui-visual` sets YJ_VISUAL=1; the default run
 * asserts behaviour only.
 */
export const visualEnabled = import.meta.env['YJ_VISUAL'] === '1';

/**
 * Screenshot a component against its baseline, when visual regression
 * is enabled. A no-op otherwise — deliberately not a skipped test, so
 * the behavioural assertions around it still run.
 */
export async function visual(el: Element, name: string): Promise<void> {
  if (!visualEnabled) return;

  await expect(el).toMatchScreenshot(name);
}
