import {
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from 'react';
import Debug from 'debug';

import type { CSSProperties, FC, RefObject } from 'react';
import type { TransitionInOut2Api } from '../TransitionInOut2';
import type { YoutubePlayerEvent, YouTubePlayerInfo } from './types';

import { isIos, isMobileBrowser } from '~/lib/device/utils';
import { isTestEnv } from '~/lib/getSongwhipEnv';
import { tryParseJson } from '~/lib/utils/object';
import { on } from '~/src/lib/utils/events';
import uuid from '~/src/lib/utils/uuid';
import wait from '~/src/lib/utils/wait';
import AspectBox from '../AspectBox';
import { pushLoaderItem } from './loaderQueue';
import { YouTubePlayerStates } from './types';

const _debug = Debug('songwhip/ChromelessYouTubeVideo');

export interface ChromelessYouTubeVideoApi {
  mute: () => void;
  unmute: () => void;
  isMuted: () => boolean;
  play: () => void;
  pause: () => void;
  hideControls: () => void;
  showControls: () => void;
}

export type YouTubeVideoInfo = Partial<YouTubePlayerInfo>;

export interface ChromelessYouTubeVideoProps {
  apiRef?: RefObject<ChromelessYouTubeVideoApi | null>;
  videoId: string;
  aspect: number;
  playOnLoad?: boolean;
  containerAspect: number;
  width?: string;
  height?: string;
  loop?: boolean;
  muted?: boolean;
  withControls?: boolean;
  instanceId?: string;
  shouldLoad?: boolean;
  onReady?: (params: { info: YouTubeVideoInfo }) => void;
  onStart?: (params: {
    info: YouTubeVideoInfo;
    isFirstLoop: boolean;
    instanceId: string;
  }) => void;

  /**
   * When the video is paused by the user from within the player iframe
   */
  onUserPause?: () => void;

  /**
   * When the video is played by the user from within the player iframe
   */
  onUserPlay?: () => void;

  onStop?: (params: { info: YouTubeVideoInfo }) => void;
  onEnd?: (params: { info: YouTubeVideoInfo; isFirstLoop: boolean }) => void;
  onMutedChange?: (params: { muted: boolean }) => void;
  onTimeUpdate?: (params: {
    elapsedSecs: number;
    durationSecs: number;
    isAtEnd: boolean;
    isFirstLoop: boolean;
    info: YouTubeVideoInfo;
  }) => void;
  onClick?: () => void;
  onError?: (code: string) => void;
  style?: CSSProperties;
}

const NOOP = () => {};

/**
 * TODO:
 *
 * - Infer a short from hashtags in the title and a playtime of < 60 secs and adjust iframe aspect.
 *   This means that when we hide controls we won't see the video scale up/down. The `size` is
 *   also exposed in the playerInfo, need to confirm this is always correct.
 */
const ChromelessYouTubeVideo: FC<ChromelessYouTubeVideoProps> = ({
  apiRef,
  videoId,
  aspect,
  playOnLoad,
  containerAspect,
  loop,
  muted,
  onReady = NOOP,
  onStart = NOOP,
  onStop = NOOP,
  onEnd = NOOP,
  onMutedChange,
  onUserPause,
  onUserPlay,
  onTimeUpdate = NOOP,
  onError = NOOP,
  withControls,
  instanceId: customInstanceId,
  shouldLoad = true,
  style,
}) => {
  const rootElRef = useRef<HTMLDivElement>(null);
  const iframeRef = useRef<HTMLIFrameElement>(null);
  const instanceId = useMemo(() => customInstanceId ?? uuid(), [videoId]);
  const transitionApiRef = useRef<TransitionInOut2Api>(null);
  const isPlayingRef = useRef(false);
  const playerInfoRef = useRef<Partial<YouTubePlayerInfo>>(null);
  const [controlsHidden, setControlsHidden] = useState(false);
  const isReadyRef = useRef(false);
  const playOnLoadRef = useRef(playOnLoad);
  const pendingPauseRef = useRef(false);
  const pendingPlayRef = useRef(false);
  const debug = _debug.extend(videoId);

  // We initially mute all videos on mobile devices. This is because some browsers
  // (eg. ios safari) will not auto play when unmuted, videos can only be unmuted
  // upon a trusted user interaction (eg. 'click').
  // REVIEW: This is subpar UX when loading and unloading videos as the user has to
  // unmute each time. A possible workaround for this might be to keep the youtube
  // iframe in the dom and when the second video is played reuse that iframe and
  // load the new video into the old player if youtube uses the same underlying <video>
  // ios might allow it to play unmuted. According to this thread iframe must be kept
  // in the document (not moved or detached) in order to preserve their state. This means
  // we'll actually need to keep the
  // https://stackoverflow.com/questions/8318264/how-to-move-an-iframe-in-the-dom-without-losing-its-state
  const isInitiallyMuted = muted ?? isIos(navigator.userAgent);

  const getPlayerInfo = () =>
    playerInfoRef.current || ({} as Partial<YouTubePlayerInfo>);

  const setPlayerInfo = (info: Partial<YouTubePlayerInfo>) => {
    playerInfoRef.current = {
      ...playerInfoRef.current,
      ...info,
    };
  };

  const getIsPlaying = () =>
    getPlayerInfo().playerState === YouTubePlayerStates.PLAYING;

  const messageIframe = (params: any) => {
    const iframe = iframeRef.current;
    if (!iframe) return;

    const payload = {
      id: instanceId,
      channel: 'widget',
      ...params,
    };

    iframe.contentWindow?.postMessage(JSON.stringify(payload), '*');
  };

  useEffect(() => {
    // HACK: When GoogleTagManager is run on the page it messes with our youtube
    // iframe breaking the messaging and loading lots of youtube sdk scripts.
    // If we define a fake YT global it seems not to not bother loading anything.
    // The downside here is that GTM won't auto report video analytics, but we should
    // be providing that insight via songwhip anyway. There might be a way we can trigger
    // the correct gtm events if marketers need this.
    // @ts-expect-error
    window.YT = {};

    // desktop browsers we hide the controls initially and show them on mouse enter
    // this means when the video first starts playing the controls aren't visible
    // which gives a much cleaner/unbranded UX. But the controls are still visible
    // instantly when the user hover over the video.
    if (!isMobileBrowser(navigator.userAgent)) {
      if (!rootElRef.current) return;

      setControlsHidden(true);

      // show the controls when mouse over
      return on(rootElRef.current, 'mouseenter', () => {
        setControlsHidden(false);
      });
    }
  }, []);

  const playerApi = {
    play() {
      debug('play', {
        videoId,
        instanceId,
      });

      messageIframe({
        event: 'command',
        func: 'playVideo',
      });

      pendingPlayRef.current = true;
    },

    pause() {
      debug('pause', {
        videoId,
        instanceId,
      });

      messageIframe({
        event: 'command',
        func: 'pauseVideo',
      });

      pendingPauseRef.current = true;
    },

    mute() {
      debug('mute');

      messageIframe({
        event: 'command',
        func: 'mute',
      });
    },

    unmute() {
      debug('unmute');

      messageIframe({
        event: 'command',
        func: 'unMute',
      });
    },

    seekTo(secs: number) {
      debug('seek to', secs);

      messageIframe({
        event: 'command',
        func: 'seekTo',
        args: [secs],
      });
    },

    /**
     * Only works on desktop
     */
    wakeUpControls() {
      debug('wakeup controls');

      messageIframe({
        event: 'command',
        func: 'wakeUpControls',
        args: [],
      });
    },
  };

  const publicApi = {
    pause() {
      debug('pause');

      const isPlaying = getIsPlaying();

      if (!isPlaying) {
        debug('noop: not playing');
        return;
      }

      // if we get a pause instruction before the plater is ready
      // then we need to ensure it does not play when it becomes ready
      if (!isReadyRef.current) {
        debug('player not ready: will now not play on ready');
        playOnLoadRef.current = false;
      }

      playerApi.pause();
    },

    play() {
      const isPlaying = getIsPlaying();

      if (isPlaying) {
        debug('noop: already playing');
        return;
      }

      // if we get a play instruction before the plater is ready
      // then we need to ensure it plays when it becomes ready
      if (!isReadyRef.current) {
        debug('player not ready: will play on ready');
        playOnLoadRef.current = true;
      }

      playerApi.play();
    },

    hideControls() {
      debug('hide controls');
      setControlsHidden(true);
    },

    showControls() {
      debug('show controls');
      setControlsHidden(false);
    },

    mute() {
      if (!publicApi.isMuted()) {
        debug('mute');
        playerApi.mute();
      }
    },

    unmute() {
      if (publicApi.isMuted()) {
        debug('unmute');
        playerApi.unmute();
      }
    },

    isMuted() {
      return !!getPlayerInfo().muted;
    },
  };

  useImperativeHandle(apiRef, () => {
    return publicApi;
  });

  useEffect(() => {
    const iframeEl = iframeRef.current;

    if (!iframeEl) {
      return;
    }

    if (!shouldLoad) {
      return;
    }

    let isFirstLoop = true;
    let interval;

    const src = `https://www.youtube-nocookie.com/embed/${videoId}?${new URLSearchParams(
      {
        // setting the player to autoplay can mean the playstate
        // is set to playing on `initialDelivery` and no `onStateChange`
        // event is fired before the video starts
        autoplay: playOnLoad ? '1' : '0',

        showinfo: '0',
        autohide: '1',
        controls: withControls ? '1' : '0',
        mute: isInitiallyMuted ? '1' : '0',
        modestbranding: '1',
        enablejsapi: '1',
        origin: location.origin,
        widgetid: instanceId,

        // only show "related videos" from the same channel
        rel: '0',
      }
    )}`;

    const loadManager = pushLoaderItem(() => {
      debug('load iframe', { videoId });
      iframeEl.src = src;
    });

    const offIframeLoad = on(iframeEl, 'load', () => {
      debug('iframe loaded', videoId);
      loadManager.done();

      interval = setInterval(() => {
        messageIframe({ event: 'listening' });
      }, 250);
    });

    const offWindowMessage = on(
      window,
      'message',
      async (event: MessageEvent) => {
        const data = toYouTubePlayerEvent(event.data);
        if (!data) return;

        // only handle events for this instance
        if (data.id !== instanceId) {
          return;
        }

        switch (data.event) {
          case 'initialDelivery':
            {
              clearInterval(interval);

              const info = data.info;
              const error = info.videoData?.errorCode;
              debug('on initial delivery', info);
              debug('got size', info.size);

              setPlayerInfo(info);

              if (error) {
                onError(error);
                return;
              }

              messageIframe({
                event: 'command',
                func: 'addEventListener',
                args: ['onReady'],
              });

              // required to get onStateChange events
              messageIframe({
                event: 'command',
                func: 'addEventListener',
                args: ['onStateChange'],
              });
            }

            break;

          case 'onReady':
            {
              debug('on ready', videoId, data);

              if (playOnLoadRef.current && !getIsPlaying()) {
                debug('play video');
                playerApi.play();
              } else if (getIsPlaying()) {
                debug('video already playing');
                onVideoPlaying();
              }

              isReadyRef.current = true;

              onReady({
                info: getPlayerInfo(),
              });
            }

            break;

          case 'infoDelivery':
            {
              const info = data.info;
              const error = info.videoData?.errorCode;
              const newMuted = info.muted;
              const oldMuted = getPlayerInfo().muted;

              const mutedChanged =
                newMuted !== undefined && newMuted !== oldMuted;

              if (error) {
                onError(error);
                return;
              }

              setPlayerInfo(info);
              const { currentTime } = info;

              if (mutedChanged) {
                debug('muted change', {
                  newMuted,
                  oldMuted,
                });

                onMutedChange?.({
                  muted: newMuted,
                });
              }

              if (currentTime) {
                // currentTime is emitted very frequently,
                // but we only care about whole second changes.
                const elapsedSecs = Math.floor(currentTime);

                const durationSecs = Math.floor(getPlayerInfo().duration || 0);
                if (!durationSecs) return;

                // we have to loop before the actual video ends to avoid yt player
                // showing suggested videos and blocking our request to loop
                const isAtEnd = elapsedSecs >= durationSecs - 1;

                onTimeUpdate({
                  elapsedSecs,
                  durationSecs,
                  isAtEnd,
                  isFirstLoop,
                  info: getPlayerInfo(),
                });

                if (isAtEnd && getIsPlaying()) {
                  onVideoEnd();
                }
              }
            }

            break;

          case 'onStateChange':
            {
              const state = data.info;
              const rootEl = rootElRef.current;

              // set the play state on the root element for testing purposes
              // as we have no way of reading the iframe state directly from cypress
              if (rootEl && isTestEnv()) {
                rootEl.dataset.playerState = String(state);
              }

              debug('on state change', { state });

              switch (state) {
                case YouTubePlayerStates.PLAYING:
                  onVideoPlaying();
                  break;

                case YouTubePlayerStates.PAUSED:
                  if (!pendingPauseRef.current) {
                    onUserPause?.();
                  }

                  pendingPauseRef.current = false;

                  if (getIsPlaying()) {
                    return;
                  }

                  onStop?.({
                    info: getPlayerInfo(),
                  });

                  break;

                case YouTubePlayerStates.ENDED:
                  if (isPlayingRef.current) {
                    onVideoEnd();
                  }

                  break;
              }
            }

            break;
        }
      }
    );

    const onVideoPlaying = async () => {
      debug('on video start', getPlayerInfo());

      transitionApiRef.current?.setVisible(true);

      // Mobile browsers don't have controls hidden by default as autoplay videos don't seem
      // to show the controls initially. But we need to show them in the case when the video is
      // tapped and controls should become visible. We also hide the controls whenever video
      // is paused (to avoid seeing "More videos" overlay), so when the video starts playing
      // again we must re-show the controls. 3 seconds is the time it take for youtube to
      // hide the controls overlay after playing resumes.
      if (isMobileBrowser(navigator.userAgent)) {
        wait(3000).then(() => {
          if (getIsPlaying()) {
            setControlsHidden(false);
          }
        });
      }

      if (!pendingPlayRef.current) {
        onUserPlay?.();
      }

      pendingPlayRef.current = false;

      onStart({
        info: getPlayerInfo(),
        isFirstLoop,
        instanceId,
      });
    };

    const onVideoEnd = () => {
      debug('on video end', { loop });
      isPlayingRef.current = false;

      onEnd({
        info: getPlayerInfo(),
        isFirstLoop,
      });

      if (loop) {
        isFirstLoop = false;
        playerApi.seekTo(0);
        playerApi.play();
      }
    };

    return () => {
      offIframeLoad();
      offWindowMessage();

      // cancel if iframe load is still pending
      loadManager.cancel();
    };
  }, [videoId, shouldLoad]);

  return (
    <div
      className="root"
      ref={rootElRef}
      id={instanceId}
      data-video-id={videoId}
      data-testid="youtube-video-player"
      onMouseEnter={() => setControlsHidden(false)}
      style={{
        ...style,
        position: 'relative',
        overflow: 'hidden',
        width: '100%',
        height: '100%',
      }}
    >
      <AspectBox containerAspect={containerAspect} aspect={aspect}>
        <div
          className="inner"
          style={{
            position: 'absolute',
            left: 0,
            top: controlsHidden ? '-150%' : 0,
            height: controlsHidden ? '400%' : '100%',
            width: '100%',
          }}
        >
          <iframe
            ref={iframeRef}
            allowFullScreen
            data-testid="youtubeIframe"
            allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture;"
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              right: 0,
              bottom: 0,
              border: 0,
              margin: 'auto',
              minWidth: '50%',
              width: '100.2%',
              height: '100.2%',
            }}
          />
        </div>
      </AspectBox>
    </div>
  );
};

const toYouTubePlayerEvent = (data: string) => {
  const parsed = tryParseJson<unknown>(data);
  if (!parsed) return;

  if (typeof parsed === 'object' && 'channel' in parsed) {
    return parsed as YoutubePlayerEvent;
  }
};

export default ChromelessYouTubeVideo;
