import {useCallback, useEffect, useRef, useState} from 'react';
import {Pressable, StyleSheet, View} from 'react-native';
import {useTranslation} from 'react-i18next';
import {useEvent} from 'expo';
import {
  VideoPlayer as VideoPlayerInstance,
  VideoSource,
  VideoView,
} from 'expo-video';
import {useNetInfo} from '@react-native-community/netinfo';
import {IconDownload} from '@tabler/icons-react-native';
import {Button, Spinner} from '@components';
import {IconPlayVideo, ProgressiveImage} from '@modules/chats/components';
import {IMAGE_SIZE_SMALL_PX} from '@modules/chats/constants';
import {useVideoSource} from '@modules/chats/hooks';
import {VideoPlayerPool} from '@modules/chats/instances/VideoPlayerPool';

import {Controls} from './Controls';
import {styles} from './styles';

/** Smoothness of the scrubber. Default is 0.5s; 0.25s lets the thumb
 *  glide between native ticks without flooding the JS thread. */
const TIME_UPDATE_INTERVAL = 0.25;
/** Silent retries before surfacing the retry button to the user. Mirrors
 *  the image gallery's AUTO_RETRY_LIMIT in the shared `<Image>` wrapper. */
const AUTO_RETRY_LIMIT = 3;
/** How long the loading state must persist before we surface the big
 *  spinner. Anything that loads faster than this (warm cache, tile
 *  preload) plays through without ever flashing the centre-of-screen
 *  spinner. */
const LOADING_SPINNER_DELAY_MS = 5_000;

interface Props {
  url: string;
  messageId: string;
  assetId: number;
  /**
   * Thumbnail (poster) URL — same `thumbnail_url` the chat tile renders.
   * Painted on top of the VideoView until `onFirstFrameRender` fires so
   * the user sees the poster, not the black native surface, while the
   * player loads + buffers. Optional: omit to fall back to a black box.
   */
  thumbnailUrl?: string;
  /**
   * Only the currently-visible carousel page is active. Inactive players
   * pause and seek to 0 so audio doesn't bleed from neighbour pages while
   * the user swipes.
   */
  isActive: boolean;
  /**
   * Whether the video should start playing as soon as it becomes active.
   * `true` for the video the user originally tapped on in the chat
   * (`initialAssetId`); `false` for every other video they swipe to in
   * the same gallery. When `false`, a big circle play button overlay
   * is shown until the user taps it (or dismisses by tapping outside).
   */
  autoPlay: boolean;
}

/**
 * Acquires a player from the shared pool (which the chat tile may
 * already have pre-buffering on) and renders it via `<VideoView>`. The
 * pool guarantees the player's native lifecycle stays valid for as long
 * as someone is holding a reference — no more shared-object hazards
 * between the tile and the gallery.
 */
export function VideoPlayer({
  url,
  messageId,
  assetId,
  thumbnailUrl,
  isActive,
  autoPlay,
}: Props) {
  const source = useVideoSource(url);
  // Only acquire a player on the *active* carousel page. Multiple
  // concurrent source loads (one per multi-video carousel page) split
  // network bandwidth and starve the page the user actually clicked
  // — the symptom is the clicked video staying stuck on the spinner
  // until the user swipes away and back. With this gate, only the
  // visible page loads, and swiping reuses the pool slot via its
  // sourceUri match (so re-visiting an already-loaded video is
  // instant).
  const player = usePoolPlayer(messageId, assetId, isActive ? source : null);

  // Persistent placeholder, always mounted. When the inactive→active
  // page swap happens, React keeps this element instead of remounting,
  // so the already-decoded poster stays painted on screen through the
  // tick that `VideoSurface`'s native `VideoView` + its internal
  // ExpoImage placeholder need to draw — no black flash.
  //
  // The non-interactive play icon is rendered ONLY when there's no
  // player. On the active page `VideoSurface` owns the play / loading
  // / dismissed-state logic; doubling up with a persistent icon would
  // leave a ghost play button showing through any time the active
  // overlay was hidden (dismissed, playing, etc.).
  return (
    <View style={styles.videoContainer}>
      <ThumbnailPlaceholder thumbnailUrl={thumbnailUrl} />
      {!player && (
        <View style={styles.bigOverlayWrapper} pointerEvents="none">
          <View style={styles.bigPlayPressable}>
            <IconPlayVideo size="lg" />
          </View>
        </View>
      )}
      {player && (
        <VideoSurface
          player={player}
          source={source}
          thumbnailUrl={thumbnailUrl}
          isActive={isActive}
          autoPlay={autoPlay}
        />
      )}
    </View>
  );
}

