import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import { StyleSheet } from 'react-native';

import type VideoPlayer from './VideoPlayer.web';
import { getSourceUri } from './VideoPlayer.web';
import type { VideoViewProps } from './VideoView.types';

type FullscreenChangeListeners = {
  default: () => void;
  safariEnter: () => void;
  safariExit: () => void;
  msListener: () => void;
};

function createAudioContext(): AudioContext | null {
  return typeof window !== 'undefined' ? new window.AudioContext() : null;
}

function createZeroGainNode(audioContext: AudioContext | null): GainNode | null {
  const zeroGainNode = audioContext?.createGain() ?? null;

  if (audioContext && zeroGainNode) {
    zeroGainNode.gain.value = 0;
    zeroGainNode.connect(audioContext.destination);
  }
  return zeroGainNode;
}

function mapStyles(style: VideoViewProps['style']): React.CSSProperties {
  const flattenedStyles = StyleSheet.flatten(style);
  // Looking through react-native-web source code they also just pass styles directly without further conversions, so it's just a cast.
  return flattenedStyles as React.CSSProperties;
}

export function isPictureInPictureSupported(): boolean {
  return typeof document === 'object' && typeof document.exitPictureInPicture === 'function';
}

export const VideoView = forwardRef((props: { player?: VideoPlayer } & VideoViewProps, ref) => {
  const videoRef = useRef<null | HTMLVideoElement>(null);
  const mediaNodeRef = useRef<null | MediaElementAudioSourceNode>(null);
  const hasToSetupAudioContext = useRef(false);
  const fullscreenChangeListeners = useRef<null | FullscreenChangeListeners>(null);
  const isWaitingForFirstFrame = useRef(false);
  const mountedPlayerRef = useRef<null | VideoPlayer>(null);

  /**
   * Audio context is used to mute all but one video when multiple video views are playing from one player simultaneously.
   * Using audio context nodes allows muting videos without displaying the mute icon in the video player.
   * We have to keep the context that called createMediaElementSource(videoRef), as the method can't be called
   * for the second time with another context and there is no way to unbind the video and audio context afterward.
   */
  const audioContextRef = useRef<null | AudioContext>(null);
  const zeroGainNodeRef = useRef<null | GainNode>(null);

  useEffect(() => {
    if (props.useAudioNodePlayback) {
      maybeSetupAudioContext();
      attachAudioNodes();
    } else {
      detachAudioNodes();
    }
  }, [props.useAudioNodePlayback]);

  useImperativeHandle(ref, () => ({
    enterFullscreen: async () => {
      // Cast the video to any to avoid ts errors. Methods such as webkitRequestFullscreen,
      // webkitEnterFullScreen, msRequestFullscreen are not typed even though they exist.
      const video = videoRef.current as any;

      if (video.requestFullscreen) {
        await video.requestFullscreen();
      } else if (video.webkitRequestFullscreen) {
        // @ts-ignore webkitRequestFullscreen can exist on Apple devices
        await video.webkitRequestFullscreen();
      } else if (video.webkitEnterFullScreen) {
        await video.webkitEnterFullScreen();
      } else if (video.msRequestFullscreen) {
        await video.msRequestFullscreen();
      }
    },
    exitFullscreen: async () => {
      await document.exitFullscreen();
    },
    startPictureInPicture: async () => {
      await videoRef.current?.requestPictureInPicture();
    },
    stopPictureInPicture: async () => {
      try {
        await document.exitPictureInPicture();
      } catch (e) {
        if (e instanceof DOMException && e.name === 'InvalidStateError') {
          console.warn('The VideoView is not in Picture-in-Picture mode.');
        } else {
          throw e;
        }
      }
    },
    nativeRef: videoRef,
  }));

  useEffect(() => {
    const onEnter = () => {
      props.onPictureInPictureStart?.();
    };
    const onLeave = () => {
      props.onPictureInPictureStop?.();
    };
    const onLoadStart = () => {
      isWaitingForFirstFrame.current = true;
    };
    const onCanPlay = () => {
      if (isWaitingForFirstFrame.current) {
        props.onFirstFrameRender?.();
      }
      isWaitingForFirstFrame.current = false;
    };
    videoRef.current?.addEventListener('enterpictureinpicture', onEnter);
    videoRef.current?.addEventListener('leavepictureinpicture', onLeave);
    videoRef.current?.addEventListener('loadstart', onLoadStart);
    videoRef.current?.addEventListener('loadeddata', onCanPlay);

    return () => {
      videoRef.current?.removeEventListener('enterpictureinpicture', onEnter);
      videoRef.current?.removeEventListener('leavepictureinpicture', onLeave);
      videoRef.current?.removeEventListener('loadstart', onLoadStart);
      videoRef.current?.removeEventListener('loadeddata', onCanPlay);
    };
  }, [videoRef, props.onPictureInPictureStop, props.onPictureInPictureStart]);

  // Adds the video view as a candidate for being the audio source for the player (when multiple views play from one
  // player only one will emit audio).
  function attachAudioNodes() {
    if (!props.useAudioNodePlayback) {
      return;
    }
    const audioContext = audioContextRef.current;
    const zeroGainNode = zeroGainNodeRef.current;
    const mediaNode = mediaNodeRef.current;

    if (audioContext && zeroGainNode && mediaNode) {
      props.player?.mountAudioNode(audioContext, zeroGainNode, mediaNode);
    } else {
      console.warn(
        "Couldn't mount audio node, this might affect the audio playback when using multiple video views with the same player."
      );
    }
  }

  function detachAudioNodes() {
    if (!props.useAudioNodePlayback) {
      return;
    }
    const audioContext = audioContextRef.current;
    const mediaNode = mediaNodeRef.current;
    if (audioContext && mediaNode && videoRef.current) {
      props.player?.unmountAudioNode(videoRef.current, audioContext, mediaNode);
    }
  }

  function maybeSetupAudioContext() {
    // Not all browsers support the UserActivation API, so check it exists before we access it.
    // If the API doesn't exist then we'll continue as if the user has been active.
    const userHasNotBeenActive =
      'userActivation' in navigator && !navigator.userActivation.hasBeenActive;

    if (
      !hasToSetupAudioContext.current ||
      userHasNotBeenActive ||
      !videoRef.current ||
      !props.useAudioNodePlayback
    ) {
      return;
    }
    const audioContext = createAudioContext();

    detachAudioNodes();
    audioContextRef.current = audioContext;
    zeroGainNodeRef.current = createZeroGainNode(audioContextRef.current);
    mediaNodeRef.current = audioContext
      ? audioContext.createMediaElementSource(videoRef.current)
      : null;
    attachAudioNodes();
    hasToSetupAudioContext.current = false;
  }

  function fullscreenListener() {
    if (document.fullscreenElement === videoRef.current) {
      props.onFullscreenEnter?.();
    } else {
      props.onFullscreenExit?.();
    }
  }

  function setupFullscreenListener() {
    cleanupFullscreenListener();
    const video = videoRef.current;
    if (!video) return;
    const fullscreenListeners: FullscreenChangeListeners = {
      default: fullscreenListener,
      safariEnter: () => props.onFullscreenEnter?.(),
      safariExit: () => props.onFullscreenExit?.(),
      msListener: fullscreenListener,
    };

    fullscreenChangeListeners.current = fullscreenListeners;

    // Standard Fullscreen API
    video.addEventListener('fullscreenchange', fullscreenListeners.default);

    // Safari (webkit)
    video.addEventListener('webkitbeginfullscreen', fullscreenListeners.safariEnter);
    video.addEventListener('webkitendfullscreen', fullscreenListeners.safariExit);

    // IE11 (ms)
    document.addEventListener('MSFullscreenChange', fullscreenListeners.msListener);
  }

  function cleanupFullscreenListener() {
    const video = videoRef.current;
    if (!video || !fullscreenChangeListeners.current) return;

    video.removeEventListener('fullscreenchange', fullscreenChangeListeners.current.default);
    video.removeEventListener(
      'webkitbeginfullscreen',
      fullscreenChangeListeners.current?.safariEnter
    );
    video.removeEventListener('webkitendfullscreen', fullscreenChangeListeners.current.safariExit);
    document.removeEventListener(
      'MSFullscreenChange',
      fullscreenChangeListeners.current.msListener
    );
  }

  useEffect(() => {
    videoRef.current && mountedPlayerRef.current?.unmountVideoView(videoRef.current);

    if (videoRef.current) {
      props.player?.mountVideoView(videoRef.current);
    }
    setupFullscreenListener();
    attachAudioNodes();

    mountedPlayerRef.current = props.player ?? null;

    if (props.player == null) {
      videoRef.current?.removeAttribute('src');
      videoRef.current?.load();
    }

    return () => {
      if (videoRef.current) {
        props.player?.unmountVideoView(videoRef.current);
      }
      mountedPlayerRef.current = null;
      cleanupFullscreenListener();
      detachAudioNodes();
    };
  }, [props.player]);

  return (
    <video
      controls={props.nativeControls ?? true}
      controlsList={props.fullscreenOptions?.enable ? undefined : 'nofullscreen'}
      crossOrigin={props.crossOrigin}
      style={{
        ...mapStyles(props.style),
        objectFit: props.contentFit,
      }}
      onPlay={() => {
        maybeSetupAudioContext();
      }}
      // The player can autoplay when muted, unmuting by a user should create the audio context
      onVolumeChange={() => {
        maybeSetupAudioContext();
      }}
      ref={(newRef) => {
        // This is called with a null value before `player.unmountVideoView` is called,
        // we can't assign null to videoRef if we want to unmount it from the player.
        if (newRef && !newRef.isEqualNode(videoRef.current)) {
          videoRef.current = newRef;
          hasToSetupAudioContext.current = props.useAudioNodePlayback ?? false;
          maybeSetupAudioContext();
        }
      }}
      disablePictureInPicture={!props.allowsPictureInPicture}
      playsInline={props.playsInline}
      src={getSourceUri(props.player?.src) ?? undefined}
    />
  );
});

export default VideoView;
