// Sliding horseshoe sheet for the channels workspace switcher. Mirror of
// `features/settings/MobileSettingsHorseshoe.tsx` (canonical, shipped in
// commit a7d6fc2) with three deliberate adaptations:
//
//   • Single code path on web AND native — no
//     `useScreenSizeContext()` mobile-only gate. The horseshoe lives
//     wherever channels render, so a desktop tap on the workspace
//     footer slides the sheet up inside the PageNav column the same
//     way a mobile tap does.
//   • Rail height is a fraction (0.55) of the wrapper container's
//     height, NOT `window.innerHeight`. The sheet is scoped to its
//     PageNav slot, so a viewport-relative rail would overshoot on
//     desktop where the column is short. `ResizeObserver` on the
//     container drives `containerHeightPx`; the rail recomputes
//     on column resize.
//   • Drag-origin data attribute is renamed to
//     `data-channels-workspace-drag-origin` so it doesn't collide with
//     the existing settings-sheet selector elsewhere on the page.
//
// Everything else (clip-path carve, void colour, VAUL easing curve,
// entry rAF gate, hasEntered mirror, keepMounted unmount delay,
// dialog/aria-label invariants, portal marker for Android back) is
// preserved verbatim — those were learned through review cycles on the
// canonical horseshoe and are not re-litigated here.
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 { Room } from 'matrix-js-sdk';
import { channelsWorkspaceSheetAtom } from '../../../state/channelsWorkspaceSheet';
import {
  useCloseChannelsWorkspaceSheet,
  useOpenChannelsWorkspaceSheet,
} from '../../../state/hooks/channelsWorkspaceSheet';
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
import { mobileHorseshoeActiveAtom } from '../../../state/mobilePagerHeader';
import { VOJO_HORSESHOE_VOID_COLOR } from '../../../styles/horseshoe';
import { WorkspaceSwitcherSheet } from './WorkspaceSwitcherSheet';
import * as css from './ChannelsWorkspaceHorseshoe.css';

// Data attribute set on `WorkspaceFooter` to mark it as the drag-up /
// tap-to-open origin for the sheet. The document-level touchstart /
// pointerdown listeners use `target.closest(SELECTOR)` so the footer's
// own onClick handler still fires for no-movement taps. Keep this in
// sync with the attribute spread on the footer row.
export const CHANNELS_WORKSPACE_DRAG_ORIGIN_ATTR = 'data-channels-workspace-drag-origin';
const DRAG_ORIGIN_SELECTOR = `[${CHANNELS_WORKSPACE_DRAG_ORIGIN_ATTR}]`;

const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
const ANIMATION_MS = 250;
// Commit distance (px). 80px matches the canonical horseshoe so the two
// gestures (settings open, workspace open) feel identical to muscle
// memory.
const COMMIT_THRESHOLD_PX = 80;
// Sheet rail as a fraction of the PageNav column's measured height.
// Workspace switcher content is small (spaces list + one create row), so
// 55% reads as a compact tray rather than a half-screen takeover.
const RAIL_FRACTION = 0.55;
// Drag distance over which radii + void gap ramp from 0 to full during
// finger-drag. 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 — linear ramp was too snappy in the canonical
// horseshoe (corners jumped in within ~10px of drag); cubic keeps them
// subtle until ~40% of the gesture, then blossoms around the midpoint.
const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);

// Axis dead-zone for horizontal-bail. The finger must travel this far
// on either axis before we resolve as vertical (open/close the sheet)
// or horizontal (yield to MobileTabsPager on listing surfaces). Note:
// the workspace sheet only mounts on /channels/!space/, where the
// pager is inactive — the bail here is defensive symmetry with the
// settings horseshoe so future layout changes can't surprise us.
const AXIS_DEAD_ZONE_PX = 12;

type DragSource = 'footer' | 'handle';

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

type ChannelsWorkspaceHorseshoeProps = {
  space: Room;
  children: ReactNode;
};

