import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {Pressable, StyleSheet, Text, View} from 'react-native';
import {useEventListener} from 'expo';
import throttle from 'lodash.throttle';
import {
  IconArrowsMaximize,
  IconPlayerPauseFilled,
  IconPlayerPlayFilled,
} from '@tabler/icons-react-native';
import {VideoPlayer as VideoPlayerInstance} from 'expo-video';
import {LinearGradient} from 'expo-linear-gradient';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

import {Scrubber} from './Scrubber';
import {
  BOTTOM_GRADIENT_COLORS,
  controlsStyles as styles,
  TOP_GRADIENT_COLORS,
} from './styles';

interface Props {
  player: VideoPlayerInstance;
  /** Called when the user taps the fullscreen button. The parent owns
   *  the actual `enterFullscreen()` call + the `nativeControls` toggle
   *  needed for the fullscreen UI to show platform controls. */
  onFullscreen: () => void;
  /** Toggled by the parent in response to taps on the video. Animated
   *  opacity + pointerEvents so a hidden bar can't intercept touches. */
  visible: boolean;
}

/** Loose tolerance while dragging — expo-video can return faster from
 *  imprecise seeks, which is what we want for a real-time scrub preview.
 *  Reset to precise (0/0) on commit so the final frame matches the user's
 *  intent exactly. */
const SCRUB_TOLERANCE_SECONDS = 0.5;
/** Throttle live `currentTime` writes during a drag so we don't queue
 *  more seeks than the player can honor — 80 ms is ~12 dispatches/s,
 *  smooth enough to feel responsive without flooding the native side. */
const SCRUB_DISPATCH_MS = 80;
/** Tolerance for treating the playhead as "at end" so play-after-end
 *  reliably triggers a rewind. */
const END_EPSILON = 0.1;

const formatTime = (s: number): string => {
  if (!Number.isFinite(s) || s < 0) return '0:00';
  const total = Math.floor(s);
  const m = Math.floor(total / 60);
  const sec = total % 60;
  return `${m}:${sec.toString().padStart(2, '0')}`;
};

