// aircon.jsx — HomeKit-style aircon control, integrated into the Home screen.
//
// Reuses the proven API logic from aircon.html (endpoints, field names, command
// format, localStorage config). The token + base URL live only in localStorage,
// entered by the user via the in-panel settings gear — never hardcoded.
//
// Public surface (used by screen-home.jsx): <AirconTiles c={c} />
//   • polls /aircon/all once and renders one tile per aircon; tap opens <AirconPanel>
//   • also exports window.SaveHorAircon for one-tap suggestion cards (Home's tip)
//   • everything else (polling, panel, slider, steppers, settings) is encapsulated here.

// Wrapped in an IIFE: this is a buildless app where every <script type=text/babel>
// shares one global lexical scope, so top-level `const`s would collide with other
// files (e.g. app.jsx also destructures React hooks). The IIFE keeps everything
// file-local and exports only AirconTiles + SaveHorAircon onto window.
(function () {
const { useState, useEffect, useRef, useCallback, useLayoutEffect } = React;

// ── config + API ─────────────────────────────────────────────────────────────
// Aircon now runs through the SAME main backend as the rest of the app (auth,
// plugs, clamp), via the shared SaveHorAPI layer and the user's login token.
// There is no separate aircon backend URL or token any more — being logged in
// is all that's needed.
//   GET  /household/aircons    -> [ { id, mqtt_device_id, state:{...} }, ... ]
//   POST /devices/{id}/aircon  -> { action, value }
const AC_TMIN = 16, AC_TMAX = 31;
const AC_MODES = [['cool', 'Cool'], ['dry', 'Dry'], ['fan_only', 'Fan'], ['auto', 'Auto']];
const AC_FANS = [['AUTO', 'Auto'], ['QUIET', 'Quiet'], ['1', '1'], ['2', '2'], ['3', '3'], ['4', '4']];
const AC_EASE = 'cubic-bezier(.32,.72,0,1)';

const AirconAPI = {
  _ids: {},   // aircon name (mqtt_device_id) -> device UUID, learned on each getAll()
  // Auth is the user's login token, held by the shared SaveHorAPI layer.
  getToken() { return (window.SaveHorAPI && window.SaveHorAPI.getToken()) || ''; },
  // The whole fleet in one call: { name: rawState, ... } (same shape as before).
  async getAll() {
    const list = await window.SaveHorAPI.getAircons();   // [ { id, mqtt_device_id, state } ]
    const out = {};
    this._ids = {};
    for (const d of list) {
      this._ids[d.mqtt_device_id] = d.id;
      out[d.mqtt_device_id] = d.state || {};
    }
    return out;
  },
  // action: power | mode | temp | fan — `device` is the aircon name from getAll().
  command(device, action, value) {
    const id = this._ids[device];
    if (!id) return Promise.reject(new Error('Unknown aircon — reload the screen'));
    return window.SaveHorAPI.airconCommand(id, action, value);
  },
};

// matts-beloved-aircon → "Matts Beloved Aircon". Generic tidy-up, no per-name mapping.
function prettyName(name) {
  const s = String(name).replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
  return s.replace(/\b\w/g, (ch) => ch.toUpperCase()) || 'Aircon';
}

// Derive a friendly view of the raw state for display.
function acView(state) {
  const mode = (state && state.mode) || 'off';
  const action = (state && state.action) || 'off';
  const on = mode !== 'off';
  const setpoint = state && state.temperature != null ? state.temperature : null;
  const room = state && state.roomTemperature != null ? state.roomTemperature : null;
  const fan = state ? String(state.fan) : 'AUTO';
  const pretty = { cool: 'Cooling', cooling: 'Cooling', idle: 'Idle', off: 'Off',
                   dry: 'Drying', fan_only: 'Fan', auto: 'Auto' }[action] || action;
  const statusLabel = on ? `${pretty}${setpoint != null ? ' · ' + setpoint + '°' : ''}` : 'Off';
  return { mode, action, on, setpoint, room, fan, pretty, statusLabel };
}

// ── prefers-reduced-motion ───────────────────────────────────────────────────
function useReducedMotion() {
  const [rm, setRm] = useState(() =>
    !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches));
  useEffect(() => {
    if (!window.matchMedia) return;
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    const on = () => setRm(mq.matches);
    mq.addEventListener ? mq.addEventListener('change', on) : mq.addListener(on);
    return () => { mq.removeEventListener ? mq.removeEventListener('change', on) : mq.removeListener(on); };
  }, []);
  return rm;
}

// ── the live-fleet hook: ONE poller for every aircon ─────────────────────────
// Holds a map { deviceName: rawState }. Commands & drags are tracked per device,
// so a slider drag (or in-flight command) on one aircon never yanks its optimistic
// state, while the others keep refreshing. This is the single source of polling —
// each tile is handed a thin per-device view built by useAircons's consumers.
function useAircons() {
  const [states, setStates] = useState(null);   // { name: state } | null (null = not loaded yet)
  const [err, setErr] = useState('');
  const busyRef = useRef(new Set());   // device names with a command in flight
  const dragRef = useRef(new Set());   // device names being dragged / stepped
  const aliveRef = useRef(true);
  // Guards against overlapping requests (the 5s interval + the visibilitychange
  // re-fetch on tab/app foreground can both be in flight at once): only the
  // response to the MOST RECENTLY issued request is allowed to update state, so
  // a slower, older request landing late can never stomp fresher data.
  const seqRef = useRef(0);
  // A device missing from ONE response doesn't drop its tile — only after it's
  // absent from `missTolerance` consecutive polls, so a single incomplete
  // backend response can't blank out a real, connected aircon.
  const missesRef = useRef({});
  const MISS_TOLERANCE = 2;

  const refetch = useCallback(async () => {
    // Aircon auth is just the normal login token now — nothing to "connect".
    if (!AirconAPI.getToken()) return;
    const mySeq = ++seqRef.current;
    try {
      const all = await AirconAPI.getAll();
      if (!aliveRef.current || mySeq !== seqRef.current) return;   // superseded by a newer request
      const freshNames = new Set(Object.keys(all || {}));
      setStates((prev) => {
        const next = { ...(all || {}) };
        if (prev) {
          for (const name of Object.keys(prev)) {
            // keep the optimistic copy for any device mid-command or mid-drag
            if (busyRef.current.has(name) || dragRef.current.has(name)) { next[name] = prev[name]; continue; }
            // device missing from this response — tolerate a few misses before dropping its tile
            if (!freshNames.has(name)) {
              const misses = (missesRef.current[name] || 0) + 1;
              missesRef.current[name] = misses;
              if (misses < MISS_TOLERANCE) next[name] = prev[name];
            }
          }
        }
        // any device the backend actually reported this time is confirmed present again
        for (const name of freshNames) missesRef.current[name] = 0;
        return next;
      });
      setErr('');
    } catch (e) {
      if (aliveRef.current && mySeq === seqRef.current) setErr(e.message);
    }
  }, []);

  // patch: optimistic fields for THIS device merged immediately so the UI feels instant.
  const sendCommand = useCallback(async (device, action, value, patch) => {
    if (busyRef.current.has(device)) return;
    busyRef.current.add(device);
    setErr('');
    if (patch) setStates((s) => ({ ...(s || {}), [device]: { ...((s && s[device]) || {}), ...patch } }));
    try {
      await AirconAPI.command(device, action, value);
      await new Promise((r) => setTimeout(r, 1200)); // let the unit report back
      busyRef.current.delete(device);
      await refetch();
    } catch (e) {
      busyRef.current.delete(device);
      if (aliveRef.current) setErr(e.message);
    }
  }, [refetch]);

  useEffect(() => {
    aliveRef.current = true;
    refetch();
    const id = setInterval(() => { if (!document.hidden) refetch(); }, 5000);
    const onVis = () => { if (!document.hidden) refetch(); };
    document.addEventListener('visibilitychange', onVis);
    return () => { aliveRef.current = false; clearInterval(id); document.removeEventListener('visibilitychange', onVis); };
  }, [refetch]);

  const setDragging = useCallback((device, d) => {
    if (d) dragRef.current.add(device); else dragRef.current.delete(device);
  }, []);

  return { states, err, sendCommand, refetch, setDragging };
}

// ── small building blocks ────────────────────────────────────────────────────
function PowerGlyph({ size = 20, color = '#fff' }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
      <path d="M12 3v8.5" stroke={color} strokeWidth="2.3" strokeLinecap="round" />
      <path d="M6.6 6.6a7.5 7.5 0 1 0 10.8 0" stroke={color} strokeWidth="2.3" strokeLinecap="round" />
    </svg>
  );
}

