import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { FakeDocument, type FakeElement } from "./fakeDom.ts";
import {
  GODMODE_HOTKEY,
  KEYMAP,
  SECTION_TITLES,
  SHORTCUT_SECTIONS,
  boundKeys,
  controlForKey,
  edgeForKey,
  railHints,
  renderShortcutSheet,
  shortcutRows,
} from "../../ui/shortcuts.ts";
import type { ShortcutSheetInputs } from "../../ui/shortcuts.ts";

function inputs(overrides: Partial<ShortcutSheetInputs> = {}): ShortcutSheetInputs {
  return {
    coarsePointer: false,
    inside: false,
    god: false,
    degraded: [],
    credits: [],
    ...overrides,
  };
}

/** Every `<dt>` id in the rendered sheet, which is `shortcut-<KEYMAP id>`. */
function renderedIds(root: FakeElement): string[] {
  return root
    .querySelectorAll("dt")
    .map((term) => term.id)
    .filter((id) => id.startsWith("shortcut-"))
    .map((id) => id.slice("shortcut-".length));
}

describe("the keymap", () => {
  it("binds no key to two different meanings", () => {
    /*
     * The defect this table exists to stop, and it was live: `G` was
     * `secondary` (the crow's glide) in the play handler AND godmode's drawer
     * hotkey, registered in the capture phase with a `preventDefault()`. So for
     * the one visitor with godmode, glide silently did nothing — and the `?`
     * card grew a runtime-inserted `G` row saying the opposite of what the
     * printed table said.
     */
    const keys = boundKeys();
    const duplicates = keys.filter((key, index) => keys.indexOf(key) !== index);
    assert.deepEqual(duplicates, [], `these keys are bound twice: ${duplicates.join(", ")}`);
  });

  it("keeps godmode off the play bindings", () => {
    assert.equal(controlForKey(GODMODE_HOTKEY), null);
    assert.equal(edgeForKey(GODMODE_HOTKEY), null);
    // And the crow keeps its glide.
    assert.equal(controlForKey("g"), "secondary");
    assert.equal(controlForKey("G"), "secondary");
  });

  it("is the source of the held-control lookup, not a description of it", () => {
    const expected: Record<string, string> = {
      w: "forward", s: "backward", a: "left", d: "right",
      q: "descend", e: "ascend", i: "pitch-up", k: "pitch-down",
      " ": "primary", g: "secondary",
    };
    for (const [key, control] of Object.entries(expected)) {
      assert.equal(controlForKey(key), control, `${key} should feed ${control}`);
    }
    assert.equal(controlForKey("z"), null);
    assert.equal(controlForKey("Escape"), null);
    // Space is compared before lower-casing. Every other binding is a letter.
    assert.equal(controlForKey(" "), "primary");
  });

  it("derives the one-shot requests from the table's own edge field", () => {
    assert.equal(edgeForKey("p"), "assist");
    assert.equal(edgeForKey("P"), "assist");
    assert.equal(edgeForKey("r"), "reset");
    assert.equal(edgeForKey("c"), "camera");
    assert.equal(edgeForKey("w"), null);
  });

  it("says what Space actually does, in every mode", () => {
    const primary = KEYMAP.find((entry) => entry.id === "primary");
    assert.ok(primary);
    // It read "Handbrake while driving" for as long as the card existed, which
    // is true in one of the four modes it fires in.
    assert.equal(primary.meaning.toLowerCase().includes("handbrake"), true);
    assert.equal(primary.meaning.toLowerCase().includes("sprint"), true);
    assert.equal(primary.meaning.toLowerCase().includes("throttle"), true);
    assert.equal(primary.meaning.toLowerCase().includes("climb"), true);
  });

  it("gives every entry a unique id and a real meaning", () => {
    const ids = KEYMAP.map((entry) => entry.id);
    assert.equal(new Set(ids).size, ids.length);
    for (const entry of KEYMAP) {
      assert.ok(entry.meaning.trim().length > 8, `${entry.id} has no meaning worth printing`);
      assert.ok(SHORTCUT_SECTIONS.includes(entry.section), `${entry.id} has an unknown section`);
      // A row is either keys or a gesture. A row that is neither is invisible.
      assert.ok(
        entry.keys.length > 0 || (entry.gesture ?? "") !== "",
        `${entry.id} names neither a key nor a gesture`,
      );
    }
  });

  it("mentions the controls that are not keys at all", () => {
    // The old sheet listed only the half of the product that needs a keyboard,
    // which on a phone is none of it.
    const ids = KEYMAP.map((entry) => entry.id);
    for (const id of ["mode-dock", "panel-sheet", "character", "office-screens", "aircraft-detail"]) {
      assert.ok(ids.includes(id), `the sheet never mentions ${id}`);
    }
  });
});

