// i18n — the t() layer shared by both terminal shells (docs/i18n.md).
//
// Loaded like theme.jsx / api.jsx: a plain <script type="text/babel"> whose
// exports reach the other files through Object.assign(window, …). babel-standalone
// runs each script in its own strict-mode scope, so a top-level `function` here
// is NOT visible to markets.jsx — only what is assigned onto window is.
//
// ——— boot gate (no flash of raw keys) ———
// The bundle is fetched, not inlined, and both shells already gate their first
// paint on an async step (initInstruments). initI18n() joins that same promise:
//
//     Promise.all([initInstruments(), initI18n()]).then(() => render(<App/>))
//
// Nothing renders until the bundle is in memory, so there is no window in which
// t() can miss and no flash to fix.
//
// The source is GET /api/i18n/{locale} (F-i18n-2, ID2): one response carries the
// merged bundle, its content-hash version, the document direction, and the
// catalogue of enabled locales — so the switcher needs no second call. The URL
// base comes from window.TS_I18N_BASE, which each index.html sets before loading
// this file; deriving it from location.pathname was the alternative and is
// guesswork the moment the app is mounted under a prefix.
//
// ——— performance ———
// t() sits inside render paths that run on every price tick (the desktop
// ChartStatic/ChartLive split exists to keep per-tick work tiny). The bundle is
// therefore a FLAT map of dotted keys — one hash lookup, no split(), no walk —
// and the no-params path allocates nothing. Interpolation only runs when a
// params object is actually passed. NOTHING added by the locale switcher runs
// per tick: the switch bumps a version integer and re-renders once.

const I18N_FALLBACK_LOCALE = 'en';

// Flat { "ticket.volume": "Volume", … }. Two slots so FR5's fallback chain
// (active → en) is a lookup, not a merge.
let I18N_BUNDLE = null;   // active locale
let I18N_EN = null;       // en reference, the production floor
let I18N_LOCALE = I18N_FALLBACK_LOCALE;
let I18N_DIR = 'ltr';
let I18N_READY = false;
let I18N_VERSION = 0;     // bumps on every successful locale switch
let I18N_CATALOGUE = [{ tag: 'en', label: 'English', dir: 'ltr' }];

// Fetched bundles, kept for the session so switching back and forth is
// instantaneous and costs no network. Small: five bundles of ~650 short
// strings. The HTTP layer still revalidates on a real page load (ETag → 304),
// so this cache cannot pin a stale bundle across reloads.
const I18N_CACHE = Object.create(null);

// Dev flag: localStorage.ts_i18n_dev = "1" renders misses as ⟦key⟧ so a gap is
// impossible to miss on screen. Read once — flipping it is a reload.
let I18N_DEV = false;
try { I18N_DEV = localStorage.getItem('ts_i18n_dev') === '1'; } catch (e) { /* private mode */ }

// One warning per key, not per render: a miss in a per-tick component would
// otherwise emit thousands of identical lines and bury everything else.
const I18N_WARNED = new Set();
function i18nWarn(key) {
  if (I18N_WARNED.has(key)) return;
  I18N_WARNED.add(key);
  console.warn('[i18n] missing key: ' + key + ' (locale=' + I18N_LOCALE + ')');
}

// Last-resort English from the key itself: "ticket.takeProfit" → "Take profit".
// FR5 forbids a raw key reaching a production screen, and the CI check
// (internal/i18ncheck) makes a key that is missing from en.json a build
// failure — so this can only fire if that gate was bypassed. It is a
// legibility floor, not a translation path.
function i18nHumanize(key) {
  const tail = key.slice(key.lastIndexOf('.') + 1);
  const spaced = tail.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim();
  return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
}

// {name}-style interpolation. Values are substituted verbatim: numbers and
// prices stay ASCII/dot-decimal (ID6) because the caller formats them — and
// the caller is also responsible for having isolated them (see bidiIsolate).
function i18nFormat(str, params) {
  return str.replace(/\{(\w+)\}/g, function (m, name) {
    const v = params[name];
    return v === undefined || v === null ? m : String(v);
  });
}

