import { MutableRefObject, useEffect, useRef } from 'react';
import { isNativePlatform } from '../../utils/capacitor';
import {
  ACTIVE_CLOSE_THRESHOLD_PX,
  DIRECTION_DEAD_ZONE_PX,
  REFRESH_COMMIT_PX,
  RUBBER_BAND,
} from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState';
import {
  assertNeverCurtainTransition,
  CurtainTransition,
  resolveCurtainTransition,
} from './useCurtainHandleGesture';

type Args = {
  // The curtain element. Touch listeners bind here so anywhere on the
  // curtain body — the chat list, an empty-state placeholder, the
  // DirectSelfRow / WorkspaceFooter at the bottom — can drive a
  // gesture. The handle's own listener (`useCurtainHandleGesture`)
  // is bound to a child element of this curtain and runs first; we
  // explicitly bail on touches that originate inside the handle so
  // the two surfaces don't double-engage.
  curtainRef: MutableRefObject<HTMLDivElement | null>;
  // The handle element. Used solely to short-circuit our listener
  // when the touch starts inside the handle's hit-zone (the handle
  // hook has already armed for that touch).
  handleRef: MutableRefObject<HTMLDivElement | null>;
  // The `bottomPinned` slot at the bottom of the curtain (hosts
  // DirectSelfRow, WorkspaceFooter). These rows open their own bottom
  // sheets via vertical drag, so a touch that starts there must NOT
  // engage the curtain body — otherwise the
  // user's «pull settings up» gesture would also drive the curtain
  // and the two motions would visually fight. `null` is fine (the
  // surface has no bottomPinned content); the contains() check is
  // optional-chained.
  bottomPinnedRef: MutableRefObject<HTMLDivElement | null>;
  // Scroll viewport of the chat list inside the curtain. The body
  // gesture engages only when this element is NOT scrollable
  // (scrollHeight ≤ clientHeight + 1): on long lists the user's
  // vertical drag must remain a native scroll gesture, on short /
  // empty lists the same drag drives the curtain instead. Treated
  // as «not scrollable» when `scrollRef.current` is null (some
  // listing surfaces render their empty state DIRECTLY as a curtain
  // child, bypassing `PageNavContent` — `Direct.tsx::DirectEmpty`,
  // `ChannelsRootNav::ChannelsLanding` — so scrollRef stays null and
  // the body gesture must still engage).
  scrollRef: MutableRefObject<HTMLDivElement | null>;
  // Current snap stop. Mirrored into a ref so the listener — bound
  // once per `disabled` flip — reads fresh values without rebinding.
  snap: CurtainSnap;
  // Per-pane pinned overlay; also ref-mirrored. Only READ here (used
  // by the touchstart bail) — pin / unpin commits are the handle's
  // exclusive contract, see «Direction asymmetry» on the hook.
  pinned: boolean;
  // Live drag delta sink — feeds the curtain's `top` via React state,
  // no direct DOM writes.
  setLiveDrag: (px: number, dragging: boolean) => void;
  // Snap commit. `'refresh'` fires from closed-free's down-half;
  // `'closed'` fires from close-refresh and form-close. The pin / unpin
  // paths are handle-only and never flip state through this setter
  // from the body.
  commit: (next: 'refresh' | 'closed') => void;
  // Suppress gesture binding entirely. Same conditions as the handle
  // hook — see StreamHeader's `gestureDisabled`.
  disabled?: boolean;
  // Shared handle-visual sink. The grabber pill at the top of the
  // curtain animates Primary-blue + stretches whenever the user has
  // crossed the per-transition commit threshold, on ANY surface —
  // handle or body. Dedupe inside the hook keeps consumer re-renders
  // bounded to actual state flips.
  setHandleState?: (state: { dragging: boolean; atCommit: boolean }) => void;
};

