import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import uPlot from 'uplot';
import * as echarts from 'echarts';
import { getSignalStore } from '../lib/stores';
import { getWidgetCmdStore } from '../lib/widgetCommands';
import { wsClient } from '../lib/ws';
import { formatValue } from '../lib/format';
import { fftMag, resampleUniform } from '../lib/fft';
import { PLOT_SYNC_KEY, subscribeXRange, broadcastXRange } from '../lib/plotSync';
import type { Widget, SignalRef } from '../lib/types';

interface Props {
  widget: Widget;
  onContextMenu?: (e: MouseEvent) => void;
  timeRange?: { start: string; end: string } | null;
}

interface SeriesBuffer {
  timestamps: number[];
  values: number[];
}

interface MeasureRow {
  name: string;
  color: string;
  va: number | null;
  vb: number | null;
}

interface MeasureReadout {
  a: number;        // cursor A time (seconds)
  b: number | null; // cursor B time (seconds), once placed
  rows: MeasureRow[];
}

const RING_MAX = 200_000;
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];

const ECHARTS_DARK = {
  backgroundColor: '#1a1f2e',
  textStyle: { color: '#94a3b8' },
  axisLineStyle: { color: '#475569' },
  axisLabelStyle: { color: '#64748b' },
  splitLineStyle: { color: '#2d3748' },
};

function pushSample(buf: SeriesBuffer, ts: number, val: number) {
  buf.timestamps.push(ts);
  buf.values.push(val);
  if (buf.timestamps.length > RING_MAX) {
    buf.timestamps.splice(0, buf.timestamps.length - RING_MAX);
    buf.values.splice(0, buf.values.length - RING_MAX);
  }
}

function windowedSlice(buf: SeriesBuffer, windowSec: number) {
  const cutoff = Date.now() / 1000 - windowSec;
  const start = buf.timestamps.findIndex(t => t >= cutoff);
  if (start === -1) return { ts: [] as number[], vals: [] as number[] };
  return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
}

// Compact display of a measured value (null → em-dash).
function fmtNum(v: number | null): string {
  if (v == null || !isFinite(v)) return '—';
  const a = Math.abs(v);
  if (a !== 0 && (a < 1e-3 || a >= 1e6)) return v.toExponential(3);
  return v.toFixed(Math.abs(v) >= 100 ? 1 : 3);
}

// Human-readable Δt between two cursors (seconds), with rate where useful.
function formatDt(dt: number): string {
  const a = Math.abs(dt);
  if (a < 1) return `${(dt * 1000).toFixed(1)} ms`;
  if (a < 60) return `${dt.toFixed(3)} s (${(1 / dt).toFixed(2)} Hz)`;
  if (a < 3600) return `${(dt / 60).toFixed(2)} min`;
  return `${(dt / 3600).toFixed(2)} h`;
}

// Step-hold value of a series at time t (seconds): the most recent sample whose
// timestamp is <= t. Used by the measurement cursors. Binary search over the
// (sorted) timestamp buffer.
function valueAt(buf: SeriesBuffer, t: number): number | null {
  const ts = buf.timestamps;
  if (ts.length === 0) return null;
  let lo = 0, hi = ts.length - 1, res = -1;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1;
    if (ts[mid] <= t) { res = mid; lo = mid + 1; }
    else hi = mid - 1;
  }
  if (res === -1) return null; // t is before the first sample
  return buf.values[res];
}

function buildHistogram(allVals: number[][]): { labels: string[]; counts: number[][] } {
  const BUCKETS = 20;
  let lo = Infinity, hi = -Infinity;
  for (const vals of allVals) for (const v of vals) { if (v < lo) lo = v; if (v > hi) hi = v; }
  if (!isFinite(lo) || lo === hi) return { labels: [], counts: allVals.map(() => []) };
  const size = (hi - lo) / BUCKETS;
  const counts = allVals.map(vals => {
    const bins = new Array<number>(BUCKETS).fill(0);
    for (const v of vals) bins[Math.min(BUCKETS - 1, Math.floor((v - lo) / size))]++;
    return bins;
  });
  const labels = Array.from({ length: BUCKETS }, (_, i) => (lo + i * size).toPrecision(3));
  return { labels, counts };
}