// Per-mode identity: the icon + colour the tile and slider adopt so the control
// visually *becomes* cool / dry / fan / auto as you switch.
const MODE_THEME = {
  cool:     { label: 'Cool', accent: '#2F83C9', grad: 'linear-gradient(180deg,#7CB8DE,#2F83C9)', soft: '#E4F1FA' },
  dry:      { label: 'Dry',  accent: '#2E9E8E', grad: 'linear-gradient(180deg,#84D2C6,#2E9E8E)', soft: '#E1F3F0' },
  fan_only: { label: 'Fan',  accent: '#6E8B5A', grad: 'linear-gradient(180deg,#BCCBA9,#6E8B5A)', soft: '#EDF1E6' },
  auto:     { label: 'Auto', accent: '#2E6B4A', grad: 'linear-gradient(180deg,#84C29E,#2E6B4A)', soft: '#E5EFE5' },
  off:      { label: 'Off',  accent: '#8A938E', grad: 'linear-gradient(180deg,#DCDFD9,#9AA29C)', soft: '#F1EEE6' },
};
const modeTheme = (v) => MODE_THEME[v.on ? v.mode : 'off'] || MODE_THEME.off;

function Snowflake({ size = 22, color = '#fff' }) {
  const s = { stroke: color, strokeWidth: 2, strokeLinecap: 'round' };
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
      <path d="M12 2.5v19M3.8 7.2l16.4 9.6M20.2 7.2L3.8 16.8" {...s} />
      <path d="M12 5.4l2.1 1.6M12 5.4L9.9 7M12 18.6l2.1-1.6M12 18.6L9.9 17M5.2 9.2l.4 2.6M5.2 9.2l2.5-.9M18.8 14.8l-.4-2.6M18.8 14.8l-2.5.9M5.2 14.8l2.5.9M5.2 14.8l.4-2.6M18.8 9.2l-2.5-.9M18.8 9.2l-.4 2.6" {...s} strokeWidth="1.5" />
    </svg>
  );
}
function AutoGlyph({ size = 22, color = '#fff' }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
      <circle cx="12" cy="12" r="8.5" stroke={color} strokeWidth="2" />
      <path d="M12 3.5a8.5 8.5 0 0 0 0 17z" fill={color} />
    </svg>
  );
}
// The dynamic "logo" — swaps with the current mode (grey IcAC when off).
function ModeGlyph({ mode, on, size = 22, color = '#fff' }) {
  const m = on ? mode : 'off';
  if (m === 'cool')     return <Snowflake size={size} color={color} />;
  if (m === 'dry')      return <IcWater size={size} color={color} />;
  if (m === 'fan_only') return <IcWind size={size} color={color} />;
  if (m === 'auto')     return <AutoGlyph size={size} color={color} />;
  return <IcAC size={size} color={color} />;
}