/**
 * Poster overlay used in two places:
 *  - Inactive carousel pages where no player is mounted yet (placeholder
 *    fills the whole container).
 *  - Active page on top of `VideoView`, masking the black native surface
 *    until `onFirstFrameRender` fires.
 *
 * Same source + sized variants the chat tile already cached, so it
 * paints in one frame from ExpoImage's disk cache. `pointerEvents="none"`
 * so taps fall through to the toggle Pressable on the active page.
 */
function ThumbnailPlaceholder({thumbnailUrl}: {thumbnailUrl?: string}) {
  if (!thumbnailUrl) return null;
  return (
    <View style={styles.placeholder} pointerEvents="none">
      <ProgressiveImage
        url={thumbnailUrl}
        smallWidth={IMAGE_SIZE_SMALL_PX}
        largeWidth="original"
        contentFit="contain"
        style={styles.placeholderImage}
        recyclingKey={thumbnailUrl}
      />
    </View>
  );
}

/**
 * Acquires + releases a pooled player by key. The gallery acquires
 * with `disposable: true` — if the chat tile had already preloaded this
 * video, the pool's key-match branch shares that slot (so we still get
 * the instant-play benefit). If it wasn't preloaded, the pool skips the
 * "reuse a stranger's idle slot via replaceAsync" branch and creates a
 * fresh player instead. That avoids the brief flash of the previously
 * recycled video's last frame while the new source loads, and the
 * fresh slot is fully released when this hook unmounts — so leaving
 * the gallery leaves only the chat-tile preloaded players behind.
 */
function usePoolPlayer(
  messageId: string,
  assetId: number,
  source: VideoSource,
): VideoPlayerInstance | null {
  const [player, setPlayer] = useState<VideoPlayerInstance | null>(null);
  useEffect(() => {
    if (!source) {
      setPlayer(null);
      return;
    }
    const p = VideoPlayerPool.acquire(messageId, assetId, source, {
      disposable: true,
    });
    setPlayer(p);
    return () => {
      VideoPlayerPool.release(messageId, assetId);
    };
  }, [messageId, assetId, source]);
  return player;
}

/** Auto-hide controls after this long when the video is playing and
 *  the user hasn't interacted recently. */
const CONTROLS_AUTOHIDE_MS = 3_000;

