import {
	MediaCallWebRTCProcessor,
	type CallContact,
	type ClientMediaSignal,
	type IClientMediaCall,
	type CallActorType,
	type MediaSignalingSession,
	type ServerMediaSignal,
	type WebRTCProcessorConfig
} from '@rocket.chat/media-signaling';
import { Platform } from 'react-native';
import RNCallKeep from 'react-native-callkeep';
import { registerGlobals } from 'react-native-webrtc';
import { getUniqueIdSync } from 'react-native-device-info';
import { dequal } from 'dequal';

import NativeVoipModule from '../../native/NativeVoip';
import { mediaSessionStore } from './MediaSessionStore';
import { pendingHangups } from './pendingHangups';
import { terminateNativeCall } from './terminateNativeCall';
import { useCallStore } from './useCallStore';
import { MediaCallLogger } from './MediaCallLogger';
import { isSelfUserId } from './isSelfUserId';
import { store } from '../../store/auxStore';
import sdk from '../sdk';
import { mediaCallsStateSignals } from '../restApi';
import Navigation, { waitForNavigationReady } from '../../navigation/appNavigation';
import { parseStringToIceServers } from './parseStringToIceServers';
import type { IceServer } from '../../../definitions/Voip';
import type { IDDPMessage } from '../../../definitions/IDDPMessage';
import type { ISubscription, TSubscriptionModel } from '../../../definitions';
import { getDMSubscriptionByUsername } from '../../database/services/Subscription';
import { getUidDirectMessage } from '../../methods/helpers/helpers';
import log from '../../methods/helpers/log';
import { isInActiveVoipCall } from './isInActiveVoipCall';
import { requestVoipCallPermissions } from '../../methods/voipCallPermissions';
import I18n from '../../../i18n';
import { showErrorAlert } from '../../methods/helpers/info';

const mediaCallLogger = new MediaCallLogger();

class MediaSessionInstance {
	private iceServers: IceServer[] = [];
	private iceGatheringTimeout: number = 5000;
	private mediaSignalListener: { stop: () => void } | null = null;
	private instance: MediaSignalingSession | null = null;
	private mediaSessionStoreChangeUnsubscribe: (() => void) | null = null;
	private storeTimeoutUnsubscribe: (() => void) | null = null;
	private storeIceServersUnsubscribe: (() => void) | null = null;

	private tryAnswerIfNativeAcceptedNotification(signal: ServerMediaSignal): void {
		const { call, nativeAcceptedCallId } = useCallStore.getState();
		if (
			signal.type === 'notification' &&
			signal.notification === 'accepted' &&
			signal.signedContractId === getUniqueIdSync() &&
			nativeAcceptedCallId === signal.callId &&
			call == null
		) {
			// Defer past the lib's `updatingInputTrack` re-entrancy lock held during the
			// current `processSignal` tick — otherwise `accept()`'s state change is dropped
			// inside the lock, mic never opens, call hangs in `waiting-for-offer` for 10s.
			queueMicrotask(() => {
				this.answerCall(signal.callId).catch(error => log(error));
			});
		}
	}

	/** Replays `media-calls.stateSignals`. Used on init and when native accept raced ahead of `nativeAcceptedCallId`. Caller must ensure SDK/session host matches the call (see MediaCallEvents host gate). `tryAnswerIfNativeAcceptedNotification` may also fire from the stream-notify-user path; `answerCall` is idempotent. */
	public async applyRestStateSignals(): Promise<void> {
		if (!this.instance) {
			return;
		}
		try {
			const { signals } = await mediaCallsStateSignals(getUniqueIdSync());
			for (const signal of signals) {
				// Sequential replay: signals depend on prior state mutations.
				// eslint-disable-next-line no-await-in-loop
				await this.instance.processSignal(signal);
				this.tryAnswerIfNativeAcceptedNotification(signal);
			}
		} catch (error) {
			log(error);
		}
	}