// ——— composite strings must carry their own direction ———
//
// An INTERPOLATED Arabic string is only safe where it inherits an RTL
// container. Take "{h}س {m}د" (2h 30m). In an RTL paragraph every element
// resolves to the same bidi level, the run reverses as one adjacency-
// preserving block, and "2س" and "30د" stay glued to each other — correct.
//
// Drop the identical string into an LTR paragraph and it breaks: the leading
// placeholder resolves to level 0 while the Arabic goes to level 1, the
// reversal glues the 2 to the wrong unit and orphans the other letter at the
// far end. The number pairs with the wrong unit — a wrong VALUE, not a layout
// nit. No wording change can fix it, because ANY string starting with a
// placeholder has the same exposure.
//
// So the guarantee is made at the render layer instead: an interpolated
// result is wrapped in an explicit RTL isolate, which pins its base direction
// wherever it lands. RLI (not FSI) because first-strong would inspect the
// leading character, find a DIGIT — a neutral — and guess LTR, which is the
// bug. Inside a genuine RTL container this is a no-op (same level, same
// resolution); inside an LTR one, or inside browser-rendered attribute text
// (`title`, `aria-label`) that may not inherit the document direction at all,
// it is the only thing that saves the string.
//
// Only the params path pays for this. t(key) with no interpolation is
// untouched — it is the per-tick hot path and it still allocates nothing.
//
// The three isolate characters are declared together, ABOVE their first use.
// i18nComposite() below reads I18N_PDI, which used to be declared 150 lines
// further down with the bidiIsolate() block: legal today only because nothing
// calls i18nComposite during script evaluation, and a temporal-dead-zone
// ReferenceError the moment anything does. FSI/PDI are documented at
// bidiIsolate(); RLI is documented directly above.
const I18N_RLI = '⁧';   // U+2067 RIGHT-TO-LEFT ISOLATE
const I18N_FSI = '⁨';   // U+2068 FIRST STRONG ISOLATE
const I18N_PDI = '⁩';   // U+2069 POP DIRECTIONAL ISOLATE

function i18nComposite(s) {
  return I18N_DIR === 'rtl' ? I18N_RLI + s + I18N_PDI : s;
}

// t(key) / t(key, params) — the whole public surface.
//
// FR5 fallback chain: active bundle → en → dev ⟦key⟧ / prod humanized key.
function t(key, params) {
  let s = I18N_BUNDLE !== null ? I18N_BUNDLE[key] : undefined;
  if (s === undefined) {
    s = I18N_EN !== null ? I18N_EN[key] : undefined;
    if (s === undefined) {
      if (!I18N_READY) console.warn('[i18n] t("' + key + '") called before initI18n() resolved');
      i18nWarn(key);
      return I18N_DEV ? '⟦' + key + '⟧' : i18nHumanize(key);
    }
  }
  return params === undefined ? s : i18nComposite(i18nFormat(s, params));
}

// ——————————————————— plurals: CLDR categories ———————————————————
//
// A count-bearing string does NOT have two forms. It has as many as its
// language has CLDR plural categories, and that number is a property of the
// LANGUAGE, not of the sentence:
//
//   en   one, other                       (2)
//   ar   zero, one, two, few, many, other (6)
//   es   one, many, other                 (3)
//   ja   other                            (1)
//   zh   other                            (1)
//
// The English-shaped `n === 1 ? '.one' : '.other'` this replaces is wrong in
// Arabic for most counts a user can actually produce: 2 needs the DUAL (a word
// form, not a number plus a plural), 3–10 the genitive plural, and 11–99 the
// accusative singular. On the desktop chart grid — up to 3×4 — that is every
// count from 2 to 12, i.e. nearly all of them. It is not a rendering nit: the
// wrong form reads as a different quantity.
//
// Selection is Intl.PluralRules, which IS the CLDR table (ICU ships it), so the
// browser and the CI gate agree by construction rather than by a table we keep
// in sync by hand.
//
// ——— performance ———
// A PluralRules object is expensive to construct and cheap to call, so one is
// built per locale, lazily, and kept for the session. There are at most as many
// as there are locales. select() on a cached instance is a table lookup; the
// grid picker re-renders on hover and must not allocate an ICU object per cell.
const I18N_PLURAL_RULES = Object.create(null);

function i18nPluralRules(locale) {
  let pr = I18N_PLURAL_RULES[locale];
  if (pr === undefined) {
    try {
      pr = new Intl.PluralRules(locale);
    } catch (e) {
      // No Intl.PluralRules, or a tag it refuses. Degrade to the English shape
      // rather than throwing: a wrong plural form is a bad string, an exception
      // here is a blank panel.
      console.warn('[i18n] Intl.PluralRules unavailable for ' + locale + ' — using en plural shape', e);
      pr = null;
    }
    I18N_PLURAL_RULES[locale] = pr;
  }
  return pr;
}

