/* ============================================================================
   cand-tabs.jsx — the candidate record's TAB STRIP and the two input gestures
   that drive it (1-5 keys, horizontal swipe).

   Split out of screens-core.jsx's CandidateDetailScreen. Loaded AFTER
   screens-core.jsx and BEFORE main.jsx, so everything screens-core declares is
   already in the shared global scope by the time anything here runs.

   The tab LIST is exported as one array (CAND_TABS). It used to live twice -
   once in the strip and once in the key handler - and the two drifted the moment
   Files was added: the strip went to five while the key map stopped at four, so
   the hint promised a "5" that did nothing. One list, no second place to forget.

   The keyboard/swipe behaviour ships as HOOKS, not as a wrapper component, so
   the shell calls them in its own body in the same order it always did. Wrapping
   them in a component would move their state to a different node of the React
   tree, and the point of this split is that nothing moves.

   ---------------------------------------------------------------------------
   WHY THIS STRIP NO LONGER USES DSc.Tabs (measured, 390x844, #/candidate/c-okafor)

   DSc.Tabs is a static row inside an `overflow-x: auto` box, and app.css:4708
   paints a permanent 18px right-edge MASK on `.e8-page .e8-tabs` under a
   `@media (max-width: 840px)` - a viewport query inside a record, the exact bug
   class CLAUDE.md documents. Measured before this rewrite:

     scroller clientWidth 242 / scrollWidth 443  -> 201px of tabs off-screen
     "Skills" spans x 219..273, the visible window ends at 262 and the mask
     starts fading at 244, so Skills reads as "hidden behind Details"
     pressing "5" selected Files (x 387..463) and left scrollLeft at 0 - the
     selected tab was 125px past the right edge, invisible, forever
     tab height 34px, Details trigger 30px - both under the 44px touch target

   None of that is fixable from the call site: it needs a ref to the scroller, a
   ref per tab, and a measured indicator. DSc.Tabs is a shared component owned by
   `components/ats-tabs/Tabs.jsx`, which this lane may not edit, so the strip is
   rebuilt here under its own `.e8-cand-tabs-*` namespace (app/cand-tabs.css).
   `.e8-cand-tabrow` / `.e8-cand-tabhint` had exactly one call site - this one - so
   after this rewrite app.css:2466-2468 match nothing anywhere in the app. The job
   record's strip is its own `.e8-jrail-tabrow` / `.e8-jrail-tabhint` and is
   untouched. Deleting the three dead rules is an app.css edit, which this lane may
   not make; it is reported instead.

   Three behaviours the rewrite exists for:
     1. the active tab is ALWAYS scrolled into view - on tab change, on mount,
        on container resize, and once the icon font has loaded and re-measured;
     2. the edge fades are STATE, not decoration - they appear only when there is
        actually more strip in that direction, so nothing is ever hidden by
        accident the way the unconditional mask hid Skills;
     3. the underline is one measured element that slides, so switching tabs
        cannot reflow the row.

   The 1-5 handler, its input guards and the rail-flip control are unchanged.
   ============================================================================ */

const CAND_TABS = ['overview', 'experience', 'skills', 'messages', 'files'];

/* Motion is a preference, read at the moment of the gesture rather than cached: the OS setting
   can change while the tab is open, and the CSS side (`@media (prefers-reduced-motion:
   no-preference)` in cand-tabs.css) already tracks it live.

   `document.hidden` is the second half of the same question. A HIDDEN tab gets no animation
   frames at all - measured here: five chained requestAnimationFrame callbacks produced 0 ticks
   with `document.visibilityState === 'hidden'`. Anything that animates a POSITION on rAF
   therefore never arrives in a background tab, and the tab strip would be revealed scrolled to
   the wrong place. So a hidden document takes the instant path: the guarantee is that the active
   tab is on screen, and the animation is only ever how it got there. */
