// Bottom-up sheet that wraps the mobile chat column for the media
// viewer. Built on the same scaffolding as `MobileSettingsHorseshoe`
// (Strict-Mode-safe entry animation, `keepMounted` delayed unmount), but
// this sheet is full-bleed — no clip-path bottom-carve — and rides the
// pager / swipe-back easing (`PAGER_EASING`), not vaul's. Being a
// near-fullscreen viewer it keeps the «horseshoe» rounded top corners
// (same radius as the tab curtains) but stays tall — the rounded top
// lands flush under the system status bar (see the geometry block below).
//
// Differences from the settings sheet:
//   • Tap-to-open only. No drag-up origin — opening is driven from
//     `useOpenMediaViewer` called by `ImageContent.onClick`. Drag-to-
//     CLOSE works from BOTH the 20px handle band AND anywhere on the
//     viewer body (a vertical-down drag at zoom=1, fed by
//     MediaViewerBody's `onCloseDrag`); horizontal swipe pages, pinch
//     zooms, and a one-finger drag at zoom>1 pans.
//   • Near-fullscreen, flush top: the sheet covers the chat (incl. its
//     header) and its rounded top lands flush under the system status
//     bar — vs settings's shorter rounded 2/3.
//   • Silhouette bg = `Background.Container` (deepest Dawn tone) for a
//     dark image backdrop. The rounded top corners are transparent and
//     reveal the live chat behind (appBody is full-bleed, not clipped) —
//     no backing square — unlike settings's `SurfaceVariant.Container`
//     silhouette over a void seam. See the css file.
//   • Wrapped content is the chat column, not the DM list.
//   • The silhouette resets `--vojo-safe-top` to 0 (mirror of the
//     settings sheet) so a status-bar-padded descendant added here later
//     isn't pushed down inside a sheet that already sits below the bar.

