/* global React, Icon, Badge, ModalOverlay, PageHeader, SectionHead, apiGet, apiPost, apiDelete, activeMembers, MEMBERS, MEMBER_CLASSES */
const { useState: useAvState, useEffect: useAvEffect, useMemo: useAvMemo, useRef: useAvRef, useCallback: useAvCallback } = React;

// when2meet, but for a recurring week. Members paint the times they are generally free;
// anyone can shade the same grid by how many of a chosen subset overlap.
//
// The grid's shape is not defined here — /api/availability hands down a descriptor
// (days, day window, slot length) from lib/availability-grid.js and everything below
// renders from it, so the two sides can never disagree about what a slot index means.
// See tasks/weekly-availability-heatmap.md.

// Heat ramp, coolest first. Deliberately restricted to the accent steps the Tweaks panel
// remaps (100/200/300/600) so the map follows a coral, steel, or mist accent rather than
// staying coral while the rest of the page changes.
const HEAT_STEPS = [
  'var(--cream-100)',
  'var(--coral-100)',
  'var(--coral-200)',
  'var(--coral-300)',
  'var(--coral-600)',
];

function minuteLabel(minute, { compact = false } = {}) {
  const hour24 = Math.floor(minute / 60);
  const minutes = minute % 60;
  const suffix = hour24 >= 12 ? 'PM' : 'AM';
  const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12;
  if (compact && minutes === 0) return `${hour12} ${suffix}`;
  return `${hour12}:${String(minutes).padStart(2, '0')} ${suffix}`;
}

function slotStartMinute(grid, slotOfDay) {
  return grid.dayStartMinute + slotOfDay * grid.slotMinutes;
}

function slotIndex(grid, dayIndex, slotOfDay) {
  return dayIndex * grid.slotsPerDay + slotOfDay;
}

function describeSlot(grid, index) {
  const dayIndex = Math.floor(index / grid.slotsPerDay);
  const slotOfDay = index % grid.slotsPerDay;
  const start = slotStartMinute(grid, slotOfDay);
  return {
    dayIndex,
    slotOfDay,
    day: grid.days[dayIndex],
    label: `${grid.days[dayIndex].short} ${minuteLabel(start)}–${minuteLabel(start + grid.slotMinutes)}`,
  };
}

// Contiguous same-day runs of equal head-count, best first. Equal-count runs are the
// honest unit: across one of them the exact same people are free, so "Tue 6:00–7:30 PM,
// 14 free" is a claim that holds for the whole window rather than only its peak.
function bestWindows(counts, grid, limit = 5) {
  const runs = [];
  for (let dayIndex = 0; dayIndex < grid.days.length; dayIndex += 1) {
    let run = null;
    for (let slotOfDay = 0; slotOfDay < grid.slotsPerDay; slotOfDay += 1) {
      const count = counts[slotIndex(grid, dayIndex, slotOfDay)] || 0;
      if (run && run.count === count) {
        run.endMinute += grid.slotMinutes;
        continue;
      }
      if (run && run.count > 0) runs.push(run);
      run = {
        dayIndex,
        count,
        startMinute: slotStartMinute(grid, slotOfDay),
        endMinute: slotStartMinute(grid, slotOfDay) + grid.slotMinutes,
      };
    }
    if (run && run.count > 0) runs.push(run);
  }
  return runs
    .sort((a, b) => (b.count - a.count) || ((b.endMinute - b.startMinute) - (a.endMinute - a.startMinute)))
    .slice(0, limit);
}

// Slot lists keyed by member, so a head-count is a map lookup per member rather than a
// scan of the whole response list per cell.
function slotsByMemberMap(responses) {
  const map = new Map();
  responses.forEach((response) => map.set(response.memberId, response.slots));
  return map;
}

// Head-count per slot across a set of members. The subset filter changes constantly, so
// this recomputes on the client rather than round-tripping to the server.
function countAvailability(slotsByMember, memberIds, totalSlots) {
  const totals = new Array(totalSlots).fill(0);
  memberIds.forEach((memberId) => {
    (slotsByMember.get(memberId) || []).forEach((index) => {
      if (index >= 0 && index < totals.length) totals[index] += 1;
    });
  });
  return totals;
}

