// System Management / Mission Control — per-unit hardware dashboard for
// a single physical asset. Reached by clicking a row in either the
// reseller portal's "My assets" table (reseller-portal-dashboard.jsx /
// reseller-assets-page.jsx) or the admin Assets page
// (admin-assets-page.jsx); which one determines `viewerType` ("reseller"
// | "admin"), threaded through onNavigate's opts (see app.jsx) as
// { unitId, viewerType }.
//
// MERGED (customer decision, this pass): "asset management and mission
// control is the same" — this per-unit screen now IS Mission Control.
// There used to be two separate standalone "Mission Control" pages
// (admin's dark mission-control.jsx and a neumorphic
// reseller-mission-control-page.jsx) with their own sidebar escape-hatch
// buttons; both have been unhooked from navigation (files kept on disk,
// unrouted) in favour of this single screen, which now also folds in
// their real-vendor-data plumbing: Victron shunt/MPPT/mains-charger,
// Ajax relay hub devices (with customer-editable label + schedule, see
// ajax_relay_config / migrations/0027), and Teltonika router — each
// overriding the equivalent unit_telemetry demo field when linked and
// present, so a linked asset shows live data and an unlinked one still
// shows a fully working demo screen. See UM_pick() below for the
// real-wins-over-demo precedence helper.
//
// Reseller and admin share the exact same 12-tab shell and General-tab
// layout — only the data source (routes/portal.ts vs
// routes/admin-assets.ts), the outer chrome (ResellerShell vs AdminShell)
// and one extra admin-only control (grant/revoke this unit's reseller-
// side access) differ. Every other tab beyond General remains a
// placeholder today ("we will add all these later" — product decision).
//
// Uses unique `UM_`-prefixed local helper names throughout, deliberately
// NOT reusing the generic inputStyle/SectionLabel/MiniField/etc. names
// that several other top-level site/*.jsx files declare — this codebase
// loads every file as a global <script type="text/babel"> tag with no
// module isolation, and an unresolved naming-collision issue (see
// support-page.jsx investigation notes) makes shared generic names risky
// until that's understood. Prefixing costs nothing and avoids the risk.

// Mission Control rearchitecture, Phase 2 (spec §4 Navigation / §55
// Remove From Mission Control): renamed "General" → "Dashboard" (§6),
// added "Devices" (§8 — dynamically generated from the assigned Device
// Profile, see UM_DevicesTab below) and "Integrations" (§45), and
// REMOVED Alarm / Errors / Schedules entirely per §55's explicit
// instruction ("their info folds into Ajax Devices / Events /
// Notifications / Integrations / unified device health instead") — all
// three were "Coming Soon" placeholders with zero live content, so
// removing them carries no user-facing disruption. "ARC Integration"
// is renamed to "Integrations" for the same reason (no ARC integration
// was ever actually built behind that tab).
//
// Top-nav consolidation (customer's exact instruction, this session):
// "Efoy and Speakers need to go under Devices. Intergrations under
// system. Settings under system. The device health, and summarys need
// to go under notifications." This is a pure NAVIGATION reorg -- none of
// the underlying tab content changed:
//   - EFOY / Speakers: no longer their own top-level tabs. Their exact
//     same components (UM_EfoyTab / UM_SpeakersTab, untouched) are now
//     reached as two extra entries in the Devices tab's own left-hand
//     group nav (UM_DevicesTab), alongside the dynamic Victron/Ajax/
//     Teltonika/Additional-Devices groups. Both stay independent of
//     Device Profile assignment, exactly as before (gated on
//     unit.has_efoy / unit.has_speakers, not on any device_profile_slots
//     row), so they're reachable under Devices even for an asset with no
//     Device Profile assigned yet.
//   - Settings / Integrations: no longer their own top-level tabs.
//     Folded into the System tab's own left-hand sub-nav (UM_SystemTab)
//     alongside "System" itself -- all three are still "Coming Soon"
//     placeholders today (no backend exists for any of them yet), this
//     only changes where an admin/reseller clicks to reach them.
//   - Device Health / Connectivity Summary / Camera Summary ("the
//     summarys"): moved off the Dashboard tab entirely onto the
//     Notifications tab (UM_NotificationsTab), alongside the existing
//     Active Notifications placeholder -- Dashboard no longer renders a
//     summary row above its product-flag card grid. Recent Events moved
//     the same way, also onto Notifications ("events can go under
//     notifications" -- customer's exact follow-up instruction, this
//     session), so the standalone "Events" top-level tab that briefly
//     existed has been removed again -- every one of these placeholders
//     now lives on the one Notifications tab instead of being spread
//     across Dashboard/Events/Notifications.
const UM_TABS = [
  { id: "general", label: "Dashboard" },
  { id: "cameras", label: "Cameras" },
  { id: "devices", label: "Devices" },
  { id: "notifications", label: "Notifications" },
  { id: "system", label: "System" },
];

// Sub-sections folded into the System tab (see header comment above) --
// all three are still "Coming Soon" placeholders with no backend today;
// UM_SystemTab just gives each its own left-nav entry, same visual
// pattern as UM_DevicesTab's group nav.
const UM_SYSTEM_SECTIONS = [
  { id: "system", label: "System" },
  { id: "integrations", label: "Integrations" },
  { id: "settings", label: "Settings" },
];

// §14 Dynamic Device Navigation / §58 Overall UI Layout — the fixed
// display order + label for each device_category value stored on
// device_profile_slots (see migrations/0042's VALID_CATEGORIES:
// "Ajax" | "Victron" | "Teltonika" | "EFOY" | "Other"). "Other" is
// presented to the user as "Additional Devices" per the spec's exact
// nav wording; the rest keep their vendor name except Teltonika, which
// the spec always labels "Teltonika Router".
const UM_DEVICE_CATEGORY_ORDER = ["Victron", "Ajax", "Teltonika", "Other", "EFOY"];
const UM_DEVICE_CATEGORY_LABELS = {
  Victron: "Victron",
  Ajax: "Ajax",
  Teltonika: "Teltonika Router",
  Other: "Additional Devices",
  EFOY: "EFOY",
};

// "make sure in the asset itself it knows theres a 15 min check so it
// doesnt keep showing online and offline" (customer's exact
// instruction, this session). The background health-check cron
// (routes/cron-sync-health.ts) retries a currently-broken link every
// ~30 minutes and a healthy one every ~3 hours, but a single
// TRANSIENT failure (one bad request that then succeeds again on the
// very next attempt seconds/minutes later) would otherwise flip
// sync_status 'ok' -> 'error' -> 'ok' and make this bar's Ajax/
// Victron/Teltonika segments visibly flash Disconnected then
// Connected again -- exactly the flapping the customer flagged.
// Instead of reacting to the immediate sync_status value, a link is
// still treated as Connected as long as a sync has SUCCEEDED at some
// point within the last UM_API_GRACE_WINDOW_MS, regardless of what
// its most recent attempt did -- so one bad attempt right after a
// healthy one is invisible, and only a link that's failed to sync
// successfully for a full 15 minutes straight reads as genuinely
// Disconnected. A link with sync_status='never_synced' and no
// last_synced_at at all gets no grace period (there's no prior
// success to extend), so a brand-new broken link still shows
// Disconnected immediately rather than silently reading Connected for
// 15 minutes it never earned.
const UM_API_GRACE_WINDOW_MS = 15 * 60 * 1000;

// Pilot-rollout gate for the Ajax hub arm/disarm toggle -- customer's
// explicit instruction: "get the arm and disarm toggle... working. This
// is only to go on the user and devices under 'Solo Secure Group UK
// (TEST)' for now". The real enforcement lives server-side
// (routes/admin-ajax.ts's runAjaxHubArm, matched against this exact same
// company name -- keep these two in sync if the pilot company is ever
// renamed), so this frontend copy is UX-only: it hides the toggle behind
// a clear "not yet enabled" message for every other company instead of
// letting someone click it and get a raw 403 back. Matched against
// unit.current_company_name (already loaded into every General-tab
// fetch, see lib/telemetry.ts's shared unit query) rather than a company
// id for the same "id isn't stable across environments, name is what
// the customer actually said" reasoning as the backend copy.
// TODO(product): delete this + the backend's matching block once ready
// to roll out beyond the pilot.
const AJAX_ARM_PILOT_COMPANY_NAME = "Solo Secure Group UK (TEST)";

// Shared "is this hub armed, for display purposes" derivation --
// customer: "this toggle and status change needs to be instant. Armed
// to Disarmed and Disarmed to Armed." The instant optimistic flip
// (UM_HubArmRow's handleToggleHub) already changes this the moment the
// user clicks, but it gets immediately OVERWRITTEN by the real
// post-command read-back once the Ajax API call resolves -- and that
// read-back can legitimately report the TRANSITIONAL "ARMING"/
// "DISARMING" rather than the terminal "ARMED"/"DISARMED" if Ajax's own
// hub hasn't fully settled yet (see runAjaxHubArm's own header comment
// in admin-ajax.ts, which documents this exact possibility). Every
// caller here used to test state === "ARMED" with strict equality, so
// that transitional read-back snapped the toggle/badge/Security Status
// glow straight back to "unarmed" (grey/green) for however long Ajax
// took to settle -- up to the next 30s background poll -- which is
// indistinguishable from the toggle just not having worked yet. Fixed
// by treating ARMING as "reads as armed" and DISARMING as "reads as
// not armed" everywhere armed/unarmed is derived from hub.state, so the
// instant flip's visual result survives all the way through to the
// real terminal state with no flicker-back in between. PARTIAL_ARMED
// keeps reading as not-fully-armed, same as before.
function UM_hubArmedForDisplay(state) {
  const s = String(state || "").toUpperCase();
  return s === "ARMED" || s === "ARMING";
}

function UM_apiLinkConnected(row) {
  if (!row) return null; // caller handles the "not linked at all" case separately
  if (row.sync_status === "ok") return true;
  if (!row.last_synced_at) return false;
  const then = new Date(/Z$|[+-]\d\d:?\d\d$/.test(row.last_synced_at) ? row.last_synced_at : `${row.last_synced_at}Z`).getTime();
  if (Number.isNaN(then)) return false;
  return Date.now() - then <= UM_API_GRACE_WINDOW_MS;
}

// §5 Persistent Asset Status Bar — derives the headline connectivity/
// battery/power/network/Ajax figures from the exact same unit/telemetry/
// vendor data already loaded for the rest of this page (no extra fetch).
// Mirrors UM_GeneralTab's Victron sub-device lookup + real-wins-over-demo
// (UM_pick) precedence rather than sharing code with it directly, since
// UM_GeneralTab's version is entangled with that tab's own rendering.
// UM_timeAgo/UM_isStale are declared later in this file as function
// declarations, so they're hoisted and safe to call from here.
function UM_computeAssetSummary(unit, telemetry, victron, ajax, teltonika, efoy) {
  const vDevices = (victron && victron.devices) || [];
  const findV = (role) => {
    const row = vDevices.find((d) => d.device_role === role);
    if (!row) return null;
    try { return JSON.parse(row.cached_fields_json || "{}"); } catch { return {}; }
  };
  const shunt = findV("shunt");
  const batteryPercent = UM_pick(UM_parseVrmNumber(shunt && shunt.SOC), telemetry.battery_percent);

  const router = teltonika || null;
  const routerConfigured = !!unit.has_router;

  // Line 1 spec is explicit: "Ajax/Victron/Teltonika - Connected /
  // Disconnected (This is API)" -- all three of these segments describe
  // OUR API connection to that vendor, not the physical device's own
  // radio/online state (a hub can be sat there powered on and reachable
  // over GSM while our OWN pull of it is broken -- e.g. the company's
  // Ajax API link was revoked -- and that's exactly the case this bar
  // needs to surface, not hide behind a device-level "online"). So all
  // three reuse the same sync_status signal migrations/0049_vendor_
  // sync_health.sql already tracks for this ('ok' | 'error' |
  // 'never_synced' -- see that migration's header comment and cron-
  // sync-health.ts's identical treatment of the three values), run
  // through UM_apiLinkConnected's 15-minute grace window (see that
  // function's comment) rather than reacting to the raw value
  // directly, so a single transient failure between two background
  // cron ticks doesn't visibly flap this bar's dot from green to red
  // and back within a few minutes.
  const routerOnline = router ? UM_apiLinkConnected(router) : null;
  const networkStatus = !routerConfigured
    ? { label: "Not Linked", tone: "grey" }
    : { label: routerOnline ? "Connected" : "Disconnected", tone: routerOnline ? "green" : "red" };

  const ajaxHub = ajax && ajax.hub;
  const ajaxOnline = ajaxHub ? UM_apiLinkConnected(ajaxHub) : null;
  const ajaxStatus = !ajaxHub
    ? { label: "Not Linked", tone: "grey" }
    : { label: ajaxOnline ? "Connected" : "Disconnected", tone: ajaxOnline ? "green" : "red" };

  const victronInstallation = victron && victron.installation;
  const victronOnline = victronInstallation ? UM_apiLinkConnected(victronInstallation) : null;
  const victronApiStatus = !victronInstallation
    ? { label: "Not Linked", tone: "grey" }
    : { label: victronOnline ? "Connected" : "Disconnected", tone: victronOnline ? "green" : "red" };

  const lastSeenIso = [
    ajaxHub && ajaxHub.last_synced_at,
    router && router.last_synced_at,
    efoy && efoy.last_synced_at,
    victronInstallation && victronInstallation.last_synced_at,
  ].filter(Boolean).sort().pop() || null;

  // NOTE: "problems"/connectivity below intentionally keeps using the
  // OLD device-level online signals (router.connection_state/status,
  // ajaxHub.online, not the API sync_status above) -- this is the
  // pre-existing Connectivity Summary / Device Health card logic
  // elsewhere on this page, deliberately left untouched by this
  // status-bar rework; only the bar's own three "This is API" segments
  // move to sync_status.
  const routerDeviceOnline = router
    ? String(router.connection_state || "").toLowerCase() !== "offline" && !!router.status
    : routerConfigured;
  const ajaxDeviceOnline = ajaxHub ? !!ajaxHub.online : null;
  const victronDeviceOnline = victronInstallation ? victronInstallation.sync_status !== "error" : null;
  const problems = [];
  if (routerConfigured && !routerDeviceOnline) problems.push("network");
  if (ajaxHub && !ajaxDeviceOnline) problems.push("ajax");
  if (victronInstallation && !victronDeviceOnline) problems.push("victron");

  let connectivity;
  if (!victron && !ajaxHub && !router && !efoy) connectivity = { label: "Unknown", tone: "grey" };
  else if (problems.length > 0) connectivity = { label: "Degraded", tone: "amber" };
  else connectivity = { label: "Online", tone: "green" };

  // Line 1 spec: "Device Status - Online Offline" -- a plain binary,
  // distinct from `connectivity` above (which also has an amber
  // "Degraded" state, still used elsewhere on this page e.g. the
  // Connectivity Summary card). Green -> Online; amber (Degraded) or
  // grey (Unknown) both read as Offline for this specific two-state
  // segment, while keeping connectivity's own tone (amber/grey/red) on
  // the dot so a Degraded unit isn't visually indistinguishable from a
  // fully healthy one.
  const deviceStatus = {
    label: connectivity.tone === "green" ? "Online" : "Offline",
    tone: connectivity.tone,
  };

  // "Time in region selected" -- companies.region/country (already
  // joined onto `unit` as current_region/current_country by both
  // UNIT_SELECT (admin) and loadResellerUnitOrNull (reseller)) resolved
  // to an IANA zone via geo-data.js's timezoneForCountry (mirrors
  // src/lib/geo.ts's TIMEZONE_BY_COUNTRY -- see that file's header
  // comment for why this is country-keyed, not region-keyed, and why a
  // few countries are pinned to one representative zone). No fixed
  // country list exists for "Rest of the World" and a company can be
  // entirely unassigned, so this can legitimately resolve to null --
  // the status bar just omits the clock in that case (UM_AssetStatusBar
  // handles the null case, this function only resolves the zone).
  const regionTimezone = (window.GEO_DATA && window.GEO_DATA.timezoneForCountry(unit.current_country)) || null;

  return {
    batteryPercent, networkStatus, ajaxStatus, victronApiStatus, connectivity, deviceStatus, regionTimezone,
    lastSeenText: lastSeenIso ? UM_timeAgo(lastSeenIso) : "Unknown",
  };
}

// Live 24h clock in a specific IANA timezone, ticking once a minute
// (status bar shows HH:MM, not seconds, so a full-second tick would just
// be wasted re-renders). Returns null (renders nothing) if `timezone` is
// null -- e.g. the company has no country set, or its country isn't in
// TIMEZONE_BY_COUNTRY yet (see src/lib/geo.ts's header comment).
function UM_useRegionClock(timezone) {
  const [now, setNow] = useState(() => new Date());
  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 30000);
    return () => clearInterval(id);
  }, []);
  if (!timezone) return null;
  try {
    return new Intl.DateTimeFormat("en-GB", {
      timeZone: timezone, hour: "2-digit", minute: "2-digit", hour12: false,
    }).format(now);
  } catch {
    // Unrecognised zone string (shouldn't happen -- TIMEZONE_BY_COUNTRY
    // is a fixed, hand-picked list of real IANA names) -- fail quiet
    // rather than crash the whole status bar over a clock.
    return null;
  }
}

// §5 — spans full width above the tab nav, visible on every Mission
// Control sub-tab (rendered once, at the top of UM_Dashboard, not
// per-tab). Two fixed rows (never a single scrolling/sliding strip --
// "we dont want it to be a sliding bar"), each grouping segments by
// status type per the customer's exact spec:
//   Row 1: Device Status, Ajax/Victron/Teltonika (Connected/Disconnected,
//   API-derived), Last Seen, Sync (fires all linked vendor syncs for
//   this unit), Battery %, local time in the asset's company's region.
//   Row 2: reserved for a follow-up group the customer hasn't specified
//   yet ("Then we will pickup the above") -- placeholder only for now.
// Segment click-through unchanged: Battery -> Devices tab's Victron
// group, Teltonika -> Devices tab's Teltonika group, Ajax -> Devices
// tab's Ajax group, everything else -> Dashboard (general).
function UM_AssetStatusBar({ unit, telemetry, victron, ajax, teltonika, efoy, isAdmin, onGoToDashboard, onGoToDeviceCategory, onSynced }) {
  const summary = UM_computeAssetSummary(unit, telemetry, victron, ajax, teltonika, efoy);
  const fmtPercent = (n) => (n === null || n === undefined ? "\u2014" : `${Math.round(Number(n))}%`);
  const toneColor = { green: "#3FAE5C", amber: "#D9A02B", red: "#D64545", grey: "#8A8474", blue: "#3B82C4" };
  const regionTime = UM_useRegionClock(summary.regionTimezone);

  // Sync button -- "Sync Button (Syncs All APIs for this unit)". Fires
  // the same shared vendor-sync functions the existing per-vendor "Sync
  // now" buttons use (see routes/admin-assets.ts's / the reseller
  // mirror's POST .../units/:id/sync-vendors), scoped to whichever of
  // Victron/Ajax/Teltonika are linked to this unit. Manual click only
  // for now -- "all for now, will need to be automatic" is a separate,
  // not-yet-built follow-up (background/scheduled syncing), not this
  // button's job today. Available to both admin and reseller viewers
  // (unlike the page's other mutating controls, which are admin-only) --
  // the customer's spec doesn't gate this one, and syncing your own
  // unit's own vendor data isn't a privileged action the way e.g.
  // revoking System Management access is.
  const [syncing, setSyncing] = useState(false);
  const [syncError, setSyncError] = useState(false);
  const handleSync = async (e) => {
    e.stopPropagation();
    if (syncing) return;
    setSyncing(true);
    setSyncError(false);
    const url = isAdmin
      ? `/api/admin/units/${unit.id}/sync-vendors`
      : `/api/portal/mission-control/units/${unit.id}/sync-vendors`;
    try {
      const res = await fetch(url, { method: "POST", credentials: "same-origin" });
      if (!res.ok) { setSyncError(true); setSyncing(false); return; }
      if (onSynced) await onSynced();
    } catch {
      setSyncError(true);
    }
    setSyncing(false);
  };

  const Segment = ({ label, value, tone, onClick, testId }) => (
    <button
      type="button" onClick={onClick} disabled={!onClick}
      data-testid={testId}
      style={{
        display: "flex", alignItems: "center", gap: 7, background: "none", border: "none",
        cursor: onClick ? "pointer" : "default", padding: "0 14px", height: 40,
        borderRight: "1px solid rgba(255,255,255,0.1)", flexShrink: 0,
        fontFamily: "var(--font-body)", fontSize: 12, minWidth: 0,
      }}
    >
      {tone && <span style={{ width: 7, height: 7, borderRadius: "50%", background: toneColor[tone] || toneColor.grey, flexShrink: 0 }} />}
      {label && <span style={{ color: "rgba(255,255,255,0.5)", letterSpacing: "0.04em", whiteSpace: "nowrap" }}>{label}</span>}
      <span style={{ color: "#fff", fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{value}</span>
    </button>
  );

  // Sync gets its own primitive rather than reusing plain Segment --
  // customer asked it to "stand out" from the passive status readouts
  // around it, since it's the one actual ACTION in the bar (everything
  // else is read-only status). Solid accent-amber fill (Solo's own
  // brand accent, matching the active-nav highlight color used
  // elsewhere on this page e.g. UM_DevicesTab's active category
  // background) + a small icon-like sync glyph, instead of the plain
  // borderless text button every other segment uses. Falls back to a
  // red fill on error, matching the red-dot-on-error convention the
  // rest of the bar already uses for failure states.
  //
  // marginLeft gives it visible breathing room from "Last Seen" right
  // before it (per customer's explicit ask -- they were butted up
  // against each other with only the hairline segment divider between
  // them) and the brighter solid fill + glow ring (vs. the previous
  // semi-transparent brownish fill) makes it read as a clearly
  // highlighted, separate call-to-action rather than just another bar
  // segment.
  const SyncSegment = ({ value, tone, onClick, testId }) => (
    <button
      type="button" onClick={onClick}
      data-testid={testId}
      style={{
        display: "flex", alignItems: "center", gap: 6, border: "none",
        cursor: "pointer", padding: "0 18px", height: 32, flexShrink: 0,
        fontFamily: "var(--font-body)", fontSize: 12, fontWeight: 700,
        letterSpacing: "0.03em", minWidth: 0, marginLeft: 16, alignSelf: "center",
        borderRadius: 6,
        background: tone === "red" ? "#D64545" : "#E8920A",
        boxShadow: tone === "red" ? "0 0 0 1px rgba(214,69,69,0.4)" : "0 0 0 1px rgba(232,146,11,0.5), 0 0 10px rgba(232,146,11,0.45)",
        color: "#fff",
      }}
    >
      <span style={{ fontSize: 13, lineHeight: 1, display: "inline-block", animation: syncing ? "um-sync-spin 0.9s linear infinite" : "none" }}>
        &#x21bb;
      </span>
      <span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{value}</span>
    </button>
  );

  // flexWrap "nowrap" (rather than the old "wrap") so segments never
  // spill onto a 2nd row or trigger a horizontal scrollbar -- stays
  // exactly one row, always, per "the black banner... we dont want it
  // to be a sliding bar". The second placeholder row ("More status
  // metrics coming soon") this bar briefly had has been removed per
  // "the more status line can go" -- this is back to a single row, so
  // the borderBottom that used to separate row 1 from row 2 is gone
  // too (it would otherwise now render as a stray line under the
  // whole bar).
  const rowStyle = {
    display: "flex", flexWrap: "nowrap", alignItems: "stretch", overflow: "hidden",
  };

  return (
    <div data-testid="unit-asset-status-bar" style={{ background: "#1A1712", marginBottom: 22 }}>
      <div style={rowStyle} data-testid="status-bar-row-1">
        <Segment
          label="Device Status" value={summary.deviceStatus.label.toUpperCase()} tone={summary.deviceStatus.tone}
          onClick={onGoToDashboard} testId="status-bar-device-status"
        />
        <Segment
          label="Ajax" value={summary.ajaxStatus.label} tone={summary.ajaxStatus.tone}
          onClick={() => onGoToDeviceCategory("Ajax")} testId="status-bar-ajax"
        />
        <Segment
          label="Victron" value={summary.victronApiStatus.label} tone={summary.victronApiStatus.tone}
          onClick={() => onGoToDeviceCategory("Victron")} testId="status-bar-victron-api"
        />
        <Segment
          label="Teltonika" value={summary.networkStatus.label} tone={summary.networkStatus.tone}
          onClick={() => onGoToDeviceCategory("Teltonika")} testId="status-bar-network"
        />
        <Segment
          label="Battery" value={fmtPercent(summary.batteryPercent)}
          tone={summary.batteryPercent != null && Number(summary.batteryPercent) < 20 ? "red" : "green"}
          onClick={() => onGoToDeviceCategory("Victron")} testId="status-bar-battery"
        />
        {regionTime && (
          <Segment label="Local Time" value={regionTime} testId="status-bar-region-time" />
        )}
        {/* Last Seen + Sync moved to the very end of the row per the
            customer's explicit ask -- Sync (the bar's one clickable
            ACTION, everything else here is a passive status readout)
            renders via SyncSegment's solid accent-fill button instead
            of the plain borderless Segment every status readout uses,
            so it visually "stands out" rather than reading as just
            another status field. */}
        <Segment
          label="Last Seen" value={summary.lastSeenText} onClick={onGoToDashboard} testId="status-bar-last-seen"
        />
        <SyncSegment
          value={syncing ? "Syncing\u2026" : syncError ? "Sync Failed" : "Sync"}
          tone={syncError ? "red" : undefined}
          onClick={handleSync} testId="status-bar-sync-button"
        />
      </div>
      {/* Row 2 (the "More status metrics coming soon" placeholder) has
          been removed per "the more status line can go" -- row 1 above
          now stands alone as the whole status bar, with its own
          borderBottom left as-is (it never carried the ": none"
          override, so removing row 2 needs no further change there). */}
      <style>{`
        @keyframes um-sync-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
      `}</style>
    </div>
  );
}

function UnitManagementPage({ onNavigate, unitId, viewerType }) {
  const isAdmin = viewerType === "admin";
  const [state, setState] = useState({
    status: "loading", viewer: null, isSuperAdmin: false, unit: null, telemetry: null,
    victron: null, ajax: null, teltonika: null, efoy: null, error: "",
  });
  const [activeSubTab, setActiveSubTab] = useState("general");
  // §5 status-bar click-through target: which Devices-tab category group
  // to land on when a segment (Battery/Network/Ajax) is clicked. Cleared
  // once consumed so a later manual "Devices" tab click doesn't get
  // stuck re-forcing an old category (see UM_DevicesTab's effect below).
  const [deviceCategoryTarget, setDeviceCategoryTarget] = useState(null);
  const [toggleBusy, setToggleBusy] = useState(null);
  const [accessBusy, setAccessBusy] = useState(false);
  const [efoyEnabledBusy, setEfoyEnabledBusy] = useState(false);
  // Bug fix (customer report, this session -- "not smooth, doesn't always
  // do the command, sometimes it goes back"), SECOND cause found alongside
  // the server-side read-back race fixed in runAjaxHubArm
  // (routes/admin-ajax.ts): this page's background refreshes -- the 30s
  // poll, the 2min vendor-sync poll, AND the toggle's own "background
  // reconcile" load fired right after a successful command -- all do a
  // FULL setState({ ajax: unitData.ajax, ... }) replace from a fresh D1/
  // API read. If one of those had ALREADY started (its fetch in flight)
  // before the user clicked Arm/Disarm or a relay button, it resolves
  // with a snapshot taken BEFORE the toggle's own write landed -- and
  // because it's a full replace, applying it clobbers the toggle's
  // correct optimistic/reconciled ajax state with the stale pre-toggle
  // value. That is indistinguishable from "the command didn't work" or
  // "it changed then changed back", and got WORSE once the read-back fix
  // above added up to ~4s of extra backend latency to every hub toggle --
  // more time for a background load() to land mid-toggle.
  //
  // Fixed with a simple monotonic guard, no arbitrary timers/expiry
  // needed: every time a toggle applies an ajax state change --
  // optimistic flip, real reconciliation, OR revert-on-failure, all three
  // go through handleHubStateChanged/handleRelayStateChanged below --
  // this ref is stamped with the current time. Each load() call records
  // its OWN start time before firing its fetches; when the response comes
  // back, if a toggle wrote to ajax state AFTER this particular load()
  // started, this load's ajax snapshot is provably stale relative to that
  // write, so it's discarded (keeping whatever's already on screen)
  // instead of overwriting it. Every other field this page tracks (unit,
  // telemetry, victron, teltonika, efoy) is untouched by this guard and
  // still refreshes normally on every load().
  const ajaxWriteMarkerRef = useRef(0);
  const relayUrlBase = isAdmin ? "/api/admin/relays" : "/api/portal/mission-control/relays";
  const hubUrlBase = isAdmin ? "/api/admin/hub" : "/api/portal/mission-control/hub";

  const telemetryUrl = isAdmin
    ? `/api/admin/units/${unitId}/telemetry`
    : `/api/portal/assets/${unitId}/telemetry`;
  const toggleUrl = isAdmin
    ? `/api/admin/units/${unitId}/toggle`
    : `/api/portal/assets/${unitId}/toggle`;

  // `silent` -- used by the auto-refresh loop below so a background
  // re-fetch never flashes "Loading unit..." over the page the operator
  // is actively looking at, and never wipes good data off the screen
  // over a single transient network hiccup (only the very first,
  // non-silent load shows the loading/error empty states; a failed
  // silent refresh just leaves whatever was already rendered in place
  // and quietly tries again next tick).
  const load = (silent) => {
    if (!unitId) {
      setState({ status: "error", viewer: null, unit: null, telemetry: null, victron: null, ajax: null, teltonika: null, efoy: null, error: "No unit selected." });
      return;
    }
    // Stamp this call's own start time BEFORE firing the fetches (see
    // ajaxWriteMarkerRef's comment above) -- used below to detect if a
    // toggle wrote a newer ajax state while this particular load() was
    // still in flight, in which case this load's ajax snapshot is stale
    // and must not overwrite that newer write.
    const loadStartedAt = Date.now();
    const meUrl = isAdmin ? "/api/admin/me" : "/api/portal/me";
    Promise.all([
      fetch(meUrl, { credentials: "same-origin" }).then((r) => (r.ok ? r.json() : Promise.reject({ authFailed: true }))),
      fetch(telemetryUrl, { credentials: "same-origin" }).then(async (r) => {
        const data = await r.json().catch(() => ({}));
        if (!r.ok) throw { authFailed: false, message: data.error || "Couldn't load this unit." };
        return data;
      }),
    ])
      .then(([me, unitData]) => {
        // If a hub/relay toggle wrote a newer ajax state AFTER this
        // load() started, this load's own ajax snapshot predates that
        // write and is stale -- keep whatever's already on screen for
        // `ajax` (the toggle's own optimistic/reconciled/reverted value)
        // rather than clobbering it, while every other field this load()
        // carries still applies normally.
        const ajaxIsStale = ajaxWriteMarkerRef.current > loadStartedAt;
        setState((s) => ({
          ...s,
          status: "ready",
          viewer: isAdmin ? me.admin : { user: me.user, company: me.company },
          // Live View start/stop is gated requireSuperAdmin on the
          // backend (admin-live-view.ts) -- same posture as "Launch
          // WebUI" -- so a plain (non-super) admin needs this flag to
          // know to show the read-only WHEP player without the
          // start/stop buttons. Always false for resellers (portal has
          // no super_admin concept; they get read-only viewing only).
          isSuperAdmin: isAdmin ? !!me.isSuperAdmin : false,
          unit: unitData.unit, telemetry: unitData.telemetry,
          victron: unitData.victron || null,
          ajax: ajaxIsStale ? s.ajax : (unitData.ajax || null),
          teltonika: unitData.teltonika || null,
          efoy: unitData.efoy || null,
          error: "",
        }));
      })
      .catch((err) => {
        if (err && err.authFailed) { onNavigate(isAdmin ? "admin-login" : "reseller-login"); return; }
        if (silent) return; // leave existing state on screen, try again next tick
        setState({ status: "error", viewer: null, isSuperAdmin: false, unit: null, telemetry: null, victron: null, ajax: null, teltonika: null, efoy: null, error: (err && err.message) || "Couldn't load this unit." });
      });
  };

  useEffect(() => load(false), [unitId, viewerType]); // eslint-disable-line react-hooks/exhaustive-deps

  // "when go into the physical asset it updates frequently why we are
  // in that asset" (customer's exact instruction, this session) --
  // while this specific Mission Control screen is open, keep it
  // refreshed automatically instead of requiring a manual "Sync" click
  // or a full page reload. Two tiers, both scoped to only THIS one
  // unit's vendor links (never all 146+ units the background cron
  // covers, and never running when nobody's actually looking):
  //   1. Every UM_LIVE_REFRESH_MS (30s): re-fetch the telemetry route,
  //      a cheap read of whatever's already cached in D1 -- picks up
  //      anything the background cron (routes/cron-sync-health.ts,
  //      running every ~5 min against the whole fleet) already wrote
  //      since the operator opened this page.
  //   2. Every UM_LIVE_SYNC_MS (2 min): also fire the same POST
  //      .../sync-vendors the status bar's own manual "Sync" button
  //      uses -- an active pull from Victron/Ajax/Teltonika for just
  //      this one unit, so the specific asset someone is actively
  //      staring at gets fresher-than-fleet-cadence data without
  //      waiting for its turn in the shared background batch. This is
  //      exactly the kind of small, single-asset-scoped, human-present
  //      polling that IS safe at a tighter interval than the earlier
  //      "every 3 seconds for the whole fleet" ask -- one unit's three
  //      vendor calls, only while a person is on this exact screen, is
  //      nothing like polling 146+ hubs unattended around the clock.
  // Paused entirely while the browser tab isn't visible
  // (document.visibilitychange) so switching away to another tab, or
  // minimising the window, doesn't keep spending vendor API calls and
  // Worker invocations for a screen nobody's looking at -- resumes (and
  // immediately refreshes once) the moment it's visible again.
  const UM_LIVE_REFRESH_MS = 30 * 1000;
  const UM_LIVE_SYNC_MS = 2 * 60 * 1000;
  const syncVendorsUrl = isAdmin
    ? `/api/admin/units/${unitId}/sync-vendors`
    : `/api/portal/mission-control/units/${unitId}/sync-vendors`;
  useEffect(() => {
    if (!unitId) return;
    let refreshTimer = null;
    let syncTimer = null;
    const startTimers = () => {
      if (refreshTimer || syncTimer) return;
      refreshTimer = setInterval(() => load(true), UM_LIVE_REFRESH_MS);
      syncTimer = setInterval(() => {
        fetch(syncVendorsUrl, { method: "POST", credentials: "same-origin" })
          .then(() => load(true))
          .catch(() => {}); // best-effort -- a failed background sync just leaves last-known data up, same as a manual Sync failure would
      }, UM_LIVE_SYNC_MS);
    };
    const stopTimers = () => {
      if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
      if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
    };
    const handleVisibility = () => {
      if (document.hidden) { stopTimers(); return; }
      startTimers();
      load(true); // catch up immediately on returning to the tab, don't wait for the next tick
    };
    if (!document.hidden) startTimers();
    document.addEventListener("visibilitychange", handleVisibility);
    return () => {
      stopTimers();
      document.removeEventListener("visibilitychange", handleVisibility);
    };
  }, [unitId, viewerType]); // eslint-disable-line react-hooks/exhaustive-deps

  const handleToggle = async (key, next) => {
    setToggleBusy(key);
    try {
      const res = await fetch(toggleUrl, {
        method: "PATCH", credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ key, on: next }),
      });
      if (res.ok) {
        setState((s) => ({ ...s, telemetry: { ...s.telemetry, [`${key}_on`]: next ? 1 : 0 } }));
      }
    } finally { setToggleBusy(null); }
  };

  // Instant arm/disarm + relay on/off reflection (customer: "when i arm
  // or disarm an ajax device or relay. I need to do an instant API call
  // and refresh the mission control to update the status and button...
  // Green / Red for Arm and then Red to Green for disarm"). Previously
  // UM_HubArmRow/UM_RelayRow's own button colour was driven ENTIRELY by
  // the hub/device prop passed down from this page's state -- so even
  // though the toggle command itself completed instantly, the button
  // stayed the OLD colour until onRelaysChanged (= a full `load()`,
  // re-fetching /me + /telemetry from scratch) finished a full second
  // round trip. These two patch this page's own `ajax` state directly
  // from the toggle endpoint's own response body -- which already
  // carries the real post-command state (runAjaxHubArm's read-back for
  // hubs; the relay toggle's own `on` echo) -- so the badge/button/
  // Security Status glow all flip colour on this exact API call
  // completing, with no wait for a second reload. The full `load()`
  // reload (still triggered right after, see onHubChanged/
  // onRelaysChanged below) is kept running in the background purely to
  // reconcile anything else this page derives from ajax/telemetry (e.g.
  // vendor sync_status), but the visible colour change no longer
  // depends on it finishing.
  const handleHubStateChanged = (hubId, newState) => {
    // Stamp the write marker on EVERY call -- optimistic flip, real
    // reconciliation, AND revert-on-failure all come through here, and
    // all three are "the current truth" that a stale in-flight load()
    // must not be allowed to overwrite (see ajaxWriteMarkerRef's comment
    // above load()'s declaration).
    ajaxWriteMarkerRef.current = Date.now();
    setState((s) => {
      if (!s.ajax || !s.ajax.hub || s.ajax.hub.id !== hubId) return s;
      return { ...s, ajax: { ...s.ajax, hub: { ...s.ajax.hub, state: newState } } };
    });
  };
  const handleRelayStateChanged = (deviceId, on) => {
    ajaxWriteMarkerRef.current = Date.now();
    setState((s) => {
      if (!s.ajax || !s.ajax.devices) return s;
      return {
        ...s,
        ajax: {
          ...s.ajax,
          devices: s.ajax.devices.map((d) => {
            if (d.id !== deviceId) return d;
            let st = {};
            try { st = JSON.parse(d.state_json || "{}"); } catch { /* ignore */ }
            st.on = on;
            return { ...d, state_json: JSON.stringify(st) };
          }),
        },
      };
    });
  };

  const handleToggleAccess = async () => {
    if (!isAdmin) return;
    setAccessBusy(true);
    try {
      const res = await fetch(`/api/admin/units/${unitId}/management-access`, {
        method: "PATCH", credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ enabled: !state.unit.management_access_enabled }),
      });
      if (res.ok) {
        setState((s) => ({ ...s, unit: { ...s.unit, management_access_enabled: s.unit.management_access_enabled ? 0 : 1 } }));
      }
    } finally { setAccessBusy(false); }
  };

  // Admin-only EFOY enable/disable (migrations/0035) -- "On the efoy page
  // have the ability to enable or disable, if disabled does not show the
  // efoy information on the general screen" (customer's exact
  // instruction, this session). Same shape as handleToggleAccess above,
  // just a different flag/route -- disabling never unlinks the device,
  // it only hides the General tab's EFOY card (see UM_GeneralTab).
  const handleToggleEfoyEnabled = async () => {
    if (!isAdmin || !state.unit) return;
    setEfoyEnabledBusy(true);
    try {
      const res = await fetch(`/api/admin/units/${unitId}/efoy-enabled`, {
        method: "PATCH", credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ enabled: !state.unit.efoy_enabled }),
      });
      if (res.ok) {
        setState((s) => ({ ...s, unit: { ...s.unit, efoy_enabled: s.unit.efoy_enabled ? 0 : 1 } }));
      }
    } finally { setEfoyEnabledBusy(false); }
  };

  const backAction = (
    <button
      type="button"
      onClick={() => onNavigate(isAdmin ? "admin-assets" : "portal-dashboard")}
      data-testid="unit-management-back"
      style={{
        background: "none", border: "1px solid rgba(0,0,0,0.25)",
        color: "rgba(0,0,0,0.75)", cursor: "pointer", padding: "10px 18px",
        fontFamily: "var(--font-body)", fontSize: 11.5, fontWeight: 500,
        letterSpacing: "0.14em", textTransform: "uppercase",
      }}
    >
      &larr; Back to assets
    </button>
  );

  let body;
  if (state.status === "loading") {
    body = <UM_EmptyNote>Loading unit&hellip;</UM_EmptyNote>;
  } else if (state.status === "error") {
    body = <UM_EmptyNote>{state.error}</UM_EmptyNote>;
  } else {
    body = (
      <UM_Dashboard
        unit={state.unit}
        telemetry={state.telemetry}
        victron={state.victron}
        ajax={state.ajax}
        teltonika={state.teltonika}
        efoy={state.efoy}
        activeSubTab={activeSubTab}
        setActiveSubTab={setActiveSubTab}
        onToggle={handleToggle}
        toggleBusy={toggleBusy}
        isAdmin={isAdmin}
        isSuperAdmin={state.isSuperAdmin}
        accessBusy={accessBusy}
        onToggleAccess={handleToggleAccess}
        efoyEnabledBusy={efoyEnabledBusy}
        onToggleEfoyEnabled={handleToggleEfoyEnabled}
        relayUrlBase={relayUrlBase}
        hubUrlBase={hubUrlBase}
        onRelaysChanged={load}
        onHubStateChanged={handleHubStateChanged}
        onRelayStateChanged={handleRelayStateChanged}
        deviceCategoryTarget={deviceCategoryTarget}
        onDeviceCategoryTargetConsumed={() => setDeviceCategoryTarget(null)}
        onGoToDeviceCategory={(category) => { setDeviceCategoryTarget(category); setActiveSubTab("devices"); }}
      />
    );
  }

  if (isAdmin) {
    return (
      <AdminShell
        admin={state.viewer} page="admin-assets" onNavigate={onNavigate}
        subtitle="Staff only" title={state.unit ? `${state.unit.serial_number} · Mission Control` : "Mission Control"}
        actions={backAction}
      >
        {body}
      </AdminShell>
    );
  }

  return (
    <ResellerShell
      page="portal-dashboard" onNavigate={onNavigate}
      userName={state.viewer && state.viewer.user ? state.viewer.user.name : undefined}
      companyName={state.viewer && state.viewer.company ? state.viewer.company.name : undefined}
      subtitle="Mission Control"
      title={state.unit ? `${state.unit.serial_number}` : "Mission Control"}
      actions={backAction}
    >
      {body}
    </ResellerShell>
  );
}