// ── ± temperature steppers (flank the big number, for fine ±1° nudges) ───────
function StepGlyph({ kind, size = 22, color }) {
  const s = { stroke: color, strokeWidth: 2.6, strokeLinecap: 'round' };
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
      <path d="M5 12h14" {...s} />
      {kind === 'plus' && <path d="M12 5v14" {...s} />}
    </svg>
  );
}
function StepButton({ c, theme, disabled, onClick, kind, label }) {
  return (
    <button onClick={onClick} disabled={disabled} aria-label={label} style={{
      width: 52, height: 52, borderRadius: 26, flexShrink: 0,
      cursor: disabled ? 'default' : 'pointer', fontFamily: 'inherit',
      border: `1.5px solid ${c.borderSoft}`, background: c.surface,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      opacity: disabled ? 0.4 : 1, WebkitTapHighlightColor: 'transparent',
      transition: 'opacity .15s, border-color .15s, background .15s',
    }}>
      <StepGlyph kind={kind} size={22} color={disabled ? c.textLight : theme.accent} />
    </button>
  );
}

// ── the vertical drag slider (track only; the big number lives in the panel) ──
// Purely a visual gauge now — no drag-to-set. The ± steppers either side of
// it (rendered by AirconPanel) are the only way to actually change anything.
function TempBar({ c, value, on, mode, theme, room }) {
  const H = 170;
  const pct = (value - AC_TMIN) / (AC_TMAX - AC_TMIN);
  const roomPct = room != null ? Math.max(0, Math.min(1, (room - AC_TMIN) / (AC_TMAX - AC_TMIN))) : null;
  const fanMode = on && mode === 'fan_only';

  return (
    <div style={{
      position: 'relative', width: '100%', height: H, borderRadius: 26, overflow: 'hidden',
      background: on ? theme.soft : c.surfaceAlt, border: `1px solid ${c.borderSoft}`, flexShrink: 0,
    }}>
      {/* fill — tinted to the active mode */}
      <div style={{
        position: 'absolute', left: 0, right: 0, bottom: 0, height: `${pct * 100}%`,
        background: on ? theme.grad : `linear-gradient(180deg,${c.border},${c.textLight})`,
        opacity: fanMode ? 0.55 : 1,
        transition: `height .25s ${AC_EASE}, opacity .2s ease`,
      }} />
      {/* room-temperature marker */}
      {roomPct != null && (
        <div style={{ position: 'absolute', left: 0, right: 0, bottom: `${roomPct * 100}%`, pointerEvents: 'none' }}>
          <div style={{ height: 2, background: 'rgba(31,42,36,0.28)' }} />
          <div style={{ position: 'absolute', right: 10, bottom: 3, fontSize: 10, fontWeight: 700, color: c.textMuted }}>{room}°</div>
        </div>
      )}
      {/* mode badge — the dynamic indicator, always legible on a white chip */}
      <div style={{
        position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)',
        width: 32, height: 32, borderRadius: 16, background: 'rgba(255,255,255,0.92)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        boxShadow: '0 1px 4px rgba(20,30,25,0.18)', pointerEvents: 'none',
        transition: 'background .2s ease',
      }}>
        <ModeGlyph mode={mode} on={on} size={18} color={on ? theme.accent : c.textLight} />
      </div>
    </div>
  );
}