// Touch-gesture driver for the curtain BODY (everything outside the
// dedicated drag-handle). Native-only.
//
// Why a second surface? On listing surfaces with content that fits in
// one screen (empty Direct / Bots / Channels states, the ChannelsLanding
// CTA, a workspace with few rooms) the user's natural «pull the curtain
// down to refresh» gesture happens anywhere on the visible card.
// Restricting all motion to the 32 px handle on these surfaces felt
// artificial. On the other hand, surfaces with a scrollable list need
// their native vertical scroll preserved — so the body gesture is
// *conditional*: it engages only when the chat list has no scrollable
// content (scrollHeight ≤ clientHeight + 1). Long lists keep using the
// handle for curtain motion.
//
// Direction asymmetry — pinning is handle-only, retracting is shared.
// The body engages on:
//   * closed + DOWN  → refresh (closed-free, down-half only)
//   * refresh + UP   → closed (close-refresh)
//   * form-* + UP    → closed (form-close)
// The body does NOT engage on:
//   * closed + UP    → would be pin via closed-free's up-half. The
//                       user reported that arbitrary upward drag on
//                       the body made it too easy to accidentally
//                       close the directs/channels/bots header by
//                       pinning. Pin must be a deliberate gesture on
//                       the dedicated pin-handle. After close-refresh /
//                       form-close lands at `closed`, the curtain
//                       can only go further up via the handle.
//   * pinned + DOWN  → unpin. Same rationale: the pin handle owns the
//                       unpin contract too, so an accidental drag on
//                       the visible card can't undo it. Bailed at
//                       touchstart (see Pinned override below).
// The asymmetric block on closed+UP is implemented in onTouchMove
// after the transition resolves — we only bail closed-free's UP half,
// not every upward drag, so close-refresh and form-close still engage
// on the body.
//
// Dynamics: all transitions use rubber-band 0.65 (= RUBBER_BAND) so
// the body drag feels physically «heavier» than the handle's crisp
// 1:1 — the user reads the two surfaces as distinct affordances. The
// commit math is expressed in CURTAIN displacement (lastDelta), not
// raw finger pull, so a body «commit at REFRESH_COMMIT_PX» visually
// matches a handle commit at the same point — only the finger pull
// needed to get there differs.
//
// Form-snap override: when a form is mounted, the chat list under it
// is mostly hidden but still in DOM with whatever scrollHeight it has.
// Skip the scrollable-bail in that case — the body's visible area is
// the strip BELOW the form, and a drag there is unambiguously a
// form-close intent (the only valid transition from form-* snap).
//
// Pinned override: the body gesture is INERT while the curtain is
// pinned. Unpin is exclusively the handle's contract — the user has
// to grab the dedicated pin-handle to release the lock, so an
// accidental drag anywhere on the visible card doesn't undo it. We
// bail at touchstart so no listener side-effects (preventDefault,
// liveDrag emit, …) can fire either.
export function useCurtainBodyGesture({
  curtainRef,
  handleRef,
  bottomPinnedRef,
  scrollRef,
  snap,
  pinned,
  setLiveDrag,
  commit,
  disabled,
  setHandleState,
}: Args): void {
  const snapRef = useRef<CurtainSnap>(snap);
  snapRef.current = snap;
  const pinnedRef = useRef<boolean>(pinned);
  pinnedRef.current = pinned;
  const commitRef = useRef(commit);
  commitRef.current = commit;
  const setHandleStateRef = useRef(setHandleState);
  setHandleStateRef.current = setHandleState;

  useEffect(() => {
    if (!isNativePlatform()) return undefined;
    if (disabled) return undefined;
    const curtain = curtainRef.current;
    if (!curtain) return undefined;

    let startX: number | null = null;
    let startY: number | null = null;
    let direction: 'up' | 'down' | null = null;
    let transition: CurtainTransition | null = null;
    let engaged = false;
    let lastDelta = 0;
    // Same dedupe pattern as the handle hook — re-render the consumer
    // only on actual visual-state flips.
    let emittedDragging = false;
    let emittedAtCommit = false;
    const emitHandle = (dragging: boolean, atCommit: boolean) => {
      if (dragging === emittedDragging && atCommit === emittedAtCommit) return;
      emittedDragging = dragging;
      emittedAtCommit = atCommit;
      setHandleStateRef.current?.({ dragging, atCommit });
    };

    const onTouchStart = (e: TouchEvent) => {
      if (e.touches.length !== 1) return;
      // Pinned bail — handle owns unpin exclusively. See the «Pinned
      // override» note above the hook for the rationale.
      if (pinnedRef.current) return;
      // Hand off to the handle hook if the touch starts inside the
      // handle's 32 px hit-zone — the handle's own listener has
      // already armed for this touch.
      const target = e.target as Node | null;
      if (target && handleRef.current?.contains(target)) return;
      // Hand off to the bottomPinned region (DirectSelfRow,
      // WorkspaceFooter). Those rows host their own drag-to-open
      // bottom sheets — engaging the curtain gesture here would drive
      // the curtain in parallel with the sheet opening, and the two
      // motions would visually fight.
      if (target && bottomPinnedRef.current?.contains(target)) return;
      // Scroll-aware bail: leave a scrollable chat list to its native
      // vertical scroll. Skipped in form-* snaps because the visible
      // body area there is the strip BELOW the form (where the list
      // mostly isn't paintable anyway), and form-close is the only
      // valid transition — letting the list scroll instead would
      // strand the user in the form.
      const list = scrollRef.current;
      if (!isFormSnap(snapRef.current) && list && list.scrollHeight > list.clientHeight + 1) {
        return;
      }
      startX = e.touches[0].clientX;
      startY = e.touches[0].clientY;
      direction = null;
      transition = null;
      engaged = false;
      lastDelta = 0;
    };

    const onTouchMove = (e: TouchEvent) => {
      if (e.touches.length !== 1) {
        // Second finger landed mid-gesture — abort.
        startX = null;
        startY = null;
        direction = null;
        transition = null;
        if (engaged) setLiveDrag(0, false);
        engaged = false;
        lastDelta = 0;
        emitHandle(false, false);
        return;
      }
      if (startY === null) return;

      const delta = e.touches[0].clientY - startY;
      const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;

      if (direction === null) {
        if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return;
        // Horizontal-bail: pager horizontal swipe wins ties → we drop.
        if (Math.abs(deltaX) > Math.abs(delta)) {
          startX = null;
          startY = null;
          direction = null;
          return;
        }
        direction = delta > 0 ? 'down' : 'up';
        transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction);
        if (transition === null) {
          // (snap, pinned, direction) has no valid motion — refresh+down,
          // form+down (pinned+up also resolves to null, though the
          // touchstart pinned-bail already filters every pinned
          // gesture before we reach here). Bail without preventDefault
          // so any native default (overscroll bounce, etc.) can still
          // play.
          startX = null;
          startY = null;
          direction = null;
          return;
        }
        // Closed-free UP-half bail. closed-free is the only transition
        // whose upward direction commits to pin — and pin via body is
        // exactly what the user banned (see «Direction asymmetry» on
        // the hook). The downward half (closed → refresh) stays on body.
        // close-refresh and form-close are also upward, but their commit
        // target is `closed` — they're the «retract» gestures the user
        // explicitly wants to keep on the body, so they pass through.
        if (transition === 'closed-free' && direction === 'up') {
          startX = null;
          startY = null;
          direction = null;
          transition = null;
          return;
        }
      }

      engaged = true;
      e.preventDefault();

      // Per-transition rubber-band dynamics + atCommit semantics. All
      // thresholds expressed against CURTAIN displacement (lastDelta)
      // so the body and the handle commit at the same visual point,
      // only the finger pull needed differs.
      let atCommit = false;
      switch (transition) {
        case 'closed-free':
          // Body-side `closed-free` is DOWN-only: the handle owns the
          // UP half (pin commit) per «Direction asymmetry» above, and
          // the closed-free up-bail in the dead-zone block makes sure
          // we only ever engage this branch with direction='down'.
          // Clamp at 0 below so a mid-gesture finger-up past the
          // start point can't drag the curtain into pin territory
          // and offer a pin commit that the user explicitly didn't
          // want exposed on the body. Rubber-banded 0.65×
          // displacement matches the «physically heavier» body feel.
          lastDelta = Math.max(0, delta * RUBBER_BAND);
          atCommit = lastDelta >= REFRESH_COMMIT_PX;
          break;
        case 'close-refresh':
          // Rubber-banded up. No clamp either side — matches the
          // original list-bound retract feel; a downward jitter past
          // the refresh snap is visually negligible against the rubber-
          // band damping. Commit target is `closed`; no path into pin
          // territory (the user's hard rule — pin is handle-only).
          lastDelta = delta * RUBBER_BAND;
          atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
          break;
        case 'form-close':
          // Rubber-banded up; capped at 0 so an accidental downward
          // jitter doesn't push the curtain below its form-snap top.
          // Commit target is `closed` (the form-close drag retracts
          // through the form's vertical footprint into the closed
          // snap).
          lastDelta = Math.min(0, delta * RUBBER_BAND);
          atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
          break;
        case 'pinned-free':
          // Unreachable on the body — the pinned bail at touchstart
          // prevents the hook from ever resolving this transition.
          // Kept here so the `never` default below stays exhaustive
          // and a future opening of pinned-free on the body would
          // need to wire the dispatch explicitly.
          break;
        case null:
          // Unreachable: `engaged` is set only after `transition` is
          // resolved non-null in the dead-zone block above.
          break;
        default: {
          assertNeverCurtainTransition(transition);
          break;
        }
      }
      setLiveDrag(lastDelta, true);
      emitHandle(true, atCommit);
    };

    const onTouchEnd = () => {
      if (!engaged) {
        startX = null;
        startY = null;
        direction = null;
        transition = null;
        return;
      }
      switch (transition) {
        case 'closed-free':
          // Body is DOWN-only — refresh is the sole commit target. Pin
          // commit lives on the handle (see «Direction asymmetry»
          // above and the touchmove switch). Below threshold the
          // curtain springs back to closed.
          if (lastDelta >= REFRESH_COMMIT_PX) {
            commitRef.current('refresh');
          } else {
            setLiveDrag(0, false);
          }
          break;
        case 'close-refresh':
          if (-lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX) {
            commitRef.current('closed');
          } else {
            setLiveDrag(0, false);
          }
          break;
        case 'form-close':
          if (-lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX) {
            commitRef.current('closed');
          } else {
            setLiveDrag(0, false);
          }
          break;
        case 'pinned-free':
        case null:
          // Both unreachable per the touchmove switch above (pinned
          // bail at touchstart filters pinned-free; `engaged` only
          // flips once `transition` is non-null). The setLiveDrag
          // fallback preserves spring-back behaviour if a future
          // change exposes either path here.
          setLiveDrag(0, false);
          break;
        default: {
          assertNeverCurtainTransition(transition);
          setLiveDrag(0, false);
          break;
        }
      }
      startX = null;
      startY = null;
      direction = null;
      transition = null;
      engaged = false;
      lastDelta = 0;
      emitHandle(false, false);
    };

    const onTouchCancel = () => {
      if (engaged) setLiveDrag(0, false);
      startX = null;
      startY = null;
      direction = null;
      transition = null;
      engaged = false;
      lastDelta = 0;
      emitHandle(false, false);
    };

    curtain.addEventListener('touchstart', onTouchStart, { passive: true });
    curtain.addEventListener('touchmove', onTouchMove, { passive: false });
    curtain.addEventListener('touchend', onTouchEnd, { passive: true });
    curtain.addEventListener('touchcancel', onTouchCancel, { passive: true });
    return () => {
      curtain.removeEventListener('touchstart', onTouchStart);
      curtain.removeEventListener('touchmove', onTouchMove);
      curtain.removeEventListener('touchend', onTouchEnd);
      curtain.removeEventListener('touchcancel', onTouchCancel);
      // Same teardown contract as the handle hook — see its cleanup for
      // the rationale. If `disabled` flips true while a body drag is in
      // flight, the touchend never reaches us and the curtain would stay
      // frozen at the finger position until the next touch.
      if (engaged) {
        setLiveDrag(0, false);
        emitHandle(false, false);
      }
    };
    // setLiveDrag is stable; the ref args are stable. `snap`, `pinned`,
    // and `commit` are ref-mirrored. Only `disabled` needs to tear
    // listeners down — it's the sole effect dep.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [curtainRef, handleRef, bottomPinnedRef, scrollRef, setLiveDrag, disabled]);
}