import React, { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { mediaViewerAtom } from '../../state/mediaViewer';
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { PAGER_EASING, PAGER_TRANSITION_MS } from '../../components/mobile-tabs-pager/geometry';
import { MediaViewerBody } from './MediaViewerBody';
import * as css from './MobileMediaViewerHorseshoe.css';

// Open / close timing borrowed from the tab pager — the SAME constants the
// swipe-back-from-chat gesture re-uses (`swipe-back/geometry.ts`). Reusing
// them (not re-literalling 250ms / a vaul curve) keeps the sheet's slide
// reading at the same speed + decelerating curve as returning from a chat,
// and means the two can never drift in feel. `cubic-bezier(0.22, 1, 0.36, 1)`
// is the long-tail easeOut that makes the motion settle rather than snap.
const SHEET_EASING = PAGER_EASING;
const ANIMATION_MS = PAGER_TRANSITION_MS;
// Drag distance past which release commits the close. Matches the
// settings sheet's 80px (the only commit gesture there too). Measured
// against the RUBBER-BANDED sheet displacement below, not the raw finger
// pull — the same «displacement, not raw» rule the chat-list curtain uses.
const COMMIT_THRESHOLD_PX = 80;
// Finger→sheet damping for the drag-to-close, mirroring the chat-list
// curtain's body gesture (`stream-header/geometry.ts::RUBBER_BAND`): the
// sheet follows the finger at 0.65× so the pull reads as physically
// «heavier». With the threshold above on the damped displacement, a commit
// needs ≈80 / 0.65 ≈ 123px of actual finger travel.
const DRAG_RUBBER_BAND = 0.65;
// Sheet height as a fraction of the viewport — near-fullscreen by
// design: the viewer should cover the chat (incl. its header) and stop
// just under the system status bar / tray. Capped at runtime by the
// wrapper container's actual height minus the status-bar inset (see
// `railHeightPx` calc below) so the sheet's rounded top edge lands at
// the bottom of the status bar, never over the tray icons.
const RAIL_VIEWPORT_FRACTION = 1;
// Absolute floor for tiny / split-screen viewports — the sheet
// becomes useless below this. NB: floor cannot exceed the
// container's ceiling (`Math.min` is applied last), so in extreme
// squeeze cases (split-screen, oversized call rail) the sheet
// silently shrinks below the floor rather than clipping the status bar.
const RAIL_MIN_PX = 200;

type DragState = {
  // 'touch' / 'pointer' originate on the top handle band; 'body' is fed by
  // MediaViewerBody's drag-to-close (a downward drag anywhere on the stage).
  inputType: 'touch' | 'pointer' | 'body';
  startY: number;
  deltaY: number;
};

type MobileMediaViewerHorseshoeProps = {
  children: ReactNode;
};

function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoeProps) {
  const { t } = useTranslation();
  const entry = useAtomValue(mediaViewerAtom);
  const closeSheet = useCloseMediaViewer();

  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);
  }, []);

  // Measure the wrapper container directly via `ResizeObserver` so
  // the rail height tracks whatever vertical space is actually
  // available to the chat column. The viewport-based fraction is
  // the *intent* (full screen — product spec), but
  // `HorseshoeContainer.bottomRail` (the call rail) sits OUTSIDE
  // the chat column inside the same viewport and shrinks our
  // wrapper when active. Without the container clamp, a full-viewport
  // sheet could exceed the wrapper's height and spill past the bottom
  // chrome; the clamp keeps the top edge at the status bar instead.
  const containerRef = useRef<HTMLDivElement>(null);
  const [containerHeight, setContainerHeight] = useState(() =>
    typeof window === 'undefined' ? 800 : window.innerHeight
  );

  useLayoutEffect(() => {
    const el = containerRef.current;
    if (!el) return undefined;
    setContainerHeight(el.clientHeight);
    const ro = new ResizeObserver((entries) => {
      const cr = entries[0]?.contentRect;
      if (cr) setContainerHeight(cr.height);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // Live status-bar inset. `env(safe-area-inset-top)` can't be read
  // reliably off a custom property in JS, so we measure a zero-width
  // probe whose height is `var(--vojo-safe-top)` (= the env value).
  // This is the only thing the near-fullscreen sheet must stay below
  // — it covers the chat header but must not climb over the tray.
  const safeTopRef = useRef<HTMLDivElement>(null);
  const [safeTopPx, setSafeTopPx] = useState(0);
  useLayoutEffect(() => {
    const el = safeTopRef.current;
    if (!el) return undefined;
    setSafeTopPx(el.offsetHeight);
    const ro = new ResizeObserver(() => setSafeTopPx(el.offsetHeight));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // Final rail = viewport × fraction (full screen), then floor-then-
  // ceiling clamped. Container minus the status-bar inset is the HARD
  // ceiling: the sheet's rounded top lands flush at the bottom of the
  // status bar (no void gap / breathing), covering the whole chat column
  // down to just under the tray; when the wrapper shrinks (call rail /
  // split-screen) it shrinks below `RAIL_MIN_PX` rather than spilling
  // over the tray. Order matters — `Math.min` last makes container win.
  const idealRailPx = Math.round(viewportHeight * RAIL_VIEWPORT_FRACTION);
  const railHeightPx = Math.min(
    Math.max(0, containerHeight - safeTopPx),
    Math.max(RAIL_MIN_PX, idealRailPx)
  );
  const open = !!entry;

  // Entry-animation gate — see `MobileSettingsHorseshoe` for the
  // full rationale. `hasEnteredRef` mirror guards the atom-clearing
  // unmount cleanup against React 18 strict-mode dev rehearsal.
  const [hasEntered, setHasEntered] = useState(false);
  const hasEnteredRef = useRef(false);
  useLayoutEffect(() => {
    const id = requestAnimationFrame(() => {
      hasEnteredRef.current = true;
      setHasEntered(true);
    });
    return () => cancelAnimationFrame(id);
  }, []);

  // Keep the viewer body mounted through the exit slide (`ANIMATION_MS`)
  // so the user sees the image slide down with the sheet instead of an
  // empty panel collapsing.
  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 entry through the exit animation so the
  // body keeps rendering the image as it slides down.
  const lastEntryRef = useRef(entry);
  if (entry) lastEntryRef.current = entry;

  const baseExpanded = open && hasEntered ? railHeightPx : 0;
  // Drag is close-only (positive deltaY) and rubber-banded: the sheet
  // shrinks by `deltaY × DRAG_RUBBER_BAND`, so it lags the finger and the
  // pull feels «heavier» (same as the chat-list curtain body gesture).
  // `Math.max(0, deltaY)` ignores any upward reversal past the start so it
  // can't expand the sheet past its open height.
  const dragDisplacementPx = drag ? Math.max(0, drag.deltaY) * DRAG_RUBBER_BAND : 0;
  const expandedPx = Math.max(0, baseExpanded - dragDisplacementPx);
  const isDragging = drag !== null;

  const handleRef = useRef<HTMLDivElement>(null);

  const dragRef = useRef<DragState | null>(null);
  dragRef.current = drag;
  const entryRef = useRef(entry);
  entryRef.current = entry;
  const closeSheetRef = useRef(closeSheet);
  closeSheetRef.current = closeSheet;

  // Drag-to-close fed from MediaViewerBody — a downward drag anywhere on
  // the stage (zoom=1). Mirrors the handle's rubber-band + commit threshold
  // so the two surfaces feel identical. `dragging:false` is the release:
  // commit if the damped displacement cleared the threshold, else spring
  // back. Stable identity — MediaViewerBody mirrors it into a ref anyway.
  const onBodyCloseDrag = useCallback((rawDeltaY: number, dragging: boolean) => {
    if (dragging) {
      setDrag({ inputType: 'body', startY: 0, deltaY: Math.max(0, rawDeltaY) });
      return;
    }
    if (Math.max(0, rawDeltaY) * DRAG_RUBBER_BAND > COMMIT_THRESHOLD_PX) {
      closeSheetRef.current();
    }
    setDrag(null);
  }, []);

  // Clear the atom if the wrapper unmounts while the sheet is open
  // (route change). Strict-mode rehearsal guard via `hasEnteredRef`
  // — without it, the cleanup fires before the entry rAF and a
  // deep-link cold-start would clear the atom mid-rehearsal.
  useEffect(
    () => () => {
      if (!hasEnteredRef.current) return;
      if (entryRef.current) closeSheetRef.current();
    },
    []
  );

  // Hardware Escape (also dispatched by the global Android-back
  // handler via the portal-marker pattern below). Skip inputs /
  // contenteditable so typing inside a future caption field doesn't
  // dismiss the sheet.
  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-to-close only — handle band at the top of the silhouette.
  // No document-level open-drag listener; opening is tap-only via
  // `useOpenMediaViewer`.
  //
  // CLAMP, not early-return — see `MobileSettingsHorseshoe` for
  // the bug-fix rationale. Reversing the gesture must drag deltaY
  // back toward 0, not leave the stale value.
  useEffect(() => {
    const handleEl = handleRef.current;
    if (!handleEl) return undefined;

    const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => {
      const d = dragRef.current;
      if (!d) return;
      const rawDelta = clientY - d.startY;
      const nextDelta = Math.max(0, rawDelta);
      if (e.cancelable) e.preventDefault();
      setDrag({ ...d, deltaY: nextDelta });
    };

    const applyEnd = () => {
      const d = dragRef.current;
      if (!d) return;
      // Threshold is on the rubber-banded sheet displacement, not the raw
      // finger pull — matches the live `dragDisplacementPx` the user sees.
      if (Math.max(0, d.deltaY) * DRAG_RUBBER_BAND > COMMIT_THRESHOLD_PX) {
        closeSheetRef.current();
      }
      setDrag(null);
    };

    const onHandleTouchStart = (e: TouchEvent) => {
      if (dragRef.current) return;
      if (!entryRef.current) return;
      const touch = e.touches[0];
      setDrag({ inputType: 'touch', startY: touch.clientY, deltaY: 0 });
    };
    const onTouchMove = (e: TouchEvent) => {
      const d = dragRef.current;
      if (!d || d.inputType !== 'touch') return;
      applyMove(e.touches[0].clientY, e);
    };
    const onTouchEnd = () => {
      const d = dragRef.current;
      if (!d || d.inputType !== 'touch') return;
      applyEnd();
    };

    const onHandlePointerDown = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      if (dragRef.current) return;
      if (!entryRef.current) return;
      if (e.button !== 0) return;
      setDrag({ inputType: 'pointer', startY: e.clientY, deltaY: 0 });
    };
    const onPointerMove = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      const d = dragRef.current;
      if (!d || d.inputType !== 'pointer') return;
      applyMove(e.clientY, e);
    };
    const onPointerEnd = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      const d = dragRef.current;
      if (!d || d.inputType !== 'pointer') return;
      applyEnd();
    };

    handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true });
    document.addEventListener('touchmove', onTouchMove, { passive: false });
    document.addEventListener('touchend', onTouchEnd, { passive: true });
    document.addEventListener('touchcancel', onTouchEnd, { passive: true });
    handleEl.addEventListener('pointerdown', onHandlePointerDown);
    document.addEventListener('pointermove', onPointerMove, { passive: false });
    document.addEventListener('pointerup', onPointerEnd, { passive: true });
    document.addEventListener('pointercancel', onPointerEnd, { passive: true });

    return () => {
      handleEl.removeEventListener('touchstart', onHandleTouchStart);
      document.removeEventListener('touchmove', onTouchMove);
      document.removeEventListener('touchend', onTouchEnd);
      document.removeEventListener('touchcancel', onTouchEnd);
      handleEl.removeEventListener('pointerdown', onHandlePointerDown);
      document.removeEventListener('pointermove', onPointerMove);
      document.removeEventListener('pointerup', onPointerEnd);
      document.removeEventListener('pointercancel', onPointerEnd);
    };
  }, []);

  // Geometry — the media viewer is near-fullscreen: its rounded top lands
  // flush under the status-bar strip. The opaque silhouette grows from the
  // bottom over the live chat (`appBody`, full-bleed behind it); its rounded
  // TL/TR corners stay transparent and reveal that chat, so the rounding
  // reads against real content — no clip-path carve, no backing square (see
  // the css file). Only the silhouette's height animates.
  const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${SHEET_EASING}`;

  const renderEntry = entry ?? lastEntryRef.current;
  const renderBody = !!renderEntry && (keepMounted || isDragging);

  // Marker portal for the global Android-back handler — when
  // `portalContainer.firstChild` matches our `data-vojo-…-active`
  // attribute, the back-button dispatches an Escape keydown that
  // the `keydown` effect above catches.
  const portalTarget =
    typeof document !== 'undefined'
      ? document.getElementById('portalContainer') ?? document.body
      : null;

  return (
    <div ref={containerRef} className={css.container}>
      {/* Zero-width probe — its height resolves `env(safe-area-inset-top)`
          so the JS rail-height calc can keep the sheet below the tray. */}
      <div
        ref={safeTopRef}
        aria-hidden="true"
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          width: 0,
          height: 'var(--vojo-safe-top, 0px)',
          pointerEvents: 'none',
          visibility: 'hidden',
        }}
      />
      {open && portalTarget
        ? createPortal(
            <div
              data-vojo-media-viewer-sheet-active="true"
              aria-hidden="true"
              style={{ display: 'none' }}
            />,
            portalTarget
          )
        : null}
      <div className={css.appBody} style={{ overscrollBehaviorY: 'contain' }}>
        {children}
      </div>

      <div
        className={css.silhouette}
        style={{
          height: `${expandedPx}px`,
          transition: silhouetteTransition,
          // Gate on `renderBody` (open / dragging / exiting), NOT on
          // `expandedPx > 0`. On close `expandedPx` snaps to 0 immediately
          // while the height transition animates X→0 over `ANIMATION_MS`;
          // gating on `expandedPx` would flip to `hidden` on that first
          // frame and the slide-down would never be seen (the sheet would
          // just pop out). `renderBody` stays true through the exit window
          // (`keepMounted`), so the retract animation is visible, then it
          // goes false and hides the spent 0-height sheet.
          visibility: renderBody ? 'visible' : 'hidden',
          // Reset `--vojo-safe-top` so any descendant that paints a
          // `padding-top: var(--vojo-safe-top)` for status-bar safe-
          // area (e.g. PageNav-style headers, if anyone adds one
          // here later) doesn't push the viewer header down inside
          // the sheet — the sheet itself sits below the status bar.
          // Mirror of the settings horseshoe.
          ['--vojo-safe-top' as string]: '0px',
        }}
        role="dialog"
        aria-label={renderEntry?.body ?? t('MediaViewer.title', 'Media viewer')}
      >
        <div className={css.panelContent} style={{ height: `${railHeightPx}px` }}>
          <div
            ref={handleRef}
            className={css.panelHandle}
            aria-label={t('MediaViewer.drag_to_close', 'Drag down to close')}
          >
            <div className={css.panelHandleBar} />
          </div>
          <div className={css.panelBody}>
            {renderBody && renderEntry && (
              <MediaViewerBody
                entry={renderEntry}
                requestClose={() => closeSheetRef.current()}
                onCloseDrag={onBodyCloseDrag}
              />
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

// Top-level branch — pass through on non-mobile (the desktop right
// pane handles the same atom over there). The mobile branch owns
// the drag / atom hooks in a sub-component so we don't run them on
// desktop renders.
export function MobileMediaViewerHorseshoe({
  children,
}: MobileMediaViewerHorseshoeProps): React.ReactElement {
  const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
  if (!isMobile) {
    return children as React.ReactElement;
  }
  return <MobileMediaViewerHorseshoeImpl>{children}</MobileMediaViewerHorseshoeImpl>;
}