describe("the ? sheet", () => {
  it("has no orphan in either direction", () => {
    const doc = new FakeDocument();
    const sheet = renderShortcutSheet(doc as unknown as Document, inputs({ god: true }));
    const drawn = renderedIds(sheet.root as unknown as FakeElement);
    const expected = shortcutRows(inputs({ god: true })).map((row) => row.id);

    // Every KEYMAP entry appears...
    assert.deepEqual([...drawn].sort(), [...expected].sort());
    // ...exactly once...
    assert.equal(new Set(drawn).size, drawn.length);
    // ...and every drawn row names a real entry.
    const known = new Set(KEYMAP.map((entry) => entry.id));
    for (const id of drawn) assert.ok(known.has(id), `the sheet drew an orphan row: ${id}`);
  });

  it("hides the godmode row from everyone who does not have godmode", () => {
    // Hard-coding it would advertise an instrument nine visitors in ten have no
    // way to open; omitting it from a god's card would leave a live key
    // undocumented. Both halves are the same rule.
    assert.equal(shortcutRows(inputs({ god: false })).some((row) => row.id === "godmode"), false);
    assert.equal(shortcutRows(inputs({ god: true })).some((row) => row.id === "godmode"), true);
  });

  it("groups rows under their section, in section order", () => {
    const doc = new FakeDocument();
    const sheet = renderShortcutSheet(doc as unknown as Document, inputs({ god: true }));
    const root = sheet.root as unknown as FakeElement;
    const headings = root.querySelectorAll("h3").map((heading) => heading.textContent);
    const wanted = SHORTCUT_SECTIONS.map((section) => SECTION_TITLES[section]);
    assert.deepEqual(headings, wanted);
  });

  it("leads with the gesture on a coarse pointer and with the keycap otherwise", () => {
    const doc = new FakeDocument();
    const fine = renderShortcutSheet(doc as unknown as Document, inputs());
    const fineMove = (fine.root as unknown as FakeElement).querySelector("#shortcut-move");
    assert.ok(fineMove);
    assert.equal(fineMove.classList.contains("keys-gesture"), false);
    assert.equal(fineMove.querySelectorAll("kbd").length, 4);

    const coarse = renderShortcutSheet(doc as unknown as Document, inputs({ coarsePointer: true }));
    const coarseMove = (coarse.root as unknown as FakeElement).querySelector("#shortcut-move");
    assert.ok(coarseMove);
    assert.equal(coarseMove.classList.contains("keys-gesture"), true);
    assert.match(coarseMove.textContent, /joystick/);
  });

  it("always states the fabricated-markers rule", () => {
    const doc = new FakeDocument();
    const sheet = renderShortcutSheet(doc as unknown as Document, inputs());
    assert.match((sheet.root as unknown as FakeElement).text(), /fabricated/);
    assert.match((sheet.root as unknown as FakeElement).text(), /simulated/);
  });

  it("surfaces degraded[] to an admin and to nobody else", () => {
    // Built by the server, served in the health body, and read by nobody since
    // it was written. Every line in it answers a question — "why is the weather
    // always clear" — that otherwise needs the source to answer.
    const doc = new FakeDocument();
    const degraded = ["No weather source is configured, so the sky is synthetic."];

    const admin = renderShortcutSheet(doc as unknown as Document, inputs({ god: true, degraded }));
    assert.match((admin.root as unknown as FakeElement).text(), /No weather source/);

    const member = renderShortcutSheet(doc as unknown as Document, inputs({ degraded }));
    assert.equal((member.root as unknown as FakeElement).text().includes("No weather source"), false);
  });

  it("prints the live sources' credit lines when there are any", () => {
    const doc = new FakeDocument();
    const withCredit = renderShortcutSheet(
      doc as unknown as Document,
      inputs({ credits: ["Data from MET Norway"] }),
    );
    assert.match((withCredit.root as unknown as FakeElement).text(), /MET Norway/);

    const without = renderShortcutSheet(doc as unknown as Document, inputs());
    assert.equal(
      (without.root as unknown as FakeElement).querySelector(".sheet-credits"),
      null,
      "a credit for data nobody is looking at is noise",
    );
  });

  it("re-renders in place rather than growing a second copy", () => {
    const doc = new FakeDocument();
    const sheet = renderShortcutSheet(doc as unknown as Document, inputs());
    const root = sheet.root as unknown as FakeElement;
    const before = renderedIds(root).length;
    sheet.apply(inputs({ god: true }));
    assert.equal(renderedIds(root).length, before + 1);
    sheet.apply(inputs());
    assert.equal(renderedIds(root).length, before);
  });
});