function i18nCategory(locale, n) {
  const pr = i18nPluralRules(locale);
  if (pr === null) return n === 1 ? 'one' : 'other';
  return pr.select(n);
}

// tn(baseKey, n, params) — the plural form of t(). The bundle holds
// `baseKey.<category>` for exactly the categories the locale uses, and {n} is
// always in scope for interpolation without the caller passing it.
//
// Fallback, in order:
//   1. the active bundle's form for the locale's own category for n,
//   2. the active bundle's `other` — every CLDR locale has one, so a bundle
//      that is missing a category still renders IN THE RIGHT LANGUAGE. Falling
//      straight to English here would be worse: the shape is wrong, the
//      language is not.
//   3. English (FR5) — and English's OWN category for n, not the active
//      locale's: asking en.json for `.two` because Arabic wanted a dual would
//      miss and drop to the humanized-key floor for no reason.
function tn(key, n, params) {
  const p = { n: n };
  if (params !== undefined) for (const k in params) p[k] = params[k];
  if (I18N_BUNDLE !== null) {
    let s = I18N_BUNDLE[key + '.' + i18nCategory(I18N_LOCALE, n)];
    if (s === undefined) s = I18N_BUNDLE[key + '.other'];
    if (s !== undefined) return i18nComposite(i18nFormat(s, p));
  }
  return t(key + '.' + i18nCategory(I18N_FALLBACK_LOCALE, n), p);
}

// tCode(family, code) — FR4. Server outcomes render from their CODE, never
// from the server's English `text`: reject codes, order statuses, order roles,
// margin events, access roles. The wire codes are the proto enum names
// lowercased with their prefix stripped (frontsrv rejectFromProto /
// statusString), e.g. REJECT_CODE_NO_LIQUIDITY → "no_liquidity".
//
// An unmapped code is a real event (the backend added a code and nobody added a
// key — the docs/i18n.md FR4 checklist), so it degrades to the family's
// `.unknown` string rather than to the raw token.
function tCode(family, code, params) {
  const norm = String(code == null ? '' : code).toLowerCase().replace(/[^a-z0-9]+/g, '_');
  const key = 'outcome.' + family + '.' + norm;
  const bundled = (I18N_BUNDLE !== null && I18N_BUNDLE[key] !== undefined)
    || (I18N_EN !== null && I18N_EN[key] !== undefined);
  if (bundled) return t(key, params);
  i18nWarn(key);
  if (I18N_DEV) return '⟦' + key + '⟧';
  return t('outcome.' + family + '.unknown');
}

// hasKey — for the rare caller that wants to know whether a code is mapped
// before it decides how to lay something out. Not a lookup fast path.
function i18nHas(key) {
  return (I18N_BUNDLE !== null && I18N_BUNDLE[key] !== undefined)
    || (I18N_EN !== null && I18N_EN[key] !== undefined);
}

// Nested-or-flat: bundles are authored flat, but the serving endpoint's merge
// (and any translation service) may hand back nested objects. Flatten
// defensively so the hot path stays a single lookup either way.
function i18nFlatten(obj, prefix, out) {
  for (const k in obj) {
    if (!Object.prototype.hasOwnProperty.call(obj, k)) continue;
    const v = obj[k];
    const key = prefix ? prefix + '.' + k : k;
    if (v !== null && typeof v === 'object' && !Array.isArray(v)) i18nFlatten(v, key, out);
    else out[key] = String(v);
  }
  return out;
}

// ——————————————————— bidi isolation (ID5) ———————————————————
//
// Under RTL, every numeric run has to stay LTR *and* keep its own sign and
// separators attached. This is not cosmetic: in an RTL paragraph a leading
// "-" or "+" is a neutral at a run boundary, so it resolves to the paragraph
// direction and renders on the WRONG SIDE — "-12.50" displays as "12.50-",
// which is a different number to a reader. "$" and "%" are European
// Terminators and drift the same way at a boundary.
//
// The fix is a first-strong isolate: U+2068 … U+2069 around the run. Two plain
// characters rather than a DOM wrapper, which matters here because most of
// these values are substituted INTO a translated sentence by i18nFormat —
// there is no element to put unicode-bidi on. (Elements that hold a bare
// number and no sentence get `unicodeBidi: 'isolate'` in the markup instead;
// both mechanisms are the same Unicode algorithm.)
//
// Under LTR this is the identity function, so the four LTR locales render
// byte-identical output to before and nothing enters the DOM that was not
// there in F-i18n-1.
//
// (I18N_FSI / I18N_PDI are declared with I18N_RLI at the top of the file, above
// i18nComposite's use of PDI.)
function bidiIsolate(s) {
  if (I18N_DIR !== 'rtl') return s;
  return I18N_FSI + s + I18N_PDI;
}

