/* ==================================================================
   Admin Cockpit → Student Attendance
   ------------------------------------------------------------------
   The whole-school register, driven by the same server-side report the
   teacher app, the PDF and the spreadsheet all render. Nothing on this
   page recomputes a total the server already gives — the figures in the
   totals row, the by-class table and the % column are the server's.

   Three things worth knowing before reading a number here:

   1. A cell whose `source` is 'rfid' was filled in by the gate reader and
      NO TEACHER HAS CONFIRMED IT. Such cells are drawn dotted, and the
      figure "awaiting teacher" counts them.
   2. A child with no cell for a day did not tap and was not marked. That
      is blank — never absent. "Not yet tapped" today is exactly that set.
   3. Dates are 'YYYY-MM-DD' strings end to end, and "today" is the school
      day in Asia/Kolkata regardless of the device clock.

   Global-scope rule: every top-level identifier in this file is prefixed
   SA / Sa / sa. A duplicate top-level `const` anywhere in admin-panel
   kills the whole later script file silently.
   ================================================================== */

const SA_STATUSES = ["present", "absent", "late", "half_day", "excused"];

const SA_STATUS_LABEL = {
  present: "Present", absent: "Absent", late: "Late",
  half_day: "Half day", excused: "Excused",
};

const SA_STATUS_CODE = { present: "P", absent: "A", late: "L", half_day: "H", excused: "E" };

/* ---------- Dates (IST-pinned, like teacher-mobile/utils/dates.ts) ---------- */

const saIstDay = (d) =>
  new Intl.DateTimeFormat("en-CA", {
    year: "numeric", month: "2-digit", day: "2-digit", timeZone: "Asia/Kolkata",
  }).format(d);

const saIstToday = () => saIstDay(new Date());

const saShiftDay = (by) => {
  const d = new Date(`${saIstToday()}T00:00:00`);
  d.setDate(d.getDate() + by);
  const p = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
};

const saMonthStart = () => `${saIstToday().slice(0, 7)}-01`;

const SA_PRESETS = [
  { key: "today",     label: "Today",      from: () => saShiftDay(0),   to: () => saShiftDay(0) },
  { key: "yesterday", label: "Yesterday",  from: () => saShiftDay(-1),  to: () => saShiftDay(-1) },
  { key: "7",         label: "7 days",     from: () => saShiftDay(-6),  to: () => saShiftDay(0) },
  { key: "30",        label: "30 days",    from: () => saShiftDay(-29), to: () => saShiftDay(0) },
  { key: "month",     label: "This month", from: saMonthStart,          to: () => saShiftDay(0) },
];

const saPrettyDate = (day, opts) => {
  const d = new Date(`${day}T00:00:00`);
  return isNaN(d.getTime()) ? day : d.toLocaleDateString("en-IN",
    opts || { day: "numeric", month: "short", year: "numeric" });
};

const saMonthAbbr = (day) => {
  const m = Number(day.slice(5, 7));
  return ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
          "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m] || "";
};

const saWeekday = (day) => {
  const d = new Date(`${day}T00:00:00`);
  return isNaN(d.getTime()) ? "" : d.toLocaleDateString("en-IN", { weekday: "short" });
};

// Timestamps arrive as whatever the server's Date stringified to; the browser
// parses either ISO or the long form. Rendered in IST whatever the device says.
const saTime = (ts) => {
  if (!ts) return "—";
  const d = new Date(ts);
  if (isNaN(d.getTime())) return "—";
  return d.toLocaleTimeString("en-IN", { hour: "2-digit", minute: "2-digit", timeZone: "Asia/Kolkata" });
};

const saDateTime = (ts) => {
  if (!ts) return "—";
  const d = new Date(ts);
  if (isNaN(d.getTime())) return "—";
  return d.toLocaleString("en-IN", {
    day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", timeZone: "Asia/Kolkata",
  });
};

const saAgo = (minutes) => {
  if (minutes == null) return "never seen";
  if (minutes < 1) return "just now";
  if (minutes < 60) return `${minutes} min ago`;
  if (minutes < 48 * 60) return `${Math.floor(minutes / 60)} h ago`;
  return `${Math.floor(minutes / 1440)} d ago`;
};

/* ---------- Query plumbing ---------- */

const saQs = (q) => {
  const p = new URLSearchParams();
  for (const [k, v] of Object.entries(q)) if (v) p.set(k, String(v));
  const s = p.toString();
  return s ? `?${s}` : "";
};

const saCaption = (r) => {
  const bits = [];
  bits.push(r.filters.from === r.filters.to
    ? saPrettyDate(r.filters.from)
    : `${saPrettyDate(r.filters.from)} — ${saPrettyDate(r.filters.to)}`);
  if (r.labels.class_section) bits.push(`Class ${r.labels.class_section}`);
  if (r.labels.status) bits.push(`only ${r.labels.status.toLowerCase()}`);
  if (r.filters.q) bits.push(`matching "${r.filters.q}"`);
  return bits.join(" · ");
};

// Fallback for a blocked popup, and the only path for the spreadsheet.
const saTriggerDownload = (blobUrl, filename) => {
  const a = document.createElement("a");
  a.href = blobUrl;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
};