	public async init(userId: string): Promise<void> {
		this.reset();

		registerGlobals();
		this.configureIceServers();

		mediaSessionStore.setWebRTCProcessorFactory(
			(config: WebRTCProcessorConfig) =>
				new MediaCallWebRTCProcessor({
					...config,
					rtc: { ...config.rtc, iceServers: this.iceServers },
					iceGatheringTimeout: this.iceGatheringTimeout
				})
		);
		mediaSessionStore.setSendSignalFn((signal: ClientMediaSignal) => {
			sdk.methodCall('stream-notify-user', `${userId}/media-calls`, JSON.stringify(signal));
		});
		this.instance = mediaSessionStore.getInstance(userId);

		if (!this.instance) {
			throw new Error('Failed to create media session instance');
		}

		await this.applyRestStateSignals();

		this.mediaSessionStoreChangeUnsubscribe = mediaSessionStore.onChange(() => {
			this.instance = mediaSessionStore.getInstance(userId);
		});

		this.mediaSignalListener = sdk.onStreamData('stream-notify-user', async (ddpMessage: IDDPMessage) => {
			if (!this.instance) {
				return;
			}
			const [, ev] = ddpMessage.fields.eventName.split('/');
			if (ev !== 'media-signal') {
				return;
			}
			const signal = ddpMessage.fields.args[0];
			await this.instance.processSignal(signal);
			this.tryAnswerIfNativeAcceptedNotification(signal as ServerMediaSignal);
		});

		this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => {
			if (call && !call.hidden) {
				if (call.localParticipant.role === 'caller') {
					useCallStore.getState().setCall(call);
					useCallStore.getState().setDirection('outgoing');
					// Outgoing calls don't go through VoipNotification, so the FGS that the incoming
					// path starts after Telecom-accept doesn't exist here. Start it now (we're on the
					// JS thread that ran startCall, the initiating activity is still visible) so the
					// while-in-use microphone permission survives the user backgrounding the app
					// while the call is in progress.
					// Navigation is gated on the FGS resolving so a rejection doesn't strand the user
					// on a `CallView` whose `'ended'` listener has already been detached by `endCall` →
					// `useCallStore.reset()` before the async `hangup()` could fire `Navigation.back()`.
					if (Platform.OS === 'android') {
						NativeVoipModule.startVoipCallService(call.callId)
							.then(() => Navigation.navigate('CallView'))
							.catch(error => {
								log(error);
								showErrorAlert(I18n.t('VoIP_Call_Issue'), I18n.t('Oops'));
								this.endCall(call.callId);
							});
					} else {
						Navigation.navigate('CallView');
					}
					if (useCallStore.getState().roomId == null) {
						this.resolveRoomIdFromContact(call.remoteParticipants[0]?.contact).catch(error => {
							log(error);
						});
					}
				}

				call.emitter.on('ended', () => {
					terminateNativeCall(call.callId);
				});
			}
		});
	}

	public answerCall = async (callId: string) => {
		const { call: existingCall } = useCallStore.getState();
		if (existingCall != null && existingCall.callId === callId) {
			return;
		}

		const mainCall = this.instance?.getCallData(callId);

		if (mainCall && mainCall.callId === callId) {
			try {
				await mainCall.accept();
			} catch (error) {
				log(error);
				terminateNativeCall(callId);
				const st = useCallStore.getState();
				if (st.nativeAcceptedCallId === callId) {
					st.resetNativeCallId();
				}
				showErrorAlert(I18n.t('VoIP_Answer_Failed'), I18n.t('Oops'));
				return;
			}
			RNCallKeep.setCurrentCallActive(callId);
			useCallStore.getState().setCall(mainCall);
			useCallStore.getState().setDirection('incoming');
			await waitForNavigationReady();
			Navigation.navigate('CallView');
			this.resolveRoomIdFromContact(mainCall.remoteParticipants[0]?.contact).catch(error => {
				log(error);
			});
		} else {
			terminateNativeCall(callId);
			const st = useCallStore.getState();
			if (st.nativeAcceptedCallId === callId) {
				st.resetNativeCallId();
			}
			log(new Error(`[VoIP] Call not found after accept: ${callId}`));
		}
	};

	public startCallByRoom = (room: TSubscriptionModel | ISubscription) => {
		if (isInActiveVoipCall()) return;
		useCallStore.getState().setRoomId(room.rid ?? null);
		const otherUserId = getUidDirectMessage(room);
		if (otherUserId) {
			this.startCall(otherUserId, 'user').catch(error => {
				// Clear the optimistic roomId so a concurrent incoming call can resolve its own DM context.
				useCallStore.getState().setRoomId(null);
				log(error);
			});
		}
	};

	public startCall = async (userId: string, actor: CallActorType): Promise<void> => {
		if (isInActiveVoipCall()) {
			throw new Error(I18n.t('VoIP_Already_In_Call'));
		}
		if (isSelfUserId(userId)) {
			mediaCallLogger.debug('[VoIP] startCall blocked: target userId matches logged-in user');
			return;
		}
		if (!this.instance) {
			mediaCallLogger.debug('[VoIP] startCall blocked: MediaSessionInstance not initialized');
			showErrorAlert(I18n.t('VoIP_Still_Connecting'), I18n.t('Oops'));
			return;
		}
		const granted = await requestVoipCallPermissions();
		if (!granted) {
			showErrorAlert(
				I18n.t('Go_to_your_device_settings_and_allow_microphone'),
				I18n.t('Microphone_access_needed_to_record_audio')
			);
			return;
		}
		// Re-evaluate: an incoming call may have been accepted during the permission prompt.
		if (isInActiveVoipCall()) {
			throw new Error(I18n.t('VoIP_Already_In_Call'));
		}
		await this.instance.startCall(actor, userId);
	};

	public endCall = (callId: string) => {
		const mainCall = this.instance?.getCallData(callId);

		if (mainCall && mainCall.callId === callId) {
			// Record before dispatching so a hangup written into a dead socket is replayed on reconnect.
			pendingHangups.record(callId);

			if (mainCall.state === 'ringing') {
				mainCall.reject();
			} else {
				mainCall.hangup();
			}
		}
		terminateNativeCall(callId);
		RNCallKeep.setCurrentCallActive('');
		RNCallKeep.setAvailable(true);
		useCallStore.getState().resetNativeCallId();
		useCallStore.getState().reset();
	};

	/**
	 * Re-dispatch hangup signals for calls the user ended while the WebSocket was unhealthy.
	 * `Session.transporter` is declared `private` in the lib's `.d.ts`; routing the replay through
	 * `MediaSignalTransportWrapper.hangup` keeps the wire shape consistent with the happy path.
	 */
	public drainPendingHangups(): void {
		if (this.instance == null) {
			return;
		}
		const ids = pendingHangups.drainAll();
		for (const id of ids) {
			try {
				(this.instance as any).transporter.hangup(id, 'normal');
			} catch (error) {
				log(error);
			}
		}
	}

	private async resolveRoomIdFromContact(contact: CallContact | undefined): Promise<void> {
		if (!contact) {
			return;
		}
		const { username } = contact;
		if (!username) {
			return;
		}
		const sub = await getDMSubscriptionByUsername(username);
		if (sub) {
			useCallStore.getState().setRoomId(sub.rid);
		}
	}

	private getIceServers() {
		const iceServers = store.getState().settings.VoIP_TeamCollab_Ice_Servers as any;
		return parseStringToIceServers(iceServers);
	}

	private configureIceServers() {
		this.iceServers = this.getIceServers();
		this.iceGatheringTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number;

		this.storeTimeoutUnsubscribe = store.subscribe(() => {
			const currentTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number;
			if (currentTimeout !== this.iceGatheringTimeout) {
				this.iceGatheringTimeout = currentTimeout;
				this.instance?.setIceGatheringTimeout(this.iceGatheringTimeout);
			}
		});

		this.storeIceServersUnsubscribe = store.subscribe(() => {
			const currentIceServers = this.getIceServers();
			if (!dequal(currentIceServers, this.iceServers)) {
				this.iceServers = currentIceServers;
				this.instance?.setIceServers(this.iceServers);
			}
		});
	}

	public reset() {
		if (this.mediaSessionStoreChangeUnsubscribe) {
			this.mediaSessionStoreChangeUnsubscribe();
			this.mediaSessionStoreChangeUnsubscribe = null;
		}
		if (this.mediaSignalListener?.stop) {
			this.mediaSignalListener.stop();
		}
		this.mediaSignalListener = null;
		if (this.storeTimeoutUnsubscribe) {
			this.storeTimeoutUnsubscribe();
			this.storeTimeoutUnsubscribe = null;
		}
		if (this.storeIceServersUnsubscribe) {
			this.storeIceServersUnsubscribe();
			this.storeIceServersUnsubscribe = null;
		}
		mediaSessionStore.dispose();
		this.instance = null;
		pendingHangups.clear();
		useCallStore.getState().reset();
	}
}

export const mediaSessionInstance = new MediaSessionInstance();