// Position on the heat ramp. Scaled to the busiest cell in view, the way when2meet
// does it — an absolute scale reads as flat whenever nobody is free all at once, and
// the exact head-count is always a hover away.
function heatLevel(count, peak) {
  if (!count || !peak) return 0;
  return Math.min(HEAT_STEPS.length - 1, 1 + Math.floor((count / peak) * (HEAT_STEPS.length - 1.001)));
}

function withSlots(base, indices, fill) {
  const next = new Set(base);
  indices.forEach((index) => (fill ? next.add(index) : next.delete(index)));
  return next;
}

// Every slot in the rectangle spanned by two cells — the days between their columns
// crossed with the times between their rows.
function slotsInBlock(grid, from, to) {
  const a = describeSlot(grid, from);
  const b = describeSlot(grid, to);
  const indices = [];
  for (let day = Math.min(a.dayIndex, b.dayIndex); day <= Math.max(a.dayIndex, b.dayIndex); day += 1) {
    for (let slot = Math.min(a.slotOfDay, b.slotOfDay); slot <= Math.max(a.slotOfDay, b.slotOfDay); slot += 1) {
      indices.push(slotIndex(grid, day, slot));
    }
  }
  return indices;
}

function AvailabilityGrid({
  grid, children, headerAction, onToggleDay, onToggleTime, className = '',
}) {
  return (
    <div className={`av-grid-wrap ${className}`}>
      <div className="av-grid" style={{ '--av-days': grid.days.length }}>
        <div className="av-corner">{headerAction}</div>
        {grid.days.map((day, dayIndex) => (
          <button
            key={day.key}
            type="button"
            className="av-day-head"
            title={onToggleDay ? `Toggle all of ${day.label}` : day.label}
            disabled={!onToggleDay}
            onClick={() => onToggleDay && onToggleDay(dayIndex)}
          >
            {day.short}
          </button>
        ))}
        {Array.from({ length: grid.slotsPerDay }, (_, slotOfDay) => {
          const start = slotStartMinute(grid, slotOfDay);
          const onHour = start % 60 === 0;
          return (
            <React.Fragment key={slotOfDay}>
              <button
                type="button"
                className={`av-time${onHour ? ' on-hour' : ''}`}
                title={onToggleTime ? `Toggle ${minuteLabel(start)} all week` : undefined}
                disabled={!onToggleTime}
                onClick={() => onToggleTime && onToggleTime(slotOfDay)}
              >
                {onHour ? minuteLabel(start, { compact: true }) : ''}
              </button>
              {grid.days.map((day, dayIndex) => children(slotIndex(grid, dayIndex, slotOfDay), { onHour, dayIndex, slotOfDay }))}
            </React.Fragment>
          );
        })}
      </div>
    </div>
  );
}

// --- My availability ---------------------------------------------------------