function UM_Dashboard({
  unit, telemetry, victron, ajax, teltonika, efoy, activeSubTab, setActiveSubTab, onToggle, toggleBusy,
  isAdmin, isSuperAdmin, accessBusy, onToggleAccess, efoyEnabledBusy, onToggleEfoyEnabled, relayUrlBase, hubUrlBase, onRelaysChanged,
  onHubStateChanged, onRelayStateChanged,
  deviceCategoryTarget, onDeviceCategoryTargetConsumed, onGoToDeviceCategory,
}) {
  const accessEnabled = !!unit.management_access_enabled;
  return (
    <div>
      {/* §5 Persistent Asset Status Bar — visible above every sub-tab. */}
      <UM_AssetStatusBar
        unit={unit} telemetry={telemetry} victron={victron} ajax={ajax} teltonika={teltonika} efoy={efoy}
        isAdmin={isAdmin}
        onGoToDashboard={() => setActiveSubTab("general")}
        onGoToDeviceCategory={onGoToDeviceCategory}
        onSynced={onRelaysChanged}
      />

      <div style={{
        display: "flex", justifyContent: "space-between", alignItems: "flex-end",
        flexWrap: "wrap", gap: 14, marginBottom: 22,
      }}>
        <div style={{ fontFamily: "var(--font-body)", fontSize: 13.5, color: "rgba(0,0,0,0.55)" }}>
          {unit.product_name}
          {unit.current_company_name && <> &middot; {unit.current_company_name}</>}
        </div>
        {isAdmin && (
          <button
            type="button" onClick={onToggleAccess} disabled={accessBusy}
            data-testid="unit-management-toggle-access"
            style={{
              background: accessEnabled ? "transparent" : "#000",
              color: accessEnabled ? "rgba(190,40,40,0.9)" : "#fff",
              border: `1px solid ${accessEnabled ? "rgba(190,40,40,0.5)" : "#000"}`,
              padding: "10px 18px", cursor: accessBusy ? "not-allowed" : "pointer",
              opacity: accessBusy ? 0.6 : 1,
              fontFamily: "var(--font-body)", fontSize: 11.5, fontWeight: 500,
              letterSpacing: "0.14em", textTransform: "uppercase",
            }}
          >
            {accessEnabled ? "Revoke reseller access" : "Allow reseller access"}
          </button>
        )}
      </div>

      {!accessEnabled && (
        <div style={{
          background: "rgba(190,40,40,0.08)", border: "1px solid rgba(190,40,40,0.35)",
          color: "rgba(140,30,30,0.95)", padding: "12px 16px", marginBottom: 22,
          fontFamily: "var(--font-body)", fontSize: 13, letterSpacing: "0.01em",
        }}>
          {isAdmin
            ? "This unit's System Management access is currently revoked for the reseller — they cannot view this dashboard until it's re-enabled."
            : "System Management access for this unit has been disabled by Solo staff."}
        </div>
      )}

      {/* Top-level sub-navigation (§4 Navigation) */}
      <div style={{ borderBottom: "1px solid rgba(0,0,0,0.12)", marginBottom: 28, overflowX: "auto" }}>
        <div style={{ display: "flex", gap: 4, minWidth: "max-content" }}>
          {UM_TABS.map((tab) => {
            const active = activeSubTab === tab.id;
            return (
              <button
                key={tab.id} type="button" onClick={() => setActiveSubTab(tab.id)}
                data-testid={`unit-tab-${tab.id}`}
                style={{
                  background: "none", border: "none", cursor: "pointer",
                  padding: "14px 4px", marginRight: 24, marginBottom: -1, whiteSpace: "nowrap",
                  borderBottom: `2px solid ${active ? "rgba(180,110,0,0.85)" : "transparent"}`,
                  color: active ? "#000" : "rgba(0,0,0,0.55)",
                  fontFamily: "var(--font-body)", fontSize: 12,
                  fontWeight: active ? 600 : 500, letterSpacing: "0.06em",
                  textTransform: "uppercase",
                }}
              >
                {tab.label}
              </button>
            );
          })}
        </div>
      </div>

      {activeSubTab === "general" ? (
        <UM_GeneralTab
          unit={unit} telemetry={telemetry} victron={victron} ajax={ajax} teltonika={teltonika} efoy={efoy}
          onToggle={onToggle} toggleBusy={toggleBusy}
          relayUrlBase={relayUrlBase} hubUrlBase={hubUrlBase} onRelaysChanged={onRelaysChanged}
          onHubStateChanged={onHubStateChanged} onRelayStateChanged={onRelayStateChanged}
          isAdmin={isAdmin}
        />
      ) : activeSubTab === "cameras" ? (
        <UM_CamerasTab unit={unit} isAdmin={isAdmin} isSuperAdmin={isSuperAdmin} />
      ) : activeSubTab === "devices" ? (
        // EFOY / Speakers now live inside this tab's own left-nav (top-nav
        // consolidation, this session) -- see UM_DevicesTab's header
        // comment. efoyEnabledBusy/onToggleEfoyEnabled thread straight
        // through to its internal <UM_EfoyTab> the same way they used to
        // thread to this switcher's old standalone "efoy" branch.
        <UM_DevicesTab
          unit={unit} isAdmin={isAdmin} victron={victron} ajax={ajax} teltonika={teltonika} efoy={efoy}
          categoryTarget={deviceCategoryTarget} onCategoryTargetConsumed={onDeviceCategoryTargetConsumed}
          relayUrlBase={relayUrlBase} hubUrlBase={hubUrlBase} onRelaysChanged={onRelaysChanged}
          onHubStateChanged={onHubStateChanged} onRelayStateChanged={onRelayStateChanged}
          efoyEnabledBusy={efoyEnabledBusy} onToggleEfoyEnabled={onToggleEfoyEnabled}
        />
      ) : activeSubTab === "notifications" ? (
        <UM_NotificationsTab
          unit={unit} telemetry={telemetry} victron={victron} ajax={ajax} teltonika={teltonika} efoy={efoy}
          isAdmin={isAdmin}
        />
      ) : activeSubTab === "system" ? (
        <UM_SystemTab />
      ) : (
        <UM_ComingSoon tab={UM_TABS.find((t) => t.id === activeSubTab)} />
      )}
    </div>
  );
}

// Dedicated EFOY tab — fuller device detail than the General tab's
// summary card (which only shows a handful of headline figures shared
// with the demo-data layout). This tab reads the exact same `efoy` prop
// (the linked efoy_devices row, or null — see lib/telemetry.ts's
// loadUnitVendorData), so it's always in sync with General; it just has
// room to show everything EFOY Cloud actually reports for a device
// (identity/firmware/mode, full live telemetry, and any active
// error/warning) since it isn't fighting the demo-data layout for space.
//
// "Not linked yet" state mirrors the wording used everywhere else this
// product says the same thing (VendorLinkSection's empty note on the
// Assets page's Link Devices modal) so a reseller/admin sees consistent
// language whether they're looking at the summary card, this tab, or the
// linking modal itself.
//
// Enable/disable switch (migrations/0035, admin-only) -- "On the efoy
// page have the ability to enable or disable, if disabled does not show
// the efoy information on the general screen" (customer's exact
// instruction, this session). Purely a display switch: disabling never
// touches the underlying efoy_devices link, it only hides the General
// tab's EFOY card (UM_GeneralTab's render condition) and swaps this
// tab's own content for a plain "disabled" notice + re-enable button,
// same pattern as the top-of-dashboard "Revoke/Allow reseller access"
// control above.
function UM_EfoyTab({ unit, efoy, isAdmin, enabledBusy, onToggleEnabled }) {
  const fmt = (n, dp) => (n === null || n === undefined ? "\u2014" : Number(n).toFixed(dp));

  if (!unit.has_efoy) {
    return (
      <UM_EmptyNote>This unit's product profile doesn't include an EFOY fuel cell.</UM_EmptyNote>
    );
  }

  const efoyEnabled = !!unit.efoy_enabled;

  const enabledToggleButton = isAdmin && (
    <button
      type="button" onClick={onToggleEnabled} disabled={enabledBusy}
      data-testid="unit-efoy-toggle-enabled"
      style={{
        background: efoyEnabled ? "transparent" : "#000",
        color: efoyEnabled ? "rgba(190,40,40,0.9)" : "#fff",
        border: `1px solid ${efoyEnabled ? "rgba(190,40,40,0.5)" : "#000"}`,
        padding: "9px 16px", cursor: enabledBusy ? "not-allowed" : "pointer",
        opacity: enabledBusy ? 0.6 : 1,
        fontFamily: "var(--font-body)", fontSize: 11, fontWeight: 500,
        letterSpacing: "0.12em", textTransform: "uppercase",
      }}
    >
      {efoyEnabled ? "Disable EFOY" : "Enable EFOY"}
    </button>
  );

  if (!efoyEnabled) {
    return (
      <div>
        <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 16 }}>{enabledToggleButton}</div>
        <div data-testid="efoy-tab-disabled" style={{
          background: "rgba(0,0,0,0.02)", border: "1px solid rgba(0,0,0,0.1)",
          padding: "40px 32px", textAlign: "center",
        }}>
          <div style={{
            fontFamily: "var(--font-body)", fontSize: 9, letterSpacing: "0.2em",
            textTransform: "uppercase", color: "rgba(0,0,0,0.4)", marginBottom: 12, fontWeight: 500,
          }}>Disabled</div>
          <div style={{
            fontFamily: "var(--font-display)", fontWeight: 700, fontSize: 18,
            textTransform: "uppercase", color: "rgba(0,0,0,0.8)", marginBottom: 8,
          }}>EFOY</div>
          <div style={{ fontFamily: "var(--font-body)", fontSize: 13.5, color: "rgba(0,0,0,0.5)", maxWidth: 420, margin: "0 auto" }}>
            {isAdmin
              ? "EFOY has been disabled for this unit. Its General tab card is hidden until you re-enable it here."
              : "EFOY has been disabled for this unit by Solo staff."}
          </div>
        </div>
      </div>
    );
  }

  if (!efoy) {
    return (
      <div>
        {isAdmin && <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 16 }}>{enabledToggleButton}</div>}
        <div data-testid="efoy-tab-not-linked" style={{
          background: "rgba(0,0,0,0.02)", border: "1px solid rgba(0,0,0,0.1)",
          padding: "40px 32px", textAlign: "center",
        }}>
          <div style={{
            fontFamily: "var(--font-body)", fontSize: 9, letterSpacing: "0.2em",
            textTransform: "uppercase", color: "rgba(0,0,0,0.4)", marginBottom: 12, fontWeight: 500,
          }}>Not linked</div>
          <div style={{
            fontFamily: "var(--font-display)", fontWeight: 700, fontSize: 18,
            textTransform: "uppercase", color: "rgba(0,0,0,0.8)", marginBottom: 8,
          }}>EFOY</div>
          <div style={{ fontFamily: "var(--font-body)", fontSize: 13.5, color: "rgba(0,0,0,0.5)", maxWidth: 420, margin: "0 auto" }}>
            No EFOY Cloud device is linked to this unit yet. Connect the company on
            Settings &rarr; Data Connections, then use &ldquo;Link devices&rdquo; on the Assets page to
            sync and match a device to this unit. The General tab shows demo figures until then.
          </div>
        </div>
      </div>
    );
  }

  const badge = efoy.state
    ? { label: efoy.state.replace(/_/g, " "), tone: efoy.connected ? "green" : "grey" }
    : { label: "Unknown", tone: "grey" };
  const hasIssue = !!(efoy.active_error || efoy.active_warning);

  return (
    <div>
      {isAdmin && <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 16 }}>{enabledToggleButton}</div>}
      <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 20 }}>
      <UM_Card title="Device" badge={badge}>
        <UM_Row label="Name" value={efoy.name || "\u2014"} />
        <UM_Row label="Serial" value={efoy.serial_number || "\u2014"} />
        <UM_Row label="Type" value={efoy.device_type || "\u2014"} />
        <UM_Row label="Firmware" value={efoy.firmware_version || "\u2014"} />
        <UM_Row label="User Mode" value={efoy.user_mode || "\u2014"} />
        <UM_Row label="Service Mode" value={efoy.in_service_mode ? "On" : "Off"} last />
      </UM_Card>

      <UM_Card title="Live Telemetry" badge={efoy.connected ? { label: "Connected", tone: "green" } : { label: "Offline", tone: "grey" }}>
        <UM_BigValue>{fmt(efoy.power_output_w, 2)} W</UM_BigValue>
        <UM_Row label="Battery Voltage" value={`${fmt(efoy.voltage_battery_v, 2)} V`} />
        <UM_Row label="EFOY Voltage" value={`${fmt(efoy.voltage_efoy_v, 2)} V`} />
        <UM_Row label="Charging Current" value={`${fmt(efoy.charging_current_a, 2)} A`} />
        <UM_Row label="State of Charge" value={efoy.state_of_charge != null ? `${fmt(efoy.state_of_charge, 0)}%` : "\u2014"} />
        <UM_Row label="Temperature" value={efoy.efoy_temperature_c != null ? `${fmt(efoy.efoy_temperature_c, 0)} \u00b0C` : "\u2014"} last />
      </UM_Card>

      <UM_Card title="Fuel & Status" badge={hasIssue ? { label: "Attention", tone: "red" } : undefined}>
        <UM_BigValue>{fmt(efoy.fuel_level_percent, 0)}%</UM_BigValue>
        <UM_Row label="Run Time" value={UM_formatRuntime(efoy.stack_operation_time != null ? efoy.stack_operation_time * 60 : null)} />
        <UM_Row label="Active Error" value={efoy.active_error || "None"} />
        <UM_Row label="Active Warning" value={efoy.active_warning || "None"} />
        <UM_Row label="Last Synced" value={efoy.last_synced_at ? new Date(efoy.last_synced_at + "Z").toLocaleString() : "\u2014"} last />
      </UM_Card>
      </div>
    </div>
  );
}

// ─────────────────────────── Speakers tab ───────────────────────────
// Exactly 2 Ajax SpeakerPhone Jeweller slots per unit (unit_speakers —
// migrations/0034). "on speakers tab this needs to link to ajax
// speakerphone devices maximum 2" (customer's exact instruction, this
// session). Deliberately its own tab/table, separate from the General
// tab's Relay Control panel (UM_RelayPanel below) — "assigned by id
// seperate to relay" (customer, this session).
//
// Talkdown (SIP) and Speaker Test are real buttons wired to real backend
// routes (routes/admin-assets.ts + routes/portal.ts's
// .../speakers/:slot/talkdown and /speaker-test), which in turn call
// lib/ajax.ts's triggerAjaxSpeakerphoneTalkdown/triggerAjaxSpeakerphoneTest
// stubs. Those stubs deliberately fail with a clear message rather than
// pretending to succeed — "endpoint or ajax linkage another time"
// (customer, this session): the actual Ajax Enterprise API endpoint for
// either action isn't confirmed yet, so this tab is honest about that
// (an inline error under the buttons) instead of showing a fake "Sent"
// confirmation. Only lib/ajax.ts's two functions need to change once
// that access + endpoint are confirmed — this UI and its routes stay as
// they are.
function UM_SpeakersTab({ unit, isAdmin }) {
  const [state, setState] = useState({ status: "loading", speakers: [], ajaxCandidates: [] });
  const [editingSlot, setEditingSlot] = useState(null);

  const speakersUrl = isAdmin ? `/api/admin/units/${unit.id}/speakers` : `/api/portal/assets/${unit.id}/speakers`;
  const actionUrlBase = isAdmin ? `/api/admin/units/${unit.id}/speakers` : `/api/portal/assets/${unit.id}/speakers`;

  const load = () => {
    fetch(speakersUrl, { credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then((data) => setState({ status: "ready", speakers: data.speakers || [], ajaxCandidates: data.ajaxCandidates || [] }))
      .catch(() => setState({ status: "error", speakers: [], ajaxCandidates: [] }));
  };

  useEffect(load, [unit.id]); // eslint-disable-line react-hooks/exhaustive-deps

  if (!unit.has_speakers) {
    return <UM_EmptyNote>This unit's product profile doesn't include speakerphones.</UM_EmptyNote>;
  }
  if (state.status === "loading") return <UM_EmptyNote>Loading speakers&hellip;</UM_EmptyNote>;

  const bySlot = {};
  state.speakers.forEach((s) => { bySlot[s.slot] = s; });

  return (
    <div>
      <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474", marginBottom: 16 }}>
        Up to 2 Ajax SpeakerPhone Jeweller devices per asset, linked from this unit's synced Ajax hub.
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16 }}>
        {UM_SPEAKER_SLOTS.map((slot) => (
          <UM_SpeakerCard
            key={slot} unit={unit} actionUrlBase={actionUrlBase} slot={slot} speaker={bySlot[slot]}
            onEdit={isAdmin ? () => setEditingSlot(slot) : undefined}
          />
        ))}
      </div>

      {editingSlot != null && (
        <UM_SpeakerEditModal
          unit={unit} slot={editingSlot} speaker={bySlot[editingSlot]} ajaxCandidates={state.ajaxCandidates}
          onCancel={() => setEditingSlot(null)}
          onSaved={() => { setEditingSlot(null); load(); }}
        />
      )}
    </div>
  );
}

const UM_SPEAKER_SLOTS = [1, 2];

// A single speaker slot card — device identity + Online/Battery/Signal
// status when linked, an empty placeholder when not, and the two action
// buttons (disabled/greyed with an inline error once clicked, since
// neither is wired to a real Ajax call yet — see this tab's own header
// comment above).
function UM_SpeakerCard({ unit, actionUrlBase, slot, speaker, onEdit }) {
  const linked = !!(speaker && speaker.ajax_device_id);
  const label = (speaker && speaker.label) || (speaker && speaker.device_name) || `Speaker ${slot}`;
  const online = linked ? !!speaker.online : null;
  const [actionState, setActionState] = useState({ busy: null, error: null });

  const runAction = async (action) => {
    setActionState({ busy: action, error: null });
    try {
      const res = await fetch(`${actionUrlBase}/${slot}/${action}`, { method: "POST", credentials: "same-origin" });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "That didn't work.");
      setActionState({ busy: null, error: null });
    } catch (err) {
      setActionState({ busy: null, error: err.message });
    }
  };

  return (
    <UM_Card
      title={label}
      badge={linked ? { label: online ? "Online" : "Offline", tone: online ? "green" : "grey" } : undefined}
    >
      {!linked ? (
        <div style={{
          display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
          gap: 8, padding: "18px 0", color: "#B8B2A0",
        }}>
          <IconSpeakerphone size={22} />
          <span style={{ fontFamily: "var(--font-body)", fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase" }}>
            Not linked
          </span>
        </div>
      ) : (
        <>
          <UM_Row label="Room" value={speaker.room_name || "\u2014"} />
          <UM_Row label="Battery" value={speaker.battery_level != null ? `${speaker.battery_level}%` : "\u2014"} />
          <UM_Row label="Signal" value={speaker.signal_level || "\u2014"} last />

          <div style={{ display: "flex", gap: 8, marginTop: 14 }}>
            <button
              type="button" onClick={() => runAction("talkdown")} disabled={actionState.busy != null}
              data-testid={`unit-speaker-talkdown-${slot}`}
              title="Talkdown (SIP)"
              style={{
                flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
                background: "none", border: "1px solid rgba(0,0,0,0.25)", color: "#3A3630",
                cursor: actionState.busy != null ? "default" : "pointer",
                opacity: actionState.busy != null ? 0.6 : 1,
                padding: "8px 10px", fontFamily: "var(--font-body)", fontSize: 10.5,
                fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase",
              }}
            ><IconMic size={13} /> {actionState.busy === "talkdown" ? "Calling\u2026" : "Talkdown (SIP)"}</button>

            <button
              type="button" onClick={() => runAction("speaker-test")} disabled={actionState.busy != null}
              data-testid={`unit-speaker-test-${slot}`}
              title="Speaker Test"
              style={{
                flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
                background: "none", border: "1px solid rgba(0,0,0,0.25)", color: "#3A3630",
                cursor: actionState.busy != null ? "default" : "pointer",
                opacity: actionState.busy != null ? 0.6 : 1,
                padding: "8px 10px", fontFamily: "var(--font-body)", fontSize: 10.5,
                fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase",
              }}
            ><IconSpeakerphone size={13} /> {actionState.busy === "speaker-test" ? "Testing\u2026" : "Speaker Test"}</button>
          </div>

          {actionState.error && (
            <div data-testid={`unit-speaker-action-error-${slot}`} style={{
              marginTop: 10, background: "rgba(224,163,57,0.1)", border: "1px solid rgba(224,163,57,0.4)",
              color: "#8a6d1f", padding: "8px 10px", fontFamily: "var(--font-body)", fontSize: 11.5, lineHeight: 1.5,
            }}>{actionState.error}</div>
          )}
        </>
      )}

      {onEdit && (
        <button
          type="button" onClick={onEdit} data-testid={`unit-speaker-edit-${slot}`}
          style={{
            marginTop: linked ? 10 : 0, width: "100%",
            background: "none", border: "1px solid rgba(0,0,0,0.15)", color: "rgba(0,0,0,0.6)",
            cursor: "pointer", padding: "7px 10px", fontFamily: "var(--font-body)", fontSize: 10.5,
            fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase",
          }}
        >{linked ? "Change device" : "Link device"}</button>
      )}
    </UM_Card>
  );
}

// Admin-only device picker — no host/credentials fields at all (unlike
// Cameras' edit modal), since a speakerphone has no ONVIF-style
// connection of its own: it's purely an existing ajax_devices row.
function UM_SpeakerEditModal({ unit, slot, speaker, ajaxCandidates, onCancel, onSaved }) {
  const [label, setLabel] = useState((speaker && speaker.label) || "");
  const [ajaxDeviceId, setAjaxDeviceId] = useState((speaker && speaker.ajax_device_id) || "");
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState("");

  const applyCandidate = (id) => {
    setAjaxDeviceId(id);
    const device = ajaxCandidates.find((d) => String(d.id) === String(id));
    if (device && !label) setLabel(device.room_name ? `${device.name} (${device.room_name})` : device.name || "");
  };

  const handleSave = async () => {
    setSaving(true);
    setError("");
    try {
      const res = await fetch(`/api/admin/units/${unit.id}/speakers/${slot}`, {
        method: "PUT", credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ label, ajaxDeviceId: ajaxDeviceId ? Number(ajaxDeviceId) : null }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "Couldn't save this speaker.");
      onSaved();
    } catch (err) {
      setError(err.message);
    } finally {
      setSaving(false);
    }
  };

  const handleRemove = async () => {
    setSaving(true);
    try {
      await fetch(`/api/admin/units/${unit.id}/speakers/${slot}`, { method: "DELETE", credentials: "same-origin" });
      onSaved();
    } finally {
      setSaving(false);
    }
  };

  return (
    <ModalShell title={`Speaker ${slot} — ${unit.serial_number}`} onCancel={onCancel}>
      {error && <ModalError>{error}</ModalError>}

      <MiniField label="Ajax SpeakerPhone device">
        <select
          value={ajaxDeviceId} onChange={(e) => applyCandidate(e.target.value)}
          data-testid={`unit-speaker-ajax-select-${slot}`} style={adminSelectStyle}
        >
          <option value="">None</option>
          {ajaxCandidates.map((d) => (
            <option key={d.id} value={d.id}>{d.name}{d.room_name ? ` — ${d.room_name}` : ""}</option>
          ))}
        </select>
      </MiniField>
      {ajaxCandidates.length === 0 && (
        <div style={{ fontFamily: "var(--font-body)", fontSize: 11.5, color: "#8A8474", marginBottom: 14, marginTop: -6 }}>
          No SpeakerPhone Jeweller devices found on this unit's synced Ajax hub yet.
        </div>
      )}

      <MiniField label="Label">
        <input
          value={label} onChange={(e) => setLabel(e.target.value)}
          placeholder={`Speaker ${slot}`} data-testid={`unit-speaker-label-${slot}`} style={adminInputStyle}
        />
      </MiniField>

      <div style={{ display: "flex", justifyContent: "space-between", marginTop: 8 }}>
        {speaker && speaker.ajax_device_id ? (
          <ActionButton danger onClick={handleRemove} disabled={saving} testId={`unit-speaker-remove-${slot}`}>Remove</ActionButton>
        ) : <span />}
        <div style={{ display: "flex", gap: 10 }}>
          <ActionButton onClick={onCancel} disabled={saving} testId={`unit-speaker-cancel-${slot}`}>Cancel</ActionButton>
          <ActionButton onClick={handleSave} disabled={saving} testId={`unit-speaker-save-${slot}`}>{saving ? "Saving\u2026" : "Save"}</ActionButton>
        </div>
      </div>
    </ModalShell>
  );
}

// New top-level tabs created by the top-nav consolidation, this session
// ("Intergrations under system. Settings under system. The device
// health, and summarys need to go under notifications"). Each simply
// re-parents already-existing, unchanged pieces (UM_ComingSoon /
// UM_DeviceHealthCard / UM_ConnectivitySummaryCard / UM_CameraSummaryCard
// / UM_NotificationsPlaceholderCard / UM_RecentEventsPlaceholderCard) --
// no new backend, no new data model, purely navigational.

// System tab: left-nav sub-sections (UM_SYSTEM_SECTIONS, defined above
// with UM_TABS) -- System / Integrations / Settings. All three are still
// "Coming Soon" placeholders today (no backend exists for any of them
// yet); this just gives each its own click target instead of Integrations
// and Settings each being their own top-level tab.
function UM_SystemTab() {
  const [activeSection, setActiveSection] = useState(UM_SYSTEM_SECTIONS[0].id);
  const current = UM_SYSTEM_SECTIONS.find((s) => s.id === activeSection) || UM_SYSTEM_SECTIONS[0];
  return (
    <div style={{ display: "grid", gridTemplateColumns: "220px 1fr", gap: 24 }}>
      <div style={{ borderRight: "1px solid rgba(0,0,0,0.1)", paddingRight: 16 }}>
        {UM_SYSTEM_SECTIONS.map((s) => {
          const active = s.id === current.id;
          return (
            <button
              key={s.id} type="button" onClick={() => setActiveSection(s.id)}
              data-testid={`unit-system-section-${s.id}`}
              style={{
                display: "block", width: "100%", textAlign: "left", background: active ? "rgba(180,110,0,0.1)" : "none",
                border: "none", borderLeft: `2px solid ${active ? "rgba(180,110,0,0.85)" : "transparent"}`,
                cursor: "pointer", padding: "10px 12px", marginBottom: 2,
                fontFamily: "var(--font-body)", fontSize: 12.5, fontWeight: active ? 600 : 500,
                color: active ? "#000" : "rgba(0,0,0,0.6)",
              }}
            >
              {s.label}
            </button>
          );
        })}
      </div>
      <UM_ComingSoon tab={current} />
    </div>
  );
}

// Notifications tab: Device Health / Connectivity Summary / Camera
// Summary moved here from Dashboard (top-nav consolidation, this
// session), alongside the existing Active Notifications placeholder AND
// Recent Events (also folded in here, per the customer's follow-up
// "events can go under notifications" -- no separate Events tab exists
// anymore). Same auto-fit grid + alignItems:"start" treatment as before,
// just relocated -- see the removed UM_GeneralTab block's header comment
// (git history) for the original "why two separate grids" reasoning.
function UM_NotificationsTab({ unit, telemetry, victron, ajax, teltonika, efoy, isAdmin }) {
  return (
    <div>
      <div data-testid="dashboard-summary-row" style={{
        display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
        alignItems: "start",
        gap: 16, marginBottom: 16,
      }}>
        <UM_DeviceHealthCard unit={unit} isAdmin={isAdmin} victron={victron} ajax={ajax} teltonika={teltonika} efoy={efoy} />
        <UM_ConnectivitySummaryCard unit={unit} telemetry={telemetry} victron={victron} ajax={ajax} teltonika={teltonika} efoy={efoy} />
        <UM_CameraSummaryCard unit={unit} isAdmin={isAdmin} />
      </div>
      {/* Active Notifications + Recent Events, side by side -- Recent
          Events folded in here too ("events can go under notifications",
          customer's exact follow-up instruction, this session) rather
          than keeping its own separate top-level tab. */}
      <div data-testid="dashboard-summary-row-2" style={{
        display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
        alignItems: "start",
        gap: 16,
      }}>
        <UM_NotificationsPlaceholderCard />
        <UM_RecentEventsPlaceholderCard />
      </div>
    </div>
  );
}

function UM_ComingSoon({ tab }) {
  return (
    <div data-testid="unit-tab-panel-coming-soon" style={{
      background: "rgba(0,0,0,0.02)", border: "1px solid rgba(0,0,0,0.1)",
      padding: "40px 32px", textAlign: "center",
    }}>
      <div style={{
        fontFamily: "var(--font-body)", fontSize: 9, letterSpacing: "0.2em",
        textTransform: "uppercase", color: "rgba(0,0,0,0.4)", marginBottom: 12, fontWeight: 500,
      }}>Coming soon</div>
      <div style={{
        fontFamily: "var(--font-display)", fontWeight: 700, fontSize: 18,
        textTransform: "uppercase", color: "rgba(0,0,0,0.8)", marginBottom: 8,
      }}>{tab ? tab.label : ""}</div>
      <div style={{ fontFamily: "var(--font-body)", fontSize: 13.5, color: "rgba(0,0,0,0.5)" }}>
        This integration hasn't been connected yet — it'll appear here once it's live.
      </div>
    </div>
  );
}

// ─────────────────────────── Cameras tab ───────────────────────────
// Up to 4 ONVIF (or Ajax-linked video device) camera slots per unit
// (unit_cameras — migrations/0030, hard schema cap via its own CHECK).
// A 2x2 grid of live-refreshing tiles; double-click any tile to go
// fullscreen with a back button. Config (host/username/password) is
// super_admin-only — "only solo admin can lock the camera feed
// details", customer's explicit instruction — the reseller side
// (isAdmin=false) is strictly view-only, same read-only posture as the
// rest of this file's admin-vs-reseller split. See lib/cameraStream.ts
// for why frames are polled JPEG snapshots (or a proxied MJPEG stream
// when a camera supports one) rather than RTSP — a hard Cloudflare
// Workers platform limit, not a missing feature.
//
// §7's "number of available cameras must be determined by the assigned
// Device Profile": camera slot COUNT is driven by how many enabled
// Ajax-category, Camera-type slots (device_type matching /camera/i,
// e.g. admin-device-profiles-page.jsx's "Camera" preset, but excluding
// the separate LED/Camera *Relay* slot types) the asset's assigned
// Device Profile defines -- via the same device-mappings read
// UM_DevicesTab already uses (UM_deviceMappingsUrl), so no new backend
// route is needed. Two safety nets, both required by the "preserve
// existing tab behaviour exactly, no user-facing disruption" governing
// constraint:
//   1. No Device Profile assigned at all (assets that predate this
//      feature) -> unchanged legacy behaviour, fixed 4 slots.
//   2. A profile IS assigned but already-configured unit_cameras rows
//      exist beyond what the profile currently defines (e.g. a profile
//      edited down after cameras were wired up) -> those configured
//      slots stay visible; the effective count is
//      max(profile count, highest configured slot number), still
//      capped at unit_cameras' own hard 4-slot schema limit. A
//      configured camera can never silently vanish from view.
// Slot NUMBERS themselves are still the plain 1..N unit_cameras
// convention (unrelated to a profile slot's own opaque id) -- the
// profile only ever changes N via its Camera-type slot COUNT, per the
// spec's own wording ("the number of available cameras").
const UM_MAX_CAMERA_SLOTS = 4;
const UM_LEGACY_CAMERA_SLOTS = [1, 2, 3, 4];
const UM_SNAPSHOT_POLL_MS = 1000;

// Numbers a profile's Camera-type slots 1..N (same rule
// lib/deviceMappingApply.ts's resolveSlotId uses to resolve a
// spreadsheet's "Camera N Serial" candidate against these same slots:
// prefer a slot whose display_name literally contains that number,
// otherwise fall back to sort_order/id position), then returns a plain
// { [unitCameraSlotNumber]: serialOrNull } map for the Cameras tab's
// tiles/edit modal to read the matching spreadsheet-imported serial
// straight off of -- no separate backend field/route needed, since
// GET .../device-mappings already returns each slot's serial_number.
function UM_cameraSlotSerials(cameraProfileSlots) {
  const sorted = [...cameraProfileSlots].sort((a, b) => a.sort_order - b.sort_order || a.slot_id - b.slot_id);
  const out = {};
  sorted.forEach((slot, i) => {
    const byNumberMatch = slot.display_name && slot.display_name.match(/\b([1-4])\b/);
    const n = byNumberMatch ? Number(byNumberMatch[1]) : i + 1;
    if (n >= 1 && n <= UM_MAX_CAMERA_SLOTS) out[n] = slot.serial_number || null;
  });
  return out;
}

// Phase 3 (2026-09-12, real-router live testing this session):
// "genuine live video" over the customer's own Teltonika VPN Hub, via
// the relay pipeline documented in migrations/0056_live_view_sessions.sql
// and routes/admin-live-view.ts's own header comment (VPN custom-user
// -> Fly.io relay -> Cloudflare Stream WHIP-in/WHEP-out). This is a
// genuinely different mechanism from every other camera mode on this
// tab (ONVIF snapshot polling, MJPEG proxy, Ajax, test_stream HLS) --
// those all either poll a still image or play a pre-made/proxied
// stream the Worker itself can reach directly; this one requires a
// whole separate relay process on the far side of the customer's VPN,
// which only exists for the lifetime of an explicit Start/Stop action
// (unlike the snapshot tiles, which are always "on" whenever the tab
// is open) since it costs real money by the second (a running Fly.io
// Machine) rather than being a free/idle proxy call.
//
// Gating: Start/Stop are requireSuperAdmin on the backend (same
// posture as "Launch WebUI" -- opening a live remote-access session is
// a mutating, security-relevant action) -- a plain admin or a reseller
// viewer only ever sees the read-only WHEP player once someone else
// has started a session, never the buttons. `liveViewSlots` is the
// subset of this unit's configured camera slots that are actually
// relay-eligible (source_type 'rtsp' or 'onvif' -- see the filter just
// above this component's call site).
const UM_LIVE_VIEW_STATUS_POLL_MS = 3000;

function UM_LiveViewCard({ unit, isSuperAdmin, liveViewSlots, bySlot }) {
  const [session, setSession] = useState(undefined); // undefined = not loaded yet, null = no active session
  const [streamConnected, setStreamConnected] = useState(null);
  const [selectedSlot, setSelectedSlot] = useState(liveViewSlots[0]);
  const [starting, setStarting] = useState(false);
  const [stopping, setStopping] = useState(false);
  const [error, setError] = useState("");

  // Admin-only feature for now -- routes/admin-live-view.ts is mounted
  // under /api/admin only (no reseller-portal equivalent has been built
  // yet), so this card is only ever rendered when isAdmin is true (see
  // UM_CamerasTab's call site) and every request below can assume that
  // base path unconditionally.
  const liveViewUrlBase = `/api/admin/units/${unit.id}/live-view`;

  const pollStatus = () => {
    fetch(`${liveViewUrlBase}/status`, { credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then((data) => {
        setSession(data.session || null);
        setStreamConnected(data.streamConnected ?? null);
      })
      .catch(() => {});
  };

  // Initial load: plain GET (not /status) is the lightweight "is there
  // a session" read; once a session exists, switch to the heavier
  // /status poll (cross-checks the Fly Machine + Stream input's live
  // state, not just the DB row) for as long as it stays non-terminal.
  useEffect(() => {
    fetch(liveViewUrlBase, { credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then((data) => setSession(data.session || null))
      .catch(() => setSession(null));
  }, [unit.id]); // eslint-disable-line react-hooks/exhaustive-deps

  useEffect(() => {
    if (!session || session.status === "stopped" || session.status === "error") return;
    const interval = setInterval(pollStatus, UM_LIVE_VIEW_STATUS_POLL_MS);
    return () => clearInterval(interval);
  }, [session && session.id, session && session.status]); // eslint-disable-line react-hooks/exhaustive-deps

  const handleStart = async () => {
    if (!liveViewUrlBase || starting) return;
    setStarting(true);
    setError("");
    try {
      const res = await fetch(`${liveViewUrlBase}/start`, {
        method: "POST", credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ cameraSlot: selectedSlot }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "Couldn't start live view.");
      setSession(data.session);
    } catch (err) {
      setError(err.message);
    }
    setStarting(false);
  };

  const handleStop = async () => {
    if (!liveViewUrlBase || stopping) return;
    setStopping(true);
    setError("");
    try {
      const res = await fetch(`${liveViewUrlBase}/stop`, { method: "POST", credentials: "same-origin" });
      if (!res.ok) {
        const data = await res.json().catch(() => ({}));
        throw new Error(data.error || "Couldn't stop live view.");
      }
      setSession(null);
      setStreamConnected(null);
    } catch (err) {
      setError(err.message);
    }
    setStopping(false);
  };

  const isActive = session && session.status !== "stopped" && session.status !== "error";
  const statusLabel = !session
    ? null
    : {
        starting: "Starting\u2026",
        vpn_connecting: "Connecting to router's VPN\u2026",
        vpn_connected: "VPN connected, starting stream\u2026",
        streaming: streamConnected === false ? "Stream connecting\u2026" : "Live",
        stopping: "Stopping\u2026",
        stopped: "Stopped",
        error: "Error",
      }[session.status] || session.status;

  return (
    <div
      data-testid="unit-live-view-card"
      style={{ border: "1px solid #E2DCCB", background: "#fff", padding: 16, marginBottom: 16 }}
    >
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10, flexWrap: "wrap", gap: 8 }}>
        <div>
          <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, fontWeight: 600, color: "#1A1712" }}>
            Live View
          </div>
          <div style={{ fontFamily: "var(--font-body)", fontSize: 10.5, color: "#8A8474", marginTop: 2 }}>
            Genuine live video streamed over this router's own VPN connection \u2014 separate from the snapshot tiles below.
          </div>
        </div>
        {statusLabel && (
          <span
            data-testid="unit-live-view-status"
            style={{
              fontFamily: "var(--font-body)", fontSize: 9.5, fontWeight: 600, letterSpacing: "0.06em",
              textTransform: "uppercase", padding: "4px 9px", borderRadius: 3,
              background: session.status === "streaming" && streamConnected !== false ? "rgba(63,174,92,0.15)"
                : session.status === "error" ? "rgba(190,40,40,0.12)" : "rgba(180,116,11,0.12)",
              color: session.status === "streaming" && streamConnected !== false ? "#2f7d47"
                : session.status === "error" ? "#a12b2b" : "#B4740B",
            }}
          >{statusLabel}</span>
        )}
      </div>

      {isSuperAdmin && !isActive && (
        <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
          {liveViewSlots.length > 1 && (
            <select
              value={selectedSlot}
              onChange={(e) => setSelectedSlot(Number(e.target.value))}
              disabled={starting}
              data-testid="unit-live-view-slot-select"
              style={{
                border: "1px solid #E2DCCB", background: "#FFFFFF", padding: "8px 10px",
                fontFamily: "var(--font-body)", fontSize: 11.5, color: "#1A1712",
              }}
            >
              {liveViewSlots.map((slot) => (
                <option key={slot} value={slot}>{(bySlot[slot] && bySlot[slot].label) || `Camera ${slot}`}</option>
              ))}
            </select>
          )}
          <button
            type="button" onClick={handleStart} disabled={starting}
            data-testid="unit-live-view-start"
            style={{
              background: "#1A1712", color: "#fff", border: "none", padding: "8px 18px",
              cursor: starting ? "default" : "pointer", opacity: starting ? 0.6 : 1,
              fontFamily: "var(--font-body)", fontSize: 10.5, fontWeight: 600,
              letterSpacing: "0.06em", textTransform: "uppercase",
            }}
          >{starting ? "Starting\u2026" : "Start Live View"}</button>
        </div>
      )}

      {isSuperAdmin && isActive && (
        <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: session.cf_stream_whep_url ? 10 : 0 }}>
          <button
            type="button" onClick={handleStop} disabled={stopping}
            data-testid="unit-live-view-stop"
            style={{
              background: "none", border: "1px solid rgba(190,40,40,0.4)", color: "#a12b2b",
              cursor: stopping ? "default" : "pointer", opacity: stopping ? 0.6 : 1,
              padding: "7px 16px", fontFamily: "var(--font-body)", fontSize: 10.5,
              fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase",
            }}
          >{stopping ? "Stopping\u2026" : "Stop Live View"}</button>
        </div>
      )}

      {error && (
        <div data-testid="unit-live-view-error" style={{
          marginTop: 10, background: "rgba(190,40,40,0.08)", border: "1px solid rgba(190,40,40,0.3)",
          color: "#a12b2b", padding: "8px 10px", fontFamily: "var(--font-body)", fontSize: 11.5, lineHeight: 1.5,
        }}>{error}</div>
      )}

      {session && session.status === "error" && session.last_error && (
        <div data-testid="unit-live-view-last-error" style={{
          marginTop: 10, background: "rgba(190,40,40,0.08)", border: "1px solid rgba(190,40,40,0.3)",
          color: "#a12b2b", padding: "8px 10px", fontFamily: "var(--font-body)", fontSize: 11.5, lineHeight: 1.5,
        }}>{session.last_error}</div>
      )}

      {isActive && session.cf_stream_whep_url && (
        <div style={{ marginTop: 10, position: "relative", width: "100%", aspectRatio: "16 / 9", background: "#0d0d0d", overflow: "hidden" }}>
          <UM_WhepPlayer whepUrl={session.cf_stream_whep_url} />
        </div>
      )}

      {isActive && !session.cf_stream_whep_url && (
        <div style={{ marginTop: 10, fontFamily: "var(--font-body)", fontSize: 11.5, color: "#8A8474" }}>
          {"Waiting for the video stream to connect\u2026"}
        </div>
      )}
    </div>
  );
}

