// clamp.jsx — whole-home CT clamp (Ampo / AmpoCloud) on the Home screen.
//
// A household with a clamp has ONE `devices` row: type "ct_clamp",
// mqtt_device_id "ampo-<UUID>", is_controllable false. Nothing for the
// resident to set up — once that row exists on their household, this file
// finds it via GET /household/devices and everything below switches on.
//
// The clamp has three CT channels:
//   ch1 = mains (the whole home)
//   ch2 = aircon compressor circuit  → "Circuit 1"
//   ch3 = aircon compressor circuit  → "Circuit 2"
// ch2/ch3 are ALREADY inside ch1 (same as the backend's MAINS_CHANNEL rule), so
// they're never added to the home total — "everything else" = ch1 − ch2 − ch3.
//
// Where the data comes from:
//   • Whole home, live V/A — GET /household/readings/latest            (stored mains reading)
//   • Every channel, live  — GET /devices/{id}/channels                (watts + today's kWh, live from AmpoCloud)
//   • Whole home, history  — GET /household/usage/history, filtered to the clamp
//   • Circuit history      — GET /devices/{id}/channels/history         (PROPOSED — not live yet,
//                                                                         docs/CT_CLAMP_BACKEND_HANDOFF.md)
// Anything not available shows an honest "not available yet" state — never
// made-up numbers.
//
// Design preview: open the app with ?clamp=preview to render SAMPLE data
// (badged PREVIEW everywhere) so the screens can be reviewed without a clamp
// household. Never on by default.
//
// Public surface (used by screen-home.jsx):
//   useClamp()             — ONE poller; returns the clamp view model below
//   <ClampHeroLive>        — big live whole-home readout inside the battery card
//   <ClampCircuitTiles>    — one tile per aircon circuit, for the "Right now" grid
//   <ClampSheet>           — tap-in detail: live electrical readings + history + insights
//   isClampDevice(dev)     — so the plug grid can skip the clamp's own devices row

