// Live API layer (CFD v2) — replaces the design's mock data.jsx + price sim.
// Talks to the Concierge: REST for commands, one WebSocket per active account
// for the price feed + account events. Owns the globals the design components
// consume (INSTRUMENTS, CATEGORIES, computePL, pipValue) and self-computes
// display PL at the edge (R28). Cabinet (deposit/withdraw/transfer/reset/open)
// calls Concierge placeholder endpoints — see docs/bff-api.md.

const CATEGORIES = ['FX', 'Metals', 'Energy', 'Indices', 'Crypto'];

const CAT_ICONS = {
  FX: (c) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M3 12h18M3 12l4-4M3 12l4 4M21 12l-4-4M21 12l-4 4" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  Metals: (c) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8" stroke={c} strokeWidth="2"/><path d="M12 8v8M9 10l3-2 3 2M9 14l3 2 3-2" stroke={c} strokeWidth="1.5" strokeLinecap="round"/></svg>,
  Energy: (c) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M13 2L5 14h6l-2 8 8-12h-6l2-8z" stroke={c} strokeWidth="2" strokeLinejoin="round"/></svg>,
  Indices: (c) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M3 20V8M9 20V4M15 20v-9M21 20v-6" stroke={c} strokeWidth="2" strokeLinecap="round"/></svg>,
  Crypto: (c) => <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M8 4h6a4 4 0 010 8H8V4zM8 12h7a4 4 0 010 8H8v-8zM10 2v2M10 20v2M14 2v2M14 20v2" stroke={c} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
};

let INSTRUMENTS = [];
const SYM_BY_BACKEND = {}; // "GBP/USD" → instrument
const SYM_BY_DISPLAY = {}; // "GBPUSD"  → instrument

