// Live candlestick + volume chart — canvas, ticks in realtime
//
// Polish (2026-04-27):
//   - Right-side price axis (PRICE_GUTTER ~56px) drawn inside the canvas.
//     Price labels at each grid level, latest-price ribbon at last close y.
//   - TradingView-style crosshair on hover: dashed vertical + horizontal
//     lines, time pill below the chart at cursor X, price pill in the
//     right gutter at cursor Y, and an OHLCV chip in the top-left of the
//     canvas showing the candle under the cursor.
//   - Crosshair on the volume strip too — vertical line stays in sync
//     with the price chart.
//   - Wheel zoom inside chart, page scroll outside (preserved).
//   - Live candles from window.MMM_LIVE.candle_buffer (preserved).
//   - i18n: session legend + LIVE/CLOSED badges read
//     document.documentElement.lang.
//
const { useRef, useEffect, useState, useMemo } = React;

// ─── Layout constants ──────────────────────────────────────────────
const PRICE_GUTTER = 56; // reserved right strip for price labels + price pill
const X_AXIS_H = 18;     // bottom strip for time labels + time pill

// ─── i18n ───────────────────────────────────────────────────────────
const STR_chart = {
  en: {
    asia: 'Asia', london: 'London', ny: 'NY', extended: 'Extended',
    live_badge: 'LIVE', closed_badge: 'CLOSED',
    o: 'O', h: 'H', l: 'L', c: 'C', v: 'V',
  },
  es: {
    asia: 'Asia', london: 'Londres', ny: 'NY', extended: 'Extendido',
    live_badge: 'EN VIVO', closed_badge: 'CERRADO',
    o: 'A', h: 'M', l: 'm', c: 'C', v: 'V',
  },
};
function t_chart(key) {
  const lang = (document.documentElement.lang || 'en').toLowerCase().split('-')[0];
  return (STR_chart[lang] && STR_chart[lang][key]) || STR_chart.en[key] || key;
}