function e8CandTabsMotionOK() {
  if (document.hidden) return false;
  return !!(window.matchMedia && !window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}

/* The tab strip + its right-hand affordance: a Details trigger when the rail has
   collapsed into a sheet (its only door), otherwise the keyboard hint. */
function CandTabStrip({ tab, onTab, counts, railCollapsed, railTrigRef, onOpenRail }) {
  const n = counts || {};
  const items = [
    { id: 'overview', label: 'Overview' },
    { id: 'experience', label: 'Experience', count: n.experience },
    { id: 'skills', label: 'Skills' },
    { id: 'messages', label: 'Messages', count: n.messages },
    /* Files were reachable only through the rail's "All files" link, which is
       invisible when the rail is collapsed and on a phone. They are record content,
       not rail furniture, so they get a tab. Same CandidateFilesSection the dialog
       renders - one implementation, two entrances. */
    { id: 'files', label: 'Files', count: n.files },
  ];

  const scRef = React.useRef(null);
  const rowRef = React.useRef(null);
  const btnRefs = React.useRef({});
  const animRef = React.useRef(0);
  /* Indicator geometry is DATA - three measured pixel offsets handed to CSS as custom properties.
     Held in state (not written to the node) so React owns the paint and a re-render cannot leave
     a stale underline behind.

     The Y offset is why there are three and not two. The underline used to be pinned to the
     SCROLLER's bottom edge (`bottom: 0`), which is the same line as every button's bottom edge
     only while the strip is a single row. Under the narrow container the strip now WRAPS (see
     cand-tabs.css), so a tab on the first row is 44px above that edge and the underline would
     have sat under the row below it. Measuring the active button's own bottom keeps the two
     together at every width; on an unwrapped strip it resolves to exactly the old position. */
  const [ind, setInd] = React.useState(null);
  /* The underline must NOT animate from x=0 on first paint - that reads as the page loading
     sideways. It is armed shortly after the first measurement (see the timer below). */
  const [armed, setArmed] = React.useState(false);
  const [edge, setEdge] = React.useState({ l: false, r: false });

  /* Our own tween rather than `scrollTo({ behavior: 'smooth' })`. MEASURED: in this browser the
     smooth path is a silent no-op - the call was made twice with `{left: 209}` and scrollLeft
     stayed at 0 both times, while the identical call with `behavior: 'auto'` landed. A guarantee
     ("the active tab is never off-screen") cannot rest on an engine behaviour that can decline
     without telling you, so the animation is ours and the fallback is a plain assignment. */
  const glide = React.useCallback((sc, to, animate) => {
    if (animRef.current) { window.cancelAnimationFrame(animRef.current); animRef.current = 0; }
    if (!animate || !window.requestAnimationFrame || !window.performance) { sc.scrollLeft = to; return; }
    const from = sc.scrollLeft;
    const dist = to - from;
    const t0 = window.performance.now();
    const dur = 190; /* same duration as the underline, so the two move as one object */
    const step = (t) => {
      const k = Math.min(1, (t - t0) / dur);
      sc.scrollLeft = from + dist * (1 - Math.pow(1 - k, 3));
      animRef.current = k < 1 ? window.requestAnimationFrame(step) : 0;
    };
    animRef.current = window.requestAnimationFrame(step);
  }, []);

  /* An in-flight tween holds a frame callback that would keep writing scrollLeft on a node this
     component no longer owns. Cancelled on unmount. */
  React.useEffect(() => () => { if (animRef.current) window.cancelAnimationFrame(animRef.current); }, []);
  /* A finger or a wheel outranks an in-flight tween - nothing is more irritating than a strip
     that scrolls itself back while you are dragging it. Wired to pointerdown/wheel/touchstart. */
  const stopGlide = () => { if (animRef.current) { window.cancelAnimationFrame(animRef.current); animRef.current = 0; } };

  const readEdges = React.useCallback(() => {
    const sc = scRef.current;
    if (!sc) return;
    const l = sc.scrollLeft > 1;
    const r = sc.scrollLeft + sc.clientWidth < sc.scrollWidth - 1;
    setEdge((p) => ((p.l === l && p.r === r) ? p : { l: l, r: r }));
  }, []);

  /* One routine for both jobs, because they answer the same question ("where is the active tab
     right now"): re-measure the underline, and pull the active tab back inside the scroll window
     if it has drifted out. Called on every input that can move either. */
  const sync = React.useCallback((scroll) => {
    const sc = scRef.current;
    const el = btnRefs.current[tab];
    if (!sc || !el) return;
    const x = el.offsetLeft;
    const w = el.offsetWidth;
    /* offsetTop/offsetLeft are both measured against the scroller, which is the underline's
       containing block (`position: relative`), so no second coordinate conversion is needed. */
    const y = el.offsetTop + el.offsetHeight - 2; /* 2 == the underline's own height */
    setInd((p) => ((p && p.x === x && p.w === w && p.y === y) ? p : { x: x, w: w, y: y }));
    if (scroll) {
      const pad = 20; /* a sliver of the neighbouring tab stays visible, so the strip reads as scrollable */
      const max = Math.max(0, sc.scrollWidth - sc.clientWidth);
      let next = sc.scrollLeft;
      if (x - pad < next) next = x - pad;
      else if (x + w + pad > next + sc.clientWidth) next = x + w + pad - sc.clientWidth;
      next = Math.max(0, Math.min(next, max));
      if (Math.abs(next - sc.scrollLeft) > 1) glide(sc, next, e8CandTabsMotionOK());
    }
    readEdges();
  }, [tab, readEdges, glide]);

  /* Tab change, and any count arriving late (a count changes a tab's width, which moves every
     tab after it and therefore the underline). Layout effect: measured before the browser paints,
     so the underline never shows one frame in the old place. */
  React.useLayoutEffect(() => { sync(true); }, [sync, n.experience, n.messages, n.files]);

  /* A TIMER, not requestAnimationFrame: rAF does not run in a hidden tab (measured - 0 ticks),
     and an underline that is never armed is an underline that never animates for the whole life
     of that record. A timer is throttled when hidden, but it fires. */
  React.useEffect(() => {
    const id = window.setTimeout(() => setArmed(true), 50);
    return () => window.clearTimeout(id);
  }, []);

  /* The RECORD ROW is what squeezes this strip - dragging the rail wider narrows the scroller
     without the window changing size at all - so the observer watches the scroller's own box.
     Asking the viewport here is the bug CLAUDE.md documents twice. */
  React.useEffect(() => {
    const sc = scRef.current;
    if (!sc || !window.ResizeObserver) return undefined;
    const ro = new window.ResizeObserver(() => sync(true));
    ro.observe(sc);
    return () => ro.disconnect();
  }, [sync]);

  /* ─────────────────── WAVE-3 SEAM FIX: PUBLISH THE STICKY HEIGHT ───────────────────
     THE CONTRACT THIS SIGNS. cand-overview.css writes its activity day header as
     `top: var(--e8-cand-rec-sticky-top, 0px)` and says in the file: "whatever is sticky at the
     top of the record's content column must publish its height so anything else sticky in that
     column can sit below it… the record shell or the tab row can start publishing without a
     change here." Nothing ever published it, so the variable resolved to the 0px fallback.

     WHAT THAT COST, MEASURED on #/candidate/c-okafor, Overview, persona Sarah Kim, rail
     expanded, `.e8-content.e8-rbody-content` scrolled past the feed:
       390x844  tab row sticky, top 0, z-index 2, height 89 (five tabs wrapped to two lines),
                stuck at y=163. Day header sticky, top 0, z-index 1, y=163. Identical line,
                lower z - the header ("Today" / "Yesterday") was painted behind the tab strip
                for the whole time it was stuck, and the activity rows scrolled under it.
       834x1112 same, height 45, row 113..158, header at 138 - inside the row's band again.
       1440x900 tab row `position: static`; header lands at its own top:0 and is correct.
     The note in cand-tabs.css:285 - "Nothing else in this scroller is sticky (checked: zero
     position: sticky|fixed descendants)" - was TRUE when it was written and stopped being true
     when the neighbouring module added the day header. That is the whole seam: two blocks,
     two authors, one line of the viewport.

     WHY JS AND NOT CSS. The height is not a constant - 89 at 390 (wrapped strip), 45 at 834
     (one line) - so no stylesheet can state it, and a descendant cannot set a custom property
     on an ancestor. This is a MEASURED PIXEL OFFSET handed to CSS as a custom property, the
     same documented exemption the underline three refs above already uses.
     It is published on the row's parent (`.e8-ui-grow`, cand-record.jsx:1259), which is the
     nearest common ancestor of the strip and the tab body, and which carries no `style` prop -
     so React never manages its inline style and never clobbers this.
     ZERO WHEN NOT STICKY: at desktop the row is `position: static` and the day header's own
     top: 0 is already right, so publishing a height there would push it down for nothing. */
  React.useEffect(() => {
    const row = rowRef.current;
    const host = row && row.parentElement;
    if (!row || !host) return undefined;
    const publish = () => {
      const stuck = window.getComputedStyle(row).position === 'sticky';
      const px = stuck ? Math.round(row.getBoundingClientRect().height) : 0;
      host.style.setProperty('--e8-cand-rec-sticky-top', px + 'px');
    };
    publish();
    if (!window.ResizeObserver) return () => host.style.removeProperty('--e8-cand-rec-sticky-top');
    const ro = new window.ResizeObserver(publish);
    ro.observe(row);
    return () => { ro.disconnect(); host.style.removeProperty('--e8-cand-rec-sticky-top'); };
  }, []);

  /* Material Symbols and the UI font land after first paint and change every label's width. A
     measurement taken before they do is wrong by a few pixels on every tab. */
  React.useEffect(() => {
    if (!document.fonts || !document.fonts.ready) return undefined;
    let alive = true;
    document.fonts.ready.then(() => { if (alive) sync(true); });
    return () => { alive = false; };
  }, [sync]);

  /* Arrow keys move within the strip once it has focus - the standard tablist gesture, and the
     only way to reach tabs 4 and 5 from the keyboard without knowing the digit shortcuts.
     Scoped to the strip, so it cannot collide with any global handler.

     This now pairs with a ROVING TABINDEX (see the button below). The earlier note here said the
     roving half was deliberately omitted so that "nothing moves" relative to the pre-rewrite
     strip; measured, what that preserved was five separate Tab stops on one control, so a
     keyboard user crossed the whole strip to reach the body. ARIA APG's tablist pattern is one
     stop for the group with Arrow keys inside it, which is what the arrow handler was already
     half-building. The 1-5 digit shortcuts are untouched: they move SELECTION without moving
     focus, and a focused tab that loses selection keeps focus even at tabindex -1. */
  const onStripKey = (e) => {
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    const i = CAND_TABS.indexOf(tab);
    let next = null;
    if (e.key === 'ArrowRight') next = CAND_TABS[(i + 1) % CAND_TABS.length];
    else if (e.key === 'ArrowLeft') next = CAND_TABS[(i - 1 + CAND_TABS.length) % CAND_TABS.length];
    else if (e.key === 'Home') next = CAND_TABS[0];
    else if (e.key === 'End') next = CAND_TABS[CAND_TABS.length - 1];
    if (!next) return;
    e.preventDefault();
    onTab(next);
    const el = btnRefs.current[next];
    /* preventScroll: the browser's own "reveal the focused element" jump is instant and
       ignores prefers-reduced-motion; sync() does the same move under our own rules. */
    if (el) el.focus({ preventScroll: true });
  };

  return (
    <div className="e8-cand-tabs-row" ref={rowRef}>
      <div className={'e8-cand-tabs-shell' + (edge.l ? ' is-fade-l' : '') + (edge.r ? ' is-fade-r' : '')}>
        <div className="e8-cand-tabs-sc" ref={scRef} role="tablist" aria-label="Candidate record sections"
          onScroll={readEdges} onKeyDown={onStripKey}
          onPointerDown={stopGlide} onWheel={stopGlide} onTouchStart={stopGlide}>
          {/* tabIndex is the roving half: the STRIP is one Tab stop, Arrow/Home/End move inside it. */}
          {items.map((it) => (
            <button key={it.id} type="button" role="tab" aria-selected={tab === it.id}
              tabIndex={tab === it.id ? 0 : -1}
              ref={(el) => { btnRefs.current[it.id] = el; }}
              className={'e8-cand-tabs-btn' + (tab === it.id ? ' is-on' : '')}
              onClick={() => onTab(it.id)}>
              {it.label}
              {it.count != null ? <span className="e8-cand-tabs-count">{it.count}</span> : null}
            </button>
          ))}
          {/* One underline for the whole strip, not one ::after per button: a single element can
              slide between tabs, and it cannot change any button's box, so switching tabs moves
              nothing. The two offsets are measured DATA, handed over as custom properties. */}
          <span className={'e8-cand-tabs-ind' + (armed ? ' is-armed' : '')} aria-hidden="true"
            style={ind ? { '--e8-cand-tabs-x': ind.x + 'px', '--e8-cand-tabs-w': ind.w + 'px', '--e8-cand-tabs-y': ind.y + 'px' } : undefined} />
        </div>
      </div>
      {railCollapsed ? (
        /* The label is a SPAN so the narrow container query can drop it and leave the icon. It
           cost 87px of a 346px row at 390 - a quarter of the strip spent on a word that the
           icon already says - and that width is the difference between two tabs hidden and
           one. `aria-label` is unconditional so the accessible name survives the label being
           display:none, and matches it exactly at every width. */
        <button type="button" ref={railTrigRef} className="e8-rail-trigger e8-cand-tabs-trig" aria-haspopup="dialog"
          aria-label="Details" onClick={onOpenRail}>
          <span className="material-symbols-outlined" aria-hidden="true">info</span>
          <span className="e8-cand-tabs-triglbl">Details</span>
        </button>
      ) : (
        /* The copy is checked against the handlers, not against intent. `1-5` is true and is
           DERIVED from CAND_TABS below, so it cannot drift again. "` flips the rail" was false:
           record-shell.jsx:172-200 cycles the RAIL'S OWN TABS (Details -> Activity -> ...) and
           there is no show/hide control for the rail at all - so a recruiter who pressed it after
           reading this saw the rail change contents rather than disappear, and concluded the key
           was broken.

           `aria-hidden` is gone with it. It was hiding a keyboard hint from the users most likely
           to be navigating by keyboard; the string is short, factual, and reads once at the end of
           the tablist. (It appears only while the rail is expanded, i.e. never on the phone shell
           where the Details trigger takes this slot.) */
        <span className="e8-cand-tabs-hint">1–5 switch tabs · ` cycles rail tabs</span>
      )}
    </div>
  );
}

/* R124: 1-5 switch the record tabs (Overview · Experience · Skills · Messages · Files); the rail's
   ` toggle lives in RecordRail. Input-guarded like every global key, and stands down while
   any blocking overlay is up (same rule as the triage handler's reviewReject || present) -
   switching the tabs BEHIND the files dialog or a centered note reads as a lost keystroke. */
function useCandTabKeys(setTab, blocked) {
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      const t = e.target;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
      if (blocked) return;
      /* Hosted outside this component: a CENTERED NoteDoc or a TaskComposer also blocks. */
      if (document.querySelector('.e8-ndoc-scrim, .e8-tkc-scrim')) return;
      /* The digits are DERIVED from the tab list, because the two drifted the moment Files was
         added: the strip and the hint went to five while the key map still stopped at 4, so the
         hint promised a "5" that did nothing. One list, no second place to forget. */
      const i = CAND_TABS.map((_, n) => String(n + 1)).indexOf(e.key);
      if (i > -1) { e.preventDefault(); setTab(CAND_TABS[i]); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [blocked]);
}

/* Horizontal swipe between the same five tabs (mobile). Thin on purpose: the gesture itself is
   useSwipeTabs in shell.jsx - this only pins the tab list so the swipe order and the key order
   can never disagree. */
function useCandSwipeTabs(tab, setTab) {
  return useSwipeTabs(CAND_TABS, tab, setTab);
}

Object.assign(window, { CAND_TABS, CandTabStrip, useCandTabKeys, useCandSwipeTabs });
