import type {
	IVoipRoom,
	IUser,
	VoipEventDataSignature,
	ICallerInfo,
	ICallDetails,
	ILivechatVisitor,
	Serialized,
} from '@rocket.chat/core-typings';
import {
	VoipClientEvents,
	isVoipEventAgentCalled,
	isVoipEventAgentConnected,
	isVoipEventCallerJoined,
	isVoipEventQueueMemberAdded,
	isVoipEventQueueMemberRemoved,
	isVoipEventCallAbandoned,
	UserState,
} from '@rocket.chat/core-typings';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { Random } from '@rocket.chat/random';
import type { Device, IExperimentalHTMLAudioElement } from '@rocket.chat/ui-contexts';
import {
	useRoute,
	useUser,
	useSetting,
	useEndpoint,
	useStream,
	useSetOutputMediaDevice,
	useSetInputMediaDevice,
	useSetModal,
	useTranslation,
} from '@rocket.chat/ui-contexts';
// import { useRoute, useUser, useSetting, useEndpoint, useStream, useSetModal } from '@rocket.chat/ui-contexts';
import type { FC } from 'react';
import React, { useMemo, useRef, useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import type { OutgoingByeRequest } from 'sip.js/lib/core';

import { CustomSounds } from '../../../app/custom-sounds/client';
import { getUserPreference } from '../../../app/utils/client';
import { isOutboundClient, useVoipClient } from '../../../ee/client/hooks/useVoipClient';
import { parseOutboundPhoneNumber } from '../../../ee/client/lib/voip/parseOutboundPhoneNumber';
import { WrapUpCallModal } from '../../../ee/client/voip/components/modals/WrapUpCallModal';
import type { CallContextValue } from '../../contexts/CallContext';
import { CallContext, useIsVoipEnterprise } from '../../contexts/CallContext';
import { useDialModal } from '../../hooks/useDialModal';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import type { QueueAggregator } from '../../lib/voip/QueueAggregator';

type VoipSound = 'telephone' | 'outbound-call-ringing' | 'call-ended';

const startRingback = (user: IUser, soundId: VoipSound, loop = true): void => {
	const audioVolume = getUserPreference(user, 'notificationsSoundVolume', 100) as number;
	CustomSounds.play(soundId, {
		volume: Number((audioVolume / 100).toPrecision(2)),
		loop,
	});
};

const stopRingBackById = (soundId: VoipSound): void => {
	const sound = CustomSounds.getSound(soundId);
	CustomSounds.pause(soundId);
	CustomSounds.remove(sound);
};

const stopTelephoneRingback = (): void => stopRingBackById('telephone');
const stopOutboundCallRinging = (): void => stopRingBackById('outbound-call-ringing');

const stopAllRingback = (): void => {
	stopTelephoneRingback();
	stopOutboundCallRinging();
};

type NetworkState = 'online' | 'offline';

export const CallProvider: FC = ({ children }) => {
	const [clientState, setClientState] = useState<'registered' | 'unregistered'>('unregistered');

	const voipEnabled = useSetting('VoIP_Enabled');
	const subscribeToNotifyUser = useStream('notify-user');
	const dispatchEvent = useEndpoint('POST', '/v1/voip/events');
	const visitorEndpoint = useEndpoint('POST', '/v1/livechat/visitor');
	const voipEndpoint = useEndpoint('GET', '/v1/voip/room');
	const voipCloseRoomEndpoint = useEndpoint('POST', '/v1/voip/room.close');
	const getContactBy = useEndpoint('GET', '/v1/omnichannel/contact.search');
	const setModal = useSetModal();
	const t = useTranslation();

	const result = useVoipClient();
	const user = useUser();
	const homeRoute = useRoute('home');
	const setOutputMediaDevice = useSetOutputMediaDevice();
	const setInputMediaDevice = useSetInputMediaDevice();

	const hasVoIPEnterpriseLicense = useIsVoipEnterprise();

	const remoteAudioMediaRef = useRef<IExperimentalHTMLAudioElement>(null); // TODO: Create a dedicated file for the AUDIO and make the controls accessible

	const [queueCounter, setQueueCounter] = useState(0);
	const [queueName, setQueueName] = useState('');
	const [roomInfo, setRoomInfo] = useState<{ v: { token?: string }; rid: string }>({ v: {}, rid: '' });

	const { openDialModal } = useDialModal();

	const closeRoom = useCallback(
		async (data = {}): Promise<void> => {
			roomInfo &&
				(await voipCloseRoomEndpoint({
					rid: roomInfo.rid,
					token: roomInfo.v.token || '',
					options: { comment: data?.comment, tags: data?.tags },
				}));
			homeRoute.push({});

			const queueAggregator = result.voipClient?.getAggregator();
			if (queueAggregator) {
				queueAggregator.callEnded();
			}
		},
		[homeRoute, result?.voipClient, roomInfo, voipCloseRoomEndpoint],
	);

	const openWrapUpModal = useCallback((): void => {
		setModal(() => <WrapUpCallModal closeRoom={closeRoom} />);
	}, [closeRoom, setModal]);

	const changeAudioOutputDevice = useMutableCallback((selectedAudioDevice: Device): void => {
		remoteAudioMediaRef?.current &&
			setOutputMediaDevice({ outputDevice: selectedAudioDevice, HTMLAudioElement: remoteAudioMediaRef.current });
	});

	const changeAudioInputDevice = useMutableCallback((selectedAudioDevice: Device): void => {
		if (!result.voipClient) {
			return;
		}
		const constraints = { audio: { deviceId: { exact: selectedAudioDevice.id } } };

		// TODO: Migrate the classes that manage MediaStream to a more react based approach (using contexts/providers perhaps)
		// For now the MediaStream management is very coupled with the VoIP client,
		// decoupling it will make it usable by other areas of the project that needs to handle MediaStreams and avoid code duplication
		result.voipClient.changeAudioInputDevice(constraints);

		setInputMediaDevice(selectedAudioDevice);
	});

	const [queueAggregator, setQueueAggregator] = useState<QueueAggregator>();

	const [networkStatus, setNetworkStatus] = useState<NetworkState>('online');

	useEffect(() => {
		const { voipClient } = result || {};

		if (!voipClient) {
			return;
		}

		setQueueAggregator(voipClient.getAggregator());

		return (): void => {
			if (clientState === 'registered') {
				return voipClient.unregister();
			}
		};
	}, [result, clientState]);

	const openRoom = useCallback((rid: IVoipRoom['_id']): void => {
		roomCoordinator.openRouteLink('v', { rid });
	}, []);

	const findOrCreateVisitor = useCallback(
		async (caller: ICallerInfo): Promise<Serialized<ILivechatVisitor>> => {
			const phone = parseOutboundPhoneNumber(caller.callerId);

			const { contact } = await getContactBy({ phone });

			if (contact) {
				return contact;
			}

			const { visitor } = await visitorEndpoint({
				visitor: {
					token: Random.id(),
					phone,
					name: caller.callerName || phone,
				},
			});

			return visitor;
		},
		[getContactBy, visitorEndpoint],
	);

	const createRoom = useCallback(
		async (caller: ICallerInfo, direction: IVoipRoom['direction'] = 'inbound'): Promise<IVoipRoom['_id']> => {
			if (!user) {
				return '';
			}
			try {
				const visitor = await findOrCreateVisitor(caller);
				const voipRoom = await voipEndpoint({ token: visitor.token, agentId: user._id, direction });
				openRoom(voipRoom.room._id);
				voipRoom.room && setRoomInfo({ v: { token: voipRoom.room.v.token }, rid: voipRoom.room._id });
				const queueAggregator = result.voipClient?.getAggregator();
				if (queueAggregator) {
					queueAggregator.callStarted();
				}
				return voipRoom.room._id;
			} catch (error) {
				console.error(`Error while creating a visitor ${error}`);
				return '';
			}
		},
		[openRoom, result.voipClient, user, voipEndpoint, findOrCreateVisitor],
	);

	useEffect(() => {
		if (!voipEnabled || !user || !queueAggregator) {
			return;
		}

		const handleEventReceived = async (event: VoipEventDataSignature): Promise<void> => {
			if (isVoipEventAgentCalled(event)) {
				const { data } = event;
				queueAggregator.callRinging({ queuename: data.queue, callerid: data.callerId });
				setQueueName(queueAggregator.getCurrentQueueName());
				return;
			}
			if (isVoipEventAgentConnected(event)) {
				const { data } = event;
				queueAggregator.callPickedup({ queuename: data.queue, queuedcalls: data.queuedCalls, waittimeinqueue: data.waitTimeInQueue });
				setQueueName(queueAggregator.getCurrentQueueName());
				setQueueCounter(queueAggregator.getCallWaitingCount());
				return;
			}
			if (isVoipEventCallerJoined(event)) {
				const { data } = event;
				queueAggregator.queueJoined({ queuename: data.queue, callerid: data.callerId, queuedcalls: data.queuedCalls });
				setQueueCounter(queueAggregator.getCallWaitingCount());
				return;
			}
			if (isVoipEventQueueMemberAdded(event)) {
				const { data } = event;
				queueAggregator.memberAdded({ queuename: data.queue, queuedcalls: data.queuedCalls });
				setQueueName(queueAggregator.getCurrentQueueName());
				setQueueCounter(queueAggregator.getCallWaitingCount());
				return;
			}
			if (isVoipEventQueueMemberRemoved(event)) {
				const { data } = event;
				queueAggregator.memberRemoved({ queuename: data.queue, queuedcalls: data.queuedCalls });
				setQueueCounter(queueAggregator.getCallWaitingCount());
				return;
			}
			if (isVoipEventCallAbandoned(event)) {
				const { data } = event;
				queueAggregator.queueAbandoned({ queuename: data.queue, queuedcallafterabandon: data.queuedCallAfterAbandon });
				setQueueName(queueAggregator.getCurrentQueueName());
				setQueueCounter(queueAggregator.getCallWaitingCount());
				return;
			}

			console.warn('Unknown event received');
		};

		return subscribeToNotifyUser(`${user._id}/voip.events`, handleEventReceived);
	}, [subscribeToNotifyUser, user, queueAggregator, voipEnabled]);

	// This was causing event duplication before, so we'll leave this here for now
	useEffect(() => {
		if (!voipEnabled || !user || !queueAggregator) {
			return;
		}

		return subscribeToNotifyUser(`${user._id}/call.hangup`, (event): void => {
			setQueueName(queueAggregator.getCurrentQueueName());

			if (hasVoIPEnterpriseLicense) {
				openWrapUpModal();
				return;
			}

			closeRoom();

			dispatchEvent({ event: VoipClientEvents['VOIP-CALL-ENDED'], rid: event.roomId });
		});
	}, [openWrapUpModal, queueAggregator, subscribeToNotifyUser, user, voipEnabled, dispatchEvent, hasVoIPEnterpriseLicense, closeRoom]);

	useEffect(() => {
		if (!result.voipClient) {
			return;
		}

		const offRegistered = result.voipClient.on('registered', (): void => setClientState('registered'));
		const offUnregistered = result.voipClient.on('unregistered', (): void => setClientState('unregistered'));

		return (): void => {
			offRegistered();
			offUnregistered();
		};
	}, [result.voipClient]);

	useEffect(() => {
		if (!result.voipClient) {
			return;
		}

		/*
		 * This code may need a revisit when we handle callinqueue differently.
		 * Check clickup tasks for more details
		 * https://app.clickup.com/t/22hy1k4
		 * When customer called a queue (Either using skype or using internal number), call would get established
		 * customer would hear agent's voice but agent would not hear anything from customer.
		 * This issue was observed on unstable. It was found to be inconsistent to reproduce.
		 * On some developer env, it would happen randomly. On Safari it did not happen if
		 * user refreshes before taking every call.
		 *
		 * The reason behind this was as soon as agent accepts a call, queueCounter would change.
		 * This change will trigger re-rendering of media and creation of audio element.
		 * This audio element gets used by voipClient to render the remote audio.
		 * Because the re-render happened, it would hold a stale reference.
		 *
		 * If the dom is inspected, audio element just before body is usually created by this class.
		 * this audio element.srcObject contains null value. In working case, it should display
		 * valid stream object.
		 *
		 * Reason for inconsistencies :
		 * This element is utilized in VoIPUser::setupRemoteMedia
		 * This function is called when webRTC receives a remote track event. i.e when the webrtc's peer connection
		 * starts receiving media. This event call back depends on several factors. How does asterisk setup streams.
		 * How does it creates a bridge which patches up the agent and customer (Media is flowing thru asterisk).
		 * When it works in de-environment, it was observed that the audio element in dom and the audio element hold
		 * by VoIPUser is different. Nonetheless, this stale audio element holds valid media stream, which is being played.
		 * Hence sometimes the audio is heard.
		 *
		 * Ideally call component once gets stable, should not get rerendered. Queue, Room creation are the parameters
		 * which should be independent and should not control the call component.
		 *
		 * Solution :
		 * Either make the audio elemenent rendered independent of rest of the DOM.
		 * or implement useEffect. This useEffect will reset the rendering elements with the latest audio tag.
		 *
		 * Note : If this code gets refactor, revisit the line below to check if this call is needed.
		 *
		 */
		remoteAudioMediaRef.current && result.voipClient.switchMediaRenderer({ remoteMediaElement: remoteAudioMediaRef.current });
	}, [result.voipClient]);

	useEffect(() => {
		if (!result.voipClient) {
			return;
		}

		if (!user) {
			return;
		}

		const onCallEstablished = async (callDetails: ICallDetails): Promise<undefined> => {
			if (!callDetails.callInfo) {
				return;
			}
			stopAllRingback();
			if (callDetails.userState !== UserState.UAC) {
				return;
			}
			// Agent has sent Invite. So it must create a room.
			const { callInfo } = callDetails;
			// While making the call, there is no remote media element available.
			// When the call is ringing we have that element created. But we still
			// do not want it to be attached.
			// When call gets established, then switch the media renderer.
			remoteAudioMediaRef.current && result.voipClient?.switchMediaRenderer({ remoteMediaElement: remoteAudioMediaRef.current });
			const roomId = await createRoom(callInfo, 'outbound');
			dispatchEvent({ event: VoipClientEvents['VOIP-CALL-STARTED'], rid: roomId });
		};

		const onNetworkConnected = (): void => {
			if (networkStatus === 'offline') {
				setNetworkStatus('online');
			}
		};

		const onNetworkDisconnected = (): void => {
			// Transitioning from online -> offline
			// If there is ongoing call, terminate it or if we are processing an incoming/outgoing call
			// reject it.
			if (networkStatus === 'online') {
				setNetworkStatus('offline');
				switch (result.voipClient?.callerInfo.state) {
					case 'IN_CALL':
					case 'ON_HOLD':
						result.voipClient?.endCall();
						break;
					case 'OFFER_RECEIVED':
					case 'ANSWER_SENT':
						result.voipClient?.rejectCall();
						break;
				}
			}
		};

		const onRinging = (): void => {
			startRingback(user, 'outbound-call-ringing');
		};

		const onIncomingCallRinging = (): void => {
			startRingback(user, 'telephone');
		};

		const onCallTerminated = (): void => {
			startRingback(user, 'call-ended', false);
			stopAllRingback();
		};

		const onCallFailed = (reason: 'Not Found' | 'Address Incomplete' | 'Request Terminated' | string): void => {
			switch (reason) {
				case 'Not Found':
					// This happens when the call matches dialplan and goes to the world, but the trunk doesnt find the number.
					openDialModal({ errorMessage: t('Dialed_number_doesnt_exist') });
					break;
				case 'Address Incomplete':
					// This happens when the dialed number doesnt match a valid asterisk dialplan pattern or the number is invalid.
					openDialModal({ errorMessage: t('Dialed_number_is_incomplete') });
					break;
				case 'Request Terminated':
					break;
				default:
					openDialModal({ errorMessage: t('Something_went_wrong_try_again_later') });
			}
		};

		result.voipClient.onNetworkEvent('connected', onNetworkConnected);
		result.voipClient.onNetworkEvent('disconnected', onNetworkDisconnected);
		result.voipClient.onNetworkEvent('connectionerror', onNetworkDisconnected);
		result.voipClient.onNetworkEvent('localnetworkonline', onNetworkConnected);
		result.voipClient.onNetworkEvent('localnetworkoffline', onNetworkDisconnected);
		result.voipClient.on('callestablished', onCallEstablished);
		result.voipClient.on('ringing', onRinging); // not called for incoming call
		result.voipClient.on('incomingcall', onIncomingCallRinging);
		result.voipClient.on('callterminated', onCallTerminated);

		if (isOutboundClient(result.voipClient)) {
			result.voipClient.on('callfailed', onCallFailed);
		}

		return (): void => {
			result.voipClient?.offNetworkEvent('connected', onNetworkConnected);
			result.voipClient?.offNetworkEvent('disconnected', onNetworkDisconnected);
			result.voipClient?.offNetworkEvent('connectionerror', onNetworkDisconnected);
			result.voipClient?.offNetworkEvent('localnetworkonline', onNetworkConnected);
			result.voipClient?.offNetworkEvent('localnetworkoffline', onNetworkDisconnected);
			result.voipClient?.off('incomingcall', onIncomingCallRinging);
			result.voipClient?.off('ringing', onRinging);
			result.voipClient?.off('callestablished', onCallEstablished);
			result.voipClient?.off('callterminated', onCallTerminated);

			if (isOutboundClient(result.voipClient)) {
				result.voipClient?.off('callfailed', onCallFailed);
			}
		};
	}, [createRoom, dispatchEvent, networkStatus, openDialModal, result.voipClient, t, user]);

	const contextValue: CallContextValue = useMemo(() => {
		if (!voipEnabled) {
			return {
				enabled: false,
				ready: false,
				outBoundCallsAllowed: undefined, // set to true only if enterprise license is present.
				outBoundCallsEnabled: undefined, // set to true even if enterprise license is not present.
				outBoundCallsEnabledForUser: undefined, // set to true if the user has enterprise license, but is not able to make outbound calls. (busy, or disabled)
			};
		}

		if (!user?.extension) {
			return {
				enabled: false,
				ready: false,
				outBoundCallsAllowed: undefined, // set to true only if enterprise license is present.
				outBoundCallsEnabled: undefined, // set to true even if enterprise license is not present.
				outBoundCallsEnabledForUser: undefined, // set to true if the user has enterprise license, but is not able to make outbound calls. (busy, or disabled)
			};
		}

		if (result.error) {
			return {
				enabled: true,
				ready: false,
				error: result.error,
				outBoundCallsAllowed: undefined, // set to true only if enterprise license is present.
				outBoundCallsEnabled: undefined, // set to true even if enterprise license is not present.
				outBoundCallsEnabledForUser: undefined, // set to true if the user has enterprise license, but is not able to make outbound calls. (busy, or disabled)
			};
		}

		if (!result.voipClient) {
			return {
				enabled: true,
				ready: false,
				outBoundCallsAllowed: undefined, // set to true only if enterprise license is present.
				outBoundCallsEnabled: undefined, // set to true even if enterprise license is not present.
				outBoundCallsEnabledForUser: undefined, // set to true if the user has enterprise license, but is not able to make outbound calls. (busy, or disabled)
			};
		}

		const { registrationInfo, voipClient } = result;

		return {
			outBoundCallsAllowed: hasVoIPEnterpriseLicense, // set to true only if enterprise license is present.
			outBoundCallsEnabled: hasVoIPEnterpriseLicense, // set to true even if enterprise license is not present.
			outBoundCallsEnabledForUser:
				hasVoIPEnterpriseLicense && clientState === 'registered' && !['IN_CALL', 'ON_HOLD'].includes(voipClient.callerInfo.state), // set to true if the user has enterprise license, but is not able to make outbound calls. (busy, or disabled)

			enabled: true,
			ready: true,
			openedRoomInfo: roomInfo,
			voipClient,
			registrationInfo,
			queueCounter,
			queueName,
			actions: {
				mute: (): Promise<void> => voipClient.muteCall(true), // voipClient.mute(),
				unmute: (): Promise<void> => voipClient.muteCall(false), // voipClient.unmute()
				pause: (): Promise<void> => voipClient.holdCall(true), // voipClient.pause()
				resume: (): Promise<void> => voipClient.holdCall(false), // voipClient.resume()
				end: (): Promise<OutgoingByeRequest | void> => voipClient.endCall(),
				pickUp: async (): Promise<void | null> =>
					remoteAudioMediaRef.current && voipClient.acceptCall({ remoteMediaElement: remoteAudioMediaRef.current }),
				reject: (): Promise<void> => voipClient.rejectCall(),
			},
			openRoom,
			createRoom,
			closeRoom,
			networkStatus,
			openWrapUpModal,
			changeAudioOutputDevice,
			changeAudioInputDevice,
			register: (): void => voipClient.register(),
			unregister: (): void => voipClient.unregister(),
		};
	}, [
		voipEnabled,
		user?.extension,
		result,
		hasVoIPEnterpriseLicense,
		clientState,
		roomInfo,
		queueCounter,
		queueName,
		openRoom,
		createRoom,
		closeRoom,
		openWrapUpModal,
		changeAudioOutputDevice,
		changeAudioInputDevice,
		networkStatus,
	]);

	return (
		<CallContext.Provider value={contextValue}>
			{children}
			{contextValue.enabled && createPortal(<audio ref={remoteAudioMediaRef} />, document.body)}
		</CallContext.Provider>
	);
};