export default function PlotWidget({ widget, onContextMenu, timeRange }: Props) {
  const outerRef = useRef<HTMLDivElement>(null);
  const chartRef = useRef<HTMLDivElement>(null);
  const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');

  // Live chart instances + sample buffers held in refs so the hover toolbar can
  // pause/clear/screenshot without tearing down and recreating the chart. The
  // buffers persist across effect re-runs (plot-type / window / legend changes),
  // keyed by widget+signals+time-range so only a genuine data-source change
  // resets them.
  const uplotRef = useRef<uPlot | null>(null);
  const echartRef = useRef<echarts.EChartsType | null>(null);
  const buffersRef = useRef<{ key: string; bufs: SeriesBuffer[]; waves: number[][] } | null>(null);
  // Toolbar state: local pause (OR'd with panel-logic pause) and a view-time
  // rolling-window override (null = use the configured timeWindow option).
  const [paused, setPaused] = useState(false);
  const pausedRef = useRef(false);
  useEffect(() => { pausedRef.current = paused; }, [paused]);
  const [winOverride, setWinOverride] = useState<number | null>(null);

  // Cross-plot link: when on, this plot joins the shared cursor + x-range sync
  // group (see lib/plotSync). Toggling re-inits the chart (cursor.sync is an
  // init-time uPlot option); buffers persist via buffersRef.
  const [linked, setLinked] = useState(true);
  // Measurement cursors: marksRef holds two x-times (A/B) on the time axis; the
  // readout panel shows Δt plus each series' value at A, B and ΔV. Toggling
  // measure mode does NOT re-init the chart — the draw hook and click handler
  // read live refs and a redraw() is enough.
  const [measureOn, setMeasureOn] = useState(false);
  const measureOnRef = useRef(false);
  const marksRef = useRef<{ a: number | null; b: number | null }>({ a: null, b: null });
  const [readout, setReadout] = useState<MeasureReadout | null>(null);
  // Guards a feedback loop when applying a synced x-range back into uPlot.
  const applyingSyncRef = useRef(false);

  // Recompute the measurement readout from the current marks + buffers.
  function refreshReadout() {
    const { a, b } = marksRef.current;
    const b0 = buffersRef.current?.bufs;
    if (a == null || !b0) { setReadout(null); return; }
    const rows: MeasureRow[] = widget.signals.map((s, i) => ({
      name: s.name,
      color: s.color ?? COLORS[i % COLORS.length],
      va: b0[i] ? valueAt(b0[i], a) : null,
      vb: b != null && b0[i] ? valueAt(b0[i], b) : null,
    }));
    setReadout({ a, b, rows });
  }

  // Place / move measurement cursors on click while in measure mode.
  function onMeasureClick() {
    const u = uplotRef.current;
    const left = u ? (u as any).cursor?.left : null;
    if (!measureOnRef.current || !u || left == null || left < 0) return;
    const val = (u as any).posToVal(left, 'x');
    const m = marksRef.current;
    // 1st click (or restart after both set) → A; 2nd click → B.
    if (m.a == null || m.b != null) { m.a = val; m.b = null; }
    else m.b = val;
    refreshReadout();
    (u as any).redraw();
  }

  function toggleMeasure() {
    const on = !measureOnRef.current;
    measureOnRef.current = on;
    setMeasureOn(on);
    if (!on) { marksRef.current = { a: null, b: null }; setReadout(null); }
    (uplotRef.current as any)?.redraw();
  }

  // ── Toolbar handlers (operate on the live ref instances) ──────────────────
  function clearPlot() {
    const b = buffersRef.current;
    if (!b) return;
    b.bufs.forEach(buf => { buf.timestamps.length = 0; buf.values.length = 0; });
    b.waves.forEach(w => { w.length = 0; });
    uplotRef.current?.setData([[], ...b.bufs.map(() => [])] as uPlot.AlignedData);
  }

  function screenshot() {
    let dataUrl: string | undefined;
    const canvas = chartRef.current?.querySelector('canvas');
    if (uplotRef.current && canvas) {
      dataUrl = canvas.toDataURL('image/png');
    } else if (echartRef.current) {
      dataUrl = (echartRef.current as any).getDataURL({ pixelRatio: 2, backgroundColor: '#1a1f2e' });
    }
    if (!dataUrl) return;
    const name = (widget.signals[0]?.name ?? 'plot').replace(/[^\w.-]+/g, '_');
    const a = document.createElement('a');
    a.href = dataUrl;
    a.download = `${name}-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
    a.click();
  }

  // Rolling-window presets (seconds); null = use the widget's configured window.
  const WINDOW_PRESETS: { label: string; val: number | null }[] = [
    { label: 'Auto', val: null },
    { label: '15s', val: 15 },
    { label: '30s', val: 30 },
    { label: '1m', val: 60 },
    { label: '5m', val: 300 },
    { label: '15m', val: 900 },
    { label: '1h', val: 3600 },
  ];

  // Stable primitive deps so the effect re-runs when plotType or signals change.
  const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
  const plotType = widget.options['plotType'] ?? 'timeseries';
  const legendPos = widget.options['legend'] ?? 'bottom';
  const signalsKey = widget.signals.map(s => `${s.ds}:${s.name}${s.color ?? ''}`).join(',');

  useEffect(() => {
    if (!chartRef.current) return;

    const signals = widget.signals;
    const timeWindow = winOverride ?? parseFloat(widget.options['timeWindow'] ?? '60');
    const yMin = widget.options['yMin'];
    const yMax = widget.options['yMax'];
    const legendPos = widget.options['legend'] ?? 'bottom';
    const showLegend = legendPos !== 'none';

    // Reuse persisted buffers across effect re-runs (plot-type / window / legend
    // changes) so the toolbar and a window switch don't drop live history; reset
    // only when the widget, its signals or the time-range change.
    const bufKey = `${widget.id}|${signalsKey}|${timeRangeKey}`;
    if (!buffersRef.current || buffersRef.current.key !== bufKey) {
      buffersRef.current = {
        key: bufKey,
        bufs: signals.map(() => ({ timestamps: [], values: [] })),
        waves: signals.map(() => []),
      };
    }
    const buffers: SeriesBuffer[] = buffersRef.current.bufs;
    // Latest waveform (array) value per signal — used only by plotType 'waveform'.
    const waveforms: number[][] = buffersRef.current.waves;
    const unsubs: (() => void)[] = [];
    const fmt = widget.options['format'] ?? '';

    // ── uPlot (timeseries) ──────────────────────────────────────────────────
    let uplot: uPlot | null = null;

    // Newest sample timestamp across all series (seconds). Anchoring the live
    // x-axis to this — rather than wall-clock Date.now() — avoids client/server
    // clock skew clipping the latest point, and keeps the right edge on real data.
    function latestSampleTs(): number {
      let m = -Infinity;
      for (const b of buffers) {
        if (b.timestamps.length) m = Math.max(m, b.timestamps[b.timestamps.length - 1]);
      }
      return isFinite(m) ? m : Date.now() / 1000;
    }

    // windowSec: apply rolling window for live mode; 0 = use full buffer (historical)
    function uplotData(windowSec = 0): uPlot.AlignedData {
      if (signals.length === 0) return [[]];
      const cutoff = windowSec > 0 ? Date.now() / 1000 - windowSec : -Infinity;

      const allTs = new Set<number>();
      buffers.forEach(b => {
        const pts = windowSec > 0 ? windowedSlice(b, windowSec).ts : b.timestamps;
        pts.forEach(t => allTs.add(t));
      });
      const tsSorted = Array.from(allTs).sort((a, b) => a - b);
      if (tsSorted.length === 0) {
        return [[], ...signals.map(() => [])] as uPlot.AlignedData;
      }

      // Step-hold interpolation: carry the most recent sample forward so signals
      // with different update rates align correctly on the shared time axis.
      return [
        tsSorted,
        ...buffers.map(b => {
          if (b.timestamps.length === 0) return tsSorted.map(() => null);
          let bi = 0;
          let hold: number | null = null;
          while (bi < b.timestamps.length && b.timestamps[bi] < cutoff) {
            hold = b.values[bi++];
          }
          return tsSorted.map(t => {
            while (bi < b.timestamps.length && b.timestamps[bi] <= t) {
              hold = b.values[bi++];
            }
            return hold;
          });
        }),
      ] as uPlot.AlignedData;
    }

    // Draw the measurement cursor lines (A=amber, B=green) onto the uPlot canvas
    // so they track the data on zoom / pan / resize. Runs in the 'draw' hook.
    function drawMeasureMarks(u: uPlot) {
      const { a, b } = marksRef.current;
      if (a == null && b == null) return;
      const ctx = (u as any).ctx as CanvasRenderingContext2D;
      const bb = (u as any).bbox as { top: number; height: number };
      ctx.save();
      ctx.lineWidth = 1;
      ctx.setLineDash([4, 3]);
      const line = (val: number, color: string) => {
        const x = (u as any).valToPos(val, 'x', true);
        ctx.strokeStyle = color;
        ctx.beginPath();
        ctx.moveTo(x, bb.top);
        ctx.lineTo(x, bb.top + bb.height);
        ctx.stroke();
      };
      if (a != null) line(a, '#f59e0b');
      if (b != null) line(b, '#22c55e');
      ctx.restore();
    }

    function makeUplotOpts(): uPlot.Options {
      const yAxis: uPlot.Axis = {
        stroke: '#94a3b8',
        grid: { stroke: '#2d3748' },
        ticks: { stroke: '#475569' },
        size: 55,
      };
      if (fmt) {
        yAxis.values = (_u: uPlot, vals: (number | null)[]) =>
          vals.map(v => (v === null || v === undefined) ? '' : formatValue(v, fmt));
      }
      const scaleYRange = (_u: uPlot, min: number, max: number): [number, number] => {
        if (!isFinite(min) || !isFinite(max) || min === max) {
          const mid = isFinite(min) ? min : 0;
          return [mid - 1, mid + 1];
        }
        return [min, max];
      };
      const seriesConf: uPlot.Series[] = [
        {},
        ...signals.map((s, i) => {
          const color = s.color ?? COLORS[i % COLORS.length];
          return {
            label: s.name,
            stroke: color,
            width: 1.5,
            points: { show: true, size: 5, stroke: color, fill: color },
          };
        }),
      ];
      return {
        width: w,
        height: Math.max(50, h - legendH),
        series: seriesConf,
        legend: { show: showLegend },
        axes: [
          { stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
          yAxis,
        ],
        scales: {
          x: {
            time: true,
            // Live mode with a finite window: pin the x-axis to a rolling
            // [newest − window, newest] span so a single old/outlier sample
            // can't stretch the axis and compress real points to the right.
            // Historical mode (timeRange) keeps uPlot's data-driven auto-range.
            ...(!timeRange && timeWindow > 0
              ? { range: (): [number, number] => {
                  const r = latestSampleTs();
                  return [r - timeWindow, r];
                } }
              : {}),
          },
          y: { ...scaleY, range: scaleYRange },
        },
        // Cross-plot crosshair sync (x only) for all linked plots.
        cursor: linked ? { sync: { key: PLOT_SYNC_KEY, scales: ['x', null] } } : {},
        hooks: {
          draw: [drawMeasureMarks],
          // Broadcast zoom/pan to other linked plots (historical mode only — live
          // mode auto-scrolls its own rolling window). Guard against the echo of
          // an applied sync to avoid a feedback loop.
          setScale: [(u: uPlot, key: string) => {
            if (key !== 'x' || !linked || !timeRange || applyingSyncRef.current) return;
            const sc = (u as any).scales.x as { min: number | null; max: number | null };
            if (sc.min != null && sc.max != null) {
              broadcastXRange(widget.id, { min: sc.min, max: sc.max });
            }
          }],
        },
      };
    }

    // ── ECharts ────────────────────────────────────────────────────────────
    let echart: echarts.EChartsType | null = null;

    function echartsOption(): any {
      const axisLine = { lineStyle: { color: '#475569' } };
      const axisLabel = { color: '#64748b' };
      const splitLine = { lineStyle: { color: '#2d3748' } };
      const legendOpt = showLegend
        ? { top: legendPos === 'top' ? 0 : 'bottom', textStyle: { color: '#94a3b8' } }
        : undefined;

      switch (plotType) {
        case 'histogram': {
          // Distribution over all buffered samples (ring-buffer bounded).
          const { labels, counts } = buildHistogram(buffers.map(b => b.values));
          return {
            backgroundColor: ECHARTS_DARK.backgroundColor,
            legend: legendOpt,
            grid: { left: 56, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
            xAxis: { type: 'category', name: 'Value', nameLocation: 'middle', nameGap: 24, data: labels, axisLine, axisLabel, splitLine },
            yAxis: { type: 'value', name: 'Count', axisLine, axisLabel, splitLine },
            series: counts.map((d, i) => ({
              name: signals[i]?.name ?? `S${i}`,
              type: 'bar',
              data: d,
              itemStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
            })),
          };
        }
        case 'bar': {
          return {
            backgroundColor: ECHARTS_DARK.backgroundColor,
            legend: legendOpt,
            grid: { left: 56, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
            xAxis: { type: 'category', data: signals.map(s => s.name), axisLine, axisLabel },
            yAxis: { type: 'value', axisLine, axisLabel, splitLine },
            series: [{
              type: 'bar',
              data: buffers.map((b, i) => ({
                value: b.values.length ? b.values[b.values.length - 1] : 0,
                itemStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
              })),
            }],
          };
        }
        case 'fft': {
          // Single-sided magnitude spectrum per signal, computed from the
          // windowed samples resampled onto a uniform grid.
          const N = 256;
          return {
            backgroundColor: ECHARTS_DARK.backgroundColor,
            legend: legendOpt,
            grid: { left: 60, right: 16, top: showLegend ? 28 : 12, bottom: 40 },
            xAxis: { type: 'value', name: 'Hz', nameLocation: 'middle', nameGap: 24, min: 0, axisLine, axisLabel, splitLine },
            yAxis: { type: 'value', name: 'Mag', axisLine, axisLabel, splitLine },
            series: buffers.map((b, i) => {
              const { ts, vals } = windowedSlice(b, timeWindow);
              let data: [number, number][] = [];
              if (vals.length >= 4) {
                const { sampled, dt } = resampleUniform(ts, vals, N);
                const mag = fftMag(sampled);
                const fs = dt > 0 ? 1 / dt : 1; // sample rate (Hz)
                data = mag.map((m, k) => [(k * fs) / N, m]);
              }
              return {
                name: signals[i]?.name ?? `S${i}`,
                type: 'line',
                data,
                lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
                showSymbol: false,
              };
            }),
          };
        }
        case 'waveform': {
          // Latest waveform (array) sample per signal, drawn as an x-vs-index
          // trace. Each update replaces the trace rather than appending.
          return {
            backgroundColor: ECHARTS_DARK.backgroundColor,
            legend: legendOpt,
            grid: { left: 60, right: 16, top: showLegend ? 28 : 12, bottom: 40 },
            xAxis: { type: 'value', name: 'Index', nameLocation: 'middle', nameGap: 24, min: 0, axisLine, axisLabel, splitLine },
            yAxis: { type: 'value', name: 'Value', axisLine, axisLabel, splitLine },
            series: signals.map((s, i) => ({
              name: s.name,
              type: 'line',
              data: (waveforms[i] ?? []).map((v, k) => [k, v]),
              lineStyle: { color: s.color ?? COLORS[i % COLORS.length] },
              showSymbol: false,
            })),
          };
        }
        case 'logic': {
          // Logic-analyser style: each signal is a 0/1 step trace stacked on its
          // own lane, with a relative-time x-axis (seconds before now).
          const nowSec = Date.now() / 1000;
          const lane = 1.4;
          return {
            backgroundColor: ECHARTS_DARK.backgroundColor,
            legend: legendOpt,
            grid: { left: 90, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
            xAxis: {
              type: 'value', min: 'dataMin', max: 0, axisLine, splitLine,
              axisLabel: { color: '#64748b', formatter: (v: number) => `${v.toFixed(0)}s` },
            },
            yAxis: {
              type: 'value', min: 0, max: Math.max(lane, signals.length * lane),
              interval: lane, axisLine, splitLine,
              axisLabel: {
                color: '#64748b',
                formatter: (v: number) => signals[Math.round(v / lane)]?.name ?? '',
              },
            },
            series: buffers.map((b, i) => {
              const { ts, vals } = windowedSlice(b, timeWindow);
              const base = i * lane;
              return {
                name: signals[i]?.name ?? `S${i}`,
                type: 'line', step: 'end',
                data: ts.map((t, j) => [t - nowSec, base + (vals[j] > 0 ? 1 : 0)]),
                lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
                showSymbol: false,
              };
            }),
          };
        }
        case 'waterfall': {
          // Spectrogram of the first signal: split the windowed samples into
          // ROWS time frames and FFT each frame (freq on x, time on y).
          const ROWS = 24, FRAME = 32;
          const b = buffers[0];
          const flatData: [number, number, number][] = [];
          let maxMag = 0;
          if (b && b.values.length >= FRAME) {
            const { sampled } = resampleUniform(b.timestamps, b.values, ROWS * FRAME);
            for (let r = 0; r < ROWS; r++) {
              const mag = fftMag(sampled.slice(r * FRAME, (r + 1) * FRAME));
              for (let k = 0; k < mag.length; k++) {
                flatData.push([k, r, mag[k]]);
                if (mag[k] > maxMag) maxMag = mag[k];
              }
            }
          }
          const bins = FRAME >> 1;
          return {
            backgroundColor: ECHARTS_DARK.backgroundColor,
            grid: { left: 56, right: 70, top: 12, bottom: 36 },
            visualMap: {
              min: 0, max: maxMag || 1, calculable: true, right: 8, top: 'center',
              dimension: 2, textStyle: { color: '#94a3b8' },
              inRange: { color: ['#0f1117', '#1e3a8a', '#4a9eff', '#22c55e', '#f59e0b', '#ef4444'] },
            },
            xAxis: { type: 'category', name: 'Freq bin', nameLocation: 'middle', nameGap: 22, data: Array.from({ length: bins }, (_, k) => String(k)), axisLine, axisLabel },
            yAxis: { type: 'category', name: 'Time', data: Array.from({ length: ROWS }, (_, r) => String(r - ROWS + 1)), axisLine, axisLabel },
            series: [{ type: 'heatmap', data: flatData }],
          };
        }
        default:
          return { backgroundColor: ECHARTS_DARK.backgroundColor };
      }
    }

    // ── Init chart ─────────────────────────────────────────────────────────
    const el = chartRef.current;
    const w = el.clientWidth || widget.w;
    const h = el.clientHeight || widget.h;
    // Reserve vertical space for the uPlot legend so it isn't clipped.
    const legendH = showLegend ? 30 : 0;

    const scaleY: uPlot.Scale = {};
    if (yMin !== undefined && yMin !== 'auto') scaleY.min = parseFloat(yMin);
    if (yMax !== undefined && yMax !== 'auto') scaleY.max = parseFloat(yMax);

    if (plotType !== 'timeseries') {
      echart = echarts.init(el);
      echart.setOption(echartsOption());
      echartRef.current = echart;
    }

    // Wire a freshly-created uPlot into the refs + measurement click handler.
    function mountUplot(u: uPlot) {
      uplotRef.current = u;
      (u as any).over.addEventListener('click', onMeasureClick);
    }

    // Apply a synced x-range pushed from a peer plot (historical zoom/pan sync).
    if (linked && plotType === 'timeseries') {
      unsubs.push(subscribeXRange(widget.id, r => {
        const u = uplotRef.current;
        if (!u || !timeRange) return;
        applyingSyncRef.current = true;
        (u as any).setScale('x', { min: r.min, max: r.max });
        applyingSyncRef.current = false;
      }));
    }

    // ── Historical mode (timeseries only) ──────────────────────────────────
    // uPlot is NOT created until data arrives — initialising with empty arrays
    // causes the x-axis to start at epoch 0, compressing all real data to the
    // right edge of the chart.
    if (timeRange && plotType === 'timeseries') {
      setHistStatus('loading');
      let cancelled = false;

      Promise.all(
        signals.map((sig: SignalRef & { color?: string }, i) =>
          wsClient.history(sig, timeRange.start, timeRange.end, 10_000).then(pts => {
            if (cancelled) return;
            for (const p of pts) {
              const ts = new Date(p.ts).getTime() / 1000;
              const v = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
              if (!isNaN(v)) {
                buffers[i].timestamps.push(ts);
                buffers[i].values.push(v);
              }
            }
          }),
        ),
      ).then(() => {
        if (cancelled) return;
        const totalPoints = buffers.reduce((s, b) => s + b.timestamps.length, 0);
        setHistStatus(totalPoints === 0 ? 'empty' : 'idle');
        // Create uPlot now — with real data — so the x-axis is correct from the start.
        uplot = new uPlot(makeUplotOpts(), uplotData(), el);
        mountUplot(uplot);
      }).catch(() => {
        if (!cancelled) setHistStatus('error');
      });

      return () => {
        cancelled = true;
        unsubs.forEach(u => u());
        uplot?.destroy();
        echart?.dispose();
        uplotRef.current = null;
        echartRef.current = null;
      };
    }

    // ── Live mode: init uPlot immediately ──────────────────────────────────
    if (plotType === 'timeseries') {
      uplot = new uPlot(makeUplotOpts(), uplotData(), el);
      mountUplot(uplot);
    }

    // ── Live streaming mode ────────────────────────────────────────────────
    setHistStatus('idle');

    // Honour action.widget logic commands: `paused` suspends live ingestion and
    // a bumped `clearNonce` empties the buffers + redraws once.
    const cmdStore = getWidgetCmdStore(widget.id);
    let cmdPaused = cmdStore.get().paused;
    let lastClear = cmdStore.get().clearNonce;
    unsubs.push(cmdStore.subscribe(cmd => {
      cmdPaused = cmd.paused;
      if (cmd.clearNonce !== lastClear) {
        lastClear = cmd.clearNonce;
        buffers.forEach(b => { b.timestamps.length = 0; b.values.length = 0; });
        if (uplot) uplot.setData(uplotData(timeWindow));
        if (echart) echart.setOption(echartsOption(), { notMerge: true });
      }
    }));

    signals.forEach((sig: SignalRef & { color?: string }, i) => {
      const unsub = getSignalStore(sig).subscribe(sv => {
        if (cmdPaused || pausedRef.current) return;
        if (sv.value === null || sv.value === undefined || sv.ts === null) return;
        const ts = new Date(sv.ts).getTime() / 1000;

        if (plotType === 'waveform') {
          // Array-valued sample: keep only the latest waveform and redraw.
          if (Array.isArray(sv.value)) {
            waveforms[i] = sv.value.map((x: any) => typeof x === 'number' ? x : parseFloat(String(x)));
          } else {
            const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
            waveforms[i] = isNaN(v) ? [] : [v];
          }
          if (echart) echart.setOption(echartsOption(), { notMerge: true });
        } else if (plotType === 'timeseries') {
          const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
          if (isNaN(v)) return;
          pushSample(buffers[i], ts, v);
          if (uplot) {
            uplot.setData(uplotData(timeWindow));
          }
        } else {
          const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
          if (!isNaN(v)) {
            pushSample(buffers[i], ts, v);
          }
          if (echart) echart.setOption(echartsOption(), { notMerge: true });
        }
      });
      unsubs.push(unsub);
    });

    // ── Resize ─────────────────────────────────────────────────────────────
    const ro = new ResizeObserver(() => {
      if (!chartRef.current) return;
      const nw = chartRef.current.clientWidth;
      const nh = chartRef.current.clientHeight;
      if (nw > 0 && nh > 0) {
        if (uplot) uplot.setSize({ width: nw, height: Math.max(50, nh - legendH) });
        if (echart) echart.resize();
      }
    });
    if (outerRef.current) ro.observe(outerRef.current);

    return () => {
      ro.disconnect();
      unsubs.forEach(u => u());
      if (uplot) uplot.destroy();
      if (echart) echart.dispose();
      uplotRef.current = null;
      echartRef.current = null;
    };
  }, [widget.id, timeRangeKey, plotType, signalsKey, winOverride, linked, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);

  return (
    <div
      class={`plot-widget legend-${legendPos}`}
      ref={outerRef}
      style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
      onContextMenu={onContextMenu}
    >
      <div class="plot-toolbar" onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
        <button
          class={`plot-tb-btn${paused ? ' plot-tb-active' : ''}`}
          title={paused ? 'Resume live updates' : 'Pause live updates'}
          onClick={() => setPaused(p => !p)}
        >{paused ? '▶' : '⏸'}</button>
        {!timeRange && (plotType === 'timeseries' || plotType === 'fft' || plotType === 'logic') && (
          <select
            class="plot-tb-select"
            title="Rolling time window"
            value={winOverride === null ? 'auto' : String(winOverride)}
            onChange={(e) => {
              const v = (e.target as HTMLSelectElement).value;
              setWinOverride(v === 'auto' ? null : parseFloat(v));
            }}
          >
            {WINDOW_PRESETS.map(p => (
              <option key={p.label} value={p.val === null ? 'auto' : String(p.val)}>{p.label}</option>
            ))}
          </select>
        )}
        {plotType === 'timeseries' && (
          <button
            class={`plot-tb-btn${linked ? ' plot-tb-active' : ''}`}
            title={linked ? 'Linked: cursor / zoom synced with other plots — click to unlink' : 'Unlinked — click to sync cursor / zoom with other plots'}
            onClick={() => setLinked(v => !v)}
          >{linked ? '🔗' : '⛓️‍💥'}</button>
        )}
        {plotType === 'timeseries' && (
          <button
            class={`plot-tb-btn${measureOn ? ' plot-tb-active' : ''}`}
            title="Measure: click to place cursor A then B; read Δt and per-signal Δ"
            onClick={toggleMeasure}
          >📏</button>
        )}
        <button class="plot-tb-btn" title="Clear buffered data" onClick={clearPlot}>🗑</button>
        <button class="plot-tb-btn" title="Save screenshot (PNG)" onClick={screenshot}>📷</button>
      </div>
      {readout && plotType === 'timeseries' && (
        <div class="measure-readout" onMouseDown={(e) => e.stopPropagation()}>
          <div class="measure-row measure-head">
            <span>Δt</span>
            <span>{readout.b != null ? formatDt(readout.b - readout.a) : '—'}</span>
          </div>
          {readout.rows.map(r => (
            <div class="measure-row" key={r.name}>
              <span class="measure-name" style={`color:${r.color}`}>{r.name}</span>
              <span class="measure-vals">
                {fmtNum(r.va)}
                {readout.b != null && <span class="measure-delta"> → {fmtNum(r.vb)} (Δ {fmtNum(r.va != null && r.vb != null ? r.vb - r.va : null)})</span>}
              </span>
            </div>
          ))}
        </div>
      )}
      <div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
      {histStatus === 'loading' && (
        <div class="hist-overlay">Loading history…</div>
      )}
      {histStatus === 'empty' && (
        <div class="hist-overlay hist-overlay-warn">No archive data for this range</div>
      )}
      {histStatus === 'error' && (
        <div class="hist-overlay hist-overlay-warn">Archive unavailable</div>
      )}
    </div>
  );
}
