/**
 * A DOM small enough to run in `node --test` and honest enough to catch the bugs
 * this interface actually has.
 *
 * The repo already had three hand-rolled fakes of this shape — in
 * `presenceIndicator.test.ts`, `webcamPanel.test.ts` and
 * `officeScreenPanel.test.ts` — each covering exactly the four methods its own
 * subject called, and each subtly different. This is the fourth, written once
 * and shared by the six `src/test/ui/*` suites, because the chrome applier
 * touches considerably more of the DOM than any single panel does: attributes,
 * classes, `hidden`, `style` custom properties, `replaceChildren`, event
 * dispatch, `closest` and a small selector engine.
 *
 * It is deliberately not jsdom. The repo has no DOM dependency, the test command
 * is `node --test` with type stripping and nothing else, and adding a
 * multi-megabyte browser emulation to assert that a button gets
 * `aria-pressed="true"` would be a poor trade — the "no surprise dependencies"
 * allowlist in CI exists for exactly this kind of drive-by addition.
 *
 * What it supports, and therefore what a test may rely on: `#id`, `.class`,
 * `tag` and `[attribute]` selectors, and comma-free compound selectors of the
 * form `tag.class[attr]`. Anything more expressive is a sign the code under test
 * is doing something a pure state function should have decided instead.
 */

type Listener = (event: FakeEvent) => void;

export interface FakeEvent {
  type: string;
  target: FakeElement | null;
  preventDefault(): void;
  defaultPrevented: boolean;
  [key: string]: unknown;
}

export class FakeClassList {
  private readonly owner: FakeElement;
  constructor(owner: FakeElement) {
    this.owner = owner;
  }
  private list(): string[] {
    return this.owner.className.split(/\s+/).filter((name) => name !== "");
  }
  private write(names: readonly string[]): void {
    this.owner.className = [...new Set(names)].join(" ");
  }
  add(...names: string[]): void {
    this.write([...this.list(), ...names]);
  }
  remove(...names: string[]): void {
    this.write(this.list().filter((name) => !names.includes(name)));
  }
  contains(name: string): boolean {
    return this.list().includes(name);
  }
  toggle(name: string, force?: boolean): boolean {
    const on = force ?? !this.contains(name);
    if (on) this.add(name);
    else this.remove(name);
    return on;
  }
}

export class FakeStyle {
  readonly properties = new Map<string, string>();
  width = "";
  minHeight = "";
  setProperty(name: string, value: string): void {
    this.properties.set(name, value);
  }
  getPropertyValue(name: string): string {
    return this.properties.get(name) ?? "";
  }
}

export class FakeElement {
  readonly tagName: string;
  readonly ownerDocument: FakeDocument;
  /**
   * Elements and text, in document order.
   *
   * Kept as one mixed list rather than as `children` plus a `textContent`
   * string, because the difference is load-bearing for this applier: in a real
   * DOM, assigning `textContent` **removes every child**, and `mount.ts` relies
   * on exactly that to replace a rendered aircraft card with a one-line marker
   * caption. A fake that stored text beside its children instead of in place of
   * them let that pass and would have shipped a detail card with the previous
   * aircraft still stacked underneath it.
   */
  readonly nodes: (FakeElement | FakeText)[] = [];
  readonly attributes = new Map<string, string>();
  readonly listeners = new Map<string, Listener[]>();
  readonly classList = new FakeClassList(this);
  readonly style = new FakeStyle();
  parentElement: FakeElement | null = null;
  className = "";
  id = "";
  hidden = false;
  disabled = false;
  type = "";
  href = "";
  value = "";
  min = "";
  max = "";
  step = "";
  capturedPointers: number[] = [];
  focused = false;

  constructor(ownerDocument: FakeDocument, tagName: string) {
    this.ownerDocument = ownerDocument;
    this.tagName = tagName.toUpperCase();
  }

  /** Element children only, in order — the same subset `Element.children` is. */
  get children(): FakeElement[] {
    return this.nodes.filter((node): node is FakeElement => node instanceof FakeElement);
  }

  /** Every string in this subtree, exactly as the real getter concatenates it. */
  get textContent(): string {
    return this.nodes
      .map((node) => (node instanceof FakeText ? node.data : node.textContent))
      .join("");
  }

  /** Assigning removes every child, which is the behaviour that matters here. */
  set textContent(value: string) {
    for (const child of this.children) child.parentElement = null;
    this.nodes.length = 0;
    if (value !== "") this.nodes.push(new FakeText(value));
  }

  append(...nodes: (FakeElement | FakeText)[]): void {
    for (const node of nodes) {
      if (node instanceof FakeElement) node.parentElement = this;
      this.nodes.push(node);
    }
  }

  replaceChildren(...nodes: (FakeElement | FakeText)[]): void {
    for (const child of this.children) child.parentElement = null;
    this.nodes.length = 0;
    this.append(...nodes);
  }

  remove(): void {
    const parent = this.parentElement;
    if (parent === null) return;
    const index = parent.nodes.indexOf(this);
    if (index >= 0) parent.nodes.splice(index, 1);
    this.parentElement = null;
  }

  setAttribute(name: string, value: string): void {
    if (name === "id") this.id = value;
    this.attributes.set(name, value);
  }

  getAttribute(name: string): string | null {
    if (name === "id" && this.id !== "") return this.id;
    return this.attributes.get(name) ?? null;
  }