// Seeded RNG for the cold-load placeholder series only
function mulberry32(a) {
  return function() {
    let t = a += 0x6D2B79F5;
    t = Math.imul(t ^ t >>> 15, t | 1);
    t ^= t + Math.imul(t ^ t >>> 7, t | 61);
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}

function generatePlaceholderCandles(count, seed = 7) {
  const rng = mulberry32(seed);
  let price = 26050;
  const candles = [];
  const now = Date.now();
  for (let i = 0; i < count; i++) {
    const open = price;
    const drift = (rng() - 0.47) * 12;
    const close = open + drift + Math.sin(i / 8) * 3;
    const high = Math.max(open, close) + rng() * 6;
    const low = Math.min(open, close) - rng() * 6;
    const vol = Math.floor(200 + rng() * 900 + Math.abs(drift) * 30);
    const t = new Date(now - (count - i) * 120000).toISOString();
    candles.push({ t, o: open, h: high, l: low, c: close, v: vol });
    price = close;
  }
  return candles;
}

// ─── Session helpers ────────────────────────────────────────────────
function sessionForETHour(hourMinET) {
  // Session boundaries per Samu's schedule (display + chart band only —
  // the bot's internal session config in nq_bot_vscode/config/constants.py
  // is NOT bound to these values).
  //   Asia    18:00 – 02:00 ET (spans midnight)
  //   London  02:00 – 08:00 ET
  //   NY      08:00 – 17:00 ET
  //   Maint   17:00 – 18:00 ET (1-hour CME daily break)
  if (hourMinET >= 1800 || hourMinET < 200) return 'asia';
  if (hourMinET >= 200 && hourMinET < 800) return 'london';
  if (hourMinET >= 800 && hourMinET < 1700) return 'ny';
  if (hourMinET >= 1700 && hourMinET < 1800) return 'maint';
  return 'extended';
}
function etHourMin(input) {
  const d = typeof input === 'string' ? new Date(input) : input;
  if (Number.isNaN(d.getTime())) return 0;
  const fmt = new Intl.DateTimeFormat('en-US', {
    timeZone: 'America/New_York', hour12: false, hour: '2-digit', minute: '2-digit'
  });
  const parts = fmt.formatToParts(d);
  let h = 0, m = 0;
  for (const p of parts) {
    if (p.type === 'hour') h = parseInt(p.value, 10) || 0;
    if (p.type === 'minute') m = parseInt(p.value, 10) || 0;
  }
  return h * 100 + m;
}
// PR-SIGNALS-1B (2026-05-22): date-aware key for cross-day signal matching.
// Returns "YYYY-MM-DD-HHMM" in America/New_York or null if input is bad.
// Used to match a decision (carrying full ISO ts from publisher) to the
// candle in its own date, rather than just same-HH:MM on a different day.
function etDateMinKey(input) {
  const d = typeof input === 'string' ? new Date(input) : input;
  if (!d || Number.isNaN(d.getTime())) return null;
  const fmt = new Intl.DateTimeFormat('en-US', {
    timeZone: 'America/New_York', hour12: false,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit',
  });
  const parts = fmt.formatToParts(d);
  let y = '', mo = '', dd = '', h = '', mm = '';
  for (const p of parts) {
    if (p.type === 'year') y = p.value;
    else if (p.type === 'month') mo = p.value;
    else if (p.type === 'day') dd = p.value;
    else if (p.type === 'hour') h = p.value;
    else if (p.type === 'minute') mm = p.value;
  }
  if (!y || !mo || !dd || !h || !mm) return null;
  return y + '-' + mo + '-' + dd + '-' + h + mm;
}
function candleSession(candle) { return sessionForETHour(etHourMin(candle.t)); }
function currentSessionET() { return sessionForETHour(etHourMin(new Date())); }
function isMarketOpenSession(s) { return s === 'asia' || s === 'london' || s === 'ny'; }

function sessionFill(session, isActive) {
  // PR ui-polish: align chart fill RGB with CSS pill RGB so the
  // chart bands and the top-right session pill always match. The
  // exact RGB triplets here mirror styles.css .session-key__item
  // (and .session-zone) for asia/london/ny. Previously the chart
  // used different blues/purples and asia/london ended up swapped
  // visually relative to the legend pills.
  const alpha = isActive ? 0.18 : 0.08;
  const colors = {
    asia:     `rgba(139, 76, 126, ${alpha})`,
    london:   `rgba(90, 111, 216, ${alpha})`,
    ny:       `rgba(217, 119, 87, ${alpha})`,
    extended: `rgba(168, 156, 137, ${alpha * 0.6})`,
    maint:    `rgba(168, 156, 137, ${alpha * 0.4})`,
  };
  return colors[session] || 'rgba(0,0,0,0)';
}

// HH:MM ET (short form, used for axis ticks)
function fmtETShort(input) {
  const d = typeof input === 'string' ? new Date(input) : input;
  if (Number.isNaN(d.getTime())) return '';
  return d.toLocaleTimeString('en-US', {
    timeZone: 'America/New_York', hour12: false, hour: '2-digit', minute: '2-digit'
  });
}
// HH:MM:SS ET (used for the crosshair time pill)
function fmtETLong(input) {
  const d = typeof input === 'string' ? new Date(input) : input;
  if (Number.isNaN(d.getTime())) return '';
  return d.toLocaleTimeString('en-US', {
    timeZone: 'America/New_York', hour12: false,
    hour: '2-digit', minute: '2-digit', second: '2-digit'
  });
}

// ─── Signal marker helpers ────────────────────────────────────────
// Parse "12:37:05 ET" or "12:37 ET" → { hh, mm }, ignoring seconds
// (signals fire at :05 within the 1-minute candle).
function parseETSignalTime(str) {
  if (!str || typeof str !== 'string') return null;
  const m = /^(\d{1,2}):(\d{2})/.exec(str);
  if (!m) return null;
  return { hh: parseInt(m[1], 10), mm: parseInt(m[2], 10) };
}

// Build a Map from "HHMM" (ET) → candle index, scoped to the visible window.
function indexCandlesByETMinute(visible) {
  const map = new Map();
  for (let i = 0; i < visible.length; i++) {
    const hm = etHourMin(visible[i].t);
    map.set(hm, i);
  }
  return map;
}

// PR-SIGNALS-1B (2026-05-22): Build a Map from "YYYY-MM-DD-HHMM" (ET) →
// candle index. Used by the marker layer when a decision has a full ts
// field; lets signals from yesterday match yesterday's candle rather than
// today's same-HH:MM candle.
function indexCandlesByETDateMin(visible) {
  const map = new Map();
  for (let i = 0; i < visible.length; i++) {
    const key = etDateMinKey(visible[i].t);
    if (key) map.set(key, i);
  }
  return map;
}

// ─── Chart component ────────────────────────────────────────────────
function LiveChart({ symbol = '', dimmed = false }) {
  const canvasRef = useRef(null);
  const volumeRef = useRef(null);
  const wrapRef = useRef(null);

  // Read the actual contract being traded from live_stats.json. Falls back
  // to whatever was passed via prop, then to a generic "NQ" so we never
  // hardcode a specific expiry that may already be stale.
  const liveSymbol = (() => {
    const live = (typeof window !== 'undefined' && window.MMM_LIVE) || {};
    return live.contract || live.symbol || symbol || 'NQ';
  })();
  const volWrapRef = useRef(null);

  const [candles, setCandles] = useState(() => generatePlaceholderCandles(120));
  const [activeSession, setActiveSession] = useState(() => currentSessionET());
  const [visibleCount, setVisibleCount] = useState(() => 120);
  const [hover, setHover] = useState(null); // { x, y, candle, price } in CSS px relative to wrap
  const [hoveredMarker, setHoveredMarker] = useState(null); // marker tooltip state
  // Ref persists marker positions across renders so the mousemove handler
  // can hit-test against them without triggering its own re-render loop.
  const markersRef = useRef([]);

  // ─── Live wiring ─────────────────────────────────────────────────
  useEffect(() => {
    const onUpdate = () => {
      const live = window.MMM_LIVE;
      if (live && Array.isArray(live.candle_buffer) && live.candle_buffer.length > 0) {
        setCandles(live.candle_buffer);
      }
      const sess = currentSessionET();
      setActiveSession(sess);
      window.MMM_LIVE_SESSION = sess;
      window.MMM_MARKET_OPEN = isMarketOpenSession(sess);
      window.dispatchEvent(new CustomEvent('mmm-session-update', {
        detail: { session: sess, marketOpen: isMarketOpenSession(sess) }
      }));
    };
    window.addEventListener('mmm-live-update', onUpdate);
    onUpdate();
    const minTick = setInterval(onUpdate, 60000);
    return () => {
      window.removeEventListener('mmm-live-update', onUpdate);
      clearInterval(minTick);
    };
  }, []);

  // ─── Wheel zoom inside chart ─────────────────────────────────────
  useEffect(() => {
    const wrap = wrapRef.current;
    if (!wrap) return;
    const onWheel = (e) => {
      e.preventDefault();
      const factor = e.deltaY > 0 ? 1.15 : (1 / 1.15);
      setVisibleCount(prev => {
        const next = Math.round(prev * factor);
        return Math.max(20, Math.min(candles.length, next));
      });
    };
    wrap.addEventListener('wheel', onWheel, { passive: false });
    return () => wrap.removeEventListener('wheel', onWheel);
  }, [candles.length]);

  useEffect(() => {
    setVisibleCount(prev => Math.max(20, Math.min(candles.length || 120, prev || 120)));
  }, [candles.length]);

  const visible = useMemo(() => {
    const n = Math.max(20, Math.min(candles.length, visibleCount));
    return candles.slice(-n);
  }, [candles, visibleCount]);

  // ─── Memo'd y-range so hover handler can use the same scale ──────
  const range = useMemo(() => {
    if (!visible.length) return { hi: 1, lo: 0, vmax: 0 };
    let hi = -Infinity, lo = Infinity, vmax = 0;
    for (const c of visible) {
      if (c.h > hi) hi = c.h;
      if (c.l < lo) lo = c.l;
      if (c.v > vmax) vmax = c.v;
    }
    const pad = (hi - lo) * 0.08;
    return { hi: hi + pad, lo: lo - pad, vmax };
  }, [visible]);

  // ─── Mouse handlers (crosshair) ──────────────────────────────────
  const onWrapMouseMove = (e) => {
    const wrap = wrapRef.current;
    if (!wrap || !visible.length) return;
    const rect = wrap.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;
    const Wcss = rect.width;
    const Hcss = rect.height;
    const drawW = Math.max(1, Wcss - PRICE_GUTTER);
    const drawH = Math.max(1, Hcss - X_AXIS_H);
    // Outside the drawable area? Hide crosshair.
    if (x < 0 || x > drawW || y < 0 || y > drawH) {
      if (hover) setHover(null);
      return;
    }
    const cw = drawW / visible.length;
    const idx = Math.max(0, Math.min(visible.length - 1, Math.floor(x / cw)));
    const candle = visible[idx];
    // y → price (inverse of yOf)
    const span = (range.hi - range.lo) || 1;
    const inner = drawH - 8;
    const price = range.hi - ((y - 4) / inner) * span;
    setHover({ x, y, idx, candle, price, drawW, drawH });
    // Marker hit-testing — hover within ~9px of any marker shows its tooltip.
    const HIT_R2 = 81; // 9px squared
    let hitMarker = null;
    for (const m of markersRef.current) {
      const dx = m.x - x;
      const dy = m.y - y;
      if (dx*dx + dy*dy <= HIT_R2) { hitMarker = m; break; }
    }
    setHoveredMarker(hitMarker);
  };
  const onWrapMouseLeave = () => { setHover(null); setHoveredMarker(null); };

  // ─── Draw ─────────────────────────────────────────────────────────
  useEffect(() => {
    const cvs = canvasRef.current;
    const vcvs = volumeRef.current;
    if (!cvs || !vcvs) return;

    const dpr = window.devicePixelRatio || 1;
    const wrap = cvs.parentElement;
    const Wcss = wrap.clientWidth;
    const Hcss = cvs.clientHeight;
    cvs.width = Wcss * dpr; cvs.height = Hcss * dpr;
    const ctx = cvs.getContext('2d'); ctx.scale(dpr, dpr);

    const Hvol = vcvs.clientHeight;
    vcvs.width = Wcss * dpr; vcvs.height = Hvol * dpr;
    const vctx = vcvs.getContext('2d'); vctx.scale(dpr, dpr);

    if (!visible.length) return;

    const drawW = Wcss - PRICE_GUTTER;
    const drawH = Hcss - X_AXIS_H;

    const { hi, lo, vmax } = range;
    const span = hi - lo || 1;
    const cw = drawW / visible.length;
    const bw = Math.max(1, cw * 0.6);
    const yOf = (price) => ((hi - price) / span) * (drawH - 8) + 4;

    // Background
    ctx.fillStyle = getCSSVar('--surface', '#fffaf3');
    ctx.fillRect(0, 0, Wcss, Hcss);

    // ─── Session zones (behind candles, only over draw area) ──────
    if (visible.length > 0) {
      let runStart = 0;
      let runSession = candleSession(visible[0]);
      for (let i = 1; i <= visible.length; i++) {
        const sess = i < visible.length ? candleSession(visible[i]) : null;
        if (sess !== runSession) {
          const x0 = runStart * cw;
          const x1 = i * cw;
          ctx.fillStyle = sessionFill(runSession, runSession === activeSession);
          ctx.fillRect(x0, 0, x1 - x0, drawH);
          if (sess !== null) { runStart = i; runSession = sess; }
        }
      }
    }

    // ─── Grid lines ───────────────────────────────────────────────
    ctx.strokeStyle = getCSSVar('--rule', '#e8dcc7');
    ctx.lineWidth = 1;
    for (let i = 0; i <= 4; i++) {
      const y = (drawH / 4) * i;
      ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(drawW, y); ctx.stroke();
    }
    // bottom rule above x-axis strip
    ctx.beginPath(); ctx.moveTo(0, drawH); ctx.lineTo(Wcss, drawH); ctx.stroke();
    // left rule of price gutter
    ctx.beginPath(); ctx.moveTo(drawW, 0); ctx.lineTo(drawW, drawH); ctx.stroke();

    // ─── Candles ──────────────────────────────────────────────────
    const gain = getCSSVar('--gain', '#2e7d4f');
    const loss = getCSSVar('--loss', '#b93424');
    visible.forEach((c, i) => {
      const x = i * cw + cw / 2;
      const isUp = c.c >= c.o;
      const color = isUp ? gain : loss;
      ctx.strokeStyle = color; ctx.fillStyle = color;
      // wick
      ctx.beginPath();
      ctx.moveTo(x, yOf(c.h));
      ctx.lineTo(x, yOf(c.l));
      ctx.stroke();
      // body
      const yo = yOf(c.o), yc = yOf(c.c);
      const top = Math.min(yo, yc);
      const bh = Math.max(1, Math.abs(yc - yo));
      ctx.fillRect(x - bw/2, top, bw, bh);
    });

    // ─── Right-side price axis ────────────────────────────────────
    ctx.fillStyle = getCSSVar('--muted', '#8a7d68');
    ctx.font = `10px ${getCSSVar('--font-mono', 'JetBrains Mono, monospace')}`;
    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    for (let i = 0; i <= 4; i++) {
      const y = (drawH / 4) * i;
      const price = hi - (i / 4) * span;
      const label = price.toFixed(2);
      ctx.fillText(label, drawW + 6, y);
    }

    // ─── Last-price ribbon in price gutter ────────────────────────
    const lastC = visible[visible.length - 1];
    if (lastC) {
      const lastY = yOf(lastC.c);
      const isUp = lastC.c >= lastC.o;
      ctx.fillStyle = isUp ? gain : loss;
      ctx.fillRect(drawW, lastY - 9, PRICE_GUTTER, 18);
      ctx.fillStyle = '#fff';
      ctx.font = `600 10px ${getCSSVar('--font-mono', 'JetBrains Mono, monospace')}`;
      ctx.textAlign = 'left';
      ctx.fillText(lastC.c.toFixed(2), drawW + 6, lastY);
    }

    // ─── Phase 6B: OCO bracket lines (drawn when bot is in a trade) ─
    // Mirrors how IBKR / TradingView render working orders: 3 horizontal
    // dashed lines (stop / entry / C2 target) with right-edge price tags.
    // Pulls from window.MMM_LIVE.active_trade -- populated by Phase 6A.
    // Hides when null. SHORT trades draw the same 3 lines; the geometry
    // naturally inverts because target < entry < stop on a SHORT.
    const at = (typeof window !== 'undefined' && window.MMM_LIVE && window.MMM_LIVE.active_trade) || null;
    if (at && at.entry_price) {
      const entryPx  = Number(at.entry_price)   || 0;
      const stopPx   = Number(at.current_stop)  || 0;
      const origStop = Number(at.original_stop || at.stop_price || stopPx) || 0;
      const targetPx = Number(at.target_price)  || 0;
      // BE detected when the live stop has moved off the original.
      const stopAtBE = stopPx > 0 && origStop > 0 && Math.abs(stopPx - origStop) > 0.001;
      const muted = getCSSVar('--muted', '#8a7d68');
      const surface = getCSSVar('--surface', '#fffaf3');

      const drawBracketLine = (price, color, label, filled) => {
        if (!price || !isFinite(price) || price < lo || price > hi) return;
        const y = yOf(price);
        ctx.save();
        // Dashed horizontal line across the draw area
        ctx.strokeStyle = color;
        ctx.lineWidth = 1;
        ctx.setLineDash([6, 4]);
        ctx.beginPath();
        ctx.moveTo(0, y);
        ctx.lineTo(drawW, y);
        ctx.stroke();
        ctx.setLineDash([]);
        // Right-edge price tag (filled = solid color, hollow = outlined)
        if (filled) {
          ctx.fillStyle = color;
          ctx.fillRect(drawW, y - 11, PRICE_GUTTER, 22);
          ctx.fillStyle = '#fff';
        } else {
          ctx.fillStyle = surface;
          ctx.fillRect(drawW, y - 11, PRICE_GUTTER, 22);
          ctx.strokeStyle = color;
          ctx.lineWidth = 1;
          ctx.strokeRect(drawW + 0.5, y - 10.5, PRICE_GUTTER - 1, 21);
          ctx.fillStyle = color;
        }
        // Price text inside the tag
        ctx.font = `600 10px ${getCSSVar('--font-mono', 'JetBrains Mono, monospace')}`;
        ctx.textAlign = 'left';
        ctx.textBaseline = 'middle';
        ctx.fillText(price.toFixed(2), drawW + 6, y);
        // Tiny label above the tag (STOP / ENTRY / C2 TGT)
        if (label) {
          ctx.fillStyle = color;
          ctx.font = `9px ${getCSSVar('--font-mono', 'JetBrains Mono, monospace')}`;
          ctx.fillText(label, drawW + 6, y - 14);
        }
        ctx.restore();
      };

      // Stop -- color flips to gain when BE-or-better; label flips too.
      const stopColor = stopAtBE ? gain : loss;
      const stopLabel = stopAtBE ? 'STOP / BE' : 'STOP';
      drawBracketLine(stopPx, stopColor, stopLabel, false);

      // Entry -- neutral muted color (always present)
      drawBracketLine(entryPx, muted, 'ENTRY', false);

      // C2 target -- filled green tag, most actionable
      drawBracketLine(targetPx, gain, 'C2 TGT', true);
    }

    // ─── Signal markers (entries + exits) ───────────────────────
    // Pull from window.MMM_LIVE.recent_decisions for the marker set,
    // and cross-reference with trade_events for explicit price + stop +
    // c1_target + score so the hover tooltip can show full bracket info.
    const live = (typeof window !== 'undefined' && window.MMM_LIVE) || {};
    const decisions = Array.isArray(live.recent_decisions) ? live.recent_decisions : [];
    const tradeEvents = Array.isArray(live.trade_events) ? live.trade_events : [];
    const markerHits = []; // captured for the mousemove hit-test
    if (decisions.length > 0 && visible.length > 0) {
      const candleByMin = indexCandlesByETMinute(visible);
      // PR-SIGNALS-1B (2026-05-22): also build a date-aware index so signals
      // from earlier days (publisher emits 48h of recent_decisions) match
      // candles on their actual date rather than today's same HH:MM.
      const candleByDateMin = indexCandlesByETDateMin(visible);
      ctx.save();
      ctx.lineWidth = 1.5;
      for (const d of decisions) {
        const decType = (d.decision || '').toString().toUpperCase();
        const dirRaw = (d.direction || '').toString().toUpperCase();
        // PR-SIGNALS-UX-1 (2026-05-25): chart markers show ONLY actually-filled
        // trades + their EXIT events. The firehose of "considered but didn't
        // fill" signals made it look like the bot was buying/selling every
        // candle. EXIT events are always real (they only fire on actual closes).
        // APPROVED rows get rendered only if execution_status === 'FILLED' --
        // suppressing APPROVED-but-CANCELLED, APPROVED-but-BLOCKED, and PENDING.
        if (decType === 'EXIT') {
          // always render exit
        } else if (decType === 'APPROVED') {
          const es = (d.execution_status || '').toString().toUpperCase();
          if (es !== 'FILLED') continue;
        } else {
          continue;  // PENDING or anything else -- hide
        }
        // PR-SIGNALS-1B: prefer date-aware match when d.ts (full ISO) is
        // available, fall back to HH:MM matching for legacy records.
        let idx;
        let t = null;
        if (d.ts) {
          const dateKey = etDateMinKey(d.ts);
          if (dateKey) idx = candleByDateMin.get(dateKey);
          // Also extract hh/mm for downstream tradeEvents cross-reference.
          const dObj = new Date(d.ts);
          if (!Number.isNaN(dObj.getTime())) {
            const hm = etHourMin(dObj);
            t = { hh: Math.floor(hm / 100), mm: hm % 100 };
          }
        }
        if (idx === undefined) {
          t = t || parseETSignalTime(d.time);
          if (!t) continue;
          idx = candleByMin.get(t.hh * 100 + t.mm);
        }
        if (idx === undefined) continue;
        const candle = visible[idx];
        const x = idx * cw + cw / 2;
        const yClose = yOf(candle.c);
        const isLong = dirRaw === 'LONG';
        const isShort = dirRaw === 'SHORT';
        // Cross-reference with trade_events: same direction + same minute (ET).
        const matched = tradeEvents.find(e => {
          if (!e || !e.time_et || !e.direction) return false;
          if (e.direction.toUpperCase() !== dirRaw) return false;
          const et = parseETSignalTime(e.time_et);
          return et && et.hh === t.hh && et.mm === t.mm;
        });
        // Default marker hit position (used by tooltip + hit-test)
        let markerX = x;
        let markerY = yClose;
        if (decType === 'APPROVED') {
          const color = isLong ? gain : (isShort ? loss : getCSSVar('--accent', '#d97757'));
          const halfBase = 5;
          const triH = 8;
          ctx.fillStyle = color;
          ctx.strokeStyle = '#fff';
          ctx.beginPath();
          if (isLong) {
            const apexY = yOf(candle.l) + 12;
            ctx.moveTo(x, apexY - triH);
            ctx.lineTo(x - halfBase, apexY);
            ctx.lineTo(x + halfBase, apexY);
            markerY = apexY - triH / 2;
          } else {
            const apexY = yOf(candle.h) - 12;
            ctx.moveTo(x, apexY + triH);
            ctx.lineTo(x - halfBase, apexY);
            ctx.lineTo(x + halfBase, apexY);
            markerY = apexY + triH / 2;
          }
          ctx.closePath();
          ctx.fill();
          ctx.stroke();
        } else if (decType === 'EXIT') {
          ctx.fillStyle = getCSSVar('--accent', '#d97757');
          ctx.strokeStyle = '#fff';
          const r = 5;
          ctx.beginPath();
          ctx.moveTo(x, yClose - r);
          ctx.lineTo(x + r, yClose);
          ctx.lineTo(x, yClose + r);
          ctx.lineTo(x - r, yClose);
          ctx.closePath();
          ctx.fill();
          ctx.stroke();
        } else if (decType === 'PENDING') {
          ctx.fillStyle = '#fff';
          ctx.strokeStyle = isLong ? gain : (isShort ? loss : getCSSVar('--muted', '#8a7d68'));
          ctx.beginPath();
          ctx.arc(x, yClose, 3, 0, Math.PI * 2);
          ctx.fill();
          ctx.stroke();
        }
        // Save for hover hit-testing
        markerHits.push({
          x: markerX,
          y: markerY,
          decType, dirRaw,
          time: d.time,
          reason: d.reason,
          candleClose: candle.c,
          // Explicit fields when matched against trade_events
          price: matched ? matched.price : null,
          score: matched ? matched.score : null,
          stop_width: matched ? matched.stop_width : null,
          c1_target: matched ? matched.c1_target : null,
          c2_trail_start: matched ? matched.c2_trail_start : null,
        });
      }
      ctx.restore();
    }
    // Persist to ref for the mousemove handler.
    markersRef.current = markerHits;

    // ─── Time axis (HH:MM ET) ────────────────────────────────────
    ctx.fillStyle = getCSSVar('--muted', '#8a7d68');
    ctx.font = `10px ${getCSSVar('--font-mono', 'JetBrains Mono, monospace')}`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    const TICK_TARGET = Math.max(3, Math.min(7, Math.floor(drawW / 110)));
    const step = Math.max(1, Math.floor(visible.length / (TICK_TARGET - 1)));
    for (let i = 0; i < visible.length; i += step) {
      if (i === 0) continue;
      if (i > visible.length - Math.floor(step / 2)) continue;
      const x = i * cw + cw / 2;
      const label = fmtETShort(visible[i].t);
      if (label) ctx.fillText(label, x, drawH + (X_AXIS_H / 2) + 1);
    }

    // ─── Volume strip ────────────────────────────────────────────
    vctx.fillStyle = getCSSVar('--surface', '#fffaf3');
    vctx.fillRect(0, 0, Wcss, Hvol);
    visible.forEach((c, i) => {
      const x = i * cw;
      const h = (c.v / (vmax || 1)) * (Hvol - 4);
      const isUp = c.c >= c.o;
      vctx.fillStyle = (isUp ? gain : loss) + '88';
      vctx.fillRect(x + (cw - bw) / 2, Hvol - h - 2, bw, h);
    });
    // Volume strip — left rule of gutter (mirror chart layout)
    vctx.strokeStyle = getCSSVar('--rule', '#e8dcc7');
    vctx.lineWidth = 1;
    vctx.beginPath(); vctx.moveTo(drawW, 0); vctx.lineTo(drawW, Hvol); vctx.stroke();
  }, [visible, activeSession, range]);

  // ─── Header ──────────────────────────────────────────────────────
  const lastClose = candles.length ? candles[candles.length - 1].c : '—';
  const prevClose = candles.length > 1 ? candles[candles.length - 2].c : lastClose;
  const delta = (typeof lastClose === 'number' && typeof prevClose === 'number')
    ? lastClose - prevClose : 0;
  const deltaPct = (typeof lastClose === 'number' && typeof prevClose === 'number' && prevClose)
    ? (delta / prevClose) * 100 : 0;
  const isUpDelta = delta >= 0;
  const marketOpen = isMarketOpenSession(activeSession);

  const sessionLegend = [
    { key: 'asia', label: t_chart('asia') },
    { key: 'london', label: t_chart('london') },
    { key: 'ny', label: t_chart('ny') },
    { key: 'extended', label: t_chart('extended') },
  ];

  // ─── OHLC chip: from hovered candle, fall back to last ────────
  const chipCandle = (hover && hover.candle) || (candles.length ? candles[candles.length - 1] : null);
  const chipUp = chipCandle ? chipCandle.c >= chipCandle.o : true;

  return (
    <div className={`live-chart ${dimmed ? 'dimmed' : ''}`}>
      <div className="chart-header">
        <div>
          <span className="symbol">{liveSymbol}{' '}
            <span className="muted" style={{fontSize:'var(--fs-xs)', fontWeight:400}}>
              {marketOpen ? t_chart('live_badge') : t_chart('closed_badge')}
            </span>
          </span>
          <span className="price mono" style={{marginLeft:8}}>
            {typeof lastClose === 'number' ? lastClose.toFixed(2) : '—'}
          </span>
          <span className={`delta ${isUpDelta ? 'up':'down'} mono`} style={{marginLeft:6}}>
            {isUpDelta ? '+' : ''}{delta.toFixed(2)} ({isUpDelta ? '+' : ''}{deltaPct.toFixed(2)}%)
          </span>
        </div>
      </div>

      <div
        className="chart-canvas-wrap"
        ref={wrapRef}
        onMouseMove={onWrapMouseMove}
        onMouseLeave={onWrapMouseLeave}
      >
        <canvas ref={canvasRef} />

        {/* OHLC chip — top-left */}
        {chipCandle && (
          <div className="ohlc-chip mono">
            <span className="muted">{t_chart('o')}</span>
            <span>{chipCandle.o.toFixed(2)}</span>
            <span className="muted">{t_chart('h')}</span>
            <span>{chipCandle.h.toFixed(2)}</span>
            <span className="muted">{t_chart('l')}</span>
            <span>{chipCandle.l.toFixed(2)}</span>
            <span className="muted">{t_chart('c')}</span>
            <span className={chipUp ? 'up' : 'down'}>{chipCandle.c.toFixed(2)}</span>
            <span className="muted">{t_chart('v')}</span>
            <span>{chipCandle.v.toLocaleString()}</span>
          </div>
        )}

        {/* Crosshair overlay */}
        {hover && (
          <>
            <div className="crosshair-v" style={{ left: hover.x, height: hover.drawH }} />
            <div className="crosshair-h" style={{ top: hover.y, width: hover.drawW }} />
            <div className="crosshair-price-pill" style={{
              top: hover.y, left: hover.drawW
            }}>
              {hover.price.toFixed(2)}
            </div>
            <div className="crosshair-time-pill" style={{
              left: hover.x, top: hover.drawH
            }}>
              {fmtETLong(hover.candle.t)}
            </div>
          </>
        )}

        {/* Marker hover tooltip — shows full trade details when you hover
            on a triangle / diamond / circle. */}
        {hoveredMarker && (
          <div
            className={'marker-tooltip ' + (hoveredMarker.dirRaw === 'LONG' ? 'is-long' : (hoveredMarker.dirRaw === 'SHORT' ? 'is-short' : ''))}
            style={{
              left: Math.min(hoveredMarker.x + 14, (hover && hover.drawW) || 800),
              top: Math.max(8, hoveredMarker.y - 70),
            }}
          >
            <div className="marker-tooltip__header">
              <span className={'side ' + (hoveredMarker.dirRaw === 'LONG' ? 'long' : (hoveredMarker.dirRaw === 'SHORT' ? 'short' : ''))}>
                {hoveredMarker.dirRaw || '—'}
              </span>
              <span className="dec">{hoveredMarker.decType}</span>
              <span className="time mono">{hoveredMarker.time}</span>
            </div>
            <div className="marker-tooltip__rows">
              <div className="row"><span className="k">Entry</span><span className="v mono">{hoveredMarker.price != null ? hoveredMarker.price.toFixed(2) : hoveredMarker.candleClose.toFixed(2)}</span></div>
              {hoveredMarker.stop_width != null && (
                <div className="row"><span className="k">Stop width</span><span className="v mono">{hoveredMarker.stop_width.toFixed(2)} pts</span></div>
              )}
              {hoveredMarker.c1_target != null && (
                <div className="row"><span className="k">C1 target</span><span className="v mono">{hoveredMarker.c1_target.toFixed(2)}</span></div>
              )}
              {hoveredMarker.c2_trail_start != null && hoveredMarker.c2_trail_start !== 0 && (
                <div className="row"><span className="k">C2 trail</span><span className="v mono">{hoveredMarker.c2_trail_start.toFixed(2)}</span></div>
              )}
              {hoveredMarker.score != null && (
                <div className="row"><span className="k">Score</span><span className="v mono">{hoveredMarker.score.toFixed(3)}</span></div>
              )}
              {hoveredMarker.reason && (
                <div className="row"><span className="k">Reason</span><span className="v mono">{hoveredMarker.reason}</span></div>
              )}
            </div>
          </div>
        )}

        <div className="session-legend">
          {sessionLegend.map(s => (
            <span key={s.key} className="session-legend__item" style={{
              opacity: s.key === activeSession ? 1 : 0.45,
              fontWeight: s.key === activeSession ? 600 : 400,
              color: s.key === activeSession ? 'var(--ink)' : 'var(--muted)',
            }}>
              <span className="session-legend__swatch" style={{
                background: sessionFill(s.key, s.key === activeSession).replace(/[\d.]+\)$/, '0.7)'),
              }}></span>
              {s.label}
            </span>
          ))}
        </div>
      </div>
      <div className="volume-strip" ref={volWrapRef}>
        <span className="label">Volume</span>
        <canvas ref={volumeRef} />
        {/* Crosshair vertical line extends through volume strip */}
        {hover && (
          <div className="crosshair-v vol-strip-line" style={{ left: hover.x }} />
        )}
      </div>
    </div>
  );
}

function getCSSVar(name, fallback='') {
  if (typeof window === 'undefined') return fallback;
  const v = getComputedStyle(document.documentElement).getPropertyValue(name);
  return v.trim() || fallback;
}

window.LiveChart = LiveChart;