// IIFE for the same reason as aircon.jsx: every text/babel script shares one
// global scope, so file-level consts would collide.
(function () {
const { useState, useEffect, useRef, useMemo } = React;

const MAINS = 1;
const CIRCUIT_CHANNELS = [2, 3];      // Ampohub is 3-CT hardware: ch1 mains + two sub-circuits
const RUNNING_W = 60;                 // a compressor circuit above this is "running"
const STALE_MS = 3 * 60 * 1000;       // backend polls Ampo every 30s — 3 min with nothing new = stale
// Each /channels call makes two live AmpoCloud queries server-side (and the
// power is a ~35s average anyway), so poll gently.
const LIVE_POLL_MS = 15000;
const PREVIEW_POLL_MS = 3000;
const CIRCUITS_RETRY_EVERY = 4;       // after a hard "no" (404/400), only re-ask every 4th poll (~1 min)

const CHANNEL_DEFAULTS = {
  1: { label: 'Whole home', description: 'Your main electricity supply' },
  2: { label: 'Circuit 1',  description: 'Aircon compressor circuit' },
  3: { label: 'Circuit 2',  description: 'Aircon compressor circuit' },
};

const CLAMP_RANGES = {
  '24h': { bucket: 'hour', count: 24, label: 'Last 24 hours', tab: '24h' },
  '7d':  { bucket: 'day',  count: 7,  label: 'Last 7 days',   tab: '7 days' },
  '30d': { bucket: 'day',  count: 30, label: 'Last 30 days',  tab: '30 days' },
};

const CLAMP_PREVIEW = typeof location !== 'undefined' && /(?:^|[?&])clamp=preview(?:&|$)/.test(location.search);

function isClampDevice(dev) {
  return !!dev && (dev.type === 'ct_clamp' || /^ampo-/i.test(dev.mqtt_device_id || ''));
}

// One colour identity per channel, used by the hero split bar, the tiles and the
// sheet so "Circuit 1" is the same colour everywhere. `dark` = on the bold hero card.
function channelTone(c, key) {
  return {
    2:     { bar: c.sky,       dark: '#A9C6DE',                fg: '#345070',   soft: c.skySoft },
    3:     { bar: c.accent,    dark: '#F2C77E',                fg: '#8A5A1E',   soft: c.accentSoft },
    other: { bar: c.textLight, dark: 'rgba(255,255,255,0.34)', fg: c.textMuted, soft: c.surfaceAlt },
    1:     { bar: c.primary,   dark: '#6FC79A',                fg: c.primary,   soft: c.primarySoft },
  }[key];
}

// ── formatting ───────────────────────────────────────────────────────────────
const sum = (arr) => (arr || []).reduce((s, v) => s + (v || 0), 0);
const pct = (part, whole) => (whole > 0 ? Math.round((part / whole) * 100) : 0);
// Every dollar here comes from src/tariff.js — never a rate typed in place.
const money = (kwh) => SaveHorTariff.money(kwh);
function fmtKwh(k) {
  if (k == null) return '—';
  return k < 10 ? k.toFixed(2) : k < 100 ? k.toFixed(1) : String(Math.round(k));
}
// → [number, unit] so the number and unit can be styled separately.
function splitWatts(w) {
  if (w == null || !isFinite(w)) return ['—', 'W'];
  if (w >= 10000) return [(w / 1000).toFixed(1), 'kW'];
  return [Math.round(w).toLocaleString(), 'W'];
}
function fmtWatts(w) {
  const [n, u] = splitWatts(w);
  return u === 'kW' ? `${n} kW` : `${n}W`;
}
function fmtAgo(ms) {
  if (ms == null || !isFinite(ms)) return '';
  const s = Math.max(0, Math.round(ms / 1000));
  if (s < 5) return 'just now';
  if (s < 60) return `${s}s ago`;
  const m = Math.round(s / 60);
  if (m < 60) return `${m} min ago`;
  return `${Math.round(m / 60)}h ago`;
}
const h12 = (h) => { const hh = ((h % 24) + 24) % 24; return `${hh % 12 || 12}${hh < 12 ? 'am' : 'pm'}`; };
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Bucket keys are SGT wall-clock strings ("YYYY-MM-DDTHH" or "YYYY-MM-DD"),
// parsed straight from the string so no Date() timezone shift can creep in.
function fmtBucket(key, bucket, style) {
  // A missing key is never worth crashing a screen over: callers index into a
  // keys array that can be the wrong length for a moment while the range is
  // changing, so return an empty label rather than throwing on undefined.
  if (typeof key !== 'string' || !key) return '';
  if (bucket === 'hour') {
    const h = parseInt(key.slice(11, 13), 10);
    if (!isFinite(h)) return '';
    return style === 'long' ? `${h12(h)}–${h12(h + 1)}` : h12(h);
  }
  const d = new Date(`${key}T00:00:00Z`);
  if (isNaN(d.getTime())) return '';
  const md = `${d.getUTCDate()} ${MON[d.getUTCMonth()]}`;
  if (style === 'long') return `${DOW[d.getUTCDay()]} ${md}`;
  return style === 'dow' ? DOW[d.getUTCDay()] : md;
}

// The last `count` SGT hour/day keys ending now — the chart's fixed x-axis, so
// hours with no readings show as gaps instead of silently collapsing the axis.
function sgtBucketKeys(bucket, count) {
  const nowSgt = new Date(Date.now() + 8 * 3600e3);   // read back with getUTC* → SGT wall clock
  const keys = [];
  for (let i = count - 1; i >= 0; i--) {
    const d = new Date(nowSgt);
    if (bucket === 'hour') { d.setUTCMinutes(0, 0, 0); d.setUTCHours(d.getUTCHours() - i); }
    else { d.setUTCHours(0, 0, 0, 0); d.setUTCDate(d.getUTCDate() - i); }
    keys.push(d.toISOString().slice(0, bucket === 'hour' ? 13 : 10));
  }
  return keys;
}

// Rows of { bucket_start, kwh, … } → { keys, bucket, byChannel: { [ch]: kwh[] } }.
// `channelOf(row)` picks the channel a row belongs to (null = ignore the row).
function bucketize(rows, range, channelOf) {
  const { bucket, count } = CLAMP_RANGES[range];
  const keys = sgtBucketKeys(bucket, count);
  const len = bucket === 'hour' ? 13 : 10;
  const index = new Map(keys.map((k, i) => [k, i]));
  const byChannel = {};
  for (const row of rows || []) {
    const i = index.get(String(row.bucket_start || '').replace(' ', 'T').slice(0, len));
    const ch = channelOf(row);
    if (i == null || ch == null) continue;
    (byChannel[ch] || (byChannel[ch] = new Array(keys.length).fill(0)))[i] += row.kwh || 0;
  }
  return { keys, bucket, byChannel };
}

// ── design-preview sample data (?clamp=preview only) ─────────────────────────
const PREVIEW_DEVICE = { id: 'preview-clamp', label: 'Whole home', type: 'ct_clamp', mqtt_device_id: 'ampo-PREVIEW' };
const PREVIEW_DESCRIPTIONS = {
  2: 'Master bedroom + living room aircons',
  3: 'Bedroom 1 + bedroom 2 aircons',
};
// Same shapes as the real endpoints: { reading } like /household/readings/latest
// for the mains, and { channels } like /devices/{id}/channels.
function previewLive(t) {
  const s = t / 1000;
  const c1 = 780 + 140 * Math.sin(s / 40) + 40 * Math.sin(s / 7);
  const c2 = Math.sin(s / 90) > -0.3 ? 620 + 90 * Math.sin(s / 13) : 6;
  const rest = 380 + 120 * Math.max(0, Math.sin(s / 25)) + 30 * Math.sin(s / 3);
  const total = c1 + c2 + rest;
  const v = 239 + 1.5 * Math.sin(s / 17);
  const hrs = new Date(t + 8 * 3600e3).getUTCHours() + 0.5;
  return {
    reading: { apower: +total.toFixed(1), voltage: +v.toFixed(1), current: +(total / v).toFixed(2), recorded_at: new Date(t).toISOString() },
    channels: [
      { channel: 1, name: 'Main (total)', watts: +total.toFixed(1), today_kwh: +(hrs * 0.62).toFixed(3) },
      { channel: 2, name: 'Channel 2', watts: +c1.toFixed(1), today_kwh: +(hrs * 0.24).toFixed(3) },
      { channel: 3, name: 'Channel 3', watts: +c2.toFixed(1), today_kwh: +(hrs * 0.19).toFixed(3) },
    ],
  };
}
const rnd = (n) => { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x); };
function previewHistory(range) {
  const { bucket } = CLAMP_RANGES[range];
  const rows = [];
  // Hourly kWh shapes: living/master room aircons run evenings, bedrooms overnight.
  const C1 = [.55,.35,.1,0,0,0,0,0,0,0,.1,.2,.35,.4,.4,.35,.3,.4,.6,.8,.85,.85,.8,.7];
  const C2 = [.65,.65,.6,.6,.55,.5,.45,.2,0,0,0,0,0,0,0,0,0,0,0,0,.1,.3,.55,.65];
  const REST = [.2,.18,.17,.17,.17,.18,.3,.45,.4,.3,.25,.3,.35,.3,.25,.25,.3,.4,.55,.6,.5,.4,.3,.25];
  for (const [i, key] of sgtBucketKeys(bucket, CLAMP_RANGES[range].count).entries()) {
    const at = bucket === 'hour' ? `${key}:00:00` : `${key}T00:00:00`;
    const h = parseInt(key.slice(11, 13), 10);
    const j = 0.8 + 0.4 * rnd(i + key.length);
    const c1 = bucket === 'hour' ? C1[h] * j : sum(C1) * j;
    const c2 = bucket === 'hour' ? C2[h] * (0.8 + 0.4 * rnd(i + 7)) : sum(C2) * (0.8 + 0.4 * rnd(i + 7));
    const rest = bucket === 'hour' ? REST[h] * (0.85 + 0.3 * rnd(i + 3)) : sum(REST) * (0.85 + 0.3 * rnd(i + 3));
    rows.push({ channel: 1, bucket_start: at, kwh: c1 + c2 + rest }, { channel: 2, bucket_start: at, kwh: c1 }, { channel: 3, bucket_start: at, kwh: c2 });
  }
  return { ...bucketize(rows, range, (r) => r.channel), source: 'clamp' };
}

// ── the poller: ONE per Home screen ──────────────────────────────────────────
function useClamp() {
  // undefined = still looking · null = this household has no clamp · object = the clamp's devices row
  const [device, setDevice] = useState(CLAMP_PREVIEW ? PREVIEW_DEVICE : undefined);
  const [mains, setMains] = useState(null);         // stored mains reading: { apower, voltage, current, at }
  const [channels, setChannels] = useState(null);   // live: { [ch]: { watts, today_kwh } }
  const [channelsAt, setChannelsAt] = useState(null);
  const [circuitsState, setCircuitsState] = useState('loading');   // loading | live | unavailable
  const [error, setError] = useState(false);
  const [checkedAt, setCheckedAt] = useState(null);

  // Find the clamp among the household's devices. Retries on failure; stops
  // once it gets a real answer (clamp or no clamp).
  useEffect(() => {
    if (CLAMP_PREVIEW) return undefined;
    let alive = true;
    let id;
    const find = async () => {
      try {
        const devs = await SaveHorAPI.getDevices();
        if (!alive) return;
        clearInterval(id);
        setError(false);
        setDevice((Array.isArray(devs) && devs.find(isClampDevice)) || null);
      } catch (e) {
        if (alive) setError(true);
      }
    };
    find();
    id = setInterval(() => { if (!document.hidden) find(); }, 15000);
    return () => { alive = false; clearInterval(id); };
  }, []);

  const deviceId = device && device.id;
  useEffect(() => {
    if (!deviceId) return undefined;
    let alive = true;
    let seq = 0;
    let tick = 0;
    let circuitsRefused = false;

    const applyReading = (r) => setMains({ apower: r.apower, voltage: r.voltage, current: r.current, at: Date.parse(r.recorded_at) || null });
    const applyChannels = (resp) => {
      const byCh = {};
      for (const ch of (resp && resp.channels) || []) byCh[ch.channel] = ch;
      setChannels(byCh);
      setChannelsAt(Date.now());
      setCircuitsState('live');
    };

    const load = async () => {
      if (document.hidden) return;
      const mySeq = ++seq;
      tick += 1;
      if (CLAMP_PREVIEW) {
        const p = previewLive(Date.now());
        applyReading(p.reading);
        applyChannels(p);
        setCheckedAt(Date.now());
        return;
      }
      const askChannels = !circuitsRefused || tick % CIRCUITS_RETRY_EVERY === 0;
      const [readings, live] = await Promise.allSettled([
        SaveHorAPI.getLatestReadings(),
        askChannels ? SaveHorAPI.getDeviceChannels(deviceId) : Promise.reject(null),
      ]);
      if (!alive || mySeq !== seq) return;   // a newer poll has already been issued

      if (askChannels) {
        if (live.status === 'fulfilled') {
          circuitsRefused = false;
          applyChannels(live.value);
        } else {
          // 400/404 = the backend says no (not a clamp / gone) → back off.
          // 502/503/network = an AmpoCloud blip: keep last-known circuits.
          const st = live.reason && live.reason.status;
          circuitsRefused = st === 400 || st === 404 || st === 405;
          setCircuitsState((s) => (s === 'live' && !circuitsRefused ? s : 'unavailable'));
        }
      }
      if (readings.status === 'fulfilled') {
        setError(false);
        const r = readings.value && readings.value[deviceId];
        if (r) applyReading(r);
      } else {
        setError(true);   // keep last-known numbers; the interval keeps retrying
      }
      setCheckedAt(Date.now());
    };

    load();
    const id = setInterval(load, CLAMP_PREVIEW ? PREVIEW_POLL_MS : LIVE_POLL_MS);
    const onVis = () => { if (!document.hidden) load(); };
    document.addEventListener('visibilitychange', onVis);
    return () => { alive = false; clearInterval(id); document.removeEventListener('visibilitychange', onVis); };
  }, [deviceId]);

  return useMemo(() => {
    const hasClamp = !!device;
    const liveCh = (ch) => (circuitsState === 'live' && channels && channels[ch]) || null;

    // Mains watts: the live /channels figure when we have it (same sample as the
    // circuits, so the split adds up), else the stored reading.
    const liveMains = liveCh(MAINS);
    const mainsFromLive = !!liveMains && liveMains.watts != null;
    const mainsAt = mainsFromLive ? channelsAt : mains && mains.at;
    const age = mainsAt && checkedAt ? checkedAt - mainsAt : null;
    const stale = age != null && age > STALE_MS;
    const haveMains = mainsFromLive || (mains && mains.apower != null);
    // loading | none | live | stale | waiting (clamp found, no reading yet) | offline
    const status = device === undefined ? (error ? 'offline' : 'loading')
      : device === null ? 'none'
      : haveMains ? (stale ? 'stale' : 'live')
      : error ? 'offline' : 'waiting';

    const channelView = (ch) => {
      const lc = liveCh(ch);
      const def = CHANNEL_DEFAULTS[ch];
      const raw = ch === MAINS ? (mainsFromLive ? liveMains.watts : mains && mains.apower) : lc && lc.watts;
      const watts = raw != null ? Math.max(0, raw) : null;
      return {
        channel: ch,
        // The backend's names are deliberately generic ("Channel 2"), so the
        // app's own names win; room descriptions belong to the install (preview only for now).
        label: def.label,
        description: (CLAMP_PREVIEW && PREVIEW_DESCRIPTIONS[ch]) || def.description,
        watts,
        voltage: ch === MAINS && mains ? mains.voltage : null,   // per-circuit V/A aren't exposed
        current: ch === MAINS && mains ? mains.current : null,
        todayKwh: lc && lc.today_kwh != null ? lc.today_kwh : null,
        running: ch !== MAINS && watts != null && watts >= RUNNING_W,
      };
    };
    const mainsView = hasClamp ? channelView(MAINS) : null;
    const circuits = hasClamp ? CIRCUIT_CHANNELS.map(channelView) : [];
    const circuitsLive = circuitsState === 'live' && circuits.every((ci) => ci.watts != null);
    const other = circuitsLive && mainsFromLive
      ? Math.max(0, mainsView.watts - sum(circuits.map((ci) => ci.watts)))
      : null;

    return {
      preview: CLAMP_PREVIEW,
      hasClamp, status, device,
      // 'live' only when every circuit actually reported; a response with a
      // null circuit counts as unavailable rather than half-drawing the split.
      mains: mainsView, circuits, circuitsState: circuitsLive ? 'live' : circuitsState === 'live' ? 'unavailable' : circuitsState,
      other, updatedAgo: age, stale,
      channel: (ch) => (ch === MAINS ? mainsView : circuits.find((ci) => ci.channel === ch) || null),
    };
  }, [device, mains, channels, channelsAt, circuitsState, error, checkedAt]);
}

// History for every channel over one range. Tries the per-channel history
// endpoint; if that isn't deployed yet, falls back to the household history
// endpoint filtered to the clamp's device row — which only has the mains.
// Fetched once per range while the sheet is open.
function useClampHistory(clamp, range) {
  const deviceId = clamp.device && clamp.device.id;
  const [data, setData] = useState({});
  useEffect(() => {
    if (!deviceId || data[range]) return undefined;
    let alive = true;
    (async () => {
      let result;
      if (clamp.preview) {
        result = previewHistory(range);
      } else {
        try {
          const resp = await SaveHorAPI.getDeviceChannelHistory(deviceId, range);
          result = { ...bucketize(resp && resp.series, range, (r) => r.channel), source: 'clamp' };
        } catch (e) {
          try {
            const resp = await SaveHorAPI.getUsageHistory(range);
            result = { ...bucketize(resp && resp.series, range, (r) => (r.device_id === deviceId ? MAINS : null)), source: 'household' };
          } catch (e2) {
            result = { error: true };
          }
        }
      }
      if (alive) setData((d) => ({ ...d, [range]: result }));
    })();
    return () => { alive = false; };
  }, [deviceId, range]);
  return data[range] || { loading: true };
}

// What the history can tell a resident, computed only from real buckets.
// Returns [] when there's nothing honest to say.
function clampInsights(hist, channel) {
  const bars = hist.byChannel[channel];
  const total = sum(bars);
  if (!bars || total <= 0) return [];
  const isMains = channel === MAINS;
  const out = [];
  const peakI = bars.indexOf(Math.max(...bars));

  if (hist.bucket === 'hour') {
    out.push({ label: 'Peak hour', value: fmtBucket(hist.keys[peakI], 'hour', 'long'), sub: `${fmtKwh(bars[peakI])} kWh in that hour` });
    const night = hist.keys.reduce((s, k, i) => (parseInt(k.slice(11, 13), 10) < 7 ? s + bars[i] : s), 0);
    out.push({ label: 'Overnight · 12–7am', value: `${fmtKwh(night)} kWh`, sub: `${money(night)} · ${pct(night, total)}% of the day` });
    if (!isMains) {
      const hrs = bars.filter((v) => v >= RUNNING_W / 1000).length;
      out.push({ label: 'Hours running', value: `${hrs}h`, sub: 'hours the compressor was on' });
    } else {
      // The quietest hour with data, as average watts. Deliberately NOT called
      // "always-on load": an hour-long average includes whatever cycled on
      // during that hour, so it always overstates the true standing draw (on
      // the pilot home it reads ~414W against a real floor nearer 200W). A
      // genuine figure needs a low percentile of raw samples — see
      // docs/CALIBRATION_BACKEND_HANDOFF.md. Needs most of the day covered, or
      // a gap would masquerade as a quiet hour.
      const withData = bars.filter((v) => v > 0);
      if (withData.length >= 12) {
        const baseW = Math.min(...withData) * 1000;
        out.push({ label: 'Quietest hour', value: `${Math.round(baseW)} W`, sub: `average draw · ≈ $${Math.round(SaveHorTariff.cost((baseW / 1000) * 24 * 30))}/month at that rate` });
      }
    }
  } else {
    const days = bars.filter((v) => v > 0).length || 1;
    const avg = total / days;
    const now = new Date();
    const dim = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
    out.push({ label: 'Daily average', value: `${fmtKwh(avg)} kWh`, sub: `${money(avg)} a day` });
    out.push({ label: 'Busiest day', value: fmtBucket(hist.keys[peakI], 'day', 'long'), sub: `${fmtKwh(bars[peakI])} kWh` });
    out.push({ label: 'Monthly pace', value: `$${Math.round(SaveHorTariff.cost(avg * dim))}`, sub: `≈ ${fmtKwh(avg * dim)} kWh this month` });
  }

  if (hist.source === 'clamp') {
    if (isMains) {
      const ac = sum(hist.byChannel[2]) + sum(hist.byChannel[3]);
      if (ac > 0) out.push({ label: 'Aircon circuits', value: `${pct(ac, total)}%`, sub: `${fmtKwh(ac)} of ${fmtKwh(total)} kWh` });
    } else {
      const home = sum(hist.byChannel[MAINS]);
      if (home > 0) out.push({ label: 'Share of home', value: `${pct(total, home)}%`, sub: 'of whole-home use' });
    }
  }
  return out;
}

// ── small pieces ─────────────────────────────────────────────────────────────
function PreviewBadge({ dark, c }) {
  return (
    <span style={{
      fontSize: 9, fontWeight: 800, letterSpacing: 0.5, lineHeight: 1, padding: '3px 6px', borderRadius: 999,
      background: dark ? 'rgba(255,255,255,0.16)' : c.surfaceAlt, color: dark ? '#fff' : c.textMuted,
    }}>PREVIEW</span>
  );
}

function PulseDot({ color, on }) {
  return <span style={{ width: 7, height: 7, borderRadius: 4, background: color, flexShrink: 0,
    animation: on ? 'sh-pulse 1.6s ease-in-out infinite' : 'none' }} />;
}

// Outdoor aircon unit — fan spins while the compressor circuit is drawing power.
function CompressorArt({ size = 46, running, tone }) {
  const blade = 'M18 23 C 16.6 19.4 17.4 15.6 20.6 14.4 C 21.4 17.6 20.6 20.8 18 23 Z';
  return (
    <svg viewBox="0 0 48 48" width={size} height={size} style={{ flexShrink: 0 }}>
      <rect x="3" y="9" width="42" height="28" rx="5" fill={tone.soft} stroke={tone.bar} strokeOpacity="0.55" strokeWidth="1.6" />
      <circle cx="18" cy="23" r="10" fill="#fff" fillOpacity="0.75" stroke={tone.bar} strokeWidth="1.6" />
      <g className={running ? 'ap-fan' : ''} style={{ transformBox: 'fill-box', transformOrigin: 'center' }}>
        <circle cx="18" cy="23" r="9" fill="none" />
        <path d={blade} fill={tone.bar} />
        <path d={blade} fill={tone.bar} transform="rotate(120 18 23)" />
        <path d={blade} fill={tone.bar} transform="rotate(240 18 23)" />
      </g>
      <circle cx="18" cy="23" r="1.8" fill={tone.fg} />
      <path d="M33 17h7M33 21h7M33 25h7M33 29h7M9 37v3M39 37v3" stroke={tone.bar} strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

// Proportional split bar + legend: Circuit 1 / Circuit 2 / Everything else.
function SplitBar({ segs, height = 10, legendColor, valueColor, dark, onPick }) {
  const total = sum(segs.map((s) => s.watts));
  if (total <= 0) return null;
  return (
    <div>
      <div style={{ display: 'flex', height, borderRadius: 999, overflow: 'hidden', gap: 2 }}>
        {segs.filter((s) => s.watts > 0).map((s) => (
          <div key={s.key} style={{ flex: `${s.watts} 1 0px`, minWidth: 4, background: dark ? s.tone.dark : s.tone.bar, transition: 'flex-grow .6s ease' }} />
        ))}
      </div>
      <div style={{ display: 'flex', flexWrap: 'wrap', columnGap: 12, rowGap: 4, marginTop: 7 }}>
        {segs.map((s) => (
          <span key={s.key} onClick={onPick && s.channel ? (e) => { e.stopPropagation(); onPick(s.channel); } : undefined}
            style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11.5, fontWeight: 600, color: legendColor, cursor: onPick && s.channel ? 'pointer' : 'inherit' }}>
            <span style={{ width: 8, height: 8, borderRadius: 2, background: dark ? s.tone.dark : s.tone.bar }} />
            {s.label}
            <b style={{ color: valueColor, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>{fmtWatts(s.watts)}</b>
          </span>
        ))}
      </div>
    </div>
  );
}

function splitSegments(c, clamp) {
  if (clamp.circuitsState !== 'live' || clamp.other == null) return null;
  return [
    ...clamp.circuits.map((ci) => ({ key: ci.channel, channel: ci.channel, label: ci.label, watts: ci.watts, tone: channelTone(c, ci.channel) })),
    { key: 'other', label: 'Everything else', watts: clamp.other, tone: channelTone(c, 'other') },
  ];
}

function statusLine(clamp) {
  switch (clamp.status) {
    case 'waiting': return 'Waiting for the first reading from your meter';
    case 'stale':   return `Meter quiet · last reading ${fmtAgo(clamp.updatedAgo)}`;
    case 'offline': return 'Meter offline — retrying…';
    default:        return clamp.updatedAgo != null ? `Updated ${fmtAgo(clamp.updatedAgo)}` : '';
  }
}

// ── Home: live whole-home readout inside the battery card ────────────────────
// `hc` is the hero card's palette (light-on-dark when the card is bold).
function ClampHeroLive({ c, hc, dark, clamp, onOpen }) {
  const m = clamp.mains;
  const live = clamp.status === 'live';
  const shown = useCountUp(m && m.watts != null ? Math.round(m.watts) : 0, { duration: 600 });
  const [num, unit] = splitWatts(m && m.watts != null ? shown : null);
  const perHour = m && m.watts != null ? SaveHorTariff.costPerHour(m.watts) : null;
  const segs = splitSegments(c, clamp);

  return (
    <button onClick={() => onOpen(MAINS)} style={{
      width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
      background: hc.surfaceAlt, borderRadius: 16, padding: '12px 14px 13px',
      display: 'flex', flexDirection: 'column', gap: 8, WebkitTapHighlightColor: 'transparent',
    }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 10.5, fontWeight: 800, letterSpacing: 0.6, color: hc.textMuted }}>
          <PulseDot color={live ? hc.primary : hc.textLight} on={live} />
          WHOLE HOME · {live ? 'LIVE' : clamp.status === 'stale' ? 'LAST READING' : 'METER'}
          {clamp.preview && <PreviewBadge dark={dark} c={c} />}
        </span>
        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, fontWeight: 700, color: hc.textMuted, fontVariantNumeric: 'tabular-nums' }}>
          {perHour != null && `≈ $${perHour.toFixed(2)}/h`}
          <IcChevR size={14} color={hc.textLight} />
        </span>
      </div>

      <div style={{ display: 'flex', alignItems: 'baseline', gap: 5, color: hc.text }}>
        <span style={{ fontSize: 34, fontWeight: 800, letterSpacing: -1.2, lineHeight: 1, fontVariantNumeric: 'tabular-nums', opacity: live ? 1 : 0.6 }}>{num}</span>
        <span style={{ fontSize: 16, fontWeight: 700, color: hc.textMuted }}>{unit}</span>
        <span style={{ fontSize: 12, fontWeight: 600, color: hc.textMuted, marginLeft: 4 }}>drawing now</span>
      </div>

      {segs
        ? <SplitBar segs={segs} dark={dark} legendColor={hc.textMuted} valueColor={hc.text} />
        : statusLine(clamp) && <div style={{ fontSize: 12, fontWeight: 600, color: hc.textMuted }}>{statusLine(clamp)}</div>}
    </button>
  );
}

