//
// Copyright 2016 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import CallKit
import Foundation
import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import SignalUI
import WebRTC

// MARK: - CallService

// This class' state should only be accessed on the main queue.
final class IndividualCallService: CallServiceStateObserver {

    // MARK: Class

    private let callManager: CallService.CallManagerType
    private let callServiceState: CallServiceState

    @MainActor
    init(
        callManager: CallService.CallManagerType,
        callServiceState: CallServiceState,
    ) {
        self.callManager = callManager
        self.callServiceState = callServiceState
        SwiftSingletons.register(self)
        self.callServiceState.addObserver(self)
    }

    private var audioSession: AudioSession { SUIEnvironment.shared.audioSessionRef }
    private var callService: CallService { AppEnvironment.shared.callService }
    @MainActor
    private var callUIAdapter: CallUIAdapter { AppEnvironment.shared.callService.callUIAdapter }
    private var contactManager: any ContactManager { SSKEnvironment.shared.contactManagerRef }
    private var databaseStorage: SDSDatabaseStorage { SSKEnvironment.shared.databaseStorageRef }
    private var networkManager: NetworkManager { SSKEnvironment.shared.networkManagerRef }
    private var notificationPresenter: NotificationPresenter { SSKEnvironment.shared.notificationPresenterRef }
    private var preferences: Preferences { SSKEnvironment.shared.preferencesRef }
    private var profileManager: any ProfileManager { SSKEnvironment.shared.profileManagerRef }
    private var tsAccountManager: any TSAccountManager { DependenciesBridge.shared.tsAccountManager }
    private var identityManager: any OWSIdentityManager { DependenciesBridge.shared.identityManager }

    @MainActor
    func didUpdateCall(from oldValue: SignalCall?, to newValue: SignalCall?) {
        stopAnyCallTimer()
        if let newValue {
            switch newValue.mode {
            case .individual:
                startCallTimer(for: newValue)
            case .groupThread, .callLink:
                break
            }
        }
    }

    // MARK: - Call Control Actions

    private var pniRemoteUuids = [Pni: UUID]()

    /**
     * Initiate an outgoing call.
     */
    @MainActor
    func handleOutgoingCall(_ call: SignalCall) {
        Logger.info("call: \(call)")

        guard callServiceState.currentCall == nil else {
            owsFailDebug("call already exists: \(String(describing: callServiceState.currentCall))")
            return
        }

        // Create a call interaction for outgoing calls immediately.
        call.individualCall.createOrUpdateCallInteractionAsync(callType: .outgoingIncomplete)

        guard let serviceId = call.individualCall.remoteAddress.serviceId else {
            owsFailDebug("service id not available")
            return
        }

        let remoteUuid: UUID
        switch serviceId.concreteType {
        case .aci(let aci):
            remoteUuid = aci.rawUUID
        case .pni(let pni):
            if let pniRemoteUuid = self.pniRemoteUuids[pni] {
                remoteUuid = pniRemoteUuid
            } else {
                remoteUuid = UUID()
                self.pniRemoteUuids[pni] = remoteUuid
            }
        }

        do {
            try callManager.placeCall(call: call, remoteUuid: remoteUuid, callMediaType: call.individualCall.offerMediaType.asCallMediaType, localDevice: call.individualCall.localDeviceId.uint32Value)
        } catch {
            self.handleFailedCall(failedCall: call, error: error, shouldResetUI: true, shouldResetRingRTC: true)
        }
    }

    /**
     * User chose to answer the call. Used by the Callee only.
     */
    @MainActor
    func handleAcceptCall(_ call: SignalCall) {
        Logger.info("\(call)")

        defer {
            // This should only be non-nil if we had to defer accepting the call while waiting for RingRTC
            // If it's set, we need to make sure we call it before returning.
            call.individualCall.deferredAnswerCompletion?()
            call.individualCall.deferredAnswerCompletion = nil
        }

        guard callServiceState.currentCall === call else {
            let error = OWSAssertionError("accepting call: \(call) which is different from currentCall: \(callServiceState.currentCall as Optional)")
            handleFailedCall(failedCall: call, error: error, shouldResetUI: true, shouldResetRingRTC: true)
            return
        }

        guard let callId = call.individualCall.callId else {
            handleFailedCall(failedCall: call, error: OWSAssertionError("no callId for call: \(call)"), shouldResetUI: true, shouldResetRingRTC: true)
            return
        }

        Logger.info("Creating call interaction: \(call)")
        call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingIncomplete)

        // It's key that we configure the AVAudioSession for a call *before* we fulfill the
        // CXAnswerCallAction.
        //
        // Otherwise CallKit has been seen not to activate the audio session.
        // That is, `provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession)`
        // was sometimes not called.`
        //
        // That is why we connect here, rather than waiting for a racy async response from
        // CallManager, confirming that the call has connected. It is also safer to do the
        // audio session configuration before WebRTC starts operating on the audio resources
        // via CallManager.accept().
        handleConnected(call: call)

        // Update the interaction now that we've accepted.
        call.individualCall.createOrUpdateCallInteractionAsync(callType: .incoming)

