/**
 * Every way there is to control this thing, in one table.
 *
 * Before this file there were three lists and they disagreed. The key handler in
 * `main.ts` had a `switch` of ten letters; the `?` card in `index.html` had a
 * hand-typed `<dl>`; and `tools/godmode.ts` spliced an eleventh row into that
 * `<dl>` at runtime. The three had drifted in exactly the ways three copies
 * always drift:
 *
 * - **`G` was bound twice.** The play handler maps it to `secondary`, which is
 *   the crow's glide. Godmode also took it, in the capture phase, with
 *   `preventDefault()` — so for the one visitor who has godmode, glide silently
 *   did nothing, and for everybody else the `?` card gained a row advertising a
 *   panel they cannot open. (The half of that fix which is not in this file: the
 *   godmode hotkey has moved to the backtick. See `tools/godmode.ts`.)
 * - **`Space` was documented as "Handbrake while driving"** and is in fact the
 *   generic `primary` in every mode — sprint on foot, climb as a crow, throttle
 *   in the aircraft. A keyboard reference that is right in one mode out of four
 *   is worse than none, because it is believed.
 * - **The card never mentioned anything that was not a key**: not the mode dock,
 *   not the panel sheet, not the Walk / Fly / Character / Office-screens buttons,
 *   not the joystick. On a phone — where the whole card is `display: none` — that
 *   meant the product had no reference at all.
 *
 * So: one `KEYMAP`, and everything else is derived from it. `controlForKey` reads
 * it, the `?` sheet is rendered from it, the rail's contextual hint is selected
 * from it, and `src/test/ui/shortcuts.test.ts` asserts there are no orphans in
 * either direction and no key with two meanings. Adding a binding is one row.
 *
 * This module holds no DOM state and imports no renderer. `renderShortcutSheet`
 * takes a `Document` and returns an element; everything else is pure.
 */

import type { PlayDigitalControl, PlayEdgeControl } from "../input/play.ts";
import type { ControlMode } from "../play/controlMode.ts";

// ---- The vocabulary -------------------------------------------------------

/**
 * Where a row is drawn in the `?` sheet.
 *
 * Grouped by *what you are trying to do*, not by input device, which is why
 * `touch` and `pointer` are not sections of their own for the movement rows: the
 * joystick and `WASD` are the same intent and belong next to each other. The two
 * device-shaped sections that remain are the gestures that genuinely have no
 * keyboard equivalent.
 */
export type ShortcutSection = "move" | "world" | "view" | "screen" | "gesture" | "instrument";

export const SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
  "move",
  "world",
  "view",
  "screen",
  "gesture",
  "instrument",
];

export const SECTION_TITLES: Readonly<Record<ShortcutSection, string>> = {
  move: "Moving",
  world: "The world",
  view: "The view",
  screen: "On screen",
  gesture: "Touch and pointer",
  instrument: "Instruments",
};

/**
 * When a row is true.
 *
 * `always` rows are drawn in every state. The rest are drawn always in the sheet
 * — a reference that hides what you are not currently doing cannot teach you
 * that it exists — but they are what the *rail hint* selects on, and what a
 * future contextual coach would select on.
 */
export type ShortcutScope =
  | "always"
  | "city"
  | "office"
  | "play"
  | "drive"
  | "fly"
  | "crow"
  | "god";