// ── Home: one tile per aircon compressor circuit ─────────────────────────────
function ClampCircuitTiles({ c, clamp, onOpen }) {
  return <>{clamp.circuits.map((ci) => <ClampCircuitTile key={ci.channel} c={c} clamp={clamp} circuit={ci} onOpen={() => onOpen(ci.channel)} />)}</>;
}

function ClampCircuitTile({ c, clamp, circuit, onOpen }) {
  const tone = channelTone(c, circuit.channel);
  const has = circuit.watts != null;
  const on = circuit.running;
  const shown = useCountUp(has ? Math.round(circuit.watts) : 0, { duration: 500 });
  const value = has ? (on ? fmtWatts(shown) : 'Idle')
    : clamp.circuitsState === 'loading' ? '…' : 'No data yet';
  return (
    <button onClick={onOpen} style={{
      background: c.surface, border: `1.5px solid ${on ? tone.bar : c.borderSoft}`,
      borderRadius: 16, padding: '8px 6px 10px', position: 'relative',
      display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
      cursor: 'pointer', fontFamily: 'inherit', minHeight: 102,
      transition: 'border-color .2s', WebkitTapHighlightColor: 'transparent',
    }}>
      <div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
        {on && <span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: 0.5, color: tone.fg }}>LIVE</span>}
        <PulseDot color={on ? tone.bar : c.border} on={on} />
      </div>
      <div style={{ height: 46, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: on ? 1 : 0.6, filter: on ? 'none' : 'grayscale(0.6)' }}>
        <CompressorArt size={46} running={on} tone={tone} />
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', maxWidth: '100%' }}>
        <div style={{ fontSize: 11.5, fontWeight: 600, color: c.textMuted, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '100%' }}>{circuit.label}</div>
        <div style={{ fontSize: has ? 13.5 : 11.5, fontWeight: 700, color: on ? tone.fg : c.textLight, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>{value}</div>
      </div>
    </button>
  );
}

