/* The console's shared runtime: one HTTP client, one error banner, one refresh loop and
   the handful of formatters and markup helpers every page draws with. A page fragment
   should contain the decisions that are its own and nothing else — if two pages need the
   same piece of markup it belongs here, beside `ui`.

   Pages plug into it rather than reaching around it:
     Admin.onStatus(fn)  fn(status) on every /admin/api/status poll
     Admin.onRefresh(fn) an async task run alongside that poll
     Admin.ready(fn)     once, after the page has parsed
     Admin.act(fn)       run a mutation, then refresh, reporting failure in the banner  */

const Admin = (() => {
  const page = document.querySelector('[data-admin-page]').dataset.adminPage;
  const statusHandlers = [];
  const refreshTasks = [];

  /* ---- transport ------------------------------------------------------- */

  // The sign-in behind this page lasts twelve hours and slides forward only for requests
  // an operator actually caused, so the poll of a tab nobody is reading cannot keep it
  // alive. Anything the console does while somebody is working it says so with this
  // header; see operatorPresent on the server.
  const ACTIVITY_WINDOW_MS = 5 * 60 * 1000;
  let lastInteraction = Date.now();
  for (const name of ['pointerdown', 'pointermove', 'keydown', 'wheel', 'scroll']) {
    window.addEventListener(name, () => { lastInteraction = Date.now(); }, { passive: true });
  }

  // A 401 is an expired sign-in rather than a wrong token. Reloading re-renders this URL as
  // the login form with `next` pointing back at it, so the operator signs in once and lands
  // where they were, instead of reading a banner the page can never clear. The timestamp is
  // what stops a 401 that survives the reload from looping.
  const RELOGIN_KEY = 'memby-admin-relogin';
  function reauthenticate() {
    try {
      if (Date.now() - Number(sessionStorage.getItem(RELOGIN_KEY) || 0) < 30000) return false;
      sessionStorage.setItem(RELOGIN_KEY, String(Date.now()));
    } catch (err) {
      // Private-mode storage refusals must not cost the reload; take the loop risk.
    }
    window.location.reload();
    return true;
  }

  async function api(path, options = {}) {
    const active = Date.now() - lastInteraction < ACTIVITY_WINDOW_MS;
    const response = await fetch(path, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...(active ? { 'X-Memby-Admin-Active': '1' } : {}),
        ...(options.headers || {}),
      },
    });
    if (response.status === 401) {
      throw new Error(reauthenticate()
        ? 'Your sign-in has expired. Signing in again…'
        : 'Your sign-in has expired. Reload this page to sign in again.');
    }
    if (!response.ok) {
      const body = await response.json().catch(() => ({}));
      throw new Error(body.error || ('Request failed (' + response.status + ')'));
    }
    return response.status === 204 ? null : response.json();
  }

  /* ---- formatting ------------------------------------------------------ */

  const escape = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  }[char]));

  const number = (value) => (value ?? 0).toLocaleString();
  const when = (value) => (value ? new Date(value).toLocaleString() : '—');
  const time = (value) => (value ? new Date(value).toLocaleTimeString() : '—');

  function duration(ms) {
    if (!ms) return '0s';
    const seconds = Math.round(ms / 1000);
    if (seconds < 60) return seconds + 's';
    const minutes = Math.floor(seconds / 60);
    if (minutes < 60) return minutes + 'm ' + (seconds % 60) + 's';
    return Math.floor(minutes / 60) + 'h ' + (minutes % 60) + 'm';
  }

  function bytes(value) {
    const units = ['B', 'KB', 'MB', 'GB'];
    let amount = Number(value || 0);
    let unit = 0;
    while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; }
    return (unit === 0 ? amount : amount.toFixed(1)) + ' ' + units[unit];
  }

  const initials = (name) => String(name || '?').trim().split(/\s+/).slice(0, 2)
    .map((part) => part[0] || '').join('').toUpperCase();

  // "Seen in the last quarter of an hour" is what the console means by active: a television
  // checks in every few seconds while somebody is using it.
  const ACTIVE_MS = 15 * 60 * 1000;
  const IDLE_MS = 7 * 24 * 60 * 60 * 1000;
  const recent = (value) => Boolean(value) && Date.now() - new Date(value).getTime() < ACTIVE_MS;

  // Three states rather than two, because "not active this minute" covers both a set
  // somebody switched off after breakfast and one that has not been seen since a firmware
  // update in March — and only the second is worth an operator's attention. Green is on
  // now, amber is a set in ordinary use that happens to be off, red is one that has
  // stopped checking in. A device with no timestamp at all is red: never seen is the
  // strongest version of not seen.
  function presence(value) {
    const seen = value ? new Date(value).getTime() : 0;
    if (!seen) return { tone: 'bad', label: 'never seen' };
    const age = Date.now() - seen;
    if (age < ACTIVE_MS) return { tone: 'ok', label: 'active now' };
    if (age < IDLE_MS) return { tone: 'warn', label: 'seen recently' };
    return { tone: 'bad', label: 'not seen lately' };
  }

  const fmt = { escape, number, when, time, duration, bytes, initials, recent, presence };

  /* ---- markup components ----------------------------------------------- */

  /* Icons live here and nowhere else. Each is the `d` of one stroked path on a 24×24 grid,
     the same shape the rail's marks take, so a page never carries SVG markup of its own and
     two screens showing the same idea cannot draw it two ways. A fragment asks for one by
     writing data-icon="…" on any element; a script asks with ui.icon(). An unknown name
     draws nothing rather than a broken box — a mark is decoration, and a typo in one must
     never be what an operator notices about a page. */
  const icons = {
    library: 'M3 5h18v14H3zM7 5v14M17 5v14M3 9.5h4M3 14.5h4M17 9.5h4M17 14.5h4',
    people: 'M15 19v-1.2a3.3 3.3 0 0 0-3.3-3.3H6.8A3.3 3.3 0 0 0 3.5 17.8V19M9.2 11a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4ZM17 10.6a3 3 0 0 0-1.4-5.7M20.5 19v-1.2a3.3 3.3 0 0 0-2.4-3.2',
    person: 'M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20M12 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z',
    tv: 'M4 5h16v10H4zM9 19h6M12 15v4M8 2.5 12 5l4-2.5',
    sliders: 'M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6',
    clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7.2V12l3 1.8',
    pulse: 'M3 12h3.5L9 19l5-14 2.5 7H21',
    chart: 'M4 19V9m5 10V5m5 14v-7m5 7V3',
    chip: 'M8 8h8v8H8zM4.5 4.5h15v15h-15zM9 2v2.5M15 2v2.5M9 19.5V22M15 19.5V22M2 9h2.5M2 15h2.5M19.5 9H22M19.5 15H22',
    database: 'M12 8.2c4.4 0 8-1.2 8-2.6S16.4 3 12 3 4 4.2 4 5.6s3.6 2.6 8 2.6ZM4 5.6v12.8C4 19.8 7.6 21 12 21s8-1.2 8-2.6V5.6M4 12c0 1.4 3.6 2.6 8 2.6s8-1.2 8-2.6',
    download: 'M12 3.5v10m0 0 4-4m-4 4-4-4M4.5 18h15',
    sync: 'M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5',
    search: 'M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20',
    star: 'm12 3.2 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.4l5.9-.8L12 3.2Z',
    sparkle: 'm10 3 1.5 4.3L16 8.8l-4.5 1.5L10 14.6 8.5 10.3 4 8.8l4.5-1.5L10 3ZM17.5 14l.9 2.4 2.6.9-2.6.9-.9 2.4-.9-2.4-2.6-.9 2.6-.9.9-2.4Z',
    bell: 'M6.2 9.5a5.8 5.8 0 1 1 11.6 0c0 4.6 2.2 5.9 2.2 5.9H4s2.2-1.3 2.2-5.9M10 19.5a2 2 0 0 0 4 0',
    shield: 'm12 3 7.5 3v5.4c0 5-3.2 8.2-7.5 9.6-4.3-1.4-7.5-4.6-7.5-9.6V6L12 3Zm-2.6 8.7 1.9 1.9 3.6-3.6',
    wrench: 'm14.5 6.5 3-3 3 3-3 3M9 15l-5.5 5.5M13 4a5 5 0 0 0 6.5 6.5L10 20l-6-6 9.5-9.5Z',
    play: 'M8 5.2v13.6L19 12 8 5.2ZM4 5v14',
    list: 'M4 7h16M4 12h16M4 17h10',
    inbox: 'M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5',
    history: 'M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8',
    check: 'm5 12.5 4.5 4.5L19 7.5',
    alert: 'M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z',
    power: 'M12 3v9M7.5 6.2a7.5 7.5 0 1 0 9 0',
    key: 'M14.5 3a6.5 6.5 0 1 0 3.4 12L19 14h2v-2h2V9.5l-2.5-2.5A6.5 6.5 0 0 0 14.5 3Zm-2.6 4.6a1.6 1.6 0 1 1-2.3 2.3 1.6 1.6 0 0 1 2.3-2.3Z',
    captions: 'M4 5.5h16v13H4zM7 11h3m2 0h5M7 15h5m3 0h2',
  };

  const icon = (name) => (icons[name]
    ? '<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="' + icons[name] + '"/></svg>'
    : '');

  // An icon in a tinted plate — the tile and card-heading mark. The tone is what says which
  // area a thing belongs to, so it is passed rather than derived.
  const glyph = (name, tone) => (icons[name]
    ? '<span class="glyph"' + (tone ? ' data-tone="' + tone + '"' : '') + '>' + icon(name) + '</span>'
    : '');

  const ui = {
    icon,
    glyph,

    // [label, value, options?] → the tile strip used at the top of most pages. The options
    // are { small, icon, tone }: `small` for a value that is a sentence rather than a
    // number, the other two for the mark above it.
    tiles: (entries) => entries.map(([label, value, options]) => {
      const opts = typeof options === 'object' && options !== null ? options : { small: options };
      return '<div class="tile">' + (opts.icon ? glyph(opts.icon, opts.tone) : '') +
        '<b' + (opts.small ? ' class="small"' : '') + '>' + escape(String(value)) +
        '</b><span>' + escape(label) + '</span></div>';
    }).join(''),

    tag: (label, tone) => '<span class="tag"' + (tone ? ' data-tone="' + tone + '"' : '') +
      '>' + escape(label) + '</span>',

    chip: (label, tone) => '<span class="chip"' + (tone ? ' data-tone="' + tone + '"' : '') +
      '>' + escape(label) + '</span>',

    empty: (message) => '<p class="empty">' + escape(message) + '</p>',

    emptyRow: (columns, message) => '<tr><td colspan="' + columns + '" class="muted">' +
      escape(message) + '</td></tr>',
  };

  /* ---- page plumbing --------------------------------------------------- */

  const $ = (id) => document.getElementById(id);

  // `data-icon` (with an optional `data-icon-tone`) on any element is how a fragment asks
  // for a mark without writing SVG. It is applied once, when the page has parsed, because
  // what carries it is the static markup a fragment ships — anything a poll redraws asks
  // with ui.glyph instead, or the mark would be wiped on the first refresh. The attribute
  // is consumed, so running this again over the same tree cannot double the icon.
  function decorate(root = document) {
    for (const element of root.querySelectorAll('[data-icon]')) {
      element.insertAdjacentHTML('afterbegin', glyph(element.dataset.icon, element.dataset.iconTone));
      delete element.dataset.icon;
    }
  }

  function error(message) {
    const banner = $('error');
    banner.textContent = message || '';
    banner.hidden = !message;
  }

  // Never redraw markup the operator is working inside. Every poll would otherwise take a
  // half-typed field, an open select or a scrolled list away mid-edit.
  const settled = (element) => element && !element.contains(document.activeElement);

  // The same rule for a single control: fill it in unless it is the one being used.
  function fill(element, value) {
    if (element && document.activeElement !== element) element.value = value;
    return element;
  }

  function check(element, value) {
    if (element && document.activeElement !== element) element.checked = Boolean(value);
    return element;
  }

  const onStatus = (fn) => statusHandlers.push(fn);
  const onRefresh = (fn) => refreshTasks.push(fn);
  const ready = (fn) => (document.readyState === 'loading'
    ? document.addEventListener('DOMContentLoaded', fn) : fn());

  function live(ok, label) {
    const tag = $('live');
    tag.textContent = label;
    tag.dataset.tone = ok ? 'ok' : 'bad';
    $('rail-live').dataset.tone = ok ? 'ok' : 'bad';
    $('rail-live-label').textContent = ok ? 'online' : 'unreachable';
  }

  async function refresh() {
    try {
      const status = await api('/admin/api/status');
      $('rail-version').textContent = 'gateway ' + (status.serverVersion || 'unknown');
      statusHandlers.forEach((handler) => handler(status));
      await Promise.all(refreshTasks.map((task) => task()));
      live(true, 'updated ' + new Date().toLocaleTimeString());
      error('');
    } catch (err) {
      live(false, 'not responding');
      error(err.message);
    }
  }

  async function act(fn) {
    try {
      await fn();
      await refresh();
    } catch (err) {
      error(err.message);
    }
  }

  /* ---- refresh loop ----------------------------------------------------- */

  // Someone leaves this open on a second monitor, which makes its poll the gateway's most
  // frequent caller by a wide margin. It stops entirely on a hidden tab and catches up the
  // moment the tab is looked at again — a background tab nobody is reading has no status
  // worth fetching.
  const REFRESH_MS = 30000;
  let timer = null;
  function schedule() {
    clearInterval(timer);
    timer = document.hidden ? null : setInterval(refresh, REFRESH_MS);
  }
  document.addEventListener('visibilitychange', () => {
    schedule();
    if (!document.hidden) refresh();
  });

  ready(() => { decorate(); refresh(); schedule(); });

  return {
    page, api, fmt, ui, $, error, settled, fill, check,
    onStatus, onRefresh, ready, refresh, act, decorate,
  };
})();