export interface Shortcut {
  /** Stable, and the DOM id of the row: `shortcut-<id>`. */
  id: string;
  /**
   * The keys as they are printed on a keycap, in press order. Empty for a row
   * that is a gesture or an on-screen control rather than a key.
   */
  keys: readonly string[];
  /** For a row with no key: the gesture or control, named as the person sees it. */
  gesture?: string;
  /** What it does, in one clause, true in every mode the scope admits. */
  meaning: string;
  /**
   * The two-or-three-word form, for the rail in the corner of the scene.
   *
   * `meaning` is written for the `?` sheet, where a full sentence is exactly
   * right — it is a reference somebody is reading. The rail is a glance over a
   * moving 3D board with room for one line, and reusing the sentence there put
   * "Fly to a chapter of the tour, or to a viewpoint inside a studio" in a
   * flex-wrap container: it wrapped, the wrapped line collided with the chip
   * below it, and both were illegible over a bright sky. Two labels for two
   * jobs, rather than one label doing neither well.
   *
   * Optional: a shortcut the rail never selects does not need one, and
   * `railHints` only ever picks from a fixed set of ids.
   */
  rail?: string;
  /**
   * The rail word to use **inside a studio**, when the same binding means
   * something else there. Falls back to `rail`.
   *
   * One key, one row in the `?` sheet, two true glances. `ui/shortcuts.test.ts`
   * asserts that no key is bound to two different meanings — it is the check
   * that caught `G` being both the crow's glide and godmode's drawer — so a
   * second entry for the digits is not available and would be wrong anyway:
   * `1`-`9` is one binding whose list changes with where you are standing.
   */
  railOffice?: string;
  section: ShortcutSection;
  scope: ShortcutScope;
  /**
   * The held play control this key feeds, when it feeds one. This is what makes
   * the table the *source* of `controlForKey` rather than a description of it.
   */
  control?: PlayDigitalControl;
  /** The one-shot request this key fires, when it fires one. */
  edge?: PlayEdgeControl;
  /**
   * The on-screen control that does the same thing without a keyboard.
   *
   * Present on every row a phone needs, and its presence is what puts the row in
   * the phone's variant of the sheet. A reference card on a device with no
   * keyboard should list what that device can actually press.
   */
  onScreen?: string;
}

// ---- The table ------------------------------------------------------------

/**
 * The single source of truth.
 *
 * Order is the reading order of the `?` sheet within each section, so the most
 * used thing in a section comes first.
 */
