/*
 * Copyright (C) 2022 Savoir-faire Linux Inc.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation; either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public
 * License along with this program.  If not, see
 * <https://www.gnu.org/licenses/>.
 */
import { CallAction, CallBegin, WebSocketMessageType } from 'jami-web-common';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';

import { createOptionalContext } from '../hooks/createOptionalContext';
import { ConversationMember } from '../models/conversation-member';
import { callTimeoutMs } from '../utils/constants';
import { AsyncSetState, SetState, WithChildren } from '../utils/utils';
import { useWebRtcManager } from '../webrtc/WebRtcManager';
import { useAuthContext } from './AuthProvider';
import { CallData, CallManagerContext } from './CallManagerProvider';
import ConditionalContextProvider from './ConditionalContextProvider';
import { IWebSocketContext, useWebSocketContext } from './WebSocketProvider';

export type CallRole = 'caller' | 'receiver';

export enum CallStatus {
  Default,
  Loading,
  Ringing,
  Connecting,
  InCall,
  PermissionsDenied,
}

export enum VideoStatus {
  Off,
  Camera,
  ScreenShare,
}

type MediaDeviceIdState = {
  id: string | undefined;
  setId: (id: string | undefined) => void | Promise<void>;
};
type CurrentMediaDeviceIds = Record<MediaDeviceKind, MediaDeviceIdState>;

export type MediaDevicesInfo = Record<MediaDeviceKind, MediaDeviceInfo[]>;
export type MediaInputKind = 'audio' | 'video';
export type MediaInputIds = Record<MediaInputKind, string | false | undefined>;

export interface ICallContext {
  localStream: MediaStream | undefined;
  screenShareLocalStream: MediaStream | undefined;
  remoteStreams: readonly MediaStream[];

  mediaDevices: MediaDevicesInfo;
  currentMediaDeviceIds: CurrentMediaDeviceIds;

  isAudioOn: boolean;
  setIsAudioOn: SetState<boolean>;
  videoStatus: VideoStatus;
  updateVideoStatus: AsyncSetState<VideoStatus>;
  isChatShown: boolean;
  setIsChatShown: SetState<boolean>;
  isFullscreen: boolean;
  setIsFullscreen: SetState<boolean>;
  callRole: CallRole;
  callStatus: CallStatus;
  callStartTime: number | undefined;

  acceptCall: (withVideoOn: boolean) => void;
  endCall: () => void;
}

const optionalCallContext = createOptionalContext<ICallContext>('CallContext');
export const useCallContext = optionalCallContext.useOptionalContext;

export default ({ children }: WithChildren) => {
  const webSocket = useWebSocketContext();
  const { callMembers, callData, exitCall } = useContext(CallManagerContext);

  const dependencies = useMemo(
    () => ({
      webSocket,
      callMembers,
      callData,
      exitCall,
      conversationId: callData?.conversationId,
    }),
    [webSocket, callMembers, callData, exitCall]
  );

  return (
    <ConditionalContextProvider
      Context={optionalCallContext.Context}
      initialValue={undefined}
      dependencies={dependencies}
      useProviderValue={CallProvider}
    >
      {children}
    </ConditionalContextProvider>
  );
};

