/**
 * Base class for browser-mode page test harnesses.
 *
 * Owns the generic DOM / wait / interaction plumbing every page harness needs,
 * so concrete harnesses (providers, attack-paths, …) only declare their own
 * domain vocabulary. The DOM / wait / interaction primitives are `protected` —
 * subclasses build their semantic API on top of them and tests don't reach
 * them directly. The public members are the deliberate exceptions: `user`
 * (harness tests spy on it) and the request-tracking assertion helpers
 * (`requestLog`, `countRequests`, `lastRequestBody`) that page harnesses expose
 * as domain vocab.
 *
 * Mount-agnostic on purpose: some pages are mounted by their harness, others
 * (attack-paths) are rendered by the test directly, so a `render` here would
 * only serve half the call sites. Mounting lives in `render-browser.tsx`, which
 * wraps every render in the shared app shell (`app-shell.tsx`) — both kinds of
 * call site reach it.
 *
 * Request tracking is opt-in via `trackRequests(worker)`, and unregisters
 * itself when the test ends.
 */
import type { SetupWorker } from "msw/browser";
import { onTestFinished, vi } from "vitest";
import { userEvent } from "vitest/browser";

type RequestStartListener = (event: { request: Request }) => void;

export abstract class BrowserHarness<TFixture> {
  readonly user = userEvent;

  /**
   * Every request MSW saw since `trackRequests` was wired, for assertions. The
   * entry keeps a clone, so a payload assertion can read a body the app's own
   * fetch already consumed.
   */
  readonly requestLog: Array<{
    method: string;
    url: string;
    request: Request;
  }> = [];

  private trackedWorker: SetupWorker | null = null;
  private requestListener: RequestStartListener | null = null;

  constructor(readonly fixture: TFixture) {}

  // --- Request tracking (opt-in) ------------------------------------------

  /** Start recording MSW requests into `requestLog`. Call once, after mounting. */
  protected trackRequests(worker: SetupWorker): void {
    const listener: RequestStartListener = ({ request }) => {
      this.requestLog.push({
        method: request.method,
        url: request.url,
        request: request.clone(),
      });
    };
    this.trackedWorker = worker;
    this.requestListener = listener;
    worker.events.on("request:start", listener);
    // The worker is module-level and shared across harnesses, so listeners
    // would otherwise accumulate run over run. Drop only this harness's
    // listener when the test ends — clearing the emitter would also silence
    // listeners another harness or diagnostic owns.
    onTestFinished(() => this.untrackRequests());
  }

  private untrackRequests(): void {
    const worker = this.trackedWorker;
    const listener = this.requestListener;
    if (!worker || !listener) return;

    worker.events.removeListener("request:start", listener);
    this.trackedWorker = null;
    this.requestListener = null;
  }

  countRequests(method: string, pathIncludes: string): number {
    return this.requestLog.filter(
      (r) => r.method === method && r.url.includes(pathIncludes),
    ).length;
  }

  /** Parsed JSON body of the most recent request matching method + path. */
  async lastRequestBody<T = unknown>(
    method: string,
    pathIncludes: string,
  ): Promise<T | null> {
    const entry = [...this.requestLog]
      .reverse()
      .find((r) => r.method === method && r.url.includes(pathIncludes));
    return entry ? ((await entry.request.clone().json()) as T) : null;
  }

  // --- Low-level DOM ------------------------------------------------------

  protected get container(): HTMLElement {
    return document.body;
  }

  protected q(selector: string): HTMLElement | null {
    return this.container.querySelector<HTMLElement>(selector);
  }

  protected byRoleName(
    role: string,
    name: RegExp,
    scope: ParentNode = document,
  ): HTMLElement | null {
    const explicit = Array.from(
      scope.querySelectorAll<HTMLElement>(`[role="${role}"]`),
    ).find((el) => name.test(el.textContent ?? ""));
    if (explicit) return explicit;
    // A native <button> exposes role "button" implicitly, without the
    // attribute — so it isn't matched by the `[role="button"]` query above.
    if (role === "button") {
      return (
        Array.from(scope.querySelectorAll<HTMLElement>("button")).find(
          (el) => !el.hasAttribute("role") && name.test(el.textContent ?? ""),
        ) ?? null
      );
    }
    return null;
  }

  protected buttonByText(
    name: RegExp,
    scope: ParentNode = document,
  ): HTMLButtonElement | null {
    return (
      Array.from(scope.querySelectorAll<HTMLButtonElement>("button")).find(
        (b) => name.test(b.textContent ?? ""),
      ) ?? null
    );
  }

  protected inputByName(name: string): HTMLInputElement | null {
    return this.q(`input[name="${name}"]`) as HTMLInputElement | null;
  }

  protected containsText(pattern: RegExp): boolean {
    return pattern.test(this.container.textContent ?? "");
  }

  // --- Sync helpers -------------------------------------------------------

  /** Wait until the predicate returns truthy and return that value. */
  protected async waitFor<T>(
    fn: () => T | null | undefined | false,
    timeoutMs = 5000,
    intervalMs = 30,
  ): Promise<T> {
    return vi.waitFor(
      () => {
        const v = fn();
        if (!v) throw new Error("waitFor predicate not yet truthy");
        return v;
      },
      { timeout: timeoutMs, interval: intervalMs },
    ) as Promise<T>;
  }

  protected async waitForText(
    pattern: RegExp,
    timeoutMs = 5000,
  ): Promise<void> {
    await this.waitFor(() => this.containsText(pattern), timeoutMs);
  }

  protected async waitForButton(
    name: RegExp,
    timeoutMs = 5000,
  ): Promise<HTMLButtonElement> {
    return this.waitFor(() => {
      const btn = this.buttonByText(name);
      return btn && !btn.disabled ? btn : null;
    }, timeoutMs);
  }

  /**
   * Sleep for a fixed duration to let a CSS/layout transition settle. Public
   * because a few flows assert on animation-tail state that has no queryable
   * settled signal; prefer waiting on an observable post-condition when one
   * exists.
   */
  async waitForTransition(ms = 350): Promise<void> {
    await new Promise((r) => setTimeout(r, ms));
  }

  // --- Interactions -------------------------------------------------------

  /** Click via user-event, optionally falling back to a native DOM click. */
  protected async clickElement(
    element: HTMLElement,
    options?: { fallbackToDomClick?: boolean },
  ): Promise<void> {
    try {
      await this.user.click(element);
    } catch (error) {
      if (!options?.fallbackToDomClick) throw error;
      element.click();
    }
  }

  protected async clickButton(name: RegExp): Promise<void> {
    const btn = await this.waitForButton(name);
    await this.user.click(btn);
  }

  /** Click a dropdown/menu item (rendered in a Radix portal) by its label. */
  protected async clickMenuItem(name: RegExp): Promise<void> {
    const item = await this.waitFor(() => this.byRoleName("menuitem", name));
    await this.user.click(item);
  }
}