// ——————————————————— document direction (IO7/IO8) ———————————————————
//
// Both index.html files used to hardcode <html lang="en"> with no dir. The
// active locale drives both now, and re-drives them on a runtime switch — the
// whole RTL layout, the CJK line-breaking rules and the font stack all key off
// these two attributes, so nothing else has to be plumbed through React.
function i18nApplyDocument() {
  const el = document.documentElement;
  el.setAttribute('lang', I18N_LOCALE);
  el.setAttribute('dir', I18N_DIR);
}

// ——————————————————— locale resolution ———————————————————
//
// Boot order: explicit `?lang=` → the last locale this browser used → the
// browser's own languages → en. After login the user's stored preference is
// adopted and mirrored into localStorage, so the NEXT first paint is already
// correct and does not flash English (ID4).
//
// ——— the ?lang= contract ———
//
// An explicit `?lang=` is a deliberate user action and **wins for that page
// load**. Two consequences, and the whole point is that they are BOTH true:
//
//   - It is not clobbered by the post-login preference. Without this, opening
//     ?lang=ar as a logged-in Spanish user showed Arabic for a few hundred ms
//     and then silently flipped back to Spanish — the link appeared not to
//     work, intermittently, which is the worst way for it not to work.
//   - It is not persisted — not to the server preference and not to
//     localStorage. A link someone was sent is a VIEW, not a silent change to
//     their account: following it must not re-language their terminal on every
//     later visit from this browser, and must not change what their other
//     devices show. Choosing a language in the picker still persists both,
//     because that IS the deliberate change.
//
// The synchronous dir pre-stamp in each index.html reads `?lang=` directly, so
// dropping the localStorage write costs nothing at first paint.
const I18N_LS_KEY = 'ts_locale';

// Set when this page load was forced by ?lang=. Consumed by
// i18nAdoptStoredLocale, which is the only path that must yield to it.
let I18N_FORCED = null;

function i18nStored() {
  try { return localStorage.getItem(I18N_LS_KEY) || null; } catch (e) { return null; }
}

function i18nRemember(locale) {
  try { localStorage.setItem(I18N_LS_KEY, locale); } catch (e) { /* private mode */ }
}

// Matches a BCP-47 tag against the catalogue, base-language first: "es-419"
// and "zh-Hans-CN" must find "es" and "zh". The server does the same
// resolution — this copy exists so the client can pick a sane FIRST request
// instead of asking for a tag it already knows is unsupported.
function i18nMatch(tag) {
  if (!tag) return null;
  const want = String(tag).toLowerCase();
  for (const l of I18N_CATALOGUE) {
    if (l.tag.toLowerCase() === want) return l.tag;
  }
  const dash = want.indexOf('-');
  const base = dash > 0 ? want.slice(0, dash) : null;
  if (base) {
    for (const l of I18N_CATALOGUE) {
      if (l.tag.toLowerCase() === base) return l.tag;
    }
  }
  return null;
}

function i18nPreferredLocale() {
  let forced = null;
  try {
    forced = new URLSearchParams(location.search).get('lang');
  } catch (e) { /* no URL API */ }
  if (forced) {
    const hit = i18nMatch(forced);
    // Deliberately NOT remembered — see the ?lang= contract above.
    if (hit) { I18N_FORCED = hit; return hit; }
  }
  const stored = i18nMatch(i18nStored());
  if (stored) return stored;
  const navLangs = (navigator.languages && navigator.languages.length)
    ? navigator.languages : [navigator.language];
  for (const cand of navLangs) {
    const hit = i18nMatch(cand);
    if (hit) return hit;
  }
  return I18N_FALLBACK_LOCALE;
}

// ——————————————————— fetching ———————————————————