const CallProvider = ({
  callMembers,
  callData,
  exitCall,
  conversationId,
  webSocket,
}: {
  webSocket: IWebSocketContext;
  callMembers: ConversationMember[];
  callData: CallData;
  exitCall: () => void;
  conversationId: string;
}): ICallContext => {
  const [localStream, setLocalStream] = useState<MediaStream>();
  const [screenShareLocalStream, setScreenShareLocalStream] = useState<MediaStream>();
  const { account } = useAuthContext();
  const webRtcManager = useWebRtcManager();

  // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
  //       The client could make a single request with the conversationId, and the server would be tasked with sending
  //       all the individual requests to the members of the conversation.
  const contactUri = callMembers[0]?.contact.uri;
  const connectionInfos = webRtcManager.connectionsInfos[contactUri];
  const remoteStreams = connectionInfos?.remoteStreams;
  const iceConnectionState = connectionInfos?.iceConnectionState;

  const [mediaDevices, setMediaDevices] = useState<MediaDevicesInfo>({
    audioinput: [],
    audiooutput: [],
    videoinput: [],
  });
  const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
  const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
  const [videoDeviceId, setVideoDeviceId] = useState<string>();

  const [isAudioOn, setIsAudioOn] = useState(false);
  const [videoStatus, setVideoStatus] = useState(VideoStatus.Off);
  const [isChatShown, setIsChatShown] = useState(false);
  const [isFullscreen, setIsFullscreen] = useState(false);
  const [callStatus, setCallStatus] = useState(CallStatus.Default);
  const [callRole] = useState(callData?.role);
  const [callStartTime, setCallStartTime] = useState<number | undefined>(undefined);

  // TODO: Replace this by a callback
  useEffect(() => {
    if (callData.role === 'receiver' && contactUri && localStream) {
      webRtcManager.addConnection(webSocket, account, contactUri, callData, localStream, screenShareLocalStream);
    }
  }, [account, callData, contactUri, localStream, screenShareLocalStream, webRtcManager, webSocket]);

  const getMediaDevices = useCallback(async (): Promise<MediaDevicesInfo> => {
    try {
      const devices = await navigator.mediaDevices.enumerateDevices();

      // TODO: On Firefox, some devices can sometime be duplicated (2 devices can share the same deviceId). Using a map
      //       and then converting it to an array makes it so that there is no duplicate. If we find a way to prevent
      //       Firefox from listing 2 devices with the same deviceId, we can remove this logic.
      const newMediaDevices: Record<MediaDeviceKind, Record<string, MediaDeviceInfo>> = {
        audioinput: {},
        audiooutput: {},
        videoinput: {},
      };

      for (const device of devices) {
        newMediaDevices[device.kind][device.deviceId] = device;
      }

      return {
        audioinput: Object.values(newMediaDevices.audioinput),
        audiooutput: Object.values(newMediaDevices.audiooutput),
        videoinput: Object.values(newMediaDevices.videoinput),
      };
    } catch (e) {
      throw new Error('Could not get media devices', { cause: e });
    }
  }, []);

  const updateLocalStream = useCallback(
    async (mediaDeviceIds?: MediaInputIds) => {
      const devices = await getMediaDevices();

      let audioConstraint: MediaTrackConstraints | boolean = devices.audioinput.length !== 0;
      let videoConstraint: MediaTrackConstraints | boolean = devices.videoinput.length !== 0;

      if (!audioConstraint && !videoConstraint) {
        return;
      }

      if (mediaDeviceIds?.audio !== undefined) {
        audioConstraint = mediaDeviceIds.audio !== false ? { deviceId: mediaDeviceIds.audio } : false;
      }
      if (mediaDeviceIds?.video !== undefined) {
        videoConstraint = mediaDeviceIds.video !== false ? { deviceId: mediaDeviceIds.video } : false;
      }

      try {
        const stream = await navigator.mediaDevices.getUserMedia({
          audio: audioConstraint,
          video: videoConstraint,
        });

        for (const track of stream.getTracks()) {
          track.enabled = false;
        }

        setLocalStream(stream);
      } catch (e) {
        throw new Error('Could not get media devices', { cause: e });
      }
    },
    [getMediaDevices]
  );

  const updateScreenShare = useCallback(
    async (isOn: boolean) => {
      if (isOn) {
        const stream = await navigator.mediaDevices.getDisplayMedia({
          video: true,
          audio: false,
        });

        setScreenShareLocalStream(stream);
        return stream;
      } else {
        if (screenShareLocalStream) {
          for (const track of screenShareLocalStream.getTracks()) {
            track.stop();
          }
        }

        setScreenShareLocalStream(undefined);
      }
    },
    [screenShareLocalStream]
  );

  // TODO: Transform the effect into a callback
  const updateLocalStreams = webRtcManager.updateLocalStreams;
  useEffect(() => {
    if ((!localStream && !screenShareLocalStream) || !updateLocalStreams) {
      return;
    }

    updateLocalStreams(localStream, screenShareLocalStream);
  }, [localStream, screenShareLocalStream, updateLocalStreams]);

  const sendWebRtcOffer = useCallback(async () => {
    if (contactUri) {
      webRtcManager.addConnection(webSocket, account, contactUri, callData, localStream, screenShareLocalStream);
    }
  }, [account, callData, contactUri, localStream, screenShareLocalStream, webRtcManager, webSocket]);

  const closeConnection = useCallback(() => {
    const stopStream = (stream: MediaStream) => {
      const localTracks = stream.getTracks();
      if (localTracks) {
        for (const track of localTracks) {
          track.stop();
        }
      }
    };

    if (localStream) {
      stopStream(localStream);
    }
    if (screenShareLocalStream) {
      stopStream(screenShareLocalStream);
    }

    webRtcManager.clean();
  }, [localStream, screenShareLocalStream, webRtcManager]);

  useEffect(() => {
    if (callStatus !== CallStatus.InCall) {
      return;
    }

    const updateMediaDevices = async () => {
      try {
        const newMediaDevices = await getMediaDevices();

        if (newMediaDevices.audiooutput.length !== 0 && !audioOutputDeviceId) {
          setAudioOutputDeviceId(newMediaDevices.audiooutput[0].deviceId);
        }

        setMediaDevices(newMediaDevices);
      } catch (e) {
        console.error('Could not update media devices:', e);
      }
    };

    navigator.mediaDevices.addEventListener('devicechange', updateMediaDevices);
    updateMediaDevices();

    return () => {
      navigator.mediaDevices.removeEventListener('devicechange', updateMediaDevices);
    };
  }, [callStatus, getMediaDevices, audioOutputDeviceId]);

  useEffect(() => {
    if (localStream) {
      for (const track of localStream.getAudioTracks()) {
        track.enabled = isAudioOn;
        const deviceId = track.getSettings().deviceId;
        if (deviceId) {
          setAudioInputDeviceId(deviceId);
        }
      }
    }
  }, [isAudioOn, localStream]);

  useEffect(() => {
    if (localStream) {
      for (const track of localStream.getVideoTracks()) {
        track.enabled = videoStatus === VideoStatus.Camera;
        const deviceId = track.getSettings().deviceId;
        if (deviceId) {
          setVideoDeviceId(deviceId);
        }
      }
    }
  }, [videoStatus, localStream]);

  const updateVideoStatus = useCallback(
    async (newStatus: ((prevState: VideoStatus) => VideoStatus) | VideoStatus) => {
      if (typeof newStatus === 'function') {
        newStatus = newStatus(videoStatus);
      }

      const stream = await updateScreenShare(newStatus === VideoStatus.ScreenShare);
      if (stream) {
        for (const track of stream.getTracks()) {
          track.addEventListener('ended', () => {
            console.warn('Browser ended screen sharing');
            updateVideoStatus(VideoStatus.Off);
          });
        }
      }

      setVideoStatus(newStatus);
    },
    [videoStatus, updateScreenShare]
  );

  useEffect(() => {
    const onFullscreenChange = () => {
      setIsFullscreen(document.fullscreenElement !== null);
    };

    document.addEventListener('fullscreenchange', onFullscreenChange);
    return () => {
      document.removeEventListener('fullscreenchange', onFullscreenChange);
    };
  }, []);

  useEffect(() => {
    if (callRole === 'caller' && callStatus === CallStatus.Default) {
      const withVideoOn = callData?.withVideoOn ?? false;
      setCallStatus(CallStatus.Loading);
      updateLocalStream()
        .then(() => {
          const callBegin: CallBegin = {
            contactId: contactUri,
            conversationId,
            withVideoOn,
          };

          setCallStatus(CallStatus.Ringing);
          setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off);
          console.info('Sending CallBegin', callBegin);
          webSocket.send(WebSocketMessageType.CallBegin, callBegin);
        })
        .catch((e) => {
          console.error(e);
          setCallStatus(CallStatus.PermissionsDenied);
        });
    }
  }, [webSocket, updateLocalStream, callRole, callStatus, contactUri, conversationId, callData]);

  const acceptCall = useCallback(
    (withVideoOn: boolean) => {
      setCallStatus(CallStatus.Loading);
      updateLocalStream()
        .then(() => {
          const callAccept: CallAction = {
            contactId: contactUri,
            conversationId,
          };

          setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off);
          setCallStatus(CallStatus.Connecting);
          console.info('Sending CallAccept', callAccept);
          webSocket.send(WebSocketMessageType.CallAccept, callAccept);
        })
        .catch((e) => {
          console.error(e);
          setCallStatus(CallStatus.PermissionsDenied);
        });
    },
    [webSocket, updateLocalStream, contactUri, conversationId]
  );

  useEffect(() => {
    if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
      const callAcceptListener = (data: CallAction) => {
        console.info('Received event on CallAccept', data);
        if (data.conversationId !== conversationId) {
          console.warn('Wrong incoming conversationId, ignoring action');
          return;
        }

        setCallStatus(CallStatus.Connecting);

        sendWebRtcOffer();
      };

      webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);

      return () => {
        webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
      };
    }
  }, [callRole, webSocket, sendWebRtcOffer, callStatus, conversationId]);

  const endCall = useCallback(() => {
    const callEnd: CallAction = {
      contactId: contactUri,
      conversationId,
    };

    console.info('Sending CallEnd', callEnd);
    closeConnection();
    webSocket.send(WebSocketMessageType.CallEnd, callEnd);
    exitCall();
    // TODO: write in chat that the call ended
  }, [webSocket, contactUri, conversationId, closeConnection, exitCall]);

  useEffect(() => {
    const callEndListener = (data: CallAction) => {
      console.info('Received event on CallEnd', data);
      if (data.conversationId !== conversationId) {
        console.warn('Wrong incoming conversationId, ignoring action');
        return;
      }

      closeConnection();
      exitCall();
      // TODO: write in chat that the call ended
    };

    webSocket.bind(WebSocketMessageType.CallEnd, callEndListener);
    return () => {
      webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
    };
  }, [webSocket, exitCall, conversationId, closeConnection]);

  useEffect(() => {
    if (
      callStatus === CallStatus.Connecting &&
      (iceConnectionState === 'connected' || iceConnectionState === 'completed')
    ) {
      console.info('Changing call status to InCall');
      setCallStatus(CallStatus.InCall);
      setCallStartTime(Date.now());
    }
  }, [iceConnectionState, callStatus]);

  useEffect(() => {
    if (iceConnectionState === 'disconnected' || iceConnectionState === 'failed') {
      console.info('ICE connection disconnected or failed, ending call');
      endCall();
    }
  }, [iceConnectionState, callStatus, videoStatus, endCall]);

  useEffect(() => {
    const checkStatusTimeout = () => {
      if (callStatus !== CallStatus.InCall) {
        endCall();
      }
    };
    const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs);

    return () => {
      clearTimeout(timeoutId);
    };
  }, [callStatus, endCall]);

  const currentMediaDeviceIds: CurrentMediaDeviceIds = useMemo(() => {
    const createSetIdForDeviceKind = (mediaInputKind: MediaInputKind) => async (id: string | undefined) => {
      const mediaDeviceIds = {
        audio: audioInputDeviceId,
        video: videoDeviceId,
      };

      mediaDeviceIds[mediaInputKind] = id;

      await updateLocalStream(mediaDeviceIds);
    };

    return {
      audioinput: {
        id: audioInputDeviceId,
        setId: createSetIdForDeviceKind('audio'),
      },
      audiooutput: {
        id: audioOutputDeviceId,
        setId: setAudioOutputDeviceId,
      },
      videoinput: {
        id: videoDeviceId,
        setId: createSetIdForDeviceKind('video'),
      },
    };
  }, [updateLocalStream, audioInputDeviceId, audioOutputDeviceId, videoDeviceId]);

  return useMemo(
    () => ({
      localStream,
      screenShareLocalStream,
      remoteStreams,
      mediaDevices,
      currentMediaDeviceIds,
      isAudioOn,
      setIsAudioOn,
      videoStatus,
      updateVideoStatus,
      isChatShown,
      setIsChatShown,
      isFullscreen,
      setIsFullscreen,
      callRole,
      callStatus,
      callStartTime,
      acceptCall,
      endCall,
    }),
    [
      localStream,
      screenShareLocalStream,
      remoteStreams,
      mediaDevices,
      currentMediaDeviceIds,
      isAudioOn,
      videoStatus,
      updateVideoStatus,
      isChatShown,
      isFullscreen,
      callRole,
      callStatus,
      callStartTime,
      acceptCall,
      endCall,
    ]
  );
};