export const KEYMAP: readonly Shortcut[] = [
  // ---- Moving -------------------------------------------------------------
  {
    id: "move",
    keys: ["W", "A", "S", "D"],
    meaning: "Move: drive, walk, or fly whatever you are controlling",
    rail: "Move",
    section: "move",
    scope: "play",
    control: "forward",
    onScreen: "the joystick, lower left",
  },
  {
    id: "primary",
    keys: ["Space"],
    /**
     * Named as the *role* and not as one mode's version of it. This row read
     * "Handbrake while driving" for as long as the card existed, which is true
     * in one of the four modes it fires in.
     */
    meaning: "The primary action of whatever you are controlling — sprint, handbrake, throttle, climb",
    rail: "Boost",
    section: "move",
    scope: "play",
    control: "primary",
    onScreen: "the large action button, lower right",
  },
  {
    id: "altitude",
    keys: ["Q", "E"],
    meaning: "Descend / climb, flying as a crow or an aircraft",
    rail: "Altitude",
    section: "move",
    scope: "fly",
    control: "ascend",
    onScreen: "the joystick's action pad while flying",
  },
  {
    id: "pitch",
    keys: ["I", "K"],
    meaning: "Pitch the nose up / down while flying",
    rail: "Pitch",
    section: "move",
    scope: "fly",
    control: "pitch-up",
    onScreen: "Pitch + / Pitch − on the action pad",
  },
  {
    id: "glide",
    /**
     * The row that did not exist. `G` has been bound to the crow's glide since
     * the actor landed and the card has never said so — because godmode had
     * quietly taken the same letter, and the row godmode inserted said the
     * opposite thing.
     */
    keys: ["G"],
    meaning: "Glide — stop beating and hold the air, as a crow",
    rail: "Glide",
    section: "move",
    scope: "crow",
    control: "secondary",
    onScreen: "Glide on the action pad",
  },
  {
    id: "assist",
    keys: ["P"],
    meaning: "Hand the vehicle or aircraft back to its assisted route",
    rail: "Autopilot",
    section: "move",
    scope: "drive",
    edge: "assist",
    onScreen: "Assist on the action pad",
  },
  {
    id: "reset",
    keys: ["R"],
    meaning: "Put the vehicle or aircraft back where it started",
    section: "move",
    scope: "drive",
    edge: "reset",
    onScreen: "Reset on the action pad",
  },
  {
    id: "camera",
    keys: ["C"],
    meaning: "Switch between the chase camera and driver height",
    section: "move",
    scope: "drive",
    edge: "camera",
    onScreen: "Camera on the action pad",
  },

  // ---- The world ----------------------------------------------------------
  {
    id: "office",
    keys: ["O"],
    meaning: "Walk into the studio, or step back out to the city",
    rail: "Studio",
    section: "world",
    scope: "always",
    onScreen: "the door button at the top of the left column",
  },
  {
    id: "walk",
    keys: ["V"],
    meaning: "Take control of a body — walk inside a studio, explore outside one",
    rail: "Take control",
    section: "world",
    scope: "always",
    onScreen: "Walk / Explore in the left column, or the mode dock",
  },
  {
    id: "board",
    keys: ["[", "]"],
    /*
     * It used to read "Previous / next board — California, the Bay Area,
     * Southern California", which stopped being a true sentence the moment
     * boards stopped being modes. There is one list now and it descends: the
     * state at 1,551 km of stand-off down to a street in Mission Bay at 3.9,
     * with the Southland and the Bay in between. Stepping it crosses from one
     * board to the next wherever the list does, which is the whole point.
     */
    meaning: "Up / down the list of places — the state, the Southland, the Bay",
    section: "world",
    scope: "city",
    onScreen: "the Places list in the left column",
  },
  {
    id: "chapters",
    keys: ["1", "…", "9"],
    meaning: "Fly to one of the first nine places, or to a viewpoint inside a studio",
    rail: "Places",
    /*
     * One binding, two true words.
     *
     * A second KEYMAP entry was the obvious way to say "Viewpoints" in a room
     * and `ui/shortcuts.test.ts` refused it, correctly: it asserts that no key
     * is bound to two different meanings, which is the check that caught `G`
     * being both the crow's glide and godmode's drawer. The keys here are the
     * *same* binding said in two places, so the row stays one row and only the
     * rail's glance changes.
     */
    railOffice: "Viewpoints",
    section: "world",
    scope: "always",
    onScreen: "the numbered rows at the top of the Places list",
  },

  // ---- The view -----------------------------------------------------------
  {
    id: "plan",
    keys: ["M"],
    meaning: "Show or hide the plan view",
    section: "view",
    scope: "always",
    onScreen: "the plan button on the rail, and Map on the action pad",
  },
  {
    id: "help",
    keys: ["?"],
    meaning: "This card",
    section: "view",
    scope: "always",
    onScreen: "the ? button on the rail",
  },
  {
    id: "escape",
    keys: ["Esc"],
    meaning: "Close whatever is open, or leave the studio",
    section: "view",
    scope: "always",
    onScreen: "the close button on any card",
  },
  {
    id: "tab",
    keys: ["Tab"],
    meaning: "Every control on the page, in reading order",
    section: "view",
    scope: "always",
  },

  // ---- On screen ----------------------------------------------------------
  //
  // Rows with no key at all. They are here because the sheet is the one place a
  // visitor looks to find out what the page can do, and until now it listed only
  // the half of the product that needs a keyboard — which on a phone is none of
  // it.
  {
    id: "mode-dock",
    keys: [],
    gesture: "The mode dock",
    meaning: "The pill at the bottom: View, Drive, Explore, Fly, Walk. It is the fastest way between them",
    section: "screen",
    scope: "always",
    onScreen: "bottom centre",
  },
  {
    id: "panel-sheet",
    keys: [],
    gesture: "The ☰ button",
    meaning: "Opens the left column — boards, chapters, and the door into a studio. On a phone it rises as a sheet",
    section: "screen",
    scope: "always",
    onScreen: "top left",
  },
  {
    id: "character",
    keys: [],
    gesture: "Character",
    meaning: "Choose the body you appear as, and your name over it. Signed in only",
    section: "screen",
    scope: "always",
    onScreen: "beside your name, top right",
  },
  {
    id: "office-screens",
    keys: [],
    gesture: "Office screens",
    meaning: "Put something on a screen in the room. Nothing is shared until you choose it",
    section: "screen",
    scope: "office",
    onScreen: "the left column, inside a studio",
  },
  {
    id: "devices",
    keys: [],
    gesture: "Studio hardware",
    meaning: "The mic and the monitor speaker on the desk — power, gain, level, volume",
    section: "screen",
    scope: "office",
    onScreen: "the left column, inside a studio",
  },
  {
    id: "aircraft-detail",
    keys: [],
    gesture: "Click an aircraft",
    meaning: "Its callsign, transponder hex, altitude, heading and position. Open to everyone, signed in or not",
    section: "screen",
    scope: "city",
    onScreen: "anywhere on the board",
  },

  // ---- Touch and pointer --------------------------------------------------
  {
    id: "orbit",
    keys: [],
    gesture: "Drag",
    meaning: "Orbit the camera around the board. One finger, or the left mouse button",
    section: "gesture",
    scope: "always",
  },
  {
    id: "zoom",
    keys: [],
    gesture: "Pinch or scroll",
    meaning: "Zoom in and out. Two fingers also drag the view across the ground",
    section: "gesture",
    scope: "always",
  },
  {
    id: "pick",
    keys: [],
    gesture: "Tap or click a marker",
    meaning: "Its detail card, at the bottom of the screen. Tap empty ground to dismiss it",
    section: "gesture",
    scope: "always",
  },
  {
    id: "plan-drag",
    keys: [],
    gesture: "Drag on the plan",
    meaning: "Move the view; scroll to zoom; arrow keys aim and Enter goes there",
    section: "gesture",
    scope: "always",
  },

  // ---- Instruments --------------------------------------------------------
  {
    id: "godmode",
    /**
     * The backtick, not `G`. See the header: `G` is the crow's glide and always
     * was, and this panel was taking it in the capture phase.
     */
    keys: ["`"],
    meaning: "Godmode: the clock, the weather, the counters and the feed health",
    section: "instrument",
    scope: "god",
    onScreen: "the godmode tab, bottom centre",
  },
];

