import React, {
  MutableRefObject,
  ReactNode,
  TransitionEvent,
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useMatch, useNavigate } from 'react-router-dom';
import { useAtomValue, useSetAtom } from 'jotai';
import { ClientEvent, SyncState } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, toRem } from 'folds';
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
import { isNativePlatform } from '../../utils/capacitor';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useBotPresets } from '../../features/bots/catalog';
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
import type { MobilePagerTab } from '../mobile-tabs-pager/MobilePagerPaneContext';
import {
  MobilePagerCurtainControls,
  StreamHeaderPrimaryAction,
  curtainRefreshRevealByTabAtom,
  mobilePagerCurtainAtom,
  mobilePagerMascotApiAtom,
} from '../../state/mobilePagerHeader';
import { REFRESH_MAX_MS, REFRESH_MIN_MS, TABS_ROW_PX, WEB_TABS_ROW_PX } from './geometry';
import { settingsSheetAtom } from '../../state/settingsSheet';
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import * as css from './StreamHeader.css';
import { Segment } from './Segment';
import { RefreshMascot } from './RefreshMascot';
import { RadarPulse, useEchoes } from './RadarPulse';
import { isFormSnap, isRefreshSnap, snapTopPx, useCurtainState } from './useCurtainState';
import { useCurtainHandleGesture } from './useCurtainHandleGesture';
import { useCurtainBodyGesture } from './useCurtainBodyGesture';
import { InlineRoomSearch } from './forms/InlineRoomSearch';

// Exported so the pager's static header can `aria-controls` the same
// form region (MobileTabsPagerHeader imports this instead of keeping a
// hand-synced copy of the literal).
export const INLINE_FORM_ID = 'stream-header-inline-form';

type StreamHeaderProps = {
  // Scroll viewport that hosts the chat list under the curtain. The
  // curtain BODY gesture (`useCurtainBodyGesture`) reads this ref's
  // `scrollHeight`/`clientHeight` to decide whether to engage: long
  // lists keep native scroll, short / empty lists drive the curtain
  // via body drag. May be a ref whose `.current` is null on listing
  // surfaces that render their empty state directly as a curtain
  // child without wrapping it in `PageNavContent` (Direct's
  // `DirectEmpty`, ChannelsRootNav's `ChannelsLanding`) — the body
  // gesture treats null as «not scrollable» and engages.
  scrollRef: MutableRefObject<HTMLDivElement | null>;
  // Curtain contents — the chat list. The list is rendered inside an
  // `overflow: auto` div that the gesture hook listens to.
  children: ReactNode;
  // Optional row(s) pinned to the bottom of the curtain (DirectSelfRow,
  // WorkspaceFooter). Hidden while a form is active so the on-screen
  // keyboard's viewport resize doesn't push them up over the form
  // (see commit 14ed080).
  bottomPinned?: ReactNode;
  // Stable identifier used to persist the curtain's pinned overlay
  // across listing-pane remounts (the user taps into a Room and back,
  // which unmounts the listing pane). Pin state is stored in
  // `curtainPinnedByTabAtom[pinKey]` so it outlives any individual
  // StreamHeader instance. Each listing tab (Direct/Channels/Bots)
  // passes its own key; the Channels landing CTA and workspace
  // listing share `"channels"` so pin survives the toggle between
  // empty state and a chosen workspace.
  //
  // Typed as the pager tab name: the pin/reveal maps keyed by this are
  // read back by MobileTabsPager(+Header) via the pager's own tab
  // union, so the names must coincide — compiler-enforced.
  pinKey: MobilePagerTab;
  // Optional contextual create action (a Plus button in the tabs row).
  // When omitted (Direct) the header shows no Plus at all — starting a chat
  // is folded into search, which finds people via the homeserver user
  // directory. Channels sets this to «create channel» / «create
  // community» so its Plus launches that flow.
  primaryAction?: StreamHeaderPrimaryAction;
};