// Plays a Cloudflare Stream WHEP (WebRTC-HTTP Egress Protocol) url via
// the browser's native RTCPeerConnection -- no external library needed
// (unlike UM_CameraHlsFeed's hls.js, WHEP's whole point is a plain
// SDP-over-HTTP exchange the Fetch API can do on its own). Sequence,
// per Cloudflare's own WHEP client examples: create an RTCPeerConnection
// with a single recvonly video+audio transceiver each, createOffer,
// setLocalDescription, POST the offer SDP to the WHEP url
// (Content-Type: application/sdp), take the 201 response's SDP body as
// the answer, setRemoteDescription -- Cloudflare's edge then starts
// pushing the relay's ffmpeg -f whip video over that connection like
// any other WebRTC peer.
// Auto-retry budget for the BROWSER's own WebRTC/WHEP handshake --
// separate from (but complementary to) the gateway's own ffmpeg
// auto-restart-on-drop (sessionManager.js). The two failure modes are
// distinct: the gateway's retry recovers a dead ffmpeg process feeding
// Cloudflare Stream, while this retry recovers THIS browser tab's own
// RTCPeerConnection to Cloudflare's edge, which can independently drop
// (Wi-Fi blip, laptop sleep/wake, tab backgrounding) even when the
// gateway's relay is perfectly healthy. Before this, any failure here
// was a permanent dead-end ("Couldn't connect to the live stream.")
// requiring a manual Stop+Start -- most drops are transient and should
// just quietly reconnect instead.
const UM_WHEP_RETRY_BACKOFF_MS = [1500, 3000, 6000, 10000, 15000];

function UM_WhepPlayer({ whepUrl }) {
  const videoRef = useRef(null);
  const [connecting, setConnecting] = useState(true);
  const [retryAttempt, setRetryAttempt] = useState(0); // 0 = first attempt, not yet a "retry"
  const [exhausted, setExhausted] = useState(false);

  useEffect(() => {
    if (!whepUrl) return;
    setExhausted(false);
    setRetryAttempt(0);
  }, [whepUrl]);

  useEffect(() => {
    if (!whepUrl || exhausted) return;
    setConnecting(true);
    let pc = null;
    let cancelled = false;
    let retryTimer = null;

    const scheduleRetry = () => {
      if (cancelled) return;
      if (retryAttempt >= UM_WHEP_RETRY_BACKOFF_MS.length) {
        setExhausted(true);
        return;
      }
      const delay = UM_WHEP_RETRY_BACKOFF_MS[retryAttempt];
      retryTimer = setTimeout(() => {
        if (!cancelled) setRetryAttempt((n) => n + 1);
      }, delay);
    };

    (async () => {
      try {
        pc = new RTCPeerConnection();
        pc.addTransceiver("video", { direction: "recvonly" });
        pc.addTransceiver("audio", { direction: "recvonly" });

        const remoteStream = new MediaStream();
        pc.ontrack = (event) => {
          remoteStream.addTrack(event.track);
          if (videoRef.current) videoRef.current.srcObject = remoteStream;
        };
        pc.onconnectionstatechange = () => {
          if (pc && (pc.connectionState === "failed" || pc.connectionState === "closed")) {
            if (!cancelled) scheduleRetry();
          } else if (pc && pc.connectionState === "connected") {
            // A real connection landed -- reset the retry counter so a
            // FUTURE drop (hours later) gets its full budget again,
            // rather than slowly using up a lifetime-of-the-tile budget.
            if (!cancelled) setRetryAttempt(0);
          }
        };

        const offer = await pc.createOffer();
        await pc.setLocalDescription(offer);

        const res = await fetch(whepUrl, {
          method: "POST",
          headers: { "Content-Type": "application/sdp" },
          body: offer.sdp,
        });
        if (!res.ok) throw new Error(`WHEP endpoint returned ${res.status}`);
        const answerSdp = await res.text();
        if (cancelled) return;
        await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
        if (!cancelled) setConnecting(false);
      } catch {
        if (!cancelled) scheduleRetry();
      }
    })();

    return () => {
      cancelled = true;
      if (retryTimer) clearTimeout(retryTimer);
      if (pc) pc.close();
    };
  }, [whepUrl, retryAttempt, exhausted]);

  if (exhausted) {
    return (
      <div style={{
        position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
        flexDirection: "column", gap: 8,
        color: "rgba(255,255,255,0.4)", fontFamily: "var(--font-body)", fontSize: 11.5, textAlign: "center", padding: 16,
      }}>
        <span>Couldn't connect to the live stream.</span>
        <button
          type="button"
          onClick={() => { setRetryAttempt(0); setExhausted(false); }}
          style={{
            background: "none", border: "1px solid rgba(255,255,255,0.35)", color: "rgba(255,255,255,0.85)",
            cursor: "pointer", padding: "5px 12px", fontFamily: "var(--font-body)", fontSize: 10.5,
            fontWeight: 600, letterSpacing: "0.05em", textTransform: "uppercase",
          }}
        >Try again</button>
      </div>
    );
  }

  return (
    <>
      <video
        ref={videoRef}
        autoPlay muted playsInline controls
        style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "contain" }}
      />
      {(connecting || retryAttempt > 0) && (
        <div style={{
          position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
          background: retryAttempt > 0 ? "rgba(13,13,13,0.55)" : "none",
          color: "rgba(255,255,255,0.55)", fontFamily: "var(--font-body)", fontSize: 11.5, pointerEvents: "none",
        }}>
          {retryAttempt > 0 ? "Reconnecting\u2026" : "Connecting to stream\u2026"}
        </div>
      )}
    </>
  );
}

// Ajax Video SDK live view -- a SECOND, separate live-video mechanism
// from UM_LiveViewCard above, for camera slots linked to a real Ajax
// video-edge (camera.ajax_video_edge_id set -- see admin-ajax.ts's
// auto-link route). Genuinely different backend path: routes/
// admin-live-view.ts's /live-view/ajax/:slot/* endpoints redeem an Ajax
// REST video-player-access-token through the Ajax Video SDK's native
// gRPC/RTSP servers running on our own dedicated VPS gateway (see
// lib/ajaxGateway.ts's header comment and README's "Ajax Video SDK —
// VPS gateway proven end-to-end" section for the full architecture and
// the three real bugs it took to get this working), rather than the
// VPN-relay path's Fly.io Machine. CONFIRMED LIVE end-to-end this
// session against unit 550's real cameras -- Cloudflare itself reports
// streamConnected:true on the resulting live input.
//
// One card PER camera slot (unlike UM_LiveViewCard, which is one card
// for the whole unit) since Ajax sessions are keyed by unit_camera_id,
// not by unit -- two different Ajax-linked cameras on the same asset
// can stream simultaneously and independently. Rendered by
// UM_CameraTile itself (below) right where the ordinary snapshot feed
// would otherwise go, so start/stop/play lives directly on that
// camera's own tile rather than a separate section.
//
// Reuses UM_WhepPlayer as-is -- the session shape returned by
// redactAjaxSession is deliberately close to the VPN path's
// redactSession (same cf_stream_whep_url/status/last_error fields), so
// no new player code is needed, only a new fetch/start/stop wiring.
//
// REDESIGNED (this session, customer feedback on the first cut): the
// original UM_AjaxLiveViewCard owned BOTH the video area AND its own
// absolutely-positioned Start/Stop/status controls floating in the
// tile's top-right corner -- the exact same corner UM_CameraTile's own
// edit-gear button lives in, which fully overlapped it (confirmed via
// a local repro harness's elementFromPoint()) and looked broken (a
// bare sliver of the gear peeking out from behind "STOP"). Fixed
// properly this pass by splitting the state/polling logic out into
// useAjaxLiveView (below) -- a plain hook, no DOM of its own -- so
// UM_CameraTile can own ONE real flex header bar for every slot type
// (ID left, status+edit+start/stop right, all in normal in-flow
// layout, never absolutely positioned over each other) and hand this
// component only the video body to render underneath it. See
// UM_CameraTile's header comment for the rest of this redesign.
const UM_AJAX_LIVE_VIEW_STATUS_POLL_MS = 3000;