// i18nFetch resolves one locale to { locale, dir, version, bundle } and
// caches it. The endpoint never 404s an unknown locale — it serves en and
// stamps X-I18n-Fallback — so `locale` in the response is authoritative and
// may differ from what was asked for.
async function i18nFetch(locale) {
  const cached = I18N_CACHE[locale];
  if (cached) return cached;
  const base = window.TS_I18N_BASE || '/api/i18n/';
  const res = await fetch(base + encodeURIComponent(locale), { credentials: 'same-origin' });
  if (!res.ok) throw new Error('HTTP ' + res.status);
  const doc = await res.json();
  const entry = {
    locale: doc.locale || locale,
    dir: doc.dir === 'rtl' ? 'rtl' : 'ltr',
    version: doc.version || '',
    bundle: i18nFlatten(doc.bundle || {}, '', {}),
  };
  if (Array.isArray(doc.locales) && doc.locales.length) I18N_CATALOGUE = doc.locales;
  // Key the cache by what was SERVED, and also by what was asked for, so a
  // fallback is not re-requested on every switch attempt.
  I18N_CACHE[entry.locale] = entry;
  I18N_CACHE[locale] = entry;
  return entry;
}

// i18nActivate installs a fetched bundle as the active one and tells the
// shells to re-render. Separated from the fetch so the boot path and the
// switch path share exactly one activation, and there is one place where
// I18N_DIR, the document attributes and the version counter move together.
function i18nActivate(entry) {
  I18N_BUNDLE = entry.bundle;
  I18N_LOCALE = entry.locale;
  I18N_DIR = entry.dir;
  I18N_VERSION++;
  i18nApplyDocument();
  I18N_LISTENERS.forEach(fn => { try { fn(I18N_VERSION); } catch (e) { console.error(e); } });
}

// initI18n — resolve before first render. Awaited by both shells' boot chain.
//
// A failed fetch must not black-hole the terminal: the bundle falls back to an
// empty map, every t() misses, and the humanized-key floor keeps the UI
// readable while the console says exactly what happened.
async function initI18n(locale) {
  // The en floor is always loaded: it is the FR5 fallback for any key a
  // translated bundle is missing, and it is what the catalogue arrives with on
  // the very first request (before any preference is known).
  try {
    const en = await i18nFetch(I18N_FALLBACK_LOCALE);
    I18N_EN = en.bundle;
  } catch (e) {
    console.error('[i18n] failed to load the en bundle — falling back to key text', e);
    I18N_EN = {};
  }
  const want = i18nMatch(locale) || i18nPreferredLocale();
  if (want === I18N_FALLBACK_LOCALE) {
    i18nActivate(I18N_CACHE[I18N_FALLBACK_LOCALE]
      || { locale: I18N_FALLBACK_LOCALE, dir: 'ltr', version: '', bundle: I18N_EN });
  } else {
    try {
      i18nActivate(await i18nFetch(want));
    } catch (e) {
      console.error('[i18n] failed to load the ' + want + ' bundle — staying on en', e);
      i18nActivate({ locale: I18N_FALLBACK_LOCALE, dir: 'ltr', version: '', bundle: I18N_EN });
    }
  }
  I18N_READY = true;
  return I18N_LOCALE;
}

// setLocale — the runtime switch. No reload: fetch (or hit the session cache),
// swap the bundle, restamp <html lang/dir>, bump the version, and let the
// subscribed shells re-render once. Returns the locale actually active, which
// is the previous one if the fetch failed — a switch that cannot load must
// leave a working terminal in the old language rather than a broken one in the
// new.
async function setLocale(locale) {
  const want = i18nMatch(locale);
  if (!want || want === I18N_LOCALE) return I18N_LOCALE;
  try {
    const entry = await i18nFetch(want);
    i18nActivate(entry);
    i18nRemember(I18N_LOCALE);
  } catch (e) {
    console.error('[i18n] switch to ' + want + ' failed — staying on ' + I18N_LOCALE, e);
  }
  return I18N_LOCALE;
}

// i18nAdoptStoredLocale — apply the user's SERVER-STORED preference at boot.
//
// A separate entry point from setLocale() rather than a flag on it, because the
// two are different events that happen to end in the same place: this one is
// "restore what this account chose earlier", setLocale() is "the user is
// choosing now". Only the restore yields to ?lang=; a picker click must still
// work with ?lang= in the URL, and must still persist.
//
// Returns the locale left active, so a caller can tell whether it took.
async function i18nAdoptStoredLocale(locale) {
  if (I18N_FORCED) return I18N_LOCALE;
  return setLocale(locale);
}

