/**
 * Can a stranger on a phone read a detail card at all?
 *
 * The answer on the deployed build was **no**, for every card the product has —
 * an aeroplane, a marker, a landmark — and nothing in the suite could see it.
 * The pick was correct, `mount.ts` wrote the card's text into `#detail`, and
 * about 32 ms later the card was blanked again. `#detail.textContent` still
 * held `SIM 1 / Simulated track — no receiver involved / …` while
 * `#detail.hidden` was `true`, continuously, for as long as you cared to watch.
 *
 * The cause is the one part of a tap that is not a touch. Chrome finishes a tap
 * by replaying it as the legacy mouse events pages were written against before
 * pointer events existed, and the tail of that replay — recorded off a real tap
 * on the canvas — is:
 *
 *   pointerdown/touch, pointerup/touch, pointerout/touch, pointerleave/touch,
 *   mousemove, click/touch, pointerout/MOUSE, pointerleave/MOUSE
 *
 * `onPointerLeave` filtered on `pointerType === "touch"`, which correctly
 * ignored the fourth event and then honoured the eighth. A `pointerleave`
 * calling itself a mouse is indistinguishable from a real one by type; the only
 * thing that tells them apart is the clock.
 *
 * So this file drives that exact sequence through a real `SceneKit` and asserts
 * the pick is still there afterwards — and, in the other direction, that a
 * genuine mouse leaving the canvas long after any touch still clears it, which
 * is the behaviour the desktop has always had and must keep.
 *
 * It is an integration test rather than a unit one because there is no seam to
 * unit-test: the defect lives in the wiring between three listeners and a piece
 * of module state, and the listeners are only reachable through the constructor.
 * The fake DOM here therefore *records* its listeners, which is the one thing
 * `sceneWiring.test.ts`'s fake deliberately does not do.
 */

import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";

// `scenekit.ts` reads `window.matchMedia` while constructing, so the stub has to
// be installed before the module is imported. Hence the dynamic import below.
(globalThis as unknown as { window: unknown }).window = {
  matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
  innerWidth: 390,
  innerHeight: 844,
  devicePixelRatio: 2,
  addEventListener() {},
  removeEventListener() {},
};

const { createSceneKit } = await import("../../engine/scenekit.ts");

/** The canvas, plus a record of what was bound to it so a test can fire it. */
interface RecordingDom {
  el: HTMLElement;
  fire(type: string, event: Record<string, unknown>): void;
}

function recordingDom(): RecordingDom {
  const listeners = new Map<string, Set<(event: unknown) => void>>();
  const el = {
    style: {} as Record<string, string>,
    clientWidth: 390,
    clientHeight: 844,
    addEventListener(type: string, fn: (event: unknown) => void) {
      let set = listeners.get(type);
      if (!set) listeners.set(type, (set = new Set()));
      set.add(fn);
    },
    removeEventListener(type: string, fn: (event: unknown) => void) {
      listeners.get(type)?.delete(fn);
    },
    setPointerCapture() {},
    releasePointerCapture() {},
    getBoundingClientRect: () => ({
      left: 0, top: 0, width: 390, height: 844, right: 390, bottom: 844, x: 0, y: 0,
    }),
    getRootNode: () => ({ addEventListener() {}, removeEventListener() {} }),
    ownerDocument: { addEventListener() {}, removeEventListener() {} },
  };
  return {
    el: el as unknown as HTMLElement,
    fire(type, event) {
      for (const fn of listeners.get(type) ?? []) fn(event);
    },
  };
}

/**
 * A kit looking straight down the −Z axis at one box, with that box picked.
 *
 * The centre of the viewport is therefore a hit and anywhere else is a miss,
 * which is all the geometry any test here needs.
 */
function board() {
  const scene = new THREE.Scene();
  const dom = recordingDom();
  const target = new THREE.Mesh(new THREE.BoxGeometry(4, 4, 4), new THREE.MeshBasicMaterial());
  target.userData.id = "sim-1";
  scene.add(target);

  const kit = createSceneKit({ scene, dom: dom.el });
  kit.setPose({ position: new THREE.Vector3(0, 0, 40), target: new THREE.Vector3(0, 0, 0) });
  kit.camera.updateMatrixWorld(true);

  const changes: (string | null)[] = [];
  kit.setPicking<string>({
    targets: [target],
    resolve: (hit) => (hit.object.userData.id as string) ?? null,
    onChange: (picked) => changes.push(picked),
  });

  return {
    kit,
    dom,
    changes,
    dispose() {
      kit.dispose();
      target.geometry.dispose();
      target.material.dispose();
    },
  };
}