function useAjaxLiveView(unit, slot) {
  const [session, setSession] = useState(undefined); // undefined = not loaded yet, null = no active session
  const [streamConnected, setStreamConnected] = useState(null);
  // Gateway auto-restart diagnostics (admin-live-view.ts forwards these
  // from sessionManager.js's getSessionStatus -- see that file's own
  // header comment for the full auto-restart-on-drop design). Surfaced
  // here so the tile/badge can show "Reconnecting..." while the gateway
  // is mid-backoff respawning a dropped ffmpeg process, instead of the
  // same flat "Offline"/error UI a genuinely-dead session gets -- most
  // drops now self-heal within seconds and shouldn't look like a fault.
  const [gatewayAlive, setGatewayAlive] = useState(null);
  const [restartPending, setRestartPending] = useState(false);
  const [restartCount, setRestartCount] = useState(null);
  const [autoRestartExhausted, setAutoRestartExhausted] = useState(false);
  const [lastFfmpegExit, setLastFfmpegExit] = useState(null);
  const [ffmpegStderrTail, setFfmpegStderrTail] = useState(null);
  const [starting, setStarting] = useState(false);
  const [stopping, setStopping] = useState(false);
  const [error, setError] = useState("");

  const ajaxLiveViewUrlBase = `/api/admin/units/${unit.id}/live-view/ajax/${slot}`;

  const applyStatus = (data) => {
    setSession(data.session || null);
    setStreamConnected(data.streamConnected ?? null);
    setGatewayAlive(data.gatewayAlive ?? null);
    setRestartPending(!!data.restartPending);
    setRestartCount(typeof data.restartCount === "number" ? data.restartCount : null);
    setAutoRestartExhausted(!!data.autoRestartExhausted);
    setLastFfmpegExit(data.lastFfmpegExit || null);
    setFfmpegStderrTail(data.ffmpegStderrTail || null);
  };

  const pollStatus = () => {
    fetch(ajaxLiveViewUrlBase, { credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then(applyStatus)
      .catch(() => {});
  };

  useEffect(() => {
    fetch(ajaxLiveViewUrlBase, { credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then(applyStatus)
      .catch(() => setSession(null));
  }, [unit.id, slot]); // eslint-disable-line react-hooks/exhaustive-deps

  useEffect(() => {
    if (!session || session.status === "stopped" || session.status === "error") return;
    const interval = setInterval(pollStatus, UM_AJAX_LIVE_VIEW_STATUS_POLL_MS);
    return () => clearInterval(interval);
  }, [session && session.id, session && session.status]); // eslint-disable-line react-hooks/exhaustive-deps

  const handleStart = async () => {
    if (starting) return;
    setStarting(true);
    setError("");
    try {
      const res = await fetch(`${ajaxLiveViewUrlBase}/start`, { method: "POST", credentials: "same-origin" });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "Couldn't start the Ajax live view.");
      setSession(data.session);
    } catch (err) {
      setError(err.message);
    }
    setStarting(false);
  };

  const handleStop = async () => {
    if (stopping) return;
    setStopping(true);
    setError("");
    try {
      const res = await fetch(`${ajaxLiveViewUrlBase}/stop`, { method: "POST", credentials: "same-origin" });
      if (!res.ok) {
        const data = await res.json().catch(() => ({}));
        throw new Error(data.error || "Couldn't stop the Ajax live view.");
      }
      setSession(null);
      setStreamConnected(null);
    } catch (err) {
      setError(err.message);
    }
    setStopping(false);
  };

  const isActive = session && session.status !== "stopped" && session.status !== "error";
  const statusLabel = !session
    ? null
    : {
        starting: "Starting\u2026",
        token_issued: "Connecting\u2026",
        streaming: streamConnected === false ? "Connecting\u2026" : "Live",
        stopping: "Stopping\u2026",
        stopped: "Stopped",
        error: "Error",
      }[session.status] || session.status;

  return {
    session, streamConnected, starting, stopping, error, isActive, statusLabel, handleStart, handleStop,
    gatewayAlive, restartPending, restartCount, autoRestartExhausted, lastFfmpegExit, ffmpegStderrTail,
  };
}

// Ajax archive (recorded-video) playback -- roadmap item #2, customer-
// confirmed this session ("This we also want to add") right alongside
// the live-view work above. A SECOND, independent session type from
// useAjaxLiveView (its own DB table -- migrations/0064 -- and its own
// gateway registry, see ajax-video-gateway/gateway/src/sessionManager.js's
// header comment for why they're not merged), only ever active while
// UM_ArchiveViewModal below is open -- unlike live view, this is not
// meant to run continuously in the background of a grid tile, so this
// hook is only ever instantiated by that modal, never by UM_CameraTile
// itself.
//
// Server response shape mirrors routes/admin-archive-view.ts's GET
// exactly: { session, streamConnected, archiveStatus, timeline,
// timelineAvailableRange, activities, calendar, ...gateway-health
// fields }. See src/lib/ajaxGateway.ts's AjaxGatewayArchiveSessionStatus
// for the authoritative field list this was built against.
const UM_ARCHIVE_STATUS_POLL_MS = 3000;

function useAjaxArchiveView(unit, slot) {
  const [session, setSession] = useState(undefined); // undefined = not loaded yet, null = no active session
  const [streamConnected, setStreamConnected] = useState(null);
  const [archiveStatus, setArchiveStatus] = useState(null); // { playing, positionMs }
  const [timeline, setTimeline] = useState([]); // [{ ms, hasVideo, hasAudio, hasIframe }]
  const [timelineAvailableRange, setTimelineAvailableRange] = useState(null); // { startMs, endMs }
  const [activities, setActivities] = useState([]);
  const [calendar, setCalendar] = useState(null); // { startDayMs, days: [0|1,...] }
  const [gatewayAlive, setGatewayAlive] = useState(null);
  const [restartPending, setRestartPending] = useState(false);
  const [autoRestartExhausted, setAutoRestartExhausted] = useState(false);
  const [starting, setStarting] = useState(false);
  const [stopping, setStopping] = useState(false);
  const [error, setError] = useState("");

  const archiveUrlBase = `/api/admin/units/${unit.id}/archive-view/ajax/${slot}`;

  const applyStatus = (data) => {
    setSession(data.session || null);
    setStreamConnected(data.streamConnected ?? null);
    setArchiveStatus(data.archiveStatus ?? null);
    setTimeline(data.timeline || []);
    setTimelineAvailableRange(data.timelineAvailableRange ?? null);
    setActivities(data.activities || []);
    // Calendar only ever grows/replaces via the gateway's own push (see
    // sessionManager.js's applyTimelineUpdate-style caching) -- once
    // received, keep showing the last known calendar even on a poll tick
    // that (rarely) comes back without one, rather than flashing it away.
    if (data.calendar) setCalendar(data.calendar);
    setGatewayAlive(data.gatewayAlive ?? null);
    setRestartPending(!!data.restartPending);
    setAutoRestartExhausted(!!data.autoRestartExhausted);
  };

  const pollStatus = () => {
    fetch(archiveUrlBase, { credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then(applyStatus)
      .catch(() => {});
  };

  useEffect(() => {
    fetch(archiveUrlBase, { credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then(applyStatus)
      .catch(() => setSession(null));
  }, [unit.id, slot]); // eslint-disable-line react-hooks/exhaustive-deps

  useEffect(() => {
    if (!session || session.status === "stopped" || session.status === "error") return;
    const interval = setInterval(pollStatus, UM_ARCHIVE_STATUS_POLL_MS);
    return () => clearInterval(interval);
  }, [session && session.id, session && session.status]); // eslint-disable-line react-hooks/exhaustive-deps

  const handleStart = async (rangeStartMs, rangeEndMs) => {
    if (starting) return;
    setStarting(true);
    setError("");
    try {
      const res = await fetch(`${archiveUrlBase}/start`, {
        method: "POST", credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ rangeStartMs, rangeEndMs }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "Couldn't start archive playback.");
      setSession(data.session);
    } catch (err) {
      setError(err.message);
    }
    setStarting(false);
  };

  const handleControl = async (command, payload) => {
    setError("");
    try {
      const res = await fetch(`${archiveUrlBase}/control`, {
        method: "POST", credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ command, ...(payload || {}) }),
      });
      if (!res.ok) {
        const data = await res.json().catch(() => ({}));
        throw new Error(data.error || "Couldn't send that command.");
      }
      // Optimistic local update for play/pause/seek so the UI feels
      // instant instead of waiting for the next 3s poll -- the poll
      // still corrects it from the gateway's real archive_status push.
      if (command === "play") setArchiveStatus((s) => ({ playing: true, positionMs: s ? s.positionMs : null }));
      else if (command === "pause") setArchiveStatus((s) => ({ playing: false, positionMs: s ? s.positionMs : null }));
      else if (command === "seek") setArchiveStatus((s) => ({ playing: s ? s.playing : false, positionMs: payload.positionMs }));
    } catch (err) {
      setError(err.message);
    }
  };

  const handleStop = async () => {
    if (stopping) return;
    setStopping(true);
    setError("");
    try {
      const res = await fetch(`${archiveUrlBase}/stop`, { method: "POST", credentials: "same-origin" });
      if (!res.ok) {
        const data = await res.json().catch(() => ({}));
        throw new Error(data.error || "Couldn't stop archive playback.");
      }
      setSession(null);
      setStreamConnected(null);
      setArchiveStatus(null);
      setTimeline([]);
      setTimelineAvailableRange(null);
      setActivities([]);
    } catch (err) {
      setError(err.message);
    }
    setStopping(false);
  };

  const isActive = session && session.status !== "stopped" && session.status !== "error";
  const statusLabel = !session
    ? null
    : {
        starting: "Starting\u2026",
        token_issued: "Connecting\u2026",
        streaming: streamConnected === false ? "Connecting\u2026" : "Ready",
        stopping: "Stopping\u2026",
        stopped: "Stopped",
        error: "Error",
      }[session.status] || session.status;

  return {
    session, streamConnected, archiveStatus, timeline, timelineAvailableRange, activities, calendar,
    starting, stopping, error, isActive, statusLabel, handleStart, handleControl, handleStop,
    gatewayAlive, restartPending, autoRestartExhausted,
  };
}

// Human-readable ActivityKind labels -- see types.proto's ActivityKind
// enum (AK_OFFLINE/AK_PERMANENT_RECORD excluded here, neither is a
// meaningful "event" to flag on the timeline the way motion/human/
// vehicle/pet/ring are).
const UM_ACTIVITY_KIND_LABELS = {
  AK_MOTION: "Motion", AK_VEHICLE: "Vehicle", AK_PET: "Pet",
  AK_HUMAN: "Person", AK_RING: "Doorbell", AK_VIDEO_SCENARIO: "Scenario",
};

// A datetime-local <input> reads/writes in the BROWSER's local timezone
// with no offset info, formatted "YYYY-MM-DDTHH:mm" -- these two
// helpers convert to/from that exact string and an absolute ms-since-
// epoch, which is all the archive API ever deals in.
function UM_msToDatetimeLocal(ms) {
  const d = new Date(ms);
  const pad = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function UM_datetimeLocalToMs(value) {
  const ms = new Date(value).getTime();
  return Number.isFinite(ms) ? ms : null;
}

// Archive playback modal -- reuses the same ModalShell every other admin
// dialog in this app uses (admin-assets-page.jsx), wide variant since it
// needs to fit a real video player plus a timeline scrubber. Two
// distinct render states:
//   1. No active session yet -- a calendar quick-pick (if the gateway
//      has already told us which days have footage from a PREVIOUS
//      session this browser tab saw) plus a plain start/end datetime
//      range form, defaulting to the last hour.
//   2. An active session -- the video (UM_WhepPlayer, same player the
//      live path uses -- both paths hand it a cf_stream_whep_url), a
//      calendar day-picker (now genuinely populated, since GetCalendar
//      is only called by the gateway AFTER stream_created -- see
//      sessionManager.js's startArchiveSession), a timeline scrubber
//      built from the per-second availability array, and play/pause/
//      seek/change-range controls.
function UM_ArchiveViewModal({ unit, slot, camera, onClose }) {
  const archive = useAjaxArchiveView(unit, slot);
  const label = (camera && camera.label) || `Camera ${slot}`;

  // Range picker state -- only used before a session exists, and again
  // if the admin wants to change the range on an already-open session
  // (reuses the same two fields, just dispatches to setRange control
  // instead of /start once a session is live).
  const defaultEndMs = () => Date.now();
  const defaultStartMs = () => Date.now() - 60 * 60 * 1000; // last hour
  const [rangeStartLocal, setRangeStartLocal] = useState(() => UM_msToDatetimeLocal(defaultStartMs()));
  const [rangeEndLocal, setRangeEndLocal] = useState(() => UM_msToDatetimeLocal(defaultEndMs()));
  const [rangeError, setRangeError] = useState("");

  const handleClose = async () => {
    if (archive.isActive) await archive.handleStop();
    onClose();
  };

  const submitRange = (fn) => {
    setRangeError("");
    const startMs = UM_datetimeLocalToMs(rangeStartLocal);
    const endMs = UM_datetimeLocalToMs(rangeEndLocal);
    if (startMs == null || endMs == null || endMs <= startMs) {
      setRangeError("End time must be after start time.");
      return;
    }
    if (endMs - startMs > 3 * 24 * 60 * 60 * 1000) {
      setRangeError("The requested range can't exceed 3 days.");
      return;
    }
    fn(startMs, endMs);
  };

  // Clicking a calendar day fills the range picker with that whole day
  // (00:00 -> 23:59:59) rather than immediately starting playback -- the
  // admin can still narrow it before loading.
  const pickCalendarDay = (dayMs) => {
    setRangeStartLocal(UM_msToDatetimeLocal(dayMs));
    setRangeEndLocal(UM_msToDatetimeLocal(Math.min(dayMs + 24 * 60 * 60 * 1000 - 1000, Date.now())));
  };

  return (
    <ModalShell title={`Recordings \u2014 ${label} \u2014 ${unit.serial_number}`} onCancel={handleClose} wide>
      {archive.error && <ModalError>{archive.error}</ModalError>}

      {archive.calendar && archive.calendar.days.length > 0 && (
        <div style={{ marginBottom: 18 }}>
          <div style={{ fontFamily: "var(--font-body)", fontSize: 9.5, letterSpacing: "0.16em", textTransform: "uppercase", color: "rgba(0,0,0,0.5)", marginBottom: 8 }}>
            Days with recordings
          </div>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
            {archive.calendar.days.map((hasVideo, i) => {
              const dayMs = archive.calendar.startDayMs + i * 24 * 60 * 60 * 1000;
              const d = new Date(dayMs);
              return (
                <button
                  key={i} type="button" disabled={!hasVideo} onClick={() => pickCalendarDay(dayMs)}
                  data-testid={`unit-archive-calendar-day-${slot}-${i}`}
                  title={hasVideo ? "Load this day" : "No recordings this day"}
                  style={{
                    minWidth: 42, padding: "6px 4px", textAlign: "center",
                    background: hasVideo ? "#1A1712" : "rgba(0,0,0,0.05)",
                    color: hasVideo ? "#fff" : "rgba(0,0,0,0.3)",
                    border: "none", cursor: hasVideo ? "pointer" : "default",
                    fontFamily: "var(--font-body)", fontSize: 10.5,
                  }}
                >{d.getDate()}/{d.getMonth() + 1}</button>
              );
            })}
          </div>
        </div>
      )}

      {!archive.isActive ? (
        <>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
            <MiniField label="From">
              <input
                type="datetime-local" value={rangeStartLocal} onChange={(e) => setRangeStartLocal(e.target.value)}
                data-testid={`unit-archive-range-start-${slot}`} style={adminInputStyle}
              />
            </MiniField>
            <MiniField label="To">
              <input
                type="datetime-local" value={rangeEndLocal} onChange={(e) => setRangeEndLocal(e.target.value)}
                data-testid={`unit-archive-range-end-${slot}`} style={adminInputStyle}
              />
            </MiniField>
          </div>
          {rangeError && <ModalError>{rangeError}</ModalError>}
          <div style={{ fontFamily: "var(--font-body)", fontSize: 11, color: "rgba(0,0,0,0.45)", marginBottom: 18, lineHeight: 1.5 }}>
            Max 3 days per request. Once loaded, you can change the range without closing this window.
          </div>
          <div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
            <button
              type="button" onClick={handleClose}
              style={{ background: "none", border: "1px solid rgba(0,0,0,0.25)", color: "rgba(0,0,0,0.7)", cursor: "pointer", padding: "10px 18px", fontFamily: "var(--font-body)", fontSize: 11.5, letterSpacing: "0.1em", textTransform: "uppercase" }}
            >Cancel</button>
            <ActionButton
              onClick={() => submitRange(archive.handleStart)} disabled={archive.starting}
              testId={`unit-archive-load-${slot}`}
            >{archive.starting ? "Loading\u2026" : "Load recording"}</ActionButton>
          </div>
        </>
      ) : (
        <UM_ArchivePlaybackBody
          unit={unit} slot={slot} archive={archive}
          rangeStartLocal={rangeStartLocal} setRangeStartLocal={setRangeStartLocal}
          rangeEndLocal={rangeEndLocal} setRangeEndLocal={setRangeEndLocal}
          rangeError={rangeError} submitRange={submitRange} onClose={handleClose}
        />
      )}
    </ModalShell>
  );
}

// The active-session half of UM_ArchiveViewModal -- video player,
// timeline scrubber (with activity markers), play/pause, and a
// collapsible "change range" form that reuses the same two datetime
// fields the initial load screen shows, dispatching to the setRange
// control command instead of /start.
function UM_ArchivePlaybackBody({ unit, slot, archive, rangeStartLocal, setRangeStartLocal, rangeEndLocal, setRangeEndLocal, rangeError, submitRange, onClose }) {
  const [showRangeForm, setShowRangeForm] = useState(false);
  const barRef = useRef(null);

  const availStart = archive.timelineAvailableRange && archive.timelineAvailableRange.startMs;
  const availEnd = archive.timelineAvailableRange && archive.timelineAvailableRange.endMs;
  const hasRange = typeof availStart === "number" && typeof availEnd === "number" && availEnd > availStart;
  const positionMs = archive.archiveStatus ? archive.archiveStatus.positionMs : null;
  const playing = archive.archiveStatus ? !!archive.archiveStatus.playing : false;

  const ratioForMs = (ms) => (hasRange ? Math.min(1, Math.max(0, (ms - availStart) / (availEnd - availStart))) : 0);

  const handleBarClick = (e) => {
    if (!hasRange || !barRef.current) return;
    const rect = barRef.current.getBoundingClientRect();
    const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
    const targetMs = Math.round(availStart + ratio * (availEnd - availStart));
    archive.handleControl("seek", { positionMs: targetMs });
  };

  // Timeline availability rendered as contiguous coloured runs (has
  // video / gap) rather than one <div> per second -- a 3-day range at
  // 1px/second would be an unreasonable number of DOM nodes. Adjacent
  // seconds sharing the same hasVideo flag are merged into one segment.
  const segments = [];
  if (hasRange && archive.timeline.length > 0) {
    const sorted = archive.timeline; // already sorted ascending by ms (see admin-archive-view.ts)
    let segStart = sorted[0].ms;
    let segHasVideo = sorted[0].hasVideo;
    for (let i = 1; i <= sorted.length; i++) {
      const cur = sorted[i];
      const curHasVideo = cur ? cur.hasVideo : null;
      if (i === sorted.length || curHasVideo !== segHasVideo) {
        const segEnd = cur ? cur.ms : sorted[i - 1].ms + 1000;
        segments.push({ startMs: segStart, endMs: segEnd, hasVideo: segHasVideo });
        if (cur) { segStart = cur.ms; segHasVideo = cur.hasVideo; }
      }
    }
  }

  return (
    <>
      <div style={{ position: "relative", width: "100%", aspectRatio: "16 / 9", background: "#0d0d0d", marginBottom: 14 }}>
        {archive.session && archive.session.cf_stream_whep_url ? (
          <UM_WhepPlayer whepUrl={archive.session.cf_stream_whep_url} />
        ) : (
          <div style={{
            position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
            color: "rgba(255,255,255,0.55)", fontFamily: "var(--font-body)", fontSize: 11.5,
          }}>{archive.statusLabel || "Connecting\u2026"}</div>
        )}
      </div>

      <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
        <button
          type="button"
          onClick={() => archive.handleControl(playing ? "pause" : "play")}
          data-testid={`unit-archive-playpause-${slot}`}
          style={{
            width: 32, height: 32, borderRadius: "50%", flexShrink: 0,
            border: "1px solid rgba(0,0,0,0.3)", background: "#1A1712", color: "#fff",
            cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
          }}
        >{playing ? <IconPause size={14} /> : <IconPlay size={14} />}</button>

        <div
          ref={barRef} onClick={handleBarClick} data-testid={`unit-archive-timeline-${slot}`}
          style={{ position: "relative", flex: 1, height: 22, background: "rgba(0,0,0,0.08)", cursor: hasRange ? "pointer" : "default" }}
        >
          {segments.map((seg, i) => (
            <div key={i} style={{
              position: "absolute", top: 0, bottom: 0,
              left: `${ratioForMs(seg.startMs) * 100}%`,
              width: `${Math.max(0.2, (ratioForMs(seg.endMs) - ratioForMs(seg.startMs)) * 100)}%`,
              background: seg.hasVideo ? "rgba(63,174,92,0.55)" : "transparent",
            }} />
          ))}
          {archive.activities.map((act, i) => (
            act.endMs == null ? null : (
              <div
                key={i} title={act.kinds.map((k) => UM_ACTIVITY_KIND_LABELS[k] || k).join(", ")}
                style={{
                  position: "absolute", top: 2, width: 4, height: 4, borderRadius: "50%",
                  left: `calc(${ratioForMs(act.endMs) * 100}% - 2px)`, background: "#e0a11a",
                }}
              />
            )
          ))}
          {positionMs != null && hasRange && (
            <div style={{
              position: "absolute", top: 0, bottom: 0, width: 2, background: "#1A1712",
              left: `${ratioForMs(positionMs) * 100}%`,
            }} />
          )}
        </div>

        <span style={{ fontFamily: "monospace", fontSize: 10.5, color: "rgba(0,0,0,0.55)", minWidth: 118, textAlign: "right" }}>
          {positionMs != null ? new Date(positionMs).toLocaleString() : "\u2014"}
        </span>
      </div>

      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: showRangeForm ? 12 : 0 }}>
        <button
          type="button" onClick={() => setShowRangeForm((v) => !v)}
          data-testid={`unit-archive-change-range-${slot}`}
          style={{ background: "none", border: "none", color: "rgba(0,0,0,0.6)", cursor: "pointer", padding: 0, fontFamily: "var(--font-body)", fontSize: 11, textDecoration: "underline" }}
        >{showRangeForm ? "Cancel range change" : "Change date/time range"}</button>
        <ActionButton danger onClick={onClose} disabled={archive.stopping} testId={`unit-archive-close-${slot}`}>
          {archive.stopping ? "Closing\u2026" : "Close"}
        </ActionButton>
      </div>

      {showRangeForm && (
        <div style={{ borderTop: "1px solid rgba(0,0,0,0.1)", paddingTop: 12 }}>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
            <MiniField label="From">
              <input type="datetime-local" value={rangeStartLocal} onChange={(e) => setRangeStartLocal(e.target.value)} style={adminInputStyle} />
            </MiniField>
            <MiniField label="To">
              <input type="datetime-local" value={rangeEndLocal} onChange={(e) => setRangeEndLocal(e.target.value)} style={adminInputStyle} />
            </MiniField>
          </div>
          {rangeError && <ModalError>{rangeError}</ModalError>}
          <div style={{ display: "flex", justifyContent: "flex-end" }}>
            <ActionButton
              onClick={() => submitRange((startMs, endMs) => archive.handleControl("setRange", { startMs, endMs }).then(() => setShowRangeForm(false)))}
              testId={`unit-archive-apply-range-${slot}`}
            >Apply range</ActionButton>
          </div>
        </div>
      )}
    </>
  );
}

// UM_CameraFeed (further below) is also reused, unmodified, by the
// double-click fullscreen view (UM_CameraFullscreen) -- a plain,
// controls-less video body, same as an Ajax-linked grid tile's video
// area. It doesn't own a useAjaxLiveView() poll of its own (that lives
// in UM_CameraTile now, lifted so the header controls and video body
// share one poll loop) so this tiny bridge runs its own independent
// useAjaxLiveView() call just for the fullscreen render and hands the
// result straight to UM_AjaxLiveViewCard. Fullscreen has no Start/Stop
// controls of its own (those stay on the grid tile), so a second,
// short-lived poll loop here is a fine, isolated tradeoff.
function UM_AjaxCameraFeedBridge({ unit, slot }) {
  const live = useAjaxLiveView(unit, slot);
  return <UM_AjaxLiveViewCard live={live} />;
}

// Renders ONLY the video body (or its waiting/idle/loading placeholder)
// for an Ajax-linked slot -- no controls of its own, see this section's
// header comment above for why that moved up into UM_CameraTile's
// header bar. `live` is this slot's useAjaxLiveView() result, lifted by
// UM_CameraTile so the SAME poll drives both the header controls and
// this body without a second, duplicate fetch loop.
function UM_AjaxLiveViewCard({ live }) {
  const { session, isActive, restartPending, autoRestartExhausted } = live;

  // Not loaded yet -- the initial GET redeems a fresh Ajax REST token
  // through our VPS gateway and can genuinely take a couple of seconds
  // (confirmed live this session against unit 550: 4-4.5s before this
  // session's backend fix parallelized the two remote cross-checks
  // instead of running them one after another -- see
  // admin-live-view.ts's GET .../live-view/ajax/:slot for the fix and
  // its timeout comment), so silently rendering nothing here left a
  // tile looking dead/broken. Show an explicit loading placeholder for
  // that window instead.
  if (session === undefined) {
    return (
      <div style={{
        position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
        flexDirection: "column", gap: 10, color: "rgba(255,255,255,0.4)", textAlign: "center", padding: 16,
      }}>
        <IconCamera size={22} />
        <span style={{ fontFamily: "var(--font-body)", fontSize: 11, letterSpacing: "0.04em" }}>
          {"Checking camera status\u2026"}
        </span>
      </div>
    );
  }

  if (isActive && session.cf_stream_whep_url) {
    return <UM_WhepPlayer whepUrl={session.cf_stream_whep_url} />;
  }

  // The gateway's own ffmpeg relay is mid-backoff respawning after an
  // unexpected drop (sessionManager.js's auto-restart-on-drop) -- no
  // cf_stream_whep_url to hand the player yet, but this is a transient,
  // self-healing state, not a fault. Show a distinct "Reconnecting..."
  // placeholder instead of the generic "Waiting..." text so it doesn't
  // read as broken. Once the gateway gives up (autoRestartExhausted),
  // fall through to the plain waiting/idle text below -- at that point
  // it genuinely needs a manual Stop+Start, same as before.
  if (isActive && restartPending && !autoRestartExhausted) {
    return (
      <div style={{
        position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
        flexDirection: "column", gap: 10, color: "rgba(255,255,255,0.55)", textAlign: "center", padding: 16,
      }}>
        <IconCamera size={22} />
        <span style={{ fontFamily: "var(--font-body)", fontSize: 11, letterSpacing: "0.04em" }}>
          {"Reconnecting to camera\u2026"}
        </span>
      </div>
    );
  }

  return (
    <div style={{
      position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
      flexDirection: "column", gap: 10, color: "rgba(255,255,255,0.55)", textAlign: "center", padding: 16,
    }}>
      <IconCamera size={22} />
      <span style={{ fontFamily: "var(--font-body)", fontSize: 11, letterSpacing: "0.04em" }}>
        {isActive ? "Waiting for the video stream to connect\u2026" : "Not currently streaming."}
      </span>
    </div>
  );
}

function UM_CamerasTab({ unit, isAdmin, isSuperAdmin }) {
  const [state, setState] = useState({ status: "loading", cameras: [], ajaxCandidates: [], ajaxCompanyId: null, profileCameraCount: null, spreadsheetSerialsBySlot: {} });
  const [fullscreenSlot, setFullscreenSlot] = useState(null);
  const [editingSlot, setEditingSlot] = useState(null);
  // Archive/recorded-video playback (roadmap item #2) -- a modal, not a
  // tile mode, since it needs real screen space for the timeline/
  // calendar/range controls. Independent of fullscreenSlot/editingSlot;
  // only ever set for Ajax-linked slots (see UM_CameraTile's "History"
  // button, isSuperAdmin-gated same as Start/Stop live view).
  const [archiveViewSlot, setArchiveViewSlot] = useState(null);

  const camerasUrl = isAdmin ? `/api/admin/units/${unit.id}/cameras` : `/api/portal/assets/${unit.id}/cameras`;

  const load = () => {
    Promise.all([
      fetch(camerasUrl, { credentials: "same-origin" }).then((r) => (r.ok ? r.json() : Promise.reject())),
      fetch(UM_deviceMappingsUrl(unit.id, isAdmin), { credentials: "same-origin" }).then((r) => (r.ok ? r.json() : Promise.reject())),
    ])
      .then(([camerasData, mappingsData]) => {
        // null (not 0) when no Device Profile is assigned at all -- that's
        // the "legacy, unfiltered 4 slots" signal, distinct from a
        // profile that's deliberately assigned zero camera slots.
        const cameraProfileSlots = (mappingsData.slots || []).filter(
          (s) => s.device_category === "Ajax" && /camera/i.test(s.device_type || "") && !/relay/i.test(s.device_type || "")
        );
        let profileCameraCount = null;
        if (mappingsData.device_profile) {
          profileCameraCount = cameraProfileSlots.length;
        }
        setState({
          status: "ready", cameras: camerasData.cameras || [], ajaxCandidates: camerasData.ajaxCandidates || [],
          // Phase 1 (2026-09-12, "1 and 2 now"): admin-only, needed by
          // UM_CameraEditModal's "Connect automatically" button (see
          // admin-assets.ts's loadAjaxCameraCandidates doc comment for
          // why this is a separate field from ajaxCandidates itself).
          // Always null for the reseller viewer -- routes/portal.ts's
          // read-only cameras route never returns it.
          ajaxCompanyId: camerasData.ajaxCompanyId ?? null,
          profileCameraCount,
          // Maps unit_cameras' plain 1..4 slot number -> the "Camera N
          // Serial" value captured on this asset's spreadsheet import
          // (asset_device_mappings.serial_number for the matching Ajax/
          // Camera device-profile slot -- see lib/assetImport.ts's
          // `add("Ajax", "Camera", N, "serial_number", row.cameraNSerial)`
          // and lib/deviceMappingApply.ts's resolveSlotId for exactly how
          // a spreadsheet's "Camera N" ordinal picks which slot N is).
          // Same numbering rule as that resolver: a slot whose
          // display_name literally names a number ("Camera 2") uses that
          // number; otherwise slots are numbered positionally in
          // sort_order/id order. Read-only display only -- this is never
          // written back, it always reflects the spreadsheet import.
          spreadsheetSerialsBySlot: UM_cameraSlotSerials(cameraProfileSlots),
        });
      })
      .catch(() => setState({ status: "error", cameras: [], ajaxCandidates: [], ajaxCompanyId: null, profileCameraCount: null, spreadsheetSerialsBySlot: {} }));
  };

  useEffect(load, [unit.id]); // eslint-disable-line react-hooks/exhaustive-deps

  const bySlot = {};
  state.cameras.forEach((cam) => { bySlot[cam.slot] = cam; });

  if (state.status === "loading") return <UM_EmptyNote>Loading cameras&hellip;</UM_EmptyNote>;

  if (fullscreenSlot != null) {
    return (
      <UM_CameraFullscreen
        unit={unit} isAdmin={isAdmin} isSuperAdmin={isSuperAdmin} slot={fullscreenSlot} camera={bySlot[fullscreenSlot]}
        onBack={() => setFullscreenSlot(null)}
      />
    );
  }

  // See this component's header comment for the two safety nets baked
  // into this calculation.
  const highestConfiguredSlot = state.cameras.reduce((max, cam) => Math.max(max, cam.slot), 0);
  const slotCount = state.profileCameraCount === null
    ? UM_MAX_CAMERA_SLOTS
    : Math.min(UM_MAX_CAMERA_SLOTS, Math.max(state.profileCameraCount, highestConfiguredSlot));
  const cameraSlots = slotCount === UM_MAX_CAMERA_SLOTS ? UM_LEGACY_CAMERA_SLOTS : Array.from({ length: slotCount }, (_, i) => i + 1);

  // Phase 3 live view: only 'rtsp' and plain 'onvif' camera slots are
  // VPN-relay candidates (admin-live-view.ts's own POST /start
  // validation -- 'ajax' snapshot-polling and 'test_stream' HLS slots
  // already have their own working live path and aren't relay
  // candidates). CRITICAL: an Ajax-linked camera is ALSO stored with
  // source_type='onvif' (used for the ONVIF snapshot-fallback tile --
  // see redactCamera's device_identifier doc comment) but its `host` is
  // a LAN-local IP the relay's generic ONVIF GetStreamUri negotiation
  // can't actually pull a working stream from (confirmed live this
  // session: unit 550's cameras all show source_type='onvif' AND
  // ajax_video_edge_id set, and offering them here produced a session
  // that reached 'streaming' in the DB yet the browser's WHEP player
  // never connected -- "Couldn't connect to the live stream"). Those
  // slots must go through UM_AjaxLiveViewCard (below, keyed off the
  // same ajax_video_edge_id) instead, so they're explicitly excluded
  // here even though source_type alone would otherwise match.
  const liveViewSlots = cameraSlots.filter((slot) => {
    const cam = bySlot[slot];
    return cam && cam.configured && !cam.ajax_video_edge_id && (cam.source_type === "rtsp" || cam.source_type === "onvif");
  });

  return (
    <div>
      {isAdmin && unit.has_router && liveViewSlots.length > 0 && (
        <UM_LiveViewCard unit={unit} isSuperAdmin={isSuperAdmin} liveViewSlots={liveViewSlots} bySlot={bySlot} />
      )}

      <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474", marginBottom: 16 }}>
        {cameraSlots.length === 0
          ? "This asset's Device Profile doesn't include any camera slots."
          : `Up to ${cameraSlots.length} camera${cameraSlots.length === 1 ? "" : "s"} per asset, connected via ONVIF, an Ajax video device, or a direct RTSP ("Own Camera") connection. Double-click a tile to view it fullscreen.`}
      </div>
      {cameraSlots.length > 0 && (
        <div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16 }}>
          {cameraSlots.map((slot) => (
            <UM_CameraTile
              key={slot} unit={unit} isAdmin={isAdmin} isSuperAdmin={isSuperAdmin} slot={slot} camera={bySlot[slot]}
              onOpenFullscreen={() => setFullscreenSlot(slot)}
              onEdit={isAdmin ? () => setEditingSlot(slot) : undefined}
              onOpenArchive={isAdmin ? () => setArchiveViewSlot(slot) : undefined}
            />
          ))}
        </div>
      )}

      {editingSlot != null && (
        <UM_CameraEditModal
          unit={unit} slot={editingSlot} camera={bySlot[editingSlot]} ajaxCandidates={state.ajaxCandidates}
          ajaxCompanyId={state.ajaxCompanyId}
          spreadsheetSerial={state.spreadsheetSerialsBySlot[editingSlot] || null}
          onCancel={() => setEditingSlot(null)}
          onSaved={() => { setEditingSlot(null); load(); }}
        />
      )}

      {archiveViewSlot != null && (
        <UM_ArchiveViewModal
          unit={unit} slot={archiveViewSlot} camera={bySlot[archiveViewSlot]}
          onClose={() => setArchiveViewSlot(null)}
        />
      )}
    </div>
  );
}

// A single camera tile. REDESIGNED (this session, customer feedback:
// "the OFFLINE icon is stuck", "stop button is in the way of the
// settings icon", "camera ID is over the OSD top left", "this needs to
// be slick, and fast loading") -- the previous cut absolutely-positioned
// FOUR independent overlays inside the video area (device-ID badge
// top-left, edit-gear top-right, Ajax Start/Stop/status ALSO top-right
// one layer down inside UM_CameraFeed, and a label+OFFLINE badge on the
// bottom gradient), which is exactly what produced the overlapping-
// controls and stuck-badge bugs: absolutely-positioned siblings with no
// shared layout know nothing about each other's space.
//
// Fixed properly by giving every slot type ONE real flex header row,
// in normal document flow above the video (not floating over it): the
// device-ID/serial on the left, a single accurate status badge +
// (super-admin only) Start/Stop/edit-gear on the right. Nothing here
// is ever absolutely positioned over the video, so nothing can overlap
// anything else regardless of slot type or live-view state -- the
// video area underneath is now ALWAYS just the feed/placeholder, full
// stop, matching what UM_CameraFullscreen already did for its own
// (non-overlapping) header.
//
// The bottom "stuck OFFLINE" badge is also fixed here, not just
// relocated: it used to read camera.last_error, a field the ONVIF
// save-time validator (admin-assets.ts) sets and NEVER clears for any
// Ajax-linked slot -- confirmed live this session via direct D1 query,
// unit 550's 4 cameras all permanently carry "Cloudflare blocked this
// request because the camera's host is a bare IP address ... Error
// 1003" from that one-time ONVIF probe, which has nothing to do with
// whether the camera is actually live via the Ajax path today. A
// status badge driven by that field can never be anything but
// permanently red for these slots. Fixed by computing `liveStatus`
// from the SAME live-session data (session/streamConnected) the Ajax
// header controls already show, for Ajax-linked slots -- both the
// badge and the controls now agree with each other and with reality,
// and this stale field is only consulted at all for the OTHER slot
// types (plain ONVIF/RTSP) where it's still the right signal (no
// separate live-session state exists for them to check instead).
function UM_CameraTile({ unit, isAdmin, isSuperAdmin, slot, camera, onOpenFullscreen, onEdit, onOpenArchive }) {
  const configured = !!(camera && camera.configured);
  const label = (camera && camera.label) || `Camera ${slot}`;
  const isAjax = !!(camera && camera.ajax_video_edge_id);
  // Customer's explicit ask (this session): "show the camera ID/Serial
  // number in the window of each camera ... pull this from the API as
  // the identifier" -- device_identifier is a single combined field
  // the backend already resolves (Ajax's video-edge id, which IS the
  // real hardware serial per Ajax's own docs, or a plain ONVIF
  // camera's own GetDeviceInformation serial -- see
  // routes/admin-assets.ts's redactCamera / routes/portal.ts's
  // equivalent for exactly how). Never a Solo-invented label -- shown
  // only when the vendor/device actually reported one.
  const deviceIdentifier = camera && camera.device_identifier;
  // Ajax video-edge health warning (migrations/0063, routes/admin-
  // assets.ts's redactCamera / routes/portal.ts's equivalent) -- an SD-
  // card fault/missing-card/needs-format condition, or a pending
  // firmware update. Null for non-Ajax slots and for healthy cameras.
  const healthWarning = camera && camera.ajax_health_warning;

  // Ajax-linked slots poll their own live-session state here, in the
  // PARENT, and hand it down to both the header controls below and
  // UM_AjaxLiveViewCard's video body -- one poll loop, not two, and the
  // header/body can never disagree about whether the camera is live.
  // isAdmin-gated the same way UM_CameraFeed's own dispatch already is
  // (the reseller portal has no Ajax gateway routes at all) -- calling
  // the hook itself is fine either way (it's just fetch calls, no
  // rendering), it's only actually USED below when isAdmin && isAjax.
  const ajaxLive = useAjaxLiveView(unit, slot);

  let liveStatus; // { tone: 'green'|'red'|'amber'|'grey', label: string }
  if (!configured) {
    liveStatus = null;
  } else if (isAdmin && isAjax) {
    if (ajaxLive.session === undefined) liveStatus = { tone: "grey", label: "Checking\u2026" };
    else if (!ajaxLive.isActive) liveStatus = { tone: "grey", label: "Offline" };
    else if (ajaxLive.session.status === "error") liveStatus = { tone: "red", label: "Error" };
    // Gateway is mid-backoff respawning ffmpeg after an unexpected drop
    // (sessionManager.js auto-restart-on-drop) -- surface this as its
    // own amber "Reconnecting" state rather than either "Live" (wrong,
    // no frames are flowing right now) or the flat "Connecting..." a
    // brand-new session shows (this is a recovery, not a first start).
    // Once the gateway gives up (autoRestartExhausted), fall through so
    // it reads as a real error instead.
    else if (ajaxLive.restartPending && !ajaxLive.autoRestartExhausted) liveStatus = { tone: "amber", label: "Reconnecting" };
    else if (ajaxLive.session.status === "streaming" && ajaxLive.streamConnected !== false) liveStatus = { tone: "green", label: "Live" };
    else liveStatus = { tone: "amber", label: ajaxLive.statusLabel || "Connecting\u2026" };
  } else {
    // Every other slot type (plain ONVIF, RTSP, test_stream) has no
    // separate live-session state to check -- last_ok_at/last_error
    // from the camera row itself (set by the ONVIF save-time
    // validator, the RTSP Test Connection button, or the snapshot
    // proxy's own request-by-request result) is still the right,
    // accurate signal for these.
    liveStatus = camera.last_error ? { tone: "red", label: "Offline" } : { tone: "green", label: "Online" };
  }
  const statusColors = {
    green: { bg: "rgba(63,174,92,0.85)", color: "#fff" },
    red: { bg: "rgba(190,40,40,0.85)", color: "#fff" },
    amber: { bg: "rgba(180,116,11,0.85)", color: "#fff" },
    grey: { bg: "rgba(255,255,255,0.15)", color: "rgba(255,255,255,0.85)" },
  };

  return (
    <div data-testid={`unit-camera-tile-${slot}`} style={{ border: "1px solid #E2DCCB", background: "#1A1712", overflow: "hidden" }}>
      {/* Header bar -- normal flex flow, never absolutely positioned,
          so it can never overlap the video below or its own contents
          overlap each other regardless of slot type or live state. */}
      <div style={{
        display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8,
        padding: "6px 8px", background: "#1A1712", minHeight: 30,
      }}>
        <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0, overflow: "hidden" }}>
          <span style={{ fontFamily: "var(--font-body)", fontSize: 11.5, color: "#fff", fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
            {label}
          </span>
          {configured && deviceIdentifier && (
            <span
              data-testid={`unit-camera-serial-${slot}`}
              title="Device ID / serial number (reported by the camera or Ajax API)"
              style={{ fontFamily: "monospace", fontSize: 10, color: "rgba(255,255,255,0.5)", whiteSpace: "nowrap" }}
            >{deviceIdentifier}</span>
          )}
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: 6, flexShrink: 0 }}>
          {configured && healthWarning && (
            <span
              data-testid={`unit-camera-health-warning-${slot}`}
              title={healthWarning}
              style={{ display: "flex", alignItems: "center", color: "#e0a11a" }}
            >
              <IconWarningTriangle size={13} />
            </span>
          )}
          {liveStatus && (
            <span
              data-testid={`unit-camera-status-${slot}`}
              style={{
                fontFamily: "var(--font-body)", fontSize: 9, fontWeight: 600, letterSpacing: "0.05em",
                textTransform: "uppercase", padding: "3px 7px", borderRadius: 3,
                background: statusColors[liveStatus.tone].bg, color: statusColors[liveStatus.tone].color,
              }}
            >{liveStatus.label}</span>
          )}
          {isAdmin && isSuperAdmin && isAjax && ajaxLive.session !== undefined && (
            !ajaxLive.isActive ? (
              <button
                type="button" onClick={ajaxLive.handleStart} disabled={ajaxLive.starting}
                data-testid={`unit-ajax-live-view-start-${slot}`}
                style={{
                  background: "#3A342A", color: "#fff", border: "none", padding: "5px 10px", borderRadius: 3,
                  cursor: ajaxLive.starting ? "default" : "pointer", opacity: ajaxLive.starting ? 0.6 : 1,
                  fontFamily: "var(--font-body)", fontSize: 9.5, fontWeight: 600,
                  letterSpacing: "0.05em", textTransform: "uppercase", whiteSpace: "nowrap",
                }}
              >{ajaxLive.starting ? "Starting\u2026" : "Start"}</button>
            ) : (
              <button
                type="button" onClick={ajaxLive.handleStop} disabled={ajaxLive.stopping}
                data-testid={`unit-ajax-live-view-stop-${slot}`}
                style={{
                  background: "none", border: "1px solid rgba(255,255,255,0.35)", color: "#fff", borderRadius: 3,
                  cursor: ajaxLive.stopping ? "default" : "pointer", opacity: ajaxLive.stopping ? 0.6 : 1,
                  padding: "5px 10px", fontFamily: "var(--font-body)", fontSize: 9.5,
                  fontWeight: 600, letterSpacing: "0.05em", textTransform: "uppercase", whiteSpace: "nowrap",
                }}
              >{ajaxLive.stopping ? "Stopping\u2026" : "Stop"}</button>
            )
          )}
          {isAdmin && isSuperAdmin && isAjax && configured && onOpenArchive && (
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); onOpenArchive(); }}
              data-testid={`unit-camera-archive-${slot}`}
              title="View recorded video"
              style={{
                width: 22, height: 22, borderRadius: 4, flexShrink: 0,
                border: "1px solid rgba(255,255,255,0.3)", background: "none", color: "rgba(255,255,255,0.85)",
                cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
              }}
            >
              <IconHistory size={12} />
            </button>
          )}
          {isAdmin && onEdit && (
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); onEdit(); }}
              data-testid={`unit-camera-edit-${slot}`}
              title="Configure this camera"
              style={{
                width: 22, height: 22, borderRadius: 4, flexShrink: 0,
                border: "1px solid rgba(255,255,255,0.3)", background: "none", color: "rgba(255,255,255,0.85)",
                cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
              }}
            >
              <IconSettings size={12} />
            </button>
          )}
        </div>
      </div>

      {/* Video body -- always JUST the feed/placeholder now, nothing
          else is ever drawn over it. */}
      <div
        onDoubleClick={configured ? onOpenFullscreen : undefined}
        style={{
          position: "relative", aspectRatio: "16 / 9", background: "#0d0d0d",
          cursor: configured ? "pointer" : "default",
        }}
      >
        {configured ? (
          isAdmin && isAjax
            ? <UM_AjaxLiveViewCard live={ajaxLive} />
            : <UM_CameraFeed unit={unit} isAdmin={isAdmin} isSuperAdmin={isSuperAdmin} slot={slot} camera={camera} />
        ) : (
          <div style={{
            position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
            flexDirection: "column", gap: 8, color: "rgba(255,255,255,0.35)",
          }}>
            <IconCamera size={22} />
            <span style={{ fontFamily: "var(--font-body)", fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase" }}>
              {isAdmin ? "Not connected" : "Not yet connected"}
            </span>
          </div>
        )}
      </div>

      {configured && isAdmin && isAjax && ajaxLive.error && (
        <div data-testid={`unit-ajax-live-view-error-${slot}`} style={{
          padding: "6px 9px", background: "rgba(190,40,40,0.12)", color: "#a12b2b",
          fontFamily: "var(--font-body)", fontSize: 10.5, lineHeight: 1.4,
        }}>{ajaxLive.error}</div>
      )}
      {configured && isAdmin && isAjax && !ajaxLive.error && ajaxLive.session && ajaxLive.session.status === "error" && ajaxLive.session.last_error && (
        <div data-testid={`unit-ajax-live-view-last-error-${slot}`} style={{
          padding: "6px 9px", background: "rgba(190,40,40,0.12)", color: "#a12b2b",
          fontFamily: "var(--font-body)", fontSize: 10.5, lineHeight: 1.4,
        }}>{ajaxLive.session.last_error}</div>
      )}
      {configured && healthWarning && (
        <div data-testid={`unit-camera-health-warning-strip-${slot}`} style={{
          display: "flex", alignItems: "center", gap: 6,
          padding: "6px 9px", background: "rgba(224,161,26,0.14)", color: "#a1740f",
          fontFamily: "var(--font-body)", fontSize: 10.5, lineHeight: 1.4,
        }}>
          <IconWarningTriangle size={12} />
          {healthWarning}
        </div>
      )}
    </div>
  );
}

// Fullscreen single-camera view — same polling feed, larger, with a back
// button to return to the 2x2 grid. Reached by double-clicking a tile.
function UM_CameraFullscreen({ unit, isAdmin, isSuperAdmin, slot, camera, onBack }) {
  const label = (camera && camera.label) || `Camera ${slot}`;
  const deviceIdentifier = camera && camera.device_identifier;
  const healthWarning = camera && camera.ajax_health_warning;
  return (
    <div data-testid={`unit-camera-fullscreen-${slot}`}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
        <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
          <div style={{ fontFamily: "var(--font-body)", fontSize: 13, fontWeight: 600, color: "#1A1712" }}>{label}</div>
          {deviceIdentifier && (
            <div
              data-testid={`unit-camera-fullscreen-serial-${slot}`}
              title="Device ID / serial number (reported by the camera or Ajax API)"
              style={{ fontFamily: "monospace", fontSize: 11.5, color: "#8A8474" }}
            >{deviceIdentifier}</div>
          )}
          {healthWarning && (
            <div
              data-testid={`unit-camera-fullscreen-health-warning-${slot}`}
              title={healthWarning}
              style={{ display: "flex", alignItems: "center", gap: 4, color: "#a1740f", fontFamily: "var(--font-body)", fontSize: 11 }}
            >
              <IconWarningTriangle size={12} />
              {healthWarning}
            </div>
          )}
        </div>
        <button
          type="button" onClick={onBack} data-testid="unit-camera-fullscreen-back"
          style={{
            background: "none", border: "1px solid rgba(0,0,0,0.25)", color: "rgba(0,0,0,0.75)",
            cursor: "pointer", padding: "8px 16px", fontFamily: "var(--font-body)", fontSize: 11,
            fontWeight: 500, letterSpacing: "0.12em", textTransform: "uppercase",
          }}
        >&larr; Back to cameras</button>
      </div>
      <div style={{ position: "relative", width: "100%", aspectRatio: "16 / 9", background: "#0d0d0d", border: "1px solid #E2DCCB", overflow: "hidden" }}>
        <UM_CameraFeed unit={unit} isAdmin={isAdmin} isSuperAdmin={isSuperAdmin} slot={slot} camera={camera} />
      </div>
    </div>
  );
}

// Renders the actual live feed for one camera slot. Two distinct modes:
//   - 'test_stream': a ready-made HLS (.m3u8) URL, played by a real
//     <video> element via hls.js (Safari plays HLS natively and skips
//     hls.js entirely). Genuine continuous video, no polling — used for
//     demo/QA cameras (see lib/cameraStream.ts's stream_url doc comment)
//     since there's no real ONVIF/Ajax camera to point at in every
//     environment.
//   - everything else (onvif/ajax): snapshot mode, the only mode that
//     works on every ONVIF camera — re-fetches the snapshot endpoint on
//     an interval and swaps a cache-busted <img> src each tick. A real
//     MJPEG multipart stream (when a camera's mjpeg_path is set
//     server-side) is instead a single long-lived <img> whose src the
//     browser keeps decoding frame-by-frame on its own, so this
//     component doesn't need to know which mode the backend chose —
//     either way it's just "point an <img> at the snapshot URL and keep
//     it fresh".
function UM_CameraFeed({ unit, isAdmin, isSuperAdmin, slot, camera }) {
  // Ajax Video SDK path takes priority over every other mode when a
  // camera slot is linked to a real Ajax video-edge (ajax_video_edge_id
  // set -- see admin-ajax.ts's auto-link route and redactCamera's
  // device_identifier doc comment for why this is the reliable signal
  // rather than source_type, which stays 'onvif' for these slots so the
  // ONVIF snapshot proxy keeps working as an automatic fallback
  // wherever this component ISN'T the one rendering, e.g. the reseller
  // portal's read-only view -- see UM_AjaxLiveViewCard's header comment
  // for the full architecture). Admin-only: the reseller portal has no
  // Ajax gateway routes at all (routes/portal.ts), so a non-admin
  // render always falls through to the plain snapshot feed below.
  if (isAdmin && camera && camera.ajax_video_edge_id) {
    return <UM_AjaxCameraFeedBridge unit={unit} slot={slot} />;
  }
  if (camera && camera.source_type === "test_stream") {
    return <UM_CameraHlsFeed streamUrl={camera.stream_url} />;
  }
  // 'rtsp' ("Own Camera") slots have no snapshot proxy or player on this
  // stack (see rtspClient.ts's header comment) -- rendered as a
  // connectivity/status card instead, driven by the same last_ok_at/
  // last_error the tile's Live/Offline badge already reads, refreshed by
  // the admin's Test Connection button rather than continuous polling.
  if (camera && camera.source_type === "rtsp") {
    return (
      <div style={{
        position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
        flexDirection: "column", gap: 8, color: "rgba(255,255,255,0.55)", textAlign: "center", padding: 16,
      }}>
        <IconCamera size={22} />
        <span style={{ fontFamily: "var(--font-body)", fontSize: 11, letterSpacing: "0.04em" }}>
          RTSP camera configured — use Test Connection to verify it.
        </span>
      </div>
    );
  }

  const [tick, setTick] = useState(0);
  const [failed, setFailed] = useState(false);
  const snapshotUrl = isAdmin
    ? `/api/admin/units/${unit.id}/cameras/${slot}/snapshot`
    : `/api/portal/assets/${unit.id}/cameras/${slot}/snapshot`;

  useEffect(() => {
    const interval = setInterval(() => setTick((t) => t + 1), UM_SNAPSHOT_POLL_MS);
    return () => clearInterval(interval);
  }, []);

  if (failed) {
    return (
      <div style={{
        position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
        color: "rgba(255,255,255,0.4)", fontFamily: "var(--font-body)", fontSize: 11.5, textAlign: "center", padding: 16,
      }}>
        Couldn't load this camera's feed.
      </div>
    );
  }

  return (
    <img
      src={`${snapshotUrl}?t=${tick}`}
      onError={() => setFailed(true)}
      onLoad={() => setFailed(false)}
      style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }}
      alt=""
    />
  );
}

// Plays a stream_url with hls.js, falling back to native HLS support
// (Safari/iOS) when the browser can play application/vnd.apple.mpegurl
// directly without a JS library at all. hls.js is loaded globally from
// index.html (unpkg, pinned) — this component only touches it inside
// useEffect so it never runs during the render-only test harness used
// to verify this file (see README's Mission Control Cameras section).
function UM_CameraHlsFeed({ streamUrl }) {
  const videoRef = useRef(null);
  const [failed, setFailed] = useState(false);

  useEffect(() => {
    setFailed(false);
    const video = videoRef.current;
    if (!video || !streamUrl) return;

    let hls = null;
    if (video.canPlayType("application/vnd.apple.mpegurl")) {
      // Native HLS (Safari/iOS) — no library needed.
      video.src = streamUrl;
    } else if (window.Hls && window.Hls.isSupported()) {
      hls = new window.Hls({ liveDurationInfinity: true });
      hls.on(window.Hls.Events.ERROR, (_event, data) => {
        if (data && data.fatal) setFailed(true);
      });
      hls.loadSource(streamUrl);
      hls.attachMedia(video);
    } else {
      setFailed(true);
    }

    return () => { if (hls) hls.destroy(); };
  }, [streamUrl]);

  if (!streamUrl) {
    return (
      <div style={{
        position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
        color: "rgba(255,255,255,0.4)", fontFamily: "var(--font-body)", fontSize: 11.5, textAlign: "center", padding: 16,
      }}>
        No stream URL configured.
      </div>
    );
  }

  if (failed) {
    return (
      <div style={{
        position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
        color: "rgba(255,255,255,0.4)", fontFamily: "var(--font-body)", fontSize: 11.5, textAlign: "center", padding: 16,
      }}>
        Couldn't load this camera's stream.
      </div>
    );
  }

  return (
    <video
      ref={videoRef}
      autoPlay muted playsInline controls={false}
      onError={() => setFailed(true)}
      style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }}
    />
  );
}