  removeAttribute(name: string): void {
    this.attributes.delete(name);
  }

  hasAttribute(name: string): boolean {
    return this.getAttribute(name) !== null;
  }

  addEventListener(type: string, listener: Listener): void {
    const existing = this.listeners.get(type);
    if (existing) existing.push(listener);
    else this.listeners.set(type, [listener]);
  }

  removeEventListener(type: string, listener: Listener): void {
    const existing = this.listeners.get(type);
    if (!existing) return;
    const index = existing.indexOf(listener);
    if (index >= 0) existing.splice(index, 1);
  }

  /** Fire an event on this element and bubble it to every ancestor. */
  dispatch(type: string, extra: Record<string, unknown> = {}): FakeEvent {
    const event: FakeEvent = {
      type,
      target: this,
      defaultPrevented: false,
      preventDefault() {
        event.defaultPrevented = true;
      },
      ...extra,
    };
    let node: FakeElement | null = this;
    while (node !== null) {
      for (const listener of [...(node.listeners.get(type) ?? [])]) listener(event);
      node = node.parentElement;
    }
    return event;
  }

  click(): FakeEvent {
    return this.dispatch("click");
  }

  focus(): void {
    this.focused = true;
    this.ownerDocument.activeElement = this;
  }

  setPointerCapture(pointerId: number): void {
    this.capturedPointers.push(pointerId);
  }

  getBoundingClientRect(): { left: number; top: number; width: number; height: number } {
    return { left: 0, top: 0, width: 100, height: 100 };
  }

  closest(selector: string): FakeElement | null {
    let node: FakeElement | null = this;
    while (node !== null) {
      if (matches(node, selector)) return node;
      node = node.parentElement;
    }
    return null;
  }

  querySelector(selector: string): FakeElement | null {
    return this.querySelectorAll(selector)[0] ?? null;
  }

  querySelectorAll(selector: string): FakeElement[] {
    const found: FakeElement[] = [];
    const walk = (node: FakeElement): void => {
      for (const child of node.children) {
        if (matches(child, selector)) found.push(child);
        walk(child);
      }
    };
    walk(this);
    return found;
  }

  /** Alias for `textContent`, kept because it reads better in an assertion. */
  text(): string {
    return this.textContent;
  }
}

export class FakeText {
  readonly data: string;
  constructor(data: string) {
    this.data = data;
  }
}

/**
 * The selector engine. `#id`, `.class`, `tag`, `[attr]`, and any concatenation
 * of those with no combinator — which covers every selector the chrome uses.
 */
function matches(element: FakeElement, selector: string): boolean {
  const parts = selector.trim().match(/#[^.#[\]]+|\.[^.#[\]]+|\[[^\]]+\]|^[a-zA-Z][a-zA-Z0-9-]*/g);
  if (parts === null || parts.length === 0) return false;
  for (const part of parts) {
    if (part.startsWith("#")) {
      if (element.id !== part.slice(1)) return false;
    } else if (part.startsWith(".")) {
      if (!element.classList.contains(part.slice(1))) return false;
    } else if (part.startsWith("[")) {
      const body = part.slice(1, -1);
      const equals = body.indexOf("=");
      if (equals < 0) {
        if (!element.hasAttribute(body)) return false;
      } else {
        const name = body.slice(0, equals);
        const wanted = body.slice(equals + 1).replace(/^["']|["']$/g, "");
        if (element.getAttribute(name) !== wanted) return false;
      }
    } else if (element.tagName !== part.toUpperCase()) {
      return false;
    }
  }
  return true;
}

export class FakeDocument {
  readonly head: FakeElement;
  readonly body: FakeElement;
  activeElement: FakeElement | null = null;

  constructor() {
    this.head = new FakeElement(this, "head");
    this.body = new FakeElement(this, "body");
  }

  createElement(tagName: string): FakeElement {
    return new FakeElement(this, tagName);
  }

  createTextNode(data: string): FakeText {
    return new FakeText(data);
  }

  querySelector(selector: string): FakeElement | null {
    return this.body.querySelector(selector) ?? this.head.querySelector(selector);
  }

  querySelectorAll(selector: string): FakeElement[] {
    return [...this.body.querySelectorAll(selector), ...this.head.querySelectorAll(selector)];
  }
}

/** Build an element with an id, appended to `parent`, in one line. */
export function el(
  doc: FakeDocument,
  parent: FakeElement,
  tagName: string,
  id: string,
  className = "",
): FakeElement {
  const element = doc.createElement(tagName);
  element.id = id;
  element.className = className;
  parent.append(element);
  return element;
}

/**
 * A storage double.
 *
 * `mode: "throwing"` throws from both accessors, which is the Safari
 * private-browsing / blocked-third-party-context case and the one the onboarding
 * module has to survive without taking the page down with it.
 */
export function fakeStorage(mode: "working" | "throwing" = "working"): {
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
  readonly map: Map<string, string>;
} {
  const map = new Map<string, string>();
  return {
    map,
    getItem(key: string): string | null {
      if (mode === "throwing") throw new Error("storage is not available");
      return map.get(key) ?? null;
    },
    setItem(key: string, value: string): void {
      if (mode === "throwing") throw new Error("storage is not available");
      map.set(key, value);
    },
  };
}
