"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMinDuration } from "@enode/core/use-min-duration";
import { BluetoothDisabledError, searchAndConnect } from "@enode/core/ble";
import type {
  SensorAdapter,
  SensorState,
} from "@enode/core/ble/enode-sensor";
import {
  groupIntoUnits,
  type PooledSensor,
} from "@enode/core/ble/sensor-pool";
import { NotASmartBarError } from "./use-sensor-connection";
import {
  isWeakThroughput,
  useSensorThroughput,
} from "@enode/core/ble/throughput-monitor";
import {
  useCalibratedFaces,
  useSensorIsCalibrating,
} from "@enode/core/calibration/hooks";
import { ALL_FACE_IDS, type FaceID } from "@enode/core/calibration/types";
import {
  useTranslation,
  type TranslateFn,
} from "@enode/core/translations/hooks";
import { ActionMenu } from "@enode/ui/action-menu";
import { BottomSheetCard } from "@enode/ui/bottom-sheet-card";
import { ConfirmDialog } from "@enode/ui/confirm-dialog";
import {
  ConnectSensorTrailing,
  SingleSensorTop,
  SmartBarConnectView,
  XIcon,
  describeStatus,
  formatFirmware,
  type SmartBarSideVM,
} from "./connect-sensor-view";

// Empty calibrated set — fed to the viewer during a visual recalibration so it
// renders ALL cones (the sensor's real calibration is untouched). Module-level
// so it's a stable reference.
const NO_CALIBRATED_FACES: ReadonlySet<FaceID> = new Set();

type Props = {
  open: boolean;
  sensor: SensorAdapter | null;
  // This station's sensors, ordered left-before-right (0/1/2). A SmartBar
  // station carries 1–2 VMP_SB sensors fused into one tracking source.
  stationSensors?: PooledSensor[];
  // True once a SmartBar sensor is assigned here — switches the panel to the
  // left/right sides layout.
  isSmartBarStation?: boolean;
  // A second SmartBar sensor may be added (exactly one here so far).
  canAddSecond?: boolean;
  // Pooled sensors connected on other stations (or unassigned) — offered as
  // "use here" rows so a connected sensor (or whole SmartBar) can be moved
  // onto this station.
  otherSensors?: PooledSensor[];
  onClose: () => void;
  onConnected: (sensor: SensorAdapter) => void;
  // Adds a freshly-connected sensor as the SmartBar's second side; throws
  // NotASmartBarError when the device isn't a VMP_SB.
  onAddSecond?: (sensor: SensorAdapter) => Promise<void>;
  onDisconnected: () => void;
  onAssignExisting?: (id: string) => void;
  // Flip the left/right tags of this station's SmartBar pair.
  onSwapSides?: () => void;
  // Disconnect and drop a single side of a SmartBar.
  onRemoveSide?: (id: string) => void;
};