// Admin-only config editor for a single camera slot — host/port/HTTPS/
// username/password, plus an optional raw MJPEG path for cameras that
// expose one (a genuine continuous stream instead of snapshot polling —
// see lib/cameraStream.ts). Offers Ajax-synced video devices from this
// unit's linked hub as one-click name/room prefill, per the customer's
// "auto configure when we have ajax device connection" instruction —
// this only prefills the label, since Ajax's own API never exposes a
// camera's network address or ONVIF credentials (confirmed against
// their Enterprise API docs), so the host/username/password still need
// pasting in from that camera's own ONVIF setup screen. A third source
// type, 'test stream', bypasses ONVIF entirely — just a public HLS URL
// played directly in the browser, useful for demoing the 4-camera grid
// without real camera hardware.
// The "test window" the customer's spec explicitly asked for -- a
// dedicated modal (reusing the same ModalShell every other admin dialog in
// this app uses) that surfaces the real result of src/lib/rtspClient.ts's
// testRtspCamera(): reachability, whether the given credentials were
// accepted, the RTSP methods the camera advertises, its server banner, a
// human-readable summary of its real SDP stream description (codec/rate),
// and -- when a SETUP was attempted -- whether the requested transport was
// accepted. This is a real protocol test result, not a canned response;
// see rtspClient.ts's header comment for exactly what it can and can't
// prove on this platform.
function UM_RtspTestWindow({ result, onClose }) {
  const row = (label, value) => (
    <div style={{ display: "flex", justifyContent: "space-between", gap: 16, padding: "7px 0", borderBottom: "1px solid rgba(0,0,0,0.08)" }}>
      <span style={{ fontFamily: "var(--font-body)", fontSize: 11.5, color: "rgba(0,0,0,0.55)" }}>{label}</span>
      <span style={{ fontFamily: "var(--font-body)", fontSize: 11.5, color: "#1A1712", fontWeight: 600, textAlign: "right" }}>{value}</span>
    </div>
  );

  return (
    <ModalShell title="RTSP Connection Test" onCancel={onClose}>
      <div
        data-testid="unit-camera-rtsp-test-window"
        style={{
          marginBottom: 18, padding: "10px 14px",
          background: result.ok ? "rgba(63,174,92,0.1)" : "rgba(190,40,40,0.08)",
          border: `1px solid ${result.ok ? "rgba(63,174,92,0.4)" : "rgba(190,40,40,0.35)"}`,
          color: result.ok ? "#256b3a" : "#8a1f1f",
          fontFamily: "var(--font-body)", fontSize: 13, fontWeight: 600,
        }}
      >
        {result.ok ? "\u2713 Connected — the camera responded to a real RTSP handshake." : `\u2717 ${result.error || "Couldn't connect to this camera."}`}
      </div>

      {result.ok && (
        <div style={{ marginBottom: 18 }}>
          {row("Reachable", result.reachable ? "Yes" : "No")}
          {row("Authenticated", result.authenticated ? "Yes" : "No")}
          {row("Server", result.serverHeader || "—")}
          {row("Supported methods", (result.optionsMethods && result.optionsMethods.length) ? result.optionsMethods.join(", ") : "—")}
          {row("Stream", result.mediaSummary || "—")}
          {result.setupAccepted !== null && row("Transport accepted", result.setupAccepted ? "Yes" : "No")}
        </div>
      )}

      <div style={{ fontFamily: "var(--font-body)", fontSize: 11, color: "rgba(0,0,0,0.45)", lineHeight: 1.6, marginBottom: 20 }}>
        This is a genuine RTSP protocol test (a real TCP connection and RTSP/1.0 handshake with the camera) —
        it confirms the camera is reachable and, if credentials were given, that they're accepted. It doesn't
        play live video here; Cloudflare's edge platform can't sustain a continuous video stream, so a
        connected "Own Camera" still shows as a connectivity/status tile rather than a live feed.
      </div>

      <div style={{ display: "flex", justifyContent: "flex-end" }}>
        <ActionButton onClick={onClose} testId="unit-camera-rtsp-test-window-close">Close</ActionButton>
      </div>
    </ModalShell>
  );
}

function UM_CameraEditModal({ unit, slot, camera, ajaxCandidates, ajaxCompanyId, spreadsheetSerial, onCancel, onSaved }) {
  const [sourceType, setSourceType] = useState((camera && camera.source_type) || "onvif");
  const [label, setLabel] = useState((camera && camera.label) || "");
  const [host, setHost] = useState((camera && camera.host) || "");
  const [port, setPort] = useState(camera && camera.port ? String(camera.port) : "80");
  const [useHttps, setUseHttps] = useState(!!(camera && camera.use_https));
  const [username, setUsername] = useState((camera && camera.username) || "");
  const [password, setPassword] = useState("");
  const [mjpegPath, setMjpegPath] = useState((camera && camera.mjpeg_path) || "");
  // Spaces-domain VIDEO_EDGE link (migrations/0055) -- see that
  // migration's header comment for why this is a separate field from
  // the old (Hubs-domain, never-actually-a-camera) ajax_device_id.
  const [ajaxVideoEdgeId, setAjaxVideoEdgeId] = useState((camera && camera.ajax_video_edge_id) || "");
  const [streamUrl, setStreamUrl] = useState((camera && camera.stream_url) || "");
  // "Own Camera" RTSP fields (migrations/0032) -- customer's exact spec:
  // RTSP URL / Username / Password / Transport (TCP/UDP) / Main-or-sub
  // stream.
  const [rtspUrl, setRtspUrl] = useState((camera && camera.rtsp_url) || "");
  const [rtspUsername, setRtspUsername] = useState((camera && camera.rtsp_username) || "");
  const [rtspPassword, setRtspPassword] = useState("");
  const [rtspTransport, setRtspTransport] = useState((camera && camera.rtsp_transport) || "tcp");
  const [rtspStreamProfile, setRtspStreamProfile] = useState((camera && camera.rtsp_stream_profile) || "main");
  const [rtspTesting, setRtspTesting] = useState(false);
  const [rtspTestResult, setRtspTestResult] = useState(null);
  const [enabled, setEnabled] = useState(camera ? !!camera.enabled : true);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState("");
  const [validation, setValidation] = useState(null);
  // Phase 1 (2026-09-12, "1 and 2 now"): auto-provisioning state for the
  // "Connect automatically" button below -- see handleAutoConnect.
  const [autoConnecting, setAutoConnecting] = useState(false);
  const [autoConnectError, setAutoConnectError] = useState("");

  const isTestStream = sourceType === "test_stream";
  const isRtsp = sourceType === "rtsp";
  const canSave = isTestStream ? !!streamUrl : isRtsp ? !!rtspUrl : !!host;

  // Real RTSP connectivity test -- customer's explicit spec: "need a test
  // button with a test window". Calls the backend's test-rtsp route,
  // which opens a genuine TCP socket to the camera and speaks real
  // RTSP/1.0 (see src/lib/rtspClient.ts) -- this is not a UI mock, the
  // result reflects an actual handshake with whatever's at rtspUrl right
  // now, even before Save is clicked.
  const handleTestRtsp = async () => {
    setRtspTesting(true);
    setRtspTestResult(null);
    try {
      const res = await fetch(`/api/admin/units/${unit.id}/cameras/${slot}/test-rtsp`, {
        method: "POST", credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          rtspUrl, rtspUsername,
          ...(rtspPassword ? { rtspPassword } : {}),
          rtspTransport, rtspStreamProfile,
        }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "Couldn't run the test.");
      setRtspTestResult(data.result);
    } catch (err) {
      setRtspTestResult({ ok: false, error: err.message });
    } finally {
      setRtspTesting(false);
    }
  };

  // ajaxCandidates are now AjaxVideoEdgeSummary rows ({spaceId,
  // spaceName, videoEdgeId, name}) from the Spaces domain -- see
  // migrations/0055's header comment -- not the old ajax_devices shape
  // ({id, name, room_name, device_type}). Keyed by videoEdgeId, not id.
  const applyAjaxCandidate = (videoEdgeId) => {
    setAjaxVideoEdgeId(videoEdgeId);
    const device = ajaxCandidates.find((d) => String(d.videoEdgeId) === String(videoEdgeId));
    if (device && !label) setLabel(device.name || "");
  };

  // Phase 1 (2026-09-12, customer's "Ajax Camera Integration into SOLO
  // Mission Control" spec, approved via "1 and 2 now"): zero-typing path
  // from "picked an Ajax video device above" to "this slot is live in
  // Mission Control" -- calls admin-ajax.ts's real provision-onvif
  // route, which creates a Solo-generated ONVIF user on the actual
  // camera via the live Ajax API and reads back its network host/port,
  // then writes straight into THIS unit_cameras slot -- no admin typing
  // in an IP or password at all. Requires a video device to already be
  // picked (ajaxVideoEdgeId) and this unit's hub to have a cached
  // ajax_space_id + live company connection (ajaxCompanyId non-null --
  // see admin-assets.ts's loadAjaxCameraCandidates doc comment for
  // exactly when that's null). Re-fetches this slot afterwards
  // (onSaved -> parent reload) so the tile/edit form immediately shows
  // the new host/username rather than this modal guessing at them.
  const selectedCandidate = ajaxCandidates.find((d) => String(d.videoEdgeId) === String(ajaxVideoEdgeId));
  const canAutoConnect = sourceType === "onvif" && !!selectedCandidate && !!ajaxCompanyId;
  const handleAutoConnect = async () => {
    if (!selectedCandidate || !ajaxCompanyId) return;
    setAutoConnecting(true);
    setAutoConnectError("");
    try {
      const res = await fetch(
        `/api/admin/companies/${ajaxCompanyId}/ajax-video-edges/${encodeURIComponent(selectedCandidate.videoEdgeId)}/provision-onvif`,
        {
          method: "POST", credentials: "same-origin",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ spaceId: selectedCandidate.spaceId, unitId: unit.id, slot }),
        }
      );
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "Couldn't connect this camera automatically.");
      onSaved();
    } catch (err) {
      setAutoConnectError(err.message);
    } finally {
      setAutoConnecting(false);
    }
  };

  const handleSave = async () => {
    setSaving(true);
    setError("");
    setValidation(null);
    try {
      const res = await fetch(`/api/admin/units/${unit.id}/cameras/${slot}`, {
        method: "PUT", credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          sourceType, label, host, port: Number(port) || 80, useHttps, username,
          ...(password ? { password } : {}),
          mjpegPath: mjpegPath || null,
          ajaxVideoEdgeId: ajaxVideoEdgeId || null,
          streamUrl: streamUrl || null,
          rtspUrl: rtspUrl || null,
          rtspUsername: rtspUsername || null,
          ...(rtspPassword ? { rtspPassword } : {}),
          rtspTransport, rtspStreamProfile,
          enabled,
        }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "Couldn't save this camera.");
      if (data.validation && !data.validation.ok) setValidation(data.validation);
      onSaved();
    } catch (err) {
      setError(err.message);
    } finally {
      setSaving(false);
    }
  };

  const handleRemove = async () => {
    setSaving(true);
    try {
      await fetch(`/api/admin/units/${unit.id}/cameras/${slot}`, { method: "DELETE", credentials: "same-origin" });
      onSaved();
    } finally {
      setSaving(false);
    }
  };

  return (
    <ModalShell title={`Camera ${slot} — ${unit.serial_number}`} onCancel={onCancel}>
      {error && <ModalError>{error}</ModalError>}
      {validation && (
        <div style={{
          background: "rgba(224,163,57,0.1)", border: "1px solid rgba(224,163,57,0.4)",
          color: "#8a6d1f", padding: "10px 12px", marginBottom: 16,
          fontFamily: "var(--font-body)", fontSize: 12, lineHeight: 1.5,
        }}>
          Saved, but couldn't confirm a live connection yet: {validation.error}
        </div>
      )}

      <MiniField label="Connection type">
        <select
          value={sourceType} onChange={(e) => setSourceType(e.target.value)}
          data-testid={`unit-camera-source-type-${slot}`} style={adminSelectStyle}
        >
          <option value="onvif">ONVIF / Ajax camera</option>
          <option value="test_stream">Test stream (HLS URL)</option>
          <option value="rtsp">Own Camera (RTSP)</option>
        </select>
      </MiniField>

      {spreadsheetSerial && (
        <MiniField label="Camera MAC (from spreadsheet import)">
          <input
            value={spreadsheetSerial} readOnly disabled
            data-testid={`unit-camera-spreadsheet-serial-${slot}`}
            style={{ ...adminInputStyle, background: "rgba(0,0,0,0.04)", color: "rgba(0,0,0,0.6)", cursor: "default" }}
          />
        </MiniField>
      )}

      {isTestStream ? (
        <MiniField label="HLS stream URL (.m3u8)">
          <input
            value={streamUrl} onChange={(e) => setStreamUrl(e.target.value)}
            placeholder="https://.../playlist.m3u8" data-testid={`unit-camera-stream-url-${slot}`} style={adminInputStyle}
          />
        </MiniField>
      ) : isRtsp ? (
        <>
          <MiniField label="RTSP URL">
            <input
              value={rtspUrl} onChange={(e) => setRtspUrl(e.target.value)}
              placeholder="rtsp://camera-host:554/stream1" data-testid={`unit-camera-rtsp-url-${slot}`} style={adminInputStyle}
            />
          </MiniField>

          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
            <MiniField label="Username">
              <input value={rtspUsername} onChange={(e) => setRtspUsername(e.target.value)} data-testid={`unit-camera-rtsp-username-${slot}`} style={adminInputStyle} />
            </MiniField>
            <MiniField label={camera && camera.has_rtsp_password ? "Password (leave blank to keep)" : "Password"}>
              <input type="password" value={rtspPassword} onChange={(e) => setRtspPassword(e.target.value)} data-testid={`unit-camera-rtsp-password-${slot}`} style={adminInputStyle} />
            </MiniField>
          </div>

          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
            <MiniField label="Transport">
              <select
                value={rtspTransport} onChange={(e) => setRtspTransport(e.target.value)}
                data-testid={`unit-camera-rtsp-transport-${slot}`} style={adminSelectStyle}
              >
                <option value="tcp">TCP</option>
                <option value="udp">UDP</option>
              </select>
            </MiniField>
            <MiniField label="Stream">
              <select
                value={rtspStreamProfile} onChange={(e) => setRtspStreamProfile(e.target.value)}
                data-testid={`unit-camera-rtsp-profile-${slot}`} style={adminSelectStyle}
              >
                <option value="main">Main stream</option>
                <option value="sub">Sub stream</option>
              </select>
            </MiniField>
          </div>

          <div style={{ marginBottom: 20 }}>
            <button
              type="button" onClick={handleTestRtsp} disabled={rtspTesting || !rtspUrl}
              data-testid={`unit-camera-rtsp-test-${slot}`}
              style={{
                background: "none", border: "1px solid rgba(0,0,0,0.35)", color: "#1A1712",
                cursor: (rtspTesting || !rtspUrl) ? "default" : "pointer",
                opacity: (rtspTesting || !rtspUrl) ? 0.5 : 1,
                padding: "9px 16px", fontFamily: "var(--font-body)", fontSize: 11,
                fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase",
              }}
            >{rtspTesting ? "Testing\u2026" : "Test connection"}</button>

            {rtspTestResult && <UM_RtspTestWindow result={rtspTestResult} onClose={() => setRtspTestResult(null)} />}
          </div>
        </>
      ) : (
        <>
          {ajaxCandidates.length > 0 && (
            <>
              <MiniField label={ajaxCompanyId ? "Ajax video device" : "Ajax video device (optional — prefills the label)"}>
                <select
                  value={ajaxVideoEdgeId} onChange={(e) => applyAjaxCandidate(e.target.value)}
                  data-testid={`unit-camera-ajax-select-${slot}`} style={adminSelectStyle}
                >
                  <option value="">None</option>
                  {ajaxCandidates.map((d) => (
                    <option key={d.videoEdgeId} value={d.videoEdgeId}>{d.name}{d.spaceName ? ` — ${d.spaceName}` : ""}</option>
                  ))}
                </select>
              </MiniField>

              {/* Phase 1: only offered when this unit's hub has a live
                  Ajax connection to provision against (ajaxCompanyId) --
                  otherwise the picker above still works as a plain
                  label-prefill like before, same as when ajaxCandidates
                  is empty entirely. */}
              {canAutoConnect && (
                <div style={{ marginBottom: 20 }}>
                  {autoConnectError && (
                    <div style={{
                      background: "rgba(190,40,40,0.08)", border: "1px solid rgba(190,40,40,0.3)",
                      color: "#8a1f1f", padding: "8px 12px", marginBottom: 10,
                      fontFamily: "var(--font-body)", fontSize: 11.5, lineHeight: 1.5,
                    }}>{autoConnectError}</div>
                  )}
                  <button
                    type="button" onClick={handleAutoConnect} disabled={autoConnecting}
                    data-testid={`unit-camera-ajax-auto-connect-${slot}`}
                    style={{
                      background: "#1A1712", border: "1px solid #1A1712", color: "#fff",
                      cursor: autoConnecting ? "default" : "pointer", opacity: autoConnecting ? 0.6 : 1,
                      padding: "9px 16px", fontFamily: "var(--font-body)", fontSize: 11,
                      fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase",
                    }}
                  >{autoConnecting ? "Connecting\u2026" : "Connect automatically"}</button>
                  <div style={{ fontFamily: "var(--font-body)", fontSize: 11, color: "rgba(0,0,0,0.45)", marginTop: 8, lineHeight: 1.5 }}>
                    Creates a Solo-managed ONVIF login on this camera via the Ajax API and fills in its host, port,
                    username and password below automatically — no typing required.
                  </div>
                </div>
              )}
            </>
          )}

          <div style={{ display: "grid", gridTemplateColumns: "1fr 120px", gap: 12 }}>
            <MiniField label="Host / IP address">
              <input value={host} onChange={(e) => setHost(e.target.value)} placeholder="82.14.xx.xx or camera.ddns.net" data-testid={`unit-camera-host-${slot}`} style={adminInputStyle} />
            </MiniField>
            <MiniField label="Port">
              <input value={port} onChange={(e) => setPort(e.target.value)} data-testid={`unit-camera-port-${slot}`} style={adminInputStyle} />
            </MiniField>
          </div>

          <label style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14, fontFamily: "var(--font-body)", fontSize: 12, color: "#3A3630", cursor: "pointer" }}>
            <input type="checkbox" checked={useHttps} onChange={(e) => setUseHttps(e.target.checked)} />
            Use HTTPS for the ONVIF connection
          </label>

          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
            <MiniField label="ONVIF username">
              <input value={username} onChange={(e) => setUsername(e.target.value)} data-testid={`unit-camera-username-${slot}`} style={adminInputStyle} />
            </MiniField>
            <MiniField label={camera && camera.has_password ? "ONVIF password (leave blank to keep)" : "ONVIF password"}>
              <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} data-testid={`unit-camera-password-${slot}`} style={adminInputStyle} />
            </MiniField>
          </div>

          <MiniField label="MJPEG path (optional — for a true live stream, not polling)">
            <input value={mjpegPath} onChange={(e) => setMjpegPath(e.target.value)} placeholder="/video.mjpg" data-testid={`unit-camera-mjpeg-${slot}`} style={adminInputStyle} />
          </MiniField>
        </>
      )}

      <label style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 20, fontFamily: "var(--font-body)", fontSize: 12, color: "#3A3630", cursor: "pointer" }}>
        <input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
        Enabled
      </label>

      <div style={{ display: "flex", justifyContent: "space-between", gap: 10 }}>
        {camera ? (
          <ActionButton danger onClick={handleRemove} disabled={saving} testId={`unit-camera-remove-${slot}`}>Remove</ActionButton>
        ) : <span />}
        <div style={{ display: "flex", gap: 10 }}>
          <button
            type="button" onClick={onCancel}
            style={{ background: "none", border: "1px solid rgba(0,0,0,0.25)", color: "rgba(0,0,0,0.7)", cursor: "pointer", padding: "10px 18px", fontFamily: "var(--font-body)", fontSize: 11.5, letterSpacing: "0.1em", textTransform: "uppercase" }}
          >Cancel</button>
          <ActionButton onClick={handleSave} disabled={saving || !canSave} testId={`unit-camera-save-${slot}`}>{saving ? "Saving\u2026" : "Save"}</ActionButton>
        </div>
      </div>
    </ModalShell>
  );
}

// ─────────────────────────── Devices tab ───────────────────────────
// Mission Control rearchitecture, Phase 2 (§8 Devices / §9 Device
// Profiles-Source-of-Truth / §14 Dynamic Device Navigation / §15 Device
// Status Logic). "The Devices menu must be dynamically generated from
// the Device Profile assigned within Admin. It must not be hard-coded."
// (§8) — this tab fetches the same {device_profile, slots} shape Phase
// 1's admin-device-profiles-page.jsx already consumes (mirrored for
// resellers by the new GET /assets/:id/device-mappings route in
// routes/portal.ts), groups the enabled slots by device_category, and
// renders a left-hand group nav (Victron / Ajax / Teltonika Router /
// Additional Devices / EFOY — only groups that actually have slots
// appear, per §14's "Devices not included in the Device Profile must
// not appear") plus a right-hand list of that group's device rows.
//
// Full per-device drill-down pages (Standard Device Page, §17-37) are
// Phase 3 scope — for now each row surfaces the §13 mapping fields
// (display name, external/serial/IMEI/MAC ids) and a normalized §15
// status derived from whether a physical device has actually been
// mapped to the slot yet (UM_deviceSlotStatus below); this is the
// "Connected / Disconnected / Unknown" tier of §15, not yet reading
// live vendor telemetry per-slot (that requires the §60 Normalised
// Solo Device Model, a larger Phase 3/4 undertaking).
function UM_deviceMappingsUrl(unitId, isAdmin) {
  return isAdmin ? `/api/admin/assets/${unitId}/device-mappings` : `/api/portal/assets/${unitId}/device-mappings`;
}

// device_profile_slots.api_integration is stored lowercase in D1
// (migrations/0042: "victron | ajax | teltonika | efoy | none" -- the
// column's own comment) since that's what the backend's own
// if/else-if branching in UM_resolveLiveDevice above compares against
// case-sensitively -- changing the STORED value would risk silently
// breaking that matching. Customer's explicit ask this session: "the
// API Integration...i need the uniform to be Victron and Ajax with a
// caps at the front" -- i.e. a display-only capitalization fix, not a
// data change. This is the one place that raw value is ever shown to a
// user (the Devices tab's per-slot "API Integration" row) rather than
// compared against in code.
function UM_capitalizeApiIntegration(value) {
  const s = String(value || "");
  return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}

// Human "N sec/min/hr/day ago" formatter for §16 freshness fields
// (Last API Update / Last Device Communication). Every vendor table
// stores these as plain SQLite `datetime('now')`-style UTC strings with
// no timezone suffix (see e.g. migrations/0019/0020/0033) — appending
// "Z" before parsing is the same trick UM_EfoyTab already uses for
// efoy.last_synced_at, applied generically here.
function UM_timeAgo(isoString) {
  if (!isoString) return null;
  const then = new Date(/Z$|[+-]\d\d:?\d\d$/.test(isoString) ? isoString : `${isoString}Z`).getTime();
  if (Number.isNaN(then)) return null;
  const seconds = Math.max(0, Math.round((Date.now() - then) / 1000));
  if (seconds < 60) return `${seconds} sec ago`;
  const minutes = Math.round(seconds / 60);
  if (minutes < 60) return `${minutes} min ago`;
  const hours = Math.round(minutes / 60);
  if (hours < 24) return `${hours} hr ago`;
  const days = Math.round(hours / 24);
  return `${days} day${days === 1 ? "" : "s"} ago`;
}

// §16 "stale" threshold — a device that hasn't reported for longer than
// this is treated as Degraded/Unknown even if its last known reading
// looked healthy, per §16's "Stale data beyond threshold moves the
// device to Degraded/Unknown" rule. 10 minutes comfortably covers every
// vendor's own poll cadence today (Ajax hubs >=30s, Victron/Teltonika/
// EFOY Cloud polls are all well under this) while still catching a
// genuinely stopped feed quickly.
const UM_STALE_THRESHOLD_MS = 10 * 60 * 1000;

function UM_isStale(isoString) {
  if (!isoString) return true;
  const then = new Date(/Z$|[+-]\d\d:?\d\d$/.test(isoString) ? isoString : `${isoString}Z`).getTime();
  if (Number.isNaN(then)) return true;
  return Date.now() - then > UM_STALE_THRESHOLD_MS;
}

// §15 Device Status Logic / §16 API Data Freshness — resolves one
// Devices-tab slot (profile template + this asset's mapping, from
// GET .../device-mappings) against the live vendor caches already
// loaded for this unit (victron/ajax/teltonika/efoy — the same props
// UM_GeneralTab uses) to produce one of the five §15 states:
//   Not in Device Profile  -> caller never renders this slot at all
//                             (UM_DevicesTab only lists enabled slots)
//   Unknown                -> no mapping yet, or mapped with no way to
//                             confirm live state (api_integration "none")
//   Disconnected            -> mapped + API-integrated, but no matching
//                             live record found, or that record reports
//                             itself offline
//   Warning                 -> live record found and communicating, but
//                             its data is stale beyond §16's threshold
//   Fault                   -> live record reports an active error
//   Connected                -> live record found, fresh, no fault
// Returns { label, tone, detail, lastSeen } — `detail` is the §16
// root-cause line (Device Offline / API Unavailable / Stale Data /
// etc.), `lastSeen` is the formatted Last API Update string for display.
// Resolves the live vendor record a device-profile slot's mapping
// should match, shared by both UM_normalizeDeviceStatus (§15/16) and
// Phase 3's Standard Device Page (§17, UM_DeviceDetailPage) so the two
// can never disagree about which record represents a given slot.
// Returns { live, onlineFlag, faultText, kind } — `kind` tells the
// detail page which field-spec/controls to render (it can't always be
// inferred from api_integration alone, e.g. Ajax hub vs Ajax device).
//
// GlobalLink 520 special case: VRM's diagnostics endpoint explicitly
// EXCLUDES the "Gateway" device (see lib/victron.ts's
// getVrmInstallationDevices, `r.Device !== "Gateway"`), so a GlobalLink
// slot never has a matching row in victron.devices — it never can. The
// GlobalLink genuinely IS the comms gateway for the installation (§19),
// so its live record is the victron_installations row itself
// (victron.installation — has gateway_identifier/installation_name/
// last_synced_at), not a victron_devices row. Without this special
// case a linked GlobalLink slot would incorrectly show "Disconnected /
// API Unavailable" forever, even on a fully-connected Victron system.
//
// Ajax serial-number matching (added alongside the asset-import
// device-mapping auto-population feature): Ajax's own device `id`
// (e.g. "30BAE3A3") IS the physical serial number printed on the
// device -- confirmed directly from Ajax's own support docs (see
// lib/ajax.ts's NormalizedAjaxDevice comment). The asset-import
// pipeline writes Camera/Relay/Sounder/Tilt-Tamper serials from the
// spreadsheet into the mapping's `serial_number` field, NOT
// `external_device_id` (there is no admin UI to manually set
// external_device_id for these slots, and there doesn't need to be --
// the spreadsheet's serial already IS Ajax's own device id). Without
// also trying serial_number here, every one of those slots would be
// stuck on "Unknown / Not linked yet" forever despite having live Ajax
// data already cached, purely because nothing connected the two
// fields. external_device_id is still tried FIRST (an admin who
// manually links a slot via the existing UI always wins), then
// serial_number is tried as a fallback identity match.
//
// Victron has NO equivalent -- VRM's only sub-device identifier
// (vrm_device_key, e.g. "Solar Charger:102") is scoped to installation
// + discovery order and has no relationship whatsoever to a physical
// serial number, so a Victron slot's imported serial_number can only
// ever be a reference/display value; live-linking Victron sub-devices
// still requires the existing manual external_device_id pick.
//
// CORRECTED (this session): the plain exact/case-insensitive comparison
// below between an Ajax slot's imported serial_number/external_device_id
// and a live device's ajax_device_id was WRONG -- confirmed live against
// real production data (unit 337's real Ajax hub 003E9BAA + its real
// relay/sounder/tamper devices) that the customer's spreadsheet encodes
// each device's real 8-hex-char Ajax id as a PREFIX of a longer,
// punctuated string (e.g. relay "315efab6122" vs. the live device's own
// id "315EFAB6" -- NOT equal under exact OR case-insensitive-exact
// comparison, but equal once both sides are normalized to "strip
// non-hex chars, uppercase, take first 8"). This mirrors the backend's
// normalizeAjaxDeviceId (src/lib/ajax.ts) -- duplicated here in plain
// JS since this frontend file has no access to that TypeScript module.
function UM_normalizeAjaxId(value) {
  if (!value) return null;
  const hexOnly = String(value).replace(/[^0-9a-fA-F]/g, "").toUpperCase();
  if (hexOnly.length < 8) return null;
  return hexOnly.slice(0, 8);
}

function UM_resolveLiveDevice(slot, vendorData) {
  const { victron, ajax, teltonika, efoy } = vendorData || {};

  if (!slot.api_integration || slot.api_integration === "none") {
    return { live: null, onlineFlag: null, faultText: null, kind: null };
  }

  let live = null;
  let onlineFlag = null; // true/false/null (null = vendor has no explicit online flag)
  let faultText = null;
  let kind = null;

  if (slot.api_integration === "victron" && victron) {
    const isGlobalLinkSlot = /global\s*link/i.test(slot.device_type || "") || /global\s*link/i.test(slot.display_name || "");
    if (isGlobalLinkSlot) {
      live = victron.installation || null;
      kind = "victron_gateway";
      // No online flag exposed for the gateway itself — freshness (last_synced_at) is the only signal.
    } else {
      const devices = victron.devices || [];
      live = devices.find((d) => d.vrm_device_key === slot.external_device_id) || null;
      if (live) kind = "victron_device";
      // Victron sub-devices have no per-device online flag today either — freshness is the only signal.
    }
  } else if (slot.api_integration === "ajax" && ajax) {
    const isHubSlot = /hub/i.test(slot.device_type || "") || /hub/i.test(slot.display_name || "");
    if (isHubSlot && ajax.hub) {
      live = ajax.hub;
      onlineFlag = !!live.online;
      kind = "ajax_hub";
    } else {
      const devices = ajax.devices || [];
      // external_device_id (an explicit manual link) always wins if
      // present; otherwise fall back to matching the imported
      // serial_number against Ajax's own device id, since for Ajax
      // those are literally the same physical identifier (see header
      // comment above). Both comparisons go through UM_normalizeAjaxId
      // (first-8-hex-chars normalization) rather than plain string
      // equality -- see this function's header comment for why a naive
      // exact/case-insensitive compare silently fails to match real
      // spreadsheet data.
      const externalIdKey = UM_normalizeAjaxId(slot.external_device_id);
      const serialKey = UM_normalizeAjaxId(slot.serial_number);
      live =
        (externalIdKey && devices.find((d) => UM_normalizeAjaxId(d.ajax_device_id) === externalIdKey)) ||
        (serialKey && devices.find((d) => UM_normalizeAjaxId(d.ajax_device_id) === serialKey)) ||
        null;
      if (live) { onlineFlag = !!live.online; kind = "ajax_device"; }
    }
  } else if (slot.api_integration === "teltonika" && teltonika) {
    const matches =
      (slot.external_device_id && String(teltonika.rms_device_id) === String(slot.external_device_id)) ||
      (slot.imei && teltonika.imei && String(teltonika.imei) === String(slot.imei)) ||
      (slot.mac && teltonika.mac && String(teltonika.mac).toLowerCase() === String(slot.mac).toLowerCase()) ||
      (!slot.external_device_id && !slot.imei && !slot.mac); // single router per unit — fall back to "it's this one"
    if (matches) {
      live = teltonika;
      onlineFlag = String(teltonika.connection_state || "").toLowerCase() === "offline" ? false : !!teltonika.status;
      kind = "teltonika";
    }
  } else if (slot.api_integration === "efoy" && efoy) {
    // Single cached row per unit (no sub-device list) — this slot's
    // mapping IS the unit's one EFOY device once linked.
    live = efoy;
    onlineFlag = !!efoy.connected;
    faultText = efoy.active_error || null;
    kind = "efoy";
  }

  return { live, onlineFlag, faultText, kind };
}

// §15 Device Status Logic / §16 API Data Freshness — resolves one
// Devices-tab slot against UM_resolveLiveDevice's match to produce one
// of the five §15 states. See that function's own header for the
// resolution rules (incl. the GlobalLink gateway special case).
//
// mapping_id (an explicit asset_device_mappings row) is only ever
// consulted as a FALLBACK signal when UM_resolveLiveDevice comes up
// empty — never as an upfront gate. Several categories (Ajax Hub,
// Teltonika Router, EFOY, Victron GlobalLink) resolve their live
// vendor record via a "one per unit" match that needs no mapping row
// at all (see UM_resolveLiveDevice), and there is no admin UI to ever
// create a mapping for those categories today. Gating on mapping_id
// first made every one of those slots permanently stuck on "Unknown /
// Not linked yet" even while showing fully live telemetry underneath —
// a real bug, not a "no vendor data" situation. Checking live data
// FIRST fixes that, while still preserving mapping_id as the only
// signal available for manual (api_integration "none") slots, and as
// the "this was explicitly linked but the API can't find it" signal
// (Disconnected, not Unknown) for the categories that DO require an
// explicit external_device_id match (Ajax sub-devices, Victron non-
// gateway devices).
function UM_normalizeDeviceStatus(slot, vendorData) {
  if (!slot.api_integration || slot.api_integration === "none") {
    return slot.mapping_id
      ? { label: "Connected", tone: "green", detail: "Manual (no API)", lastSeen: null }
      : { label: "Unknown", tone: "grey", detail: "Not linked yet", lastSeen: null };
  }

  const { live, onlineFlag, faultText } = UM_resolveLiveDevice(slot, vendorData);

  if (!live) {
    return slot.mapping_id
      ? { label: "Disconnected", tone: "red", detail: "API Unavailable", lastSeen: null }
      : { label: "Unknown", tone: "grey", detail: "Not linked yet", lastSeen: null };
  }
  if (onlineFlag === false) {
    return { label: "Disconnected", tone: "red", detail: "Device Offline", lastSeen: UM_timeAgo(live.last_synced_at) };
  }
  if (faultText) {
    return { label: "Fault", tone: "red", detail: faultText, lastSeen: UM_timeAgo(live.last_synced_at) };
  }
  if (UM_isStale(live.last_synced_at)) {
    return { label: "Warning", tone: "amber", detail: "Stale Data", lastSeen: UM_timeAgo(live.last_synced_at) };
  }
  return { label: "Connected", tone: "green", detail: null, lastSeen: UM_timeAgo(live.last_synced_at) };
}

