// Direction B — section panels (Storage / Downloads / Seeding / Calendar / Tasks / Shoko / Failed)

const B2 = (() => {
  const { useMemo, useState, useEffect } = React;
  const { bytes, speed, pct, relTime, duration, airTime, weekday,
          STATUS_TONE, seedingHealth, sourceLink, sourceCalendarLink, LINKS,
          activeDownloads, completedSeeds } = window.HL;
  const { tones, Chip, Panel } = window.DirectionB;

  // ── responsive ─────────────────────────────────────────────────────
  // The dashboard is desktop-first; below this width the multi-column
  // grids collapse and the calendar shrinks its forecast. One hook,
  // called once in OperatorDashboard, threads `narrow` down as a prop.
  const NARROW_BP = 880;
  function useNarrow() {
    const get = () => typeof window !== "undefined" && window.innerWidth < NARROW_BP;
    const [narrow, setNarrow] = useState(get);
    useEffect(() => {
      const on = () => setNarrow(get());
      window.addEventListener("resize", on);
      return () => window.removeEventListener("resize", on);
    }, []);
    return narrow;
  }

  // ── Storage (gauge cards) ──────────────────────────────────────────
  function Storage({ section, narrow }) {
    const pools = section.data || [];
    const cols = narrow ? 1 : Math.min(pools.length, 3);
    // Surface aggregate health on the Panel border so the status is visible
    // even when the panel is collapsed.
    const panelTone = pools.some(p => !p.healthy) ? "bad"
                    : pools.some(p => (p.used_percent || 0) > 90) ? "warn"
                    : undefined;
    return (
      <Panel title="Storage pools" count={pools.length} updated={section.updated_at}
             error={section.error} href={LINKS.truenasStorage} tone={panelTone}>
        <div style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
          {pools.map((p, i) => {
            const tone = !p.healthy ? "bad" : (p.used_percent || 0) > 90 ? "warn" : "ok";
            const used = (p.used_percent || 0) / 100;
            const r = 56;
            const c = 2 * Math.PI * r;
            return (
              <div key={p.name} style={{
                padding: 18, display: "flex", gap: 16, alignItems: "center",
                ...(i < pools.length - 1
                  ? (narrow
                      ? { borderBottom: "1px solid var(--b-border)" }
                      : { borderRight: "1px solid var(--b-border)" })
                  : {}),
              }}>
                {/* radial gauge */}
                <svg width="140" height="140" viewBox="0 0 140 140" style={{ flexShrink: 0 }}>
                  <circle cx="70" cy="70" r={r} fill="none"
                          stroke="color-mix(in oklab, var(--b-text) 8%, transparent)" strokeWidth="10" />
                  <circle cx="70" cy="70" r={r} fill="none"
                          stroke={tones[tone]} strokeWidth="10" strokeLinecap="butt"
                          strokeDasharray={`${used * c} ${c}`}
                          transform="rotate(-90 70 70)" />
                  <text x="70" y="68" textAnchor="middle" fill="var(--b-text)"
                        style={{ fontFamily: "var(--mono)", fontSize: 22, fontWeight: 500 }}>
                    {pct(p.used_percent, 0)}
                  </text>
                  <text x="70" y="86" textAnchor="middle" fill="var(--b-muted)"
                        style={{ fontFamily: "var(--mono)", fontSize: 10, letterSpacing: 1 }}>
                    USED
                  </text>
                </svg>
                <div style={{ minWidth: 0 }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                    <span style={{ fontFamily: "var(--mono)", fontSize: 16, color: "var(--b-text)" }}>{p.name}</span>
                    <Chip tone={tone}>{p.status}</Chip>
                  </div>
                  <div style={{ marginTop: 10, display: "grid", gap: 4,
                                fontFamily: "var(--mono)", fontSize: 11, color: "var(--b-muted)" }}>
                    <div><b style={{ color: "var(--b-text)" }}>{bytes(p.free_bytes)}</b> free of {bytes(p.size_bytes)}</div>
                    <div>{p.fragmentation_percent ?? "—"}% frag · <span style={{ color: p.scrub_errors ? tones.bad : "var(--b-muted)" }}>{p.scrub_errors ?? 0} scrub err</span></div>
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      </Panel>
    );
  }

  // ── Downloads ──────────────────────────────────────────────────────
  function Downloads({ section }) {
    const items = activeDownloads(section.data);
    // Nothing actively downloading — hide the panel entirely. Kept visible
    // only if the collector errored, so the degraded state is still surfaced.
    if (items.length === 0 && !section.error) return null;
    // Route the OPEN link to whichever downloader(s) actually have active
    // items — one button if only one client is busy, two (labelled) if both.
    const hasQbit = items.some(d => d.client === "qbittorrent");
    const hasJd   = items.some(d => d.client !== "qbittorrent");
    const links = [
      hasQbit && { href: LINKS.qbit, label: "qbt" },
      hasJd   && { href: LINKS.jdownloader, label: "jd" },
    ].filter(Boolean);
    return (
      <Panel title="Active downloads" count={items.length}
             updated={section.updated_at} error={section.error}
             links={links.length > 1 ? links : undefined}
             href={links.length === 1 ? links[0].href : undefined}>
        {items.length === 0 ? (
          <div style={{ padding: 18, color: "var(--b-muted)", fontSize: 13 }}>Queue empty.</div>
        ) : items.map((d, i) => {
          const tone = STATUS_TONE[d.state] || "muted";
          return (
            <div key={i} style={{
              padding: "12px 16px",
              borderBottom: i < items.length - 1 ? "1px solid var(--b-border)" : "none",
              display: "grid", gridTemplateColumns: "auto 1fr auto", gap: 16, alignItems: "center",
            }}>
              <Chip tone="muted">{d.client === "qbittorrent" ? "QBIT" : "JD"}</Chip>
              <div style={{ minWidth: 0 }}>
                <div style={{ fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--b-text)",
                              overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                  {d.name}
                </div>
                <div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 12 }}>
                  <div style={{ flex: 1, height: 6, background: "color-mix(in oklab, var(--b-text) 8%, transparent)", borderRadius: 999, overflow: "hidden" }}>
                    <div style={{
                      width: `${d.progress * 100}%`, height: "100%", background: tones[tone],
                      backgroundImage: d.state === "downloading" ?
                        `repeating-linear-gradient(45deg, transparent 0 6px, color-mix(in oklab, ${tones[tone]} 50%, transparent) 6px 12px)`
                        : undefined,
                    }} />
                  </div>
                  <span style={{ fontFamily: "var(--mono)", fontSize: 11, color: tones[tone], minWidth: 46, textAlign: "right" }}>
                    {pct(d.progress * 100, 1)}
                  </span>
                </div>
                <div style={{ marginTop: 4, display: "flex", gap: 14, fontFamily: "var(--mono)", fontSize: 11, color: "var(--b-muted)" }}>
                  <span>{bytes(d.size)}</span>
                  {d.dl_speed > 0 && <span style={{ color: tones.info }}>↓ {speed(d.dl_speed)}</span>}
                  {d.up_speed > 0 && <span style={{ color: tones.ok }}>↑ {speed(d.up_speed)}</span>}
                  {d.eta_seconds && <span>ETA {duration(d.eta_seconds)}</span>}
                  {d.tracker_host && <span>{d.tracker_host}</span>}
                </div>
              </div>
              <Chip tone={tone}>{d.state}</Chip>
            </div>
          );
        })}
      </Panel>
    );
  }

  // ── Seeding health ─────────────────────────────────────────────────
  function Seeding({ section, narrow }) {
    const seeds = completedSeeds(section.data);
    // No completed seeds at all — hide the panel (host strip would be empty
    // too). Kept visible on a collector error to surface the degraded state.
    if (seeds.length === 0 && !section.error) return null;
    const annotated = seeds.map(d => ({ d, h: seedingHealth(d) }));
    // Only flagged rows — green seeds aren't actionable, the host strip rolls them up.
    const flagged = annotated
      .filter(x => x.h && (x.h.level === "risk" || x.h.level === "watch"))
      .sort((a, b) => {
        const order = { risk: 0, watch: 1 };
        return (order[a.h.level] ?? 9) - (order[b.h.level] ?? 9);
      });

    const byHost = {};
    annotated.filter(x => x.h).forEach(({ d, h }) => {
      const host = d.tracker_host || "unknown";
      const b = byHost[host] || (byHost[host] = { host, total: 0, risk: 0, watch: 0, ratio: 0, up: 0 });
      b.total++; b.ratio += d.ratio || 0; b.up += d.uploaded || 0;
      if (h.level === "risk") b.risk++;
      if (h.level === "watch") b.watch++;
    });
    const hosts = Object.values(byHost).sort((a, b) => b.risk - a.risk || b.watch - a.watch);
    // Surface aggregate seeding health on the Panel border so the status is
    // visible even when the panel is collapsed.
    const panelTone = hosts.some(h => h.risk) ? "bad"
                    : hosts.some(h => h.watch) ? "warn"
                    : undefined;

    return (
      <Panel title="Seeding health" count={`${flagged.length} flagged · ${seeds.length} total`}
             updated={section.updated_at} error={section.error} href={LINKS.qbit}
             tone={panelTone}>
        {/* host strip — per-tracker rollup, all hosts shown so the green ones reassure */}
        <div style={{ display: "flex", gap: 0, borderBottom: "1px solid var(--b-border)" }}>
          {hosts.map((h, i) => {
            const tone = h.risk ? "bad" : h.watch ? "warn" : "ok";
            return (
              <div key={h.host} style={{
                flex: 1, padding: "12px 14px",
                borderRight: i < hosts.length - 1 ? "1px solid var(--b-border)" : "none",
                borderTop: `2px solid ${tones[tone]}`,
              }}>
                <div style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--b-text)",
                              overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{h.host}</div>
                <div style={{ marginTop: 6, display: "flex", alignItems: "baseline", gap: 8 }}>
                  <span style={{ fontFamily: "var(--mono)", fontSize: 20, color: tones[tone] }}>
                    {(h.ratio / h.total).toFixed(2)}
                  </span>
                  <span style={{ fontFamily: "var(--mono)", fontSize: 10, color: "var(--b-muted)" }}>avg ratio</span>
                </div>
                <div style={{ marginTop: 4, fontFamily: "var(--mono)", fontSize: 10, color: "var(--b-muted)" }}>
                  {h.total} seeds · {bytes(h.up)} uploaded
                  {h.risk ? ` · ${h.risk} risk` : ""}{h.watch ? ` · ${h.watch} watch` : ""}
                </div>
              </div>
            );
          })}
        </div>
        {/* per-torrent rows — only risk/watch */}
        {flagged.length === 0 ? (
          <div style={{ padding: "16px 18px", display: "flex", alignItems: "center", gap: 10,
                        color: "var(--b-muted)", fontSize: 12 }}>
            <span style={{ color: tones.ok }}>●</span>
            All private-tracker seeds healthy — no strike-risk items.
          </div>
        ) : flagged.map(({ d, h }, i) => {
          const tone = h.level === "risk" ? "bad" : "warn";
          const seedH = (d.seeding_time || 0) / 3600;
          const mono = "var(--mono)";
          const lbl = { fontFamily: mono, fontSize: 10, color: "var(--b-muted)" };
          const ratioCell = (
            <div style={{ textAlign: narrow ? "left" : "right" }}>
              <div style={{ fontFamily: mono, fontSize: 15, color: tones[tone] }}>
                {(d.ratio || 0).toFixed(2)}
              </div>
              <div style={lbl}>ratio</div>
            </div>
          );
          const seedCell = (
            <div style={{ textAlign: narrow ? "left" : "right" }}>
              <div style={{ fontFamily: mono, fontSize: 12, color: seedH < 72 ? tones.warn : "var(--b-text)" }}>
                {duration(d.seeding_time || 0)}
              </div>
              <div style={lbl}>seed time</div>
            </div>
          );
          const upCell = (
            <div style={{ textAlign: narrow ? "left" : "right" }}>
              <div style={{ fontFamily: mono, fontSize: 12, color: "var(--b-text)" }}>
                {bytes(d.uploaded)}
              </div>
              <div style={lbl}>↑ {d.up_speed ? speed(d.up_speed) : "idle"}</div>
            </div>
          );
          const titleBlock = (
            <div style={{ minWidth: 0 }}>
              <div style={{ fontFamily: mono, fontSize: 12, color: "var(--b-text)",
                            overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                {d.name}
              </div>
              <div style={{ fontSize: 11, color: "var(--b-muted)", marginTop: 2, fontFamily: mono,
                            whiteSpace: narrow ? "normal" : "nowrap",
                            overflow: narrow ? "visible" : "hidden",
                            textOverflow: narrow ? "clip" : "ellipsis" }}>
                {d.tracker_host} · {bytes(d.size)}{d.category ? ` · ${d.category}` : ""}
                {h.reasons.length ? ` · ${h.reasons.join(", ")}` : ""}
              </div>
            </div>
          );
          // On narrow viewports the 5-column desktop grid shears each metric
          // into an unreadable vertical column. Switch to a stacked layout:
          // chip + title on top, metrics in a 3-up row below.
          if (narrow) {
            return (
              <div key={i} style={{
                padding: "12px 16px",
                borderBottom: i < flagged.length - 1 ? "1px solid var(--b-border)" : "none",
                display: "flex", flexDirection: "column", gap: 10,
              }}>
                <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
                  <Chip tone={tone} solid={tone === "bad"}>{h.level}</Chip>
                  {titleBlock}
                </div>
                <div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12 }}>
                  {ratioCell}{seedCell}{upCell}
                </div>
              </div>
            );
          }
          return (
            <div key={i} style={{
              padding: "10px 16px",
              borderBottom: i < flagged.length - 1 ? "1px solid var(--b-border)" : "none",
              display: "grid", gridTemplateColumns: "auto 1fr 90px 110px 110px", gap: 14, alignItems: "center",
            }}>
              <Chip tone={tone} solid={tone === "bad"}>{h.level}</Chip>
              {titleBlock}
              {ratioCell}
              {seedCell}
              {upCell}
            </div>
          );
        })}
      </Panel>
    );
  }

  // ── RomM live scan ─────────────────────────────────────────────────
  // Connects straight to RomM's Socket.IO (same-site, so RomM's session
  // cookie rides along — no creds in the page) and renders live library-
  // scan progress. Read-only: it only listens for scan:* broadcasts.
  // RomM scan state is hoisted into a hook so the parent can know whether the
  // RomM panel will actually render (it's null when idle/offline) and size the
  // activity-row grid accordingly.
  function useRommScan() {
    const [conn, setConn] = useState("connecting");
    const [scan, setScan] = useState(null);

    useEffect(() => {
      if (!window.io) { setConn("down"); return; }
      const sock = window.io("wss://romm.amochis.link", {
        path: "/ws/socket.io", transports: ["websocket"], withCredentials: true,
      });
      const patch = (p) => setScan((s) =>
        ({ phase: "scanning", ...(s && s.phase === "scanning" ? s : {}), ...p }));
      sock.on("connect",       () => setConn("live"));
      sock.on("disconnect",    () => setConn("down"));
      sock.on("connect_error", () => setConn("down"));
      sock.on("scan:scanning_platform", (p) => setScan((s) => {
        const prev = s && s.phase === "scanning" ? s : {};
        const st = prev.stats || {};
        return { ...prev, phase: "scanning", platform: p,
          baseline: { scanned: st.scanned_roms || 0, new: st.new_roms || 0, identified: st.identified_roms || 0 } };
      }));
      sock.on("scan:scanning_rom",      (r)  => patch({ rom: r }));
      sock.on("scan:update_stats",      (st) => patch({ stats: st }));
      sock.on("scan:done",    (st) => setScan({ phase: "done", stats: st }));
      sock.on("scan:done_ko", (e)  => setScan({ phase: "failed", error: String(e) }));
      return () => sock.close();
    }, []);

    return { conn, scan };
  }

  // Whether the Romm panel would actually render — keep in sync with Romm()'s
  // own early-return at the top of its body.
  function rommVisible(state) {
    return state.conn === "live" && !!state.scan;
  }

  function Romm({ state }) {
    const { conn, scan } = state;
    const muted = { fontFamily: "var(--mono)", fontSize: 12, color: "var(--b-muted)" };
    const pad = { padding: "12px 16px" };
    const st = scan && scan.stats;
    const bl = scan && scan.baseline || { scanned: 0, new: 0, identified: 0 };

    // Per-platform deltas since the current platform started.
    const dScanned    = (st ? st.scanned_roms : 0)    - bl.scanned;
    const dNew        = (st ? st.new_roms : 0)         - bl.new;
    const dIdentified = (st ? st.identified_roms : 0)  - bl.identified;

    // Discover vs rescan — scoped to the current platform.
    // If new roms appeared on this platform, files on disk > known games →
    // show identification coverage. Otherwise scanned/total is linear and fine.
    const isDiscover = dNew > 0;
    const platTotal  = dScanned > 0 ? dScanned : 1;
    const platFrac   = isDiscover
      ? Math.min(1, dIdentified / platTotal)
      : (st && st.total_roms ? Math.min(1, (st.scanned_roms || 0) / st.total_roms) : 0);

    // Overall progress: platforms scanned / total platforms.
    const platBar = st && st.total_platforms
      ? Math.min(1, (st.scanned_platforms || 0) / st.total_platforms) : 0;

    const status = conn !== "live" ? (conn === "down" ? "offline" : "…")
      : scan && scan.phase === "scanning" ? "scanning" : "live";

    if (conn !== "live" || !scan) return null;

    let body;
    if (scan.phase === "failed") {
      body = <div style={{ ...pad, fontFamily: "var(--mono)", fontSize: 12, color: tones.bad }}>
        Scan failed — {scan.error}
      </div>;
    } else if (scan.phase === "done") {
      body = <div style={{ ...pad, display: "flex", flexDirection: "column", gap: 6 }}>
        <div style={{ fontFamily: "var(--mono)", fontSize: 12.5, color: tones.ok }}>● Scan complete</div>
        {st && <div style={muted}>
          +{st.new_roms} new · {st.identified_roms} identified · {st.scanned_roms} roms
        </div>}
      </div>;
    } else {
      body = <div style={{ ...pad, display: "flex", flexDirection: "column", gap: 8 }}>
        <div style={{ fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--b-text)",
                      overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
          Scanning {scan.platform ? (scan.platform.display_name || scan.platform.name) : "…"}
          {st && st.total_platforms ? <span style={{ color: "var(--b-muted)", fontWeight: 400 }}>
            {" "}({st.scanned_platforms || 0}/{st.total_platforms})
          </span> : null}
        </div>
        {/* Overall platform progress bar */}
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          <div style={{ flex: 1, height: 6, borderRadius: 999, overflow: "hidden",
                        background: "color-mix(in oklab, var(--b-text) 8%, transparent)" }}>
            <div style={{ width: `${platBar * 100}%`, height: "100%", background: tones.info,
              backgroundImage: `repeating-linear-gradient(45deg, transparent 0 6px, color-mix(in oklab, ${tones.info} 50%, transparent) 6px 12px)` }} />
          </div>
          <span style={{ fontFamily: "var(--mono)", fontSize: 11, color: tones.info, minWidth: 38, textAlign: "right" }}>
            {st && st.total_platforms ? pct(platBar * 100, 0) : "—"}
          </span>
        </div>
        {scan.rom && <div style={{ ...muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
          {scan.rom.name || scan.rom.fs_name}
        </div>}
        {st && <div style={muted}>
          {isDiscover
            ? `${dIdentified}/${dScanned} identified · +${dNew} new`
            : `${dScanned} scanned`}
        </div>}
      </div>;
    }

    return (
      <Panel title="RomM scan" count={status} href={LINKS.romm}
             tone={conn === "down" ? "bad" : undefined}>
        {body}
      </Panel>
    );
  }

  // ── Calendar (week strip) ──────────────────────────────────────────
  function Calendar({ section, narrow }) {
    const items = section.data || [];
    const days = narrow ? 2 : 7;  // narrow screens can't fit a 7-day strip
    const today = new Date(); today.setHours(0, 0, 0, 0);
    const week = Array.from({ length: days }, (_, i) => ({
      date: new Date(today.getTime() + i * 86400 * 1000),
      items: [],
    }));
    items.forEach(it => {
      const d = new Date(it.air_date);
      const idx = Math.floor((new Date(d).setHours(0,0,0,0) - today.getTime()) / 86400000);
      if (idx >= 0 && idx < days) week[idx].items.push(it);
    });
    const tag = (s) => s === "radarr" ? "MOV" : s === "sonarr-es" ? "ES" : "TV";
    return (
      <Panel title="Release calendar" subtitle={`next ${days} days`} count={items.length}
             updated={section.updated_at} error={section.error}
             href={sourceCalendarLink("sonarr")}>
        <div style={{ display: "grid", gridTemplateColumns: `repeat(${days}, 1fr)` }}>
          {week.map((col, i) => (
            <div key={i} style={{
              borderRight: i < days - 1 ? "1px solid var(--b-border)" : "none",
              padding: 10, minHeight: 220,
              background: i === 0 ? "color-mix(in oklab, var(--b-info) 5%, transparent)" : undefined,
            }}>
              <div style={{ display: "flex", alignItems: "baseline", gap: 6, marginBottom: 10 }}>
                <span style={{ fontFamily: "var(--mono)", fontSize: 10, color: "var(--b-muted)",
                               textTransform: "uppercase", letterSpacing: 0.8 }}>
                  {weekday(col.date.toISOString())}
                </span>
                <span style={{ fontFamily: "var(--mono)", fontSize: 14, color: i === 0 ? tones.info : "var(--b-text)" }}>
                  {col.date.getDate()}
                </span>
                {col.items.length > 0 && (
                  <Chip tone="muted" style={{ marginLeft: "auto" }}>{col.items.length}</Chip>
                )}
              </div>
              <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
                {col.items.length === 0 && (
                  <div style={{ fontFamily: "var(--mono)", fontSize: 10, color: "color-mix(in oklab, var(--b-muted) 60%, transparent)" }}>—</div>
                )}
                {col.items.map((it, j) => (
                  <a key={j} href={sourceCalendarLink(it.source)} target="_blank" rel="noreferrer" style={{
                    display: "block", textDecoration: "none",
                    padding: 8, borderRadius: 4,
                    background: "color-mix(in oklab, var(--b-text) 5%, transparent)",
                    borderLeft: `3px solid ${it.has_file ? tones.ok : it.source === "radarr" ? tones.info : tones.warn}`,
                  }}>
                    <div style={{ display: "flex", justifyContent: "space-between" }}>
                      <span style={{ fontFamily: "var(--mono)", fontSize: 9.5, color: "var(--b-muted)",
                                     letterSpacing: 0.6 }}>
                        {tag(it.source)} {airTime(it.air_date)}
                      </span>
                      {it.has_file && <span style={{ fontFamily: "var(--mono)", fontSize: 9.5, color: tones.ok }}>HAVE</span>}
                    </div>
                    <div style={{
                      marginTop: 4, fontSize: 11, color: "var(--b-text)", lineHeight: 1.3,
                      display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical", overflow: "hidden",
                    }}>{it.title}</div>
                  </a>
                ))}
              </div>
            </div>
          ))}
        </div>
      </Panel>
    );
  }

  // ── Tasks (single panel, grouped sub-headers) ──────────────────────
  function Tasks({ section, narrow }) {
    const items = section.data || [];
    const groups = {};
    items.forEach(t => { (groups[t.category] || (groups[t.category] = [])).push(t); });
    const order = [
      ["snapshot",    "Snapshots",   LINKS.truenasDataProtection],
      ["replication", "Replication", LINKS.truenasDataProtection],
      ["cloud_sync",  "Cloud sync",  LINKS.truenasDataProtection],
      ["backup",      "Backups",     LINKS.urbackup],
    ];
    // Surface failed tasks on the Panel border so the status is visible even
    // when the panel is collapsed.
    const panelTone = items.some(t => t.last_status === "failed") ? "bad" : undefined;
    return (
      <Panel title="Periodic tasks" count={items.length}
             updated={section.updated_at} error={section.error} tone={panelTone}>
        <div style={{ display: "grid", gridTemplateColumns: `repeat(${narrow ? 1 : 4}, 1fr)` }}>
          {order.map(([key, label, href], col) => {
            const list = groups[key] || [];
            const fails = list.filter(t => t.last_status === "failed").length;
            const last = col < order.length - 1;
            return (
              <div key={key} style={last
                ? (narrow
                    ? { borderBottom: "1px solid var(--b-border)" }
                    : { borderRight: "1px solid var(--b-border)" })
                : {}}>
                <div style={{
                  padding: "10px 14px", borderBottom: "1px solid var(--b-border)",
                  display: "flex", alignItems: "center", gap: 8,
                  background: "color-mix(in oklab, var(--b-text) 2%, transparent)",
                }}>
                  <span style={{ fontSize: 11, fontFamily: "var(--mono)", color: "var(--b-muted)",
                                 letterSpacing: 0.8, textTransform: "uppercase" }}>{label}</span>
                  <span style={{ fontSize: 11, fontFamily: "var(--mono)", color: "var(--b-text)" }}>{list.length}</span>
                  {fails > 0 && <Chip tone="bad">{fails}</Chip>}
                  <div style={{ flex: 1 }} />
                  <a href={href} target="_blank" rel="noreferrer" style={{
                    fontSize: 10, color: "var(--b-muted)", textDecoration: "none", fontFamily: "var(--mono)",
                  }}>↗</a>
                </div>
                {list.map((t, i) => {
                  const tone = STATUS_TONE[t.last_status] || "muted";
                  return (
                    <div key={i} style={{
                      padding: "8px 14px",
                      borderBottom: i < list.length - 1 ? "1px solid var(--b-border)" : "none",
                      display: "grid", gridTemplateColumns: "8px 1fr auto", gap: 10, alignItems: "center",
                      opacity: t.enabled ? 1 : 0.5,
                    }}>
                      <span style={{ width: 8, height: 8, borderRadius: 999, background: tones[tone] }} />
                      <span style={{ fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--b-text)",
                                     overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                        {t.name.replace(/^(Snapshot|Replication|Cloud sync|urBackup): /, "")}
                      </span>
                      <span style={{ fontFamily: "var(--mono)", fontSize: 10, color: "var(--b-muted)", textAlign: "right" }}>
                        {relTime(t.last_run)}
                      </span>
                    </div>
                  );
                })}
              </div>
            );
          })}
        </div>
      </Panel>
    );
  }

  // Small inline action button used in panel headers. Idle → running →
  // done/error flash → idle. Calls onClick (which returns a Promise) and
  // tracks state itself.
  function RunNowButton({ label = "Run now", busyLabel = "Running…", onClick }) {
    const [state, setState] = useState("idle"); // idle | running | done | error
    const [msg, setMsg] = useState("");

    async function handle() {
      if (state === "running") return;
      setState("running");
      setMsg("");
      try {
        await onClick();
        setState("done");
        setTimeout(() => setState("idle"), 2500);
      } catch (e) {
        setState("error");
        setMsg(String(e.message || e));
        setTimeout(() => setState("idle"), 4500);
      }
    }

    const visibleLabel =
      state === "running" ? busyLabel :
      state === "done"    ? "Linked ✓" :
      state === "error"   ? `Failed: ${msg}` :
      label;

    const tone =
      state === "done"  ? "var(--b-ok)" :
      state === "error" ? "var(--b-bad)" :
      "var(--b-text)";

    return (
      <button onClick={handle} disabled={state === "running"} style={{
        background: "transparent",
        border: `1px solid color-mix(in oklab, ${tone} 35%, var(--b-border))`,
        color: tone,
        fontFamily: "var(--mono)", fontSize: 10, letterSpacing: 0.4,
        textTransform: "uppercase",
        padding: "3px 9px", borderRadius: 4,
        cursor: state === "running" ? "default" : "pointer",
      }}>
        {visibleLabel}
      </button>
    );
  }

  // ── Shoko + Failed (paired) ────────────────────────────────────────
  function Shoko({ section }) {
    const items = section.data || [];
    async function runAutoLink() {
      const r = await fetch("/api/shoko/auto-link", { method: "POST", cache: "no-store" });
      const body = await r.json().catch(() => ({}));
      if (!r.ok) {
        throw new Error(body.detail || `HTTP ${r.status}`);
      }
      if (body.state !== "SUCCESS") {
        throw new Error(body.error || body.state || "unknown failure");
      }
      // Backend already ran poll_now on success — pull the fresh snapshot.
      await window.HL.refresh();
    }
    return (
      <Panel title="Shoko unrecognized" count={items.length}
             updated={section.updated_at} error={section.error} href={LINKS.shoko}
             action={<RunNowButton onClick={runAutoLink} />}>
        {items.length === 0 ? (
          <div style={{ padding: 18, color: "var(--b-muted)", fontSize: 13 }}>Library clean.</div>
        ) : items.map((f, i) => (
          <div key={f.id} style={{
            padding: "9px 16px",
            borderBottom: i < items.length - 1 ? "1px solid var(--b-border)" : "none",
            display: "grid", gridTemplateColumns: "1fr auto auto", gap: 14, alignItems: "center",
          }}>
            <span style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--b-text)",
                           overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
              {f.path}
            </span>
            <Chip tone="muted">{f.resolution}</Chip>
            <span style={{ fontFamily: "var(--mono)", fontSize: 11, color: "var(--b-muted)", minWidth: 56, textAlign: "right" }}>
              {bytes(f.size)}
            </span>
          </div>
        ))}
      </Panel>
    );
  }

  function Failed({ section }) {
    const items = section.data || [];
    return (
      <Panel title="Failed imports" count={items.length}
             updated={section.updated_at} error={section.error}
             tone={items.length ? "bad" : undefined}>
        {items.length === 0 ? (
          <div style={{ padding: 18, color: "var(--b-muted)", fontSize: 13 }}>Queue clean.</div>
        ) : items.map((f, i) => (
          <div key={i} style={{
            padding: "12px 16px",
            borderBottom: i < items.length - 1 ? "1px solid var(--b-border)" : "none",
          }}>
            <div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 6 }}>
              <Chip tone="bad" solid>{f.status}</Chip>
              <span style={{ fontFamily: "var(--mono)", fontSize: 10, color: "var(--b-muted)",
                             textTransform: "uppercase", letterSpacing: 0.8 }}>
                {f.source} · {f.state}
              </span>
              <div style={{ flex: 1 }} />
              <a href={sourceLink(f.source)} target="_blank" rel="noreferrer" style={{
                fontSize: 10, color: "var(--b-info)", fontFamily: "var(--mono)", textDecoration: "none",
              }}>OPEN ↗</a>
            </div>
            <div style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--b-text)",
                          marginBottom: 8, wordBreak: "break-all", lineHeight: 1.35 }}>
              {f.title}
            </div>
            {f.messages.map((m, j) => (
              <div key={j} style={{
                fontSize: 11, color: "var(--b-muted)", marginTop: 4,
                paddingLeft: 10, borderLeft: `2px solid color-mix(in oklab, ${window.DirectionB.tones.bad} 40%, transparent)`,
              }}>{m}</div>
            ))}
          </div>
        ))}
      </Panel>
    );
  }

  // ── root ───────────────────────────────────────────────────────────
  function OperatorDashboard({ snapshot }) {
    const sections = snapshot?.data || {};
    const t = useMemo(() => window.DirectionB.triage(sections), [sections]);
    const narrow = useNarrow();
    const romm = useRommScan();
    const [refreshing, setRefreshing] = useState(false);
    // Default the live-downloads poll to ON if there's anything actively
    // downloading at first paint, so the user lands on a live view without
    // having to flip it themselves.
    const [liveDl, setLiveDl] = useState(() =>
      activeDownloads(snapshot?.data?.downloads?.data || []).length > 0);
    useEffect(() => { if (liveDl) window.HL.setLiveDownloads(true); }, []);

    const doRefresh = async () => {
      if (refreshing) return;
      setRefreshing(true);
      try { await window.HL.refresh(); } finally { setRefreshing(false); }
    };
    const toggleLive = () => {
      const on = !liveDl;
      window.HL.setLiveDownloads(on);
      setLiveDl(on);
    };
    const ctrlBase = {
      fontFamily: "var(--mono)", fontSize: 11, height: 28, padding: "0 10px",
      borderRadius: 6, border: "1px solid var(--b-border)",
      background: "transparent", color: "var(--b-muted)",
      display: "inline-flex", alignItems: "center", gap: 6,
    };
    return (
      <div style={{
        background: "var(--b-bg)", color: "var(--b-text)", minHeight: "100%",
        fontFamily: "var(--sans)",
        padding: narrow
          ? "calc(16px + env(safe-area-inset-top)) 14px 24px"
          : "20px 24px 32px",
        display: "flex", flexDirection: "column", gap: 14,
      }}>
        <header style={{ display: "flex", alignItems: "center",
                         gap: narrow ? 10 : 14,
                         flexWrap: "wrap", paddingBottom: 4 }}>
          {(() => {
            const truenasAlerts = t.failedTasks.length + t.stoppedApps.length + t.degradedPools.length;
            const dotTone = truenasAlerts > 0 ? tones.bad : tones.ok;
            return (
              <span style={{
                display: "inline-block", width: 10, height: 10, borderRadius: 2,
                background: dotTone, boxShadow: `0 0 12px ${dotTone}`,
              }} />
            );
          })()}
          <span style={{ fontSize: 16, fontWeight: 600, letterSpacing: 1.5, textTransform: "uppercase" }}>
            HOMELAB OPS
          </span>
          {/* domain subtitle — redundant context on mobile (you're on it),
              hidden so the header fits one row */}
          {!narrow && (
            <span style={{ fontFamily: "var(--mono)", fontSize: 11, color: "var(--b-muted)" }}>
              amochis.link
            </span>
          )}
          <div style={{ flex: 1 }} />
          {/* Mock-fallback warning — only surfaced when the API is unreachable.
              Replaces the old always-on LIVE chip (which was redundant once
              the green dot on LIVE DOWNLOADS communicates the same thing). */}
          {snapshot && !snapshot.live && <Chip tone="warn">● MOCK</Chip>}
          {/* Last-updated timestamp — hidden on narrow viewports so the
              header keeps to a single line on mobile. */}
          {!narrow && (
            <span style={{ fontFamily: "var(--mono)", fontSize: 11, color: "var(--b-muted)" }}>
              {snapshot?.fetchedAt ? `updated ${relTime(snapshot.fetchedAt)}` : "loading…"}
            </span>
          )}
          <button onClick={doRefresh} disabled={refreshing}
                  title="Refresh dashboard data"
                  style={{
            ...ctrlBase,
            color: refreshing ? "var(--b-muted)" : "var(--b-text)",
            cursor: refreshing ? "default" : "pointer",
          }}>
            ↻{narrow && !refreshing ? "" : ` ${refreshing ? "REFRESHING" : "REFRESH"}`}
          </button>
          <button onClick={toggleLive}
                  title="Auto-refresh download progress every 5s"
                  style={{
            ...ctrlBase, cursor: "pointer",
            color: liveDl ? tones.ok : "var(--b-muted)",
            borderColor: liveDl
              ? `color-mix(in oklab, ${tones.ok} 45%, var(--b-border))`
              : "var(--b-border)",
            background: liveDl
              ? `color-mix(in oklab, ${tones.ok} 12%, transparent)`
              : "transparent",
          }}>
            <span style={{
              width: 7, height: 7, borderRadius: 999, display: "inline-block",
              background: liveDl ? tones.ok : "var(--b-muted)",
              boxShadow: liveDl ? `0 0 6px ${tones.ok}` : "none",
            }} />
            {narrow
              ? (liveDl ? "LIVE · 5s" : "LIVE")
              : (liveDl ? "LIVE DOWNLOADS · 5s" : "LIVE DOWNLOADS")}
          </button>
        </header>

        {window.DirectionB.Hero({ sections, t, narrow })}

        {/* alerts row — laid out based on which sources have items:
            - alerts only:                  full-width alerts panel
            - alerts + (failed or shoko):   2-col grid, alerts left, queue side-rail right
            - no alerts but failed/shoko:   queue panels on their own (full width)
            - none of the above:            nothing rendered */}
        {(() => {
          const failed     = sections.failed_downloads?.data   || [];
          const shoko      = sections.shoko_unrecognized?.data || [];
          const truenas    = t.failedTasks.length + t.stoppedApps.length + t.degradedPools.length;
          const hasAlerts  = truenas > 0;
          const hasQueue   = failed.length > 0 || shoko.length > 0;

          if (!hasAlerts && !hasQueue) return null;
          if (!hasAlerts && hasQueue) {
            const cols = narrow
              ? "1fr"
              : (failed.length > 0 && shoko.length > 0 ? "1.3fr 1fr" : "1fr");
            return (
              <div style={{ display: "grid", gridTemplateColumns: cols, gap: 14, alignItems: "start" }}>
                {failed.length > 0 && <Failed section={sections.failed_downloads} />}
                {shoko.length  > 0 && <Shoko  section={sections.shoko_unrecognized} />}
              </div>
            );
          }
          if (hasAlerts && !hasQueue) {
            return window.DirectionB.Alerts({ sections, t });
          }
          return (
            <div style={{ display: "grid",
                          gridTemplateColumns: narrow ? "1fr" : "1.3fr 1fr",
                          gap: 14, alignItems: "start" }}>
              <div>{window.DirectionB.Alerts({ sections, t })}</div>
              <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
                {failed.length > 0 && <Failed section={sections.failed_downloads} />}
                {shoko.length  > 0 && <Shoko  section={sections.shoko_unrecognized} />}
              </div>
            </div>
          );
        })()}

        {(() => {
          // Activity row: Downloads · Seeding · RomM. Each panel hides itself
          // when it has nothing to show. Grid columns = visible-panel count
          // (1 / 2 / 3) so two panels split 50/50 instead of being squashed
          // against an empty third track. Always 1 column on narrow screens.
          const dl = sections.downloads;
          const showDl   = dl && (!!dl.error || activeDownloads(dl.data).length > 0);
          const showSeed = dl && (!!dl.error || completedSeeds(dl.data).length > 0);
          const showRomm = rommVisible(romm);
          const count = (showDl ? 1 : 0) + (showSeed ? 1 : 0) + (showRomm ? 1 : 0);
          if (count === 0) return null;
          const cols = narrow ? 1 : count;
          return (
            <div style={{ display: "grid",
                          gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
                          gap: 14, alignItems: "start" }}>
              {showDl   && <Downloads key="dl"   section={dl} />}
              {showSeed && <Seeding   key="seed" section={dl} narrow={narrow} />}
              {showRomm && <Romm      key="romm" state={romm} />}
            </div>
          );
        })()}
        {sections.calendar  && <Calendar  section={sections.calendar} narrow={narrow} />}
        {sections.storage   && <Storage   section={sections.storage} narrow={narrow} />}
        {sections.tasks     && <Tasks     section={sections.tasks} narrow={narrow} />}
      </div>
    );
  }

  return { OperatorDashboard };
})();

window.DirectionBRoot = B2;
