// chart.jsx — single time-series chart (one per sensor, stacked).
// Industrial/Bauhaus: thick black axis, mono labels, clean step grid.
// Supports timeframes (1h | 6h | 24h | 7d | 30d | all).

const TIMEFRAMES = [
  { id: '1h',   label: '1H',    ms: 60 * 60 * 1000 },
  { id: '6h',   label: '6H',    ms: 6 * 60 * 60 * 1000 },
  { id: '24h',  label: '24H',   ms: 24 * 60 * 60 * 1000 },
  { id: '7d',   label: '7D',    ms: 7 * 24 * 60 * 60 * 1000 },
  { id: '30d',  label: '30D',   ms: 30 * 24 * 60 * 60 * 1000 },
  { id: 'all',  label: 'ALL',   ms: null },
];

// Binary search for the data point with t nearest to `target`. Returns null
// for an empty array. Exported on window so app.jsx can use the same helper
// for its hover-value display without duplicating the logic.
function findNearestPoint(arr, target) {
  if (!arr || !arr.length) return null;
  let lo = 0, hi = arr.length;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (arr[mid].t < target) lo = mid + 1; else hi = mid;
  }
  if (lo === 0) return arr[0];
  if (lo === arr.length) return arr[arr.length - 1];
  return (target - arr[lo - 1].t) < (arr[lo].t - target) ? arr[lo - 1] : arr[lo];
}

function downsample(points, maxPoints) {
  if (points.length <= maxPoints) return points;
  const stride = points.length / maxPoints;
  const out = [];
  for (let i = 0; i < maxPoints; i++) {
    const lo = Math.floor(i * stride);
    const hi = Math.min(points.length, Math.floor((i + 1) * stride));
    let sum = 0, n = 0, t = 0;
    for (let j = lo; j < hi; j++) { sum += points[j].v; t = points[j].t; n++; }
    if (n) out.push({ t, v: sum / n });
  }
  return out;
}

function niceStep(range, target) {
  const raw = range / target;
  const pow = Math.pow(10, Math.floor(Math.log10(raw)));
  const norm = raw / pow;
  let step;
  if (norm < 1.5) step = 1;
  else if (norm < 3) step = 2;
  else if (norm < 7) step = 5;
  else step = 10;
  return step * pow;
}

function formatTimeTick(t, windowMs) {
  const d = new Date(t);
  if (windowMs === 'live' || (typeof windowMs === 'number' && windowMs <= 6 * 3600 * 1000)) {
    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
  }
  if (typeof windowMs === 'number' && windowMs <= 24 * 3600 * 1000) {
    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
  }
  return `${d.getMonth()+1}/${d.getDate()}`;
}