function MyAvailability({ grid, saved, onSave, onClear, saving, editingFor }) {
  const [draft, setDraft] = useAvState(() => new Set(saved ? saved.slots : []));
  const [error, setError] = useAvState('');
  // A paint gesture covers the rectangle between the cell it started on and the cell
  // the pointer is over, which is how when2meet behaves and what "drag across Tue–Thu
  // evenings" means. The value is decided by the first cell: press on a filled cell to
  // erase, an empty one to fill.
  //
  // Each move recomputes from `base` — the draft as it was when the gesture began —
  // rather than accumulating, so shrinking the rectangle takes back the cells it no
  // longer covers. It also means a fast drag that jumps several cells between move
  // events still paints everything in between, which a per-cell "paint what I touched"
  // approach silently misses.
  const gestureRef = useAvRef(null);

  // Reset the draft whenever the saved row changes identity (a fresh load, a completed
  // save, or an admin switching to a different member's schedule).
  useAvEffect(() => {
    setDraft(new Set(saved ? saved.slots : []));
  }, [saved?.memberId, saved?.updatedAt, editingFor]);

  useAvEffect(() => {
    const commit = () => { gestureRef.current = null; };
    // pointercancel is what the browser fires when it takes a touch over to pan the
    // page (the phone grid is touch-action:pan-y so it can be scrolled past). The
    // first cell was already painted on pointerdown for responsiveness, so put the
    // draft back as it was — otherwise scrolling the grid on a phone silently flips
    // whichever cell the finger happened to land on.
    const cancel = () => {
      const gesture = gestureRef.current;
      gestureRef.current = null;
      if (gesture) setDraft(gesture.base);
    };
    window.addEventListener('pointerup', commit);
    window.addEventListener('pointercancel', cancel);
    return () => {
      window.removeEventListener('pointerup', commit);
      window.removeEventListener('pointercancel', cancel);
    };
  }, []);

  const savedSet = useAvMemo(() => new Set(saved ? saved.slots : []), [saved]);
  const dirty = draft.size !== savedSet.size || [...draft].some((index) => !savedSet.has(index));

  function paint(indices, fill) {
    setDraft((current) => withSlots(current, indices, fill));
  }

  function startPaint(index, event) {
    // Touch and pen implicitly capture the pointer on the element they went down on,
    // which would keep every later pointermove targeted at that one cell and reduce a
    // drag to a single tap. Handing the capture back restores normal hit-testing.
    if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    const fill = !draft.has(index);
    gestureRef.current = { base: new Set(draft), anchor: index, fill, last: index };
    paint([index], fill);
  }

  function extendPaint(index) {
    const gesture = gestureRef.current;
    // pointermove fires continuously, most of them inside the cell already handled.
    if (!gesture || gesture.last === index) return;
    gesture.last = index;
    setDraft(withSlots(gesture.base, slotsInBlock(grid, gesture.anchor, index), gesture.fill));
  }

  function toggleDay(dayIndex) {
    const indices = Array.from({ length: grid.slotsPerDay }, (_, slotOfDay) => slotIndex(grid, dayIndex, slotOfDay));
    paint(indices, !indices.every((index) => draft.has(index)));
  }

  function toggleTime(slotOfDay) {
    const indices = grid.days.map((day, dayIndex) => slotIndex(grid, dayIndex, slotOfDay));
    paint(indices, !indices.every((index) => draft.has(index)));
  }

  function selectWeekdayBlock(startHour, endHour) {
    const indices = [];
    for (let dayIndex = 0; dayIndex < Math.min(5, grid.days.length); dayIndex += 1) {
      for (let slotOfDay = 0; slotOfDay < grid.slotsPerDay; slotOfDay += 1) {
        const start = slotStartMinute(grid, slotOfDay);
        if (start >= startHour * 60 && start < endHour * 60) indices.push(slotIndex(grid, dayIndex, slotOfDay));
      }
    }
    paint(indices, true);
  }

  // Monday is the day people actually fill in first; copying it across Tue–Fri turns a
  // repeating class schedule into one gesture instead of four.
  function copyMondayAcrossWeekdays() {
    setDraft((current) => {
      const next = new Set(current);
      for (let slotOfDay = 0; slotOfDay < grid.slotsPerDay; slotOfDay += 1) {
        const monday = current.has(slotIndex(grid, 0, slotOfDay));
        for (let dayIndex = 1; dayIndex < Math.min(5, grid.days.length); dayIndex += 1) {
          const index = slotIndex(grid, dayIndex, slotOfDay);
          if (monday) next.add(index); else next.delete(index);
        }
      }
      return next;
    });
  }

  async function save() {
    setError('');
    try {
      await onSave([...draft].sort((a, b) => a - b));
    } catch (saveError) {
      setError(saveError.message || 'Could not save your availability.');
    }
  }

  async function clear() {
    setError('');
    try {
      await onClear();
    } catch (clearError) {
      setError(clearError.message || 'Could not clear your availability.');
    }
  }

  const hours = (draft.size * grid.slotMinutes) / 60;

  return (
    <div className="av-layout">
      <div>
        <SectionHead
          title="Paint the times you are usually free"
          meta={`${grid.timeZoneLabel} · click and drag`}
        />
        <div className="card av-card">
          <AvailabilityGrid
            grid={grid}
            onToggleDay={toggleDay}
            onToggleTime={toggleTime}
            headerAction={<span className="av-corner-hint">drag</span>}
          >
            {(index, { onHour }) => (
              <button
                key={index}
                type="button"
                className={`av-cell${draft.has(index) ? ' is-on' : ''}${onHour ? ' on-hour' : ''}`}
                aria-pressed={draft.has(index)}
                aria-label={describeSlot(grid, index).label}
                onPointerDown={(event) => startPaint(index, event)}
                // pointermove, not pointerenter: enter/leave are synthesized from real
                // boundary crossings, so a drag whose pointer is captured or whose
                // moves coalesce can skip them entirely.
                onPointerMove={() => extendPaint(index)}
                onKeyDown={(event) => {
                  if (event.key !== 'Enter' && event.key !== ' ') return;
                  event.preventDefault();
                  paint([index], !draft.has(index));
                }}
              />
            )}
          </AvailabilityGrid>
        </div>
      </div>

      <div className="av-side">
        {error && <div className="form-error">{error}</div>}

        <div className="card av-save-card">
          <div className="av-save-head">
            <div>
              <div className="stat-label">Selected</div>
              <div className="stat-value">{hours % 1 === 0 ? hours : hours.toFixed(1)}<span className="unit">hrs / week</span></div>
            </div>
            {dirty
              ? <Badge tone="coral" dot>Unsaved</Badge>
              : saved
                ? <Badge tone="success" dot>Saved</Badge>
                : <Badge tone="cream">Not submitted</Badge>}
          </div>
          <div className="stat-meta">
            {saved
              ? `Last updated ${new Date(saved.updatedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`
              : 'Your schedule is not on the heat map until you save it.'}
          </div>
          <div className="av-save-actions">
            <button className="btn btn-primary btn-sm" type="button" disabled={!dirty || saving} onClick={save}>
              <Icon name="check" size={14}/> {saving ? 'Saving…' : 'Save availability'}
            </button>
            <button className="btn btn-ghost btn-sm" type="button" disabled={!dirty || saving}
              onClick={() => setDraft(new Set(savedSet))}>
              <Icon name="rotate-ccw" size={14}/> Revert
            </button>
          </div>
        </div>

        <div>
          <SectionHead title="Shortcuts"/>
          <div className="card av-shortcuts">
            <button className="btn btn-ghost btn-sm" type="button" onClick={() => selectWeekdayBlock(9, 17)}>
              Add weekdays 9–5
            </button>
            <button className="btn btn-ghost btn-sm" type="button" onClick={() => selectWeekdayBlock(17, 21)}>
              Add weekday evenings
            </button>
            <button className="btn btn-ghost btn-sm" type="button" onClick={copyMondayAcrossWeekdays}>
              Copy Monday to Tue–Fri
            </button>
            <button className="btn btn-ghost btn-sm" type="button" onClick={() => setDraft(new Set())}>
              Clear the week
            </button>
            {saved && (
              <button className="btn btn-ghost btn-sm av-danger" type="button" disabled={saving} onClick={clear}>
                <Icon name="trash" size={14}/> Remove my submission
              </button>
            )}
          </div>
          <div className="av-hint">
            Mark when you are <em>generally</em> free — a normal week, not a specific one.
            Drag across cells to paint, click a day or a time label to toggle the whole
            row or column.
          </div>
        </div>
      </div>
    </div>
  );
}