// Fan speed's horizontal counterpart — a segmented bar (fan speed is a fixed
// list of discrete options, not a continuum, so segments read more honestly
// than a smooth fill). Also purely a visual gauge; the ± steppers control it.
function FanBar({ c, theme, on, index, count }) {
  return (
    <div style={{ display: 'flex', gap: 5, flex: 1, height: 16 }}>
      {Array.from({ length: count }).map((_, i) => (
        <div key={i} style={{
          flex: 1, borderRadius: 5,
          background: on && i <= index ? theme.accent : c.borderSoft,
          transition: 'background .15s ease',
        }} />
      ))}
    </div>
  );
}

// ── horizontal swipe picker for Mode / Fan — native scroll + snap (not custom
// drag math) so it never feels finicky, with pills big enough to grab easily.
// Tap any pill to jump straight to it; the active one re-centers itself.
function SlideSelector({ c, options, value, onChange }) {
  const trackRef = useRef(null);
  const itemRefs = useRef({});
  useEffect(() => {
    const el = itemRefs.current[value];
    if (el) el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
  }, [value]);
  return (
    <div ref={trackRef} className="sh-hscroll" style={{
      display: 'flex', gap: 10, overflowX: 'auto', scrollSnapType: 'x mandatory',
      WebkitOverflowScrolling: 'touch', padding: '2px 2px 6px', margin: '0 -2px',
    }}>
      {options.map(([key, label, accent]) => {
        const active = value === key;
        return (
          <button key={key} ref={(el) => { itemRefs.current[key] = el; }}
            onClick={() => onChange(key)} style={{
              scrollSnapAlign: 'center', flexShrink: 0, minWidth: 84, height: 52,
              borderRadius: 18, cursor: 'pointer', fontFamily: 'inherit',
              fontSize: 14, fontWeight: 700, WebkitTapHighlightColor: 'transparent',
              border: `1.5px solid ${active ? accent : c.borderSoft}`,
              background: active ? accent : c.surface, color: active ? '#fff' : c.text,
              transform: active ? 'scale(1.06)' : 'scale(1)',
              transition: 'background .15s, border-color .15s, color .15s, transform .15s',
            }}>{label}</button>
        );
      })}
    </div>
  );
}

