// Bottom-up «horseshoe» sheet that wraps the mobile Direct DM list.
// Mirror of `MobileProfileHorseshoe` in features/room — the chat
// there is wrapped by a top-down horseshoe (panel above, chat below
// with a 12px void). Here we invert: the wrapped app body is above,
// the Settings sheet emerges from below, and a 12px void separates
// them in a )|( silhouette.
//
// User-visible behaviour:
//
//   • The wrapped app body (StreamHeader → DM list → DirectSelfRow)
//     stays exactly where it was — no translate,
//     no shrink. The bottom of the visible portion is "masked away"
//     by an animated `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx
//     Rpx)` with rounded BL/BR carves at the new visible edge. The
//     carved area exposes the container's void colour underneath, and
//     the silhouette below covers the rest of the masked zone. Why
//     not `transform: translateY`: translating moves the TOP of the
//     pane out of the viewport (StreamHeader scrolls off-
//     screen). Why not `flex-shrink + margin-bottom`: the virtualized
//     DM list (`@tanstack/react-virtual`) re-measures items every
//     time the scroll container resizes — items above the shrinking
//     edge visibly smear. Clip-path leaves layout unchanged: the DM
//     list keeps its scroll position, its measured heights, and its
//     top items in place; only the bottom edge of what's visible is
//     carved into the void.
//   • Drag-up origin is `DirectSelfRow` itself, marked with the
//     `data-settings-drag-origin` attribute. A document-level
//     touchstart / pointerdown listener uses `target.closest()` to
//     detect drags starting inside the row, so the row's own click
//     handler keeps working (tap without movement → openSheet via
//     atom; tap with movement → drag).
//   • Drag-to-close origin is ONLY the 20px handle band at the top
//     of the sheet. Settings's internal content (menu, sub-pages,
//     scrolling list) is NOT drag-sensitive, so the user can scroll
//     through Settings without accidentally dismissing the sheet.
//
// State is Jotai-atom-driven (`settingsSheetAtom`) so the sheet
// overlays the DM list without route-swapping it away. The /settings
// URL still exists as a deep-link entry — see `SettingsScreen.tsx`
// for the desktop branch and the mobile-side redirect that sets the
// atom and bounces to /direct/.
//
// NO FocusTrap: focus-trap-react throws when its container has no
// tabbable nodes, which is exactly the case mid-drag before Settings
// has rendered any focusable children. Esc handling is an explicit
// `keydown` listener; touch / pointer driving the gesture means
// focus-trap semantics aren't load-bearing here anyway.

import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useAtomValue, useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { settingsSheetAtom } from '../../state/settingsSheet';
import { useCloseSettingsSheet, useOpenSettingsSheet } from '../../state/hooks/settingsSheet';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { HorseshoeEnabledContext } from '../../components/page';
import { useMobilePagerPane } from '../../components/mobile-tabs-pager/MobilePagerPaneContext';
import { mobileHorseshoeActiveAtom } from '../../state/mobilePagerHeader';
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
import { Settings } from './Settings';
import * as css from './MobileSettingsHorseshoe.css';

// Data attribute set on `DirectSelfRow` to mark it as the drag-up
// origin for the sheet. Anything with `.closest(SELECTOR)` matching is
// treated as the open-drag handle. Keep in sync with `DirectSelfRow.tsx`.
export const SETTINGS_SHEET_DRAG_ORIGIN_ATTR = 'data-settings-drag-origin';
const DRAG_ORIGIN_SELECTOR = `[${SETTINGS_SHEET_DRAG_ORIGIN_ATTR}]`;

const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
const ANIMATION_MS = 250;
// Drag distance past which release commits (open from DirectSelfRow,
// or close from the panel handle). Mirrors the profile horseshoe's
// 80px so the two gestures feel identical.
const COMMIT_THRESHOLD_PX = 80;
// Fixed sheet height — 2/3 of viewport, what the user signed off on.
// Internal scrolling inside Settings sub-pages handles content
// overflow; no rail re-sizing on menu↔sub-page navigation.
const RAIL_FRACTION = 2 / 3;
// Drag distance over which the radii + void-gap ramp from 0 to their
// full value during finger-drag — same as the profile horseshoe's
// `HORSESHOE_EMERGE_PX`. Matched to `COMMIT_THRESHOLD_PX` so the
// silhouette is fully formed exactly when the gesture qualifies to
// commit.
const HORSESHOE_EMERGE_PX = 80;