// --- Group heat map ----------------------------------------------------------

// A shortcut that adds or removes a whole group from the explicit member selection.
// `state` is 'all' | 'some' | 'none' so the chip can show a group the viewer has since
// half-unpicked from the roster list below it.
function GroupChip({ label, ids, state, onToggle }) {
  if (!ids.length) return null;
  return (
    <button
      type="button"
      className={`chip${state === 'all' ? ' active' : state === 'some' ? ' partial' : ''}`}
      onClick={() => onToggle(ids, state !== 'all')}
    >
      {state === 'all' && <span className="chip-tick"><Icon name="check" size={11}/></span>}
      {label}
      <span className="chip-count">{ids.length}</span>
    </button>
  );
}

function GroupHeatmap({ grid, responses, members, memberClasses, projects }) {
  const roster = useAvMemo(() => activeMembers(members), [members]);
  const [selectedIds, setSelectedIds] = useAvState(() => new Set(roster.map((member) => member.id)));
  const [search, setSearch] = useAvState('');
  const [pinnedSlot, setPinnedSlot] = useAvState(null);
  const [hoverSlot, setHoverSlot] = useAvState(null);

  // The roster arrives after this page mounts (App fetches /api/members in parallel),
  // and members get added while it is open. Either way a member nobody has ever made a
  // decision about starts counted — otherwise the heat map would quietly exclude the
  // whole club on first paint. Anyone deliberately unchecked stays unchecked, because
  // only ids never seen before are added back.
  const seenMemberIdsRef = useAvRef(null);
  useAvEffect(() => {
    const known = new Set(roster.map((member) => member.id));
    const seen = seenMemberIdsRef.current;
    seenMemberIdsRef.current = known;
    setSelectedIds((current) => {
      const next = new Set([...current].filter((id) => known.has(id)));
      roster.forEach((member) => {
        if (!seen || !seen.has(member.id)) next.add(member.id);
      });
      const unchanged = next.size === current.size && [...next].every((id) => current.has(id));
      return unchanged ? current : next;
    });
  }, [roster]);

  const slotsByMember = useAvMemo(() => slotsByMemberMap(responses), [responses]);

  const selectedMembers = useAvMemo(
    () => roster.filter((member) => selectedIds.has(member.id)),
    [roster, selectedIds]
  );
  const respondents = useAvMemo(
    () => selectedMembers.filter((member) => slotsByMember.has(member.id)),
    [selectedMembers, slotsByMember]
  );
  const waitingOn = useAvMemo(
    () => selectedMembers.filter((member) => !slotsByMember.has(member.id)),
    [selectedMembers, slotsByMember]
  );

  const counts = useAvMemo(
    () => countAvailability(slotsByMember, respondents.map((member) => member.id), grid.totalSlots),
    [respondents, slotsByMember, grid.totalSlots]
  );

  const peak = useAvMemo(() => counts.reduce((max, count) => Math.max(max, count), 0), [counts]);
  const windows = useAvMemo(() => bestWindows(counts, grid), [counts, grid]);

  const focusSlot = pinnedSlot ?? hoverSlot;
  const focusFree = useAvMemo(() => {
    if (focusSlot === null || focusSlot === undefined) return null;
    const free = [];
    const busy = [];
    respondents.forEach((member) => {
      ((slotsByMember.get(member.id) || []).includes(focusSlot) ? free : busy).push(member);
    });
    return { free, busy };
  }, [focusSlot, respondents, slotsByMember]);

  function setGroup(ids, on) {
    setSelectedIds((current) => {
      const next = new Set(current);
      ids.forEach((id) => (on ? next.add(id) : next.delete(id)));
      return next;
    });
  }

  function groupState(ids) {
    const selected = ids.filter((id) => selectedIds.has(id)).length;
    if (selected && selected === ids.length) return 'all';
    return selected ? 'some' : 'none';
  }

  const groupShortcuts = useAvMemo(() => [
    { key: 'everyone', label: 'Everyone', ids: roster.map((member) => member.id) },
    ...memberClasses.map((group) => ({
      key: `class:${group.id}`,
      label: group.name,
      ids: roster.filter((member) => (member.classIds || []).includes(group.id)).map((member) => member.id),
    })),
    ...projects.map((project) => ({
      key: `project:${project.id}`,
      label: project.title || project.name,
      ids: roster
        .filter((member) => (project.memberships || []).some((ms) => ms.memberId === member.id))
        .map((member) => member.id),
    })),
  ], [roster, memberClasses, projects]);

  const visibleRoster = useAvMemo(() => {
    const needle = search.trim().toLowerCase();
    if (!needle) return roster;
    return roster.filter((member) => `${member.name} ${member.email} ${member.role}`.toLowerCase().includes(needle));
  }, [roster, search]);

  return (
    <div className="av-layout">
      <div>
        <SectionHead
          title="When the group overlaps"
          meta={peak ? `Peak ${peak} of ${respondents.length} free` : 'No availability submitted yet'}
          action={
            <div className="av-legend">
              <span className="muted">0</span>
              {HEAT_STEPS.map((step, level) => (
                <span key={level} className="av-legend-swatch" style={{ background: step }}/>
              ))}
              <span className="muted">{peak || 0}</span>
            </div>
          }
        />
        <div className="card av-card">
          <AvailabilityGrid grid={grid} className="av-readonly">
            {(index, { onHour }) => {
              const count = counts[index];
              return (
                <button
                  key={index}
                  type="button"
                  className={`av-cell av-heat${onHour ? ' on-hour' : ''}${pinnedSlot === index ? ' is-pinned' : ''}`}
                  style={{ background: HEAT_STEPS[heatLevel(count, peak)] }}
                  aria-label={`${describeSlot(grid, index).label} — ${count} available`}
                  onPointerEnter={() => setHoverSlot(index)}
                  onPointerLeave={() => setHoverSlot((current) => (current === index ? null : current))}
                  onFocus={() => setHoverSlot(index)}
                  onClick={() => setPinnedSlot((current) => (current === index ? null : index))}
                />
              );
            }}
          </AvailabilityGrid>
        </div>

        <div className="av-slot-detail card">
          {focusSlot === null || focusSlot === undefined ? (
            <div className="muted av-slot-empty">
              Hover a cell to see who is free then — click to pin it.
            </div>
          ) : (
            <>
              <div className="row between av-slot-head">
                <div>
                  <div className="card-eyebrow">{pinnedSlot === focusSlot ? 'Pinned slot' : 'Slot'}</div>
                  <div className="av-slot-title">{describeSlot(grid, focusSlot).label}</div>
                </div>
                <Badge tone={focusFree.free.length ? 'coral' : 'cream'}>
                  {focusFree.free.length} of {respondents.length} free
                </Badge>
              </div>
              <div className="av-slot-cols">
                <div>
                  <div className="field-label">Free</div>
                  <div className="av-name-list">
                    {focusFree.free.length
                      ? focusFree.free.map((member) => <span key={member.id} className="av-name">{member.name}</span>)
                      : <span className="muted">Nobody</span>}
                  </div>
                </div>
                <div>
                  <div className="field-label">Busy</div>
                  <div className="av-name-list">
                    {focusFree.busy.length
                      ? focusFree.busy.map((member) => <span key={member.id} className="av-name is-busy">{member.name}</span>)
                      : <span className="muted">Nobody</span>}
                  </div>
                </div>
              </div>
            </>
          )}
        </div>
      </div>

      <div className="av-side">
        <div>
          <SectionHead title="Best windows"/>
          <div className="card av-windows">
            {windows.length ? windows.map((window, rank) => (
              <div key={`${window.dayIndex}-${window.startMinute}`} className="av-window">
                <div className="av-window-rank">{rank + 1}</div>
                <div className="av-window-body">
                  <div className="av-window-when">
                    {grid.days[window.dayIndex].label} · {minuteLabel(window.startMinute)}–{minuteLabel(window.endMinute)}
                  </div>
                  <div className="av-window-meta">
                    {(window.endMinute - window.startMinute) / 60 >= 1
                      ? `${((window.endMinute - window.startMinute) / 60).toFixed((window.endMinute - window.startMinute) % 60 ? 1 : 0)} hr`
                      : `${window.endMinute - window.startMinute} min`}
                    {' · '}
                    {window.count} of {respondents.length} free
                  </div>
                </div>
                <div className="av-window-count">{Math.round((window.count / Math.max(1, respondents.length)) * 100)}%</div>
              </div>
            )) : (
              <div className="muted av-slot-empty">
                No overlap yet — nobody in this selection has saved a schedule.
              </div>
            )}
          </div>
        </div>

        <div>
          <SectionHead title="Who's counted" meta={`${respondents.length}/${selectedMembers.length} submitted`}/>
          <div className="card av-picker">
            <div className="chip-row">
              {groupShortcuts.map((shortcut) => (
                <GroupChip
                  key={shortcut.key}
                  label={shortcut.label}
                  ids={shortcut.ids}
                  state={groupState(shortcut.ids)}
                  onToggle={setGroup}
                />
              ))}
            </div>

            <div className="recipient-search av-search">
              <Icon name="search" size={14}/>
              <input className="input" placeholder="Find a member" value={search}
                onChange={(event) => setSearch(event.target.value)}/>
            </div>

            <div className="av-roster">
              {visibleRoster.length ? visibleRoster.map((member) => {
                const submitted = slotsByMember.has(member.id);
                return (
                  <label key={member.id} className={`av-roster-row${selectedIds.has(member.id) ? ' selected' : ''}`}>
                    <input
                      type="checkbox"
                      checked={selectedIds.has(member.id)}
                      onChange={(event) => setGroup([member.id], event.target.checked)}
                    />
                    <span className="av-roster-name">{member.name}</span>
                    {submitted
                      ? <span className="av-roster-hours">{Math.round(((slotsByMember.get(member.id) || []).length * grid.slotMinutes) / 60)}h</span>
                      : <span className="av-roster-hours muted">—</span>}
                  </label>
                );
              }) : <div className="recipient-empty">No members match “{search}”.</div>}
            </div>

            {waitingOn.length > 0 && (
              <div className="av-waiting">
                <span className="field-label">Waiting on {waitingOn.length}</span>
                <span className="muted">{waitingOn.map((member) => member.name.split(' ')[0]).join(', ')}</span>
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

// --- Dashboard tile ----------------------------------------------------------

// The whole week at a glance: one 5px cell per slot, no labels, no interaction. Small
// enough to sit under the roster on the dashboard and still show where the club's
// evenings actually are. Faint rules at noon and 6 PM are the only orientation, since
// a time gutter at this scale is unreadable.
function MiniHeatmap({ grid, counts, peak }) {
  const tickMinutes = [12 * 60, 18 * 60];
  return (
    <div className="av-mini" style={{ gridTemplateColumns: `repeat(${grid.days.length}, 1fr)` }}>
      {Array.from({ length: grid.slotsPerDay }, (_, slotOfDay) => {
        const tick = tickMinutes.includes(slotStartMinute(grid, slotOfDay));
        return grid.days.map((day, dayIndex) => {
          const index = slotIndex(grid, dayIndex, slotOfDay);
          return (
            <span
              key={index}
              className={`av-mini-cell${tick ? ' tick' : ''}`}
              style={{ background: HEAT_STEPS[heatLevel(counts[index], peak)] }}
            />
          );
        });
      })}
    </div>
  );
}

// Dashboard card. Counts the active roster only — the same denominator the Availability
// page header uses — and opens the full, filterable heat map in a modal on click.
function AvailabilityTile({ members = MEMBERS, memberClasses = MEMBER_CLASSES, projects = [] }) {
  const { grid, responses, error } = useAvailabilityFeed();
  const [expanded, setExpanded] = useAvState(false);

  useAvEffect(() => {
    if (!expanded) return undefined;
    const onKey = (event) => { if (event.key === 'Escape') setExpanded(false); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [expanded]);

  const roster = useAvMemo(() => activeMembers(members), [members]);
  const slotsByMember = useAvMemo(() => slotsByMemberMap(responses), [responses]);
  const respondentIds = useAvMemo(
    () => roster.filter((member) => slotsByMember.has(member.id)).map((member) => member.id),
    [roster, slotsByMember]
  );
  const counts = useAvMemo(
    () => (grid ? countAvailability(slotsByMember, respondentIds, grid.totalSlots) : []),
    [grid, slotsByMember, respondentIds]
  );
  const peak = useAvMemo(() => counts.reduce((max, count) => Math.max(max, count), 0), [counts]);
  const top = useAvMemo(() => (grid ? bestWindows(counts, grid, 1)[0] : null), [counts, grid]);

  return (
    <>
      <button type="button" className="card av-tile" onClick={() => setExpanded(true)}
        disabled={!grid} title="Open the full heat map">
        <div className="row between av-tile-head">
          <div>
            <div className="stat-label">Availability</div>
            <div className="stat-meta">
              {grid ? `${respondentIds.length} of ${roster.length} submitted` : 'Loading…'}
            </div>
          </div>
          <Icon name="clock" size={15} stroke={1.4}/>
        </div>

        {error ? (
          <div className="muted av-tile-note">{error}</div>
        ) : !grid ? (
          <div className="av-mini av-mini-skeleton"/>
        ) : (
          <>
            <div className="av-mini-days">
              {grid.days.map((day) => <span key={day.key}>{day.short[0]}</span>)}
            </div>
            <MiniHeatmap grid={grid} counts={counts} peak={peak}/>
            <div className="av-tile-note">
              {top ? (
                <>
                  <strong>{grid.days[top.dayIndex].short} {minuteLabel(top.startMinute)}</strong>
                  <span className="muted"> · {top.count} of {respondentIds.length} free</span>
                </>
              ) : (
                <span className="muted">No availability submitted yet</span>
              )}
            </div>
          </>
        )}
      </button>

      {expanded && grid && (
        <ModalOverlay onClose={() => setExpanded(false)}>
          <div className="av-modal" role="dialog" aria-modal="true" aria-label="Club availability"
            onClick={(event) => event.stopPropagation()}>
            <div className="row between av-modal-head">
              <div>
                <div className="card-eyebrow">Operations</div>
                <div className="av-modal-title">Availability</div>
              </div>
              <button type="button" className="btn-icon" aria-label="Close" onClick={() => setExpanded(false)}>
                <Icon name="x" size={14}/>
              </button>
            </div>
            <div className="av-modal-body">
              <GroupHeatmap grid={grid} responses={responses} members={members}
                memberClasses={memberClasses} projects={projects}/>
            </div>
          </div>
        </ModalOverlay>
      )}
    </>
  );
}

// --- Page --------------------------------------------------------------------

// Loads the whole club's availability once. Shared by the full page and the dashboard
// tile so neither has its own idea of what the payload looks like.
function useAvailabilityFeed() {
  const [feed, setFeed] = useAvState({ grid: null, responses: [], myMemberId: null, error: '' });

  useAvEffect(() => {
    let cancelled = false;
    apiGet('/api/availability')
      .then((result) => {
        if (cancelled) return;
        setFeed({
          grid: result.grid,
          responses: result.responses || [],
          myMemberId: result.myMemberId || null,
          error: '',
        });
      })
      .catch((error) => {
        if (cancelled) return;
        setFeed((current) => ({ ...current, error: error.message || 'Could not load availability.' }));
      });
    return () => { cancelled = true; };
  }, []);

  // Lets a caller fold its own save straight into the loaded feed instead of refetching.
  const setResponses = useAvCallback((update) => {
    setFeed((current) => ({
      ...current,
      responses: typeof update === 'function' ? update(current.responses) : update,
    }));
  }, []);

  return { ...feed, setResponses };
}

function Availability({ members = MEMBERS, memberClasses = MEMBER_CLASSES, projects = [], currentUser }) {
  const [tab, setTab] = useAvState('mine');
  const [saving, setSaving] = useAvState(false);
  const { grid, responses, myMemberId, error: loadError, setResponses } = useAvailabilityFeed();

  const mine = useAvMemo(
    () => responses.find((response) => response.memberId === myMemberId) || null,
    [responses, myMemberId]
  );

  async function saveMine(slots) {
    setSaving(true);
    try {
      const result = await apiPost('/api/availability', { slots });
      setResponses((current) => [
        ...current.filter((response) => response.memberId !== result.response.memberId),
        result.response,
      ]);
    } finally {
      setSaving(false);
    }
  }

  async function clearMine() {
    setSaving(true);
    try {
      await apiDelete('/api/availability');
      setResponses((current) => current.filter((response) => response.memberId !== myMemberId));
    } finally {
      setSaving(false);
    }
  }

  const roster = activeMembers(members);
  // Only roster responses count toward coverage. Alumni and members abroad keep their
  // rows but are not active members, so counting them against an active-only
  // denominator can read past 100%.
  const rosterIds = useAvMemo(() => new Set(roster.map((member) => member.id)), [roster]);
  const submitted = responses.filter((response) => rosterIds.has(response.memberId)).length;
  const coverage = roster.length ? Math.round((submitted / roster.length) * 100) : 0;

  const headerTabs = (
    <div className="view-toggle" aria-label="Availability view">
      <button type="button" className={tab === 'mine' ? 'active' : ''} onClick={() => setTab('mine')}>
        My availability
      </button>
      <button type="button" className={tab === 'group' ? 'active' : ''} onClick={() => setTab('group')}>
        Group heat map
      </button>
    </div>
  );

  return (
    <>
      <PageHeader
        eyebrow="Operations · Availability"
        title="Availability"
        tabs={headerTabs}
        sub={tab === 'mine'
          ? 'Set the times you are generally free in a normal week. Everything you save feeds the club heat map.'
          : 'See when the club — or any slice of it — overlaps. Pick the people to count on the right.'}
        actions={
          <div className="av-coverage">
            <span className="stat-label">Submitted</span>
            <strong>{submitted}</strong>
            <span className="muted">of {roster.length} · {coverage}%</span>
          </div>
        }
      />

      {loadError && <div className="form-error" style={{ marginBottom: 16 }}>{loadError}</div>}

      {!grid ? (
        <div className="card muted">Loading the week…</div>
      ) : tab === 'mine' ? (
        !myMemberId ? (
          <div className="card">
            <div className="card-title">No roster record linked to your account</div>
            <div className="muted" style={{ fontSize: 12.5, marginTop: 6 }}>
              Availability is stored against the club roster, and {currentUser?.email || 'your account'} is
              not matched to a member yet. Ask an admin to add you on the Members page — the heat map is
              still readable in the meantime.
            </div>
          </div>
        ) : (
          <MyAvailability
            grid={grid}
            saved={mine}
            saving={saving}
            editingFor={myMemberId}
            onSave={saveMine}
            onClear={clearMine}
          />
        )
      ) : (
        <GroupHeatmap
          grid={grid}
          responses={responses}
          members={members}
          memberClasses={memberClasses}
          projects={projects}
        />
      )}
    </>
  );
}

Object.assign(window, { Availability, AvailabilityTile });