function VideoSurface({
  player,
  source,
  thumbnailUrl,
  isActive,
  autoPlay,
}: {
  player: VideoPlayerInstance;
  source: VideoSource;
  thumbnailUrl?: string;
  isActive: boolean;
  autoPlay: boolean;
}) {
  const {t} = useTranslation();

  // Non-autoplay videos require an explicit tap on the big play overlay
  // to start. Once tapped, this latch flips to `true` and behaves like
  // autoplay for the rest of this mount (swipe-away + back will reset
  // it, since the gallery uses disposable pool slots that drop on
  // release).
  const [hasUserTappedPlay, setHasUserTappedPlay] = useState(false);
  const shouldPlay = autoPlay || hasUserTappedPlay;
  // Once the user dismisses the big play (tap outside it), it stays
  // dismissed until remount — so we don't keep showing it over the
  // controls the user just asked for.
  const [bigPlayDismissed, setBigPlayDismissed] = useState(false);

  useActivePlayback(player, isActive, shouldPlay);

  const {isPlaying} = useEvent(player, 'playingChange', {
    isPlaying: player.playing,
  });
  const {status} = useEvent(player, 'statusChange', {
    status: player.status,
  });

  // Poster (thumbnail) overlay state. Stays visible until the very first
  // frame paints into the VideoView so the user never stares at the
  // black native surface during initial buffering. One-way latch: once
  // we've seen ANY frame (or the player has started playing), we hide
  // the poster and don't bring it back — error / retry / scrub keep
  // showing the last decoded frame instead.
  const [hasFirstFrame, setHasFirstFrame] = useState(false);
  const onFirstFrameRender = useCallback(() => {
    setHasFirstFrame(true);
  }, []);
  // Fallback: if the VideoView was bound to a player that's already
  // past loading (pool slot was preloaded by the chat tile), the native
  // `onFirstFrameRender` callback may not fire. `isPlaying === true`
  // means frames are definitely being rendered, so latch the same way.
  useEffect(() => {
    if (isPlaying) setHasFirstFrame(true);
  }, [isPlaying]);

  // One-way latch that flips true the first time the player actually
  // starts playing. Used to suppress the big play / loading circle
  // for the rest of this mount — once the video has been seen
  // playing, pause / scrub / mid-play retry must NOT bounce the big
  // spinner back into the centre of the screen. The user uses the
  // bottom-bar small play and the buffered indicator from here on.
  const [hasEverPlayed, setHasEverPlayed] = useState(false);
  useEffect(() => {
    if (isPlaying) setHasEverPlayed(true);
  }, [isPlaying]);

  const retryCountRef = useRef(0);
  const [hasError, setHasError] = useState(false);
  // True while a `replaceAsync` is in flight (status-driven auto
  // retry, network reconnect, or the user tapping the retry button).
  // Drives the big circle into the loading variant so the user sees
  // a spinner even if they haven't tapped play yet — covers the
  // non-autoplay case where `shouldPlay` is false while we're still
  // attempting to recover the source in the background.
  const [isRetrying, setIsRetrying] = useState(false);

  // Loading spinner is delayed for `LOADING_SPINNER_DELAY_MS` so a
  // fast load (warm cache, preloaded tile) doesn't flash the spinner
  // for a frame or two before the video starts. Stays false unless
  // we've genuinely been waiting too long — at which point it stays
  // true until the loading state ends.
  const isLoading = shouldPlay || isRetrying;
  const [showLoadingSpinner, setShowLoadingSpinner] = useState(false);
  useEffect(() => {
    if (!isLoading) {
      setShowLoadingSpinner(false);
      return;
    }
    const id = setTimeout(
      () => setShowLoadingSpinner(true),
      LOADING_SPINNER_DELAY_MS,
    );
    return () => clearTimeout(id);
  }, [isLoading]);

  // Tap on the video toggles controls; while playing we also auto-hide
  // them after a short delay. Paused → stays visible. Start *hidden* —
  // gives the user a clean look at the poster / first frame for a
  // moment before the bottom bar slides in.
  const [showControls, setShowControls] = useState(false);
  // Latches once the user has interacted (or once the initial reveal
  // has fired). Lets a tap during the 500 ms grace window override the
  // pending auto-reveal so we don't fight the user's intent.
  const initialRevealHandledRef = useRef(false);
  const toggleControls = useCallback(() => {
    initialRevealHandledRef.current = true;
    setShowControls(v => !v);
  }, []);
  useEffect(() => {
    // Skip the 500 ms auto-reveal while the big play overlay is the
    // primary call to action — surfacing the bottom bar on top of it
    // would steal the user's eye away from the obvious "tap to start"
    // affordance. Once the user taps big play (`hasUserTappedPlay` →
    // true) the autoplay path takes over and the auto-reveal runs.
    if (!shouldPlay) {
      initialRevealHandledRef.current = true;
      return;
    }
    const id = setTimeout(() => {
      if (initialRevealHandledRef.current) return;
      initialRevealHandledRef.current = true;
      setShowControls(true);
    }, 500);
    return () => clearTimeout(id);
  }, [shouldPlay]);
  useEffect(() => {
    if (!showControls || !isPlaying) return;
    const id = setTimeout(() => setShowControls(false), CONTROLS_AUTOHIDE_MS);
    return () => clearTimeout(id);
  }, [showControls, isPlaying]);

  // Tap on the big circle play → start playback (and let the existing
  // auto-reveal show the bottom bar). Tap *outside* the big play (i.e.
  // on the surrounding surface) → dismiss the overlay and surface the
  // controls so the user can interact via the small play button.
  const onPressBigPlay = useCallback(() => {
    setHasUserTappedPlay(true);
  }, []);
  const onTapSurface = useCallback(() => {
    // Mirror the big-overlay render condition (see JSX below): the
    // overlay shows while the player hasn't started, the user hasn't
    // dismissed it, there's no error, AND the video hasn't been
    // played in this mount. If it's showing, tap-outside-the-circle
    // dismisses it AND reveals the controls. Otherwise the tap just
    // toggles controls.
    const overlayShowing =
      !isPlaying && !bigPlayDismissed && !hasError && !hasEverPlayed;
    if (overlayShowing) {
      setBigPlayDismissed(true);
      initialRevealHandledRef.current = true;
      setShowControls(true);
      return;
    }
    toggleControls();
  }, [isPlaying, bigPlayDismissed, hasError, hasEverPlayed, toggleControls]);

  // Fullscreen state. expo-video's `nativeControls={false}` removes the
  // native UI from fullscreen mode too, which leaves the user with no
  // way to exit. We toggle it on whenever we're in fullscreen so the
  // platform's standard fullscreen controls (close + scrubber + play)
  // appear.
  const videoViewRef = useRef<VideoView>(null);
  const [isFullscreen, setIsFullscreen] = useState(false);
  const onEnterFullscreen = useCallback(() => {
    // Optimistic flip so `nativeControls=true` is committed to the
    // native view before the fullscreen presentation starts — avoids
    // the brief flash without controls if we relied solely on the
    // `onFullscreenEnter` callback.
    setIsFullscreen(true);
    requestAnimationFrame(() => {
      videoViewRef.current?.enterFullscreen().catch(() => {
        setIsFullscreen(false);
      });
    });
  }, []);
  const onFullscreenEnter = useCallback(() => {
    setIsFullscreen(true);
  }, []);
  const onFullscreenExit = useCallback(() => {
    setIsFullscreen(false);
    // Coming back out: surface the custom controls so the user has a
    // clear entry point even if the auto-hide had previously fired.
    setShowControls(true);
  }, []);

  const reload = useCallback(() => {
    if (!source || typeof source !== 'object') return;
    try {
      player.replaceAsync(source).catch(() => {});
    } catch {
      /* native side rejected */
    }
  }, [player, source]);

  useEffect(() => {
    if (isPlaying) {
      retryCountRef.current = 0;
      setHasError(false);
      setIsRetrying(false);
    }
  }, [isPlaying]);

  useEffect(() => {
    if (status !== 'error') return;
    if (retryCountRef.current < AUTO_RETRY_LIMIT) {
      retryCountRef.current += 1;
      setIsRetrying(true);
      reload();
    } else {
      setIsRetrying(false);
      setHasError(true);
    }
  }, [status, reload]);

  // Network-reconnect retry. expo-video reports `status === 'error'`
  // only for some failure modes — flaky network mid-load often leaves
  // the player parked on a spinner instead of errored, so the
  // status-driven auto-retry above can't reach it. When connectivity
  // comes back, kick a fresh `replaceAsync` if we never produced a
  // first frame. A fully-loaded video that's just paused / finished
  // stays untouched (`hasFirstFrame` already true).
  const {isInternetReachable} = useNetInfo();
  const isOnline = isInternetReachable !== false;
  const wasOfflineRef = useRef(false);
  useEffect(() => {
    if (!isOnline) {
      wasOfflineRef.current = true;
      return;
    }
    if (!wasOfflineRef.current) return;
    wasOfflineRef.current = false;
    if (hasFirstFrame) return;
    // Reset the auto-retry counter + any latched error state so the
    // first frame after reconnect can actually paint.
    retryCountRef.current = 0;
    setHasError(false);
    setIsRetrying(true);
    reload();
  }, [isOnline, hasFirstFrame, reload]);

  const onRetryPress = useCallback(() => {
    retryCountRef.current = 0;
    setHasError(false);
    setIsRetrying(true);
    reload();
  }, [reload]);

  return (
    <View
      style={styles.videoContainer}
      accessibilityLabel={t('chat.files.video_one')}>
      {/* VideoView first so the toggle-Pressable sits ON TOP of it. On
        Android the native video surface absorbs touches before they
        propagate up to a parent Pressable, so wrapping doesn't work —
        a sibling overlay does. */}
      <VideoView
        ref={videoViewRef}
        player={player}
        surfaceType="textureView"
        style={StyleSheet.absoluteFill}
        contentFit="contain"
        nativeControls={isFullscreen}
        onFullscreenEnter={onFullscreenEnter}
        onFullscreenExit={onFullscreenExit}
        onFirstFrameRender={onFirstFrameRender}
      />
      {/* Poster placeholder. Rendered AFTER the VideoView so it sits on
        top of the (initially-black) native surface, BEFORE the
        Pressable so taps still reach the toggle. Identical component
        is also rendered by the `!player` branch above for inactive
        carousel pages, so swiping between pages keeps showing the
        poster across the active/inactive transition (ExpoImage's cache
        means the second mount paints instantly). */}
      {!hasFirstFrame && <ThumbnailPlaceholder thumbnailUrl={thumbnailUrl} />}
      <Pressable onPress={onTapSurface} style={StyleSheet.absoluteFill} />
      {hasError ? (
        <View style={styles.overlay} pointerEvents="box-none">
          <Button
            IconRight={IconDownload}
            text={t('general.download')}
            onPress={onRetryPress}
            variant="primary"
            size="lg"
          />
        </View>
      ) : isFullscreen ? null : (
        <Controls
          player={player}
          onFullscreen={onEnterFullscreen}
          visible={showControls}
        />
      )}

      {!isPlaying && !bigPlayDismissed && !hasError && !hasEverPlayed && (
        <View style={styles.bigOverlayWrapper} pointerEvents="box-none">
          {isLoading ? (
            // Loading mode (autoplay, user tap, or a background
            // retry). The spinner itself only paints after the
            // `LOADING_SPINNER_DELAY_MS` grace so fast loads don't
            // flash a centre-of-screen spinner. During the grace
            // window we render nothing — the poster placeholder
            // already conveys "this is a video, loading".
            showLoadingSpinner ? (
              <View style={styles.bigLoadingCircle} pointerEvents="none">
                <Spinner />
              </View>
            ) : null
          ) : (
            <Pressable
              onPress={onPressBigPlay}
              style={styles.bigPlayPressable}
              accessibilityRole="button"
              accessibilityLabel={t('attachments.play')}>
              <IconPlayVideo size="lg" />
            </Pressable>
          )}
        </View>
      )}
    </View>
  );
}