// "08:31 via Main gate · confirmed by R. Sen". What a grid cell says on hover
// and what the one-day list prints beside the status.
const saCellTitle = (cell, scan) => {
  if (!cell) return "Not tapped, not marked";
  const bits = [];
  if (cell.first_scan_at) {
    bits.push(`${saTime(cell.first_scan_at)} via ${scan?.reader_label || "the gate"}`);
  } else {
    bits.push(`marked ${saTime(cell.marked_at)}`);
  }
  if (cell.source === "rfid") bits.push("awaiting teacher confirmation");
  else if (cell.marked_by_name) bits.push(`${cell.corrected ? "corrected" : "confirmed"} by ${cell.marked_by_name}`);
  if (cell.note) bits.push(cell.note);
  return `${SA_STATUS_LABEL[cell.status] || cell.status} · ${bits.join(" · ")}`;
};

// The live figures for the gate strip. These are gate-derived subsets the
// server does not total, so they are counted here off the cells; "not yet
// tapped" is the server's own not_marked for the day where it exists.
const saGateSummary = (report, today) => {
  let tapped = 0, onTime = 0, late = 0, unconfirmed = 0;
  for (const s of report.students) {
    const c = s.cells[today];
    if (!c) continue;
    if (c.first_scan_at) {
      tapped++;
      if (c.status === "present") onTime++;
      else if (c.status === "late") late++;
    }
    if (c.source === "rfid") unconfirmed++;
  }
  const dayRow = (report.summary.by_day || []).find((d) => d.day === today);
  const notTapped = report.filters.status
    ? null                                      // roster dropped → unknowable
    : dayRow ? dayRow.not_marked : report.students.length;
  return { tapped, onTime, late, unconfirmed, notTapped };
};

const saReaderTone = (r) => {
  if (!r.active) return "off";
  if (r.minutes_since_seen == null) return "never";
  if (r.minutes_since_seen <= 5) return "ok";
  if (r.minutes_since_seen <= 60) return "stale";
  return "down";
};

/* ---------- Screen-local styles (styles.css is not touched) ---------- */