/** The key godmode's drawer opens on. Read by `tools/godmode.ts` so the two agree. */
export const GODMODE_HOTKEY = "`";

// ---- Derived lookups ------------------------------------------------------

/**
 * The letters `WASD`/`QE`/`IK` expand into four and two rows' worth of bindings
 * from one row each, because a keyboard reference that printed `W`, `A`, `S` and
 * `D` on four lines would be four lines saying one thing. This is the expansion,
 * kept next to the table it expands.
 */
const KEY_CONTROLS: Readonly<Record<string, PlayDigitalControl>> = {
  w: "forward",
  s: "backward",
  a: "left",
  d: "right",
  q: "descend",
  e: "ascend",
  i: "pitch-up",
  k: "pitch-down",
  " ": "primary",
  g: "secondary",
};

/** The one-shot requests, by key. Derived from the `edge` field of the table. */
const KEY_EDGES: Readonly<Record<string, PlayEdgeControl>> = Object.fromEntries(
  KEYMAP.filter((entry) => entry.edge !== undefined).flatMap((entry) =>
    entry.keys.map((key) => [key.toLowerCase(), entry.edge as PlayEdgeControl]),
  ),
);

/**
 * The held play control a key feeds, or `null`.
 *
 * This replaces the `switch` that lived in `main.ts`. Space is compared before
 * lower-casing because `" ".toLowerCase()` is `" "` but the distinction is worth
 * being explicit about: every other binding is a letter and is case-insensitive,
 * and this one is not a letter at all.
 */
export function controlForKey(key: string): PlayDigitalControl | null {
  return KEY_CONTROLS[key === " " ? key : key.toLowerCase()] ?? null;
}

/** The one-shot request a key fires, or `null`. `P`, `R`, `C`. */
export function edgeForKey(key: string): PlayEdgeControl | null {
  return KEY_EDGES[key.toLowerCase()] ?? null;
}