export function Controls({player, onFullscreen, visible}: Props) {
  // Local mirrors of player state so manual seeks (playToEnd reset,
  // tap-to-play after end, etc.) reflect in the UI immediately rather
  // than waiting for the next `timeUpdate` tick.
  const [isPlaying, setIsPlaying] = useState(player.playing);
  const [currentTime, setCurrentTime] = useState(player.currentTime);
  const [bufferedPosition, setBufferedPosition] = useState(
    player.bufferedPosition,
  );

  // When playback ends, AVPlayer / ExoPlayer typically fire BOTH a
  // `playToEnd` event AND a trailing `timeUpdate` reporting
  // `currentTime ≈ duration`. The latter can land *after* our rewind,
  // overwriting the state we just set back to 0 — visible as the
  // scrubber thumb staying parked at the end of the bar. The ref below
  // suppresses trailing near-end ticks for a short window after the
  // rewind so our state actually sticks.
  const justRewoundRef = useRef(false);

  // Live mirror of the player's `currentTime` from the most recent
  // `timeUpdate`. We read THIS instead of `player.currentTime` in event
  // handlers because the native getter has been observed to lag (or
  // momentarily return 0) at the exact moment `playingChange` fires
  // for end-of-playback, which made the "is the player at end?" check
  // fail and skip the rewind.
  const lastCurrentTimeRef = useRef(0);
  // Same idea for duration — captured once on `timeUpdate` since
  // expo-video doesn't fire a dedicated event when duration first
  // becomes known.
  const lastDurationRef = useRef(0);

  // Rewind the player back to start AND reset our UI state. Both halves
  // matter: setting `player.currentTime = 0` is what makes the video
  // surface show the first frame instead of the last one when playback
  // ends; `setCurrentTime(0)` is what makes the scrubber + time labels
  // reflect that. Pause first so the player doesn't fight us with a
  // queued play. (`togglePlay`'s `replay()` is the safety net for the
  // edge case where this path doesn't fire on a particular platform.)
  const onEndedRewind = useCallback(() => {
    try {
      player.pause();
      player.currentTime = 0;
    } catch {
      /* transient */
    }
    setCurrentTime(0);
    justRewoundRef.current = true;
    // Half a second is comfortably longer than the gap between the
    // trailing `timeUpdate` and `playToEnd` in any platform I've
    // observed, while short enough not to suppress real seeks the
    // user might trigger immediately after.
    setTimeout(() => {
      justRewoundRef.current = false;
    }, 500);
  }, [player]);

  useEventListener(player, 'playingChange', ({isPlaying: next}) => {
    setIsPlaying(next);
    // Defensive rewind: if the player just stopped AND the playhead
    // was at (or very near) the end on the last `timeUpdate`, treat
    // it as an end-of-playback transition. We use the refs here
    // because `player.currentTime` has been observed to return stale
    // values right when `playingChange` fires — only the latest
    // `timeUpdate` payload is trustworthy at that instant.
    if (
      !next &&
      lastDurationRef.current > 0 &&
      lastCurrentTimeRef.current >= lastDurationRef.current - END_EPSILON
    ) {
      onEndedRewind();
    }
  });

  useEventListener(
    player,
    'timeUpdate',
    ({currentTime: ct, bufferedPosition: bp}) => {
      lastCurrentTimeRef.current = ct;
      lastDurationRef.current = player.duration || 0;
      // Drop trailing "near end" updates that race the rewind.
      if (justRewoundRef.current && ct > 0.5) return;
      setCurrentTime(ct);
      // Monotonically increase only. expo-video's `bufferedPosition`
      // transiently drops to 0 (or near it) after every seek + during
      // fullscreen transitions, even when the bytes are still on disk
      // — keeping the max we've ever seen for this player gives the
      // user a stable indicator that reflects what's actually buffered.
      setBufferedPosition(prev => Math.max(prev, bp));
    },
  );

  // Canonical end-of-playback signal. We don't always get this on
  // every platform (hence the `playingChange` fallback above), but
  // when we do, it's the most reliable trigger.
  useEventListener(player, 'playToEnd', onEndedRewind);

  // Fade in/out via Reanimated — `withTiming` on a SharedValue avoids
  // re-rendering the whole subtree on every animation frame.
  const opacitySv = useSharedValue(visible ? 1 : 0);
  useEffect(() => {
    opacitySv.value = withTiming(visible ? 1 : 0, {duration: 200});
  }, [visible, opacitySv]);
  const animatedStyle = useAnimatedStyle(() => ({opacity: opacitySv.value}));

  // `duration` is set when the source loads and doesn't change after,
  // so a plain ref-read on every render is fine — no event needed.
  const duration = player.duration;

  const togglePlay = useCallback(() => {
    try {
      if (player.playing) {
        player.pause();
        return;
      }
      // If the player is sitting at the end, the iOS AVPlayer is in
      // its "ended" state — direct `play()` is a no-op and assigning
      // `currentTime = 0` is silently dropped. `replay()` is the
      // canonical "seek to start + play" combo and works in that
      // state. We also reset the UI immediately + arm
      // `justRewoundRef` so the trailing-end `timeUpdate` from before
      // the seek doesn't punch the scrubber back to duration.
      if (
        lastDurationRef.current > 0 &&
        lastCurrentTimeRef.current >= lastDurationRef.current - END_EPSILON
      ) {
        player.replay();
        setCurrentTime(0);
        justRewoundRef.current = true;
        setTimeout(() => {
          justRewoundRef.current = false;
        }, 500);
        return;
      }
      player.play();
    } catch {
      /* transient native state during a source swap */
    }
  }, [player]);

  // Remember whether the player was already paused when the drag began
  // so we don't auto-resume a video the user had stopped on purpose.
  const wasPlayingBeforeSeekRef = useRef(false);

  const onSeekStart = useCallback(() => {
    wasPlayingBeforeSeekRef.current = player.playing;
    try {
      // expo-video's dedicated scrubbing mode: on iOS it loosens seek
      // scheduling so live updates are responsive; on Android it
      // suppresses playback for the duration so our seeks don't fight
      // the playhead. We pause on iOS to mirror Android's behavior.
      player.pause();
      player.scrubbingModeOptions = {scrubbingModeEnabled: true};
      player.seekTolerance = {
        toleranceBefore: SCRUB_TOLERANCE_SECONDS,
        toleranceAfter: SCRUB_TOLERANCE_SECONDS,
      };
    } catch {
      /* transient */
    }
  }, [player]);

  // Throttled live seek — keyed on `player`, so a player swap
  // (different page) flushes the previous queue.
  const onSeekUpdate = useMemo(
    () =>
      throttle(
        (t: number) => {
          try {
            player.currentTime = t;
            setCurrentTime(t);
          } catch {
            /* transient */
          }
        },
        SCRUB_DISPATCH_MS,
        {leading: true, trailing: true},
      ),
    [player],
  );

  const onSeek = useCallback(
    (t: number) => {
      onSeekUpdate.cancel();
      try {
        // Restore precise seeks + normal playback before committing
        // the final position. The order matters: disable scrubbing
        // mode first, otherwise iOS may treat the commit as a
        // continuation of the drag and round it.
        player.scrubbingModeOptions = {scrubbingModeEnabled: false};
        player.seekTolerance = {toleranceBefore: 0, toleranceAfter: 0};
        player.currentTime = t;
        setCurrentTime(t);
        if (wasPlayingBeforeSeekRef.current) player.play();
      } catch {
        /* transient */
      }
    },
    [player, onSeekUpdate],
  );

  const remaining = Math.max(0, (duration || 0) - currentTime);

  return (
    <Animated.View
      style={[StyleSheet.absoluteFill, animatedStyle]}
      pointerEvents={visible ? 'box-none' : 'none'}>
      <LinearGradient
        colors={TOP_GRADIENT_COLORS}
        style={styles.topGradient}
        pointerEvents="box-none">
        <Pressable
          onPress={onFullscreen}
          hitSlop={12}
          style={styles.fullscreenButton}>
          <IconArrowsMaximize size={20} color="white" />
        </Pressable>
      </LinearGradient>
      <LinearGradient
        colors={BOTTOM_GRADIENT_COLORS}
        style={styles.bottomGradient}
        pointerEvents="box-none">
        <View style={styles.bottomBar} pointerEvents="box-none">
          <Pressable
            onPress={togglePlay}
            hitSlop={12}
            style={styles.playPauseButton}>
            {isPlaying ? (
              <IconPlayerPauseFilled size={20} color="white" />
            ) : (
              <IconPlayerPlayFilled size={20} color="white" />
            )}
          </Pressable>
          <Text style={styles.timeText}>{formatTime(currentTime)}</Text>
          <Scrubber
            currentTime={currentTime}
            duration={duration || 0}
            bufferedPosition={bufferedPosition}
            onSeekStart={onSeekStart}
            onSeekUpdate={onSeekUpdate}
            onSeek={onSeek}
          />
          <Text style={styles.timeText}>-{formatTime(remaining)}</Text>
        </View>
      </LinearGradient>
    </Animated.View>
  );
}