const SA_CSS = `
.sa-filters { display: flex; flex-wrap: wrap; gap: 10px 12px; align-items: flex-end; margin-top: 14px; }
.sa-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.sa-field > span { font-size: 10.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-3); }
.sa-field .input, .sa-field .select { width: auto; padding: 7px 10px; font-size: 13px; }
.sa-field.grow { flex: 1 1 200px; }
.sa-field.grow .input { width: 100%; }
.sa-filtertoggle { display: none; }

.sa-stats { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 12px; margin-bottom: 20px; }
.sa-stats .cx-card.tight { padding: 16px 18px; }
.sa-stats .cx-stat-value.sm { font-size: 26px; }

.sa-gate { display: flex; flex-wrap: wrap; gap: 14px 28px; align-items: flex-end; }
.sa-gate-fig { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.sa-gate-fig b { font-size: 24px; font-weight: 600; letter-spacing: -0.8px; color: var(--ink-0); line-height: 1.05; }
.sa-gate-fig span { font-size: 12px; color: var(--ink-3); white-space: nowrap; }
.sa-readers { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line-soft); font-size: 12.5px; color: var(--ink-2); }
.sa-rdot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 7px; vertical-align: middle; background: var(--ink-4); }
.sa-rdot.ok    { background: var(--green-solid); box-shadow: 0 0 0 3px var(--green-bg); }
.sa-rdot.stale { background: var(--amber-solid); box-shadow: 0 0 0 3px var(--amber-bg); }
.sa-rdot.down  { background: var(--red-solid); box-shadow: 0 0 0 3px var(--red-bg); }
.sa-rdot.never { background: #fff; border: 1.5px solid var(--ink-4); }
.sa-rdot.off   { background: var(--bg-4); }

.sa-cell { display: inline-flex; width: 24px; height: 24px; align-items: center; justify-content: center; border-radius: 7px; font-weight: 700; font-size: 11.5px; font-family: 'IBM Plex Mono', ui-monospace, monospace; box-sizing: border-box; }
.sa-cell.present  { background: var(--green-bg);  color: var(--green); }
.sa-cell.absent   { background: var(--red-bg);    color: var(--red); }
.sa-cell.late     { background: var(--amber-bg);  color: var(--amber); }
.sa-cell.half_day { background: var(--bg-3);      color: var(--ink-2); }
.sa-cell.excused  { background: var(--violet-bg); color: var(--violet); }
.sa-cell.blank    { background: transparent; color: var(--ink-4); font-weight: 500; }
/* Gate-only: same colour, no fill, dotted ring — a suggestion, not a mark. */
.sa-cell.gate     { background: transparent; border: 1.5px dotted currentColor; }
.sa-cell.today    { box-shadow: 0 0 0 1.5px var(--ink-2); }

.sa-grid { border-collapse: separate; border-spacing: 0; font-size: 12.5px; min-width: 100%; }
.sa-grid th, .sa-grid td { padding: 6px 5px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; text-align: center; vertical-align: middle; }
.sa-grid th { font-size: 10px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-3); background: #fff; position: sticky; top: 0; z-index: 1; line-height: 1.15; }
.sa-grid th small { display: block; font-size: 9px; color: var(--ink-4); letter-spacing: 0; text-transform: none; margin-top: 1px; }
.sa-grid th.today { color: var(--ink-0); }
.sa-grid th:first-child, .sa-grid td:first-child { position: sticky; left: 0; background: #fff; z-index: 2; text-align: left; min-width: 168px; max-width: 220px; padding-left: 8px; box-shadow: 1px 0 0 var(--line-soft); }
.sa-grid th:first-child { z-index: 3; }
.sa-grid td:last-child, .sa-grid th:last-child { padding-right: 10px; font-variant-numeric: tabular-nums; }
.sa-grid tbody tr { cursor: pointer; }
.sa-grid tbody tr:hover td { background: var(--bg-2); }
.sa-grid tbody tr:last-child td { border-bottom: none; }
.sa-who { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.sa-who .r { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: 10.5px; color: var(--ink-3); flex-shrink: 0; width: 28px; }
.sa-who .n { font-weight: 600; color: var(--ink-0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.sa-who .c { font-size: 10.5px; color: var(--ink-4); flex-shrink: 0; }

.sa-class { padding: 12px 0 4px; }
.sa-class-head { display: flex; align-items: baseline; gap: 10px; padding: 8px 0 6px; border-bottom: 1px solid var(--line); margin-bottom: 4px; }
.sa-class-head b { font-size: 14px; color: var(--ink-0); }
.sa-class-head span { font-size: 12px; color: var(--ink-3); }
.sa-dayrow { display: grid; grid-template-columns: 34px minmax(0, 1fr) 96px minmax(0, 1.1fr) minmax(0, 1.2fr); gap: 10px; align-items: center; padding: 9px 4px; border-bottom: 1px solid var(--line-soft); cursor: pointer; border-radius: 10px; font-size: 13px; }
.sa-dayrow:hover { background: var(--bg-2); }
.sa-dayrow > * { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sa-dayrow .r { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: 11px; color: var(--ink-3); }
.sa-dayrow .n { font-weight: 600; color: var(--ink-0); }
.sa-dayrow .t { color: var(--ink-2); font-size: 12.5px; }
.sa-dayrow .k { color: var(--ink-3); font-size: 12px; }
.sa-dayrow .k.ok { color: var(--green); }
.sa-dayrow .k.wait { color: var(--amber); }

.sa-legend { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; font-size: 12.5px; color: var(--ink-2); }
.sa-legend .sa-cell { width: 20px; height: 20px; font-size: 10px; border-radius: 6px; }
.sa-foot { font-size: 12.5px; color: var(--ink-3); line-height: 1.5; margin-top: 12px; }

.sa-scrim { position: fixed; inset: 0; z-index: 245; background: rgba(20,20,23,0.34); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); animation: cx-fade-in 0.18s ease-out; }
.sa-drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 250; width: min(460px, 100vw); background: #fff; box-shadow: -14px 0 44px rgba(20,20,23,0.22); overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column; animation: sa-slide-in 0.22s cubic-bezier(.2,.85,.3,1); }
.sa-drawer-head { display: flex; align-items: flex-start; gap: 12px; padding: 18px 20px 14px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: #fff; z-index: 1; }
.sa-drawer section { padding: 14px 20px; border-bottom: 1px solid var(--line-soft); }
.sa-drawer .sa-sec { font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 10px; font-weight: 600; }
.sa-taprow { display: grid; grid-template-columns: 110px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 7px 0; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
.sa-taprow:last-child { border-bottom: none; }
.sa-taprow .m { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: 11px; color: var(--ink-3); }
.sa-drawer-grip { display: none; }
@keyframes sa-slide-in { from { transform: translateX(100%); } to { transform: translateX(0); } }
@keyframes sa-sheet-up { from { transform: translateY(100%); } to { transform: translateY(0); } }

@media (max-width: 1100px) {
  .sa-stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
  .sa-filtertoggle { display: inline-flex; }
  .sa-filters.collapsed { display: none; }
  .sa-field { flex: 1 1 45%; }
  .sa-field .input, .sa-field .select { width: 100%; }
  .sa-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
  .sa-stats .cx-card.tight { padding: 12px 14px; }
  .sa-gate { gap: 10px 18px; }
  .sa-gate-fig b { font-size: 20px; }
  .sa-dayrow { grid-template-columns: 30px minmax(0, 1fr) auto; row-gap: 2px; }
  .sa-dayrow .t { grid-column: 2; }
  .sa-dayrow .k { grid-column: 2 / -1; }
  .sa-grid th:first-child, .sa-grid td:first-child { min-width: 132px; max-width: 150px; }
  .sa-drawer { top: auto; left: 0; right: 0; bottom: 0; width: 100%; max-height: 88vh; border-radius: 24px 24px 0 0; box-shadow: 0 -14px 44px rgba(20,20,23,0.24); padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); animation: sa-sheet-up 0.26s cubic-bezier(.2,.85,.3,1); }
  .sa-drawer-grip { display: block; flex-shrink: 0; width: 40px; height: 4px; border-radius: 999px; background: var(--bg-4); margin: 10px auto 0; }
}
@media (prefers-reduced-motion: reduce) {
  .sa-drawer, .sa-scrim { animation: none; }
}
`;

/* ---------- Small pieces ---------- */

const SaField = ({ label, children, className = "" }) => (
  <label className={`sa-field ${className}`}>
    <span>{label}</span>
    {children}
  </label>
);