// Symmetric cubic in-out — slow start, fast middle, slow finish. Mirror
// of the profile horseshoe's emerge curve (file rationale there). The
// linear ramp from round 1 came out too "snappy" because the rounding
// jumped in within the first ~10px of drag; the cubic keeps the corners
// barely visible until ~40% of the way through the gesture, then
// blossoms around the midpoint. Used only during finger-drag; release
// transitions use the asymmetric VAUL_EASING curve in CSS.
const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);

type DragSource = 'directSelfRow' | 'handle';

// Axis dead-zone for horizontal-bail. The finger must travel this far
// on either axis before we resolve the gesture as vertical (open/close
// the sheet) or horizontal (yield to MobileTabsPager). Same value as
// the curtain gesture and the pager itself so all three resolve at the
// same threshold and never compete.
const AXIS_DEAD_ZONE_PX = 12;

type DragState = {
  source: DragSource;
  inputType: 'touch' | 'pointer';
  startX: number;
  startY: number;
  deltaY: number;
};

type MobileSettingsHorseshoeProps = {
  children: ReactNode;
};

function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) {
  const { t } = useTranslation();
  const sheet = useAtomValue(settingsSheetAtom);
  const openSheet = useOpenSettingsSheet();
  const closeSheet = useCloseSettingsSheet();
  // In pager mode the appBody's SurfaceVariant bg would cover the
  // pager's static header tabs (which live behind the swipe strip in
  // DOM order so the chats curtain can rise OVER them like a real
  // blind). Drop the bg in pager mode — the pager root paints the same
  // SurfaceVariant tone behind the strip, and the static header owns
  // the visible safe-top + tabsRow strip. See
  // `mobile-tabs-pager/style.css.ts::pagerStaticHeader` for the
  // overlay contract.
  const inPagerMode = useMobilePagerPane() !== null;

  const [drag, setDrag] = useState<DragState | null>(null);
  const [viewportHeight, setViewportHeight] = useState(() =>
    typeof window === 'undefined' ? 800 : window.innerHeight
  );

  useEffect(() => {
    const onResize = () => setViewportHeight(window.innerHeight);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  const railHeightPx = Math.round(viewportHeight * RAIL_FRACTION);
  const open = !!sheet;

  // Entry-animation gate. On cold-start deep-links (push notification
  // → /settings → mobile redirect → /direct/ with atom already set),
  // the wrapper would otherwise mount with `expandedPx = railHeightPx`
  // and skip the slide-up animation entirely (sheet would snap to
  // fully open). The pattern: first render uses `expandedPx = 0`
  // (`hasEntered = false`), `useLayoutEffect` queues a
  // `requestAnimationFrame` that flips `hasEntered` to true on the
  // next frame, the re-render goes to the open state, and the CSS
  // `transform` transition animates the difference. Harmless when
  // the atom is undefined on mount (just one extra render with the
  // same closed state).
  //
  // `hasEnteredRef` mirrors the state for the unmount-cleanup effect
  // below — without it, React 18 strict-mode dev would run the
  // cleanup before the rAF flips the state and would clear the atom
  // mid-rehearsal, breaking deep-link UX in dev.
  const [hasEntered, setHasEntered] = useState(false);
  const hasEnteredRef = useRef(false);
  useLayoutEffect(() => {
    const id = requestAnimationFrame(() => {
      hasEnteredRef.current = true;
      setHasEntered(true);
    });
    return () => cancelAnimationFrame(id);
  }, []);

  // Mirror `open` with a delayed unmount so Settings stays in the DOM
  // during the 250ms slide-down exit animation. Without this, clearing
  // the atom would immediately unmount Settings and the user would see
  // an empty panel shrink instead of the menu sliding away with it.
  const [keepMounted, setKeepMounted] = useState(open);
  useEffect(() => {
    if (open) {
      setKeepMounted(true);
      return undefined;
    }
    const id = window.setTimeout(() => setKeepMounted(false), ANIMATION_MS);
    return () => window.clearTimeout(id);
  }, [open]);

  // Persist the last opened sheet state so Settings's `initialPage`
  // doesn't reset to undefined during the exit animation.
  const lastSheetRef = useRef(sheet);
  if (sheet) lastSheetRef.current = sheet;

  const baseExpanded = open && hasEntered ? railHeightPx : 0;
  // `directSelfRow` drag: negative deltaY (finger up) opens the sheet.
  // `handle` drag: positive deltaY (finger down) closes the sheet.
  // Same formula: panel height = base + (-deltaY) = base - deltaY,
  // clamped to [0, railHeight].
  const expandedPx = drag
    ? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
    : baseExpanded;
  const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
  const isDragging = drag !== null;
  const horseshoeActive = expandedPx > 0;

  // Bridge our local `horseshoeActive` (geometric) signal up to the
  // pager-shared atom so `MobileTabsPagerHeader` can z-elevate the
  // static header from the first frame of drag — see the atom's docs
  // for the «no black flash» rationale. The cleanup writing `false`
  // covers route unmount mid-drag.
  const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom);
  useEffect(() => {
    setMobileHorseshoeActive(horseshoeActive);
    return () => setMobileHorseshoeActive(false);
  }, [horseshoeActive, setMobileHorseshoeActive]);

  const handleRef = useRef<HTMLDivElement>(null);

  // Refs so the always-installed event handlers see the latest state
  // without re-subscribing on every render. Same pattern as the
  // profile horseshoe.
  const dragRef = useRef<DragState | null>(null);
  dragRef.current = drag;
  const sheetRef = useRef(sheet);
  sheetRef.current = sheet;
  const openSheetRef = useRef(openSheet);
  openSheetRef.current = openSheet;
  const closeSheetRef = useRef(closeSheet);
  closeSheetRef.current = closeSheet;

  // Clear the atom on unmount — tapping a DM row while the sheet is
  // open routes to /direct/!roomId, the wrapper unmounts (Direct
  // unmounts on route change), and if we don't clear the atom here
  // a later return to /direct/ would auto-re-open the sheet (wrong:
  // the user moved on).
  //
  // The `hasEnteredRef` guard skips the dev-mode strict-mode
  // rehearsal cleanup (which fires synchronously after the first
  // useEffect commit, before the entry-rAF can flip the ref to true).
  // Without this, deep-link cold-start in dev would clear the atom
  // mid-rehearsal and the sheet would never open. Production runs
  // the cleanup only on real unmount, when `hasEnteredRef.current`
  // has long since been set.
  useEffect(
    () => () => {
      if (!hasEnteredRef.current) return;
      if (sheetRef.current) closeSheetRef.current();
    },
    []
  );

  // Hardware Escape (web only, rare on mobile) → close. Plain keydown
  // listener; no FocusTrap (rationale at the file header).
  useEffect(() => {
    if (!open) return undefined;
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Escape') return;
      const target = e.target as HTMLElement | null;
      if (
        target &&
        (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
      ) {
        return;
      }
      closeSheetRef.current();
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [open]);

  // Drag mechanics — two origin paths:
  //
  //   1. Document-level touch/pointer on anything matching
  //      `[data-settings-drag-origin]` (= DirectSelfRow). Listening at
  //      the document so the row's own onClick handler still fires for
  //      no-movement taps — touchstart and pointerdown are passive /
  //      don't preventDefault, so the synthesised click downstream is
  //      intact.
  //   2. Element-level touch/pointer on `handleRef` (the 20px drag bar
  //      at the top of the open sheet). Only triggers when the sheet
  //      is open; touches on the panel body itself (Settings content)
  //      are NOT drag-sensitive so internal scroll works without
  //      conflict.
  useEffect(() => {
    const handleEl = handleRef.current;

    // Per-drag axis-resolution flag. Reset to false in every touchstart /
    // pointerdown handler, flipped to true in applyMove once the finger
    // leaves the dead-zone with a vertical-dominant delta. If the finger
    // leaves the dead-zone horizontally, we drop the drag and yield to
    // MobileTabsPager instead of opening/closing the sheet.
    let axisResolved = false;
    // Sticky flag flipped once a touch series has yielded to the pager
    // (axis-resolved as horizontal). Without it, a rapid reversal back
    // toward vertical inside the same touch could re-enter the
    // axisResolved=false branch — the closure's `axisResolved` is still
    // false because we returned early — and re-engage the sheet drag,
    // even though we explicitly handed the gesture off. Reset on every
    // fresh touchstart / pointerdown.
    let touchBailed = false;

    // CLAMP, not early-return. Reversal of gesture direction must
    // drag `deltaY` back toward 0, not leave the stale value. The
    // earlier `if (wrong direction) return` branch let a user swipe
    // up 100px, change their mind, swipe back to start, and release
    // to find the sheet committed open anyway (stale -100 deltaY
    // still cleared the threshold on release). Clamping with
    // `Math.min/max(0, rawDelta)` is the same idiom the profile
    // horseshoe uses on its open-direction header drag — reversal
    // cancels the gesture cleanly.
    //
    // directSelfRow source: open on drag-up → clamp negative;
    //   downward (rawDelta > 0) collapses to 0 so the panel returns
    //   to closed if the user reverses.
    // handle source: close on drag-down → clamp positive;
    //   upward (rawDelta < 0) collapses to 0 so the panel doesn't
    //   try to expand past its full open height.
    //
    // preventDefault always (when cancelable) — these touches belong
    // to our gesture once axis-resolved. DM-list scroll lives ABOVE
    // the row; touches on those rows don't hit
    // `[data-settings-drag-origin]` so they never enter this handler.
    const applyMove = (clientX: number, clientY: number, e: TouchEvent | PointerEvent) => {
      if (touchBailed) return;
      const d = dragRef.current;
      if (!d) return;
      const rawDeltaX = clientX - d.startX;
      const rawDeltaY = clientY - d.startY;

      if (!axisResolved) {
        const dxAbs = Math.abs(rawDeltaX);
        const dyAbs = Math.abs(rawDeltaY);
        // Stay quiet inside the dead-zone — both the pager and us
        // need a stable signal before deciding who owns the gesture.
        if (dxAbs < AXIS_DEAD_ZONE_PX && dyAbs < AXIS_DEAD_ZONE_PX) return;
        if (dxAbs > dyAbs) {
          // Horizontal-dominant — the user is steering the pager.
          // Drop our drag state without preventDefault so the pager's
          // touchmove handler can engage and own the rest of the touch.
          // Stick the bail for the remainder of this touch series so a
          // late vertical reversal can't sneak back into the sheet.
          touchBailed = true;
          setDrag(null);
          return;
        }
        axisResolved = true;
      }

      const nextDelta =
        d.source === 'directSelfRow' ? Math.min(0, rawDeltaY) : Math.max(0, rawDeltaY);
      if (e.cancelable) e.preventDefault();
      setDrag({ ...d, deltaY: nextDelta });
    };

    const applyEnd = () => {
      const d = dragRef.current;
      if (!d) return;
      if (d.source === 'directSelfRow' && -d.deltaY > COMMIT_THRESHOLD_PX) {
        openSheetRef.current();
      } else if (d.source === 'handle' && d.deltaY > COMMIT_THRESHOLD_PX) {
        closeSheetRef.current();
      }
      setDrag(null);
    };

    // System cancel (incoming call, notification shade, app switch) must
    // NEVER commit an open/close — mirror the never-commit-on-cancel
    // contract every other gesture driver in the app upholds (curtain
    // handle/body, pager, swipe-back). Drop the drag; the silhouette
    // springs back to its resting state.
    const applyCancel = () => {
      if (!dragRef.current) return;
      setDrag(null);
    };

    const targetIsDragOrigin = (target: EventTarget | null): boolean => {
      if (!target || !(target instanceof Element)) return false;
      return target.closest(DRAG_ORIGIN_SELECTOR) !== null;
    };

    // === Touch path ===
    const onDocTouchStart = (e: TouchEvent) => {
      if (dragRef.current) return;
      if (sheetRef.current) return; // sheet is open — handle owns drag
      if (!targetIsDragOrigin(e.target)) return;
      const touch = e.touches[0];
      axisResolved = false;
      touchBailed = false;
      setDrag({
        source: 'directSelfRow',
        inputType: 'touch',
        startX: touch.clientX,
        startY: touch.clientY,
        deltaY: 0,
      });
    };
    const onHandleTouchStart = (e: TouchEvent) => {
      if (dragRef.current) return;
      if (!sheetRef.current) return;
      const touch = e.touches[0];
      axisResolved = false;
      touchBailed = false;
      setDrag({
        source: 'handle',
        inputType: 'touch',
        startX: touch.clientX,
        startY: touch.clientY,
        deltaY: 0,
      });
    };
    const onTouchMove = (e: TouchEvent) => {
      const d = dragRef.current;
      if (!d || d.inputType !== 'touch') return;
      // Second finger mid-gesture — abort without committing, mirroring
      // the curtain/pager drivers.
      if (e.touches.length !== 1) {
        applyCancel();
        return;
      }
      applyMove(e.touches[0].clientX, e.touches[0].clientY, e);
    };
    const onTouchEnd = () => {
      const d = dragRef.current;
      if (!d || d.inputType !== 'touch') return;
      applyEnd();
    };
    const onTouchCancel = () => {
      const d = dragRef.current;
      if (!d || d.inputType !== 'touch') return;
      applyCancel();
    };

    // === Mouse / pen path ===
    const onDocPointerDown = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      if (dragRef.current) return;
      if (sheetRef.current) return;
      if (e.button !== 0) return;
      if (!targetIsDragOrigin(e.target)) return;
      axisResolved = false;
      touchBailed = false;
      setDrag({
        source: 'directSelfRow',
        inputType: 'pointer',
        startX: e.clientX,
        startY: e.clientY,
        deltaY: 0,
      });
    };
    const onHandlePointerDown = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      if (dragRef.current) return;
      if (!sheetRef.current) return;
      if (e.button !== 0) return;
      axisResolved = false;
      touchBailed = false;
      setDrag({
        source: 'handle',
        inputType: 'pointer',
        startX: e.clientX,
        startY: e.clientY,
        deltaY: 0,
      });
    };
    const onDocPointerMove = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      const d = dragRef.current;
      if (!d || d.inputType !== 'pointer') return;
      applyMove(e.clientX, e.clientY, e);
    };
    const onDocPointerEnd = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      const d = dragRef.current;
      if (!d || d.inputType !== 'pointer') return;
      applyEnd();
    };
    const onDocPointerCancel = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      const d = dragRef.current;
      if (!d || d.inputType !== 'pointer') return;
      applyCancel();
    };

    document.addEventListener('touchstart', onDocTouchStart, { passive: true });
    document.addEventListener('touchmove', onTouchMove, { passive: false });
    document.addEventListener('touchend', onTouchEnd, { passive: true });
    document.addEventListener('touchcancel', onTouchCancel, { passive: true });
    document.addEventListener('pointerdown', onDocPointerDown);
    document.addEventListener('pointermove', onDocPointerMove, { passive: false });
    document.addEventListener('pointerup', onDocPointerEnd, { passive: true });
    document.addEventListener('pointercancel', onDocPointerCancel, { passive: true });
    if (handleEl) {
      handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true });
      handleEl.addEventListener('pointerdown', onHandlePointerDown);
    }

    return () => {
      document.removeEventListener('touchstart', onDocTouchStart);
      document.removeEventListener('touchmove', onTouchMove);
      document.removeEventListener('touchend', onTouchEnd);
      document.removeEventListener('touchcancel', onTouchCancel);
      document.removeEventListener('pointerdown', onDocPointerDown);
      document.removeEventListener('pointermove', onDocPointerMove);
      document.removeEventListener('pointerup', onDocPointerEnd);
      document.removeEventListener('pointercancel', onDocPointerCancel);
      if (handleEl) {
        handleEl.removeEventListener('touchstart', onHandleTouchStart);
        handleEl.removeEventListener('pointerdown', onHandlePointerDown);
      }
    };
  }, []);

  // Geometry — radii and void gap ramp through `easeInOutCubic` (slow
  // start → fast middle → slow finish) during finger-drag, matching
  // the profile horseshoe's emerge curve. Same `HORSESHOE_EMERGE_PX`
  // window as the profile horseshoe so the rounding finishes exactly
  // when the gesture qualifies to commit. During release (not
  // dragging), the values jump to their full target and the CSS
  // transition (VAUL_EASING) carries them visually.
  //
  // The mask: appBody stays in place, its bottom edge is "carved
  // away" by `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx Rpx)`
  // where BOTTOM = expandedPx + voidGap. The carved zone exposes the
  // container's bg (the void colour) above the silhouette; the void
  // gap is just `voidGap` pixels of that exposed bg between the
  // silhouette's top edge and the clip-path's lower edge.
  let horseshoeRamp: number;
  if (isDragging) {
    horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
  } else {
    horseshoeRamp = expandedFraction > 0 ? 1 : 0;
  }
  const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
  const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
  const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
  const appBodyMaskBottomPx = expandedPx + appBodyGapPx;

  // `inset()` shorthand: top right bottom left, then `round` followed
  // by 4 corner radii in TL TR BR BL order. Only the bottom two carry
  // the radius so the visible top portion of appBody has rounded BL/BR
  // at the clip boundary. Always emitted (even when all values are 0
  // for the closed state) so CSS can transition smoothly between
  // closed and open — interpolating between `inset(...)` and an
  // `undefined` clip-path would snap rather than animate.
  const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`;

  const silhouetteTransition = isDragging
    ? 'none'
    : `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
  const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;

  const containerStyle: React.CSSProperties = {
    backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
  };

  const settingsState = sheet ?? lastSheetRef.current;
  const renderSettings = keepMounted || isDragging;

  // Render an invisible marker into `#portalContainer` while the sheet
  // is open. The global Android-back handler in `useAndroidBackButton`
  // checks for `portalContainer.firstChild` and, if present, dispatches
  // an `Escape` keydown that our `keydown` effect above catches to
  // close the sheet (or, when handled in capture by Settings, to pop
  // the active sub-page back to the menu). Without this marker the
  // global handler would fall through to `navigate(-1)` and bounce the
  // user out of /direct/ entirely. Portal target falls back to body
  // when running in tests / SSR where `#portalContainer` isn't
  // mounted yet.
  const portalTarget =
    typeof document !== 'undefined'
      ? document.getElementById('portalContainer') ?? document.body
      : null;

  return (
    <div className={css.container} style={containerStyle}>
      {open && portalTarget
        ? createPortal(
            <div
              data-vojo-settings-sheet-active="true"
              aria-hidden="true"
              style={{ display: 'none' }}
            />,
            portalTarget
          )
        : null}
      <div
        className={css.appBody}
        style={{
          // Always emitted, even at rest where it computes to
          // `inset(0 …)` — see the `appBodyClipPath` comment above for
          // why (interpolating from `undefined` would snap). Safe to
          // keep always-on: the pull-to-refresh chrome is hosted in the
          // strip-level singleton OUTSIDE this subtree, so the resting
          // self-bounds clip slices nothing. (Bonus: a non-none
          // clip-path makes appBody a permanent stacking context, which
          // the singleton's paint-order contract counts on — see
          // mobile-tabs-pager/style.css.ts::pagerRefreshSingleton.)
          clipPath: appBodyClipPath,
          transition: appBodyTransition,
          overscrollBehaviorY: 'contain',
          // In pager mode the appBody is transparent AT REST so the
          // static pager header (sitting behind the swipe strip in DOM
          // order) shows through the tabsRow zone — that's how the
          // chats curtain visually rises ABOVE the header on pin.
          //
          // When the horseshoe is active (drag in flight OR sheet
          // open) we flip the appBody back to opaque
          // `SurfaceVariant.Container` (via the CSS class — unsetting
          // the inline override). This contains the container's
          // `VOJO_HORSESHOE_VOID_COLOR` paint to the carve cut-out at
          // the bottom of the appBody. Otherwise the void would bleed
          // up through every transparent pixel of the mascot/form band
          // between the static header and the curtain top (refresh/form
          // snaps), turning the strip black.
          //
          // The static-header z-elevation in `MobileTabsPagerHeader`
          // tracks the same `mobileHorseshoeActiveAtom` so the static
          // header z-pops above the now-opaque appBody in the safe-
          // top + tabsRow band — tabs stay visible. Both gestures
          // (pin + pager swipe) are gated off while a sheet is open,
          // so the opaque flip can't race with curtain-show-through.
          backgroundColor: inPagerMode && !horseshoeActive ? 'transparent' : undefined,
        }}
      >
        {children}
      </div>

      <div
        className={css.silhouette}
        style={{
          height: `${expandedPx}px`,
          borderTopLeftRadius: `${silhouetteRadiusPx}px`,
          borderTopRightRadius: `${silhouetteRadiusPx}px`,
          transition: silhouetteTransition,
          visibility: expandedPx > 0 ? 'visible' : 'hidden',
          // Reset `--vojo-safe-top` for everything mounted inside the
          // sheet. The status-bar inset is reserved by PageNav's inner
          // column via `padding-top: var(--vojo-safe-top)` for surfaces
          // anchored to the top of the viewport — the sheet itself sits
          // below the status bar, so that padding here would add dead
          // space above the menu header and (because sub-page `<Page>`
          // doesn't have the same padding) misalign the sub-page title
          // vs the menu title when the user navigates into a section.
          // Same trick Modal500 uses for its centred dialog.
          ['--vojo-safe-top' as string]: '0px',
        }}
        // `role="dialog"` alone marks this as a non-modal dialog: the
        // assistive-tech announces it as a dialog, but interaction
        // outside (DM list above, DirectSelfRow tap-through to a DM)
        // remains permitted — matching the actual behaviour. We
        // deliberately do NOT set `aria-modal="true"`: that attribute
        // claims modality, but we have no focus trap and the wrapped
        // app body is fully interactive. Per MDN, claiming
        // `aria-modal="true"` without enforcing it misleads
        // screen-reader users into thinking outside content is
        // unreachable. The profile horseshoe takes the same stance
        // (FocusTrap with `clickOutsideDeactivates: false`).
        //
        // `aria-label` (not `aria-labelledby`): the visible "Settings"
        // title lives inside the PageNavHeader, which Settings.tsx
        // unmounts when a mobile sub-page is active (so the labelledby
        // target would become a dangling reference). A stable
        // `aria-label` reads the same string regardless of which inner
        // view is mounted.
        role="dialog"
        aria-label={t('Settings.title')}
      >
        <div className={css.panelContent} style={{ height: `${railHeightPx}px` }}>
          <div ref={handleRef} className={css.panelHandle} aria-label={t('Settings.drag_to_close')}>
            <div className={css.panelHandleBar} />
          </div>
          <div className={css.panelBody}>
            {/* HorseshoeEnabledContext disabled for the embedded
                Settings so its OWN PageRoot doesn't draw a second
                12px void column inside the mobile sheet. */}
            <HorseshoeEnabledContext.Provider value={false}>
              {renderSettings && (
                <Settings
                  initialPage={settingsState?.page}
                  requestClose={() => closeSheetRef.current()}
                />
              )}
            </HorseshoeEnabledContext.Provider>
          </div>
        </div>
      </div>
    </div>
  );
}

// Top-level router. On non-mobile we pass through; the desktop /settings
// route renders Settings in the nested-horseshoe right pane (see
// `SettingsScreen.tsx`). The mobile branch owns the drag/atom state in
// a sub-component so we don't run those hooks on desktop renders.
export function MobileSettingsHorseshoe({
  children,
}: MobileSettingsHorseshoeProps): React.ReactElement {
  const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
  if (!isMobile) {
    return children as React.ReactElement;
  }
  return <MobileSettingsHorseshoeImpl>{children}</MobileSettingsHorseshoeImpl>;
}