// ——————————————————— React binding ———————————————————
//
// One subscription set, and a hook that turns it into a re-render. A shell
// calls useLocaleVersion() at the top of its App and gets a number that
// changes exactly once per switch — which re-renders the whole tree, because
// every t() call is inside that tree.
//
// The number is also the escape hatch past a React.memo barrier: a memoised
// component whose CONTENT is localized takes it as a prop and compares it
// (desktop ChartStatic does). That keeps the memo's per-tick comparison as
// cheap as it was — one integer compare — instead of forcing the barrier open
// on every render.
const I18N_LISTENERS = new Set();

function useLocaleVersion() {
  const [v, setV] = React.useState(I18N_VERSION);
  React.useEffect(() => {
    const fn = (nv) => setV(nv);
    I18N_LISTENERS.add(fn);
    // A switch can land between the first render and this effect.
    if (I18N_VERSION !== v) setV(I18N_VERSION);
    return () => I18N_LISTENERS.delete(fn);
  }, []);
  return v;
}

function i18nLocale() { return I18N_LOCALE; }
function i18nDir() { return I18N_DIR; }
function i18nIsRTL() { return I18N_DIR === 'rtl'; }
function i18nVersion() { return I18N_VERSION; }
function i18nCatalogue() { return I18N_CATALOGUE; }
function i18nIsDev() { return I18N_DEV; }

// ——— date WORDS (ID6) ———
// ID6 keeps the trading formats canonical in every locale: ASCII digits, dot
// decimal, fixed field order. But toLocaleDateString('en-US', {month:'short'})
// emits a WORD — "Aug", "Wednesday" — and no bundle can override it, so those
// screens stayed English however complete the bundle got.
//
// These helpers localize the words and nothing else. The numeric parts still
// come from the same Date methods.
const I18N_MONTH_KEYS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const I18N_DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];

function monthShort(d) { return t('common.month.' + I18N_MONTH_KEYS[d.getMonth()]); }
function weekdayShort(d) { return t('common.weekdayShort.' + I18N_DAY_KEYS[d.getDay()]); }
function weekdayLong(d) { return t('common.weekday.' + I18N_DAY_KEYS[d.getDay()]); }

// The day number and the assembled date are numeric runs inside what is often
// an RTL sentence, so each is isolated (identity under LTR).
function fmtMonthDay(d) {
  return bidiIsolate(t('common.date.monthDay', { month: monthShort(d), day: d.getDate() }));
}
// "Aug 12, 14:26" — `time` is already formatted by the caller.
function fmtMonthDayTime(d, time) {
  return bidiIsolate(t('common.date.monthDayTime', { month: monthShort(d), day: d.getDate(), time }));
}
// "Wednesday, Aug 12".
function fmtWeekdayMonthDay(d) {
  return bidiIsolate(t('common.date.weekdayMonthDay', { weekday: weekdayLong(d), month: monthShort(d), day: d.getDate() }));
}

// ——— time of day (IO9) ———
//
// Every time string in the app used to come from
// toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }).
// That combination selects the h24 hour cycle, which numbers the hours 1–24
// instead of 0–23: midnight renders as **24:00** and 00:30 as **24:30**. It is
// a real, visible, wrong timestamp on the blotter and in history — not a
// localization question at all, which is why it survived the extraction pass.
//
// Fixed here rather than by adding hourCycle:'h23' to four call sites, because
// the format is ASCII digits and a colon in every locale (ID6) and Intl has no
// part to play in producing it. Deterministic across engines, allocation-free
// enough for the blotter, and one place to change if ID6's number/date review
// (IO2) ever does make time formats locale-dependent.
function i18nPad2(n) { return n < 10 ? '0' + n : String(n); }

// fmtClock(date, withSeconds) → "14:26" / "14:26:07", always 24-hour, always
// ASCII. Isolated so it cannot be reordered inside an RTL sentence.
function fmtClock(d, withSeconds) {
  const s = i18nPad2(d.getHours()) + ':' + i18nPad2(d.getMinutes()) +
    (withSeconds ? ':' + i18nPad2(d.getSeconds()) : '');
  return bidiIsolate(s);
}

Object.assign(window, {
  t, tn, tCode, initI18n, setLocale, i18nAdoptStoredLocale, i18nHas, i18nLocale, i18nIsDev,
  i18nDir, i18nIsRTL, i18nVersion, i18nCatalogue, useLocaleVersion, bidiIsolate,
  monthShort, weekdayShort, weekdayLong, fmtMonthDay, fmtMonthDayTime, fmtWeekdayMonthDay,
  fmtClock,
});