function SensorChart({ sensor, store, timeframe, thresholds, decimals, hoverT, onHoverT }) {
  const tf = TIMEFRAMES.find(t => t.id === timeframe) || TIMEFRAMES.find(t => t.id === '24h');
  const [, setTick] = React.useState(0);
  React.useEffect(() => {
    const unsub = store.subscribe(() => setTick(t => t + 1));
    setTick(t => t + 1);
    return unsub;
  }, [store]);
  // Hooks must be called unconditionally — keep all refs/state ABOVE any early return.
  const svgRef = React.useRef(null);
  const wrapRef = React.useRef(null);

  // Responsive sizing: keep the viewBox in actual rendered pixels so text and
  // strokes never get distorted by aspect-ratio stretching. Initial estimate
  // from window width keeps the first paint close to final; the ResizeObserver
  // then snaps it to exact dimensions.
  const [size, setSize] = React.useState(() => {
    if (typeof window === 'undefined') return { w: 1200, h: 220 };
    const isPhone = window.innerWidth <= 640;
    return {
      w: Math.max(280, Math.min(1400, window.innerWidth - (isPhone ? 56 : 96))),
      h: isPhone ? 200 : 220,
    };
  });
  React.useEffect(() => {
    if (typeof ResizeObserver === 'undefined') return;
    const wrap = wrapRef.current;
    if (!wrap) return;
    const measure = () => {
      const w = Math.max(280, Math.round(wrap.getBoundingClientRect().width));
      const svg = svgRef.current;
      const h = svg ? Math.max(140, Math.round(svg.getBoundingClientRect().height)) : size.h;
      setSize(prev => (prev.w === w && prev.h === h) ? prev : { w, h });
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(wrap);
    return () => ro.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const W = size.w, H = size.h;
  const isNarrow = W < 480;
  const PAD = {
    l: isNarrow ? 38 : 64,
    r: isNarrow ? 12 : 24,
    t: 16,
    b: isNarrow ? 28 : 32,
  };
  const innerW = W - PAD.l - PAD.r;
  const innerH = H - PAD.t - PAD.b;

  const raw = store.series(sensor.id, tf.ms);
  const data = downsample(raw, isNarrow ? 240 : 600);
  const hasData = data.length >= 2;

  // Pre-compute geometry only when we actually have data; the wrap div always
  // renders so the ResizeObserver has a stable element to track.
  let chartSvg = null;
  if (hasData) {
    // X range follows the SELECTED timeframe, not the data extent — so picking
    // 30D when only 2h of data exists shows that data crammed into the right
    // sliver of the chart with an empty stretch on the left, not data
    // stretched across the full width. LIVE and ALL fall back to the data
    // range since they don't define a fixed window.
    const isNumericTf = typeof tf.ms === 'number';
    const nowMs = Date.now();
    const t0 = isNumericTf ? nowMs - tf.ms : data[0].t;
    const t1 = isNumericTf ? nowMs : data[data.length - 1].t;
    let vmin = Infinity, vmax = -Infinity;
    for (const p of data) { if (p.v < vmin) vmin = p.v; if (p.v > vmax) vmax = p.v; }
    const includeThresh = (val) => {
      if (val == null) return;
      if (val < vmin && (vmin - val) < (vmax - vmin) * 0.5) vmin = val;
      if (val > vmax && (val - vmax) < (vmax - vmin) * 0.5) vmax = val;
    };
    includeThresh(thresholds.warnLow);
    includeThresh(thresholds.warnHigh);
    if (vmax - vmin < 0.5) { vmin -= 0.5; vmax += 0.5; }
    const span = vmax - vmin;
    vmin -= span * 0.1; vmax += span * 0.1;

    const x = t => PAD.l + ((t - t0) / (t1 - t0 || 1)) * innerW;
    const y = v => PAD.t + (1 - (v - vmin) / (vmax - vmin)) * innerH;

    const ystep = niceStep(vmax - vmin, isNarrow ? 3 : 4);
    const yTicks = [];
    for (let v = Math.ceil(vmin / ystep) * ystep; v <= vmax; v += ystep) yTicks.push(v);
    const xTickCount = isNarrow ? 4 : 6;
    const xTicks = [];
    for (let i = 0; i <= xTickCount; i++) xTicks.push(t0 + (t1 - t0) * (i / xTickCount));

    // Break the line where two consecutive readings are more than this far
    // apart — visualises offline gaps as actual gaps in the chart instead of
    // a long diagonal line connecting "before" and "after".
    const MAX_GAP_MS = 10 * 60 * 1000;
    const segments = [];
    let cur = null;
    let prevT = null;
    for (const p of data) {
      const s = (() => {
        const v = p.v, t = thresholds;
        if ((t.dangerLow != null && v <= t.dangerLow) || (t.dangerHigh != null && v >= t.dangerHigh)) return 'danger';
        if ((t.warnLow != null && v <= t.warnLow) || (t.warnHigh != null && v >= t.warnHigh)) return 'warn';
        return 'ok';
      })();
      const isGap = prevT != null && (p.t - prevT) > MAX_GAP_MS;
      if (!cur || cur.status !== s || isGap) {
        // On a status change, seed with the previous point so the colored line
        // is continuous across the boundary. On a gap, start with no seed —
        // the existing pts.length > 1 filter then naturally skips this point
        // until a neighbour arrives, leaving real empty space in the chart.
        const seed = (cur && !isGap) ? [cur.pts[cur.pts.length - 1]] : [];
        cur = { status: s, pts: seed };
        segments.push(cur);
      }
      cur.pts.push(p);
      prevT = p.t;
    }
    const STAT_COLOR = { ok: '#0a0a0a', warn: '#E5A82B', danger: '#D9342B' };

    const last = data[data.length - 1];
    const pathFor = pts => 'M ' + pts.map(p => `${x(p.t).toFixed(1)} ${y(p.v).toFixed(1)}`).join(' L ');

    // The hover cursor follows a SHARED timestamp owned by the parent App,
    // so all charts cursor together. We broadcast the raw t under the mouse;
    // each chart resolves t against its own data when rendering its dot.
    const onMove = (e) => {
      const svg = svgRef.current;
      if (!svg) return;
      const rect = svg.getBoundingClientRect();
      // Translate CSS pixels to SVG user units (in steady state W ≈ rect.width
      // because the viewBox tracks the rendered size, but during the initial
      // render before ResizeObserver fires they can differ — handle both).
      const svgX = (e.clientX - rect.left) * (W / rect.width);
      if (svgX < PAD.l || svgX > W - PAD.r) {
        if (hoverT != null && onHoverT) onHoverT(null);
        return;
      }
      const tHover = t0 + ((svgX - PAD.l) / innerW) * (t1 - t0);
      if (onHoverT) onHoverT(tHover);
    };

    const onLeave = () => {
      if (hoverT != null && onHoverT) onHoverT(null);
    };

    // Resolve the shared timestamp against THIS chart's own data so the dot
    // always sits on a real reading for this sensor — even if the user is
    // hovering over a different chart.
    const hover = hoverT != null ? findNearestPoint(data, hoverT) : null;

    chartSvg = (
      <svg ref={svgRef} viewBox={`0 0 ${W} ${H}`} width="100%" className="chart-svg"
           onMouseMove={onMove} onMouseLeave={onLeave}>
        {/* y grid */}
        {yTicks.map((v, i) => (
          <g key={`y${i}`}>
            <line x1={PAD.l} x2={W - PAD.r} y1={y(v)} y2={y(v)} className="grid"/>
            <text x={PAD.l - 8} y={y(v) + 4} textAnchor="end" className="tick-lbl">{
              Math.abs(v) >= 100 ? Math.round(v) : v.toFixed(decimals)
            }</text>
          </g>
        ))}
        {/* threshold lines */}
        {['warnLow','warnHigh'].map(k => thresholds[k] != null && (
          <line key={k} x1={PAD.l} x2={W - PAD.r} y1={y(thresholds[k])} y2={y(thresholds[k])} className="thr thr-w"/>
        ))}
        {['dangerLow','dangerHigh'].map(k => thresholds[k] != null && (
          <line key={k} x1={PAD.l} x2={W - PAD.r} y1={y(thresholds[k])} y2={y(thresholds[k])} className="thr thr-d"/>
        ))}
        {/* x ticks */}
        {xTicks.map((t, i) => (
          <g key={`x${i}`}>
            <line x1={x(t)} x2={x(t)} y1={H - PAD.b} y2={H - PAD.b + 5} className="ax"/>
            <text x={x(t)} y={H - PAD.b + 18} textAnchor="middle" className="tick-lbl">{formatTimeTick(t, tf.ms)}</text>
          </g>
        ))}
        {/* axes (thick black rules) */}
        <line x1={PAD.l} x2={W - PAD.r} y1={H - PAD.b} y2={H - PAD.b} className="axb"/>
        <line x1={PAD.l} x2={PAD.l} y1={PAD.t} y2={H - PAD.b} className="axb"/>
        {/* unit label */}
        <text x={PAD.l} y={PAD.t - 4} className="axis-lbl">{sensor.unit}</text>
        {/* line, segmented */}
        {segments.map((seg, i) => seg.pts.length > 1 && (
          <path key={i} d={pathFor(seg.pts)} className="ln" stroke={STAT_COLOR[seg.status]}/>
        ))}
        {/* current value marker */}
        <circle cx={x(last.t)} cy={y(last.v)} r="4" fill="#0a0a0a"/>
        <circle cx={x(last.t)} cy={y(last.v)} r="8" fill="#0a0a0a" fillOpacity="0.12"/>
        {/* hover cursor: vertical line + dot snapped to the nearest data point */}
        {hover && (
          <g pointerEvents="none">
            <line x1={x(hover.t)} x2={x(hover.t)} y1={PAD.t} y2={H - PAD.b}
                  stroke="#0a0a0a" strokeWidth="1.25" strokeOpacity="0.55" strokeDasharray="3 3"/>
            <circle cx={x(hover.t)} cy={y(hover.v)} r="9" fill="#0a0a0a" fillOpacity="0.15"/>
            <circle cx={x(hover.t)} cy={y(hover.v)} r="4.5" fill={STAT_COLOR[(() => {
              const v = hover.v, t = thresholds;
              if ((t.dangerLow != null && v <= t.dangerLow) || (t.dangerHigh != null && v >= t.dangerHigh)) return 'danger';
              if ((t.warnLow != null && v <= t.warnLow) || (t.warnHigh != null && v >= t.warnHigh)) return 'warn';
              return 'ok';
            })()]} stroke="#FBFAF5" strokeWidth="1.5"/>
          </g>
        )}
      </svg>
    );
  }

  return (
    <div className="chart-wrap" ref={wrapRef}>
      {hasData ? chartSvg : (
        <div className="chart-empty"><span className="mono">NO DATA IN WINDOW</span></div>
      )}
    </div>
  );
}

Object.assign(window, { SensorChart, TIMEFRAMES, findNearestPoint });