async function apiJSON(method, path, body) {
  const res = await fetch(path, {
    method,
    headers: body ? { 'Content-Type': 'application/json' } : undefined,
    body: body ? JSON.stringify(body) : undefined,
    // Send the HttpOnly ts_session cookie; the token is no longer in the body.
    credentials: 'same-origin',
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(data.error || res.statusText);
  return data;
}

async function initInstruments() {
  const { symbols } = await apiJSON('GET', '/api/symbols');
  INSTRUMENTS = symbols.map(s => ({
    sym: s.sym, backend: s.backend, name: s.name, cat: s.cat,
    digits: s.digits, tickSize: s.tickSize, contract: s.contract,
    minVol: s.minVol, volStep: s.volStep, maxVol: s.maxVol,
    quote: s.backend.includes('/') ? s.backend.split('/')[1] : 'USD',
    marginPct: parseFloat(s.marginPct) || 1,
    marginStrategy: s.marginStrategy || 'with-leverage',
    // ui_pip_size: FX quotes a fractional pip (5th/3rd decimal is sub-pip).
    pipDigits: s.cat === 'FX' ? Math.max(0, s.digits - 1) : s.digits,
    bid: 0, spread: 0, pctDay: 0, vol: 0,
    dayOpen: 0, dayHigh: 0, dayLow: 0, // M3 day stats (loadStats)
  }));
  INSTRUMENTS.forEach(i => {
    SYM_BY_BACKEND[i.backend] = i;
    SYM_BY_DISPLAY[i.sym] = i;
    LIVE.prices[i.sym] = { bid: 0, ask: 0, flashBid: null, flashAsk: null, spark: [] };
  });
  window.INSTRUMENTS = INSTRUMENTS;
}

// Display PL in deposit ccy; JPY crosses converted via live USD/JPY mid.
function computePL(pos, curBid, curAsk) {
  const inst = SYM_BY_DISPLAY[pos.sym];
  if (!inst) return 0;
  const closePrice = pos.side === 'buy' ? curBid : curAsk;
  if (!closePrice) return 0;
  const diff = pos.side === 'buy' ? (closePrice - pos.openPrice) : (pos.openPrice - closePrice);
  let pl = diff * pos.vol * inst.contract;
  if (inst.backend.endsWith('/JPY')) {
    const jpy = LIVE.prices['USDJPY'];
    const mid = jpy && jpy.bid && jpy.ask ? (jpy.bid + jpy.ask) / 2 : 0;
    pl = mid > 0 ? pl / mid : 0;
  }
  return pl;
}

function leverageOf(acct) {
  if (!acct || !acct.leverage) return 100;
  const m = String(acct.leverage).match(/(\d+)\s*$/);
  return m ? parseInt(m[1]) : 100;
}

// quoteToUSD — USD per 1 unit of the instrument's QUOTE currency, from the
// live crosses (a GBP/JPY notional is JPY; showing it with a $ sign was
// wrong twice). Returns 0 when no conversion pair is streaming.
function quoteToUSD(inst) {
  const q = inst.quote || 'USD';
  if (q === 'USD') return 1;
  const usdQ = LIVE.prices['USD' + q]; // USD/JPY, USD/CHF: USD per q = 1/mid
  if (usdQ && usdQ.bid && usdQ.ask) return 2 / (usdQ.bid + usdQ.ask);
  const qUsd = LIVE.prices[q + 'USD']; // EUR/USD-style: USD per q = mid
  if (qUsd && qUsd.bid && qUsd.ask) return (qUsd.bid + qUsd.ask) / 2;
  return 0;
}

// USD notional of a volume at a price; 0 = conversion unavailable.
function usdValue(inst, vol, price) {
  return vol * inst.contract * price * quoteToUSD(inst);
}

// Margin estimate in USD for display (the gate is authoritative): notional
// converted to deposit ccy × margin%, over leverage when the symbol's
// strategy uses it.
function marginEstimate(inst, vol, price) {
  const usd = usdValue(inst, vol, price) * (inst.marginPct || 1);
  if (inst.marginStrategy === 'without-leverage') return usd;
  return usd / leverageOf(LIVE.activeAccount());
}

function pipValue() { return 10; } // legacy design helper

// ---- live state store ----

const LIVE = {
  // authed replaces the old in-memory token as the client-side "logged in"
  // flag: the session token now lives only in the HttpOnly ts_session cookie,
  // which the browser replays automatically (incl. across reloads and on the
  // WebSocket handshake), so JS never holds it. authed gates the reconnect
  // loop and the account/history fetchers the way a truthy token used to.
  authed: false,
  accountId: null,
  accounts: [],  // cabinet shape (from GET /api/accounts)
  account: { balance: 0, credit: 0, usedMargin: 0 }, // active acct authoritative figures
  prices: {},
  positions: [],
  orders: [],
  history: [],
  lastExec: {},
  toasts: [],    // short-lived execution/alert popups (pushToast)
  connected: false,
  sessionExpired: false, // server said the login session is gone → re-login
  marginEvent: null,
  listeners: new Set(),
  version: 0,
  activeAccount() { return this.accounts.find(a => a.id === this.accountId) || null; },
  // CP1: the active account's platform (10=live, 110=demo) routes every
  // request at the Concierge. Defaults to live for pre-CP1 shapes.
  activePlatform() { const a = this.activeAccount(); return a && a.platform ? a.platform : 10; },
};
window.LIVE = LIVE;

// pushToast — brief popup feedback for asynchronous outcomes (fills, partial
// fills, rejects, SL/TP closes). Execution is async by design; without these
// the terminal is silent about what happened to your order.
// kind: success | warn | error | info
// detail: FR4 — the server's own English `reject.text` is DIAGNOSTIC, never the
// primary display string. It rides here and surfaces as the toast's tooltip
// (plus a console line); what the user reads is the localized code.
let toastSeq = 0;
function pushToast(kind, title, body, detail) {
  const toast = { id: 'n' + (++toastSeq), kind, title, body, detail };
  LIVE.toasts = [toast, ...LIVE.toasts].slice(0, 4);
  notify();
  setTimeout(() => {
    LIVE.toasts = LIVE.toasts.filter(x => x.id !== toast.id);
    notify();
  }, 5000);
}

// One notification per order outcome (events can repeat post-state).
const notifiedOrders = {};
function rememberNotified(ticket) {
  notifiedOrders[ticket] = true;
  const keys = Object.keys(notifiedOrders);
  if (keys.length > 300) delete notifiedOrders[keys[0]];
}

function notify() {
  LIVE.version++;
  LIVE.listeners.forEach(fn => fn(LIVE.version));
}

function useLive() {
  const [, setV] = React.useState(0);
  React.useEffect(() => {
    const fn = v => setV(v);
    LIVE.listeners.add(fn);
    return () => LIVE.listeners.delete(fn);
  }, []);
  return LIVE;
}

// The month name is a WORD no bundle could override while it came out of
// toLocaleDateString. The time is digits and a colon in every locale (ID6) and
// now comes from fmtClock — which also fixes the hour12:false h24 quirk that
// rendered 00:30 as 24:30 (IO9).
function fmtTime(ms) {
  const d = new Date(ms);
  return fmtMonthDayTime(d, fmtClock(d));
}

function mapPosition(p) {
  const inst = SYM_BY_BACKEND[p.sym];
  return {
    id: p.id,
    ticket: p.id % 100000000, // display ticket derived from position id
    sym: inst ? inst.sym : p.sym,
    side: p.side, vol: p.vol,
    openPrice: p.openPrice,
    openTime: fmtTime(p.openTime),
    sl: p.sl || null, tp: p.tp || null,
    closePending: !!p.closePending,
    ...mapOrigin(p),
  };
}

// mapOrigin lifts the PD5 attribution off a wire row.
//
// `origin` is ABSENT on a terminal-placed row — frontsrv omits the fields
// rather than sending "terminal" — so the blotter's badge test is presence,
// not a comparison, and the overwhelming majority of rows cost nothing. The
// token id is what the kill preview counts by: it is the same key the backend
// sweeps by, unlike the label, which is only a display name.
function mapOrigin(row) {
  return {
    origin: row.origin || null,
    originLabel: row.originLabel || '',
    originTokenId: row.originTokenId || 0,
  };
}

function mapOrder(o) {
  const inst = SYM_BY_BACKEND[o.sym];
  const typeWord = o.type === 'Limit' ? 'Limit' : o.type === 'Stop' ? 'Stop' : 'Market';
  return {
    id: o.ticket, ticket: o.ticket,
    sym: inst ? inst.sym : o.sym,
    // `type` stays the canonical English token because the UI BRANCHES on it
    // (startsWith('Buy'), includes('Stop')) — it is a code, not copy.
    // `typeCode` is the display counterpart: t('order.type.' + typeCode).
    type: (o.side === 'buy' ? 'Buy ' : 'Sell ') + typeWord,
    typeCode: (o.side === 'buy' ? 'buy' : 'sell') + typeWord,
    side: o.side, vol: o.vol,
    // LIMIT keeps its trigger in price, STOP in stopPrice.
    triggerPrice: (typeWord === 'Stop' ? o.stopPrice : o.price) || o.price || o.stopPrice || 0,
    sl: o.sl || null, tp: o.tp || null,
    // `expiry` is the TIF CODE the blotter renders through tCode('tif', …);
    // the monolith rests every internal pending as GTC today.
    created: fmtTime(o.created), expiry: 'GTC', status: o.status,
    ...mapOrigin(o),
  };
}

const RESTING = new Set(['working', 'submitted', 'partial', 'reserved', 'held', 'pending']);

function syncActiveBalanceIntoAccounts() {
  const a = LIVE.activeAccount();
  if (a) { a.balance = LIVE.account.balance; a.equity = undefined; }
}

function onWSMessage(msg) {
  switch (msg.t) {
    case 'price': {
      const inst = SYM_BY_BACKEND[msg.s];
      if (!inst) return;
      const prev = LIVE.prices[inst.sym];
      const bid = msg.bid || (prev ? prev.bid : 0);
      const ask = msg.ask || (prev ? prev.ask : 0);
      if (inst.bid === 0 && bid > 0) inst.bid = bid;
      if (bid && ask) inst.spread = Math.round((ask - bid) / inst.tickSize * 10) / 10;
      // Live %-change vs day open. High/low come from the candle store only
      // (loadStats): the consolidated sim book can flicker a one-tick spike
      // that would wrongly pin the day extreme if extended client-side.
      if (inst.dayOpen > 0 && bid > 0) {
        inst.pctDay = (bid - inst.dayOpen) / inst.dayOpen * 100;
      }
      const up = prev ? bid >= prev.bid : true;
      const spark = prev && prev.spark.length ? [...prev.spark.slice(-23), bid] : [bid];
      LIVE.prices[inst.sym] = {
        bid, ask,
        flashBid: prev && prev.bid !== bid ? (up ? 'up' : 'down') : null,
        flashAsk: prev && prev.ask !== ask ? (up ? 'up' : 'down') : null,
        spark,
      };
      clearTimeout(LIVE._flashTimer);
      LIVE._flashTimer = setTimeout(() => {
        for (const k in LIVE.prices) LIVE.prices[k] = { ...LIVE.prices[k], flashBid: null, flashAsk: null };
        notify();
      }, 380);
      notify();
      break;
    }
    case 'snapshot': {
      LIVE.account = { balance: msg.balance, credit: msg.credit, usedMargin: msg.usedMargin };
      LIVE.positions = (msg.positions || []).map(mapPosition);
      LIVE.orders = (msg.orders || []).filter(o => RESTING.has(o.status)).map(mapOrder);
      syncActiveBalanceIntoAccounts();
      notify();
      break;
    }
    case 'position': {
      const mapped = mapPosition(msg.position);
      if (msg.kind === 'close') {
        const existing = LIVE.positions.find(p => p.id === mapped.id);
        LIVE.positions = LIVE.positions.filter(p => p.id !== mapped.id);
        // SL/TP fire asynchronously — the close popup is the only immediate
        // signal a position just went away.
        const src = existing || mapped;
        const inst = SYM_BY_DISPLAY[src.sym];
        // FR4: the title renders from the wire kind (open | close | mod).
        pushToast('info', tCode('positionEvent', msg.kind),
          t('toast.position.closed.body', {
            side: t('common.sideUpper.' + (src.side === 'buy' ? 'buy' : 'sell')),
            qty: src.vol.toFixed(2),
            symbol: inst ? inst.backend : src.sym,
          }));
        // History comes from the server (one aggregated row per close order,
        // VWAP price, summed PL). Fabricating an entry from this event showed
        // only the FINAL partial fill's slice (a 0.10 close displayed as
        // "0.02" with the last fill's PL) — never hand-build it here.
        scheduleHistoryRefresh();
      } else {
        const idx = LIVE.positions.findIndex(p => p.id === mapped.id);
        if (idx >= 0) LIVE.positions[idx] = mapped;
        else LIVE.positions = [mapped, ...LIVE.positions];
      }
      scheduleAccountRefresh();
      notify();
      break;
    }
    case 'order': {
      const o = msg.order;
      if (RESTING.has(o.status)) {
        const mapped = mapOrder(o);
        const idx = LIVE.orders.findIndex(x => x.id === mapped.id);
        if (idx >= 0) LIVE.orders[idx] = mapped; else LIVE.orders = [mapped, ...LIVE.orders];
      } else {
        LIVE.orders = LIVE.orders.filter(x => x.id !== o.ticket);
        // Terminal outcome → one popup. Partial fills matter most: an IOC
        // that ran out of book fills X of Y and silently cancels the rest.
        if (!notifiedOrders[o.ticket]) {
          rememberNotified(o.ticket);
          const inst = SYM_BY_BACKEND[o.sym];
          const sym = inst ? (inst.backend || o.sym) : o.sym;
          // FR4 + ID6: the side is a localized TERM (uppercase lives in the
          // bundle, not in a JS .toUpperCase() that mangles non-Latin scripts);
          // the quantity and price are formatted numbers and stay ASCII.
          const side = t('common.sideUpper.' + (o.side === 'buy' ? 'buy' : 'sell'));
          const price = o.avgFill && inst ? o.avgFill.toFixed(inst.digits) : null;
          const rejCode = o.reject ? (o.reject.code || '') : '';
          // The server's English reject text never reaches the screen — it is
          // support/diagnostic detail behind the toast tooltip and the console.
          const detail = o.reject ? (o.reject.text || '') : '';
          if (o.status === 'filled') {
            pushToast('success', t('toast.order.filled.title'),
              price
                ? t('toast.order.filled.bodyAt', { side, qty: o.filledVol.toFixed(2), symbol: sym, price })
                : t('toast.order.filled.body', { side, qty: o.filledVol.toFixed(2), symbol: sym }));
          } else if (rejCode === 'timeout') {
            // Orphan sweep: the venue never confirmed the order's outcome.
            pushToast('error', t('toast.order.unconfirmed.title'),
              t('toast.order.unconfirmed.body', { side, qty: o.vol.toFixed(2), symbol: sym }), detail);
          } else if (o.status === 'rejected') {
            if (detail) console.info('[reject]', rejCode, detail);
            pushToast('error', t('toast.order.rejected.title'),
              rejCode
                ? t('toast.order.rejected.bodyWhy', { side, qty: o.vol.toFixed(2), symbol: sym, reason: tCode('reject', rejCode) })
                : t('toast.order.rejected.body', { side, qty: o.vol.toFixed(2), symbol: sym }), detail);
          } else if (o.filledVol > 0) { // cancelled with fills = partial
            pushToast('warn', t('toast.order.partial.title'),
              price
                ? t('toast.order.partial.bodyAt', { filled: o.filledVol.toFixed(2), qty: o.vol.toFixed(2), symbol: sym, price })
                : t('toast.order.partial.body', { filled: o.filledVol.toFixed(2), qty: o.vol.toFixed(2), symbol: sym }));
          } else if (o.status === 'cancelled') {
            pushToast('info', t('toast.order.cancelled.title'),
              t('toast.order.cancelled.body', { side, qty: o.vol.toFixed(2), symbol: sym }));
          }
        }
      }
      notify();
      break;
    }
    case 'exec': {
      LIVE.lastExec[msg.positionId] = { priceTicks: msg.priceTicks, time: Date.now() };
      break;
    }
    case 'balance': {
      LIVE.account.balance = msg.balance; LIVE.account.credit = msg.credit;
      syncActiveBalanceIntoAccounts();
      scheduleAccountRefresh(); // usedMargin isn't in this event — resync it
      notify();
      break;
    }
    case 'margin': {
      LIVE.marginEvent = { kind: msg.kind, level: msg.level, at: Date.now() };
      // FR4: the margin event renders from its wire kind (call | stopout).
      pushToast(msg.kind === 'stopout' ? 'error' : 'warn',
        tCode('margin', msg.kind),
        t('toast.margin.body', { level: msg.level.toFixed(1) }));
      notify();
      break;
    }
    case 'agent_msg': {
      // PD9, one-way: an agent reporting to its human. Two destinations, and
      // deliberately different notification paths — the feed is the record and
      // lives in its own store (agentNotify), so appending one does NOT wake
      // the price-path listeners; the toast is the live courtesy and goes
      // through the ordinary stack.
      agentPush(msg);
      // PD9 calls `title` "a short headline for the toast and the feed row",
      // so every level surfaces. The body is capped at 2 KB server-side and a
      // toast is four lines of chrome, so it is clipped here rather than left
      // to push the stack off the screen. Sliced by CODE POINT: a byte or
      // UTF-16 slice can cut a surrogate pair in half and render a replacement
      // glyph, which on a CJK or emoji-carrying message is most of them.
      const from = msg.tokenName || t('agent.unnamedSession');
      const body = Array.from(String(msg.body || ''));
      pushToast(AGENT_TOAST_KIND[msg.level] || 'info',
        t('agent.toast.title', { name: from, title: msg.title || '' }),
        body.length > 160 ? body.slice(0, 160).join('') + '…' : body.join(''));
      break;
    }
    case 'ui_cmd': {
      // PD11. An agent driving the view the human is watching — chart, chart
      // annotations, and the trade ticket. Handled in its own store so a
      // command does not wake the price-path listeners; see uiCmdReceived.
      uiCmdReceived(msg);
      break;
    }
    case 'error': {
      // Terminal server signal. session_expired = the login session is gone;
      // re-login rather than reconnect with a dead token.
      if (msg.code === 'session_expired') handleSessionExpired();
      break;
    }
  }
}

// handleSessionExpired ends the live session locally: clear the authed flag
// (which stops the onclose reconnect loop — it guards on LIVE.authed), clear
// account state, and flag the app to fall back to the login screen. Fires
// before the socket's onclose, so no reconnect is scheduled after expiry.
// The dead ts_session cookie is harmless; the next login overwrites it.
function handleSessionExpired() {
  if (LIVE.sessionExpired) return; // idempotent — one signal is enough
  LIVE.sessionExpired = true;
  LIVE.authed = false;
  LIVE.connected = false;
  LIVE.positions = []; LIVE.orders = [];
  notify();
}

// Trailing debounce: a burst of fills fires many position/balance events;
// refresh the authoritative figures once, after the burst settles — a
// mid-burst fetch left usedMargin stale (seen live: $427 shown while flat).
function scheduleAccountRefresh() {
  clearTimeout(LIVE._acctTimer);
  LIVE._acctTimer = setTimeout(refreshAccount, 600);
}

// Same trailing pattern for history: the projector needs a beat to fold the
// close's executions; one reload after the burst gets the aggregated row.
function scheduleHistoryRefresh() {
  clearTimeout(LIVE._histTimer);
  LIVE._histTimer = setTimeout(loadHistory, 1200);
}

let refreshPending = false;
async function refreshAccount() {
  if (refreshPending || !LIVE.authed || !LIVE.accountId) return;
  refreshPending = true;
  try {
    const snap = await apiJSON('GET', `/api/snapshot?account=${LIVE.accountId}&platform=${LIVE.activePlatform()}`);
    LIVE.account = { balance: snap.balance, credit: snap.credit, usedMargin: snap.usedMargin };
    syncActiveBalanceIntoAccounts();
    notify();
  } catch (e) { /* transient */ } finally { refreshPending = false; }
}

function connectWS() {
  const old = LIVE.ws;
  if (old) { old._replaced = true; try { old.close(); } catch (e) {} }
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  // No token in the URL — the browser sends the ts_session cookie on the
  // WebSocket handshake (same-origin).
  const ws = new WebSocket(`${proto}://${location.host}/api/stream?account=${LIVE.accountId}&platform=${LIVE.activePlatform()}`);
  LIVE.ws = ws;
  ws.onmessage = (e) => { try { onWSMessage(JSON.parse(e.data)); } catch (err) { console.error(err); } };
  ws.onopen = () => { LIVE.connected = true; notify(); };
  ws.onclose = () => {
    if (ws._replaced) return;          // superseded by a newer socket (account switch)
    LIVE.connected = false; notify();
    if (LIVE.authed && LIVE.ws === ws) setTimeout(connectWS, 1500);
  };
}

// ---- commands ----

// bootSession brings the client up for an authenticated session (the token is
// in the HttpOnly cookie, not JS): load accounts, pick the active one, open the
// stream, load history/stats. Shared by fresh login and reload-resume.
async function bootSession() {
  LIVE.sessionExpired = false;
  LIVE.authed = true;
  await loadAccounts();
  // active = first trading account, else first
  const act = LIVE.accounts.find(a => a.trading) || LIVE.accounts[0];
  LIVE.accountId = act ? act.id : null;
  connectWS();
  loadHistory();
  startStatsLoop();
  notify();
}

async function apiLogin(email, password) {
  const res = await apiJSON('POST', '/api/login', { email, password });
  await bootSession();
  return res;
}

// Demo self-signup: create a client + login and trigger the activation email.
// Returns { ok, message, devActivationUrl? } — the dev link is only present in
// DEV_MODE so the flow is testable locally without real email.
async function apiRegister(email, password, confirmPassword) {
  return apiJSON('POST', '/api/register', { email, password, confirmPassword });
}

// apiResumeSession restores a live session on reload using the HttpOnly
// ts_session cookie (the browser replays it automatically). On a valid session
// the terminal skips the login screen and comes straight up; returns false when
// there is no valid session (server 401), so the caller shows login.
async function apiResumeSession() {
  try {
    await apiJSON('GET', '/api/session'); // throws on 401 (no/invalid session)
  } catch (e) {
    LIVE.authed = false;
    return false;
  }
  await bootSession();
  return true;
}

async function loadAccounts() {
  try {
    const res = await apiJSON('GET', '/api/accounts');
    LIVE.accounts = res.accounts || [];
  } catch (e) {
    LIVE.accounts = [];
  }
}

// Day stats from the candle store (M3): dayOpen/high/low per symbol; the
// price handler keeps pctDay live between polls.
async function loadStats() {
  try {
    const res = await apiJSON('GET', '/api/stats');
    for (const st of (res.stats || [])) {
      const inst = SYM_BY_DISPLAY[st.symbol];
      if (!inst) continue;
      inst.dayOpen = st.dayOpen; inst.dayHigh = st.dayHigh; inst.dayLow = st.dayLow;
      const bid = LIVE.prices[inst.sym] ? LIVE.prices[inst.sym].bid : 0;
      const ref = bid || st.last;
      if (st.dayOpen > 0 && ref > 0) inst.pctDay = (ref - st.dayOpen) / st.dayOpen * 100;
    }
    notify();
  } catch (e) { /* stats unavailable — grid shows 0.00% */ }
}
function startStatsLoop() {
  loadStats();
  if (!LIVE._statsTimer) LIVE._statsTimer = setInterval(loadStats, 30000);
}

// ---- instrument detail (M4/M5/M6) ----

const apiCandles = (inst, tf, limit) =>
  apiJSON('GET', `/api/instruments/candles?symbol=${encodeURIComponent(inst.backend)}&tf=${tf}&limit=${limit || 60}`);
const apiDepth = (inst) =>
  apiJSON('GET', `/api/instruments/depth?symbol=${encodeURIComponent(inst.backend)}&account=${LIVE.accountId}&platform=${LIVE.activePlatform()}`);
const apiSpec = (inst) =>
  apiJSON('GET', `/api/instruments/spec?symbol=${encodeURIComponent(inst.backend)}&account=${LIVE.accountId}&platform=${LIVE.activePlatform()}`);

async function apiLogout() {
  try { await apiJSON('POST', '/api/logout'); } catch (e) {}
  // Tear the live session down: stop the reconnect loop, close the socket, and
  // clear per-session state so the next login (or user) starts clean.
  LIVE.authed = false;
  if (LIVE.ws) { try { LIVE.ws._replaced = true; LIVE.ws.close(); } catch (e) {} LIVE.ws = null; }
  LIVE.accountId = null;
  LIVE.positions = []; LIVE.orders = []; LIVE.history = [];
  LIVE.account = { balance: 0, credit: 0, usedMargin: 0 };
  apiClearPrefs(); // never write one user's bag over the next user's
  agentResetFeeds(); // nor leave their agents' messages on screen
  notify();
}

// ---- user preferences ----
// DB-backed per-user preferences (ts_user_preferences, migration 004),
// replacing browser localStorage: they follow the USER across devices and
// browsers instead of being pinned to one machine. The bag is one opaque JSON
// object; it holds { favorites: [...], locale: "es" }.
//
// POST /api/preferences SHALLOW-MERGES the posted keys server-side, so a writer
// can only ever affect the keys it actually sends. The mirror below is
// therefore an optimisation — it keeps the local view consistent without a
// re-read — and no longer a correctness requirement.
//
// It used to be both, and that was not enough. The endpoint replaced the whole
// object, so a writer sending { locale } alone deleted the user's favorites;
// sending the whole bag fixed that for ONE client and not for two. Each tab's
// mirror is a snapshot taken at boot, so a save from tab B carries B's stale
// locale and reverts the language tab A just set — and the mirror-image case
// reverts favorites, which predates the locale work. The merge moved to the
// server (internal/frontsrv/preferences.go) to eliminate the class.
let PREFS = {};
let PREFS_LOADED = false;

// apiLoadPrefs fetches the bag once per session and memoises it. A failed load
// leaves an empty bag, which degrades to "no favorites, browser-default
// language" rather than to an error the user has to see.
async function apiLoadPrefs(force) {
  if (PREFS_LOADED && !force) return PREFS;
  try {
    const prefs = await apiJSON('GET', '/api/preferences');
    PREFS = (prefs && typeof prefs === 'object' && !Array.isArray(prefs)) ? prefs : {};
  } catch (e) {
    PREFS = {}; // store unavailable → start clean
  }
  PREFS_LOADED = true;
  return PREFS;
}

// apiSavePrefs merges a patch into the mirror and persists the WHOLE bag.
// Fire-and-forget: callers update their own state optimistically, and a failed
// save only means the next device won't see the change.
function apiSavePrefs(patch) {
  PREFS = Object.assign({}, PREFS, patch);
  PREFS_LOADED = true;
  apiJSON('POST', '/api/preferences', PREFS).catch(() => {});
}

// apiClearPrefs drops the mirror on logout, so one user's bag can never be
// written back over the next user's on a shared browser.
function apiClearPrefs() { PREFS = {}; PREFS_LOADED = false; }

async function apiLoadFavorites() {
  const prefs = await apiLoadPrefs();
  return Array.isArray(prefs.favorites) ? prefs.favorites : [];
}

function apiSaveFavorites(favs) { apiSavePrefs({ favorites: favs }); }

// The user's locale preference (ID4). Not per-account — a language is a
// property of the person, not of which account they are looking at.
async function apiLoadLocale() {
  const prefs = await apiLoadPrefs();
  return typeof prefs.locale === 'string' ? prefs.locale : null;
}

function apiSaveLocale(locale) { apiSavePrefs({ locale: locale }); }

// Server-side closed-trade history (H1, Stage 2). Live close events during
// the session still prepend in real time; a (re)load replaces the list.
async function loadHistory() {
  if (!LIVE.authed || !LIVE.accountId) return;
  try {
    const res = await apiJSON('GET',
      `/api/history?account=${LIVE.accountId}&platform=${LIVE.activePlatform()}&period=All`);
    LIVE.history = (res.trades || []).map((tr, i) => ({
      id: 'srv' + i + '-' + tr.closeT,
      ticket: tr.ticket, positionId: tr.positionId,
      sym: tr.sym, side: tr.side, vol: tr.vol,
      openP: tr.openP, closeP: tr.closeP,
      openT: new Date(tr.openT), closeT: new Date(tr.closeT),
      pl: tr.pl,
      openExecs: tr.openExecs || [], closeExecs: tr.closeExecs || [],
    }));
    notify();
  } catch (e) { /* history unavailable — session-local only */ }
}

function apiSwitchAccount(id) {
  if (id === LIVE.accountId) return;
  LIVE.accountId = id;
  LIVE.positions = []; LIVE.orders = []; LIVE.history = [];
  LIVE.account = { balance: 0, credit: 0, usedMargin: 0 };
  connectWS(); // re-subscribes → fresh snapshot for the new account
  loadHistory();
  notify();
}

// order: { inst, side, vol, type 'Market'|'Limit'|'Stop', entry, tp, sl }
async function apiPlaceOrder(order) {
  const { inst, side, vol, type, entry, tp, sl } = order;
  const toTicks = (px) => px ? Math.round(px / inst.tickSize) : 0;
  try {
    const res = await apiJSON('POST', '/api/orders', {
      account: LIVE.accountId, platform: LIVE.activePlatform(),
      symbol: inst.backend, side, vol,
      orderType: (type || 'Market').toUpperCase(),
      priceTicks: type === 'Market' ? 0 : toTicks(entry),
      slTicks: toTicks(sl), tpTicks: toTicks(tp),
      clientOrderId: 'term-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
    });
    // FR4: `rejectCode` is what the UI renders (tCode('reject', …)); `error`
    // keeps the server's own text as diagnostic detail for tooltips/support.
    if (res.status === 'rejected') {
      return {
        ok: false,
        // Falling back to a CODE keeps the display path localized: an empty
        // code used to leave callers rendering the raw English `error`.
        rejectCode: (res.reject && res.reject.code) || 'unspecified',
        error: (res.reject && (res.reject.text || res.reject.code)) || '',
      };
    }
    return { ok: true, ticket: res.ticket, status: res.status };
  } catch (e) { return { ok: false, error: String(e.message || e) }; }
}

async function apiClosePosition(pos) {
  pos.closePending = true; notify();
  try {
    const res = await apiJSON('POST', '/api/close', {
      account: LIVE.accountId, platform: LIVE.activePlatform(), positionId: pos.id,
      clientOrderId: 'close-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
    });
    if (res.status === 'rejected') {
      pos.closePending = false; notify();
      return { ok: false, rejectCode: (res.reject && res.reject.code) || 'unspecified', error: res.reject ? res.reject.text : '' };
    }
    return { ok: true };
  } catch (e) { pos.closePending = false; notify(); return { ok: false, error: String(e.message || e) }; }
}

async function apiCancelOrder(order) {
  try {
    // handleCancel answers 200 with a `reject` body rather than an HTTP error,
    // so a refused cancel has to be read out of the payload — ignoring it
    // reported success for an order that is still working.
    const res = await apiJSON('POST', '/api/cancel', { account: LIVE.accountId, platform: LIVE.activePlatform(), ticket: order.id });
    if (res.reject) {
      return { ok: false, rejectCode: res.reject.code || 'unspecified', error: res.reject.text || res.reject.code };
    }
    return { ok: true };
  } catch (e) { return { ok: false, error: String(e.message || e) }; }
}

// Modify SL/TP on an open position (O6). sl/tp in display price; 0 clears.
async function apiModifyPosition(pos, { sl, tp }) {
  const inst = SYM_BY_DISPLAY[pos.sym];
  if (!inst) return { ok: false, error: t('error.unknownSymbol') };
  try {
    const res = await apiJSON('POST', '/api/positions/modify', {
      account: LIVE.accountId, platform: LIVE.activePlatform(), positionId: pos.id,
      symbol: inst.backend, sl, tp,
    });
    if (res.reject) return { ok: false, rejectCode: res.reject.code || 'unspecified', error: res.reject.text || res.reject.code };
    return { ok: true };
  } catch (e) { return { ok: false, error: String(e.message || e) }; }
}

// Modify an internal pending (O7). price = trigger in display units;
// routed to price (LIMIT) or stopPrice (STOP). sl/tp: 0 clears, null keeps.
async function apiModifyOrder(order, { price, sl, tp }) {
  const inst = SYM_BY_DISPLAY[order.sym];
  if (!inst) return { ok: false, error: t('error.unknownSymbol') };
  const body = {
    account: LIVE.accountId, platform: LIVE.activePlatform(), ticket: order.id,
    symbol: inst.backend, sl, tp,
  };
  if (price != null) {
    if (order.type.includes('Stop')) body.stopPrice = price; else body.price = price;
  }
  try {
    const res = await apiJSON('POST', '/api/orders/modify', body);
    if (res.reject) return { ok: false, rejectCode: res.reject.code || 'unspecified', error: res.reject.text || res.reject.code };
    return { ok: true };
  } catch (e) { return { ok: false, error: String(e.message || e) }; }
}

// ---- cabinet (placeholder BFF — see docs/bff-api.md C4–C9) ----
// These hit Concierge stub endpoints, then refresh the accounts list.

async function cabinet(path, body) {
  const res = await apiJSON('POST', path, { ...body });
  await loadAccounts();
  notify();
  return res;
}
const apiDeposit  = (id, amt)        => cabinet('/api/cabinet/deposit',   { accountId: id, amount: amt });
const apiWithdraw = (id, amt)        => cabinet('/api/cabinet/withdraw',  { accountId: id, amount: amt });
const apiTransfer = (from, to, amt)  => cabinet('/api/cabinet/transfer',  { fromId: from, toId: to, amount: amt });
const platformOf  = (id)             => { const a = LIVE.accounts.find(x => x.id === id); return a && a.platform ? a.platform : 10; };
const apiReset    = (id, to)         => cabinet('/api/cabinet/demo-reset',{ accountId: id, platform: platformOf(id), targetBalance: to });
async function apiOpenAccount(acct) {
  const res = await apiJSON('POST', '/api/accounts/open', {
    type: acct.type, plan: acct.plan, leverage: acct.leverage, currency: acct.currency,
  });
  await loadAccounts();
  // A demo account (from A7) is real and tradeable — switch to it so the user
  // lands on their new account. Live opens stay a non-tradeable placeholder.
  if (res.account && res.account.id) {
    apiSwitchAccount(res.account.id);
  } else {
    notify();
  }
  return res;
}

// ---- API access / agent sessions (Track P P1 — docs/public-api.md §5) ----
// Cookie-authenticated management of the user's public /v1 tokens. Deliberately
// NOT /v1 endpoints: an agent must never be able to mint or revoke a token,
// including its own (PD10).
//
// apiMintAgentSession returns { session, token }; the plaintext `token` exists
// only in that response — the server keeps a SHA-256 and cannot recover it.

const apiAgentSessions = () => apiJSON('GET', '/api/agent-sessions');
const apiMintAgentSession = (body) => apiJSON('POST', '/api/agent-sessions/mint', body);
const apiRevokeAgentSession = (tokenId) => apiJSON('POST', '/api/agent-sessions/revoke', { tokenId });

// ---- P3: message feed, kill switch, live caps ----
//
// apiKillAgentSession is the escalating kill (PD10). `level` is 1..4 and each
// level CONTAINS the one below it: 1 disconnect · 2 revoke · 3 +cancel orders ·
// 4 +flatten positions. The response reports per-item outcomes and is the only
// honest account of what happened — see the panel's outcome report.
const apiKillAgentSession = (tokenId, level) =>
  apiJSON('POST', '/api/agent-sessions/kill', { tokenId, level });

// AGENT is the panel's store, and it is deliberately NOT part of LIVE.
//
// LIVE.listeners fires on every price tick — that is what makes the grid and
// the charts move — and the agent panel must not be dragged along with it. A
// separate listener set means a feed append re-renders the panel and nothing
// else, and a price tick re-renders everything else and not the panel. The
// panel components are memo barriers over stable props for the other half of
// the same guarantee.
//
// One feed per view: key "0" is every session, key "<id>" is one. Both exist
// because the endpoint pages by an exclusive `before` cursor, and two views
// sharing one cursor cannot both paginate.
const AGENT = {
  feeds: Object.create(null),
  unread: 0,
  version: 0,
  listeners: new Set(),
};

// Level → toast colour. PD9's vocabulary comes off the wire, so the DISPLAY
// string is tCode('agentLevel', …) (FR4) and this map carries only the tone.
const AGENT_TOAST_KIND = { info: 'info', success: 'success', warning: 'warn', alert: 'error' };

function agentFeedKey(tokenId) { return String(tokenId || 0); }

function agentFeed(tokenId) {
  const key = agentFeedKey(tokenId);
  let feed = AGENT.feeds[key];
  if (!feed) {
    feed = { tokenId: tokenId || 0, rows: [], nextBefore: 0, loaded: false, loading: false, err: null };
    AGENT.feeds[key] = feed;
  }
  return feed;
}

function agentNotify() {
  AGENT.version++;
  AGENT.listeners.forEach(fn => fn(AGENT.version));
}

// useAgentStore subscribes a component to the feed/unread store only.
function useAgentStore() {
  const [, setV] = React.useState(AGENT.version);
  React.useEffect(() => {
    const fn = (v) => setV(v);
    AGENT.listeners.add(fn);
    // A message can land between the first render and this effect.
    setV(AGENT.version);
    return () => AGENT.listeners.delete(fn);
  }, []);
  return AGENT;
}

// agentInsert prepends one message, ignoring a duplicate id. Duplicates are
// routine rather than exotic: a live frame arrives, and the next page fetch
// legitimately returns the same row because the feed is newest-first.
function agentInsert(feed, row) {
  if (feed.rows.some(m => m.id === row.id)) return;
  feed.rows = [row, ...feed.rows];
}

function agentPush(msg) {
  const row = {
    id: msg.id, tokenId: msg.tokenId || 0, tokenName: msg.tokenName || '',
    level: msg.level || 'info', title: msg.title || '', body: msg.body || '',
    ref: msg.ref || null, createdAt: msg.createdAt || Date.now(),
  };
  agentInsert(agentFeed(0), row);
  // Only into a per-session feed that ALREADY exists. Creating one here would
  // leave it holding a single live row with `loaded` false, and the panel would
  // then show that one message as if it were the whole history.
  const key = agentFeedKey(row.tokenId);
  if (row.tokenId && AGENT.feeds[key]) agentInsert(AGENT.feeds[key], row);
  AGENT.unread++;
  agentNotify();
}

// apiLoadAgentMessages fills (or extends) one view's page.
//
// `older` walks backwards with the server's exclusive cursor. The absence of
// `nextBefore` is the end of the feed — the endpoint sets it only when the page
// FILLED, precisely so the client does not have to get a count comparison right.
async function apiLoadAgentMessages(tokenId, older) {
  const feed = agentFeed(tokenId);
  if (feed.loading) return;
  if (older && !feed.nextBefore) return;
  feed.loading = true; feed.err = null;
  agentNotify();
  const params = [];
  if (tokenId) params.push('token_id=' + encodeURIComponent(tokenId));
  if (older) params.push('before=' + encodeURIComponent(feed.nextBefore));
  try {
    const res = await apiJSON('GET', '/api/agent-sessions/messages' + (params.length ? '?' + params.join('&') : ''));
    const rows = res.messages || [];
    if (older) {
      const seen = new Set(feed.rows.map(m => m.id));
      feed.rows = feed.rows.concat(rows.filter(m => !seen.has(m.id)));
    } else {
      // MERGE, never assign. An agent_msg frame can land while this request is
      // in flight — agentPush prepends it to a feed that has not loaded yet —
      // and `feed.rows = rows` would drop it on the floor: the toast has already
      // fired, the unread badge has already counted it, and the first page is
      // never fetched again, so that message would be gone for the session.
      // Sorted by id because the feed renders newest-first and a live row is
      // newer than everything the server just returned.
      const seen = new Set(rows.map(m => m.id));
      feed.rows = feed.rows.filter(m => !seen.has(m.id)).concat(rows).sort((a, b) => b.id - a.id);
    }
    feed.nextBefore = res.nextBefore || 0;
    feed.loaded = true;
  } catch (e) {
    feed.err = String((e && e.message) || e);
  }
  feed.loading = false;
  agentNotify();
}

function agentMarkRead() {
  if (!AGENT.unread) return;
  AGENT.unread = 0;
  agentNotify();
}

// agentResetFeeds drops everything on logout, so one user's automation chatter
// can never be read by the next person on a shared browser.
function agentResetFeeds() {
  AGENT.feeds = Object.create(null);
  AGENT.unread = 0;
  agentNotify();
}

// ---- kill preview: the server's own dry run ----
//
// apiKillPreview asks what a press WOULD act on, and it executes nothing: no
// revoke, no cancel, no close, no budget spent, and it does not take the
// per-token kill lock (internal/frontsrv/agentkill.go, killDryRun). Asked at
// level 4, so one request answers both L3's question (`cancelled`) and L4's
// (`closed`).
//
// ——— its own route, and never a flag on the kill ———
//
// The static SPA and the Concierge binary do not move together: the local stack
// bind-mounts web/terminal, so merging reaches the browser immediately while the
// container keeps the old binary until someone rebuilds it. encoding/json
// ignores fields a build does not know, so `{"dryRun":true}` posted to
// /api/agent-sessions/kill on an older backend does not preview — it FLATTENS
// THE ACCOUNT. An unrecognised ROUTE is a 404: inert, and something this
// function can degrade on.
//
// For the same reason there is deliberately NO fallback below. If the preview
// route answers anything other than a preview, the panel says it could not
// check; it must never re-ask the destructive route "just for the numbers".
//
// This used to be counted in the browser, from LIVE.orders / LIVE.positions,
// and it was wrong in both directions. LIVE holds the SELECTED ACCOUNT only,
// while the panel lists tokens across every account the human holds — so a
// runaway bot on the account they were not watching previewed {0,0}, and since
// every preview element was gated on `> 0` the L4 dialog rendered with no
// warning box at all, pixel-identical to killing an idle token. On the account
// it could see, it overstated instead: the client cannot apply the sweep's role
// deny-list (an S/L, T/P or CLOSE order is never cancelled, and the WS order
// row carries no role) or its close_pending skip, so "3 orders would be
// cancelled" would be followed by "1 order cancelled" and read as two orders
// still live.
//
// One enumeration on the server decides both the preview and the action, so
// they cannot disagree.
const apiKillPreview = (tokenId) =>
  apiJSON('POST', '/api/agent-sessions/kill/preview', { tokenId, level: 4 });

// useKillPreview runs that dry run once per dialog and reports its state
// HONESTLY: `loading` and `err` are distinct from a zero count, because "the
// server says nothing is open" and "I could not ask" must never render the
// same. The caller shows text in those two cases — never a silent zero.
function useKillPreview(tokenId) {
  const [state, setState] = React.useState({ loading: true, err: null, orders: 0, positions: 0 });
  React.useEffect(() => {
    if (!tokenId) { setState({ loading: false, err: null, orders: 0, positions: 0 }); return undefined; }
    let alive = true;
    setState({ loading: true, err: null, orders: 0, positions: 0 });
    apiKillPreview(tokenId).then(res => {
      if (!alive) return;
      // Only a GENUINE preview may produce a number. A 200 that is not one — a
      // static-file fallback, a proxy interstitial, some future build answering
      // differently — must degrade to "could not check", never to zero, which is
      // the one wrong answer this panel can give. `dryRun` is set by the preview
      // route and by nothing else, so it is the marker to test.
      if (!res || res.dryRun !== true || !Array.isArray(res.cancelled) || !Array.isArray(res.closed)) {
        setState({ loading: false, err: 'not a preview response', orders: 0, positions: 0 });
        return;
      }
      setState({
        loading: false, err: null,
        orders: res.cancelled.length,
        positions: res.closed.length,
        // A snapshot the server could not read is a failed preview even though
        // the request itself succeeded — otherwise an unreachable monolith
        // renders as "nothing to close".
        ...((res.failed || []).some(f => f.kind === 'snapshot') ? { err: 'snapshot' } : null),
      });
    }).catch(e => {
      // Includes the 404 an older backend gives for this route. No retry against
      // the kill endpoint — see above.
      if (alive) setState({ loading: false, err: String((e && e.message) || e), orders: 0, positions: 0 });
    });
    return () => { alive = false; };
  }, [tokenId]);
  return state;
}

// killPreviewText turns that state into one line of copy plus a semantic tone
// the two shells colour their own way. Shared because the DECISION — which of
// the four things to say — is the part that must not differ between the phone
// and the desktop; only the paint does.
//
// There is a line in every state, deliberately. The old panel rendered the
// count only when it was above zero, so "nothing is open" and "I never found
// out" were both drawn as empty space.
function killPreviewText(preview, kind) {
  const orders = kind === 'orders';
  if (preview.loading) return { text: t('agent.kill.preview.checking'), tone: 'mute' };
  if (preview.err) return { text: t('agent.kill.preview.unavailable'), tone: 'warn' };
  const n = orders ? preview.orders : preview.positions;
  if (n > 0) {
    return {
      text: tn(orders ? 'agent.kill.willCancel' : 'agent.kill.willClose', n),
      tone: orders ? 'warn' : 'danger',
    };
  }
  return { text: t(orders ? 'agent.kill.preview.noOrders' : 'agent.kill.preview.noPositions'), tone: 'mute' };
}

// copyText puts a string on the clipboard, falling back to the textarea trick
// where navigator.clipboard is unavailable (plain-http dev origins). Returns
// whether anything was copied, so the caller can tell the user the truth.
async function copyText(text) {
  try {
    if (navigator.clipboard && window.isSecureContext) {
      await navigator.clipboard.writeText(text);
      return true;
    }
  } catch (e) { /* fall through */ }
  const ta = document.createElement('textarea');
  try {
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.select();
    return document.execCommand('copy');
  } catch (e) {
    return false;
  } finally {
    // Always remove it. A throw between the append and the remove would
    // otherwise leave the plaintext token sitting in the DOM for the life of
    // the page — readable by anything that can walk it.
    if (ta.parentNode) ta.parentNode.removeChild(ta);
  }
}

// ============================================================================
// P6 — the UI-command bridge (PD11)
// ============================================================================
//
// `ui_cmd` frames let an agent drive the chart the human is watching, mark it
// up, and fill in the trade ticket. It CANNOT submit the ticket: nothing in
// this file calls apiPlaceOrder, and the prefill below sets form state and a
// banner, exactly as if the human had typed the numbers and then been told who
// really did.
//
// Its own store and its own listener set, for the reason AGENT has one: LIVE's
// notify() wakes every price-path subscriber, which on the desktop shell is up
// to twelve charts. A chart annotation that re-rendered the whole grid would
// undo the static/live split those charts are built around. Nothing here is on
// the tick path, so nothing here shares the tick path's listeners.
//
// Everything is keyed by a monotonic `seq`. The shells consume these as HINTS
// — the same shape desktop already uses for a clicked price (priceHint) —
// because a command is an event, not state: showing GBP/USD twice in a row
// must work, and it will not if the consumer diffs values.
const UICMD = {
  version: 0,
  listeners: new Set(),
  seq: 0,
  chart: null,   // {sym, tf, by, tokenId, at, seq} — last show_chart
  prefill: null, // {sym, side, vol, orderType, price, stopPrice, sl, tp, by, tokenId, at, seq}
  notes: Object.create(null), // sym → {levels, markers, key, by, tokenId, at}
};

function uiNotify() {
  UICMD.version++;
  UICMD.listeners.forEach(fn => fn(UICMD.version));
}

// useUiCommands subscribes a component to the UI-command store only.
function useUiCommands() {
  const [, setV] = React.useState(UICMD.version);
  React.useEffect(() => {
    const fn = (v) => setV(v);
    UICMD.listeners.add(fn);
    setV(UICMD.version); // a command can land between first render and this effect
    return () => UICMD.listeners.delete(fn);
  }, []);
  return UICMD;
}

// uiNotes returns the annotation set for one wire symbol, or null.
function uiNotes(backendSym) { return UICMD.notes[backendSym] || null; }

// uiClearNotes lets the human take their chart back. An agent can redraw, and
// that is fine — what matters is that the person looking at the chart is never
// stuck with someone else's marks on it.
function uiClearNotes(backendSym) {
  if (!UICMD.notes[backendSym]) return;
  delete UICMD.notes[backendSym];
  uiNotify();
}

// uiClearPrefill drops the agent-filled banner and its hint.
//
// Called when the human dismisses it AND after they submit, so the next ticket
// they open is theirs. Note what it does NOT do: it does not clear the ticket's
// values. Wiping the fields on dismiss would make "I have read this" destroy
// the thing they just read.
function uiClearPrefill() {
  if (!UICMD.prefill) return;
  UICMD.prefill = null;
  uiNotify();
}

// ---- the view-context provider (the reverse direction) ----
//
// get_view_context is the one place the terminal SPEAKS on this socket, which
// was receive-only until P6. Each shell registers a function returning what it
// currently has on screen; api.jsx owns the wire format so the two shells
// cannot answer in two shapes.
let viewContextProvider = null;
function registerViewContext(fn) {
  viewContextProvider = fn;
  return () => { if (viewContextProvider === fn) viewContextProvider = null; };
}

// answerViewContext replies to one correlated request.
//
// Silence is a legitimate answer and the edge handles it — it 504s after 2s
// with `no_terminal_connected`, which is the honest response when this tab
// cannot say what it is showing. So a missing provider or a throwing one sends
// nothing rather than an empty shape a caller would read as fact.
function answerViewContext(corrId) {
  if (!corrId || !viewContextProvider) return;
  const ws = LIVE.ws;
  if (!ws || ws.readyState !== 1) return;
  let view = null;
  try { view = viewContextProvider(); } catch (e) { return; }
  if (!view || !view.sym) return;
  try {
    ws.send(JSON.stringify({
      t: 'ui_ctx', corrId,
      sym: view.sym, tf: view.tf || '',
      selection: view.selection || {},
    }));
  } catch (e) { /* socket died mid-answer; the edge times out */ }
}

// uiAnnotationKey is the PRIMITIVE digest the desktop chart's memo comparator
// compares.
//
// This is load-bearing rather than an optimisation detail. ChartStatic is
// wrapped in React.memo with a hand-written comparator, and the annotation
// arrays are new object identities on every frame — comparing them by identity
// would repaint twelve charts on every tick, which is precisely the cost the
// static/live split exists to avoid. So the arrays travel with a string that
// changes only when their CONTENTS change.
function uiAnnotationKey(levels, markers) {
  const l = levels.map(v => v.price + '' + (v.label || '') + '' + (v.color || '')).join('|');
  const m = markers.map(v => v.time + '' + (v.price || 0) + '' + (v.label || '')).join('|');
  return l + '' + m;
}

// uiCmdReceived handles one ui_cmd frame.
//
// `tokenName` is REQUIRED on the wire (WsUiCmdFrame), because the shells render
// it over anything an agent filled in. The fallback below is defence against a
// server that breaks that promise, not an expected path — and it deliberately
// still names an agent rather than rendering nothing, because the failure mode
// this whole mechanism exists to prevent is a human believing they composed a
// ticket they did not.
function uiCmdReceived(msg) {
  const by = msg.tokenName || t('agent.unnamedSession');
  const tokenId = msg.tokenId || 0;
  const args = msg.args || {};
  const at = Date.now();

  switch (msg.command) {
    case 'get_view_context':
      answerViewContext(msg.corrId);
      return; // changes nothing on screen, so nothing to notify

    case 'show_chart': {
      const inst = SYM_BY_BACKEND[args.sym];
      if (!inst) return;
      UICMD.chart = { sym: inst.sym, tf: args.tf || '', by, tokenId, at, seq: ++UICMD.seq };
      break;
    }

    case 'annotate_chart': {
      const inst = SYM_BY_BACKEND[args.sym];
      if (!inst) return;
      const levels = Array.isArray(args.levels) ? args.levels : [];
      const markers = Array.isArray(args.markers) ? args.markers : [];
      if (!levels.length && !markers.length) {
        delete UICMD.notes[args.sym];
      } else {
        UICMD.notes[args.sym] = {
          levels, markers, key: uiAnnotationKey(levels, markers), by, tokenId, at,
        };
      }
      break;
    }

    case 'prefill_ticket': {
      const inst = SYM_BY_BACKEND[args.sym];
      if (!inst) return;
      UICMD.prefill = {
        sym: inst.sym, side: args.side === 'sell' ? 'sell' : 'buy',
        vol: Number(args.vol) || 0,
        // Wire UPPER ("MARKET") → the shells' title case ("Market"). The two
        // casings are a documented trap of this API and the conversion belongs
        // here, once, rather than in each ticket.
        orderType: TICKET_TYPE_BY_WIRE[String(args.order_type || 'MARKET').toUpperCase()] || 'Market',
        price: Number(args.price) || 0, stopPrice: Number(args.stop_price) || 0,
        sl: Number(args.sl) || 0, tp: Number(args.tp) || 0,
        by, tokenId, at, seq: ++UICMD.seq,
      };
      // A toast as well as the banner. The banner is on the ticket and the
      // human may be looking somewhere else entirely; the toast is what makes
      // them look. Both name the agent.
      pushToast('info', t('agent.prefill.toast.title', { name: by }),
        t('agent.prefill.toast.body', {
          side: UICMD.prefill.side === 'sell' ? t('agent.prefill.sell') : t('agent.prefill.buy'),
          vol: UICMD.prefill.vol, sym: inst.sym,
        }));
      break;
    }

    default:
      return; // unknown commands are ignored, never guessed at
  }
  uiNotify();
}

const TICKET_TYPE_BY_WIRE = { MARKET: 'Market', LIMIT: 'Limit', STOP: 'Stop' };

Object.assign(window, {
  useUiCommands, uiNotes, uiClearNotes, uiClearPrefill, registerViewContext,
  CATEGORIES, CAT_ICONS, computePL, pipValue, marginEstimate, usdValue, quoteToUSD, pushToast,
  apiAgentSessions, apiMintAgentSession, apiRevokeAgentSession, copyText,
  apiKillAgentSession, apiLoadAgentMessages, agentFeed, useAgentStore,
  agentMarkRead, apiKillPreview, useKillPreview, killPreviewText,
  initInstruments, useLive, apiLogin, apiRegister, apiResumeSession, apiSwitchAccount, apiLogout,
  apiPlaceOrder, apiClosePosition, apiCancelOrder,
  apiModifyPosition, apiModifyOrder,
  apiCandles, apiDepth, apiSpec,
  apiDeposit, apiWithdraw, apiTransfer, apiReset, apiOpenAccount,
  apiLoadFavorites, apiSaveFavorites, apiLoadLocale, apiSaveLocale,
  get INSTRUMENTS() { return INSTRUMENTS; },
});