/**
 * Plays + unmutes the player when `isActive` flips true; pauses,
 * rewinds, and re-mutes when it flips false. Cleanup also resets so
 * the pool's release call finds the player in a clean state.
 *
 * The active page also gets an aggressive buffer config so expo-video
 * fetches as much of the video as it can while the user watches —
 * which seeds the disk cache for fast re-opens AND means short clips
 * (most chat videos) end up fully downloaded once you've watched them
 * through. The pool's `resetPlayer` shrinks the window back down once
 * the slot goes idle so off-screen tiles don't hog the network.
 */
/** Effectively "buffer the whole video" for any clip shorter than an
 *  hour — covers all realistic chat videos. */
const GALLERY_ACTIVE_BUFFER_SECONDS = 3600;
/** Android-only ceiling. 500 MB is well above any reasonable chat
 *  video size, so the time-based limit above is the one that actually
 *  fires. */
const GALLERY_ACTIVE_MAX_BUFFER_BYTES = 500 * 1024 * 1024;

function useActivePlayback(
  player: VideoPlayerInstance,
  isActive: boolean,
  // `false` for non-autoplay videos that the user hasn't tapped to
  // start yet — we still configure buffer + audio on `isActive` so a
  // subsequent tap starts instantly, but skip the play call.
  shouldPlay: boolean,
): void {
  useEffect(() => {
    try {
      if (isActive) {
        player.bufferOptions = {
          preferredForwardBufferDuration: GALLERY_ACTIVE_BUFFER_SECONDS,
          maxBufferBytes: GALLERY_ACTIVE_MAX_BUFFER_BYTES,
          prioritizeTimeOverSizeThreshold: true,
        };
        player.timeUpdateEventInterval = TIME_UPDATE_INTERVAL;
        player.volume = 1;
        player.muted = false;
        if (shouldPlay) {
          // `replay()` is expo-video's canonical seek-to-start + play.
          // We use it here instead of bare `play()` so the initial
          // video autoplays reliably every time the page becomes
          // active again — including after `playToEnd` has parked
          // the playhead at duration on iOS (where a stray `play()`
          // is a no-op in the "ended" state) and after the cleanup
          // below has reset `currentTime` to 0. One canonical call
          // covers all cases.
          player.replay();
        }
      } else {
        player.pause();
        player.currentTime = 0;
      }
    } catch {
      /* player in transient state */
    }
    return () => {
      try {
        player.pause();
        player.muted = true;
        player.volume = 0;
        player.currentTime = 0;
      } catch {
        /* player in transient state */
      }
    };
  }, [player, isActive, shouldPlay]);
}