export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspaceHorseshoeProps) {
  const { t } = useTranslation();
  const open = useAtomValue(channelsWorkspaceSheetAtom);
  const openSheet = useOpenChannelsWorkspaceSheet();
  const closeSheet = useCloseChannelsWorkspaceSheet();

  const containerRef = useRef<HTMLDivElement>(null);
  const [containerHeightPx, setContainerHeightPx] = useState(0);
  const [drag, setDrag] = useState<DragState | null>(null);
  // In pager mode the appBody bg must be transparent so the static
  // pager header tabs (behind the swipe strip in DOM order) show
  // through until covered by the rising chats curtain. See
  // MobileSettingsHorseshoe.tsx for the matching pattern.
  const inPagerMode = useMobilePagerPane() !== null;

  // ResizeObserver on the wrapper — rail height is scoped to THIS
  // column. PageNav width changes via the resizable handle on desktop
  // and column height changes with viewport / split-screen on Android;
  // ResizeObserver handles both without a manual `window.resize`
  // listener which wouldn't catch the column-resize case.
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return undefined;
    setContainerHeightPx(el.getBoundingClientRect().height);
    const ro = new ResizeObserver((entries) => {
      const entry = entries[0];
      if (!entry) return;
      setContainerHeightPx(entry.contentRect.height);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const railHeightPx = Math.round(containerHeightPx * RAIL_FRACTION);

  // Entry-animation gate. Kept for gesture-feel parity with the
  // canonical horseshoe even though the channels sheet has no
  // deep-link path (atom is only ever flipped by a footer tap/drag on
  // an already-mounted Channels column, so a "snap-open on mount"
  // can't actually happen here). The `hasEnteredRef` mirror still
  // matters because React 18 strict-mode dev runs the unmount
  // cleanup before the rAF fires; without the guard the effect
  // would clear the atom mid-rehearsal and break HMR.
  const [hasEntered, setHasEntered] = useState(false);
  const hasEnteredRef = useRef(false);
  useLayoutEffect(() => {
    const id = requestAnimationFrame(() => {
      hasEnteredRef.current = true;
      setHasEntered(true);
    });
    return () => cancelAnimationFrame(id);
  }, []);

  // Delay unmount of the sheet content by `ANIMATION_MS` so the slide-
  // down has something to render. Without this, clearing the atom would
  // unmount the spaces list instantly 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]);

  const baseExpanded = open && hasEntered ? railHeightPx : 0;
  // CLAMP via Math.max/min, not early-return — see canonical
  // `applyMove` for the bug a wrong-direction early-return introduces
  // (stale deltaY survives a reversed swipe and commits on release).
  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. Mirror of the same
  // bridge in `MobileSettingsHorseshoe.tsx`. See the atom's docs in
  // `state/mobilePagerHeader.ts` for the «no black flash» rationale.
  const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom);
  useEffect(() => {
    setMobileHorseshoeActive(horseshoeActive);
    return () => setMobileHorseshoeActive(false);
  }, [horseshoeActive, setMobileHorseshoeActive]);

  const handleRef = useRef<HTMLDivElement>(null);

  // Refs so the always-installed document listeners see the latest
  // state without re-subscribing on every render. Same pattern as the
  // canonical horseshoe.
  const dragRef = useRef<DragState | null>(null);
  dragRef.current = drag;
  const openRef = useRef(open);
  openRef.current = open;
  const openSheetRef = useRef(openSheet);
  openSheetRef.current = openSheet;
  const closeSheetRef = useRef(closeSheet);
  closeSheetRef.current = closeSheet;

  // Clear the atom on unmount — switching tabs (Direct, Bots), picking
  // a channel, or navigating to /explore unmounts Channels.tsx; without
  // this clear, returning to /channels later would auto-reopen the
  // sheet. `hasEnteredRef` skips the strict-mode rehearsal cleanup
  // before the rAF flag flips.
  useEffect(
    () => () => {
      if (!hasEnteredRef.current) return;
      if (openRef.current) closeSheetRef.current();
    },
    []
  );

  // Hardware Escape (web only, rare on mobile) → close. Plain keydown,
  // no FocusTrap — same rationale as the canonical (focus-trap-react
  // throws when its container has no tabbable nodes, which is exactly
  // the case mid-drag before the sheet has rendered any focusable
  // children).
  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-channels-workspace-drag-origin]` (= WorkspaceFooter).
  //      Passive touchstart / non-passive listeners are split so the
  //      row's own onClick handler still fires for no-movement taps.
  //   2. Element-level touch/pointer on `handleRef` (the 20px drag
  //      band at the top of the open sheet). Only triggers when the
  //      sheet is open; touches on the spaces list inside the panel
  //      are not drag-sensitive so internal scroll works without
  //      conflict.
  useEffect(() => {
    const handleEl = handleRef.current;

    // Per-drag axis-resolution flag (same idiom as MobileSettings-
    // Horseshoe). 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.
    let axisResolved = false;
    // Sticky bail for the remainder of the current touch series once
    // we've yielded to the pager (axis horizontal). Prevents a late
    // vertical reversal in the same touch from re-engaging the sheet.
    let touchBailed = false;

    // CLAMP, not early-return — reversal of gesture direction must
    // drag `deltaY` back toward 0. footer source clamps the upward
    // drag-open path to negative deltas; handle source clamps the
    // downward drag-close path to positive deltas.
    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);
        if (dxAbs < AXIS_DEAD_ZONE_PX && dyAbs < AXIS_DEAD_ZONE_PX) return;
        if (dxAbs > dyAbs) {
          // Horizontal-dominant — yield to MobileTabsPager. Drop drag
          // state without preventDefault so the pager can take over.
          // Sticky bail prevents a late vertical reversal from
          // re-engaging the sheet within the same touch series.
          touchBailed = true;
          setDrag(null);
          return;
        }
        axisResolved = true;
      }

      const nextDelta = d.source === 'footer' ? 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 === 'footer' && -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 (openRef.current) return; // sheet open → handle owns drag
      if (!targetIsDragOrigin(e.target)) return;
      const touch = e.touches[0];
      axisResolved = false;
      touchBailed = false;
      setDrag({
        source: 'footer',
        inputType: 'touch',
        startX: touch.clientX,
        startY: touch.clientY,
        deltaY: 0,
      });
    };
    const onHandleTouchStart = (e: TouchEvent) => {
      if (dragRef.current) return;
      if (!openRef.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 (openRef.current) return;
      if (e.button !== 0) return;
      if (!targetIsDragOrigin(e.target)) return;
      axisResolved = false;
      touchBailed = false;
      setDrag({
        source: 'footer',
        inputType: 'pointer',
        startX: e.clientX,
        startY: e.clientY,
        deltaY: 0,
      });
    };
    const onHandlePointerDown = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      if (dragRef.current) return;
      if (!openRef.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 + void gap ramp via easeInOutCubic during drag;
  // release jumps to the full value and CSS transition (VAUL_EASING)
  // carries the visual. See canonical for the visual-curve rationale.
  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(top right bottom left round TL TR BR BL)` — only BR/BL carry
  // the radius so the visible top portion of appBody has rounded
  // bottom corners at the clip boundary. Always emitted (even at all
  // zeros) so CSS can transition smoothly between closed and open.
  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 renderSheet = keepMounted || isDragging;

  // Portal marker so `useAndroidBackButton` dispatches Escape instead of
  // `navigate(-1)` while the sheet is open. The keydown handler above
  // catches the synthetic Escape and closes the sheet — same pattern
  // the canonical horseshoe uses. Falls back to body for SSR / tests
  // where `#portalContainer` isn't mounted yet.
  const portalTarget =
    typeof document !== 'undefined'
      ? document.getElementById('portalContainer') ?? document.body
      : null;

  return (
    <div ref={containerRef} className={css.container} style={containerStyle}>
      {open && portalTarget
        ? createPortal(
            <div
              data-vojo-channels-workspace-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 MobileSettingsHorseshoe.tsx for the full
          // always-emitted / stacking-context rationale.
          clipPath: appBodyClipPath,
          transition: appBodyTransition,
          overscrollBehaviorY: 'contain',
          // See MobileSettingsHorseshoe.tsx for the full rationale:
          // appBody is transparent in pager mode AT REST so the static
          // pager header shows through, but flips back to its CSS-
          // class `SurfaceVariant.Container` whenever the horseshoe is
          // active so the container's `VOJO_HORSESHOE_VOID_COLOR` paint
          // stays contained to the bottom carve cut-out and doesn't
          // bleed into the chip-area band above the curtain.
          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 — same trick the canonical horseshoe uses (status-bar
          // padding is owned by the outer PageNav column; re-applying
          // it inside the sheet would create dead space above the
          // panel header).
          ['--vojo-safe-top' as string]: '0px',
        }}
        // `role="dialog"` + `aria-label` only. NO `aria-modal="true"`
        // (no focus trap; outside content stays interactive). NO
        // `aria-labelledby` (the labelled target may unmount during
        // the close animation). See canonical for the rationale.
        role="dialog"
        aria-label={t('Channels.workspace_switcher_aria')}
      >
        <div className={css.panelContent} style={{ height: `${railHeightPx}px` }}>
          <div
            ref={handleRef}
            className={css.panelHandle}
            aria-label={t('Channels.workspace_switcher_drag_to_close')}
          >
            <div className={css.panelHandleBar} />
          </div>
          <div className={css.panelBody}>
            {renderSheet && (
              <WorkspaceSwitcherSheet space={space} requestClose={() => closeSheetRef.current()} />
            )}
          </div>
        </div>
      </div>
    </div>
  );
}