// ── the expanded control panel (grows out of the tile) ───────────────────────
function AirconPanel({ c, ac, tileRect, onClose }) {
  const reduce = useReducedMotion();
  const overlayRef = useRef(null);
  const [shown, setShown] = useState(false);
  const [initialTf, setInitialTf] = useState('none');
  const [sleepOpen, setSleepOpen] = useState(false);
  const sleepSchedule = window.useDeviceSleepSchedule ? window.useDeviceSleepSchedule(ac.id) : null;
  const sleepEnabled = !!(sleepSchedule && sleepSchedule.enabled);
  const v = acView(ac.state);
  const th = modeTheme(v);
  const fanMode = v.on && v.mode === 'fan_only';

  // local target temp — tracks live state unless the user is dragging.
  const [temp, setTemp] = useState(v.setpoint || 24);
  const draggingRef = useRef(false);
  useEffect(() => {
    if (!draggingRef.current && v.setpoint != null) setTemp(v.setpoint);
  }, [v.setpoint]);

  // Panel is a bottom sheet leaving a tappable scrim strip on top.
  const TOP_GAP = 52;

  // Compute the "grow from tile" transform once, before first paint.
  useLayoutEffect(() => {
    if (reduce || !tileRect || !overlayRef.current) { setShown(true); return; }
    const cont = overlayRef.current.getBoundingClientRect();
    const panelLeft = cont.left, panelTop = cont.top + TOP_GAP;
    const panelW = cont.width, panelH = cont.height - TOP_GAP;
    const sx = tileRect.width / panelW;
    const sy = tileRect.height / panelH;
    const tx = tileRect.left - panelLeft;
    const ty = tileRect.top - panelTop;
    setInitialTf(`translate(${tx}px, ${ty}px) scale(${sx}, ${sy})`);
    requestAnimationFrame(() => requestAnimationFrame(() => setShown(true)));
  }, [reduce, tileRect]);

  const close = () => {
    if (reduce) { onClose(); return; }
    setShown(false);
    setTimeout(onClose, 300);
  };

  const setDragging = (d) => { draggingRef.current = d; ac.setDragging(d); };
  const togglePower = () => v.on ? ac.sendCommand('mode', 'off', { mode: 'off' })
                                 : ac.sendCommand('mode', 'cool', { mode: 'cool' });

  // ± steppers: adjust the number locally, then send ONE command ~600ms after the
  // last tap (so rapid presses don't fire a command each). setDragging holds off the
  // poll while the user is nudging.
  const tempRef = useRef(temp);
  useEffect(() => { tempRef.current = temp; }, [temp]);
  const stepTimerRef = useRef(null);
  useEffect(() => () => { if (stepTimerRef.current) clearTimeout(stepTimerRef.current); }, []);
  const bump = (delta) => {
    if (!v.on || fanMode) return;
    setDragging(true);
    setTemp((t) => Math.max(AC_TMIN, Math.min(AC_TMAX, t + delta)));
    if (stepTimerRef.current) clearTimeout(stepTimerRef.current);
    stepTimerRef.current = setTimeout(() => {
      setDragging(false);
      ac.sendCommand('temp', tempRef.current, { temperature: tempRef.current });
    }, 600);
  };

  // fan speed steps through AC_FANS by index — same instant, non-debounced
  // pattern Mode already uses (fan changes are single discrete jumps, not a
  // continuous value like temp, so there's nothing to coalesce).
  const fanIdx = Math.max(0, AC_FANS.findIndex(([key]) => key === v.fan));
  const bumpFan = (delta) => {
    if (!v.on) return;
    const next = Math.max(0, Math.min(AC_FANS.length - 1, fanIdx + delta));
    const key = AC_FANS[next][0];
    ac.sendCommand('fan', key, { fan: key });
  };

  const panelStyle = {
    position: 'absolute', left: 0, right: 0, bottom: 0, height: `calc(100% - ${TOP_GAP}px)`,
    background: c.bg, borderRadius: '28px 28px 0 0', zIndex: 131,
    display: 'flex', flexDirection: 'column', overflow: 'hidden',
    transformOrigin: '0 0',
    transform: shown ? 'none' : initialTf,
    opacity: shown ? 1 : (reduce ? 0 : 0.7),
    transition: reduce ? 'opacity .18s ease' : `transform .38s ${AC_EASE}, opacity .3s ease`,
    willChange: 'transform, opacity',
    boxShadow: '0 -8px 40px rgba(20,30,25,0.18)',
  };
  // Content fades/settles in just after the shell arrives.
  const contentStyle = {
    opacity: shown ? 1 : 0,
    transition: reduce ? 'none' : 'opacity .2s ease .12s',
  };

  return (
    <div ref={overlayRef} style={{ position: 'fixed', inset: 0, zIndex: 130 }}>
      {/* scrim (visible during the grow + on the top strip) */}
      <div onClick={close} style={{
        position: 'absolute', inset: 0, background: 'rgba(20,30,25,0.44)',
        opacity: shown ? 1 : 0, transition: 'opacity .3s ease',
      }} />

      <div style={panelStyle}>
        {/* grab handle */}
        <div style={{ width: 38, height: 4, background: c.border, borderRadius: 2, margin: '10px auto 4px', flexShrink: 0 }} />

        <div style={{ ...contentStyle, flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column',
          padding: '6px 22px calc(24px + env(safe-area-inset-bottom))' }}>

          {/* header */}
          <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
            <div style={{ width: 44, height: 44, borderRadius: 13, background: v.on ? th.soft : c.surfaceAlt, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'background .2s ease' }}>
              <ModeGlyph mode={v.mode} on={v.on} size={24} color={v.on ? th.accent : c.textLight} />
            </div>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ fontSize: 19, fontWeight: 700, color: c.text, letterSpacing: -0.3, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ac.label || 'Aircon'}</div>
              <div style={{ fontSize: 13, color: c.textMuted, marginTop: 1 }}>{ac.state ? v.statusLabel : 'Connecting…'}</div>
            </div>
            <button onClick={() => setSleepOpen(true)} aria-label="Sleep timer" style={{
              width: 38, height: 38, border: 'none', borderRadius: 999, cursor: 'pointer', position: 'relative',
              background: sleepEnabled ? c.primarySoft : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center',
            }}>
              <IcMoon size={18} color={sleepEnabled ? c.primary : c.textMuted} />
              {sleepEnabled && (
                <span style={{ position: 'absolute', top: 5, right: 5, width: 6, height: 6, borderRadius: 3, background: c.primary }} />
              )}
            </button>
            <button onClick={close} aria-label="Close" style={{ width: 34, height: 34, border: 'none', borderRadius: 999, background: c.surfaceAlt, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
              <IcX size={16} color={c.textMuted} />
            </button>
          </div>

          {/* power — kept right under the header so it's reachable with no scrolling */}
          <Btn kind={v.on ? 'danger' : 'primary'} size="lg" full c={c}
            onClick={togglePower} icon={<PowerGlyph size={19} color="#fff" />}
            style={{ marginTop: 10 }}>
            {v.on ? 'Turn off' : 'Turn on'}
          </Btn>

          {/* target temp — readout on top, then a vertical gauge flanked by ±:
              plus above (raises), minus below (lowers). The bar is display-only
              now — the steppers are the only way to actually change it. */}
          <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, padding: '16px 0 6px' }}>
            <div style={{ display: 'flex', alignItems: 'flex-start', color: !v.on || fanMode ? c.textMuted : c.text, fontVariantNumeric: 'tabular-nums', transition: 'color .2s ease' }}>
              <span style={{ fontSize: 52, fontWeight: 700, letterSpacing: -2, lineHeight: 0.9 }}>{temp}</span>
              <span style={{ fontSize: 22, fontWeight: 600, marginTop: 3 }}>°C</span>
            </div>
            <div style={{ fontSize: 11, fontWeight: 700, color: v.on ? th.accent : c.textLight, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 4 }}>
              {fanMode ? 'Fan only · no target' : 'Target temperature'}
            </div>
            <StepButton c={c} theme={th} kind="plus" label="Raise temperature"
              disabled={!v.on || fanMode || temp >= AC_TMAX} onClick={() => bump(1)} />
            <TempBar c={c} value={temp} on={v.on} mode={v.mode} theme={th} room={v.room} />
            <StepButton c={c} theme={th} kind="minus" label="Lower temperature"
              disabled={!v.on || fanMode || temp <= AC_TMIN} onClick={() => bump(-1)} />
            <div style={{ fontSize: 12.5, color: c.textMuted, marginTop: 2 }}>
              {v.room != null ? <>Room <b style={{ color: c.text, fontWeight: 700 }}>{v.room}°</b></> : 'Room —'}
            </div>
          </div>

          {/* fan speed — horizontal gauge + ± steppers, same display-only-bar
              pattern as temperature. Above Mode, per feedback. */}
          <div style={{ marginTop: 12 }}>
            <div style={{ fontSize: 11, fontWeight: 700, color: c.textLight, letterSpacing: 1, textTransform: 'uppercase', margin: '0 2px 8px' }}>Fan speed</div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
              <StepButton c={c} theme={th} kind="minus" label="Lower fan speed"
                disabled={!v.on || fanIdx <= 0} onClick={() => bumpFan(-1)} />
              <FanBar c={c} theme={th} on={v.on} index={fanIdx} count={AC_FANS.length} />
              <StepButton c={c} theme={th} kind="plus" label="Raise fan speed"
                disabled={!v.on || fanIdx >= AC_FANS.length - 1} onClick={() => bumpFan(1)} />
            </div>
            <div style={{ fontSize: 12.5, color: v.on ? c.text : c.textMuted, fontWeight: 600, textAlign: 'center', marginTop: 8 }}>
              {v.on ? (AC_FANS[fanIdx] ? AC_FANS[fanIdx][1] : v.fan) : '—'}
            </div>
          </div>

          {/* mode — swipe to change, tap any pill to jump straight to it */}
          <div style={{ marginTop: 16 }}>
            <div style={{ fontSize: 11, fontWeight: 700, color: c.textLight, letterSpacing: 1, textTransform: 'uppercase', margin: '0 2px 8px' }}>Mode</div>
            <SlideSelector c={c} value={v.mode}
              options={AC_MODES.map(([key, label]) => [key, label, MODE_THEME[key].accent])}
              onChange={(key) => ac.sendCommand('mode', key, { mode: key })} />
          </div>

          {/* status / error line */}
          <div style={{ minHeight: 18, textAlign: 'center', marginTop: 12, fontSize: 12,
            color: ac.err ? c.danger : c.textLight }}>
            {ac.err ? ac.err : 'Live · updates every few seconds'}
          </div>
        </div>
      </div>

      {sleepOpen && window.AirconSleepSheet &&
        <window.AirconSleepSheet c={c} deviceId={ac.id} label={ac.label} onClose={() => setSleepOpen(false)} />}
    </div>
  );
}