/** The four touch events a tap on the middle of the glass produces, in order. */
function tapCentre(dom: RecordingDom, at: number) {
  dom.fire("pointerdown", { pointerType: "touch", pointerId: 1, clientX: 195, clientY: 422, timeStamp: at });
  dom.fire("pointerup", { pointerType: "touch", pointerId: 1, clientX: 195, clientY: 422, timeStamp: at + 60 });
  dom.fire("pointerout", { pointerType: "touch", pointerId: 1, timeStamp: at + 61 });
  dom.fire("pointerleave", { pointerType: "touch", pointerId: 1, timeStamp: at + 61 });
}

test("a tap raises a card and the compatibility mouse leave does not take it away", () => {
  const { kit, dom, changes, dispose } = board();
  try {
    tapCentre(dom, 1_000);
    kit.tick(1 / 60);
    assert.deepEqual(changes, ["sim-1"], "the tap did not pick the thing under the finger");

    // The replay. `pointerType` says mouse and it is not one — it is Chrome
    // finishing the tap, 32 ms after the finger left.
    dom.fire("pointerout", { pointerType: "mouse", pointerId: 1, timeStamp: 1_092 });
    dom.fire("pointerleave", { pointerType: "mouse", pointerId: 1, timeStamp: 1_092 });
    kit.tick(1 / 60);

    assert.deepEqual(
      changes,
      ["sim-1"],
      "the card was cleared by the browser's own replay of the tap that raised it",
    );
  } finally {
    dispose();
  }
});

test("the card survives long enough to be read, over many frames", () => {
  // The failure this is really about is not one event, it is a card that is
  // never on the page long enough to look at. Two seconds of frames, with the
  // replay in the middle of them, is the shape a person experiences.
  const { kit, dom, changes, dispose } = board();
  try {
    tapCentre(dom, 0);
    for (let frame = 0; frame < 120; frame += 1) {
      if (frame === 2) {
        dom.fire("pointerleave", { pointerType: "mouse", pointerId: 1, timeStamp: 92 });
      }
      kit.tick(1 / 60);
    }
    assert.deepEqual(changes, ["sim-1"], "the card did not survive two seconds of frames");
  } finally {
    dispose();
  }
});

test("a real mouse leaving the canvas still clears the pick", () => {
  const { kit, dom, changes, dispose } = board();
  try {
    dom.fire("pointermove", { pointerType: "mouse", clientX: 195, clientY: 422, timeStamp: 500 });
    kit.tick(1 / 60);
    assert.deepEqual(changes, ["sim-1"], "hover did not pick on the desktop");

    dom.fire("pointerleave", { pointerType: "mouse", pointerId: 1, timeStamp: 900 });
    assert.deepEqual(changes, ["sim-1", null], "the pointer left the canvas and the card stayed");
  } finally {
    dispose();
  }
});

test("a hybrid laptop's mouse is only deferred to briefly, not disabled", () => {
  // The cost of the fix, stated: a device with both a touchscreen and a mouse
  // ignores a genuine mouse-leave for `COMPAT_MOUSE_MS` after a tap. Past that
  // window it behaves exactly like a desktop again, and this pins the recovery
  // rather than leaving it to be discovered.
  const { kit, dom, changes, dispose } = board();
  try {
    tapCentre(dom, 0);
    kit.tick(1 / 60);
    assert.deepEqual(changes, ["sim-1"]);

    // Inside the window: held.
    dom.fire("pointerleave", { pointerType: "mouse", pointerId: 2, timeStamp: 700 });
    assert.deepEqual(changes, ["sim-1"]);

    // Past it: a mouse is a mouse again.
    dom.fire("pointerleave", { pointerType: "mouse", pointerId: 2, timeStamp: 1_500 });
    assert.deepEqual(changes, ["sim-1", null], "the mouse never got the canvas back");
  } finally {
    dispose();
  }
});

test("tapping empty water dismisses the card", () => {
  // The other half of the touch contract, asserted here because the fix above
  // works by ignoring a clear and this is the clear that must still happen.
  const { kit, dom, changes, dispose } = board();
  try {
    tapCentre(dom, 0);
    kit.tick(1 / 60);
    assert.deepEqual(changes, ["sim-1"]);

    dom.fire("pointerdown", {
      pointerType: "touch", pointerId: 3, clientX: 20, clientY: 60, timeStamp: 2_000,
    });
    assert.deepEqual(changes, ["sim-1", null], "a tap on nothing left the old card up");
  } finally {
    dispose();
  }
});