// One register cell. `cell` may be undefined (blank — never absent).
const SaCell = ({ cell, isToday, title }) => {
  const cls = cell
    ? `sa-cell ${cell.status}${cell.source === "rfid" ? " gate" : ""}${isToday ? " today" : ""}`
    : `sa-cell blank${isToday ? " today" : ""}`;
  return <span className={cls} title={title}>{cell ? SA_STATUS_CODE[cell.status] || "?" : "·"}</span>;
};

const SaLegend = () => (
  <div className="sa-legend">
    {SA_STATUSES.map((s) => (
      <span key={s} style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
        <span className={`sa-cell ${s}`}>{SA_STATUS_CODE[s]}</span>{SA_STATUS_LABEL[s]}
      </span>
    ))}
    <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
      <span className="sa-cell present gate">P</span>gate only, unconfirmed
    </span>
    <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
      <span className="sa-cell blank">·</span>not tapped, not marked
    </span>
  </div>
);

/* ---------- Today at the gate ---------- */

// Mounted only while today is inside the range, so the reader poll runs only
// then. The tap figures come off the report (which the parent polls); the
// reader health is its own 30 s poll.
const SaGateStrip = ({ report, today, refreshing }) => {
  const CX = window.CX;
  const readers = CX.useCockpitData("/rfid/readers", { pollMs: 30000 });
  const g = saGateSummary(report, today);
  const list = readers.data?.readers || [];

  return (
    <div className="cx-card" style={{ marginBottom: 20 }}>
      <div className="cx-card-head" style={{ marginBottom: 14 }}>
        <div>
          <div className="cx-eyebrow" style={{ marginBottom: 2 }}>Today at the gate</div>
          <div style={{ fontSize: 12.5, color: "var(--ink-3)" }}>
            {saPrettyDate(today, { weekday: "long", day: "numeric", month: "long" })} · refreshes every 30 s
          </div>
        </div>
        <span className="cx-pill" style={{ color: refreshing ? "var(--ink-2)" : "var(--green)" }}>
          <span className={`sa-rdot ${refreshing ? "stale" : "ok"}`}/>{refreshing ? "Updating…" : "Live"}
        </span>
      </div>

      <div className="sa-gate">
        <div className="sa-gate-fig"><b>{CX.num(g.tapped)}</b><span>tapped in</span></div>
        <div className="sa-gate-fig"><b style={{ color: "var(--green)" }}>{CX.num(g.onTime)}</b><span>on time</span></div>
        <div className="sa-gate-fig"><b style={{ color: g.late ? "var(--amber)" : undefined }}>{CX.num(g.late)}</b><span>late</span></div>
        <div className="sa-gate-fig" title={g.notTapped == null ? "Unknown while a status filter is on — the roster is not listed" : "Children on the roster with no tap and no mark today"}>
          <b style={{ color: "var(--ink-2)" }}>{g.notTapped == null ? "—" : CX.num(g.notTapped)}</b><span>not yet tapped</span>
        </div>
        <div className="sa-gate-fig" title="Gate marks no class teacher has confirmed yet">
          <b style={{ color: g.unconfirmed ? "var(--amber)" : undefined }}>{CX.num(g.unconfirmed)}</b><span>awaiting teacher</span>
        </div>
      </div>

      <div className="sa-readers">
        <span style={{ fontWeight: 600, color: "var(--ink-3)", fontSize: 11, letterSpacing: ".06em", textTransform: "uppercase" }}>Readers</span>
        {readers.error && !list.length && (
          <span style={{ color: "var(--ink-4)" }}>Reader health unavailable</span>
        )}
        {!readers.error && !readers.loading && list.length === 0 && (
          <span style={{ color: "var(--ink-4)" }}>No readers registered</span>
        )}
        {list.map((r) => {
          const tone = saReaderTone(r);
          return (
            <span key={r.id} title={`${r.location || "—"} · firmware ${r.firmware || "?"}${r.active ? "" : " · disabled"}`}>
              <span className={`sa-rdot ${tone}`}/>
              <strong style={{ fontWeight: 600, color: "var(--ink-0)" }}>{r.label}</strong>
              {" "}
              <span style={{ color: "var(--ink-3)" }}>
                ({!r.active ? "disabled" : saAgo(r.minutes_since_seen)}{r.active && r.minutes_since_seen != null ? `, ${CX.num(r.scans_today)} today` : ""})
              </span>
            </span>
          );
        })}
      </div>
    </div>
  );
};

/* ---------- Totals row (the server's numbers, verbatim) ---------- */

const SaTotals = ({ t }) => {
  const CX = window.CX;
  const tile = (label, value, color, note) => (
    <div className="cx-card tight" key={label}>
      <div style={{ fontSize: 12.5, color: "var(--ink-3)", marginBottom: 4 }}>{label}</div>
      <div style={{ display: "flex", alignItems: "baseline", gap: 6 }}>
        <span className="cx-stat-value sm" style={color ? { color } : undefined}>{value}</span>
        {note && <span style={{ fontSize: 11.5, color: "var(--ink-4)" }}>{note}</span>}
      </div>
    </div>
  );
  return (
    <div className="sa-stats">
      {tile("Present", CX.num(t.present), "var(--green)")}
      {tile("Absent", CX.num(t.absent), t.absent ? "var(--red)" : undefined)}
      {tile("Late", CX.num(t.late), t.late ? "var(--amber)" : undefined)}
      {tile("Half day", CX.num(t.half_day))}
      {tile("Excused", CX.num(t.excused), "var(--violet)")}
      {tile("Attendance", t.percent == null ? "—" : `${t.percent}%`, undefined, `of ${CX.num(t.marked)} marks`)}
      {tile("Unconfirmed", CX.num(t.unconfirmed), t.unconfirmed ? "var(--amber)" : undefined, "gate only")}
    </div>
  );
};