describe("the rail hint", () => {
  it("returns nothing at all on a coarse pointer", () => {
    // A keycap on a device with no keys is furniture.
    for (const mode of ["overview", "drive", "actor", "aircraft", "office-walk"] as const) {
      assert.deepEqual(railHints(mode, true), []);
    }
  });

  it("returns at most two hints, and they are about the mode you are in", () => {
    const drive = railHints("drive", false);
    assert.ok(drive.length <= 2);
    assert.deepEqual(drive.map((entry) => entry.id), ["primary", "assist"]);

    const overview = railHints("overview", false);
    assert.deepEqual(overview.map((entry) => entry.id), ["chapters", "office"]);

    // The old card printed five permanent hints in the corner of a 3D scene,
    // four fifths of them irrelevant to whatever you were doing.
    for (const mode of ["overview", "drive", "actor", "aircraft", "office-walk", "office-overview"] as const) {
      assert.ok(railHints(mode, false).length <= 2, `${mode} returns too many hints`);
    }
  });

  it("only ever returns entries that exist in the table", () => {
    const known = new Set(KEYMAP.map((entry) => entry.id));
    for (const mode of ["overview", "drive", "actor", "aircraft", "office-walk", "office-overview"] as const) {
      for (const entry of railHints(mode, false)) assert.ok(known.has(entry.id));
    }
  });
});

/**
 * The rail is a glance, not a reference.
 *
 * Every hint the rail can show reused `meaning` — a full sentence written for
 * the `?` sheet. In a `flex-wrap` container over a live 3D board that wrapped,
 * and the wrapped line drew straight through the chip beneath it: on the
 * deployed build "Fly to a chapter of the tour, or to a viewpoint inside a
 * studio" and "Walk into the studio, or step back out to the city" overlapped
 * and neither could be read. Nothing failed; it just looked broken.
 *
 * So: every id `railHints` is capable of returning must carry a short `rail`
 * label, and it must be short enough to stay on one line.
 */
describe("rail hint labels", () => {
  /** Every mode the rail is asked about, from `controlMode.ts`. */
  const MODES = [
    "overview",
    "drive",
    "actor",
    "aircraft",
    "office-overview",
    "office-walk",
  ] as const;

  it("gives every rail-selectable shortcut a short label of its own", () => {
    for (const mode of MODES) {
      for (const entry of railHints(mode, false)) {
        assert.ok(
          entry.rail !== undefined,
          `${mode}: shortcut "${entry.id}" can reach the rail with no rail label, ` +
            `so it would fall back to its ${entry.meaning.length}-character sentence`,
        );
        assert.ok(
          entry.rail.length <= 16,
          `${mode}: rail label "${entry.rail}" is ${entry.rail.length} characters; ` +
            `the rail has room for a glance, not a clause`,
        );
        assert.ok(
          !entry.rail.includes(","),
          `${mode}: rail label "${entry.rail}" carries a clause; that belongs in meaning`,
        );
      }
    }
  });

  it("keeps the long form for the sheet, which is where it reads correctly", () => {
    const [chapters] = railHints("overview", false);
    assert.ok(chapters !== undefined);
    assert.notEqual(chapters.rail, chapters.meaning);
    assert.ok(chapters.meaning.length > 20, "the sheet should still get the sentence");
  });

  it("shows nothing at all on a coarse pointer, which has no keys to press", () => {
    for (const mode of MODES) assert.deepEqual(railHints(mode, true), []);
  });
});