// ── tap-in detail sheet (whole home or one circuit) ──────────────────────────
function ClampSheet({ c, clamp, channel, onSelect, onClose }) {
  const [range, setRange] = useState('24h');
  // `selRaw` is the bar the finger last touched. AnBars sets it on pointer-DOWN,
  // so merely tapping or starting a scroll anywhere over the chart selects a
  // bar — it is almost never null by the time someone reaches for the range
  // toggle. This effect clears it on a range change, but effects run AFTER the
  // render that the change triggers, so for exactly one render an index from
  // the OLD range was being used to read the NEW range's arrays: while the new
  // range is still fetching there are no arrays at all (`hist.keys` undefined),
  // and once fetched the index is often past the end (bar 20 of 24 hours, then
  // 7 days). Either way the read threw, and with no error boundary anywhere
  // that unmounted the whole app — the white screen that only a restart fixed.
  // `sel` below is the selection validated against the data actually in hand
  // this render, so nothing downstream can index out of bounds.
  const [selRaw, setSel] = useState(null);
  useEffect(() => { setSel(null); }, [range, channel]);
  const hist = useClampHistory(clamp, range);
  const scrollRef = useRef(null);
  useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = 0; }, [channel]);

  const isMains = channel === MAINS;
  const view = clamp.channel(channel);
  if (!view) return null;
  const tone = channelTone(c, channel);
  const hasLive = view.watts != null;
  const liveNow = hasLive && (clamp.status === 'live' || (!isMains && clamp.circuitsState === 'live'));
  const [num, unit] = splitWatts(view.watts);
  const segs = isMains ? splitSegments(c, clamp) : null;
  const homeW = clamp.mains && clamp.mains.watts;

  const bars = !hist.loading && !hist.error && hist.byChannel ? hist.byChannel[channel] : null;
  const total = sum(bars);
  const insights = bars ? clampInsights(hist, channel) : [];
  const cfg = CLAMP_RANGES[range];
  // Only honour a selection that actually points at a bar we're drawing now.
  const keys = (bars && hist.keys) || [];
  const sel = bars && selRaw != null && selRaw >= 0 && selRaw < bars.length ? selRaw : null;
  const readKwh = sel == null ? total : bars[sel];
  const readLabel = sel == null ? cfg.label : fmtBucket(keys[sel], hist.bucket, 'long');
  const ticks = bars && keys.length ? [0, 0.25, 0.5, 0.75, 1].map((f) => keys[Math.round(f * (keys.length - 1))]) : [];

  const stat = (label, value) => (
    <div style={{ flex: 1, minWidth: 0, background: c.surface, borderRadius: 12, padding: '8px 10px' }}>
      <div style={{ fontSize: 10, fontWeight: 700, color: c.textLight, letterSpacing: 0.4, textTransform: 'uppercase' }}>{label}</div>
      <div style={{ fontSize: 14.5, fontWeight: 700, color: c.text, marginTop: 1, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>{value}</div>
    </div>
  );
  const note = (text) => (
    <div style={{ borderRadius: 14, border: `1.5px dashed ${c.border}`, padding: '14px 12px', textAlign: 'center', fontSize: 12.5, fontWeight: 600, color: c.textMuted, lineHeight: 1.4 }}>{text}</div>
  );

  return (
    <div onClick={onClose} style={{
      position: 'fixed', inset: 0, background: 'rgba(20,30,25,0.42)', zIndex: 120,
      display: 'flex', alignItems: 'flex-end', justifyContent: 'center', animation: 'm-fadeup .18s ease',
    }}>
      <div ref={scrollRef} onClick={(e) => e.stopPropagation()} style={{
        width: '100%', maxHeight: '92vh', overflowY: 'auto', background: c.bg, borderRadius: '24px 24px 0 0',
        padding: '12px 20px calc(24px + env(safe-area-inset-bottom))', animation: 'sh-slideup .26s ease',
        display: 'flex', flexDirection: 'column', gap: 16,
      }}>
        <div style={{ width: 36, height: 4, background: c.border, borderRadius: 2, alignSelf: 'center', flexShrink: 0 }} />

        {/* header */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
          {!isMains && (
            <button onClick={() => onSelect(MAINS)} aria-label="Back to whole home" style={{ width: 34, height: 34, border: 'none', borderRadius: 999, background: c.surfaceAlt, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
              <IcArrowL size={16} color={c.textMuted} />
            </button>
          )}
          <div style={{ width: 50, height: 50, borderRadius: 15, background: tone.soft, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
            {isMains ? <IcHome size={26} color={tone.fg} /> : <CompressorArt size={40} running={view.running} tone={tone} />}
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
              <span style={{ fontSize: 19, fontWeight: 700, color: c.text, letterSpacing: -0.3 }}>{view.label}</span>
              {clamp.preview && <PreviewBadge c={c} />}
            </div>
            <div style={{ fontSize: 12.5, color: c.textMuted, marginTop: 1 }}>{view.description}</div>
          </div>
          <button onClick={onClose} aria-label="Close" style={{ width: 34, height: 34, border: 'none', borderRadius: 999, background: c.surfaceAlt, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
            <IcX size={16} color={c.textMuted} />
          </button>
        </div>

        {/* RIGHT NOW */}
        <div style={{ background: c.surfaceAlt, borderRadius: 18, padding: 14, display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
            <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 800, letterSpacing: 0.5, color: c.textLight }}>
              <PulseDot color={liveNow ? c.primary : c.border} on={liveNow} /> RIGHT NOW
            </span>
            <span style={{ fontSize: 11.5, fontWeight: 600, color: c.textLight }}>{statusLine(clamp)}</span>
          </div>

          {hasLive ? (
            <>
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
                <div style={{ display: 'flex', alignItems: 'baseline', gap: 5, color: c.text }}>
                  <span style={{ fontSize: 44, fontWeight: 800, letterSpacing: -1.6, lineHeight: 1, fontVariantNumeric: 'tabular-nums' }}>{num}</span>
                  <span style={{ fontSize: 18, fontWeight: 700, color: c.textMuted }}>{unit}</span>
                </div>
                {!isMains && (
                  <span style={{ padding: '6px 10px', borderRadius: 999, fontSize: 12, fontWeight: 700, whiteSpace: 'nowrap',
                    background: view.running ? tone.soft : c.surface, color: view.running ? tone.fg : c.textMuted }}>
                    {view.running ? 'Compressor running' : 'Compressor idle'}
                  </span>
                )}
              </div>
              {/* The mains has a stored V/A reading; circuits only expose watts +
                  today's kWh, so they show today's use instead. */}
              <div style={{ display: 'flex', gap: 8 }}>
                {isMains
                  ? stat('Voltage', view.voltage != null ? `${Number(view.voltage).toFixed(1)} V` : '—')
                  : stat('Today', view.todayKwh != null ? `${fmtKwh(view.todayKwh)} kWh` : '—')}
                {isMains
                  ? stat('Current', view.current != null ? `${Number(view.current).toFixed(2)} A` : '—')
                  : stat('Today', view.todayKwh != null ? money(view.todayKwh) : '—')}
                {stat('Cost', `$${SaveHorTariff.costPerHour(view.watts).toFixed(2)}/h`)}
              </div>
              {!isMains && view.todayKwh != null && clamp.mains && clamp.mains.todayKwh > 0 && (
                <div style={{ fontSize: 12, fontWeight: 600, color: c.textMuted }}>
                  {pct(view.todayKwh, clamp.mains.todayKwh)}% of your home's energy so far today
                </div>
              )}
              {!isMains && homeW > 0 && (
                <div>
                  <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 600, color: c.textMuted, marginBottom: 6 }}>
                    <span>Share of your home right now</span>
                    <b style={{ color: c.text, fontVariantNumeric: 'tabular-nums' }}>{pct(Math.min(view.watts, homeW), homeW)}%</b>
                  </div>
                  <div style={{ height: 8, borderRadius: 999, background: c.surface, overflow: 'hidden' }}>
                    <div style={{ height: '100%', width: `${pct(Math.min(view.watts, homeW), homeW)}%`, background: tone.bar, borderRadius: 999, transition: 'width .6s ease' }} />
                  </div>
                </div>
              )}
              {isMains && segs && (
                <div>
                  <div style={{ fontSize: 12, fontWeight: 600, color: c.textMuted, marginBottom: 6 }}>Where it's going · tap a circuit</div>
                  <SplitBar segs={segs} legendColor={c.textMuted} valueColor={c.text} onPick={onSelect} />
                </div>
              )}
              {isMains && clamp.circuitsState === 'unavailable' && (
                <div style={{ fontSize: 12, fontWeight: 500, color: c.textLight, lineHeight: 1.4 }}>
                  Couldn't read the circuit split from your meter just now — retrying.
                </div>
              )}
            </>
          ) : note(isMains
            ? (clamp.status === 'offline' ? "Can't reach your meter right now — retrying automatically." : 'Waiting for the first reading from your meter.')
            : (clamp.circuitsState === 'loading' ? 'Loading this circuit…' : "Couldn't read this circuit from your meter just now — retrying automatically."))}
        </div>

        {/* HISTORY */}
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
            <div style={{ fontSize: 16, fontWeight: 700, color: c.text, letterSpacing: -0.2 }}>Energy used</div>
            <div style={{ display: 'flex', background: c.surfaceAlt, padding: 3, borderRadius: 10, gap: 2 }}>
              {Object.keys(CLAMP_RANGES).map((r) => (
                <button key={r} onClick={() => setRange(r)} style={{
                  height: 30, padding: '0 10px', border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit',
                  fontSize: 12.5, fontWeight: 600, background: range === r ? c.surface : 'transparent',
                  color: range === r ? c.text : c.textMuted, boxShadow: range === r ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
                }}>{CLAMP_RANGES[r].tab}</button>
              ))}
            </div>
          </div>

          {/* The chart is the one thing here rendering a shape the backend
              decides, so it gets its own boundary: if it ever throws again,
              only this card falls back — the sheet and the app stay up.
              `resetKey` gives it a clean try whenever the range or circuit
              changes, so a single bad render is never sticky. */}
          <ErrorBoundary c={c} resetKey={`${channel}:${range}`} message="Couldn't load this chart">
          <div style={{ background: c.surface, border: `1px solid ${c.borderSoft}`, borderRadius: 18, padding: '14px 16px' }}>
            {hist.loading ? note('Loading history…')
              : hist.error ? note("Couldn't load history right now. Try again in a bit.")
              : !bars ? note(view.todayKwh != null
                  ? `Hour-by-hour history for this circuit is coming soon. So far today: ${fmtKwh(view.todayKwh)} kWh (${money(view.todayKwh)}).`
                  : 'Hour-by-hour history for this circuit is coming soon.')
              : total <= 0 ? note(`No readings in the ${cfg.label.toLowerCase()} yet.`)
              : (
                <>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 8, minHeight: 22 }}>
                    <span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: 0.4, textTransform: 'uppercase', color: sel == null ? c.textLight : c.primaryDk }}>{readLabel}</span>
                    {sel != null && window.AnClear && <AnClear c={c} onClick={() => setSel(null)} />}
                  </div>
                  <div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginTop: 2 }}>
                    <span style={{ fontSize: 30, fontWeight: 700, color: c.text, letterSpacing: -0.8, fontVariantNumeric: 'tabular-nums' }}>{money(readKwh)}</span>
                    <span style={{ fontSize: 13.5, fontWeight: 600, color: c.textMuted, fontVariantNumeric: 'tabular-nums' }}>{fmtKwh(readKwh)} kWh</span>
                  </div>
                  <div style={{ marginTop: 14 }}>
                    <AnBars bars={bars} sel={sel} onPick={setSel} height={110}
                      gap={range === '24h' ? 2 : range === '7d' ? 8 : 2} radius={range === '7d' ? 5 : 2}
                      colorFor={(i, isSel) => (isSel ? tone.fg : tone.bar)} />
                    <div style={{ marginTop: 7, display: 'flex', justifyContent: 'space-between', fontSize: 10.5, fontWeight: 500, color: c.textLight }}>
                      {ticks.map((k, i) => <span key={i}>{fmtBucket(k, hist.bucket, range === '7d' ? 'dow' : 'short')}</span>)}
                    </div>
                  </div>
                  {isMains && hist.source === 'household' && (
                    <div style={{ marginTop: 10, fontSize: 11.5, color: c.textLight, lineHeight: 1.4 }}>
                      History starts from when SaveHor began recording your meter.
                    </div>
                  )}
                </>
              )}
          </div>
          </ErrorBoundary>

          {insights.length > 0 && (
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
              {insights.map((ins) => (
                <div key={ins.label} style={{ background: c.surface, border: `1px solid ${c.borderSoft}`, borderRadius: 14, padding: '10px 12px' }}>
                  <div style={{ fontSize: 10.5, fontWeight: 700, color: c.textLight, letterSpacing: 0.4, textTransform: 'uppercase' }}>{ins.label}</div>
                  <div style={{ fontSize: 16, fontWeight: 800, color: c.text, marginTop: 2, fontVariantNumeric: 'tabular-nums' }}>{ins.value}</div>
                  <div style={{ fontSize: 11.5, fontWeight: 500, color: c.textMuted, marginTop: 1, lineHeight: 1.3 }}>{ins.sub}</div>
                </div>
              ))}
            </div>
          )}
        </div>

        <div style={{ fontSize: 11, color: c.textLight, textAlign: 'center' }}>
          Measured by your whole-home CT clamp · {SaveHorTariff.perKwhLabel()} estimate
        </div>
        <Btn kind="primary" size="lg" full c={c} onClick={onClose}>Done</Btn>
      </div>
    </div>
  );
}

Object.assign(window, { useClamp, ClampHeroLive, ClampCircuitTiles, ClampSheet, isClampDevice });
})();