// ── the Home-screen tile (one per aircon) ────────────────────────────────────
// Lives in the "Right now" grid alongside the other devices. Tap opens the control
// card. The icon + colour track the mode. `ac` is a thin per-device view (id,
// state, err, sendCommand, setDragging, label) built by AirconTiles.
function AirconTile({ c, ac }) {
  const [open, setOpen] = useState(false);
  const btnRef = useRef(null);
  const rectRef = useRef(null);
  const v = acView(ac.state);
  const th = modeTheme(v);
  const on = v.on;
  const sleepSchedule = window.useDeviceSleepSchedule ? window.useDeviceSleepSchedule(ac.id) : null;
  const sleeping = !!(sleepSchedule && sleepSchedule.enabled);

  const onClick = () => {
    if (btnRef.current) rectRef.current = btnRef.current.getBoundingClientRect();
    setOpen(true);
  };

  // value line: setpoint when on, else Off / —
  const value = !ac.state ? '—' : on ? `${v.setpoint != null ? v.setpoint + '°' : '--'}` : 'Off';
  const sub = (ac.err && !ac.state) ? 'Reconnect' : (ac.label || 'Aircon');

  return (
    <>
      <button ref={btnRef}
        onClick={onClick}
        onContextMenu={(e) => e.preventDefault()}
        style={{
          background: c.surface, border: `1.5px solid ${on ? th.accent : 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',
          opacity: on ? 1 : 0.72,
        }}>
        {/* status dot */}
        <div style={{ position: 'absolute', top: 8, right: 8, width: 7, height: 7, borderRadius: 4, background: on ? th.accent : c.border }} />
        {/* sleep-timer indicator */}
        {sleeping && (
          <div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
            {IcMoon && <IcMoon size={12} color={c.primary} />}
          </div>
        )}
        {/* dynamic mode icon */}
        <div style={{ width: 46, height: 46, borderRadius: 13, background: on ? th.soft : c.surfaceAlt, display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: 2, transition: 'background .2s ease' }}>
          <ModeGlyph mode={v.mode} on={on} size={26} color={on ? th.accent : c.textLight} />
        </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%' }}>{sub}</div>
          <div style={{ fontSize: 13.5, fontWeight: 700, color: on ? th.accent : c.textLight, fontVariantNumeric: 'tabular-nums' }}>{value}</div>
        </div>
      </button>

      {open && <AirconPanel c={c} ac={ac} tileRect={rectRef.current}
        onClose={() => setOpen(false)} />}
    </>
  );
}

// ── public entry point: one tile per aircon ───────────────────────────────────
// Renders a plain fragment of tiles so they flow into whatever grid hosts it
// (1 aircon = 1 tile, several = a natural grid). `mgr` is the shared
// useAircons() result, lifted up to HomeScreen so TipHero can read the same
// live data instead of running its own second poller against the same
// endpoint (see the "one poller, shared by every consumer" note on
// SaveHorAircon.primaryOnFromStates below).
function AirconTiles({ c, mgr }) {
  // Never successfully loaded yet — either still loading, or every attempt so
  // far has failed. Show an honest placeholder instead of silently rendering
  // nothing, so a real API outage never looks like "you have no aircons" or
  // makes them appear to vanish without explanation. Once `states` has ever
  // loaded, a later error just freezes the tiles at their last-known values
  // (handled in useAircons) rather than blanking them out again.
  if (!mgr.states) {
    return (
      <div style={{
        borderRadius: 16, border: `1.5px dashed ${c.borderSoft}`, minHeight: 102,
        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
        gap: 6, padding: 8, textAlign: 'center',
      }}>
        <IcAC size={22} color={c.textLight} />
        <span style={{ fontSize: 10.5, fontWeight: 600, color: c.textMuted, lineHeight: 1.3 }}>
          {mgr.err ? 'Aircons offline — retrying…' : 'Loading aircons…'}
        </span>
      </div>
    );
  }
  const names = Object.keys(mgr.states);

  // The thin per-device view each tile/panel consumes, scoped to one device
  // (its command carries the device name; `id` is its backend UUID, used to
  // key that device's own sleep schedule).
  const viewFor = (name) => ({
    id: AirconAPI._ids[name],
    label: prettyName(name),
    state: mgr.states ? mgr.states[name] : null,
    err: mgr.err,
    sendCommand: (action, value, patch) => mgr.sendCommand(name, action, value, patch),
    setDragging: (d) => mgr.setDragging(name, d),
  });

  return <>{names.map((name) => <AirconTile key={name} c={c} ac={viewFor(name)} />)}</>;
}

// The aircon Home's "One degree warmer" tip would nudge in its live preview:
// first one on with a numeric setpoint. Pure function over an already-fetched
// states map — reused by TipHero (which now reads the SHARED useAircons()
// result from HomeScreen instead of running its own independent 5s poller
// against the same /household/aircons endpoint).
function primaryOnFromStates(states) {
  for (const device of Object.keys(states || {})) {
    const v = acView(states[device]);
    if (v.on && v.mode !== 'fan_only' && v.setpoint != null) return { device, now: v.setpoint };
  }
  return null;
}

// ── imperative helper for one-tap suggestion cards (e.g. Home's "One degree warmer")
// Decoupled from the tiles: it fetches fresh, acts, and the tiles' 5s poller shows
// the result. Targets aircons that are actually running with a real setpoint.
const SaveHorAircon = {
  // The aircon we'd nudge in the card's preview: first one on with a numeric setpoint.
  async primaryOn() {
    if (!AirconAPI.getToken()) return null;
    let all;
    try { all = await AirconAPI.getAll(); } catch (e) { return null; }
    return primaryOnFromStates(all);
  },
  // Raise every running aircon by `delta`°C (clamped 16–31). Returns how many moved.
  async warmerBy(delta = 1) {
    if (!AirconAPI.getToken()) return 0;
    let all;
    try { all = await AirconAPI.getAll(); } catch (e) { return 0; }
    let n = 0;
    for (const device of Object.keys(all || {})) {
      const v = acView(all[device]);
      if (!v.on || v.mode === 'fan_only' || v.setpoint == null) continue;
      const target = Math.max(AC_TMIN, Math.min(AC_TMAX, v.setpoint + delta));
      if (target === v.setpoint) continue;
      try { await AirconAPI.command(device, 'temp', target); n++; } catch (e) { /* skip this unit */ }
    }
    return n;
  },
};

// AC_TMIN/AC_TMAX/AC_FANS/prettyName exported so sleep.jsx's curve editor and
// device picker match the live aircon panel exactly, instead of duplicating them.
// useAircons + primaryOnFromStates exported so screen-home.jsx can lift the
// poller into HomeScreen and share it between AirconTiles and TipHero.
Object.assign(window, { AirconTiles, SaveHorAircon, useAircons, primaryOnFromStates, AC_TMIN, AC_TMAX, AC_FANS, prettyName });
})();