export function ConnectSensorSheet({
  open,
  sensor,
  stationSensors = [],
  isSmartBarStation = false,
  canAddSecond = false,
  otherSensors = [],
  onClose,
  onConnected,
  onAddSecond,
  onDisconnected,
  onAssignExisting,
  onSwapSides,
  onRemoveSide,
}: Props) {
  const { t } = useTranslation();
  const [error, setError] = useState<string | null>(null);
  const [searching, setSearching] = useState(false);
  // Keep the "searching for sensor" state on screen for at least ~1s so a scan
  // that resolves sub-second doesn't flicker the scene in and straight back
  // out. Only the displayed phase is held; the real `searching` still drives
  // the scan logic. The view fades the scene in on top of this.
  const searchingDisplay = useMinDuration(searching, 1000);
  // True between picking a device and the adapter being ready — the
  // "Connecting" phase. On web this is the only window it's visible (connect +
  // initialize otherwise resolve in one step, straight from search to ready).
  const [connecting, setConnecting] = useState(false);
  // The advertised name of the device currently connecting (adapter not
  // built yet) — lets the hero already show the right body model (SmartBar
  // vs standard) during the connecting phase.
  const [connectingName, setConnectingName] = useState<string | null>(null);
  // Options ("…") menu + the device actions it drives. `infoOpen` shows the
  // serial / version in a dialog (kept off the 3D viewer so it never lands in a
  // screen recording); `recalibrating` re-shows the calibration cones (a visual
  // re-roll aid — no state change on the sensor); the shutdown flow guards the
  // irreversible power-off behind a confirm dialog.
  const [infoOpen, setInfoOpen] = useState(false);
  const [recalibrating, setRecalibrating] = useState(false);
  const [shutdownOpen, setShutdownOpen] = useState(false);
  const [shuttingDown, setShuttingDown] = useState(false);
  // AbortController for the in-flight searchAndConnect. Replaced for
  // every fresh search; cleared once that search settles. Closing the
  // sheet aborts it so we don't accidentally keep scanning / hold a
  // half-connected sensor after the user dismissed the UI.
  const abortRef = useRef<AbortController | null>(null);

  // Per-open snapshots used by the auto-dismiss guards further down.
  // Re-captured on every open transition via the `wasOpen` pattern
  // below (the `BottomSheetCard` unmount is delayed by the 300 ms
  // close animation, so the component can stay mounted across a
  // fast close+reopen — useRef alone would freeze these at the very
  // first mount's values).
  //   openedWithSensor: was a sensor already in hand at the moment of
  //     opening? If yes, the user re-opened to inspect stats — no
  //     auto-dismiss.
  //   seenCalibrating: did the sensor enter the `calibrating` state
  //     during this open? Routes which auto-dismiss path owns the
  //     close.
  const [openedWithSensor, setOpenedWithSensor] = useState(
    open && sensor !== null,
  );
  const [seenCalibrating, setSeenCalibrating] = useState(false);

  // Shared scan→connect machinery. `mode` routes the connected sensor:
  // "first" assigns it as this station's tracking source; "second" adds it
  // as a SmartBar's right side (and rejects a non-VMP_SB device).
  const runSearch = useCallback(
    async (mode: "first" | "second") => {
      const ctrl = new AbortController();
      abortRef.current = ctrl;
      setError(null);
      setSearching(true);
      setConnecting(false);
      try {
        // searchAndConnect picks the right implementation per platform —
        // Web Bluetooth picker → BleConnection → EnodeSensor on browser,
        // CapacitorEnodeSensor → iOS sensor package on native. `onConnecting`
        // flips the status off "Searching" the moment a device is chosen.
        const next = await searchAndConnect({
          signal: ctrl.signal,
          onConnecting: (deviceName) => {
            if (abortRef.current === ctrl) {
              setConnecting(true);
              setConnectingName(deviceName);
            }
          },
        });
        if (ctrl.signal.aborted) {
          // Aborted after the sensor connected but before we surfaced it
          // — disconnect to leave no orphan.
          await next.disconnect().catch(() => {});
          return;
        }
        if (mode === "second") {
          // addSecond disconnects + throws NotASmartBarError if the device
          // isn't a SmartBar, so a station never mixes sensor types.
          await onAddSecond?.(next);
        } else {
          onConnected(next);
        }
      } catch (err) {
        // User dismissed the Web Bluetooth chooser (NotFoundError) or we
        // aborted the search ourselves (AbortError) — both are normal
        // outcomes, not failures to surface.
        if (
          err instanceof DOMException &&
          (err.name === "NotFoundError" || err.name === "AbortError")
        ) {
          return;
        }
        if (err instanceof BluetoothDisabledError) {
          setError(
            t(
              "Bluetooth is off. Turn it on in your device settings to connect a sensor.",
            ),
          );
          return;
        }
        if (err instanceof NotASmartBarError) {
          setError(t("The second sensor must be a SmartBar (VMP_SB)."));
          return;
        }
        console.error("Sensor connect failed:", err);
        setError(err instanceof Error ? err.message : t("Couldn't connect."));
      } finally {
        // Only clear state if this is still the in-flight controller — a
        // newer search may have superseded us between the await and here.
        if (abortRef.current === ctrl) {
          abortRef.current = null;
          setSearching(false);
          setConnecting(false);
          setConnectingName(null);
        }
      }
    },
    [onConnected, onAddSecond, t],
  );

  const startSearch = useCallback(() => runSearch("first"), [runSearch]);
  const startSearchSecond = useCallback(
    () => runSearch("second"),
    [runSearch],
  );

  // Reset transient state on each open transition. Done during render
  // (React's "adjust state on prop change" pattern) instead of a
  // useEffect to avoid the set-state-in-effect lint.
  const [wasOpen, setWasOpen] = useState(open);
  if (open !== wasOpen) {
    setWasOpen(open);
    if (open) {
      setError(null);
      // Re-capture the per-open snapshots used by the auto-dismiss
      // guards. We can't rely on `useRef`'s initializer alone: a
      // close + quick re-open keeps the component mounted (the Sheet
      // unmount is delayed by the 300 ms close animation; faster
      // re-opens skip it entirely), so the refs would otherwise
      // freeze at the very first mount's values.
      setOpenedWithSensor(sensor !== null);
      setSeenCalibrating(false);
      setConnecting(false);
      setConnectingName(null);
      setInfoOpen(false);
      setRecalibrating(false);
      setShutdownOpen(false);
      // Seed the searching phase SYNCHRONOUSLY when this open will auto-start
      // a scan (same condition as the auto-start effect below). The effect
      // only fires after the first paint, so without the seed the sheet's
      // first frames render the idle-sensor phase and the hero flashes
      // sensor → searching on every open. Idempotent when a search is
      // already in flight (searching is true then anyway).
      if (!sensor && otherSensors.length === 0) {
        setSearching(true);
      }
    }
  }

  // Auto-start the search when the sheet opens without an existing
  // sensor. `[open]`-only deps make this fire on the open transition;
  // the abortRef check inside guards against double-fire under React
  // strict-mode dev mounts. On Disconnect mid-sheet (sensor → null) we
  // deliberately don't restart — that's a user-initiated end, not a
  // reason to immediately re-pair.
  /* eslint-disable react-hooks/set-state-in-effect */
  useEffect(() => {
    if (!open) return;
    if (sensor) return;
    if (abortRef.current) return;
    // When other connected sensors exist, don't auto-scan — show them for
    // assignment and let the user choose "Connect new sensor" explicitly.
    if (otherSensors.length > 0) return;
    void startSearch();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open]);
  /* eslint-enable react-hooks/set-state-in-effect */

  // Closing the sheet aborts any in-flight search. `searchAndConnect()`
  // honours the signal at every step and tears down a sensor that had just
  // connected. Clear the ref immediately (don't wait for `runSearch`'s
  // `finally`, which only runs once the aborted promise unwinds): a quick
  // re-open must not inherit this dead controller — the auto-start effect
  // bails on `if (abortRef.current) return`, so a lingering ref silently
  // blocks the next connect. `runSearch`'s finally is `=== ctrl`-guarded, so
  // nulling here can't clobber a newer in-flight search. Resetting the phase
  // flags stops a stale "Searching" spinner surviving a close→re-open that
  // doesn't auto-scan (`otherSensors` present).
  /* eslint-disable react-hooks/set-state-in-effect */
  useEffect(() => {
    if (open) return;
    abortRef.current?.abort();
    abortRef.current = null;
    setSearching(false);
    setConnecting(false);
    setConnectingName(null);
  }, [open]);
  /* eslint-enable react-hooks/set-state-in-effect */

  // NB: an involuntary drop is NOT handled here anymore. The sensor pool keeps
  // the sensor and starts an auto-reconnect ("reconnecting" state); calling
  // onDisconnected here would cancel that reconnect and evict the sensor. The
  // UI reflects the drop via the sensor staying in the pool as reconnecting
  // (useSensorSnapshot / useAnyReconnecting → "Stop reconnect").

  function handleDisconnect() {
    if (!sensor) return;
    // Graceful station teardown (0xF1 + grace period, then disconnect) runs via
    // onDisconnected → releaseStation. Don't disconnect the link directly here —
    // that would kill it before the notify could be sent.
    onDisconnected();
    // Tapping Disconnect is an explicit "I'm done" — close the sheet
    // straight away instead of bouncing back to the empty searching
    // state, which would otherwise auto-restart the search.
    onClose();
  }

  // Stable so the SmartBar panel's completion effect doesn't re-arm its timer
  // on every parent render (see the callback-ref-for-child-effect pattern).
  const exitRecalibration = useCallback(() => setRecalibrating(false), []);

  // Power off the station's sensor(s). Shutdown drops the BLE link, so we tear
  // the station assignment down (onDisconnected) and close, same as Disconnect.
  // A SmartBar station carries 1–2 sensors — power off both.
  async function handleShutdown() {
    const adapters = stationSensors.length
      ? stationSensors.map((s) => s.sensor)
      : sensor
        ? [sensor]
        : [];
    setShuttingDown(true);
    try {
      await Promise.all(adapters.map((a) => a.shutDown().catch(() => {})));
    } finally {
      setShuttingDown(false);
      setShutdownOpen(false);
      onDisconnected();
      onClose();
    }
  }

  const liveCalibrated = useCalibratedFaces(sensor);
  const isCalibrating = useSensorIsCalibrating(sensor);
  const facesDone = liveCalibrated.size;
  // The sensor's `state` stays "calibrating" forever — the firmware never
  // re-reports settings when calibration finishes (see enode-sensor handleSettings).
  // So completion is detected from the face count, not the stuck state field.
  const calibrationComplete = facesDone >= ALL_FACE_IDS.length;
  const activelyCalibrating = isCalibrating && !calibrationComplete;
  // Snapshot the @Published-ish fields so the sheet re-renders when the
  // device info / battery lands after connect. `state` drives the
  // status copy below; `battery` / `firmware` / `serial` feed the
  // small device-info row under the 3D viewer.
  const snapshot = useSensorSnapshot(sensor);

  // Any station sensor auto-reconnecting → the disconnect button becomes
  // "Stop reconnect" (covers a dual SmartBar where only one side dropped).
  const anyReconnecting = useAnyReconnecting(stationSensors);

  // Device view → strength mode. Only strength/weightlifting/science/isometric
  // modes carry the quaternion stream that drives the 3D model; a sensor left
  // in an exercise mode (jump/flywheel) by the tracking view would render a
  // frozen model when this sheet is re-opened. Skipped while actively
  // calibrating so that handshake is untouched. The active tracking view
  // restores the per-exercise mode when this sheet closes (it gates its config
  // effect on the sheet being open).
  const stationKey = stationSensors.map((s) => s.id).join(",");
  useEffect(() => {
    if (!open || activelyCalibrating) return;
    const adapters = stationSensors.length
      ? stationSensors.map((s) => s.sensor)
      : sensor
        ? [sensor]
        : [];
    for (const adapter of adapters) {
      adapter.setMode("strength").catch(() => {});
    }
    // stationSensors is tracked via the stable `stationKey`; `snapshot.state`
    // re-fires the write once the freshly-paired sensor becomes writable.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open, activelyCalibrating, sensor, stationKey, snapshot.state]);

  // Live BLE throughput — per-sensor data-stream rate. A sensor reads as a
  // "weak connection" once its rate has stayed below the stable floor across
  // the 2 s window (radio likely saturated). We scope it to THIS station's
  // sensors so a degraded sensor on another station never flags this sheet.
  const throughput = useSensorThroughput();
  const hzById = useMemo(() => {
    const map = new Map<string, number>();
    for (const entry of throughput) map.set(entry.id, entry.hz);
    return map;
  }, [throughput]);
  const singleWeak = isWeakThroughput(
    throughput.find((x) => x.id === sensor?.id),
  );
  const stationWeak = stationSensors.some((s) =>
    isWeakThroughput(throughput.find((x) => x.id === s.id)),
  );

  // Group the offered sensors into logical units so a SmartBar pair shows as
  // one "Use here" row (moving it carries both sides — see assignExisting).
  const otherUnits = useMemo(() => groupIntoUnits(otherSensors), [otherSensors]);
  // Battery per offered sensor for the quick-select cards (read off the live
  // adapter; a snapshot is fine — the list re-renders on pool changes).
  const batteryById = useMemo(() => {
    const map = new Map<string, number>();
    for (const s of otherSensors) map.set(s.id, s.sensor.battery);
    return map;
  }, [otherSensors]);

  // The big hero viewer makes no sense while we're just offering existing
  // sensors to assign — show it only when a sensor is attached, or when there's
  // nothing to pick from (searching / empty).
  const showHero = !!sensor || otherUnits.length === 0;

  // A connected sensor (single or SmartBar) unlocks the options menu.
  const hasSensor = !!sensor || stationSensors.length > 0;

  // Serial + firmware for the "Show info" dialog, read straight off the
  // adapter(s) — both are static once connected. Deliberately surfaced only in
  // the dialog (never on the 3D viewer) so they never appear in a recording.
  const infoEntries = (
    isSmartBarStation
      ? stationSensors.map((s) => ({
          key: s.id,
          position: s.position,
          adapter: s.sensor,
        }))
      : sensor
        ? [{ key: sensor.id, position: null, adapter: sensor }]
        : []
  ).map((u) => ({
    key: u.key,
    label: u.position === "left" ? t("L") : u.position === "right" ? t("R") : null,
    firmware: u.adapter.firmwareNumber ?? null,
    serial: u.adapter.serialNumber ?? null,
  }));

  // Cones are only meaningful while calibration is actively running. Once all
  // faces are done (or on a re-opened, already-calibrated sensor) the cones are
  // suppressed outright in the viewer — unless the user asked to recalibrate,
  // which forces them back on as a visual re-roll aid (no state change).
  const showCones = activelyCalibrating || recalibrating;

  // Status header + body copy — single source of truth shown above the
  // action button. Connected/initialised picks up the firmware version
  // as a second sentence in the description so the user has the
  // version they're running at a glance.
  const status = describeStatus({
    t,
    hasSensor: !!sensor,
    sensorState: snapshot.state,
    searching,
    connecting,
    isCalibrating: activelyCalibrating,
    facesDone,
  });

  // Flip `seenCalibrating` once when calibration starts. State (not
  // ref) so the auto-dismiss effects re-run on the transition. React
  // de-dupes the same-value setState so re-entering this effect with
  // `seenCalibrating` already true is a no-op.
  /* eslint-disable react-hooks/set-state-in-effect */
  useEffect(() => {
    if (isCalibrating) setSeenCalibrating(true);
  }, [isCalibrating]);
  /* eslint-enable react-hooks/set-state-in-effect */

  // Auto-dismiss once the user has driven all six faces through
  // calibration. The 800 ms delay lets the final cone finish its
  // grow+fade before the sheet slides out, so the user sees it
  // complete.
  //
  // Guard against re-open false-fires: when the sheet is re-opened on
  // an already-connected sensor, the firmware can leave `state ==
  // "calibrating"` (no settings reply to flip it back) AND replay
  // calibration events during re-attach — both conditions then land
  // true within a few ticks and the timer would close the sheet
  // immediately. The auto-close is conceptually "dismiss when this
  // session's pairing-and-calibration finishes"; a re-open isn't
  // that, so skip it.
  useEffect(() => {
    // SmartBar setup is multi-step (pair, add a second side, calibrate both);
    // never auto-close it on the primary's progress — the coach dismisses it.
    if (isSmartBarStation) return;
    if (!isCalibrating) return;
    if (liveCalibrated.size !== ALL_FACE_IDS.length) return;
    if (openedWithSensor) return;
    const id = window.setTimeout(() => onClose(), 800);
    return () => window.clearTimeout(id);
  }, [isSmartBarStation, isCalibrating, liveCalibrated, openedWithSensor, onClose]);

  // Auto-dismiss when a freshly-paired sensor reaches the settled
  // post-handshake state without ever needing calibration. The
  // distinguisher between this path and the 6-faces path above is
  // `seenCalibratingRef` — if the sensor went through calibrating at
  // any point, the path above owns the dismiss. Same `openedWithSensor`
  // guard so re-opens on an already-connected sensor never close
  // automatically.
  useEffect(() => {
    if (isSmartBarStation) return;
    if (openedWithSensor) return;
    if (seenCalibrating) return;
    if (snapshot.state !== "initialized" && snapshot.state !== "connected") {
      return;
    }
    const id = window.setTimeout(() => onClose(), 800);
    return () => window.clearTimeout(id);
  }, [isSmartBarStation, snapshot.state, openedWithSensor, seenCalibrating, onClose]);

  // Recalibration is a purely visual re-roll aid (single sensor): it shows all
  // cones and the sensor stays calibrated, so there's no calibration to "finish"
  // — the user toggles it off via the "Hide calibration" button. (No auto-exit:
  // an already-calibrated sensor has every face done, which previously dropped
  // straight back out and hid the cones.)

  // Title stays fixed — the active phase is communicated by the
  // state-aware status block underneath the 3D viewer, not by swapping
  // the bar copy. Less for the eye to track between transitions.
  const title = t("Enode Sensor");

  return (
    <>
    <BottomSheetCard
      open={open}
      onClose={onClose}
      ariaLabel={title}
      zIndex={90}
    >
      <div className="flex items-center justify-between px-5 pt-4">
        <button
          type="button"
          onClick={onClose}
          aria-label={t("Close")}
          className="flex size-10 shrink-0 items-center justify-center rounded-full bg-surface-base text-foreground-default shadow ring-1 ring-surface-stroke-muted hover:bg-surface-base-muted"
        >
          <XIcon className="size-5" />
        </button>

        {/* Options menu — only once there's a sensor to act on. */}
        {hasSensor && (
          <ActionMenu
            ariaLabel={t("Sensor options")}
            triggerClassName="flex size-10 shrink-0 items-center justify-center rounded-full bg-surface-base text-foreground-default shadow ring-1 ring-surface-stroke-muted hover:bg-surface-base-muted"
            items={[
              { label: t("Show info"), onClick: () => setInfoOpen(true) },
              {
                label: recalibrating ? t("Hide calibration") : t("Recalibrate"),
                onClick: () => setRecalibrating((p) => !p),
              },
              {
                label: t("Shut down"),
                destructive: true,
                onClick: () => setShutdownOpen(true),
              },
            ]}
          />
        )}
      </div>

      <h3 className="heading-portal px-5 pt-3 pb-3">{title}</h3>

      {/* Each row is a Collapsible — it smoothly grows / shrinks via a
          grid-template-rows transition, so phases (searching →
          connecting → calibrating → connected → idle) glide between
          each other without snapping. Connected/idle has nothing to
          say in the status slot, so that slot collapses to 0 and the
          Disconnect button rises to meet the device info row, rather
          than leaving a fat empty band. */}
      <div className="flex flex-col items-center px-5 pb-3">
        {isSmartBarStation ? (
          <SmartBarConnectPanel
            stationSensors={stationSensors}
            canAddSecond={canAddSecond}
            searching={searchingDisplay}
            recalibrating={recalibrating}
            onRecalibrationComplete={exitRecalibration}
            onSwapSides={onSwapSides}
            onRemoveSide={onRemoveSide}
            onAddSecond={startSearchSecond}
            t={t}
          />
        ) : showHero ? (
          <SingleSensorTop
            sensor={sensor}
            // During a visual recalibration the sensor stays calibrated, but we
            // feed the viewer an empty set so it shows ALL cones as a re-roll
            // reference instead of suppressing them (every face already done).
            calibrated={recalibrating ? NO_CALIBRATED_FACES : liveCalibrated}
            showCones={showCones}
            idleRotation={!sensor}
            connected={!!sensor}
            battery={snapshot.battery}
            weak={singleWeak}
            searching={searchingDisplay}
            connecting={connecting}
            connectingDeviceName={connectingName}
            t={t}
          />
        ) : null}

        <ConnectSensorTrailing
          status={status}
          showStatus={!isSmartBarStation}
          error={error}
          throughputUnstable={isSmartBarStation && stationWeak}
          otherUnits={otherUnits}
          hzById={hzById}
          batteryById={batteryById}
          connected={!!sensor}
          anyReconnecting={anyReconnecting}
          searching={searchingDisplay}
          hasOtherSensors={otherSensors.length > 0}
          onAssignExisting={onAssignExisting}
          onConnect={startSearch}
          onDisconnect={handleDisconnect}
          t={t}
        />
      </div>
    </BottomSheetCard>

      {/* Sensor details — serial + firmware in an acknowledge-only dialog, kept
          off the 3D viewer so they're never captured in a screen recording. */}
      <ConfirmDialog
        open={infoOpen}
        title={t("Sensor details")}
        hideCancel
        confirmLabel={t("OK")}
        zIndex={120}
        onConfirm={() => setInfoOpen(false)}
        onCancel={() => setInfoOpen(false)}
      >
        <div className="mt-3 flex flex-col gap-3">
          {infoEntries.map((e) => (
            <div key={e.key} className="flex flex-col gap-0.5">
              {e.label && (
                <p className="body-small font-medium text-foreground-muted">
                  {e.label}
                </p>
              )}
              <p className="body-normal tabular-nums text-foreground-default">
                {t("Version {version}", {
                  version: e.firmware ? formatFirmware(e.firmware) : t("—"),
                })}
              </p>
              <p className="body-normal tabular-nums text-foreground-default">
                {t("Serial {serial}", { serial: e.serial ?? t("—") })}
              </p>
            </div>
          ))}
        </div>
      </ConfirmDialog>

      {/* Shut down — guard the irreversible power-off behind a confirm dialog.
          The sensor only wakes again on a power plug, so spell that out. */}
      <ConfirmDialog
        open={shutdownOpen}
        title={t("Shut down sensor")}
        message={t(
          "Only shut down the sensor if you plan to store it for a longer time. You need to use a power plug to wake up the sensor again.",
        )}
        confirmLabel={t("Shut down")}
        destructive
        busy={shuttingDown}
        zIndex={120}
        onConfirm={handleShutdown}
        onCancel={() => setShutdownOpen(false)}
      />
    </>
  );
}

// The hook-driven SmartBar wrapper: reads each side's calibration + snapshot
// (two fixed hook slots so the hook count stays constant whether the SmartBar
// has one side or two), resolves them into plain `SmartBarSideVM`s, and renders
// the pure SmartBarConnectView. The gallery renders SmartBarConnectView
// directly with mock sides.
function SmartBarConnectPanel({
  stationSensors,
  canAddSecond,
  searching,
  recalibrating,
  onRecalibrationComplete,
  onSwapSides,
  onRemoveSide,
  onAddSecond,
  t,
}: {
  stationSensors: PooledSensor[];
  canAddSecond: boolean;
  searching: boolean;
  recalibrating: boolean;
  // Fired once both sides' faces have cleared during a manual recalibration,
  // so the sheet can drop back out of calibration mode.
  onRecalibrationComplete: () => void;
  onSwapSides?: () => void;
  onRemoveSide?: (id: string) => void;
  onAddSecond: () => void;
  t: TranslateFn;
}) {
  const a = stationSensors[0];
  const b = stationSensors[1] as PooledSensor | undefined;

  // Two fixed hook slots (the second null until a sensor lands) keep the
  // hook count constant whether the SmartBar has one side or two.
  const calA = useCalibratedFaces(a?.sensor ?? null);
  const calibratingA = useSensorIsCalibrating(a?.sensor ?? null);
  const snapA = useSensorSnapshot(a?.sensor ?? null);
  const calB = useCalibratedFaces(b?.sensor ?? null);
  const calibratingB = useSensorIsCalibrating(b?.sensor ?? null);
  const snapB = useSensorSnapshot(b?.sensor ?? null);

  // Auto-leave recalibration once every present side has all six faces
  // cleared — but only when there was still something to clear at entry. The
  // cached face sets only ever grow for a connected sensor, so an
  // already-complete bar satisfies `allFacesDone` immediately and would
  // otherwise cancel the visual re-roll 700 ms after it was requested (the
  // single-sensor path dropped its auto-exit for the same reason); there the
  // user leaves via "Hide calibration". Armed state captured on the
  // recalibrating transition during render (adjust-on-change pattern).
  const total = ALL_FACE_IDS.length;
  const allFacesDone = calA.size >= total && (!b || calB.size >= total);
  const [autoExitArmed, setAutoExitArmed] = useState(false);
  const [prevRecalibrating, setPrevRecalibrating] = useState(recalibrating);
  if (recalibrating !== prevRecalibrating) {
    setPrevRecalibrating(recalibrating);
    setAutoExitArmed(recalibrating && !allFacesDone);
  }
  useEffect(() => {
    if (!recalibrating || !autoExitArmed || !allFacesDone) return;
    const id = window.setTimeout(onRecalibrationComplete, 700);
    return () => window.clearTimeout(id);
  }, [recalibrating, autoExitArmed, allFacesDone, onRecalibrationComplete]);

  if (!a) return null;

  const sides: SmartBarSideVM[] = [
    {
      id: a.id,
      sensor: a.sensor,
      idleRotation: false,
      position: a.position,
      calibrated: calA,
      calibrating: calibratingA,
      battery: snapA.battery,
      state: snapA.state,
    },
    ...(b
      ? [
          {
            id: b.id,
            sensor: b.sensor,
            idleRotation: false,
            position: b.position,
            calibrated: calB,
            calibrating: calibratingB,
            battery: snapB.battery,
            state: snapB.state,
          },
        ]
      : []),
  ];

  return (
    <SmartBarConnectView
      sides={sides}
      canAddSecond={canAddSecond}
      searching={searching}
      recalibrating={recalibrating}
      onSwapSides={onSwapSides}
      onRemoveSide={onRemoveSide}
      onAddSecond={onAddSecond}
      t={t}
    />
  );
}

// Snapshot of the sensor fields that are mutated outside React (BLE
// notify on web; Capacitor `stateChange` on native). Polling at 500 ms
// is plenty — these only change a handful of times across a connect.
// `useState` Object.is comparison keeps a re-render from firing on each
// tick when nothing actually moved.
function useSensorSnapshot(sensor: SensorAdapter | null): {
  state: SensorState | null;
  battery: number;
  firmware: string | null;
  serial: string | null;
} {
  const [state, setState] = useState<SensorState | null>(
    sensor?.state ?? null,
  );
  const [battery, setBattery] = useState(sensor?.battery ?? -1);
  const [firmware, setFirmware] = useState<string | null>(
    sensor?.firmwareNumber ?? null,
  );
  const [serial, setSerial] = useState<string | null>(
    sensor?.serialNumber ?? null,
  );

  /* eslint-disable react-hooks/set-state-in-effect */
  useEffect(() => {
    if (!sensor) {
      setState(null);
      setBattery(-1);
      setFirmware(null);
      setSerial(null);
      return;
    }
    const tick = () => {
      setState(sensor.state);
      setBattery(sensor.battery);
      setFirmware(sensor.firmwareNumber);
      setSerial(sensor.serialNumber);
    };
    tick();
    const id = window.setInterval(tick, 500);
    return () => window.clearInterval(id);
  }, [sensor]);
  /* eslint-enable react-hooks/set-state-in-effect */

  return { state, battery, firmware, serial };
}

// True while any of the station's sensors is auto-reconnecting. Polled (like
// useSensorSnapshot, since a sensor's `state` mutates in place). Drives the
// "Stop reconnect" button label — covers a dual SmartBar where only one side
// dropped, not just the primary.
function useAnyReconnecting(sensors: PooledSensor[]): boolean {
  const [reconnecting, setReconnecting] = useState(false);
  useEffect(() => {
    const tick = () =>
      setReconnecting(sensors.some((s) => s.sensor.state === "reconnecting"));
    tick();
    const id = window.setInterval(tick, 500);
    return () => window.clearInterval(id);
  }, [sensors]);
  return reconnecting;
}