        do {
            try callManager.accept(callId: callId)
        } catch {
            self.handleFailedCall(failedCall: call, error: error, shouldResetUI: true, shouldResetRingRTC: true)
        }
    }

    /**
     * Local user chose to end the call.
     */
    @MainActor
    func handleLocalHangupCall(_ call: SignalCall) {
        Logger.info("\(call)")

        guard call === callServiceState.currentCall else {
            Logger.info("ignoring hangup for obsolete call: \(call)")
            return
        }

        do {
            try callManager.hangup()
        } catch {
            // no point in "failing" the call if the user expressed their intent to hang up
            // and we've already called: `terminate(call: cal)`
            owsFailDebug("error: \(error)")
        }
    }

    // MARK: - Signaling Functions

    /**
     * Received an incoming call Offer from call initiator.
     */
    func handleReceivedOffer(
        caller: Aci,
        sourceDevice: DeviceId,
        localIdentity: OWSIdentity,
        callId: UInt64,
        opaque: Data?,
        sentAtTimestamp: UInt64,
        serverReceivedTimestamp: UInt64,
        serverDeliveryTimestamp: UInt64,
        callType: SSKProtoCallMessageOfferType,
        tx: DBWriteTransaction,
    ) {
        Logger.info("callId: \(callId), \(caller)")

        guard let opaque else {
            return
        }

        let callOfferHandler = CallOfferHandlerImpl(
            identityManager: identityManager,
            notificationPresenter: notificationPresenter,
            profileManager: profileManager,
            tsAccountManager: tsAccountManager,
        )

        let partialResult = callOfferHandler.startHandlingOffer(
            caller: caller,
            sourceDevice: sourceDevice,
            localIdentity: localIdentity,
            callId: callId,
            callType: callType,
            sentAtTimestamp: sentAtTimestamp,
            tx: tx,
        )
        guard let partialResult else {
            return
        }

        let individualCall = IndividualCall.incomingIndividualCall(
            callId: callId,
            thread: partialResult.thread,
            sentAtTimestamp: sentAtTimestamp,
            offerMediaType: partialResult.offerMediaType,
            localDeviceId: partialResult.localDeviceId,
        )

        // Get the current local device Id, must be valid for lifetime of the call.
        let localDeviceId = partialResult.localDeviceId

        let newCall = SignalCall(individualCall: individualCall)

        DispatchQueue.main.async {
            let backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: "\(#function)", completionBlock: { [weak newCall] status in
                AssertIsOnMainThread()

                guard status == .expired else {
                    return
                }

                // See if the newCall actually became the currentCall.
                guard
                    case .individual(let currentCall) = self.callServiceState.currentCall?.mode,
                    let newCall,
                    newCall === currentCall
                else {
                    Logger.warn("ignoring obsolete call")
                    return
                }

                let error = CallError.timeout(description: "background task time ran out before call connected")
                self.handleFailedCall(failedCall: newCall, error: error, shouldResetUI: true, shouldResetRingRTC: true)
            })

            newCall.individualCall.backgroundTask = backgroundTask

            var messageAgeSec: UInt64 = 0
            if serverReceivedTimestamp > 0, serverDeliveryTimestamp >= serverReceivedTimestamp {
                messageAgeSec = (serverDeliveryTimestamp - serverReceivedTimestamp) / 1000
            }

            do {
                try self.callManager.receivedOffer(
                    call: newCall,
                    remoteUuid: caller.rawUUID,
                    sourceDevice: sourceDevice.uint32Value,
                    callId: callId,
                    opaque: opaque,
                    messageAgeSec: messageAgeSec,
                    callMediaType: newCall.individualCall.offerMediaType.asCallMediaType,
                    localDevice: localDeviceId.uint32Value,
                    senderIdentityKey: partialResult.identityKeys.contactIdentityKey.publicKey.keyBytes,
                    receiverIdentityKey: partialResult.identityKeys.localIdentityKey.publicKey.keyBytes,
                )
            } catch {
                self.handleFailedCall(failedCall: newCall, error: error, shouldResetUI: true, shouldResetRingRTC: true)
            }
        }
    }

    /**
     * Called by the call initiator after receiving an Answer from the callee.
     */
    func handleReceivedAnswer(
        caller: Aci,
        callId: UInt64,
        sourceDevice: DeviceId,
        opaque: Data?,
        tx: DBReadTransaction,
    ) {
        Logger.info("callId: \(callId), \(caller)")

        guard let opaque else {
            return
        }

        guard let identityKeys = identityManager.getCallIdentityKeys(remoteAci: caller, tx: tx) else {
            Logger.error("failed to get identity keys for answer")
            return
        }

        DispatchQueue.main.async {
            self._handleReceivedAnswer(caller: caller, callId: callId, sourceDevice: sourceDevice, opaque: opaque, identityKeys: identityKeys)
        }
    }

    @MainActor
    private func _handleReceivedAnswer(
        caller: Aci,
        callId: UInt64,
        sourceDevice: DeviceId,
        opaque: Data,
        identityKeys: CallIdentityKeys,
    ) {
        do {
            try callManager.receivedAnswer(
                remoteUuid: caller.rawUUID,
                sourceDevice: sourceDevice.uint32Value,
                callId: callId,
                opaque: opaque,
                senderIdentityKey: identityKeys.contactIdentityKey.publicKey.keyBytes,
                receiverIdentityKey: identityKeys.localIdentityKey.publicKey.keyBytes,
            )
        } catch {
            owsFailDebug("receivedAnswer failed: \(error)")
        }
    }

    /**
     * Remote client (could be caller or callee) sent us a connectivity update.
     */
    func handleReceivedIceCandidates(caller: Aci, callId: UInt64, sourceDevice: DeviceId, candidates: [SSKProtoCallMessageIceUpdate]) {
        Logger.info("callId: \(callId), \(caller)")

        let iceCandidates = candidates.filter { $0.id == callId && $0.opaque != nil }.map { $0.opaque! }

        if iceCandidates.isEmpty {
            return
        }

        DispatchQueue.main.async {
            self._handleReceivedIceCandidates(caller: caller, callId: callId, sourceDevice: sourceDevice, iceCandidates: iceCandidates)
        }
    }

    @MainActor
    private func _handleReceivedIceCandidates(caller: Aci, callId: UInt64, sourceDevice: DeviceId, iceCandidates: [Data]) {
        do {
            try callManager.receivedIceCandidates(remoteUuid: caller.rawUUID, sourceDevice: sourceDevice.uint32Value, callId: callId, candidates: iceCandidates)
        } catch {
            owsFailDebug("receivedIceCandidates failed: \(error)")
        }
    }

    /**
     * The remote client (caller or callee) ended the call.
     */
    func handleReceivedHangup(caller: Aci, callId: UInt64, sourceDevice: DeviceId, type: SSKProtoCallMessageHangupType, deviceId: UInt32) {
        Logger.info("callId: \(callId), \(caller)")

        let hangupType: HangupType
        switch type {
        case .hangupNormal: hangupType = .normal
        case .hangupAccepted: hangupType = .accepted
        case .hangupDeclined: hangupType = .declined
        case .hangupBusy: hangupType = .busy
        case .hangupNeedPermission: hangupType = .needPermission
        }

        DispatchQueue.main.async {
            self._handleReceivedHangup(caller: caller, callId: callId, sourceDevice: sourceDevice, hangupType: hangupType, deviceId: deviceId)
        }
    }

    @MainActor
    private func _handleReceivedHangup(caller: Aci, callId: UInt64, sourceDevice: DeviceId, hangupType: HangupType, deviceId: UInt32) {
        do {
            try callManager.receivedHangup(remoteUuid: caller.rawUUID, sourceDevice: sourceDevice.uint32Value, callId: callId, hangupType: hangupType, deviceId: deviceId)
        } catch {
            owsFailDebug("receivedHangup failed: \(error)")
        }
    }

    /**
     * The callee was already in another call.
     */
    func handleReceivedBusy(caller: Aci, callId: UInt64, sourceDevice: DeviceId) {
        Logger.info("callId: \(callId), \(caller)")

        DispatchQueue.main.async {
            self._handleReceivedBusy(caller: caller, callId: callId, sourceDevice: sourceDevice)
        }
    }

    @MainActor
    private func _handleReceivedBusy(caller: Aci, callId: UInt64, sourceDevice: DeviceId) {
        do {
            try callManager.receivedBusy(remoteUuid: caller.rawUUID, sourceDevice: sourceDevice.uint32Value, callId: callId)
        } catch {
            owsFailDebug("receivedBusy failed: \(error)")
        }
    }

    // MARK: - Call Manager Events

    @MainActor
    func callManager(_ callManager: CallService.CallManagerType, shouldStartCall call: SignalCall, callId: UInt64, isOutgoing: Bool, callMediaType: CallMediaType, shouldEarlyRing: Bool) {
        Logger.info("call: \(call)")

        if shouldEarlyRing {
            if !isOutgoing {
                // If we are using the NSE, we need to kick off a ring ASAP in case this incoming call
                // has resulted in the NSE waking up the main app.
                Logger.info("Performing early ring")
                handleRinging(call: call, isAnticipatory: true)
            } else {
                owsFailDebug("Cannot early ring an outgoing call")
            }
        }

        // Start the call, asynchronously.
        Task { @MainActor in
            do {
                var iceServers = try await RTCIceServerFetcher(networkManager: networkManager)
                    .getIceServers()
                guard self.callServiceState.currentCall === call else {
                    Logger.debug("call has since ended")
                    return
                }

                let isSignalConnection = self.databaseStorage.read { tx in
                    return profileManager.isThread(inProfileWhitelist: call.individualCall.thread, transaction: tx)
                }
                if !isSignalConnection {
                    Logger.warn("Using relay server because remote user is not a Signal Connection")
                }

                let useTurnOnly = !isSignalConnection || self.preferences.doCallsHideIPAddress

                let useLowData = self.callService.shouldUseLowDataWithSneakyTransaction(for: NetworkRoute(localAdapterType: .unknown))
                Logger.info("Configuring call for \(useLowData ? "low" : "standard") data")

                if DebugFlags.callingNeverRelay.get() {
                    iceServers = iceServers.filter { !$0.urlStrings.contains { $0.starts(with: "turn:") || $0.starts(with: "turns:") } }
                }
                // Tell the Call Manager to proceed with its active call.
                try self.callManager.proceed(
                    callId: callId,
                    iceServers: iceServers,
                    hideIp: useTurnOnly,
                    videoCaptureController: call.videoCaptureController,
                    dataMode: useLowData ? .low : .normal,
                    audioLevelsIntervalMillis: nil,
                    enableVp9: RingrtcVp9Config.enableVp9(with: RemoteConfig.current),
                    dredDuration: RemoteConfig.current.ringrtcDredDuration,
                )
            } catch {
                owsFailDebug("\(error)")
                guard call === self.callServiceState.currentCall else {
                    return
                }

                callManager.drop(callId: callId)
                self.handleFailedCall(failedCall: call, error: error, shouldResetUI: true, shouldResetRingRTC: false)
            }
        }
    }

    @MainActor
    func callManager(_ callManager: CallService.CallManagerType, onCallEnded call: SignalCall, callId: UInt64, reason: CallEndReason, summary: CallSummary) {
        Logger.info("call: \(call), onCallEnded: \(reason)")

        CallQualitySurveyManager(
            callSummary: summary,
            callType: {
                switch call.individualCall.offerMediaType {
                case .audio: .individualAudio
                case .video: .individualVideo
                }
            }(),
            threadUniqueId: call.individualCall.thread.uniqueId,
            deps: .init(
                db: DependenciesBridge.shared.db,
                accountManager: tsAccountManager,
                networkManager: networkManager,
            ),
        ).showIfNeeded()

        switch reason {
        case .localHangup:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            switch call.individualCall.callType {
            case .some(.outgoingIncomplete):
                call.individualCall.createOrUpdateCallInteractionAsync(callType: .outgoingMissed)
            case .some:
                break
            case .none where [.localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting].contains(call.individualCall.state):
                call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingDeclined)
            case .none:
                owsFailDebug("missing call record")
            }

            // Make RTC audio inactive early in the hangup process before the state
            // change resulting in any change to the default AudioSession.
            audioSession.isRTCAudioEnabled = false

            call.individualCall.state = .localHangup

            ensureAudioState(call: call)

            callServiceState.terminateCall(call)

        case .remoteHangup:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            // Make RTC audio inactive early in the hangup process before the state
            // change resulting in any change to the default AudioSession.
            audioSession.isRTCAudioEnabled = false

            switch call.individualCall.state {
            case .idle, .dialing, .answering, .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting, .localFailure, .remoteBusy, .remoteRinging:
                handleMissedCall(call)
            case .connected, .reconnecting, .localHangup, .remoteHangup, .remoteHangupNeedPermission, .answeredElsewhere, .declinedElsewhere, .busyElsewhere:
                Logger.info("call is finished")
            }

            call.individualCall.state = .remoteHangup

            // Notify UI
            callUIAdapter.remoteDidHangupCall(call)

            callServiceState.terminateCall(call)

        case .remoteHangupNeedPermission:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            audioSession.isRTCAudioEnabled = false

            switch call.individualCall.state {
            case .idle, .dialing, .answering, .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting, .localFailure, .remoteBusy, .remoteRinging:
                handleMissedCall(call)
            case .connected, .reconnecting, .localHangup, .remoteHangup, .remoteHangupNeedPermission, .answeredElsewhere, .declinedElsewhere, .busyElsewhere:
                Logger.info("call is finished")
            }

            call.individualCall.state = .remoteHangupNeedPermission

            // Notify UI
            callUIAdapter.remoteDidHangupCall(call)

            callServiceState.terminateCall(call)

        case .remoteHangupAccepted:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            audioSession.isRTCAudioEnabled = false

            switch call.individualCall.state {
            case .idle, .dialing, .remoteBusy, .remoteRinging, .answeredElsewhere, .declinedElsewhere, .busyElsewhere, .remoteHangup, .remoteHangupNeedPermission:
                handleFailedCall(failedCall: call, error: OWSAssertionError("unexpected state for endedRemoteHangupAccepted: \(call.individualCall.state)"), shouldResetUI: true, shouldResetRingRTC: true)
                return
            case .answering, .accepting, .connected:
                Logger.info("tried answering locally, but answered somewhere else first. state: \(call.individualCall.state)")
                handleAnsweredElsewhere(call: call)
            case .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .reconnecting:
                handleAnsweredElsewhere(call: call)
            case .localFailure, .localHangup:
                Logger.info("ignoring 'endedRemoteHangupAccepted' since call is already finished")
            }

        case .remoteHangupDeclined:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            audioSession.isRTCAudioEnabled = false

            switch call.individualCall.state {
            case .idle, .dialing, .remoteBusy, .remoteRinging, .answeredElsewhere, .declinedElsewhere, .busyElsewhere, .remoteHangup, .remoteHangupNeedPermission:
                handleFailedCall(failedCall: call, error: OWSAssertionError("unexpected state for endedRemoteHangupDeclined: \(call.individualCall.state)"), shouldResetUI: true, shouldResetRingRTC: true)
                return
            case .answering, .accepting, .connected:
                Logger.info("tried answering locally, but declined somewhere else first. state: \(call.individualCall.state)")
                handleDeclinedElsewhere(call: call)
            case .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .reconnecting:
                handleDeclinedElsewhere(call: call)
            case .localFailure, .localHangup:
                Logger.info("ignoring 'endedRemoteHangupDeclined' since call is already finished")
            }

        case .remoteHangupBusy:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            audioSession.isRTCAudioEnabled = false

            switch call.individualCall.state {
            case .idle, .dialing, .remoteBusy, .remoteRinging, .answeredElsewhere, .declinedElsewhere, .busyElsewhere, .remoteHangup, .remoteHangupNeedPermission:
                handleFailedCall(failedCall: call, error: OWSAssertionError("unexpected state for endedRemoteHangupBusy: \(call.individualCall.state)"), shouldResetUI: true, shouldResetRingRTC: true)
                return
            case .answering, .accepting, .connected:
                Logger.info("tried answering locally, but already in a call somewhere else first. state: \(call.individualCall.state)")
                handleBusyElsewhere(call: call)
            case .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .reconnecting:
                handleBusyElsewhere(call: call)
            case .localFailure, .localHangup:
                Logger.info("ignoring 'endedRemoteHangupBusy' since call is already finished")
            }

        case .remoteBusy:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            assert(call.individualCall.direction == .outgoing)
            call.individualCall.createOrUpdateCallInteractionAsync(callType: .outgoingMissed)

            call.individualCall.state = .remoteBusy

            // Notify UI
            callUIAdapter.remoteBusy(call)

            callServiceState.terminateCall(call)

        case .remoteGlare, .remoteReCall:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            if let callType = call.individualCall.callType {
                switch callType {
                case .outgoingMissed, .incomingDeclined, .incomingMissed, .incomingMissedBecauseOfChangedIdentity, .incomingAnsweredElsewhere, .incomingDeclinedElsewhere, .incomingBusyElsewhere, .incomingMissedBecauseOfDoNotDisturb, .incomingMissedBecauseBlockedSystemContact:
                    // already handled and ended, don't update the call record.
                    break
                case .incomingIncomplete, .incoming:
                    call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingMissed)
                    callUIAdapter.reportMissedCall(call, individualCall: call.individualCall)
                case .outgoingIncomplete:
                    call.individualCall.createOrUpdateCallInteractionAsync(callType: .outgoingMissed)
                    callUIAdapter.remoteBusy(call)
                case .outgoing:
                    call.individualCall.createOrUpdateCallInteractionAsync(callType: .outgoingMissed)
                    callUIAdapter.reportMissedCall(call, individualCall: call.individualCall)
                @unknown default:
                    owsFailDebug("unknown RPRecentCallType: \(callType)")
                }
            } else {
                assert(call.individualCall.direction == .incoming)
                call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingMissed)
                callUIAdapter.reportMissedCall(call, individualCall: call.individualCall)
            }
            call.individualCall.state = .localHangup
            callServiceState.terminateCall(call)

        case .timeout:
            let description: String

            if call.individualCall.direction == .outgoing {
                description = "timeout for outgoing call"
            } else {
                description = "timeout for incoming call"
            }

            handleFailedCall(failedCall: call, error: CallError.timeout(description: description), shouldResetUI: true, shouldResetRingRTC: false)

        case .internalFailure:
            handleFailedCall(failedCall: call, error: OWSAssertionError("call manager internal error"), shouldResetUI: true, shouldResetRingRTC: false)

        case .signalingFailure:
            handleFailedCall(failedCall: call, error: CallError.signaling, shouldResetUI: true, shouldResetRingRTC: false)

        case .connectionFailure:
            handleFailedCall(failedCall: call, error: CallError.disconnected, shouldResetUI: true, shouldResetRingRTC: false)

        case .appDroppedCall:
            Logger.debug("")

            // An incoming call was dropped, ignoring because we have already
            // failed the call on the screen.

        case
            .deviceExplicitlyDisconnected,
            .serverExplicitlyDisconnected,
            .deniedRequestToJoinCall,
            .removedFromCall,
            .callManagerIsBusy,
            .sfuClientFailedToJoin,
            .failedToCreatePeerConnectionFactory,
            .failedToNegotiateSrtpKeys,
            .failedToCreatePeerConnection,
            .failedToStartPeerConnection,
            .failedToUpdatePeerConnection,
            .failedToSetMaxSendBitrate,
            .iceFailedWhileConnecting,
            .iceFailedAfterConnected,
            .serverChangedDemuxId,
            .hasMaxDevices:
            Logger.error("Received Group Call reason in a Direct Call context")
        }
    }

    @MainActor
    func callManager(_ callManager: CallService.CallManagerType, onEvent call: SignalCall, event: CallManagerEvent) {
        Logger.info("call: \(call), onEvent: \(event)")

        switch event {
        case .ringingLocal:
            handleRinging(call: call)

        case .ringingRemote:
            handleRinging(call: call)

        case .connectedLocal:
            Logger.debug("")
            // nothing further to do - already handled in handleAcceptCall().

        case .connectedRemote:
            defer {
                callUIAdapter.recipientAcceptedCall(call.mode)
            }

            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            // Set the audio session configuration before audio is enabled in WebRTC
            // via recipientAcceptedCall().
            handleConnected(call: call)

            // Update the call interaction now that we've connected.
            call.individualCall.createOrUpdateCallInteractionAsync(callType: .outgoing)

        case .remoteAudioEnable:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            call.individualCall.isRemoteAudioMuted = false

        case .remoteAudioDisable:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            call.individualCall.isRemoteAudioMuted = true

        case .remoteVideoEnable:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            call.individualCall.isRemoteVideoEnabled = true

        case .remoteVideoDisable:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }

            call.individualCall.isRemoteVideoEnabled = false

        case .remoteSharingScreenEnable:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }
            call.individualCall.isRemoteSharingScreen = true

        case .remoteSharingScreenDisable:
            guard call === callServiceState.currentCall else {
                cleanUpStaleCall(call)
                return
            }
            call.individualCall.isRemoteSharingScreen = false

        case .reconnecting:
            self.handleReconnecting(call: call)

        case .reconnected:
            self.handleReconnected(call: call)

        case .receivedOfferExpired:
            // TODO - This is the case where an incoming offer's timestamp is
            // not within the range +/- 120 seconds of the current system time.
            // At the moment, this is not an issue since we are currently setting
            // the timestamp separately when we receive the offer (above).
            // This should not be a failure, it is just an 'old' call.
            handleMissedCall(call)
            call.individualCall.state = .localFailure
            callServiceState.terminateCall(call)

        case .receivedOfferWhileActive:
            handleMissedCall(call)
            // TODO - This should not be a failure.
            call.individualCall.state = .localFailure
            callServiceState.terminateCall(call)

        case .receivedOfferWithGlare:
            handleMissedCall(call)
            // TODO - This should not be a failure.
            call.individualCall.state = .localFailure
            callServiceState.terminateCall(call)

        case .glareHandlingFailure:
            handleFailedCall(failedCall: call, error: CallError.signaling, shouldResetUI: true, shouldResetRingRTC: false)
        }
    }

    @MainActor
    func callManager(_ callManager: CallService.CallManagerType, onUpdateLocalVideoSession call: SignalCall, session: AVCaptureSession?) {
        Logger.info("onUpdateLocalVideoSession")

        guard call === callServiceState.currentCall else {
            cleanUpStaleCall(call)
            return
        }
    }

    @MainActor
    func callManager(_ callManager: CallService.CallManagerType, onAddRemoteVideoTrack call: SignalCall, track: RTCVideoTrack) {
        Logger.info("onAddRemoteVideoTrack")

        guard call === callServiceState.currentCall else {
            cleanUpStaleCall(call)
            return
        }

        call.individualCall.remoteVideoTrack = track
    }

    // MARK: - Call Manager Signaling

    @MainActor
    func callManager(_ callManager: CallService.CallManagerType, shouldSendOffer callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?, opaque: Data, callMediaType: CallMediaType) {
        Logger.info("shouldSendOffer")

        Task {
            do {
                let offerBuilder = SSKProtoCallMessageOffer.builder(id: callId)
                offerBuilder.setOpaque(opaque)
                switch callMediaType {
                case .audioCall: offerBuilder.setType(.offerAudioCall)
                case .videoCall: offerBuilder.setType(.offerVideoCall)
                }
                let sendPromise = try await self.databaseStorage.awaitableWrite { tx -> Promise<Void> in
                    let callMessage = OutgoingCallMessage(
                        thread: call.individualCall.thread,
                        messageType: .offerMessage(try offerBuilder.build()),
                        destinationDeviceId: destinationDeviceId,
                        tx: tx,
                    )
                    let preparedMessage = PreparedOutgoingMessage.preprepared(
                        transientMessageWithoutAttachments: callMessage,
                    )
                    return ThreadUtil.enqueueMessagePromise(
                        message: preparedMessage,
                        limitToCurrentProcessLifetime: true,
                        isHighPriority: true,
                        transaction: tx,
                    )
                }
                try await sendPromise.awaitable()
                Logger.info("sent offer message to \(call.individualCall.thread.contactAddress) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
                try self.callManager.signalingMessageDidSend(callId: callId)
            } catch {
                Logger.error("failed to send offer message to \(call.individualCall.thread.contactAddress) with error: \(error)")
                self.callManager.signalingMessageDidFail(callId: callId)
            }
        }
    }

    @MainActor
    func callManager(_ callManager: CallService.CallManagerType, shouldSendAnswer callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?, opaque: Data) {
        Logger.info("shouldSendAnswer")

        Task {
            do {
                let answerBuilder = SSKProtoCallMessageAnswer.builder(id: callId)
                answerBuilder.setOpaque(opaque)
                let sendPromise = try await self.databaseStorage.awaitableWrite { tx -> Promise<Void> in
                    let callMessage = OutgoingCallMessage(
                        thread: call.individualCall.thread,
                        messageType: .answerMessage(try answerBuilder.build()),
                        destinationDeviceId: destinationDeviceId,
                        tx: tx,
                    )
                    let preparedMessage = PreparedOutgoingMessage.preprepared(
                        transientMessageWithoutAttachments: callMessage,
                    )
                    return ThreadUtil.enqueueMessagePromise(
                        message: preparedMessage,
                        limitToCurrentProcessLifetime: true,
                        isHighPriority: true,
                        transaction: tx,
                    )
                }
                try await sendPromise.awaitable()
                Logger.debug("sent answer message to \(call.individualCall.thread.contactAddress) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
                try self.callManager.signalingMessageDidSend(callId: callId)
            } catch {
                Logger.error("failed to send answer message to \(call.individualCall.thread.contactAddress) with error: \(error)")
                self.callManager.signalingMessageDidFail(callId: callId)
            }
        }
    }

    @MainActor
    func callManager(_ callManager: CallService.CallManagerType, shouldSendIceCandidates callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?, candidates: [Data]) {
        Logger.info("shouldSendIceCandidates")

        Task {
            do {
                var iceUpdateProtos = [SSKProtoCallMessageIceUpdate]()

                for iceCandidate in candidates {
                    let iceUpdateProto: SSKProtoCallMessageIceUpdate
                    let iceUpdateBuilder = SSKProtoCallMessageIceUpdate.builder(id: callId)
                    iceUpdateBuilder.setOpaque(iceCandidate)

                    iceUpdateProto = try iceUpdateBuilder.build()
                    iceUpdateProtos.append(iceUpdateProto)
                }

                guard !iceUpdateProtos.isEmpty else {
                    throw OWSAssertionError("no ice updates to send")
                }

                let sendPromise = await self.databaseStorage.awaitableWrite { tx -> Promise<Void> in
                    let callMessage = OutgoingCallMessage(
                        thread: call.individualCall.thread,
                        messageType: .iceUpdateMessages(iceUpdateProtos),
                        destinationDeviceId: destinationDeviceId,
                        tx: tx,
                    )
                    let preparedMessage = PreparedOutgoingMessage.preprepared(
                        transientMessageWithoutAttachments: callMessage,
                    )
                    return ThreadUtil.enqueueMessagePromise(
                        message: preparedMessage,
                        limitToCurrentProcessLifetime: true,
                        isHighPriority: true,
                        transaction: tx,
                    )
                }
                try await sendPromise.awaitable()
                Logger.debug("sent ice update message to \(call.individualCall.thread.contactAddress) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
                try self.callManager.signalingMessageDidSend(callId: callId)
            } catch {
                Logger.error("failed to send ice update message to \(call.individualCall.thread.contactAddress) with error: \(error)")
                callManager.signalingMessageDidFail(callId: callId)
            }
        }
    }

    @MainActor
    func callManager(_ callManager: CallService.CallManagerType, shouldSendHangup callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?, hangupType: HangupType, deviceId: UInt32) {
        Logger.info("shouldSendHangup")

        // At time of writing, destinationDeviceId is always nil and deviceId is
        // sometimes 0.

        Task {
            do {
                let sendPromise = await self.databaseStorage.awaitableWrite { tx in
                    return CallHangupSender.sendHangup(
                        thread: call.individualCall.thread,
                        callId: callId,
                        hangupType: { () -> SSKProtoCallMessageHangupType in
                            switch hangupType {
                            case .normal: return .hangupNormal
                            case .accepted: return .hangupAccepted
                            case .declined: return .hangupDeclined
                            case .busy: return .hangupBusy
                            case .needPermission: return .hangupNeedPermission
                            }
                        }(),
                        localDeviceId: deviceId,
                        remoteDeviceId: destinationDeviceId,
                        tx: tx,
                    )
                }
                try await sendPromise.awaitable()
                Logger.debug("sent hangup message to \(call.individualCall.thread.contactAddress) device: \(destinationDeviceId as Optional)")
                try self.callManager.signalingMessageDidSend(callId: callId)
            } catch {
                Logger.error("failed to send hangup message to \(call.individualCall.thread.contactAddress) with error: \(error)")
                self.callManager.signalingMessageDidFail(callId: callId)
            }
        }
    }

    @MainActor
    func callManager(_ callManager: CallService.CallManagerType, shouldSendBusy callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?) {
        Logger.info("shouldSendBusy")

        Task {
            do {
                let busyBuilder = SSKProtoCallMessageBusy.builder(id: callId)

                let sendPromise = try await self.databaseStorage.awaitableWrite { tx -> Promise<Void> in
                    let callMessage = OutgoingCallMessage(
                        thread: call.individualCall.thread,
                        messageType: .busyMessage(try busyBuilder.build()),
                        destinationDeviceId: destinationDeviceId,
                        tx: tx,
                    )
                    let preparedMessage = PreparedOutgoingMessage.preprepared(
                        transientMessageWithoutAttachments: callMessage,
                    )
                    return ThreadUtil.enqueueMessagePromise(
                        message: preparedMessage,
                        limitToCurrentProcessLifetime: true,
                        isHighPriority: true,
                        transaction: tx,
                    )
                }
                try await sendPromise.awaitable()
                Logger.debug("sent busy message to \(call.individualCall.thread.contactAddress) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
                try self.callManager.signalingMessageDidSend(callId: callId)
            } catch {
                Logger.error("failed to send busy message to \(call.individualCall.thread.contactAddress) with error: \(error)")
                self.callManager.signalingMessageDidFail(callId: callId)
            }
        }
    }

    // MARK: - Support Functions

    /**
     * User didn't answer incoming call
     */
    @MainActor
    func handleMissedCall(_ call: SignalCall, error: CallError? = nil) {
        Logger.info("call: \(call)")

        let callType: RPRecentCallType
        switch error {
        case .doNotDisturbEnabled?:
            callType = .incomingMissedBecauseOfDoNotDisturb
        case .contactIsBlocked:
            callType = .incomingMissedBecauseBlockedSystemContact
        default:
            if call.individualCall.direction == .outgoing {
                callType = .outgoingMissed
            } else {
                callType = .incomingMissed
            }
        }

        let oldCallType = call.individualCall.callType
        call.individualCall.createOrUpdateCallInteractionAsync(callType: callType)

        switch oldCallType {
        case .incomingMissed, .none:
            callUIAdapter.reportMissedCall(call, individualCall: call.individualCall)
        case .incomingIncomplete, .incoming:
            callUIAdapter.reportMissedCall(call, individualCall: call.individualCall)
        case .outgoingIncomplete, .incomingDeclined, .incomingDeclinedElsewhere, .incomingAnsweredElsewhere:
            break
        case .incomingMissedBecauseOfChangedIdentity, .outgoingMissed, .outgoing, .incomingBusyElsewhere, .incomingMissedBecauseOfDoNotDisturb, .incomingMissedBecauseBlockedSystemContact:
            owsFailDebug("unexpected RPRecentCallType: \(String(describing: oldCallType))")
        @unknown default:
            owsFailDebug("unknown RPRecentCallType: \(String(describing: oldCallType))")
        }
    }

    @MainActor
    func handleAnsweredElsewhere(call: SignalCall) {
        call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingAnsweredElsewhere)

        call.individualCall.state = .answeredElsewhere

        // Notify UI
        callUIAdapter.didAnswerElsewhere(call: call)

        callServiceState.terminateCall(call)
    }

    @MainActor
    func handleDeclinedElsewhere(call: SignalCall) {
        call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingDeclinedElsewhere)

        call.individualCall.state = .declinedElsewhere

        // Notify UI
        callUIAdapter.didDeclineElsewhere(call: call)

        callServiceState.terminateCall(call)
    }

    @MainActor
    func handleBusyElsewhere(call: SignalCall) {
        call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingBusyElsewhere)

        call.individualCall.state = .busyElsewhere

        // Notify UI
        callUIAdapter.wasBusyElsewhere(call: call)

        callServiceState.terminateCall(call)
    }

    /**
     * Present UI to begin ringing.
     *
     * This can be performed in response to:
     * - Established communication via WebRTC
     * - Anticipation of an expected future ring.
     *
     * In the former case, compatible ICE messages have been exchanged between the local and remote
     * client and we can ring with confidence that the call will connect.
     *
     * In the latter case, the ring is performed before any messages have been exchanged. This is to satisfy
     * callservicesd which requires that we post a CallKit ring shortly after the NSE wakes the main app.
     */
    @MainActor
    private func handleRinging(call: SignalCall, isAnticipatory: Bool = false) {
        // Only incoming calls can use the early ring states
        owsAssertDebug(!(call.individualCall.direction == .outgoing && isAnticipatory))
        Logger.info("call: \(call)")

        guard call === callServiceState.currentCall else {
            cleanUpStaleCall(call)
            return
        }

        switch call.individualCall.state {
        case .dialing:
            call.individualCall.state = .remoteRinging
        case .answering:
            call.individualCall.state = isAnticipatory ? .localRinging_Anticipatory : .localRinging_ReadyToAnswer
            callUIAdapter.reportIncomingCall(call)
        case .localRinging_Anticipatory:
            // RingRTC became ready during our anticipatory ring. User hasn't tried to answer yet.
            owsAssertDebug(isAnticipatory == false)
            call.individualCall.state = .localRinging_ReadyToAnswer
        case .accepting:
            // The user answered during our early ring, but we've been waiting for RingRTC to tell us to start
            // actually ringing before trying to accept. We can do that now.
            handleAcceptCall(call)
        case .remoteRinging:
            Logger.info("call already ringing. Ignoring \(#function): \(call).")
        case .idle, .connected, .reconnecting, .localFailure, .localHangup, .remoteHangup, .remoteHangupNeedPermission, .remoteBusy, .answeredElsewhere, .declinedElsewhere, .busyElsewhere, .localRinging_ReadyToAnswer:
            owsFailDebug("unexpected call state: \(call.individualCall.state): \(call).")
        }
    }

    @MainActor
    private func handleReconnecting(call: SignalCall) {
        Logger.info("call: \(call)")

        guard call === callServiceState.currentCall else {
            cleanUpStaleCall(call)
            return
        }

        switch call.individualCall.state {
        case .remoteRinging, .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting:
            Logger.debug("disconnect while ringing... we'll keep ringing")
        case .connected:
            call.individualCall.state = .reconnecting
        default:
            owsFailDebug("unexpected call state: \(call.individualCall.state): \(call).")
        }
    }

    @MainActor
    private func handleReconnected(call: SignalCall) {
        Logger.info("call: \(call)")

        guard call === callServiceState.currentCall else {
            cleanUpStaleCall(call)
            return
        }

        switch call.individualCall.state {
        case .reconnecting:
            call.individualCall.state = .connected
        default:
            owsFailDebug("unexpected call state: \(call.individualCall.state): \(call).")
        }
    }

    /**
     * For outgoing call, when the callee has chosen to accept the call.
     * For incoming call, when the local user has chosen to accept the call.
     */
    @MainActor
    private func handleConnected(call: SignalCall) {
        owsPrecondition(call === callServiceState.currentCall)
        Logger.info("call: \(call)")

        // End the background task.
        call.individualCall.backgroundTask = nil

        call.individualCall.state = .connected

        // We don't risk transmitting any media until the remote client has admitted to being connected.
        ensureAudioState(call: call)

        callService.updateIsVideoEnabled()
    }

    /**
     * Local user toggled to hold call. Currently only possible via CallKit screen,
     * e.g. when another Call comes in.
     */
    @MainActor
    func setIsOnHold(call: SignalCall, isOnHold: Bool) {
        Logger.info("call: \(call)")

        guard call === callServiceState.currentCall else {
            cleanUpStaleCall(call)
            return
        }

        call.individualCall.isOnHold = isOnHold

        ensureAudioState(call: call)
    }

    @MainActor
    func handleCallKitProviderReset() {
        Logger.debug("")

        // Return to a known good state by ending the current call, if any.
        if let call = callServiceState.currentCall {
            handleFailedCall(failedCall: call, error: CallError.providerReset, shouldResetUI: false, shouldResetRingRTC: true)
        }
    }

    @MainActor
    func cleanUpStaleCall(_ staleCall: SignalCall, function: StaticString = #function, line: UInt = #line) {
        assert(staleCall !== callServiceState.currentCall)
        if let currentCall = callServiceState.currentCall {
            let error = OWSAssertionError("trying \(function):\(line) for call: \(staleCall) which is not currentCall: \(currentCall as Optional)")
            handleFailedCall(failedCall: staleCall, error: error, shouldResetUI: false, shouldResetRingRTC: true)
        } else {
            Logger.info("ignoring \(function):\(line) for call: \(staleCall) since currentCall has ended.")
        }
    }

    // This method should be called when an error occurred for a call from
    // the UI/UX or the RingRTC library.
    //
    // * If we know which call it was, we should update that call's state
    //   to reflect the error.
    // * IFF that call is the current call, we want to terminate it.
    @MainActor
    func handleFailedCall(failedCall: SignalCall, error: Error, shouldResetUI: Bool, shouldResetRingRTC: Bool) {
        Logger.debug("")

        let callError = CallError.wrapErrorIfNeeded(error)

        switch failedCall.individualCall.state {
        case .answering, .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting:
            assert(failedCall.individualCall.callType == nil)
            // call failed before any call record could be created, make one now.
            handleMissedCall(failedCall, error: callError)
        default:
            assert(failedCall.individualCall.callType != nil)
        }

        guard !failedCall.individualCall.isEnded else {
            Logger.debug("ignoring error: \(error) for already terminated call: \(failedCall)")
            return
        }

        failedCall.individualCall.error = callError
        failedCall.individualCall.state = .localFailure

        if shouldResetUI {
            callUIAdapter.failCall(failedCall, error: callError)
        }

        if callError.shouldSilentlyDropCall(), let callId = failedCall.individualCall.callId {
            // Drop the call explicitly to avoid sending a hangup.
            callManager.drop(callId: callId)
        } else if shouldResetRingRTC {
            callManager.reset()
        }

        Logger.error("call: \(failedCall) failed with error: \(error)")
        callServiceState.terminateCall(failedCall)
    }

    @MainActor
    func ensureAudioState(call: SignalCall) {
        let isLocalAudioMuted = call.individualCall.state != .connected || call.individualCall.isMuted || call.individualCall.isOnHold
        callManager.setLocalAudioEnabled(enabled: !isLocalAudioMuted)
    }

    // MARK: CallViewController Timer

    private var activeCallTimer: Timer?
    @MainActor
    func startCallTimer(for call: SignalCall) {
        var hasUsedUpTimerSlop: Bool = false

        assert(self.activeCallTimer == nil)
        self.activeCallTimer = WeakTimer.scheduledTimer(timeInterval: 1, target: self, userInfo: nil, repeats: true) { timer in
            guard call === self.callServiceState.currentCall else {
                owsFailDebug("call has since ended. Timer should have been invalidated.")
                timer.invalidate()
                return
            }
            self.ensureCallScreenPresented(call: call, hasUsedUpTimerSlop: &hasUsedUpTimerSlop)
        }
    }

    @MainActor
    private func ensureCallScreenPresented(call: SignalCall, hasUsedUpTimerSlop: inout Bool) {
        guard let connectedDate = call.commonState.connectedDate else {
            // Ignore; call hasn't connected yet.
            return
        }

        let kMaxViewPresentationDelay: TimeInterval = 5
        guard MonotonicDate() - connectedDate > MonotonicDuration(clampingSeconds: kMaxViewPresentationDelay) else {
            // Ignore; call connected recently.
            return
        }

        guard !AppEnvironment.shared.windowManagerRef.hasCall else {
            // call screen is visible
            return
        }

        guard hasUsedUpTimerSlop else {
            // We hide the call screen synchronously, as soon as the user hangs up the call
            // But it takes a while to communicate the hangup from the UI -> CallKit -> CallService
            // However it's possible the timer fired the *instant* after the user hit the hangup
            // button, so we allow one tick of the timer cycle as slop.
            Logger.verbose("using up timer slop")
            hasUsedUpTimerSlop = true
            return
        }

        owsFailDebug("Call terminated due to missing call view.")
        self.handleFailedCall(
            failedCall: call,
            error: OWSAssertionError("Call view didn't present after \(kMaxViewPresentationDelay) seconds"),
            shouldResetUI: true,
            shouldResetRingRTC: true,
        )
    }

    private func stopAnyCallTimer() {
        AssertIsOnMainThread()

        self.activeCallTimer?.invalidate()
        self.activeCallTimer = nil
    }

    enum InteractionUpdateMethod {
        case writeAsync
        case inTransaction(DBWriteTransaction)
    }
}

extension NSNumber {
    convenience init?(value: UInt32?) {
        guard let value else { return nil }
        self.init(value: value)
    }
}

extension TSRecentCallOfferType {
    var asCallMediaType: CallMediaType {
        switch self {
        case .audio: return .audioCall
        case .video: return .videoCall
        }
    }
}

private extension SignalCall {
    /// Whether this call is an individual call.
    /// - SeeAlso ``individualCall``
    var isIndividualCall: Bool {
        switch self.mode {
        case .individual: return true
        case .groupThread, .callLink: return false
        }
    }

    /// - Important
    /// Callers must be *sure* that this `SignalCall` represents an individual
    /// call, either contextually or by consulting ``isIndividualCall``.
    /// - SeeAlso ``isIndividualCall``
    var individualCall: IndividualCall {
        switch self.mode {
        case .individual(let individualCall):
            return individualCall
        case .groupThread, .callLink:
            owsFail("Must have individual call.")
        }
    }
}