/** Every key string the product binds, lower-cased, for the uniqueness check. */
export function boundKeys(): readonly string[] {
  const keys: string[] = [];
  for (const entry of KEYMAP) {
    for (const key of entry.keys) {
      // The ellipsis in `1 … 9` is typography, not a key.
      if (key === "…") continue;
      keys.push(key.toLowerCase());
    }
  }
  for (const key of Object.keys(KEY_CONTROLS)) {
    if (!keys.includes(key)) keys.push(key);
  }
  return keys;
}

// ---- What the sheet shows -------------------------------------------------

export interface ShortcutSheetInputs {
  /**
   * No keyboard to speak of. The sheet keeps every row — a tablet with a paired
   * keyboard reports a coarse pointer too — but leads with the on-screen half of
   * each one and drops the keycaps to a trailing note.
   */
  coarsePointer: boolean;
  inside: boolean;
  /** Godmode rows appear only where godmode does. */
  god: boolean;
  /**
   * The one-sentence demotions the server reported: "no weather source
   * configured", "ADS-B endpoint not on the allowlist".
   *
   * Admin only, and it turns "why is the weather always clear" from a question
   * nobody can answer into one the page answers itself.
   */
  degraded: readonly string[];
  /** Licence credit lines from whichever live sources are answering. */
  credits: readonly string[];
}

export interface ShortcutRow {
  id: string;
  keys: readonly string[];
  gesture: string | null;
  meaning: string;
  section: ShortcutSection;
  /** The on-screen equivalent, promoted to the front on a coarse pointer. */
  onScreen: string | null;
}

/**
 * The rows to draw, in order, for this visitor.
 *
 * Pure, so the test can assert the two directions of the no-orphan rule without
 * a DOM: every `KEYMAP` entry that passes the scope filter appears exactly once,
 * and every row carries an id that names a `KEYMAP` entry.
 */
export function shortcutRows(inputs: ShortcutSheetInputs): readonly ShortcutRow[] {
  const rows: ShortcutRow[] = [];
  for (const section of SHORTCUT_SECTIONS) {
    for (const entry of KEYMAP) {
      if (entry.section !== section) continue;
      if (entry.scope === "god" && !inputs.god) continue;
      rows.push({
        id: entry.id,
        keys: entry.keys,
        gesture: entry.gesture ?? null,
        meaning: entry.meaning,
        section: entry.section,
        onScreen: entry.onScreen ?? null,
      });
    }
  }
  return rows;
}

/**
 * The one line the rail shows, instead of the wall of keycaps it used to.
 *
 * The old card printed five key hints at once, permanently, in the corner of a
 * 3D scene: `1–9 chapters · choose 2 or 3 to follow · [ ] city · O office · M
 * plan`. It was a paragraph of monospace over a photograph, it never changed,
 * and four fifths of it was irrelevant to whatever you were doing. This returns
 * at most two hints and they are about the mode you are actually in; everything
 * else is one press of `?` away.
 */
/** The word the rail prints for a shortcut, given where the viewer is standing. */
export function railLabel(entry: Shortcut, inside: boolean): string {
  if (inside && entry.railOffice !== undefined) return entry.railOffice;
  return entry.rail ?? entry.meaning;
}

export function railHints(mode: ControlMode, coarsePointer: boolean): readonly Shortcut[] {
  if (coarsePointer) return [];
  const wanted: readonly string[] =
    mode === "drive"
      ? ["primary", "assist"]
      : mode === "aircraft"
        ? ["primary", "pitch"]
        : mode === "actor"
          ? ["primary", "glide"]
          : mode === "office-walk"
            ? ["move", "walk"]
            : mode === "office-overview"
              ? ["walk", "chapters"]
              : ["chapters", "office"];
  const byId = new Map(KEYMAP.map((entry) => [entry.id, entry]));
  return wanted.flatMap((id) => {
    const entry = byId.get(id);
    return entry ? [entry] : [];
  });
}

// ---- Rendering ------------------------------------------------------------