/* ---------- One day: a list grouped by class ---------- */

const SaDayList = ({ report, day, scans, today, onPick }) => {
  const groups = [];
  const byClass = new Map();
  for (const s of report.students) {
    let g = byClass.get(s.class_section_id);
    if (!g) {
      g = { id: s.class_section_id, label: s.class_label || "—", rows: [] };
      byClass.set(s.class_section_id, g);
      groups.push(g);
    }
    g.rows.push(s);
  }
  const isToday = day === today;

  return (
    <div>
      {groups.map((g) => {
        const marked = g.rows.filter((s) => s.cells[day]).length;
        return (
          <div key={g.id} className="sa-class">
            <div className="sa-class-head">
              <b>{g.label}</b>
              <span>{marked} of {g.rows.length} {isToday ? "tapped or marked" : "marked"}</span>
            </div>
            {g.rows.map((s) => {
              const cell = s.cells[day];
              const scan = scans ? scans.get(s.student_id) : null;
              return (
                <div key={s.student_id} className="sa-dayrow" onClick={() => onPick(s.student_id)}
                     title="Open this student">
                  <span className="r">{s.roll_no || "—"}</span>
                  <span className="n">{s.name}</span>
                  <span>
                    {cell
                      ? <span className={`cx-pill tint ${{ present: "green", absent: "red", late: "amber", half_day: "grey", excused: "violet" }[cell.status] || "grey"}`}>
                          {SA_STATUS_LABEL[cell.status] || cell.status}
                        </span>
                      : <span className="cx-pill" style={{ color: "var(--ink-4)" }}>—</span>}
                  </span>
                  <span className="t">
                    {cell?.first_scan_at
                      ? <>{saTime(cell.first_scan_at)} · {scan?.reader_label || "gate"}</>
                      : cell
                        ? <span style={{ color: "var(--ink-3)" }}>marked {saTime(cell.marked_at)}</span>
                        : <span style={{ color: "var(--ink-4)" }}>{isToday ? "not tapped" : "not tapped, not marked"}</span>}
                  </span>
                  <span className={`k ${cell ? (cell.source === "rfid" ? "wait" : "ok") : ""}`}>
                    {cell
                      ? cell.source === "rfid"
                        ? "⏳ gate only"
                        : `✓ ${cell.corrected ? "corrected" : "confirmed"}${cell.marked_by_name ? ` by ${cell.marked_by_name}` : ""}`
                      : ""}
                    {cell?.note && <span style={{ color: "var(--ink-4)" }}> · {cell.note}</span>}
                  </span>
                </div>
              );
            })}
          </div>
        );
      })}
    </div>
  );
};

/* ---------- A range: the grid ---------- */