export function StreamHeader({
  scrollRef,
  children,
  bottomPinned,
  pinKey,
  primaryAction,
}: StreamHeaderProps) {
  const { t } = useTranslation();
  const navigate = useNavigate();
  const bots = useBotPresets();
  const navOpts = useMemo(() => ({ replace: isNativePlatform() }), []);

  const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false });
  const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: false });
  const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: false });
  const showBotsSegment = bots.length > 0 || !!botsMatch;

  // Pager mode wiring. When this StreamHeader is mounted inside
  // MobileTabsPager, the shared static tabs row at the pager root
  // owns the visible segments + action icons; our local tabs row is
  // kept in DOM (preserving the curtain's TABS_ROW_PX-based snap
  // geometry) but rendered with `opacity: 0` (still tap-able). Only
  // the currently active pane writes its curtain controls to
  // `mobilePagerCurtainAtom` so the shared icons drive THIS curtain.
  //
  // `selectTabInstant` is the pager's tap-commit entrypoint: when our
  // invisible per-pane Segments capture a tap (because the static
  // header sits behind the strip in z-stack at rest), routing through
  // this callback runs the same commit path as the static header's
  // taps and suppresses the swipe-finish slide animation so tab
  // switches feel snappy. Falls back to a plain `navigate(...)` on
  // surfaces outside pager mode (desktop, non-listing routes).
  const pagerPane = useMobilePagerPane();
  const inPagerMode = pagerPane !== null;
  const isActivePagerPane = pagerPane?.isActive ?? false;
  const selectTabInstant = pagerPane?.selectTabInstant ?? null;

  const onSegmentDirect = useCallback(() => {
    if (selectTabInstant) selectTabInstant('direct');
    else navigate(DIRECT_PATH, navOpts);
  }, [selectTabInstant, navigate, navOpts]);
  const onSegmentChannels = useCallback(() => {
    if (selectTabInstant) selectTabInstant('channels');
    else navigate(CHANNELS_PATH, navOpts);
  }, [selectTabInstant, navigate, navOpts]);
  const onSegmentBots = useCallback(() => {
    if (selectTabInstant) selectTabInstant('bots');
    else navigate(BOTS_PATH, navOpts);
  }, [selectTabInstant, navigate, navOpts]);

  const curtain = useCurtainState(pinKey);
  const mx = useMatrixClient();

  // Suppress every curtain gesture whenever the user is interacting
  // with something else that would otherwise race the pin path:
  //
  //   * Settings sheet open (DirectSelfRow-originated bottom sheet) —
  //     a drag-up on the still-visible list above the sheet would
  //     mutate the pin atom underneath the sheet and the user would
  //     see an unexpected pinned curtain on dismissal.
  //   * Workspace switcher sheet open — same shape, on the Channels
  //     workspace surface.
  //   * Inactive pager pane — the strip clips offscreen panes so they
  //     shouldn't receive touches in practice, but bind defense-in-
  //     depth so a stray pointer event on a translateX'd pane never
  //     pins someone else's tab.
  //
  // Mirrors `MobileTabsPager.gestureDisabled` which suppresses the
  // pager's OWN horizontal-swipe gesture under the same conditions.
  const settingsSheetOpen = !!useAtomValue(settingsSheetAtom);
  const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom);
  const offscreenPagerPane = inPagerMode && !isActivePagerPane;
  const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || offscreenPagerPane;

  // Two parallel curtain-gesture surfaces:
  //
  //   * `useCurtainHandleGesture` — the dedicated 32 px drag-handle
  //     at the top of the curtain. Crisp 1:1 finger ↔ curtain. From
  //     closed the gesture is a free-range drag spanning pin↔closed↔
  //     refresh in one motion (`closed-free`); other snaps drive single-
  //     destination transitions (unpin / close-refresh / form-close).
  //     Engages regardless of whether the chat list is scrollable —
  //     the handle is a distinct surface and never competes with list
  //     scroll. Only rendered on native (`isNativePlatform()`).
  //
  //   * `useCurtainBodyGesture` — anywhere on the curtain body
  //     OUTSIDE the handle (chat list, empty-state placeholder).
  //     Rubber-banded (0.65) for all transitions, so the body drag
  //     reads as physically «heavier» than the handle's crisp pull.
  //     Engages only when the chat list has no scrollable content;
  //     additionally bails on touches that start inside the bottom-
  //     pinned slot (DirectSelfRow / WorkspaceFooter have their own
  //     drag-to-open bottom sheets) and on touches that start while
  //     pinned (unpin is HANDLE-only — the user has to grab the
  //     dedicated affordance to release the lock).
  //
  // Both hooks share `handleVisual` (mirrors desktop
  // `PageNavResizeHandle`: `dragging` lights up the grabber pill;
  // `atCommit` stretches + brightens it once the user crosses the
  // per-transition commit threshold). The two surfaces are mutually
  // exclusive on each touch (handle's listener short-circuits when
  // the touch starts on the handle; body's listener does the same
  // when it ISN'T on the handle), so they never fight over the
  // visual.
  const handleRef = useRef<HTMLDivElement>(null);
  const curtainRef = useRef<HTMLDivElement>(null);
  const bottomPinnedRef = useRef<HTMLDivElement>(null);
  const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({
    dragging: false,
    atCommit: false,
  });
  useCurtainHandleGesture({
    handleRef,
    snap: curtain.snap,
    pinned: curtain.pinned,
    setPinned: curtain.setPinned,
    setLiveDrag: curtain.setLiveDrag,
    commit: curtain.commit,
    disabled: gestureDisabled,
    setHandleState: setHandleVisual,
  });
  useCurtainBodyGesture({
    curtainRef,
    handleRef,
    bottomPinnedRef,
    scrollRef,
    snap: curtain.snap,
    pinned: curtain.pinned,
    setLiveDrag: curtain.setLiveDrag,
    commit: curtain.commit,
    disabled: gestureDisabled,
    setHandleState: setHandleVisual,
  });

  const isActive = isFormSnap(curtain.snap);
  // Destructured because these callbacks are individually STABLE
  // (useCallback inside useCurtainState) while the `curtain` object
  // changes identity on every drag frame (`liveDragPx` is in its memo
  // deps). Depending on `curtain` itself here used to rebuild
  // `pagerControls` — and therefore rewrite `mobilePagerCurtainAtom`
  // (cleanup null + setup) — on every touchmove of a curtain drag,
  // re-rendering the static pager header at input frequency. The
  // memoisation only works when these flow through stable references.
  const { open: openSearch, close, acknowledgeClosed } = curtain;

  // Refresh-reveal gate — the mascot card is on screen (or being pulled
  // into view): the curtain rests at the `refresh` snap OR a drag is in
  // flight (`isDragging`).
  //
  // We gate on `isDragging` (a stable boolean held true for the whole drag)
  // NOT on `liveDragPx > 0`: a held finger micro-jitters around the drag
  // origin, so `liveDragPx` dithers across 0 every frame. With the old
  // `liveDragPx > 0` term that flipped the pulse true/false each frame,
  // remounting the pulse layer (and the mascot video) → the rings restarted
  // over and over ("circle spam") and the curtain rattled. `isDragging`
  // doesn't flip on the drag delta's sign, so the layer stays mounted.
  const revealing = isRefreshSnap(curtain.snap) || curtain.isDragging;

  // Radar-pulse overlay gate for the IN-STAGE layer. Native-only (the
  // gesture is), silent under reduced-motion — and NON-pager only: in
  // pager mode the rings live in the strip-hosted PagerRefreshSingleton,
  // fed by the reveal map published below.
  const reducedMotion = useMemo(
    () =>
      typeof window !== 'undefined' &&
      !!window.matchMedia &&
      window.matchMedia('(prefers-reduced-motion: reduce)').matches,
    []
  );
  const showPulse = isNativePlatform() && !reducedMotion && !inPagerMode && revealing;

  // Tap-to-poke echo rings (non-pager path). Tapping the mascot
  // (RefreshMascot `onPoke`) spawns a one-shot ring on top of the ambient
  // radar pulse — same look + speed as an ambient ring, just one pass
  // (drawn by the RadarPulse canvas from its spawn timestamp). They only
  // paint while the pulse layer is mounted (`showPulse`). In pager mode
  // the echo state lives in `PagerRefreshSingleton` instead, and the
  // invisible poke catcher below proxies taps to it through
  // `mobilePagerMascotApiAtom`.
  const { echoes, spawnEcho } = useEchoes();

  // Imperative channel published by the pager's refresh singleton: the
  // live dance <video> for the loop-boundary auto-close, and the echo
  // spawner the invisible poke catcher proxies taps into (the singleton
  // is pointer-events:none behind the panes and can never receive the
  // tap itself).
  const mascotApi = useAtomValue(mobilePagerMascotApiAtom);
  const onPokeCatcher = useCallback(() => mascotApi?.spawnEcho(), [mascotApi]);

  // Publish this pane's reveal state into the per-tab map (keyed by
  // `pinKey` — each pane owns its key, writers never race). The
  // strip-hosted PagerRefreshSingleton ORs the map
  // (`mobilePagerAnyRevealAtom`) and shows the one screen-static
  // mascot+rings while ANY tab reveals — an inactive pane resting at
  // `refresh` through its auto-close dwell keeps the shared mascot
  // alive so a swipe-back mid-refresh is seamless.
  //
  // The `!activeForm` guard: a form-close drag has `isDragging` true
  // but no band chrome on screen — and the singleton paints behind the
  // panes' content, where the form band has no opaque backdrop in pager
  // mode.
  //
  // useLayoutEffect so the atom write + singleton render flush in the
  // same pre-paint commit as the first band-exposing frame at
  // drag-start. Compare-and-skip keeps subscribers quiet when nothing
  // flips.
  const setRefreshRevealByTab = useSetAtom(curtainRefreshRevealByTabAtom);
  const refreshRevealing = revealing && !curtain.activeForm;
  useLayoutEffect(() => {
    if (!inPagerMode) return undefined;
    setRefreshRevealByTab((prev) =>
      !!prev[pinKey] === refreshRevealing ? prev : { ...prev, [pinKey]: refreshRevealing }
    );
    return () => {
      setRefreshRevealByTab((prev) => (prev[pinKey] ? { ...prev, [pinKey]: false } : prev));
    };
  }, [inPagerMode, pinKey, refreshRevealing, setRefreshRevealByTab]);

  // Pull-to-refresh side-effect. When the curtain commits to the
  // `refresh` snap, keep the mascot dancing for at least REFRESH_MIN_MS
  // (so a near-instant result doesn't flash), capped at REFRESH_MAX_MS
  // (so an offline client can't strand the curtain open), then auto-close.
  //
  // Matrix /sync is a continuous long-poll, so on a HEALTHY client there's
  // nothing to wait for — the state is already current and the next
  // `Syncing` event may be up to a full timeout away. So we branch on the
  // current sync state: healthy (Syncing/Prepared) → the pull is a ~1.2s
  // delight, state already fresh; otherwise (Error/Reconnecting/Catchup/
  // cold) → `mx.retryImmediately()` forces the next /sync now and we wait
  // for the recovery `Syncing`/`Prepared` event (same poke ClientRoot uses
  // on the `online` event). An early up-drag dismiss (`close-refresh`)
  // flips `refreshing` false and the cleanup cancels the pending timers.
  //
  // The "ready to close" signal (`refreshReady`) is split from the actual
  // close so the close can DEFER while a drag is in flight. Closing during
  // a drag would zero `liveDragPx` under the finger while the gesture hook
  // keeps its own `lastDelta`, snapping the curtain ~MASCOT_CARD_PX on the
  // next touchmove. The separate close effect below gates on
  // `!curtain.isDragging`, so a deferred close fires the instant a
  // mid-refresh retract drag releases (spring-back included — no strand).
  const refreshing = curtain.snap === 'refresh';
  // Ref to the dance <video> so the close effect can align the auto-close to
  // a loop boundary (see below). Null while the still poster is shown.
  const mascotVideoRef = useRef<HTMLVideoElement>(null);
  const [refreshReady, setRefreshReady] = useState(false);
  useEffect(() => {
    if (!refreshing) {
      setRefreshReady(false);
      return undefined;
    }
    let synced = false;
    let minElapsed = false;
    const update = () => {
      if (synced && minElapsed) setRefreshReady(true);
    };
    const state = mx.getSyncState();
    if (state === SyncState.Syncing || state === SyncState.Prepared) {
      // Already healthy — state is current; just run the minimum dance.
      synced = true;
    } else {
      // Stuck/cold — force the next /sync and wait for recovery below.
      mx.retryImmediately();
    }
    const onSync = (next: SyncState) => {
      if (next === SyncState.Syncing || next === SyncState.Prepared) {
        synced = true;
        update();
      }
    };
    mx.on(ClientEvent.Sync, onSync);
    const minTimer = window.setTimeout(() => {
      minElapsed = true;
      update();
    }, REFRESH_MIN_MS);
    // Hard cap: force-ready even if /sync never recovers (offline client).
    const maxTimer = window.setTimeout(() => setRefreshReady(true), REFRESH_MAX_MS);
    return () => {
      mx.removeListener(ClientEvent.Sync, onSync);
      window.clearTimeout(minTimer);
      window.clearTimeout(maxTimer);
    };
  }, [refreshing, mx]);

  // Close once the dance is ready AND no drag is in flight — but only at a
  // mascot LOOP BOUNDARY, so the curtain never retracts mid-dance. When the
  // dance becomes ready we read its position and defer the close by the time
  // left in the current loop (`(duration − currentTime) / playbackRate`); the
  // clip is a seamless 2 s loop, so any wrap point is a clean stop. If there's
  // no live video (reduced-motion poster, or it stalled / hasn't mounted) or
  // it's essentially at the boundary already, close immediately. Re-runs when
  // `isDragging` flips, so a close deferred during a mid-refresh retract
  // re-arms the moment the finger releases (whether it sprang back or not).
  const refreshDragging = curtain.isDragging;
  useEffect(() => {
    if (!(refreshing && refreshReady && !refreshDragging)) return undefined;
    // In pager mode the dance video lives in the strip-hosted singleton,
    // reachable through the api atom (null while hidden / poster — close
    // immediately, matching the non-pager null-ref semantics); the local
    // ref only hosts a video on non-pager native surfaces.
    const video = inPagerMode ? mascotApi?.getVideo() ?? null : mascotVideoRef.current;
    if (!video || video.paused || !Number.isFinite(video.duration) || video.duration <= 0) {
      close();
      return undefined;
    }
    const remainingMs = ((video.duration - video.currentTime) / (video.playbackRate || 1)) * 1000;
    if (remainingMs <= 30) {
      close();
      return undefined;
    }
    const timer = window.setTimeout(close, remainingMs);
    return () => window.clearTimeout(timer);
  }, [refreshing, refreshReady, refreshDragging, close, inPagerMode, mascotApi]);

  // Memoised controls object so the cleanup's identity check (atom
  // compare-and-clear) is meaningful — without useMemo a fresh object
  // would be created on every render and the cleanup of an earlier
  // render would never match the atom's current contents. Rewrites only
  // on real open/close/acknowledge edges — never per drag frame; see
  // the `formMounted` comment in mobilePagerHeader.ts.
  const formMounted = curtain.activeForm !== null;
  const pagerControls = useMemo<MobilePagerCurtainControls>(
    () => ({
      openSearch,
      closeForm: close,
      isFormActive: isActive,
      formMounted,
      primaryAction: primaryAction ?? null,
    }),
    [openSearch, close, isActive, formMounted, primaryAction]
  );

  const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
  useEffect(() => {
    if (!isActivePagerPane) return undefined;
    setPagerCurtain(pagerControls);
    // Compare-and-clear cleanup: only wipe the atom if it still holds
    // OUR controls. If another pane became active between this render
    // and the cleanup (rapid tab switch), it has already overwritten
    // the atom with its own controls — we must not clobber that.
    return () => {
      setPagerCurtain((prev) => (prev === pagerControls ? null : prev));
    };
  }, [isActivePagerPane, pagerControls, setPagerCurtain]);

  // Curtain's `top` is the resting snap position plus the live drag
  // delta. React-driven (no inline DOM writes), so finger-tracking and
  // commit happen in the same render pipeline and there's no
  // intermediate "snap back, then animate" flash on release.
  //
  // When `pinned` is true the local snap (kept at {closed, refresh,
  // form-*}) is overridden — the curtain rests at y = 0 inside the
  // stage (= y = safe-top in viewport), covering the tabs row. The
  // global pinned atom shares this state across every listing tab so
  // swiping between Direct / Channels / Bots preserves the lock.
  //
  // `platformOffset` is the web-only shift that lifts every non-pinned
  // snap by the delta between native and web tabs-row heights. Tabs
  // row on web is `WEB_TABS_ROW_PX` (= 54px, matching PageHeader on
  // the right pane); `snapTopPx` is computed against `TABS_ROW_PX`
  // (= 64px) which stays authoritative for native pin/refresh geometry.
  // Subtracting the delta on web realigns the closed/form snaps with
  // the smaller tabs row without touching the snap-state machine.
  // Pinned (= 0) doesn't need the offset because the safe-top + native
  // contract owns that case and pinned is native-only.
  const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX;
  const curtainTop = curtain.pinned
    ? 0 + curtain.liveDragPx
    : snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + curtain.liveDragPx;

  // After the curtain settles at `closed`, unmount any lingering form.
  // Guarded so unrelated transitionend events (e.g. children's own
  // transitions bubbling up) don't drop the form mid-animation.
  const onCurtainTransitionEnd = useCallback(
    (evt: TransitionEvent<HTMLDivElement>) => {
      if (evt.target !== evt.currentTarget) return;
      if (evt.propertyName !== 'top') return;
      acknowledgeClosed();
    },
    [acknowledgeClosed]
  );

  // On-screen keyboard detection via VisualViewport API. Global
  // viewport-meta is `interactive-widget=resizes-content` (load-
  // bearing for the room composer's keyboard-follow behaviour), which
  // shrinks the layout viewport when a soft keyboard appears. Any
  // `bottom: 0` child — including DirectSelfRow inside the curtain —
  // rises with the shrunken viewport and ends up sitting RIGHT ABOVE
  // the keyboard, blocking the inline form the user is typing into.
  //
  // Fix: when the keyboard is up, collapse the `bottomPinned` slot
  // to zero height so it neither claims flex space at the curtain
  // bottom nor renders above the keyboard. The user perceives the
  // keyboard as overlaying everything below the form (matching their
  // mental model: "клавиатура рисуется поверх кнопок и чатов, кнопка
  // настройки остаётся прибитой снизу"). The row reappears the moment
  // the keyboard retracts.
  //
  // The reference-height tracking mirrors `AuthLayout.tsx`: bump the
  // reference upward on every grow (so rotation / keyboard-close
  // events stay self-correcting) and treat a meaningful shrink (>=
  // KEYBOARD_PROBE_PX) as «keyboard is up». The probe avoids
  // spurious flips on small browser-chrome animations.
  const [keyboardOpen, setKeyboardOpen] = useState(false);
  useEffect(() => {
    // Desktop browsers / Electron have no soft keyboard, but their
    // VisualViewport DOES shrink on aggressive page zoom (Ctrl+`+`).
    // That used to slip past as a hidden quirk while the curtain
    // rendered as a rounded card; once the web variant flattened the
    // curtain it became a visible regression — the DirectSelfRow at
    // the bottom would collapse to height: 0 under zoom and read as
    // broken layout. Gate the listener to native so the probe only
    // arms where a real soft keyboard can actually appear.
    if (!isNativePlatform()) return undefined;
    const vv = window.visualViewport;
    if (!vv) return undefined;
    const KEYBOARD_PROBE_PX = 100;
    let referenceH = vv.height;
    let rafId: number | null = null;
    const apply = () => {
      rafId = null;
      if (vv.height > referenceH) referenceH = vv.height;
      setKeyboardOpen(referenceH - vv.height >= KEYBOARD_PROBE_PX);
    };
    const onResize = () => {
      if (rafId === null) rafId = requestAnimationFrame(apply);
    };
    apply();
    vv.addEventListener('resize', onResize);
    return () => {
      if (rafId !== null) cancelAnimationFrame(rafId);
      vv.removeEventListener('resize', onResize);
    };
  }, []);

  // Dancing-mascot pull-to-refresh slot below the tabs row whenever no
  // form is active. The curtain z-occludes it at `closed`; drag-down
  // reveals it at the `refresh` snap.
  //
  // In pager mode the VISIBLE mascot is hoisted to the strip-hosted
  // singleton (it must not ride this pane), so the slot holds only an
  // invisible tap-catcher of the same geometry: the singleton is
  // pointer-events-none, and without the catcher the tap-to-poke easter
  // egg would die on the pane's transparent band. Non-pager native
  // surfaces keep the in-stage card.
  const refreshSlot = inPagerMode ? (
    // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
    <div className={css.mascotPokeCatcher} onClick={onPokeCatcher} aria-hidden="true" />
  ) : (
    <RefreshMascot
      caption={t('Direct.refreshing')}
      revealing={revealing}
      videoRef={mascotVideoRef}
      onPoke={spawnEcho}
    />
  );

  return (
    <div className={css.stage} data-platform={isNativePlatform() ? undefined : 'web'}>
      <header className={css.header}>
        {/* ── Tabs row + action icons (always visible) ───────────
            In pager mode the row stays mounted (curtain snap math
            depends on its TABS_ROW_PX height) but is painted invisible
            via `opacity: 0` because the shared static tabs row at the
            pager root owns the visible chrome. Critically opacity (not
            `visibility: hidden`) keeps the row HIT-TESTABLE while
            invisible — the strip's stacking context paints ON TOP of
            the static pager header at rest (the curtain-rises-over-
            header contract), so without per-pane taps the segments
            would be unreachable. Both rows wire onClick to the same
            navigate() destination, so whichever row captures the tap
            the user-visible result is identical. The static header z-
            elevates only while a horseshoe sheet is active and the
            active pane isn't pinned — in that window the static header
            sits above the strip and captures taps directly; the rest
            of the time taps land on this opacity-0 row and resolve
            through its own per-pane onClick handlers.

            `aria-hidden` removes the duplicate (per-pane) segments and
            icons from the accessibility tree so screen readers don't
            announce three sets of "Direct / Channels / Bots" plus the
            single visible set from the shared static header. */}
        <div
          className={css.tabsRow}
          style={inPagerMode ? { opacity: 0 } : undefined}
          aria-hidden={inPagerMode || undefined}
        >
          <div className={css.tabsCluster}>
            <Segment
              active={!!directMatch}
              label={t('Direct.segment_dm')}
              onClick={onSegmentDirect}
            />
            <Segment
              active={!!channelsMatch}
              label={t('Direct.segment_channels')}
              onClick={onSegmentChannels}
            />
            {showBotsSegment && (
              <Segment
                active={!!botsMatch}
                label={t('Direct.segment_bots')}
                onClick={onSegmentBots}
              />
            )}
          </div>
          <Box grow="Yes" />
          {isActive ? (
            <IconButton
              variant="SurfaceVariant"
              fill="None"
              size="400"
              radii="Pill"
              onClick={close}
              aria-label={t('Direct.close')}
              aria-controls={INLINE_FORM_ID}
              aria-expanded
            >
              <Icon size="100" src={Icons.Cross} />
            </IconButton>
          ) : (
            <div className={css.iconsCluster}>
              {/* The default «new chat» Plus is gone — people are found
                  through search now. Only the tab-specific `primaryAction`
                  (Channels' create-channel / create-community) still
                  renders a Plus here; it opens a portal Modal dialog
                  outside this subtree, so there's no in-tree form region
                  to point `aria-controls` at. */}
              {primaryAction && (
                <IconButton
                  variant="SurfaceVariant"
                  fill="None"
                  size="400"
                  radii="Pill"
                  onClick={primaryAction.onClick}
                  aria-label={primaryAction.label}
                  aria-expanded={false}
                  aria-haspopup="dialog"
                >
                  <Icon size="100" src={primaryAction.iconSrc} />
                </IconButton>
              )}
              <IconButton
                variant="SurfaceVariant"
                fill="None"
                size="400"
                radii="Pill"
                onClick={openSearch}
                aria-label={t('Search.search')}
                aria-controls={INLINE_FORM_ID}
                aria-expanded={false}
                aria-haspopup="dialog"
              >
                <Icon size="100" src={Icons.Search} />
              </IconButton>
            </div>
          )}
        </div>

        {/* ── Mascot-refresh card vs search form ─────────────────
            Mutually exclusive. While the search form is mounted
            (including the curtain's close-snap window before
            `acknowledgeClosed`), the mascot card stays unrendered so the
            form doesn't visually jump mid-animation. Otherwise the mascot
            card sits in its fixed header position below the tabs row and
            the curtain occludes it by z-stacking; dragging the curtain
            down to `refresh` reveals it from underneath. */}
        {curtain.activeForm ? (
          <div
            id={INLINE_FORM_ID}
            role="region"
            aria-label={t('Search.search')}
            className={css.formArea}
            style={{
              height: toRem(curtain.formHeightPx ?? 0),
            }}
          >
            <div ref={curtain.formMeasureRef} className={css.formInner}>
              <InlineRoomSearch onClose={close} />
            </div>
          </div>
        ) : (
          refreshSlot
        )}
      </header>

      {/* ── Radar-pulse overlay (non-pager native surfaces only) ─
          Concentric pink rings rippling across the light-blue surface behind
          the curtain (z:1, the curtain at z:2 occludes whatever's below its
          top edge). `pointerEvents: none` so taps fall through to the tabs.
          See `RadarPulse` for the single-canvas rationale (the tile-memory
          postmortem) and `stagePulseLayer` for the geometry. In pager mode
          `showPulse` is always false — the rings render in the strip-hosted
          PagerRefreshSingleton instead. */}
      {showPulse && (
        <div className={css.stagePulseLayer}>
          <RadarPulse echoes={echoes} />
        </div>
      )}

      {/* ── Curtain layer ─────────────────────────────────────
          Renders ABOVE the header (z-index higher). `top` combines the
          snap-derived resting position with the live finger drag — one
          React-controlled inline style, no ref-based DOM writes. The
          transition is disabled during the drag and restored on commit
          so the snap commit animates smoothly without an intermediate
          "snap back then animate forward" flash. */}
      <div
        ref={curtainRef}
        className={css.curtain}
        style={{
          top: toRem(curtainTop),
          transition: curtain.isDragging ? 'none' : undefined,
        }}
        onTransitionEnd={onCurtainTransitionEnd}
      >
        {/* Drag handle — native-only. On web (desktop browsers,
            Electron) the curtain has no interactive snap states, so
            the handle would be pure decoration with no behaviour
            behind it; rendering it conditionally drops the 32 px
            grabber strip on those surfaces and lets the chat list
            sit flush against the curtain's rounded top.

            On native the handle hosts the authoritative curtain
            gesture (pin / unpin / refresh / close-refresh / form-close)
            and stays mounted across snap transitions so the gesture
            surface is always reachable when there is one to make.

            `data-dragging` / `data-at-commit` mirror the desktop
            `PageNavResizeHandle`: CSS selectors on `handleBar` light
            the pill up Primary-blue + stretch it when these flip.
            Both attrs are emitted/cleared only via React state set by
            the gesture hook (dedup'd), so the handle visual updates
            without slamming the DOM on every touchmove. */}
        {isNativePlatform() && (
          <div
            ref={handleRef}
            className={css.handle}
            data-dragging={handleVisual.dragging || undefined}
            data-at-commit={handleVisual.atCommit || undefined}
            aria-hidden
          >
            <div className={css.handleBar} />
          </div>
        )}
        {children}
        {/* `bottomPinned` (DirectSelfRow, WorkspaceFooter) is kept
            mounted across snaps so the curtain reads as a self-
            contained "screen" with its bottom row always pinned to
            the stage bottom. While the on-screen keyboard is up the
            slot collapses to `height: 0` so it neither paints nor
            claims flex space above the keyboard (see the
            `keyboardOpen` effect above for the rationale). */}
        {bottomPinned && (
          <div
            ref={bottomPinnedRef}
            className={css.bottomPinnedSlot}
            style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined}
          >
            {bottomPinned}
          </div>
        )}
      </div>
    </div>
  );
}