export interface ShortcutSheetHandles {
  root: HTMLElement;
  /** Re-render for a changed tier, pointer or feed state. */
  apply(inputs: ShortcutSheetInputs): void;
}

/**
 * Build the body of the `?` sheet.
 *
 * The caller owns the dialog, the backdrop and the focus trap — `mount.ts` does
 * — because those are page concerns and this is a content concern. What this
 * guarantees is the property the test checks: everything drawn came out of
 * `KEYMAP`, and nothing in `KEYMAP` was left out.
 */
export function renderShortcutSheet(
  doc: Document,
  inputs: ShortcutSheetInputs,
): ShortcutSheetHandles {
  const root = doc.createElement("div");
  root.className = "sheet-body";

  function apply(next: ShortcutSheetInputs): void {
    root.replaceChildren();
    const rows = shortcutRows(next);
    let currentSection: ShortcutSection | null = null;
    let list: HTMLElement | null = null;

    for (const row of rows) {
      if (row.section !== currentSection) {
        currentSection = row.section;
        const heading = doc.createElement("h3");
        heading.className = "sheet-section t-caps";
        heading.textContent = SECTION_TITLES[row.section];
        root.append(heading);
        list = doc.createElement("dl");
        list.className = "keys";
        root.append(list);
      }
      if (!list) continue;

      const term = doc.createElement("dt");
      term.id = `shortcut-${row.id}`;
      // On a coarse pointer the on-screen name is the term and the keycaps are
      // the footnote; on a keyboard it is the other way round. Same row, same
      // id, same order — only the emphasis moves.
      const leadWithGesture = row.keys.length === 0 || (next.coarsePointer && row.onScreen !== null);
      if (leadWithGesture) {
        term.className = "keys-gesture";
        term.textContent = row.gesture ?? row.onScreen ?? "";
      } else {
        for (const key of row.keys) {
          if (key === "…") {
            term.append(doc.createTextNode(" … "));
            continue;
          }
          const cap = doc.createElement("kbd");
          cap.textContent = key;
          term.append(cap);
        }
      }

      const detail = doc.createElement("dd");
      detail.textContent = row.meaning;
      if (row.onScreen !== null && !leadWithGesture) {
        const also = doc.createElement("span");
        also.className = "keys-also";
        also.textContent = ` · ${row.onScreen}`;
        detail.append(also);
      } else if (leadWithGesture && row.keys.length > 0) {
        const also = doc.createElement("span");
        also.className = "keys-also";
        also.textContent = ` · ${row.keys.join(" ")}`;
        detail.append(also);
      }
      list.append(term, detail);
    }

    // The disclosure, stated as the rule rather than as a claim about this page,
    // because this file is static and the answer is not: one build serves a
    // deployment with a weather station and one with nothing at all.
    const note = doc.createElement("p");
    note.className = "sheet-note";
    note.textContent =
      "The markers on this map are fabricated — no real company data ships in this build. " +
      "Aircraft and weather are real observations where a deployment has wired them, and the " +
      "corner names which. Studio hardware readings are simulated and say so on the panel.";
    root.append(note);

    if (next.credits.length > 0) {
      const credits = doc.createElement("p");
      credits.className = "sheet-note sheet-credits";
      credits.id = "credits";
      credits.textContent = next.credits.join(" · ");
      root.append(credits);
    }

    /**
     * The demotions, for the tier that can act on them.
     *
     * `degraded[]` has been built by the server, served in the health body and
     * read by nobody since it was written. Every line in it is the answer to a
     * question an administrator would otherwise have to read the source to
     * answer — "why is the weather always clear", "why are there no aircraft".
     */
    if (next.god && next.degraded.length > 0) {
      const heading = doc.createElement("h3");
      heading.className = "sheet-section t-caps";
      heading.textContent = "This deployment is degraded";
      const list2 = doc.createElement("ul");
      list2.className = "sheet-degraded";
      for (const line of next.degraded) {
        const item = doc.createElement("li");
        item.textContent = line;
        list2.append(item);
      }
      root.append(heading, list2);
    }
  }

  apply(inputs);
  return { root, apply };
}