const SaRegisterGrid = ({ report, today, onPick }) => (
  <div className="cx-tablescroll">
    <table className="sa-grid">
      <thead>
        <tr>
          <th>Student</th>
          {report.days.map((d) => (
            <th key={d} className={d === today ? "today" : ""} title={saPrettyDate(d, { weekday: "long", day: "numeric", month: "long" })}>
              {d.slice(8, 10)}<small>{saMonthAbbr(d)} · {saWeekday(d)}</small>
            </th>
          ))}
          <th title="Present + late over the days this student was marked">%</th>
        </tr>
      </thead>
      <tbody>
        {report.students.map((s) => (
          <tr key={s.student_id} onClick={() => onPick(s.student_id)}>
            <td>
              <div className="sa-who">
                <span className="r">{s.roll_no || "—"}</span>
                <span className="n" title={s.name}>{s.name}</span>
                <span className="c">{s.class_label}</span>
              </div>
            </td>
            {report.days.map((d) => (
              <td key={d}>
                <SaCell cell={s.cells[d]} isToday={d === today} title={`${saPrettyDate(d)} · ${saCellTitle(s.cells[d])}`}/>
              </td>
            ))}
            <td style={{ fontWeight: 700, color: s.totals.percent != null && s.totals.percent < 75 ? "var(--red)" : "var(--ink-0)" }}>
              {s.totals.percent == null ? "—" : `${s.totals.percent}%`}
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  </div>
);

/* ---------- By class ---------- */

const SaByClass = ({ rows, onPickClass }) => {
  const CX = window.CX;
  return (
    <div className="cx-tablescroll">
      <table className="cx-table compact">
        <thead>
          <tr>
            <th>Class</th>
            <th className="num">Present</th>
            <th className="num">Absent</th>
            <th className="num">Late</th>
            <th className="num">Half</th>
            <th className="num">Excused</th>
            <th className="num">Marked</th>
            <th className="num">%</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((c) => (
            <tr key={c.class_section_id} style={{ cursor: "pointer" }} title="Filter to this class"
                onClick={() => onPickClass(c.class_section_id)}>
              <td><span className="t-main">{c.class_label}</span></td>
              <td className="num" style={{ color: "var(--green)" }}>{CX.num(c.present)}</td>
              <td className="num" style={{ color: c.absent ? "var(--red)" : "var(--ink-4)" }}>{CX.num(c.absent)}</td>
              <td className="num" style={{ color: c.late ? "var(--amber)" : "var(--ink-4)" }}>{CX.num(c.late)}</td>
              <td className="num">{CX.num(c.half_day)}</td>
              <td className="num">{CX.num(c.excused)}</td>
              <td className="num">{CX.num(c.marked)}</td>
              <td className="num" style={{ fontWeight: 700, color: c.percent != null && c.percent < 75 ? "var(--red)" : "var(--ink-0)" }}>
                {c.percent == null ? "—" : `${c.percent}%`}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

/* ---------- The drawer: one student, every mark, every tap ---------- */

const SaStudentDrawer = ({ student, report, today, onClose }) => {
  const CX = window.CX;
  const { from, to } = report.filters;
  const [taps, setTaps] = useState({ loading: true, error: null, scans: [] });

  useEffect(() => {
    let alive = true;
    setTaps({ loading: true, error: null, scans: [] });
    window.KXApi.get(`/rfid/scans${saQs({ from, to, student_id: student.student_id, limit: 500 })}`)
      .then((r) => { if (alive) setTaps({ loading: false, error: null, scans: r?.scans || [] }); })
      .catch((e) => { if (alive) setTaps({ loading: false, error: e?.message || "Tap log unavailable", scans: [] }); });
    return () => { alive = false; };
  }, [student.student_id, from, to]);

  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);

  // Reader per day, from the taps, so a cell can say "via Main gate" here
  // even when the grid could not.
  const firstTapByDay = useMemo(() => {
    const m = new Map();
    for (const sc of taps.scans) {              // newest first → last write is the earliest
      const day = saIstDay(new Date(sc.scanned_at));
      m.set(day, sc);
    }
    return m;
  }, [taps.scans]);

  const t = student.totals;

  return (
    <>
      <div className="sa-scrim" onClick={onClose} aria-hidden="true"/>
      <aside className="sa-drawer" role="dialog" aria-label={student.name}>
        <div className="sa-drawer-grip" aria-hidden="true"/>
        <div className="sa-drawer-head">
          <CX.Avatar name={student.name} size="lg"/>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 16, fontWeight: 600, color: "var(--ink-0)" }}>{student.name}</div>
            <div style={{ fontSize: 12.5, color: "var(--ink-3)", marginTop: 2 }}>
              {student.class_label} · Roll {student.roll_no || "—"}
            </div>
          </div>
          <button className="cx-round-btn xs" onClick={onClose} aria-label="Close" title="Close">×</button>
        </div>

        <section>
          <div className="sa-sec">
            {from === to ? saPrettyDate(from) : `${saPrettyDate(from)} — ${saPrettyDate(to)}`}
          </div>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 12 }}>
            <CX.Pill tone="green">P {t.present}</CX.Pill>
            <CX.Pill tone={t.absent ? "red" : "grey"}>A {t.absent}</CX.Pill>
            <CX.Pill tone={t.late ? "amber" : "grey"}>L {t.late}</CX.Pill>
            <CX.Pill tone="grey">H {t.half_day}</CX.Pill>
            <CX.Pill tone="violet">E {t.excused}</CX.Pill>
            <CX.Pill tone="dark">{t.percent == null ? "—" : `${t.percent}%`}</CX.Pill>
          </div>
          {report.days.length === 0 ? (
            <div style={{ fontSize: 13, color: "var(--ink-3)" }}>Nothing marked in this range.</div>
          ) : (
            <div>
              {report.days.map((d) => {
                const cell = student.cells[d];
                const scan = firstTapByDay.get(d);
                return (
                  <div key={d} className="sa-taprow" title={saCellTitle(cell, scan)}>
                    <span className="m">{saPrettyDate(d, { weekday: "short", day: "numeric", month: "short" })}</span>
                    <span style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
                      <SaCell cell={cell} isToday={d === today}/>
                      <span style={{ color: "var(--ink-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                        {cell
                          ? cell.first_scan_at
                            ? `${saTime(cell.first_scan_at)} via ${scan?.reader_label || "gate"}`
                            : `marked ${saTime(cell.marked_at)}`
                          : <span style={{ color: "var(--ink-4)" }}>not tapped, not marked</span>}
                      </span>
                    </span>
                    <span style={{ fontSize: 11.5, color: cell?.source === "rfid" ? "var(--amber)" : "var(--ink-3)", whiteSpace: "nowrap" }}>
                      {cell ? (cell.source === "rfid" ? "gate only" : `✓ ${cell.marked_by_name || "confirmed"}`) : ""}
                    </span>
                  </div>
                );
              })}
            </div>
          )}
        </section>

        <section>
          <div className="sa-sec">Every tap</div>
          {taps.loading && <div style={{ fontSize: 13, color: "var(--ink-3)" }}>Reading the gate log…</div>}
          {taps.error && <div style={{ fontSize: 13, color: "var(--amber)" }}>{taps.error}</div>}
          {!taps.loading && !taps.error && taps.scans.length === 0 && (
            <div style={{ fontSize: 13, color: "var(--ink-3)" }}>No taps recorded in this range.</div>
          )}
          {taps.scans.map((sc) => (
            <div key={sc.id} className="sa-taprow">
              <span className="m">{saDateTime(sc.scanned_at)}</span>
              <span style={{ color: "var(--ink-1)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                {sc.reader_label || sc.reader_id}
              </span>
              <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                <span className="m" title="Card UID">{sc.card_uid}</span>
                {!sc.known && <CX.Pill tone="amber">unknown card</CX.Pill>}
              </span>
            </div>
          ))}
          <div className="sa-foot" style={{ marginTop: 10 }}>
            The first tap of the day is what the register takes; every later tap is kept here as evidence and changes nothing.
          </div>
        </section>
      </aside>
    </>
  );
};

/* ---------- The screen ---------- */

const SaStudentAttendanceScreen = ({ setScreen }) => {
  const CX = window.CX;
  const today = saIstToday();

  const [from, setFrom] = useState(() => saShiftDay(0));
  const [to, setTo] = useState(() => saShiftDay(0));
  const [classSectionId, setClassSectionId] = useState("");
  const [status, setStatus] = useState("");
  const [search, setSearch] = useState("");
  const [q, setQ] = useState("");
  const [showFilters, setShowFilters] = useState(() => {
    try { return !window.matchMedia("(max-width: 760px)").matches; } catch { return true; }
  });
  const [exporting, setExporting] = useState(null);
  const [exportErr, setExportErr] = useState(null);
  const [selectedId, setSelectedId] = useState(null);
  const [dayScans, setDayScans] = useState(null);   // Map student_id → first tap, one-day view only

  // Debounced so the search box doesn't refetch the register per keystroke.
  useEffect(() => {
    const t = setTimeout(() => setQ(search.trim()), 350);
    return () => clearTimeout(t);
  }, [search]);

  const query = useMemo(() => ({
    from, to, class_section_id: classSectionId, status, q,
  }), [from, to, classSectionId, status, q]);
  const qs = saQs(query);

  const includesToday = from <= today && today <= to;
  const state = CX.useCockpitData(`/student-attendance/report${qs}`, { pollMs: includesToday ? 30000 : 0 });
  const report = state.data;
  const single = from === to;

  // One-day view: the reader each child tapped at comes from the scan log,
  // which the report does not carry. Refreshed with every report load; a
  // missing endpoint just leaves the reader column blank.
  useEffect(() => {
    if (!single || !report) { setDayScans(null); return; }
    let alive = true;
    window.KXApi.get(`/rfid/scans${saQs({ from, to: from, limit: 2000 })}`)
      .then((r) => {
        if (!alive) return;
        const m = new Map();
        for (const sc of r?.scans || []) if (sc.student_id) m.set(sc.student_id, sc); // newest first → earliest wins
        setDayScans(m);
      })
      .catch(() => { if (alive) setDayScans(null); });
    return () => { alive = false; };
  }, [single, from, report?.generated_at]);

  const setRange = (f, t) => { setFrom(f); setTo(t); };

  const exportAs = async (ext) => {
    setExporting(ext); setExportErr(null);
    try {
      const { blobUrl, filename } = await window.KXApi.download(`/student-attendance/report.${ext}${qs}`);
      if (ext === "pdf") {
        // The browser's own PDF viewer is where Print lives. A popup blocker
        // gets the same fallback as the spreadsheet: an anchor click.
        const w = window.open(blobUrl, "_blank");
        if (!w) saTriggerDownload(blobUrl, filename);
      } else {
        saTriggerDownload(blobUrl, filename);
      }
      // Revoked late — Safari drops the blob under a viewer still loading it.
      setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
    } catch (e) {
      setExportErr(e?.message || `Couldn't build the ${ext.toUpperCase()}`);
    } finally {
      setExporting(null);
    }
  };

  const sections = window.KX?.CLASS_SECTIONS || [];
  const activeFilters = [classSectionId, status, q].filter(Boolean).length;
  const presetKey = SA_PRESETS.find((p) => p.from() === from && p.to() === to)?.key || null;
  const t = report?.summary?.totals;
  const nothingMarked = !!report && (report.days.length === 0 || report.students.length === 0);
  const selected = selectedId && report ? report.students.find((s) => s.student_id === selectedId) : null;
  const closeDrawer = useCallback(() => setSelectedId(null), []);

  return (
    <div className="cx-page">
      <style>{SA_CSS}</style>
      <CX.PageHead
        eyebrow={`Academics${t ? ` · ${CX.num(t.students)} students` : ""}`}
        title="Student Attendance"
        onBack={() => setScreen("academics")}
        actions={
          <>
            <button className="btn" onClick={() => exportAs("pdf")} disabled={!report || exporting !== null}
                    title={single ? "One day prints as a signed-off list, grouped by class" : "A range prints as a grid — students down the side, dates across the top"}>
              <CX.Icon name="file" size={15}/> {exporting === "pdf" ? "Building…" : "Print sheet"}
            </button>
            <button className="cx-btn-dark" onClick={() => exportAs("xlsx")} disabled={!report || exporting !== null}>
              <CX.Icon name="download" size={16}/> {exporting === "xlsx" ? "Building…" : "Excel"}
            </button>
          </>
        }
      />

      {/* Filters. Presets answer most questions on their own; the rest is
          collapsible on a phone, where the form would fill the first screen. */}
      <div className="cx-card" style={{ marginBottom: 20, padding: "16px 20px" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
          <CX.Seg value={presetKey} onChange={(k) => { const p = SA_PRESETS.find((x) => x.key === k); if (p) setRange(p.from(), p.to()); }}
                  options={SA_PRESETS.map((p) => ({ value: p.key, label: p.label }))}/>
          <button className={`btn sm sa-filtertoggle${showFilters || activeFilters ? " primary" : ""}`}
                  onClick={() => setShowFilters((v) => !v)}>
            Filters{activeFilters ? ` · ${activeFilters}` : ""}
          </button>
          {(state.loading || state.refreshing) && report && (
            <span className="cx-pill" style={{ marginLeft: "auto", color: "var(--ink-3)" }}>Updating…</span>
          )}
        </div>
        <div className={`sa-filters${showFilters ? "" : " collapsed"}`}>
          <SaField label="From">
            <input type="date" className="input" value={from} max={to}
                   onChange={(e) => e.target.value && setFrom(e.target.value > to ? to : e.target.value)}/>
          </SaField>
          <SaField label="To">
            <input type="date" className="input" value={to} min={from} max={today}
                   onChange={(e) => e.target.value && setTo(e.target.value < from ? from : e.target.value)}/>
          </SaField>
          <SaField label="Class">
            <select className="select" value={classSectionId} onChange={(e) => setClassSectionId(e.target.value)}>
              <option value="">All classes</option>
              {sections.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
            </select>
          </SaField>
          <SaField label="Status">
            <select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
              <option value="">Every status</option>
              {SA_STATUSES.map((s) => <option key={s} value={s}>{SA_STATUS_LABEL[s]}</option>)}
            </select>
          </SaField>
          <SaField label="Search" className="grow">
            <input className="input" placeholder="Student name or roll" value={search}
                   onChange={(e) => setSearch(e.target.value)}/>
          </SaField>
          {activeFilters > 0 && (
            <button className="btn sm ghost" style={{ alignSelf: "flex-end", marginBottom: 2 }}
                    onClick={() => { setSearch(""); setQ(""); setClassSectionId(""); setStatus(""); }}>
              Clear filters
            </button>
          )}
        </div>
        {exportErr && (
          <div style={{ marginTop: 10, fontSize: 12.5, color: "var(--red)" }}>{exportErr}</div>
        )}
      </div>

      <CX.Screen state={state} skeleton={<CX.Skeleton rows={3} height={110}/>}>
        {report && t && (
          <>
            {includesToday && (
              <SaGateStrip report={report} today={today} refreshing={state.refreshing}/>
            )}

            <SaTotals t={t}/>

            <CX.Card>
              <div className="cx-card-head" style={{ marginBottom: 10 }}>
                <div style={{ minWidth: 0 }}>
                  <div className="cx-card-title">{saCaption(report)}</div>
                  <div style={{ fontSize: 12.5, color: "var(--ink-3)", marginTop: 2 }}>
                    {CX.num(t.students)} student{t.students === 1 ? "" : "s"} · {report.days.length} day{report.days.length === 1 ? "" : "s"} marked
                    {t.days_without_marks > 0 && ` · ${t.days_without_marks} with no marks`}
                    {report.detail_truncated && " · detail list truncated, totals are complete"}
                  </div>
                </div>
                <span className="cx-pill">{single ? "Day sheet" : "Register grid"}</span>
              </div>

              {nothingMarked ? (
                <CX.Empty icon="clock"
                          title={report.students.length === 0 ? "No students match these filters" : "Nothing marked for these filters"}
                          hint={includesToday
                            ? "Marks appear here as children tap in at the gate or a class teacher takes the roll."
                            : "No gate taps and no roll were recorded for this range."}/>
              ) : single ? (
                <SaDayList report={report} day={report.days[0]} scans={dayScans} today={today} onPick={setSelectedId}/>
              ) : (
                <SaRegisterGrid report={report} today={today} onPick={setSelectedId}/>
              )}

              <div style={{ paddingTop: 16, marginTop: 8, borderTop: "1px solid var(--line-soft)" }}>
                <SaLegend/>
                <div className="sa-foot">
                  Gate marks are suggestions until the class teacher confirms the sheet. A child who did not tap is blank — never absent.
                  {" "}Attendance is present + late over the marks actually taken — never over the calendar range.
                </div>
              </div>
            </CX.Card>

            {report.summary.by_class.length > 0 && (
              <CX.Card style={{ marginTop: 20 }}>
                <div className="cx-card-head" style={{ marginBottom: 8 }}>
                  <div className="cx-card-title">By class</div>
                  <span style={{ fontSize: 12.5, color: "var(--ink-3)" }}>Counted under the class the child was in that day</span>
                </div>
                <SaByClass rows={report.summary.by_class} onPickClass={setClassSectionId}/>
              </CX.Card>
            )}
          </>
        )}
      </CX.Screen>

      {selected && (
        <SaStudentDrawer student={selected} report={report} today={today} onClose={closeDrawer}/>
      )}
    </div>
  );
};

window.CockpitStudentAttendance = SaStudentAttendanceScreen;