function UM_DevicesTab({
  unit, isAdmin, victron, ajax, teltonika, efoy, categoryTarget, onCategoryTargetConsumed,
  relayUrlBase, hubUrlBase, onRelaysChanged, onHubStateChanged, onRelayStateChanged, efoyEnabledBusy, onToggleEfoyEnabled,
}) {
  const [state, setState] = useState({ status: "loading", devicesProfile: null, slots: [], error: "" });
  // Selection is either a Device-Profile category name (e.g. "Victron")
  // OR one of the two static sentinels below, for EFOY / Speakers --
  // both of those are gated on unit.has_efoy / unit.has_speakers, NOT on
  // any device_profile_slots row, so they must stay selectable in this
  // tab's left-nav even when the asset has no Device Profile assigned at
  // all (top-nav consolidation, this session -- "Efoy and Speakers need
  // to go under Devices"). Kept as their own tab-scoped nav entries
  // rather than folded into `groups` below because they don't share that
  // array's data shape (no slot_id, no device_category row).
  const [activeSection, setActiveSection] = useState(null);
  // Phase 3 (§17 Standard Device Page): which slot's own drill-down
  // detail page is open, if any. Cleared on unit/category switch so
  // navigating away and back to Devices always lands on the group list,
  // never stuck deep in a stale device page.
  const [openSlotId, setOpenSlotId] = useState(null);

  const UM_EFOY_SECTION = "__efoy__";
  const UM_SPEAKERS_SECTION = "__speakers__";

  useEffect(() => {
    setState({ status: "loading", devicesProfile: null, slots: [], error: "" });
    setOpenSlotId(null);
    fetch(UM_deviceMappingsUrl(unit.id, isAdmin), { credentials: "same-origin" })
      .then(async (r) => {
        const data = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(data.error || "Couldn't load devices.");
        return data;
      })
      .then((data) => {
        setState({ status: "ready", devicesProfile: data.device_profile || null, slots: data.slots || [], error: "" });
      })
      .catch((err) => {
        setState({ status: "error", devicesProfile: null, slots: [], error: (err && err.message) || "Couldn't load devices." });
      });
  }, [unit.id, isAdmin]);

  // §5 status-bar click-through: land on the requested category group
  // (e.g. clicking "Ajax Connected" jumps straight to the Ajax group)
  // once the slot list has loaded, then tell the parent to clear the
  // target so a later plain "Devices" tab click doesn't re-force it.
  useEffect(() => {
    if (categoryTarget && state.status === "ready") {
      setActiveSection(categoryTarget);
      setOpenSlotId(null);
      onCategoryTargetConsumed && onCategoryTargetConsumed();
    }
  }, [categoryTarget, state.status]); // eslint-disable-line react-hooks/exhaustive-deps

  if (state.status === "loading") return <UM_EmptyNote>Loading devices&hellip;</UM_EmptyNote>;
  if (state.status === "error") return <UM_EmptyNote>{state.error}</UM_EmptyNote>;

  // Group enabled slots by device_category, in the fixed §14 display
  // order, dropping any category with zero slots for this profile. Empty
  // (no Device Profile at all) simply yields zero groups -- that no
  // longer blocks the whole tab, since EFOY/Speakers below don't depend
  // on it.
  const groups = state.devicesProfile
    ? UM_DEVICE_CATEGORY_ORDER
        .map((category) => ({
          category,
          label: UM_DEVICE_CATEGORY_LABELS[category] || category,
          slots: state.slots.filter((s) => s.device_category === category),
        }))
        .filter((g) => g.slots.length > 0)
    : [];

  const showEfoy = !!unit.has_efoy;
  const showSpeakers = !!unit.has_speakers;

  // Resolve the default/active selection across both the dynamic groups
  // and the two static sections, in that display order.
  const currentSection = groups.find((g) => g.category === activeSection)
    ? activeSection
    : activeSection === UM_EFOY_SECTION && showEfoy
      ? UM_EFOY_SECTION
      : activeSection === UM_SPEAKERS_SECTION && showSpeakers
        ? UM_SPEAKERS_SECTION
        : groups[0]
          ? groups[0].category
          : showEfoy
            ? UM_EFOY_SECTION
            : showSpeakers
              ? UM_SPEAKERS_SECTION
              : null;
  const currentGroup = groups.find((g) => g.category === currentSection) || null;

  if (!currentSection) {
    return (
      <div style={{
        background: "rgba(0,0,0,0.02)", border: "1px solid rgba(0,0,0,0.1)",
        padding: "40px 32px", textAlign: "center",
      }}>
        <div style={{ fontFamily: "var(--font-display)", fontWeight: 700, fontSize: 18, textTransform: "uppercase", color: "rgba(0,0,0,0.8)", marginBottom: 8 }}>
          No Device Profile assigned
        </div>
        <div style={{ fontFamily: "var(--font-body)", fontSize: 13.5, color: "rgba(0,0,0,0.5)" }}>
          {isAdmin
            ? "Assign a Device Profile to this asset in Admin \u2192 Devices to populate this section."
            : "This asset has no Device Profile assigned yet \u2014 contact Solo staff."}
        </div>
      </div>
    );
  }

  const openSlot = openSlotId ? state.slots.find((s) => s.slot_id === openSlotId) : null;

  // Phase 3 (§17): a slot is open -- show its Standard Device Page in
  // place of the category list, with a "back to <category>" affordance.
  // The vendor data props threaded straight through unchanged; the
  // detail page resolves its own live record via UM_resolveLiveDevice,
  // same helper the list-level status badges below use.
  if (openSlot) {
    return (
      <UM_DeviceDetailPage
        unit={unit} slot={openSlot} isAdmin={isAdmin}
        victron={victron} ajax={ajax} teltonika={teltonika} efoy={efoy}
        relayUrlBase={relayUrlBase} hubUrlBase={hubUrlBase} onRelaysChanged={onRelaysChanged}
        onHubStateChanged={onHubStateChanged} onRelayStateChanged={onRelayStateChanged}
        onBack={() => setOpenSlotId(null)}
      />
    );
  }

  const navButtonStyle = (active) => ({
    display: "block", width: "100%", textAlign: "left", background: active ? "rgba(180,110,0,0.1)" : "none",
    border: "none", borderLeft: `2px solid ${active ? "rgba(180,110,0,0.85)" : "transparent"}`,
    cursor: "pointer", padding: "10px 12px", marginBottom: 2,
    fontFamily: "var(--font-body)", fontSize: 12.5, fontWeight: active ? 600 : 500,
    color: active ? "#000" : "rgba(0,0,0,0.6)",
  });

  return (
    <div style={{ display: "grid", gridTemplateColumns: "220px 1fr", gap: 24 }}>
      {/* Left: category group nav (§14 worked example / §58 layout), plus
          the two static EFOY / Speakers entries (top-nav consolidation,
          this session) -- shown whenever the asset's product profile
          includes them, independent of Device Profile assignment. */}
      <div style={{ borderRight: "1px solid rgba(0,0,0,0.1)", paddingRight: 16 }}>
        {state.devicesProfile && (
          <div style={{
            fontFamily: "var(--font-body)", fontSize: 10, fontWeight: 600, letterSpacing: "0.14em",
            textTransform: "uppercase", color: "rgba(0,0,0,0.4)", marginBottom: 12,
          }}>
            {state.devicesProfile.name}
          </div>
        )}
        {groups.map((g) => (
          <button
            key={g.category} type="button" onClick={() => { setActiveSection(g.category); setOpenSlotId(null); }}
            data-testid={`unit-devices-group-${g.category.toLowerCase()}`}
            style={navButtonStyle(currentSection === g.category)}
          >
            {g.label} <span style={{ opacity: 0.5 }}>({g.slots.length})</span>
          </button>
        ))}
        {(showEfoy || showSpeakers) && groups.length > 0 && (
          <div style={{ borderTop: "1px solid rgba(0,0,0,0.08)", margin: "10px 0" }} />
        )}
        {showEfoy && (
          <button
            key="efoy" type="button" onClick={() => { setActiveSection(UM_EFOY_SECTION); setOpenSlotId(null); }}
            data-testid="unit-devices-group-efoy"
            style={navButtonStyle(currentSection === UM_EFOY_SECTION)}
          >
            EFOY
          </button>
        )}
        {showSpeakers && (
          <button
            key="speakers" type="button" onClick={() => { setActiveSection(UM_SPEAKERS_SECTION); setOpenSlotId(null); }}
            data-testid="unit-devices-group-speakers"
            style={navButtonStyle(currentSection === UM_SPEAKERS_SECTION)}
          >
            Speakers
          </button>
        )}
      </div>

      {/* Right: this section's content. EFOY / Speakers render their own
          unchanged tab components; everything else is this category's
          device rows, each opening its own Standard Device Page (§17,
          Phase 3) on click. */}
      {currentSection === UM_EFOY_SECTION ? (
        <UM_EfoyTab unit={unit} efoy={efoy} isAdmin={isAdmin} enabledBusy={efoyEnabledBusy} onToggleEnabled={onToggleEfoyEnabled} />
      ) : currentSection === UM_SPEAKERS_SECTION ? (
        <UM_SpeakersTab unit={unit} isAdmin={isAdmin} />
      ) : (
        <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
          {currentGroup.slots.map((slot) => {
            const status = UM_normalizeDeviceStatus(slot, { victron, ajax, teltonika, efoy });
            const isAjaxOrVictron = currentGroup.category === "Ajax" || currentGroup.category === "Victron";
            return (
              <button
                key={slot.slot_id} type="button" onClick={() => setOpenSlotId(slot.slot_id)}
                data-testid={`unit-devices-slot-${slot.slot_id}`}
                style={{ all: "unset", cursor: "pointer", display: "block" }}
              >
                <UM_Card title={slot.display_name || slot.device_type || slot.slot_key} badge={status}>
                  <UM_Row label="Device Type" value={slot.device_type || "\u2014"} />
                  <UM_Row label="API Integration" value={slot.api_integration === "none" ? "Manual (no API)" : UM_capitalizeApiIntegration(slot.api_integration)} />
                  {/* Status Detail / External Device ID dropped for Ajax
                      and Victron slots WHEN BLANK (customer's explicit ask
                      this session: "remove the blank fields, as they are
                      not needed for Ajax devices") -- Status Detail is
                      only ever populated for a Fault/Warning/Disconnected
                      state (see UM_normalizeDeviceStatus, `detail: null`
                      on the healthy-Connected path), and External Device
                      ID is only populated when an admin has manually
                      linked this slot via the Devices-tab picker rather
                      than the serial-number auto-match Ajax slots get on
                      import (see UM_resolveLiveDevice's header comment) --
                      so both are legitimately blank on a normal, healthy,
                      auto-linked Ajax/Victron device and showing an em-dash
                      row for them is just noise, not missing data. Kept
                      unconditional for Teltonika/Other/manual slots where
                      a blank value is still meaningful (e.g. "not linked
                      yet"). */}
                  {(!isAjaxOrVictron || status.detail) && <UM_Row label="Status Detail" value={status.detail || "\u2014"} />}
                  {status.lastSeen && <UM_Row label="Last API Update" value={status.lastSeen} />}
                  {(() => {
                    const showExternalId = !isAjaxOrVictron || !!slot.external_device_id;
                    return (
                      <>
                        <UM_Row label="Serial Number" value={slot.serial_number || "\u2014"} last={isAjaxOrVictron && !showExternalId} />
                        {showExternalId && (
                          <UM_Row label="External Device ID" value={slot.external_device_id || "\u2014"} last={isAjaxOrVictron} />
                        )}
                      </>
                    );
                  })()}
                  {/* IMEI/MAC dropped for Ajax and Victron slots (customer's
                      explicit ask this session: "if there is no imei / mac
                      details for the ajax or victron devices pulled from
                      the API... remove the fields"). Confirmed neither
                      vendor's API exposes either value for ANY device type
                      -- lib/ajax.ts's NormalizedAjaxDevice/NormalizedAjaxHub
                      carry no imei/mac field at all, and Victron's VRM
                      diagnostics fields (lib/victron.ts) are diagnostic
                      codes only (SOC/V/I/ScW/etc), never imei/mac either.
                      The import pipeline (lib/assetImport.ts) never writes
                      `mac` for any Ajax or Victron slot, and only ever
                      writes Victron's `imei` from the SPREADSHEET (the
                      GlobalLink 520's IMEI column) -- not a live API call --
                      so these two rows were always either blank or a
                      spreadsheet value masquerading as a device-reported
                      one for every Ajax/Victron slot. Left in place for
                      Teltonika/Other, where they're genuine (Teltonika's
                      RMS API does return both -- see lib/teltonikaRms.ts's
                      RmsDeviceRecord -- and its own live MAC is shown
                      further down this page's Standard Device Page). */}
                  {!isAjaxOrVictron && <UM_Row label="IMEI" value={slot.imei || "\u2014"} />}
                  {!isAjaxOrVictron && <UM_Row label="MAC" value={slot.mac || "\u2014"} last />}
                </UM_Card>
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}


// ───────────────────── Phase 3: Standard Device Page ─────────────────────
// §17's fixed template, shared by every device category (§18-37):
//   DEVICE NAME [badge]
//   LIVE STATUS -- Key Telemetry / Warnings-Faults
//   CONTROLS
//   API STATUS -- API Connected / Last API Update / Command Capability
//   DEVICE INFORMATION -- External Device ID / Serial Number / Firmware / Last Communication
//
// Field-honesty rule this file follows throughout: only vendor fields
// this codebase has CONFIRMED the meaning of (see the Victron code list
// below, and the columns actually written by lib/telemetry.ts's
// loadUnitVendorData) get a friendly label. Every vendor cache also
// carries additional raw fields (victron_devices.cached_fields_json,
// ajax_devices.state_json) whose semantics haven't been verified against
// this customer's real account/API docs -- rather than guessing a label
// and risking a wrong one, those are dumped verbatim under "Additional
// Diagnostics" using the vendor's own raw code as the label. Controls
// are the same honesty rule applied to actions: only rendered as live
// buttons where a real backend route already exists (Ajax relay toggle,
// Ajax hub arm/disarm); everywhere else (EFOY start/stop, Teltonika
// reboot, Ajax sounder test/activate/stop, Ajax LED "Active Pulse")
// renders a plainly-labelled "Not yet available" note instead of a
// fake/disabled button that implies a working feature.

// Victron diagnostic codes verified against the customer's real VRM
// account this build (see lib/victron.ts / migrations/0020's header) --
// keyed by victron_devices.device_role. Anything else present in a
// device's cached_fields_json is real data VRM sent, just not yet
// confirmed semantically, so it's shown raw (see UM_RawFieldsCard) rather
// than guessed at.
const UM_VICTRON_KNOWN_CODES = {
  shunt: [
    { code: "SOC", label: "Battery State of Charge", suffix: "%" },
    { code: "V", label: "Battery Voltage", suffix: " V" },
    { code: "I", label: "Battery Current", suffix: " A" },
  ],
  mppt_1: [
    { code: "ScW", label: "Solar Power", suffix: " W" },
    { code: "ScI", label: "Charger Output Current", suffix: " A" },
    { code: "ScS", label: "Charge State", suffix: "" },
  ],
  mppt_2: [
    { code: "ScW", label: "Solar Power", suffix: " W" },
    { code: "ScI", label: "Charger Output Current", suffix: " A" },
    { code: "ScS", label: "Charge State", suffix: "" },
  ],
  mppt_3: [
    { code: "ScW", label: "Solar Power", suffix: " W" },
    { code: "ScI", label: "Charger Output Current", suffix: " A" },
    { code: "ScS", label: "Charge State", suffix: "" },
  ],
  mains_charger: [
    { code: "c0V", label: "Output Voltage", suffix: " V" },
    { code: "c0I", label: "Output Current", suffix: " A" },
    { code: "cSt", label: "Charge Stage", suffix: "" },
  ],
};

// Renders whatever raw code->formattedValue pairs a vendor cache holds
// that AREN'T already covered by a known/labelled row above -- honest
// pass-through of real synced data instead of silently dropping it.
function UM_RawFieldsCard({ fields, knownCodes }) {
  const entries = Object.entries(fields || {}).filter(([code]) => !knownCodes.includes(code));
  if (entries.length === 0) return null;
  return (
    <UM_Card title="Additional Diagnostics">
      <div style={{ fontFamily: "var(--font-body)", fontSize: 10.5, color: "#8A8474", marginBottom: 8 }}>
        Raw codes synced from the vendor API that this page doesn't yet have a labelled field for.
      </div>
      {entries.map(([code, value], i) => (
        <UM_Row key={code} label={code} value={String(value)} last={i === entries.length - 1} />
      ))}
    </UM_Card>
  );
}

// §16: separates device-level "Last Communication" from poll-level
// "Last API Update" where the vendor cache actually distinguishes them
// (only Teltonika does today -- last_connection_at vs last_synced_at);
// every other vendor cache only stores the poll timestamp, so the two
// values are honestly identical until a device-level timestamp exists.
function UM_commTimes(live) {
  if (!live) return { lastComm: null, lastApi: null };
  const lastApi = UM_timeAgo(live.last_synced_at);
  const lastComm = live.last_connection_at ? UM_timeAgo(live.last_connection_at) : lastApi;
  return { lastComm, lastApi };
}

// A control that has no real backend route yet -- rendered as an
// honestly-disabled affordance rather than a working-looking button, so
// the operator never mistakes it for a live command. See file header.
function UM_NotYetAvailableControl({ label, note }) {
  return (
    <div style={{
      display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12,
      padding: "8px 0", borderBottom: "1px solid #EEE9DD",
    }}>
      <div>
        <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#1A1712" }}>{label}</div>
        {note && <div style={{ fontFamily: "var(--font-body)", fontSize: 10.5, color: "#8A8474", marginTop: 2 }}>{note}</div>}
      </div>
      <span style={{
        fontFamily: "var(--font-body)", fontSize: 9.5, fontWeight: 600, letterSpacing: "0.06em",
        textTransform: "uppercase", color: "#B4740B", background: "#FBEFD6", padding: "4px 8px", borderRadius: 3,
        whiteSpace: "nowrap",
      }}>Not yet available</span>
    </div>
  );
}

// Send SMS to Router SIM (customer instruction, Sept 2026: "We will
// need a service built into the admin area of the website to send a
// sms" / "the router's SIM") -- a genuine SMS to the router's own SIM
// card phone number (MSISDN) via Twilio, NOT an RMS-native command
// push. Backend: routes/admin-router-sms.ts's POST
// /api/admin/router-sms/units/:id/send (super_admin only) + GET
// /api/admin/router-sms/units/:id/log for this unit's recent history.
// Same fetch/busy-state pattern as UM_RelayRow/UM_HubArmRow above.
function UM_RouterSmsControl({ unitId, msisdn }) {
  const [message, setMessage] = useState("");
  const [busy, setBusy] = useState(false);
  const [result, setResult] = useState(null); // { ok, error } | null
  const [log, setLog] = useState([]);
  const [logLoaded, setLogLoaded] = useState(false);
  const [inbound, setInbound] = useState([]);
  const [inboundLoaded, setInboundLoaded] = useState(false);

  const loadLog = () => {
    fetch(`/api/admin/router-sms/units/${unitId}/log`, { credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then((data) => { setLog(data.log || []); setLogLoaded(true); })
      .catch(() => setLogLoaded(true));
  };

  // Replies from the router itself land here via Twilio's inbound-SMS
  // webhook (routes/router-sms-webhook.ts) -- separate from `log` above,
  // which is outbound-only. See migrations/0047_router_sms_replies.sql.
  const loadInbound = () => {
    fetch(`/api/admin/router-sms/units/${unitId}/inbound`, { credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then((data) => { setInbound(data.inbound || []); setInboundLoaded(true); })
      .catch(() => setInboundLoaded(true));
  };

  useEffect(loadLog, []); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(loadInbound, []); // eslint-disable-line react-hooks/exhaustive-deps

  const handleSend = async (e) => {
    e.preventDefault();
    const text = message.trim();
    if (!text || busy) return;
    setBusy(true);
    setResult(null);
    try {
      const res = await fetch(`/api/admin/router-sms/units/${unitId}/send`, {
        method: "POST", credentials: "same-origin",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: text }),
      });
      const data = await res.json().catch(() => ({}));
      if (res.ok) {
        setResult({ ok: true });
        setMessage("");
        loadLog();
      } else {
        setResult({ ok: false, error: data.error || "Failed to send SMS." });
      }
    } catch {
      setResult({ ok: false, error: "Couldn't reach the server. Check your connection and try again." });
    }
    setBusy(false);
  };

  return (
    <div>
      <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#1A1712", fontWeight: 600, marginBottom: 6 }}>
        Send SMS to Router SIM
      </div>
      <div style={{ fontFamily: "var(--font-body)", fontSize: 10.5, color: "#8A8474", marginBottom: 10 }}>
        {msisdn ? `To: ${msisdn}` : "No SIM phone number (MSISDN) recorded for this unit yet -- add it via the router-identity fields before sending."}
      </div>

      <form onSubmit={handleSend} style={{ display: "flex", gap: 8, marginBottom: 10 }}>
        <input
          type="text" value={message} onChange={(e) => setMessage(e.target.value)}
          placeholder="Message text…" disabled={busy || !msisdn}
          data-testid="unit-router-sms-input"
          style={{
            flex: 1, boxSizing: "border-box", border: "1px solid #E2DCCB", background: "#FFFFFF",
            padding: "8px 10px", fontFamily: "var(--font-body)", fontSize: 12.5, color: "#1A1712",
          }}
        />
        <button
          type="submit" disabled={busy || !msisdn || !message.trim()}
          data-testid="unit-router-sms-send"
          style={{
            background: "#1A1712", color: "#fff", border: "none", padding: "8px 16px",
            cursor: (busy || !msisdn || !message.trim()) ? "not-allowed" : "pointer",
            opacity: (busy || !msisdn || !message.trim()) ? 0.5 : 1,
            fontFamily: "var(--font-body)", fontSize: 11.5, fontWeight: 600,
            letterSpacing: "0.06em", textTransform: "uppercase", whiteSpace: "nowrap",
          }}
        >{busy ? "Sending…" : "Send"}</button>
      </form>

      {result && (
        result.ok ? (
          <div style={{ fontFamily: "var(--font-body)", fontSize: 12, color: "#2C8C4C", marginBottom: 10 }}>Sent.</div>
        ) : (
          <div style={{ fontFamily: "var(--font-body)", fontSize: 12, color: "#B4382C", marginBottom: 10 }}>{result.error}</div>
        )
      )}

      {logLoaded && log.length > 0 && (
        <div style={{ marginTop: 6 }}>
          <div style={{ fontFamily: "var(--font-body)", fontSize: 10, letterSpacing: "0.06em", textTransform: "uppercase", color: "#8A8474", marginBottom: 6 }}>
            Recent messages
          </div>
          {log.map((row) => (
            <div key={row.id} style={{ borderBottom: "1px solid #EEE9DD", padding: "6px 0" }}>
              <div style={{ display: "flex", justifyContent: "space-between", gap: 10 }}>
                <span style={{ fontFamily: "var(--font-body)", fontSize: 12, color: "#1A1712" }}>{row.message}</span>
                <span style={{
                  fontFamily: "var(--font-body)", fontSize: 9.5, fontWeight: 600, letterSpacing: "0.05em",
                  textTransform: "uppercase", whiteSpace: "nowrap",
                  color: row.status === "sent" ? "#2C8C4C" : row.status === "failed" ? "#B4382C" : "#8A8474",
                }}>{row.status}</span>
              </div>
              <div style={{ fontFamily: "var(--font-body)", fontSize: 10.5, color: "#8A8474", marginTop: 2 }}>
                {new Date(row.created_at.replace(" ", "T") + "Z").toLocaleString()}
              </div>
              {row.error && <div style={{ fontFamily: "var(--font-body)", fontSize: 10.5, color: "#B4382C", marginTop: 2 }}>{row.error}</div>}
            </div>
          ))}
        </div>
      )}

      {/* Replies FROM the router's own SIM, captured via Twilio's
          inbound-SMS webhook (routes/router-sms-webhook.ts) -- separate
          direction from the "Recent messages" (outbound) list above. */}
      {inboundLoaded && inbound.length > 0 && (
        <div style={{ marginTop: 14 }}>
          <div style={{ fontFamily: "var(--font-body)", fontSize: 10, letterSpacing: "0.06em", textTransform: "uppercase", color: "#8A8474", marginBottom: 6 }}>
            Replies from router
          </div>
          {inbound.map((row) => (
            <div key={row.id} style={{ borderBottom: "1px solid #EEE9DD", padding: "6px 0" }}>
              <div style={{ fontFamily: "var(--font-body)", fontSize: 12, color: "#1A1712" }}>{row.body}</div>
              <div style={{ fontFamily: "var(--font-body)", fontSize: 10.5, color: "#8A8474", marginTop: 2 }}>
                {new Date(row.received_at.replace(" ", "T") + "Z").toLocaleString()}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

function UM_DeviceDetailPage({ unit, slot, isAdmin, victron, ajax, teltonika, efoy, relayUrlBase, hubUrlBase, onRelaysChanged, onHubStateChanged, onRelayStateChanged, onBack }) {
  const vendorData = { victron, ajax, teltonika, efoy };
  const status = UM_normalizeDeviceStatus(slot, vendorData);
  const { live, kind } = UM_resolveLiveDevice(slot, vendorData);
  const { lastComm, lastApi } = UM_commTimes(live);
  const categoryLabel = UM_DEVICE_CATEGORY_LABELS[slot.device_category] || slot.device_category;
  const title = slot.display_name || slot.device_type || slot.slot_key;

  // ---- per-category Key Telemetry / Warnings-Faults / Controls ----
  let telemetry = null;      // JSX for the "Live Status / Key Telemetry" card body
  let warnings = null;       // JSX for the "Warnings / Faults" card body
  let hasIssue = false;      // drives that card's badge tone
  let rawFieldsCard = null;  // optional extra "Additional Diagnostics" card
  let controls = null;       // JSX for the "Controls" card body, or null to hide the card entirely
  let firmware = "\u2014";

  if (!live) {
    telemetry = (
      <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474" }}>
        {slot.mapping_id
          ? "No live data available yet \u2014 this device hasn't reported to the vendor API."
          : "This slot isn't linked to a physical device yet."}
      </div>
    );
  } else if (kind === "victron_gateway") {
    // §19 GlobalLink 520 -- the installation row itself, no cached_fields_json.
    telemetry = (
      <>
        <UM_Row label="Product Name" value={live.installation_name || "\u2014"} />
        <UM_Row label="Gateway Identifier" value={live.gateway_identifier || "\u2014"} last />
      </>
    );
    warnings = <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474" }}>No fault reporting available for the GlobalLink gateway itself.</div>;
    controls = null; // gateway has no controls per spec
  } else if (kind === "victron_device") {
    // §20-23 MPPT 1/2, Shunt, Mains Charger.
    let fields = {};
    try { fields = JSON.parse(live.cached_fields_json || "{}"); } catch { /* ignore */ }
    const known = UM_VICTRON_KNOWN_CODES[live.device_role] || [];
    telemetry = (
      <>
        {known.length === 0 && <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474", marginBottom: 8 }}>No confirmed fields for this device role yet -- see Additional Diagnostics below.</div>}
        {known.map((k, i) => (
          <UM_Row key={k.code} label={k.label} value={fields[k.code] != null ? `${fields[k.code]}${k.code === "cSt" ? "" : ""}` : "\u2014"} last={i === known.length - 1} />
        ))}
        {live.custom_name && <UM_Row label="Custom Name" value={live.custom_name} />}
      </>
    );
    warnings = <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474" }}>No Errors/Warnings diagnostic codes confirmed yet for this device -- check Additional Diagnostics for any raw ERR/WRN-style codes VRM has sent.</div>;
    rawFieldsCard = <UM_RawFieldsCard fields={fields} knownCodes={known.map((k) => k.code)} />;
    firmware = live.model || "\u2014";
    controls = null; // no confirmed Victron sub-device control API
  } else if (kind === "ajax_hub") {
    // §25 Ajax Hub -- treated as the parent Ajax device.
    telemetry = (
      <>
        <UM_Row label="Armed State" value={live.state || "Unknown"} />
        <UM_Row label="Hub Model" value={live.model || "\u2014"} />
        <UM_Row label="Connection Type" value={live.connection_type || "\u2014"} />
        <UM_Row label="Cellular / GSM Signal" value={live.gsm_signal_level || "\u2014"} />
        <UM_Row label="Battery Level" value={live.battery_level != null ? `${live.battery_level}%` : "\u2014"} last />
      </>
    );
    warnings = <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474" }}>No Errors/Warnings feed confirmed yet for the Ajax hub.</div>;
    firmware = live.firmware_version || "\u2014";
    if (!slot.control_enabled) {
      controls = <UM_NotYetAvailableControl label="Arm / Disarm Hub" note="Control is disabled for this device slot in its Device Profile." />;
    } else {
      controls = <UM_HubArmRow hub={live} hubUrlBase={hubUrlBase} onHubChanged={onRelaysChanged} onHubStateChanged={onHubStateChanged} companyName={unit.current_company_name} />;
    }
  } else if (kind === "ajax_device") {
    // §26-31 everything else under Ajax: Cameras, Tilt/Tamper, LED
    // Relay, Camera Relay, Sounder 1/2 -- all the same ajax_devices row
    // shape (state_json varies per device type, so shown raw below).
    let deviceState = {};
    try { deviceState = JSON.parse(live.state_json || "{}"); } catch { /* ignore */ }
    const typeText = String(slot.device_type || "");
    const isRelay = /led\s*relay|camera\s*relay|relay/i.test(typeText);
    const isTiltTamper = /tilt|tamper/i.test(typeText);
    const isSounder = /sounder|siren/i.test(typeText);
    const isCamera = !isRelay && /camera/i.test(typeText);

    telemetry = (
      <>
        <UM_Row label="Room" value={live.room_name || "\u2014"} />
        <UM_Row label="Battery Level" value={live.battery_level != null ? `${live.battery_level}%` : "\u2014"} />
        <UM_Row label="Signal Strength" value={live.signal_level || "\u2014"} last />
      </>
    );
    warnings = <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474" }}>No Errors/Warnings feed confirmed yet for this device type -- check Additional Diagnostics for any raw fault codes Ajax has sent.</div>;
    rawFieldsCard = <UM_RawFieldsCard fields={deviceState} knownCodes={[]} />;

    if (isRelay) {
      // Status-only per customer's "1" choice -- see UM_RelayRow's own
      // header comment for the full rationale (no toggle, no schedule).
      controls = <UM_RelayRow device={live} last />;
    } else if (isSounder) {
      controls = (
        <>
          <UM_NotYetAvailableControl label="Test" note="Ajax siren test endpoint not confirmed yet." />
          <UM_NotYetAvailableControl label="Activate" note="Ajax siren activate endpoint not confirmed yet." />
          <UM_NotYetAvailableControl label="Stop" note="Ajax siren stop endpoint not confirmed yet." />
        </>
      );
    } else if (isTiltTamper) {
      controls = <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474" }}>This device type has no controls -- it's a sensor only. A trigger should generate an Event (Phase 4).</div>;
    } else if (isCamera) {
      controls = <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474" }}>The dedicated Cameras tab is the primary live-view interface for this camera.</div>;
    }
  } else if (kind === "teltonika") {
    // §32-33 Teltonika Router -- spec explicitly defers all controls
    // ("Future Router Controls"), so no Controls card at all.
    telemetry = (
      <>
        <UM_Row label="Online / Offline" value={String(live.connection_state || "").toLowerCase() === "offline" ? "Offline" : (live.status ? "Online" : "Unknown")} />
        <UM_Row label="Operator" value={live.operator || "\u2014"} />
        {/* Signal: RMS's real device.signal is a raw dBm value (e.g. -56),
            not a percentage -- verified against the live OpenAPI v3 spec. */}
        <UM_Row label="Signal" value={live.signal != null ? `${live.signal} dBm` : "\u2014"} />
        <UM_Row label="WWAN IP" value={live.wan_ip || "\u2014"} />
        <UM_Row label="Temperature" value={live.temperature != null ? `${live.temperature} \u00b0C` : "\u2014"} />
        <UM_Row label="MAC" value={live.mac || "\u2014"} last />
      </>
    );
    warnings = <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474" }}>No Errors/Warnings feed confirmed yet for the router.</div>;
    firmware = live.firmware || "\u2014";
    // Reboot/Reconnect/APN/Diagnostics are still deferred to a future
    // phase per spec §33 -- but Send SMS to Router SIM is now built
    // (customer instruction, Sept 2026: "We will need a service built
    // into the admin area of the website to send a sms" / "the
    // router's SIM"). See routes/admin-router-sms.ts + lib/twilioSms.ts.
    controls = (
      <>
        <div style={{ fontFamily: "var(--font-body)", fontSize: 10.5, color: "#8A8474", marginBottom: 10 }}>
          {"Reboot, Reconnect, APN and Diagnostics controls are deferred to a future phase per spec \u00a733."}
        </div>
        {isAdmin ? (
          <UM_RouterSmsControl unitId={unit.id} msisdn={unit.router_sim_msisdn} />
        ) : (
          <UM_NotYetAvailableControl label="Send SMS to Router" note="Staff only." />
        )}
      </>
    );
  } else if (kind === "efoy") {
    // §37 EFOY -- reuses UM_EfoyTab's confirmed field set.
    telemetry = (
      <>
        <UM_Row label="Operating State" value={live.state ? live.state.replace(/_/g, " ") : "\u2014"} />
        <UM_Row label="Output Power" value={live.power_output_w != null ? `${Number(live.power_output_w).toFixed(2)} W` : "\u2014"} />
        <UM_Row label="Output Voltage" value={live.voltage_efoy_v != null ? `${Number(live.voltage_efoy_v).toFixed(2)} V` : "\u2014"} />
        <UM_Row label="Fuel Level" value={live.fuel_level_percent != null ? `${Number(live.fuel_level_percent).toFixed(0)}%` : "\u2014"} />
        <UM_Row label="Runtime" value={UM_formatRuntime(live.stack_operation_time != null ? live.stack_operation_time * 60 : null)} last />
      </>
    );
    hasIssue = !!(live.active_error || live.active_warning);
    warnings = (
      <>
        <UM_Row label="Active Error" value={live.active_error || "None"} />
        <UM_Row label="Active Warning" value={live.active_warning || "None"} last />
      </>
    );
    firmware = live.firmware_version || "\u2014";
    controls = (
      <>
        <UM_NotYetAvailableControl label="Start" note="EFOY Cloud start-command endpoint not confirmed yet." />
        <UM_NotYetAvailableControl label="Stop" note="EFOY Cloud stop-command endpoint not confirmed yet." />
        <UM_NotYetAvailableControl label="Automatic Mode" note="EFOY Cloud mode-switch endpoint not confirmed yet." />
        <UM_NotYetAvailableControl label="Reset Warning" note="EFOY Cloud reset endpoint not confirmed yet." />
        <UM_NotYetAvailableControl label="Device Diagnostics" note="EFOY Cloud diagnostics endpoint not confirmed yet." />
      </>
    );
  }

  // §34-36 Additional Devices / Ajax Speakerphones -- these DO have real
  // Talkdown / Speaker Test routes, but keyed by the Speakers tab's own
  // unit_speakers slot (1/2), a separate assignment model from this
  // slot's device-profile mapping. Rather than guess a cross-reference
  // between the two, this page is honest about the split.
  if (kind === "ajax_device" && /speakerphone/i.test(String(slot.device_type || ""))) {
    controls = <div style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474" }}>Talkdown (SIP) and Speaker Test controls for this device are available on the dedicated Speakers tab.</div>;
  }

  const apiConnected = slot.api_integration === "none" ? "N/A (Manual)" : live ? "Yes" : "No";
  const commandCapability = slot.control_enabled ? "Enabled" : "Not enabled for this device slot";

  return (
    <div data-testid={`unit-device-detail-${slot.slot_id}`} style={{ maxWidth: 760 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18 }}>
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          <div style={{
            fontFamily: "var(--font-display)", fontWeight: 700, fontSize: 19,
            textTransform: "uppercase", color: "#1A1712",
          }}>{title}</div>
          <UM_Badge label={status.label} tone={status.tone} />
        </div>
        <button
          type="button" onClick={onBack} data-testid="unit-device-detail-back"
          style={{
            background: "none", border: "1px solid rgba(0,0,0,0.25)", color: "rgba(0,0,0,0.75)",
            cursor: "pointer", padding: "8px 16px", fontFamily: "var(--font-body)", fontSize: 11,
            fontWeight: 500, letterSpacing: "0.12em", textTransform: "uppercase", flexShrink: 0,
          }}
        >&larr; Back to {categoryLabel}</button>
      </div>

      <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
        <UM_Card title="Live Status / Key Telemetry" badge={status.detail ? { label: status.detail, tone: status.tone } : undefined}>
          {telemetry}
        </UM_Card>

        {warnings && (
          <UM_Card title="Warnings / Faults" badge={hasIssue ? { label: "Attention", tone: "red" } : { label: "Clear", tone: "green" }}>
            {warnings}
          </UM_Card>
        )}

        {rawFieldsCard}

        {controls !== null && (
          <UM_Card title="Controls">{controls}</UM_Card>
        )}

        <UM_Card title="API Status">
          <UM_Row label="API Connected" value={apiConnected} />
          <UM_Row label="Last API Update" value={lastApi || "\u2014"} />
          <UM_Row label="Command Capability" value={commandCapability} last />
        </UM_Card>

        <UM_Card title="Device Information">
          <UM_Row label="External Device ID" value={slot.external_device_id || "\u2014"} />
          <UM_Row label="Serial Number" value={slot.serial_number || live?.serial_number || live?.serial || "\u2014"} />
          <UM_Row label="Firmware" value={firmware} />
          <UM_Row label="Last Communication" value={lastComm || "\u2014"} last />
        </UM_Card>
      </div>
    </div>
  );
}

// ─────────────────────────── General tab ───────────────────────────
// A light "device dashboard" panel, pixel-matched to the reference
// mock-up, embedded inside the dark portal/admin chrome. Cards are
// hidden per-product hardware profile (has_efoy / has_pv_charger / etc,
// from products table) rather than being tower-hardware-exclusive — see
// migrations/0010_unit_management.sql. Battery / Live Power Draw /
// Relay Control are NOT gated by a product flag — every tower has a
// battery bank and a system power draw, and Ajax relays are additive
// hardware synced independently of the product's has_* flags.
//
// Real-vendor-data precedence: when an asset is linked to Victron/Ajax/
// Teltonika (see lib/telemetry.ts's loadUnitVendorData), the live reading
// from that vendor wins over unit_telemetry's demo value for the same
// concept — UM_pick(real, demo) below is that one-line rule, applied
// per-field so a partially-linked asset (e.g. Victron linked but no
// router yet) shows live data where it exists and demo data everywhere
// else, never a blank card.

function UM_pick(real, demo) {
  return real === null || real === undefined ? demo : real;
}

// Victron VRM diagnostics fields are formatted strings, not numbers --
// e.g. shunt.SOC is "99.0 %", not 99.0 (see lib/victron.ts's
// VrmDeviceSummary.fields comment: "V: \"13.32 V\", SOC: \"100.0 %\"").
// `Number("99.0 %")` is NaN (the whole string must be numeric for
// Number() to parse it), which is exactly the bug reported live: Battery
// and PV Charger cards showed a green "Live"/badge (so a shunt/mppt WAS
// found) but rendered "NaN%" / "NaN W" because this file used Number()
// instead of pulling the leading numeric part out first. mission-control.jsx
// and reseller-mission-control-page.jsx never had this bug because they
// use parseFloat() (which happily stops at the first non-numeric
// character) for the exact same fields -- this is that same fix, applied
// here too.
function UM_parseVrmNumber(raw) {
  if (raw == null) return null;
  const n = parseFloat(raw);
  return Number.isNaN(n) ? null : n;
}

// Combines every Victron sub-device sharing one dashboard role into a
// single { [code]: "<summed value> <unit>" } object. An installation
// can have more than two physical Solar Chargers wired to one battery
// bank (e.g. mast + base + stand-mounted panels -- unit 547's real
// data has exactly this: one "mppt_1" device plus TWO physical
// chargers both mapped to "mppt_2" per lib/victron.ts's
// deviceRoleFor), but plain array.find() -- what this file used before
// -- only ever looks at the FIRST device matching a role, silently
// dropping every other physical charger sharing that slot instead of
// combining them. Same combine-then-sum approach as
// mission-control.jsx's combineV/sumVField (that file never had this
// bug), so unit-management-page.jsx's PV Charger card now reports the
// same real combined total as Mission Control instead of quietly
// under-counting.
function UM_combineVictronRole(vDevices, role, code) {
  const rows = vDevices.filter((d) => d.device_role === role);
  if (!rows.length) return null;
  let total = 0, any = false, suffixUnit = "";
  for (const row of rows) {
    let fields = {};
    try { fields = JSON.parse(row.cached_fields_json || "{}"); } catch { /* ignore */ }
    const raw = fields[code];
    const n = UM_parseVrmNumber(raw);
    if (n == null) continue;
    total += n;
    any = true;
    const match = raw != null ? String(raw).match(/-?[\d.]+/) : null;
    const suffix = match ? String(raw).slice(match[0].length).trim() : "";
    if (suffix) suffixUnit = suffix;
  }
  return any ? { [code]: `${Math.round(total * 100) / 100}${suffixUnit ? ` ${suffixUnit}` : ""}` } : null;
}

// Victron's own MPPT charge-state -> LED colour convention (SmartSolar/
// BlueSolar manual, "7.1 LED indications": Bulk = blue LED, Absorption =
// yellow LED, Float = green LED). "ScS" ("Solar Charger State") is the
// VRM diagnostics code that carries this state as plain text (observed
// live on unit 547's 3 solar chargers: "ScS":"Float") -- mapped here to
// this file's existing UM_Badge tones (blue/amber/green) rather than
// inventing new colours, so an MPPT status icon reads the same way as
// every other status badge on this page. Anything not recognised (off,
// external control, fault, etc.) falls back to the neutral grey tone
// used everywhere else on this page for "not actively charging".
function UM_mpptStateTone(state) {
  const s = String(state || "").trim().toLowerCase();
  if (s === "bulk") return { label: "Bulk", tone: "blue" };
  if (s === "absorption") return { label: "Absorption", tone: "amber" };
  if (s === "float") return { label: "Float", tone: "green" };
  if (!s || s === "off" || s === "not charging") return { label: "Standby", tone: "grey" };
  return { label: state, tone: "grey" };
}

// One MPPT card's worth of data for a single dashboard role (mppt_1/
// mppt_2/mppt_3). Unlike UM_combineVictronRole (which only sums one
// numeric code across every device sharing a role -- still needed for
// the §5 status bar's single combined PV total), this resolves ONE
// role to its own separate card: combined Solar Power (ScW, in case a
// 4th+ physical charger ever lands on the same role) plus the charge
// state (ScS) from whichever device in that role reports one, and the
// device's custom_name for a friendly card subtitle when the role has
// exactly one physical device (unit 547: Mppt 1 = "RDTa v2 PL Mast",
// Mppt 2 = "RDTa v2 PL Base", Mppt 3 = "RDTa Solar Stand").
function UM_mpptCardData(vDevices, role) {
  const rows = vDevices.filter((d) => d.device_role === role);
  if (!rows.length) return null;
  const power = UM_combineVictronRole(vDevices, role, "ScW");
  let state = null;
  for (const row of rows) {
    let fields = {};
    try { fields = JSON.parse(row.cached_fields_json || "{}"); } catch { /* ignore */ }
    if (fields.ScS) { state = fields.ScS; break; }
  }
  return {
    power: power ? power.ScW : null,
    state,
    customName: rows.length === 1 ? rows[0].custom_name : null,
  };
}

// Mains Charger presence/status (customer report: "we have a charger
// mains but it only shows when plugged in. So we do need to track this
// and its status"). Root cause, confirmed live against a real VRM
// account: Victron's own diagnostics API entirely OMITS the "Charger"
// device from its response whenever it's unplugged/not communicating --
// it isn't reported with stale values, it just has zero rows. Once a
// mains charger has EVER synced once, its victron_devices row persists
// forever with whatever cached_fields_json it last had, so "a cached
// row exists" is NOT the same thing as "AC mains is plugged in right
// now" -- see migrations/0050_victron_device_presence.sql for the
// is_present column this now relies on (flipped to 0 by
// runVictronSyncDevices/portal-devices.ts whenever a sync's diagnostics
// response no longer contains that device).
//
// Returns one of three shapes, so the Mains Power card can render a
// real three-state badge instead of the old two-state (mainsCharger ?
// "always connected" : demo) logic:
//   { status: "never_linked" } -- no mains_charger device has ever
//     synced for this installation (or Victron isn't linked at all) --
//     falls all the way back to unit_telemetry's demo fields.
//   { status: "unplugged", lastSeenIso } -- a mains_charger row exists
//     but the most recent sync confirmed it's no longer present in
//     VRM's diagnostics (is_present = 0) -- genuinely "unplugged", not
//     just stale/slow to report.
//   { status: "connected", fields, charging } -- row exists AND was
//     present in the most recent sync -- reads its live c0V/c0I/cSt
//     fields as before.
function UM_mainsChargerInfo(vDevices) {
  const row = vDevices.find((d) => d.device_role === "mains_charger");
  if (!row) return { status: "never_linked" };

  let fields = {};
  try { fields = JSON.parse(row.cached_fields_json || "{}"); } catch { /* ignore */ }

  if (!row.is_present) {
    return { status: "unplugged", lastSeenIso: row.last_synced_at || null };
  }
  return { status: "connected", fields, charging: !!(fields.cSt && fields.cSt !== "off") };
}

// §6 Dashboard summary cards — additive to the existing General-tab
// layout below (product-flag card grid, unchanged). These read the same
// unit/telemetry/vendor props UM_GeneralTab already has; no new fetches.

// Device Health — rolled up against the asset's assigned Device Profile
// (§6: "calculated against the assigned Device Profile, not raw hardware
// presence"). Reuses the exact same per-slot §15 status logic as the
// Devices tab (UM_normalizeDeviceStatus) so the two never disagree, by
// fetching the same GET .../device-mappings the Devices tab reads.
function UM_DeviceHealthCard({ unit, isAdmin, victron, ajax, teltonika, efoy }) {
  const [state, setState] = useState({ status: "loading", devicesProfile: null, slots: [] });

  useEffect(() => {
    fetch(UM_deviceMappingsUrl(unit.id, isAdmin), { credentials: "same-origin" })
      .then(async (r) => {
        const data = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error();
        return data;
      })
      .then((data) => setState({ status: "ready", devicesProfile: data.device_profile || null, slots: data.slots || [] }))
      .catch(() => setState({ status: "error", devicesProfile: null, slots: [] }));
  }, [unit.id, isAdmin]);

  if (state.status === "loading") {
    return (
      <UM_Card title="Device Health" testId="dashboard-card-device-health">
        <div style={{ fontFamily: "var(--font-body)", fontSize: 12, color: "#8A8474" }}>Loading&hellip;</div>
      </UM_Card>
    );
  }

  if (!state.devicesProfile) {
    return (
      <UM_Card title="Device Health" badge={{ label: "Unknown", tone: "grey" }} testId="dashboard-card-device-health">
        <div style={{ fontFamily: "var(--font-body)", fontSize: 12, color: "#8A8474" }}>
          No Device Profile assigned yet.
        </div>
      </UM_Card>
    );
  }

  const statuses = state.slots.map((slot) => UM_normalizeDeviceStatus(slot, { victron, ajax, teltonika, efoy }));
  const counts = { green: 0, amber: 0, red: 0, grey: 0 };
  statuses.forEach((s) => { counts[s.tone] = (counts[s.tone] || 0) + 1; });

  const overallTone = counts.red > 0 ? "red" : counts.amber > 0 ? "amber" : counts.grey === statuses.length ? "grey" : "green";
  const overallLabel = counts.red > 0 ? "Attention Needed" : counts.amber > 0 ? "Degraded" : counts.grey === statuses.length ? "Unknown" : "Healthy";

  return (
    <UM_Card
      title="Device Health" badge={{ label: overallLabel, tone: overallTone }} testId="dashboard-card-device-health"
      collapsible summary={`${state.devicesProfile.name} \u00b7 ${counts.green} connected, ${counts.amber} warning, ${counts.red} fault`}
    >
      <UM_BigValue small testId="dashboard-card-device-health-profile-name">{state.devicesProfile.name}</UM_BigValue>
      <UM_Row label="Connected" value={String(counts.green)} testId="dashboard-card-device-health-row-connected" />
      <UM_Row label="Warning" value={String(counts.amber)} testId="dashboard-card-device-health-row-warning" />
      <UM_Row label="Disconnected / Fault" value={String(counts.red)} testId="dashboard-card-device-health-row-disconnected" />
      <UM_Row label="Unknown" value={String(counts.grey)} last testId="dashboard-card-device-health-row-unknown" />
    </UM_Card>
  );
}

// Connectivity Summary — §6: Router Status/Operator/Signal/WWAN IP/Ajax
// API Status/Victron API Status/Teltonika API Status/Last Sync, one place.
// Same real-wins-over-demo (UM_pick) precedence as the rest of this file.
function UM_ConnectivitySummaryCard({ unit, telemetry, victron, ajax, teltonika, efoy }) {
  const fmt = (n, dp) => (n === null || n === undefined ? "\u2014" : Number(n).toFixed(dp));
  const router = teltonika || null;
  const routerConfigured = !!unit.has_router;
  const routerOnline = router
    ? String(router.connection_state || "").toLowerCase() !== "offline" && !!router.status
    : routerConfigured;

  const ajaxHub = ajax && ajax.hub;
  const victronInstallation = victron && victron.installation;

  const apiStatusBadge = (connected) =>
    connected === null ? { label: "Not Configured", tone: "grey" } : { label: connected ? "Connected" : "Offline", tone: connected ? "green" : "red" };

  const lastSyncIso = [
    ajaxHub && ajaxHub.last_synced_at,
    router && router.last_synced_at,
    victronInstallation && victronInstallation.last_synced_at,
    efoy && efoy.last_synced_at,
  ].filter(Boolean).sort().pop() || null;

  // Overall badge -- didn't exist before this pass (the card had no
  // badge at all), added mainly so the "smart" collapsible default
  // (auto-expand on red/amber) has something real to key off here too,
  // not just on the two cards that already had one.
  const ajaxIssue = ajaxHub && !ajaxHub.online;
  const routerIssue = routerConfigured && !routerOnline;
  const overallTone = ajaxIssue || routerIssue ? "amber" : "green";
  const overallLabel = ajaxIssue || routerIssue ? "Attention" : "All Connected";

  return (
    <UM_Card
      title="Connectivity Summary" badge={{ label: overallLabel, tone: overallTone }} testId="dashboard-card-connectivity-summary"
      collapsible summary={`Router ${!routerConfigured ? "not configured" : (routerOnline ? "online" : "offline")} \u00b7 Ajax ${apiStatusBadge(ajaxHub ? !!ajaxHub.online : null).label} \u00b7 Synced ${lastSyncIso ? UM_timeAgo(lastSyncIso) : "\u2014"}`}
    >
      <UM_Row label="Router Status" value={!routerConfigured ? "Not Configured" : (routerOnline ? "Online" : "Offline")} testId="dashboard-card-connectivity-summary-row-router-status" />
      <UM_Row label="Operator" value={UM_pick(router && router.operator, telemetry.router_operator) || "\u2014"} testId="dashboard-card-connectivity-summary-row-operator" />
      {/* Signal: real router.signal is dBm (verified against RMS's live
          OpenAPI v3 spec), not a percent -- only the demo fallback below
          is an actual percentage. */}
      <UM_Row
        label="Signal"
        value={
          router && router.signal != null
            ? `${fmt(router.signal, 0)} dBm`
            : telemetry.router_signal_percent != null
            ? `${fmt(telemetry.router_signal_percent, 0)}%`
            : "\u2014"
        }
        testId="dashboard-card-connectivity-summary-row-signal"
      />
      <UM_Row label="WWAN IP" value={UM_pick(router && router.wan_ip, telemetry.router_wwan_ip) || "\u2014"} testId="dashboard-card-connectivity-summary-row-wwan-ip" />
      <UM_Row label="Ajax API Status" value={apiStatusBadge(ajaxHub ? !!ajaxHub.online : null).label} testId="dashboard-card-connectivity-summary-row-ajax-api-status" />
      <UM_Row label="Victron API Status" value={victronInstallation ? "Connected" : "Not Configured"} testId="dashboard-card-connectivity-summary-row-victron-api-status" />
      <UM_Row label="Teltonika API Status" value={!routerConfigured ? "Not Configured" : (routerOnline ? "Connected" : "Offline")} testId="dashboard-card-connectivity-summary-row-teltonika-api-status" />
      <UM_Row label="Last Sync" value={lastSyncIso ? UM_timeAgo(lastSyncIso) : "\u2014"} last testId="dashboard-card-connectivity-summary-row-last-sync" />
    </UM_Card>
  );
}

// Camera Summary — §6: Configured/Online/Offline counts, drawn from the
// same /cameras endpoint the Cameras tab itself uses (UM_CamerasTab).
function UM_CameraSummaryCard({ unit, isAdmin }) {
  const [state, setState] = useState({ status: "loading", cameras: [] });
  const camerasUrl = isAdmin ? `/api/admin/units/${unit.id}/cameras` : `/api/portal/assets/${unit.id}/cameras`;

  useEffect(() => {
    fetch(camerasUrl, { credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then((data) => setState({ status: "ready", cameras: data.cameras || [] }))
      .catch(() => setState({ status: "error", cameras: [] }));
  }, [unit.id, isAdmin]); // eslint-disable-line react-hooks/exhaustive-deps

  if (state.status === "loading") {
    return (
      <UM_Card title="Camera Summary" testId="dashboard-card-camera-summary">
        <div style={{ fontFamily: "var(--font-body)", fontSize: 12, color: "#8A8474" }}>Loading&hellip;</div>
      </UM_Card>
    );
  }

  const configured = state.cameras.filter((c) => c.configured);
  const online = configured.filter((c) => !c.last_error);
  const offline = configured.filter((c) => !!c.last_error);

  return (
    <UM_Card
      title="Camera Summary" badge={configured.length === 0 ? { label: "None Configured", tone: "grey" } : (offline.length > 0 ? { label: "Attention", tone: "amber" } : { label: "Healthy", tone: "green" })}
      testId="dashboard-card-camera-summary" collapsible summary={`${configured.length} / 4 configured \u00b7 ${online.length} online \u00b7 ${offline.length} offline/error`}
    >
      <UM_Row label="Configured" value={`${configured.length} / 4`} testId="dashboard-card-camera-summary-row-configured" />
      <UM_Row label="Online" value={String(online.length)} testId="dashboard-card-camera-summary-row-online" />
      <UM_Row label="Offline / Error" value={String(offline.length)} last testId="dashboard-card-camera-summary-row-offline" />
    </UM_Card>
  );
}

// Active Notifications / Recent Events — §6 requires both on the
// Dashboard, but no notifications or events backend exists yet in this
// codebase (verified: no notification/event tables or routes — this is
// Phase 4 scope per the agreed 4-phase plan). Rendered as explicit
// "Coming Soon" placeholders rather than fabricated data, matching this
// file's own UM_ComingSoon convention for not-yet-built tabs, so the
// Dashboard is honest about what's live today without blocking on
// Phase 4's Events/Notifications backend work.
function UM_NotificationsPlaceholderCard() {
  return (
    <UM_Card title="Active Notifications" badge={{ label: "Coming Soon", tone: "grey" }} testId="dashboard-card-active-notifications">
      <div style={{ fontFamily: "var(--font-body)", fontSize: 12, color: "#8A8474" }}>
        Notifications are built in a later phase of this rework.
      </div>
    </UM_Card>
  );
}

function UM_RecentEventsPlaceholderCard() {
  return (
    <UM_Card title="Recent Events" badge={{ label: "Coming Soon", tone: "grey" }} testId="dashboard-card-recent-events">
      <div style={{ fontFamily: "var(--font-body)", fontSize: 12, color: "#8A8474" }}>
        Event history is built in a later phase of this rework.
      </div>
    </UM_Card>
  );
}

// Photo-based replacement for the old hand-drawn TowerSchematic line art
// (shared/primitives.jsx). History: ONE static base photo with CSS-drawn
// glow/ring overlays -> an abandoned 16-photo breathing-frame cycle ->
// a simple 2-photo armed/disarmed pair (tower-holder.png / -red.png,
// narrow 239x684 portrait crops) with the wall socket + cable drawn as
// a SEPARATE hand-coded SVG overlay (UM_MainsSocket, now removed) to
// the tower's left. The customer then supplied 4 new wide reference
// photos -- one per combination of Armed/Disarmed x Charging/Not
// charging -- with the wall socket AND cable baked directly into the
// two "charging" shots (no socket at all in the "not charging" shots,
// since nothing is plugged in). Cropped tight (whitespace trimmed) to
// a shared 375x525 window across all 4 so the tower never jumps
// position when a state flips:
//   tower-armed-charging.png       (red glow,   socket+cable visible)
//   tower-disarmed-charging.png    (green glow, socket+cable visible)
//   tower-armed-not-charging.png   (red glow,   no socket)
//   tower-disarmed-not-charging.png(green glow, no socket)
// Picked by TWO independent live signals: securityArmed (still the
// Ajax hub's ARMED/DISARMED state, unchanged) and mainsConnected (per
// the customer's "as standard all units have mains power but it's not
// connected, when it's connected the tile will update" -- i.e. whether
// AC mains is actually plugged in right now, the same signal already
// driving the "Mains Power" card, NOT mainsCharging). A unit with no
// mains-power feature at all never asks for a charging state, so it
// always renders the "not-charging" pair.
const UM_SECURITY_GLOW_RGB = { armed: "255,46,46", disarmed: "25,230,115" };

const UM_TOWER_PHOTOS = {
  "armed|charging": "/public/static/tower-armed-charging.png",
  "disarmed|charging": "/public/static/tower-disarmed-charging.png",
  "armed|not-charging": "/public/static/tower-armed-not-charging.png",
  "disarmed|not-charging": "/public/static/tower-disarmed-not-charging.png",
};

function UM_TowerPhoto({ width = 170, mainsConnected, showMains, ledsOn, securityArmed }) {
  // New reference photos are natively 375x525 (wider than the old
  // 256x512 frame convention -- these crops keep the socket+cable
  // fully in-frame on the charging variants).
  const NATIVE_W = 375;
  const NATIVE_H = 525;
  const scale = width / NATIVE_W;
  const height = Math.round(NATIVE_H * scale);
  const px = (n) => n * scale;

  const armedKey = securityArmed === true ? "armed" : "disarmed";
  const chargingKey = showMains && mainsConnected ? "charging" : "not-charging";
  const photoKey = `${armedKey}|${chargingKey}`;
  const photoSrc = UM_TOWER_PHOTOS[photoKey];

  // Camera-dome coordinates re-measured directly on the new 375x525
  // photos (left dome centre ~x=197.5,y=53.6 / right dome centre
  // ~x=238,y=53.6 in pixels) so the LED-flash overlay still lands on
  // the domes despite the wider crop.
  const domeXY = [{ x: 0.527, y: 0.102 }, { x: 0.635, y: 0.102 }];

  return (
    <div style={{ position: "relative", width, height }}>
      {/* Real product photo — one of the 4 customer reference photos,
          picked by securityArmed x mainsConnected above. The wall
          socket + cable is baked into the "charging" photos themselves
          now, so no separate overlay is drawn. objectFit:"contain"
          keeps the photo undistorted inside this box. A short opacity
          cross-fade (um-tower-frame-fade) softens every state swap. */}
      <img
        key={photoKey}
        src={photoSrc}
        width={width}
        height={height}
        style={{
          display: "block", width, height, position: "relative",
          objectFit: "contain",
          animation: "um-tower-frame-fade 0.35s ease-out",
        }}
        alt={`Tower unit — ${armedKey === "armed" ? "armed" : "unarmed"}${chargingKey === "charging" ? ", mains charging" : ""}`}
      />

      {/* LEDs on — pulsing glow over the left + right camera domes */}
      {ledsOn && domeXY.map((dot, i) => (
        <span
          key={i}
          style={{
            position: "absolute", left: px(NATIVE_W * dot.x) - px(6), top: px(NATIVE_H * dot.y) - px(6),
            width: px(12), height: px(12), borderRadius: "50%",
            background: "#fff9c4", boxShadow: "0 0 8px 3px rgba(255,235,59,0.95)",
            animation: "um-led-flash 0.6s steps(1) infinite",
            animationDelay: `${i * 0.3}s`,
          }}
        />
      ))}

      <style>{`
        @keyframes um-led-flash { 0%, 50% { opacity: 1 } 50.01%, 100% { opacity: 0.15 } }
        /* Short cross-fade whenever securityArmed or mainsConnected
           flips (any of the 4 photos swap) so it reads as a smooth
           transition rather than a hard flicker between images. */
        @keyframes um-tower-frame-fade {
          0%   { opacity: 0.55; }
          100% { opacity: 1; }
        }
      `}</style>
    </div>
  );
}

function UM_GeneralTab({ unit, telemetry, victron, ajax, teltonika, efoy, onToggle, toggleBusy, relayUrlBase, hubUrlBase, onRelaysChanged, onHubStateChanged, onRelayStateChanged, isAdmin }) {
  const fmt = (n, dp) => (n === null || n === undefined ? "\u2014" : Number(n).toFixed(dp));

  // "Launch WebUI" -- real RMS capability (POST connect/webui + poll
  // links?type=webui, see lib/teltonika.ts's getTeltonikaWebuiLink for
  // the full mechanics). Only ever callable when a real router is
  // linked (router = teltonika below), never on demo data.
  const [webuiBusy, setWebuiBusy] = useState(false);
  const [webuiError, setWebuiError] = useState("");
  const webuiUrlBase = isAdmin ? "/api/admin" : "/api/portal/mission-control";
  const handleLaunchWebui = async () => {
    setWebuiBusy(true);
    setWebuiError("");
    try {
      const res = await fetch(`${webuiUrlBase}/units/${unit.id}/teltonika-webui-link`, {
        method: "POST", credentials: "same-origin",
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "Couldn't get a WebUI link.");
      window.open(data.url, "_blank", "noopener,noreferrer");
    } catch (err) {
      setWebuiError(err.message);
    } finally {
      setWebuiBusy(false);
    }
  };

  // Victron sub-device lookups (shunt = battery, mppt_1/2 = PV, mains_charger = mains).
  const vDevices = (victron && victron.devices) || [];
  const findV = (role) => {
    const row = vDevices.find((d) => d.device_role === role);
    if (!row) return null;
    let fields = {};
    try { fields = JSON.parse(row.cached_fields_json || "{}"); } catch { /* ignore */ }
    return fields;
  };
  const shunt = findV("shunt");
  // Up to 3 distinct physical Solar Chargers, one per mppt_1/mppt_2/
  // mppt_3 dashboard role (unit 547's real data: Mast=mppt_1,
  // Base=mppt_2, Solar Stand=mppt_3 -- see lib/victron.ts's
  // deviceRoleFor). UM_mpptCardData resolves ONE role to its own PV
  // Charger card's worth of data (power + charge state), so each
  // physical charger gets a separate card instead of being combined.
  const mppt1 = UM_combineVictronRole(vDevices, "mppt_1", "ScW");
  const mppt2 = UM_combineVictronRole(vDevices, "mppt_2", "ScW");
  const mppt3 = UM_combineVictronRole(vDevices, "mppt_3", "ScW");
  // Card labels: "Mppt 3 (RS1 Solar Stand)" is the customer's own exact
  // naming for unit 547's 3rd physical charger (victron_devices.id=4,
  // vrm_device_key "Solar Charger:103") -- overriding the raw VRM
  // custom_name ("RDTa Solar Stand", the Sccn field synced from the
  // customer's own Victron app) since the customer explicitly asked for
  // this label. Mppt 1/2 keep showing their live-synced custom_name.
  const UM_MPPT_LABEL_OVERRIDE = { mppt_3: "Mppt 3 (RS1 Solar Stand)" };
  const mpptCards = ["mppt_1", "mppt_2", "mppt_3"]
    .map((role, i) => ({ role, label: `Mppt ${i + 1}`, data: UM_mpptCardData(vDevices, role) }))
    .filter((m) => m.data);
  // Mains charger: "row exists" is NOT "plugged in right now" -- see
  // UM_mainsChargerInfo's header comment (VRM omits the "Charger"
  // device entirely from diagnostics whenever it's unplugged, rather
  // than reporting stale values).
  const mainsInfo = UM_mainsChargerInfo(vDevices);

  const batteryPercent = UM_pick(UM_parseVrmNumber(shunt && shunt.SOC), telemetry.battery_percent);
  const batteryVoltage = UM_pick(UM_parseVrmNumber(shunt && shunt.V), telemetry.battery_voltage_v);
  const batteryCurrent = UM_pick(UM_parseVrmNumber(shunt && shunt.I), telemetry.battery_current_a);

  // Live Power Draw (was "System Power Draw"): FIXED -- was previously
  // Voltage x shunt.I (the shunt's raw battery-terminal current), which
  // is NOT the load's draw -- shunt.I is the NET current at the battery
  // terminal (Victron convention: positive = battery net charging,
  // negative = battery net discharging), so V x shunt.I conflates the
  // real load draw with whatever solar/mains chargers are simultaneously
  // feeding into the battery at that instant. Reported live by the
  // customer against unit 550/"PCT-Demo 1" (13.29V x 4.51A = 59.94W)
  // as "not factual... this is only taking the draw figure. Not the
  // offset" -- confirmed: that 59.94W IS exactly V x shunt.I, i.e. the
  // bug.
  //
  // Correct derivation, matching Victron's own GX/Cerbo "DC Loads"
  // calculation (see Victron's DVCC docs: "It will add extra charge
  // current if there is a load, and subtract it if there is another
  // charger in the DC system" -- i.e. on a shunt-at-the-battery wiring
  // topology, every charger's output current either goes to the
  // battery or to the load, so: load current = total charger output
  // current - net battery current). Sums each MPPT's live ScI plus the
  // mains charger's c0I (only when actually connected right now -- see
  // UM_mainsChargerInfo), then subtracts the shunt's own net battery
  // current. Verified against unit 550's real synced data: mppt_1 ScI
  // 3.0A + mppt_2 ScI 2.4A + mains c0I 0A = 5.4A total charger output,
  // minus shunt's 4.51A net-charging = 0.89A actual load current x
  // 13.29V = 11.83W true draw (not the old, wrong 59.94W). Clamped at 0
  // (Math.max) since a genuine load draw can't be negative -- a small
  // negative result here would only be shunt/MPPT reading noise, not a
  // real reading. Falls back to unit_telemetry's demo power_w/
  // power_current_a for any unit not yet linked to Victron, same
  // UM_pick precedence as every other card on this tab.
  const shuntVoltage = UM_parseVrmNumber(shunt && shunt.V);
  const shuntCurrent = UM_parseVrmNumber(shunt && shunt.I);
  const drawLive = shuntVoltage != null && shuntCurrent != null;
  const drawVoltage = UM_pick(shuntVoltage, telemetry.system_voltage_v);
  const chargerOutputCurrentA = ["mppt_1", "mppt_2", "mppt_3"].reduce((sum, role) => {
    for (const row of vDevices.filter((d) => d.device_role === role)) {
      let fields = {};
      try { fields = JSON.parse(row.cached_fields_json || "{}"); } catch { /* ignore */ }
      const i = UM_parseVrmNumber(fields.ScI);
      if (i != null) sum += i;
    }
    return sum;
  }, mainsInfo.status === "connected" ? (UM_parseVrmNumber(mainsInfo.fields.c0I) || 0) : 0);
  const drawCurrentRaw = drawLive ? Math.max(0, chargerOutputCurrentA - shuntCurrent) : null;
  const drawCurrent = UM_pick(drawCurrentRaw, telemetry.power_current_a);
  const drawPowerW = drawLive ? Math.max(0, shuntVoltage * drawCurrentRaw) : telemetry.power_w;

  const pvLive = mppt1 || mppt2 || mppt3;
  const pvPowerW = pvLive
    ? (UM_parseVrmNumber(mppt1 && mppt1.ScW) || 0) + (UM_parseVrmNumber(mppt2 && mppt2.ScW) || 0) + (UM_parseVrmNumber(mppt3 && mppt3.ScW) || 0)
    : telemetry.pv_power_w;

  // Autonomy: "how long can this unit run on battery" -- now folded
  // into the Battery card itself (see below; was its own separate
  // "Autonomy Status" card until the customer asked to merge the two
  // to save space) instead of sitting under the PV Charger card, which
  // never made sense for it
  // (same "was Fuel Level -- leftover EFOY copy-paste" smell as the row
  // it replaced). The real Victron shunt reports this as "Time To Go"
  // (shunt.TTG, hours, e.g. "240.00 h") -- a genuinely more accurate
  // reading than the demo "Run Time" figure, since it's Victron's own
  // live estimate of remaining battery life at the current discharge
  // rate, not a fixed/random demo number. Falls back to the demo
  // pv_runtime_minutes (labelled "Run Time") for any unit not yet
  // linked to a real Victron shunt, same UM_pick real-wins-over-demo
  // precedence used everywhere else on this tab. TTG is in hours, so
  // it's converted to minutes here for a consistent internal unit --
  // displayed via UM_formatDaysHours ("Xd Yh", 24h day) rather than
  // UM_formatRuntime's "Xh Ymin", since multi-day estimates (e.g. 240h)
  // read far better as "10d 0h" than "240h 0min".
  const shuntTtgHours = UM_parseVrmNumber(shunt && shunt.TTG);
  const autonomyLive = shuntTtgHours != null;
  const autonomyLabel = autonomyLive ? "Time To Go" : "Run Time";
  const autonomyMinutes = UM_pick(autonomyLive ? shuntTtgHours * 60 : null, telemetry.pv_runtime_minutes);

  // Three real states now instead of the old two ("has a cached row" ==
  // permanently connected): never_linked falls back to demo telemetry
  // exactly as before; unplugged is a genuine, distinct "confirmed gone
  // from the latest sync" state (see UM_mainsChargerInfo); connected
  // reads live c0V/c0I/cSt as before. mainsConnected is only ever true
  // for "connected" (live) or a never_linked demo unit whose telemetry
  // says connected -- an unplugged real charger is never shown as
  // connected again just because its row still exists.
  const mainsConnected = mainsInfo.status === "connected" ? true : mainsInfo.status === "never_linked" ? !!telemetry.mains_connected : false;
  const mainsCharging = mainsInfo.status === "connected" ? !!mainsInfo.charging : mainsInfo.status === "never_linked" ? !!telemetry.mains_charging : false;

  // Security breathing glow source -- SAME hub.state the "Ajax Hub"
  // arm/disarm toggle already reads (see UM_HubArmRow's identical
  // stateUpper/armed derivation below); PARTIAL_ARMED/ARMING/DISARMING
  // read as not-yet-Armed (green) until the hub settles into a plain
  // ARMED state, same "only two real glow colours" simplification the
  // toggle itself uses. Falls back to unit_telemetry's demo armed_on
  // flag for any unit with no Ajax hub linked yet (real-wins-over-demo,
  // same UM_pick precedence as every other signal on this tab); null
  // (glow omitted) only when this SKU has no alarm feature at all AND
  // there's no hub linked, so a plain camera-only tower never shows a
  // security glow it can't actually back up.
  const securityArmedLive = (() => {
    const hubForGlow = ajax && ajax.hub;
    if (!unit.has_alarm && !hubForGlow) return null;
    if (hubForGlow) return UM_hubArmedForDisplay(hubForGlow.state);
    return !!telemetry.armed_on;
  })();

  const router = teltonika || null;

  const ajaxHub = ajax && ajax.hub;
  const ajaxDevices = (ajax && ajax.devices) || [];

  // Tamper Protection / Sounder 1 / Sounder 2 are Ajax sensor & siren
  // devices, not controllable relays -- UM_RelayPanel used to render
  // every ajaxDevices row unconditionally as a toggleable relay switch
  // (with a custom label + on/off + schedule editor), which incorrectly
  // gave these three the full relay treatment. Split them into their
  // own monitor-only list (name + Online/Offline badge only, see
  // UM_DeviceStatusPanel below) and keep only genuine relays (Schedule
  // Relay / LED Relay / Remote Reboot, etc.) going into UM_RelayPanel.
  // Mirrors the /tamper/i + /sounder|siren/i vocabulary already used by
  // the Devices tab's isTiltTamper/isSounder (which tests a Device
  // Profile Slot's device_type display string) -- here the same regexes
  // are applied to the raw ajax_devices row's own name/device_type
  // fields instead, since that's what this array actually contains.
  const UM_isMonitorOnlyAjaxDevice = (d) => {
    const text = `${d.name || ""} ${d.device_type || ""}`;
    return /tamper/i.test(text) || /sounder|siren/i.test(text);
  };
  const ajaxRelayDevices = ajaxDevices.filter((d) => !UM_isMonitorOnlyAjaxDevice(d));
  const ajaxMonitorDevices = ajaxDevices.filter(UM_isMonitorOnlyAjaxDevice);

  // EFOY: a single cached efoy_devices row per unit (see lib/efoy.ts /
  // migrations/0033_efoy.sql) — no sub-device list the way Victron has,
  // so this is just "real linked device wins over unit_telemetry's demo
  // fallback" (UM_pick), same precedence rule used everywhere else on
  // this tab. `state`/`connected` come straight from EFOY Cloud's own
  // systemState; the API has no litres figure so fuel litres always
  // falls back to the demo value even once a device is linked.
  const efoyDevice = efoy || null;
  const efoyPowerW = UM_pick(efoyDevice && efoyDevice.power_output_w, telemetry.efoy_power_w);
  const efoyCurrentA = UM_pick(efoyDevice && efoyDevice.charging_current_a, telemetry.efoy_current_a);
  const efoyVoltageV = UM_pick(efoyDevice && efoyDevice.voltage_efoy_v, telemetry.efoy_voltage_v);
  const efoyRuntimeMinutes = UM_pick(
    efoyDevice && efoyDevice.stack_operation_time != null ? efoyDevice.stack_operation_time * 60 : null,
    telemetry.efoy_runtime_minutes
  );
  const efoyFuelPercent = UM_pick(efoyDevice && efoyDevice.fuel_level_percent, telemetry.efoy_fuel_percent);
  const efoyFuelLitres = telemetry.efoy_fuel_litres; // EFOY Cloud has no litres figure -- always demo
  const efoyBadge = efoyDevice && efoyDevice.state
    ? { label: efoyDevice.state.replace(/_/g, " "), tone: efoyDevice.connected ? "green" : "grey" }
    : { label: "Standby", tone: "grey" };

  // LEDs-on source: prefer the Ajax relay labelled "LED Strobe(s)" (the
  // real, customer-controllable device — see UM_RelayPanel below) and
  // fall back to the legacy unit_telemetry.strobe_on demo flag when no
  // such relay is linked, matching this file's real-wins-over-demo
  // (UM_pick) precedence used everywhere else.
  const ledStrobeDevice = ajaxDevices.find((d) => {
    const label = (d.relay_config && d.relay_config.custom_label) || d.name || "";
    return /led\s*strobe/i.test(label);
  });
  let ledStrobeOn = null;
  if (ledStrobeDevice) {
    try { ledStrobeOn = !!JSON.parse(ledStrobeDevice.state_json || "{}").on; } catch { /* ignore */ }
  }
  const ledsOn = UM_pick(ledStrobeOn, !!telemetry.strobe_on);

  return (
    <div>
    {/* "Option 1 — Clean White Card" redesign: the beige block +
        heavy border is gone in favour of a white panel on a soft
        drop shadow (rounded corners), matching the approved mockup.
        White stays the page's primary surface colour throughout —
        colour is reserved for small status accents (badges, the
        armed/disarmed tile below) rather than block backgrounds. */}
    <div style={{
      background: "#FFFFFF", border: "1px solid #EDEAE1", borderRadius: 14,
      boxShadow: "0 6px 20px rgba(26,23,18,0.06)",
      padding: "32px", display: "grid",
      gridTemplateColumns: "280px minmax(420px, 1fr) 280px", gap: 28,
    }}>
      {/* Left column */}
      <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
        {!!unit.has_efoy && !!unit.efoy_enabled && (
          <UM_Card title="EFOY" badge={efoyBadge}>
            <UM_BigValue>{fmt(efoyPowerW, 2)} W</UM_BigValue>
            <UM_Row label="Current" value={`${fmt(efoyCurrentA, 2)} A`} />
            <UM_Row label="Voltage" value={`${fmt(efoyVoltageV, 2)} V`} />
            <UM_Row label="Run Time" value={UM_formatRuntime(efoyRuntimeMinutes)} />
            <UM_Row label="Fuel Level" value={`${fmt(efoyFuelPercent, 0)}% (${fmt(efoyFuelLitres, 3)}l)`} />
            {efoyDevice && efoyDevice.serial_number && (
              <UM_Row label="Serial" value={efoyDevice.serial_number} last />
            )}
          </UM_Card>
        )}

        {/* Moved here from the top of the right column, directly above
            Battery, per the "move live power draw card above the
            battery card on the left" request -- no data/logic change,
            same card as before. */}
        <UM_Card title="Live Power Draw">
          <UM_Row label="Voltage" value={`${fmt(drawVoltage, 2)} V`} />
          <UM_Row label="Amperage" value={`${fmt(drawCurrent, 1)} A`} />
          <UM_Row label="Power" value={`${fmt(drawPowerW, 2)} W`} last />
        </UM_Card>

        {/* Battery + Autonomy Status merged into one card (customer:
            "link these two together please, save some space") --
            Autonomy Status's own "Battery (SOC)" row was always just a
            second display of this same batteryPercent value (see the
            two cards' identical fmt(batteryPercent, 0)% calls, pre-
            merge), so dropping it here loses no information: the
            merged card's own big % value at the top already covers it.
            autonomyLabel/autonomyMinutes prefer the real Victron
            shunt's live Time To Go estimate over the demo Run Time
            figure -- see the derivation above shunt for why. */}
        <UM_Card title="Battery" badge={shunt ? undefined : { label: `${fmt(batteryPercent, 0)}%`, tone: "grey" }}>
          <UM_BigValue>{fmt(batteryPercent, 0)}%</UM_BigValue>
          <UM_Row label="Voltage" value={`${fmt(batteryVoltage, 2)} V`} />
          <UM_Row label="Current" value={`${fmt(batteryCurrent, 2)} A`} />
          <UM_Row label={autonomyLabel} value={UM_formatDaysHours(autonomyMinutes)} last />
        </UM_Card>

        {!!unit.has_mains_power && (() => {
          // Three real badge states now instead of the old two --
          // "Unplugged" (real device, confirmed absent from the latest
          // sync -- red, matching UM_normalizeDeviceStatus's Disconnected
          // convention) is genuinely distinct from a never-linked unit's
          // demo "Disconnected" (grey), since the former means "we know
          // for a fact AC mains is not plugged in right now" while the
          // latter just means "no real Victron mains charger has ever
          // been linked to this asset". See UM_mainsChargerInfo's header
          // comment for the VRM-omits-the-device-when-unplugged root
          // cause this exists to fix.
          const badge = mainsInfo.status === "connected"
            ? { label: mainsCharging ? "Charging" : "Connected", tone: mainsCharging ? "blue" : "green" }
            : mainsInfo.status === "unplugged"
              ? { label: "Unplugged", tone: "red" }
              : { label: mainsConnected ? "Connected" : "Disconnected", tone: mainsConnected ? "green" : "grey" };
          const bigValue = mainsInfo.status === "connected"
            ? (mainsCharging ? "Plugged in \u2014 Charging" : "Plugged in \u2014 Not charging")
            : mainsInfo.status === "unplugged"
              ? "Not plugged in"
              : (mainsConnected ? "Plugged in \u2014 Not charging" : "Disconnected");
          return (
            <UM_Card title="Mains Power" badge={badge}>
              <UM_BigValue small>{bigValue}</UM_BigValue>
              {mainsInfo.status === "connected" && (
                <>
                  <UM_Row label="Output Voltage" value={mainsInfo.fields.c0V != null ? `${fmt(UM_parseVrmNumber(mainsInfo.fields.c0V), 1)} V` : "\u2014"} />
                  <UM_Row label="Output Current" value={mainsInfo.fields.c0I != null ? `${fmt(UM_parseVrmNumber(mainsInfo.fields.c0I), 2)} A` : "\u2014"} last />
                </>
              )}
              {mainsInfo.status === "unplugged" && (
                <UM_Row label="Last Seen Plugged In" value={mainsInfo.lastSeenIso ? UM_timeAgo(mainsInfo.lastSeenIso) : "\u2014"} last />
              )}
            </UM_Card>
          );
        })()}

        {!!unit.has_pv_charger && (
          pvLive ? (
            // One separate card PER physical Solar Charger (Mppt 1 / 2 /
            // 3, each already resolved to its own role by
            // UM_mpptCardData -- see lib/victron.ts's deviceRoleFor,
            // which no longer collapses a 2nd+ charger onto Mppt 2).
            // Each card gets its own status badge reflecting Victron's
            // own charge-state -> LED colour convention (SmartSolar/
            // BlueSolar manual §7.1: Bulk = blue, Absorption = yellow,
            // Float = green -- see UM_mpptStateTone). Run Time / Battery
            // (SOC) used to be tacked on here (only on the last card) --
            // moved into the Battery card in the left column since
            // neither is really a PV-charger concept.
            mpptCards.map((m, idx) => {
              const tone = UM_mpptStateTone(m.data.state);
              const isLastCard = idx === mpptCards.length - 1;
              const title = UM_MPPT_LABEL_OVERRIDE[m.role]
                || `${m.label}${m.data.customName ? ` (${m.data.customName})` : ""}`;
              return (
                <UM_Card
                  key={m.role}
                  title={title}
                  badge={tone}
                >
                  <UM_BigValue>{m.data.power || "\u2014"}</UM_BigValue>
                  <UM_Row label="Charge State" value={tone.label} last />
                </UM_Card>
              );
            })
          ) : (
            <UM_Card title="PV Charger" badge={{ label: telemetry.pv_status === "generating" ? "GENERATING" : "Standby", tone: telemetry.pv_status === "generating" ? "blue" : "grey" }}>
              <UM_BigValue>{fmt(pvPowerW, 0)} W</UM_BigValue>
              <UM_Row label="Panel Voltage" value={`${fmt(telemetry.pv_panel_voltage_v, 2)} V`} />
              <UM_Row label="Output Current" value={`${fmt(telemetry.pv_output_current_a, 2)} A`} last />
            </UM_Card>
          )
        )}
      </div>

      {/* Center — product outline. width bumped 170 -> 220 -> 380 (was
          sized for the old 148x476 line-art frame) so the tower reads
          as this dashboard's central visual anchor per the customer's
          explicit "make the tower image bigger and more of a feature
          of the page, it's far too small" ask, not a small aside next
          to the data cards -- height:"100%" lets it use the full row
          height the 280px side columns' card stacks establish, and the
          grid's own center track (below) was widened from a plain 1fr
          to minmax(420px, 1fr) so this doesn't get squeezed back down
          on narrower viewports. */}
      <div style={{ display: "flex", flexDirection: "column", alignItems: "center", height: "100%", width: "100%" }}>
        {/* Armed/Disarmed status -- was a small plain text+dot row,
            then a small floating pill -- both read as disconnected
            from the rest of the page, sitting in empty white space
            with nothing tying it to the layout ("the armed button
            looks out of place"). Now built as a proper full-width
            card matching the exact same white/bordered/padded shell
            every other tile on this page uses (title row + badge,
            same as UM_Card), so it reads as a genuine tile that
            belongs in the grid -- not a stray button -- while the
            large centred ARMED/UNARMED readout underneath still gives
            it the extra visual weight the "more visible" ask wanted.
            Sits at the top of the center column, level with EFOY
            (left) / Relay Control (right). Omitted entirely alongside
            the glow when this unit has no security system to report
            on. */}
        {securityArmedLive !== null && (
          <div
            data-testid="unit-tower-security-banner"
            style={{ width: "100%", background: "#FFFFFF", border: "1px solid #E2DCCB", padding: "18px 18px 20px" }}
          >
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
              <div style={{
                fontFamily: "var(--font-body)", fontSize: 11.5, fontWeight: 700,
                letterSpacing: "0.04em", textTransform: "uppercase", color: "#3A3630",
              }}>Security Status</div>
              <UM_Badge label={securityArmedLive ? "Armed" : "Unarmed"} tone={securityArmedLive ? "red" : "green"} />
            </div>
            <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10 }}>
              <span aria-hidden="true" style={{
                width: 11, height: 11, borderRadius: "50%",
                background: securityArmedLive ? "#D92B2B" : "#1E9E5A",
              }} />
              <span style={{
                fontFamily: "var(--font-display)", fontSize: 26, fontWeight: 700,
                letterSpacing: "0.02em", textTransform: "uppercase",
                color: securityArmedLive ? "#D92B2B" : "#1E9E5A",
              }}>
                {securityArmedLive ? "Armed" : "Unarmed"}
              </span>
            </div>
          </div>
        )}
        {/* justifyContent: "flex-start" (was "center") + a small fixed
            top gap -- customer: "Move the tower image up". Centering in
            the remaining flex space (after the Security Status card
            above) pushed the tower photo down into the middle of a tall
            column whose height is set by the left/right cards stacks,
            leaving a big empty gap above it and cutting the tower off
            at the bottom of the viewport. Anchoring it to the top of
            this remaining space instead keeps it right under Security
            Status, matching where the eye lands first. */}
        <div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-start", paddingTop: 16 }}>
          <UM_TowerPhoto
            width={380}
            mainsConnected={mainsConnected}
            showMains={!!unit.has_mains_power}
            ledsOn={ledsOn}
            securityArmed={securityArmedLive}
          />
          {/* Asset name (unit.product_name, e.g. "Solo PCT") and the
              serial number (unit.serial_number, e.g.
              "PCT-V4-EU-40896") have both now been removed from below
              the tower per "remove the asset name below the tower"
              and the follow-up "remove the product ID below" -- the
              tower photo + Security Status tile above are now the
              only content in this center column, nothing identifying
              text underneath it. */}
        </div>
      </div>

      {/* Right column */}
      <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
        {(ajaxHub || ajaxRelayDevices.length > 0) && (
          <UM_RelayPanel ajaxHub={ajaxHub} devices={ajaxRelayDevices} relayUrlBase={relayUrlBase} hubUrlBase={hubUrlBase} onRelaysChanged={onRelaysChanged} onHubStateChanged={onHubStateChanged} onRelayStateChanged={onRelayStateChanged} companyName={unit.current_company_name} />
        )}

        {ajaxMonitorDevices.length > 0 && (
          <UM_DeviceStatusPanel devices={ajaxMonitorDevices} />
        )}

        {/* Location: real GPS (router.latitude) wins when the linked
            router has a GPS antenna fitted and a live fix. Most
            fielded RUT241s don't -- verified live against a real
            customer router where RMS itself only ever reports a
            cell-tower-triangulated estimate via router.cell_tower_
            latitude/longitude (see migrations/0053_teltonika_cell_
            tower_location.sql). Falls back to that estimate, badged
            "Approximate" (never shown with the same confidence as a
            real fix) with its accuracy radius, and a link out to
            Google Maps since this page has no in-app map widget.
            Gated on has_router same as the Internet Router card below
            it -- location only exists here because of the linked
            router. */}
        {!!unit.has_router && (() => {
          const hasRealGps = router && router.latitude != null;
          const hasCellTower = router && router.cell_tower_latitude != null;
          const lat = hasRealGps ? router.latitude : hasCellTower ? router.cell_tower_latitude : null;
          const lng = hasRealGps ? router.longitude : hasCellTower ? router.cell_tower_longitude : null;
          if (lat == null) return null;
          return (
            <UM_Card
              title="Location"
              testId="unit-router-location"
              badge={hasRealGps ? { label: "GPS", tone: "green" } : { label: "Approximate", tone: "grey" }}
            >
              <UM_LocationMap
                lat={lat}
                lng={lng}
                accuracyMeters={!hasRealGps ? router.cell_tower_accuracy : null}
              />
              <UM_Row label="Latitude" value={fmt(lat, 5)} />
              <UM_Row label="Longitude" value={fmt(lng, 5)} />
              {!hasRealGps && router.cell_tower_accuracy != null && (
                <UM_Row label="Accuracy" value={`\u00b1${Math.round(router.cell_tower_accuracy / 1000)} km`} />
              )}
              <div style={{ marginTop: 10 }}>
                <a
                  href={`https://www.google.com/maps/search/?api=1&query=${lat},${lng}`}
                  target="_blank" rel="noopener noreferrer"
                  data-testid="unit-router-location-map-link"
                  style={{
                    display: "block", textAlign: "center", background: "none",
                    border: "1px solid rgba(0,0,0,0.25)", color: "#3A3630",
                    padding: "8px 10px", fontFamily: "var(--font-body)", fontSize: 10.5,
                    fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase",
                    textDecoration: "none",
                  }}
                >View on Map</a>
              </div>
            </UM_Card>
          );
        })()}

        {!!unit.has_router && (
          <UM_Card title="Internet Router">
            <UM_Row label="Operator" value={UM_pick(router && router.operator, telemetry.router_operator) || "\u2014"} />
            {/* Signal: real router.signal is dBm (verified against RMS's
                live OpenAPI v3 spec), not a percent -- only the demo
                fallback below is an actual percentage. */}
            <UM_Row
              label="Signal"
              value={
                router && router.signal != null
                  ? `${fmt(router.signal, 0)} dBm`
                  : telemetry.router_signal_percent != null
                  ? `${fmt(telemetry.router_signal_percent, 0)}% (${fmt(telemetry.router_signal_dbm, 0)}dBm)`
                  : "\u2014"
              }
            />
            <UM_Row label="WWAN IP" value={UM_pick(router && router.wan_ip, telemetry.router_wwan_ip) || "\u2014"} />
            {/* Active SIM: real field is RMS's sim_slot
                (migrations/0051_teltonika_sim_slot.sql), now synced into
                teltonika_devices. Shown as "SIM <slot>" to match the
                slot-number shape of the real data; demo fallback keeps its
                own pre-existing label. */}
            <UM_Row label="Active SIM" value={router && router.sim_slot != null ? `SIM ${router.sim_slot}` : (telemetry.router_active_sim || "\u2014")} />
            <UM_Row label="Firmware" value={UM_pick(router && router.firmware, telemetry.router_firmware) || "\u2014"} />
            <UM_Row label="Temperature" value={(() => { const t = UM_pick(router && router.temperature, telemetry.router_temperature_c); return t != null ? `${fmt(t, 0)} \u00b0C` : "\u2014"; })()} />
            {/* Model/Serial: real fields, already synced into
                teltonika_devices by lib/teltonika.ts's listTeltonikaDevices
                (confirmed against RMS's live OpenAPI v3 device_body schema)
                -- just never rendered here before. */}
            <UM_Row label="Model" value={UM_pick(router && router.model, null) || "\u2014"} />
            <UM_Row label="Serial" value={UM_pick(router && router.serial, null) || "\u2014"} />
            {/* CPU Load: confirmed NOT available anywhere in RMS's real API
                (grepped the full OpenAPI v3 spec -- no cpu/ram/memory/flash
                field on the device object). Demo-only, can never reflect a
                real router. */}
            <UM_Row label="CPU Load" value={telemetry.router_cpu_load != null ? fmt(telemetry.router_cpu_load, 2) : "\u2014"} />
            <UM_Row label="MAC" value={UM_pick(router && router.mac, telemetry.router_mac) || "\u2014"} last />

            {/* Launch WebUI: real RMS capability, only offered once a
                real router is linked (router != null) -- there's no
                equivalent action possible against demo data. */}
            {router && (
              <div style={{ marginTop: 14 }}>
                <button
                  type="button" onClick={handleLaunchWebui} disabled={webuiBusy}
                  data-testid="unit-router-launch-webui"
                  style={{
                    width: "100%", background: "none", border: "1px solid rgba(0,0,0,0.25)", color: "#3A3630",
                    cursor: webuiBusy ? "default" : "pointer", opacity: webuiBusy ? 0.6 : 1,
                    padding: "8px 10px", fontFamily: "var(--font-body)", fontSize: 10.5,
                    fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase",
                  }}
                >{webuiBusy ? "Opening\u2026" : "Launch WebUI"}</button>
                {webuiError && (
                  <div data-testid="unit-router-launch-webui-error" style={{
                    marginTop: 8, background: "rgba(224,163,57,0.1)", border: "1px solid rgba(224,163,57,0.4)",
                    color: "#8a6d1f", padding: "8px 10px", fontFamily: "var(--font-body)", fontSize: 11.5, lineHeight: 1.5,
                  }}>{webuiError}</div>
                )}
              </div>
            )}
          </UM_Card>
        )}
      </div>
    </div>
    </div>
  );
}

function UM_formatRuntime(minutes) {
  if (minutes === null || minutes === undefined) return "\u2014";
  const h = Math.floor(minutes / 60);
  const m = Math.round(minutes % 60);
  return `${h}h ${m}min`;
}

// "convert and display the time to go hours into days and hours based
// on a 24 our day" -- a separate formatter from UM_formatRuntime (which
// stays "Xh Ymin" for its 3 EFOY runtime call sites). Used only by the
// Battery card's Time To Go / Run Time row, where a multi-day
// estimate (e.g. 240h) reads much better as "10d 0h" than "240h 0min".
function UM_formatDaysHours(minutes) {
  if (minutes === null || minutes === undefined) return "\u2014";
  const totalHours = Math.round(minutes / 60);
  const d = Math.floor(totalHours / 24);
  const h = totalHours % 24;
  return `${d}d ${h}h`;
}

// "i need a way of iding these to tidy it up" -- every UM_Card/UM_Row/
// UM_Badge now takes an optional testId and stamps it as data-testid,
// so each card/row/badge on the page is individually addressable
// (CSS targeting, QA scripts, or just "the badge on card X" in
// feedback) without having to describe it by screen position. testId
// is optional and purely additive -- omitting it changes nothing, so
// every one of this file's 100+ pre-existing <UM_Card>/<UM_Row> call
// sites keeps working exactly as before.
// "i want it expandable less detail somthing smart" -- UM_Card can now
// collapse to a single compact summary line and expand to its full
// detail on click, instead of always showing every UM_Row. "Smart"
// has three parts:
//  1. Remembers each card's expanded/collapsed state per browser
//     (localStorage, keyed by testId) -- a reseller who always wants
//     Connectivity Summary open doesn't have to re-open it every visit.
//  2. Defaults closed UNLESS the card's own badge tone is red/amber
//     (attention needed) -- so a healthy card starts tidy, but a card
//     actually flagging a problem opens itself automatically the first
//     time you see it, instead of hiding the one thing you needed to
//     notice behind a click.
//  3. A collapsed card still shows its badge (Healthy/Attention/etc)
//     plus a one-line `summary` (passed in by the caller -- usually
//     "the single most important row", e.g. Device Health's profile
//     name, or Camera Summary's "3/4 online") so collapsing never
//     hides the headline number, only the row-by-row breakdown.
// Only cards that opt in via `collapsible` get this behaviour --
// everywhere else in this file UM_Card renders exactly as before.
function UM_Card({ title, badge, testId, collapsible, summary, children }) {
  const storageKey = testId ? `um-card-expanded:${testId}` : null;
  const needsAttention = collapsible && badge && (badge.tone === "red" || badge.tone === "amber");
  const [expanded, setExpanded] = useState(() => {
    if (!collapsible) return true;
    if (storageKey) {
      try {
        const saved = window.localStorage.getItem(storageKey);
        if (saved === "1") return true;
        if (saved === "0") return false;
      } catch { /* localStorage unavailable (private mode, etc) -- fall through to the smart default */ }
    }
    return !!needsAttention;
  });

  const toggle = () => {
    const next = !expanded;
    setExpanded(next);
    if (storageKey) {
      try { window.localStorage.setItem(storageKey, next ? "1" : "0"); } catch { /* ignore */ }
    }
  };

  return (
    <div data-testid={testId} style={{ background: "#FFFFFF", border: "1px solid #E2DCCB", padding: "18px 18px 14px" }}>
      <div
        onClick={collapsible ? toggle : undefined}
        style={{
          display: "flex", justifyContent: "space-between", alignItems: "center",
          marginBottom: collapsible && !expanded ? 0 : 12,
          cursor: collapsible ? "pointer" : "default",
        }}
      >
        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          {collapsible && (
            <span
              data-testid={testId ? `${testId}-toggle` : undefined}
              style={{ color: "#8A8474", display: "flex", transform: expanded ? "rotate(0deg)" : "rotate(-90deg)", transition: "transform 120ms" }}
            >
              <IconChevronDown size={11} />
            </span>
          )}
          <div style={{
            fontFamily: "var(--font-body)", fontSize: 11.5, fontWeight: 700,
            letterSpacing: "0.04em", textTransform: "uppercase", color: "#3A3630",
          }}>{title}</div>
        </div>
        {badge && <UM_Badge label={badge.label} tone={badge.tone} testId={testId ? `${testId}-badge` : undefined} />}
      </div>
      {collapsible && !expanded ? (
        summary != null && (
          <div data-testid={testId ? `${testId}-summary` : undefined} style={{
            fontFamily: "var(--font-body)", fontSize: 12.5, color: "#8A8474", marginTop: 10,
          }}>{summary}</div>
        )
      ) : children}
    </div>
  );
}

function UM_Badge({ label, tone, testId }) {
  const tones = {
    grey: { bg: "#EDEAE1", fg: "#8A8474" },
    blue: { bg: "#DCEEFB", fg: "#2C7BB0" },
    green: { bg: "#DFF3E3", fg: "#2C8C4C" },
    // §5 status-bar color convention: Amber = Warning/Degraded.
    amber: { bg: "#FBEFD6", fg: "#B4740B" },
    red: { bg: "#FBE2E2", fg: "#B0302C" },
  };
  const c = tones[tone] || tones.grey;
  return (
    <span data-testid={testId} style={{
      background: c.bg, color: c.fg, fontFamily: "var(--font-body)",
      fontSize: 10, fontWeight: 600, letterSpacing: "0.05em", textTransform: "uppercase",
      padding: "4px 10px", borderRadius: 3,
    }}>{label}</span>
  );
}

function UM_BigValue({ children, small, testId }) {
  return (
    <div data-testid={testId} style={{
      fontFamily: "var(--font-display)", fontWeight: 700,
      fontSize: small ? 18 : 26, color: "#1A1712", marginBottom: 10,
    }}>{children}</div>
  );
}

function UM_Row({ label, value, last, testId }) {
  return (
    <div data-testid={testId} style={{
      display: "flex", justifyContent: "space-between", gap: 12,
      padding: "6px 0", borderBottom: last ? "none" : "1px solid #EEE9DD",
      fontFamily: "var(--font-body)", fontSize: 12.5,
    }}>
      <span style={{ color: "#8A8474" }}>{label}</span>
      <span style={{ color: "#1A1712", fontWeight: 500 }}>{value}</span>
    </div>
  );
}

// Embedded Leaflet map for the Unit Management "Location" card. Renders
// the router's position (real GPS or the Teltonika cell-tower estimate --
// see the card's own header comment above its call site for how `lat`/
// `lng` are chosen) as a live, pannable/zoomable OpenStreetMap view
// instead of the old bare "View on Map" link-out. When `accuracyMeters`
// is provided (i.e. this is a cell-tower estimate, not a real fix) a
// shaded circle is drawn around the marker sized to the real reported
// radius, so the "approximate, could be several km off" framing is
// visual as well as textual. Leaflet + its CSS are loaded globally via
// <script>/<link> tags in index.html (see the comment there) -- no
// bundler, so this component talks to the `L` global directly.
function UM_LocationMap({ lat, lng, accuracyMeters }) {
  const containerRef = useRef(null);
  const mapRef = useRef(null);

  useEffect(() => {
    if (!containerRef.current || typeof window.L === "undefined") return;
    if (lat == null || lng == null) return;

    const map = window.L.map(containerRef.current, {
      center: [lat, lng],
      zoom: accuracyMeters ? 11 : 15,
      scrollWheelZoom: false,
      attributionControl: false,
    });
    mapRef.current = map;

    // CARTO "Positron" basemap instead of stock OSM raster tiles --
    // customer feedback: "can we also have a better map please... 2
    // [tile style] - cleaner less info". Same free, no-API-key CDN
    // (basemaps.cartocdn.com) OSM data is still built from, just a
    // much lighter/muted style: no bright highway-orange, far fewer
    // competing colours/POI icons, subdued grey roads and place labels
    // only -- reads as a clean location reference instead of a full
    // turn-by-turn road atlas crammed into a small card.
    window.L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
      maxZoom: 19,
      subdomains: "abcd",
    }).addTo(map);

    // Slim attribution -- CARTO's usage policy requires crediting both
    // CARTO (the style) and OSM (the underlying data), kept compact so
    // it doesn't dominate the small card-embedded map.
    window.L.control.attribution({ prefix: false, position: "bottomright" })
      .addAttribution('&copy; <a href="https://carto.com/attributions" target="_blank" rel="noopener noreferrer">CARTO</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a>')
      .addTo(map);

    window.L.marker([lat, lng]).addTo(map);

    if (accuracyMeters) {
      // BUGFIX (this session): a SECOND, never-added L.circle used to be
      // created here just to read its .getBounds() -- but Leaflet's
      // Circle.getBounds() dereferences the circle's internal _map
      // reference, which is only ever set once a circle has actually
      // been .addTo()'d a map. Calling it on a circle that was never
      // added throws "Cannot read properties of undefined (reading
      // 'layerPointToLatLng')" -- and with no React error boundary
      // anywhere in the tree, that uncaught throw inside this
      // useEffect unmounted the ENTIRE app, leaving a blank white
      // screen. Only triggered for a cell-tower-ESTIMATED location
      // (accuracyMeters set) rather than a real GPS fix -- fixed by
      // reusing the SAME circle instance that was actually added to
      // the map above, so its _map reference is populated.
      const accuracyCircle = window.L.circle([lat, lng], {
        radius: accuracyMeters,
        color: "#8A8474",
        weight: 1,
        fillColor: "#8A8474",
        fillOpacity: 0.15,
      }).addTo(map);
      // Fit the view to the accuracy circle so its scale is legible
      // rather than always centering at a fixed zoom.
      map.fitBounds(accuracyCircle.getBounds(), { padding: [8, 8] });
    }

    return () => {
      map.remove();
      mapRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [lat, lng, accuracyMeters]);

  return (
    <div
      ref={containerRef}
      data-testid="unit-router-location-map"
      style={{
        height: 180, marginBottom: 12, background: "#EEE9DD",
        border: "1px solid #E2DCCB",
      }}
    />
  );
}

function UM_ToggleRow({ label, on, busy, onChange, last }) {
  return (
    <div style={{
      display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12,
      padding: "8px 0", borderBottom: last ? "none" : "1px solid #EEE9DD",
    }}>
      <span style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#1A1712" }}>{label}</span>
      <button
        type="button" disabled={busy} onClick={() => onChange(!on)}
        data-testid={`unit-toggle-${label.toLowerCase()}`}
        style={{
          width: 42, height: 24, borderRadius: 12, border: "none", padding: 2,
          background: on ? "#3FAE5C" : "#D5D0C3", cursor: busy ? "not-allowed" : "pointer",
          opacity: busy ? 0.6 : 1, display: "flex", justifyContent: on ? "flex-end" : "flex-start",
          transition: "background 140ms",
        }}
      >
        <span style={{ width: 20, height: 20, borderRadius: "50%", background: "#fff", display: "block" }} />
      </button>
    </div>
  );
}

// ─────────────────────── Ajax relay control ───────────────────────
// Status-only panel for Ajax relay devices (LED Relay, Schedule Relay,
// Remote Reboot, etc.) — customer, this session: "For these relays we
// just need to see the status, we dont need to switch on or off... So
// we can see state from the API we want to show this. We can see when
// its online", followed by an explicit "1" choosing to remove BOTH the
// Turn On/Off toggle button AND the per-day schedule editor entirely
// (no configuration UI of any kind, purely a status readout).
//
// Previously this panel let an operator flip the relay on/off
// (handleToggleRelay, calling relayUrlBase's real Ajax command) and
// edit a 7x24 on/off schedule grid stored in ajax_relay_config
// (migrations/0027). Neither survives: the schedule was never actually
// enforced anywhere server-side (no cron/scheduled task ever read
// schedule_enabled/schedule_json -- confirmed by grep across
// src/routes/*.ts -- it was purely a UI-only editable grid with no
// backing automation), so removing it loses no real functionality.
// The backend PUT toggle/config routes (admin-assets.ts,
// portal-mission-control.ts) and runAjaxToggleRelay (admin-ajax.ts)
// are left in place but are now unused dead code from this page --
// left in case a future phase wants real relay control back.
//
// What IS shown instead: every field below is a REAL, confirmed Ajax
// Enterprise API `Relay` schema field (cross-referenced against Ajax's
// own cached swagger spec this session) that's already being synced
// end-to-end (listAjaxDevices with enrich=true -> normalizeAjaxDevice
// -> ajax_devices.state_json, see lib/ajax.ts) -- no backend/sync
// changes were needed to surface this, it was already sitting in the
// raw "Additional Diagnostics" dump. switchState is the AUTHORITATIVE
// real on/off/fault state (not to be confused with the old locally-
// cached "on" flag this file used to write via the toggle button).
function UM_relaySwitchStateDisplay(switchState) {
  const state = Array.isArray(switchState) ? switchState[0] : switchState;
  switch (state) {
    case "SWITCHED_ON": return { label: "On", tone: "green" };
    case "SWITCHED_OFF": return { label: "Off", tone: "grey" };
    case "OFF_TOO_LOW_VOLTAGE": return { label: "Off \u2014 Low Voltage", tone: "amber" };
    case "OFF_HIGH_VOLTAGE": return { label: "Off \u2014 High Voltage", tone: "amber" };
    case "OFF_HIGH_TEMPERATURE": return { label: "Off \u2014 High Temperature", tone: "red" };
    case "CONTACT_HANG": return { label: "Contact Hang", tone: "red" };
    default: return null;
  }
}

// Monitor-only card for Ajax sensor/siren devices (Tamper Protection,
// Sounder 1, Sounder 2, etc.) that were previously mixed into
// UM_RelayPanel as toggleable relays -- these aren't controllable
// relays, so this just reports each device's name and its live
// ajax_devices.online (0/1) status as an Online/Offline badge, no
// toggle/schedule/label controls.
function UM_DeviceStatusPanel({ devices }) {
  return (
    <UM_Card title="Security Sensors">
      {devices.map((d, i) => (
        <UM_Row
          key={d.id}
          label={d.name || d.device_type || "Device"}
          value={<UM_Badge label={d.online ? "Online" : "Offline"} tone={d.online ? "green" : "red"} />}
          last={i === devices.length - 1}
        />
      ))}
    </UM_Card>
  );
}

function UM_RelayPanel({ ajaxHub, devices, hubUrlBase, onRelaysChanged, onHubStateChanged, companyName }) {
  return (
    <UM_Card title="Relay Control">
      {ajaxHub && <UM_HubArmRow hub={ajaxHub} hubUrlBase={hubUrlBase} onHubChanged={onRelaysChanged} onHubStateChanged={onHubStateChanged} companyName={companyName} />}
      {devices.length === 0 ? (
        <div style={{ fontFamily: "var(--font-body)", fontSize: 12, color: "#8A8474" }}>No relays linked yet.</div>
      ) : (
        devices.map((d, i) => (
          <UM_RelayRow key={d.id} device={d} last={i === devices.length - 1} />
        ))
      )}
    </UM_Card>
  );
}

// Arm/disarm control for the Ajax hub itself -- distinct from the
// individual relay on/off rows below it. hub.state is one of
// ARMED | DISARMED | PARTIAL_ARMED | ARMING | DISARMING (see
// migrations/0021_ajax_per_company.sql); the toggle only ever requests
// ARMED or DISARMED, calling through to Ajax's real Enterprise API
// command (routes/admin-ajax.ts's runAjaxHubArm -- confirmed live this
// session against this pilot company's own hub), so PARTIAL_ARMED /
// ARMING / DISARMING just render as their own badge tone without a
// dedicated control until real vendor sync lands.
//
// Pilot rollout: only enabled for AJAX_ARM_PILOT_COMPANY_NAME's units
// (see that constant's header comment) -- every other company sees the
// toggle replaced with UM_NotYetAvailableControl instead of a button
// that would just 403. This is a UX convenience only; the real
// enforcement is server-side.
function UM_HubArmRow({ hub, hubUrlBase, onHubChanged, onHubStateChanged, companyName }) {
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  const stateUpper = String(hub.state || "").toUpperCase();
  const armed = UM_hubArmedForDisplay(hub.state);
  const inTransition = stateUpper === "ARMING" || stateUpper === "DISARMING";
  const statusLabel = hub.state || "UNKNOWN";

  if (companyName !== AJAX_ARM_PILOT_COMPANY_NAME) {
    return (
      <UM_NotYetAvailableControl
        label="Arm / Disarm Hub"
        note={`Currently piloting with ${AJAX_ARM_PILOT_COMPANY_NAME} only.`}
      />
    );
  }

  const handleToggleHub = () => {
    // TRUE instant toggle: flip the badge/button/Security Status glow
    // the MOMENT the user clicks, assuming the command will succeed --
    // do not wait for the Ajax Enterprise API round-trip (which can take
    // 1-3+ seconds) before changing anything on screen. The real API
    // call runs in the background below; if it actually fails we revert
    // to the previous state and surface the error.
    const previousState = hub.state;
    const optimisticState = armed ? "DISARMED" : "ARMED";
    setError("");
    setBusy(true);
    if (onHubStateChanged) onHubStateChanged(hub.id, optimisticState);
    (async () => {
      try {
        const res = await fetch(`${hubUrlBase}/${hub.id}/toggle`, { method: "PUT", credentials: "same-origin" });
        const data = await res.json().catch(() => ({}));
        if (!res.ok) throw new Error(data.error || "Couldn't arm/disarm this hub.");
        // Reconcile with the real post-command state in case it differs
        // from our optimistic guess (e.g. Ajax reports PARTIAL_ARMED).
        if (onHubStateChanged && data.state) onHubStateChanged(hub.id, data.state);
        if (onHubChanged) onHubChanged(true); // background reconcile only -- silent
      } catch (err) {
        // The command didn't actually succeed -- revert the optimistic flip.
        if (onHubStateChanged) onHubStateChanged(hub.id, previousState);
        setError(err.message);
      } finally { setBusy(false); }
    })();
  };

  return (
    <div style={{ padding: "8px 0 14px", borderBottom: "1px solid #E2DCCB", marginBottom: 10 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
          <span style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#1A1712", fontWeight: 600 }}>
            Ajax Hub
          </span>
          <span style={{
            fontFamily: "var(--font-body)", fontSize: 10, fontWeight: 600, letterSpacing: "0.05em",
            textTransform: "uppercase", padding: "3px 8px", borderRadius: 3,
            // Armed reads as red (not green) -- an armed hub is the
            // "attention" state a reseller/admin should notice at a
            // glance, matching the Security Status tile's own
            // armed=red/unarmed=green convention elsewhere on this page.
            background: armed ? "#FBDEDE" : "#EDEAE1",
            color: armed ? "#D92B2B" : "#8A8474",
          }}>{statusLabel}</span>
        </div>
        {/* Action button, not a sliding toggle -- customer: "shall we do
            a button? and it just changes state is this easier?" +
            "What will be smoother and faster?". A button showing the
            ACTION you're about to take ("Arm"/"Disarm") is a single
            element with no separate on/off knob position to keep in
            sync with the state text above, and deliberately has NO
            background-color transition -- the instant optimistic flip
            (handleToggleHub) lands on screen with nothing animating in
            between, which is the actual "smoother/faster" win over the
            old switch's 140ms colour fade. */}
        <button
          type="button" disabled={busy || inTransition} onClick={handleToggleHub}
          data-testid="unit-hub-arm-toggle"
          title={armed ? "Disarm hub" : "Arm hub"}
          style={{
            border: "none", borderRadius: 4, flexShrink: 0, padding: "7px 14px",
            background: armed ? "#D92B2B" : "#3A342A", color: "#fff",
            cursor: (busy || inTransition) ? "not-allowed" : "pointer",
            opacity: (busy || inTransition) ? 0.6 : 1,
            fontFamily: "var(--font-body)", fontSize: 10.5, fontWeight: 600,
            letterSpacing: "0.05em", textTransform: "uppercase", whiteSpace: "nowrap",
          }}
        >{armed ? "Disarm" : "Arm"}</button>
      </div>
      {error && (
        <div data-testid="unit-hub-arm-toggle-error" style={{
          marginTop: 8, background: "rgba(224,163,57,0.1)", border: "1px solid rgba(224,163,57,0.4)",
          color: "#8a6d1f", padding: "8px 10px", fontFamily: "var(--font-body)", fontSize: 11.5, lineHeight: 1.5,
        }}>{error}</div>
      )}
    </div>
  );
}

function UM_RelayRow({ device, last }) {
  // Pure status readout -- no toggle, no schedule, nothing to
  // configure (customer's explicit "1" choice). `state` is Ajax's raw
  // per-device `model` blob (ajax_devices.state_json), so every field
  // read below is a real, confirmed Relay schema field, not a guess.
  let state = {};
  try { state = JSON.parse(device.state_json || "{}"); } catch { /* ignore */ }
  const switchDisplay = UM_relaySwitchStateDisplay(state.switchState);
  const label = (device.relay_config && device.relay_config.custom_label) || device.name || device.device_type || "Relay";

  return (
    <div data-testid={`unit-relay-row-${device.id}`} style={{ borderBottom: last ? "none" : "1px solid #EEE9DD", padding: "8px 0" }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10 }}>
        <span style={{ fontFamily: "var(--font-body)", fontSize: 12.5, color: "#1A1712" }}>{label}</span>
        <div style={{ display: "flex", alignItems: "center", gap: 6, flexShrink: 0 }}>
          {switchDisplay && <UM_Badge label={switchDisplay.label} tone={switchDisplay.tone} testId={`unit-relay-switch-state-${device.id}`} />}
          <UM_Badge label={device.online ? "Online" : "Offline"} tone={device.online ? "green" : "red"} testId={`unit-relay-online-${device.id}`} />
        </div>
      </div>
      {(state.voltageMilliVolts != null || state.temperature != null || state.signalLevel) && (
        <div style={{
          display: "flex", flexWrap: "wrap", gap: 14, marginTop: 6,
          fontFamily: "var(--font-body)", fontSize: 10.5, color: "#8A8474",
        }}>
          {state.voltageMilliVolts != null && <span>Voltage: {(state.voltageMilliVolts / 1000).toFixed(2)} V</span>}
          {state.temperature != null && <span>{`Temp: ${state.temperature} \u00b0C`}</span>}
          {state.signalLevel && <span>Signal: {String(state.signalLevel).replace(/_/g, " ")}</span>}
        </div>
      )}
    </div>
  );
}

function UM_EmptyNote({ children }) {
  return (
    <div style={{
      fontFamily: "var(--font-body)", fontSize: 14,
      color: "rgba(0,0,0,0.5)", padding: "40px 0",
    }}>{children}</div>
  );
}

Object.assign(window, { UnitManagementPage });
