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

import LibSignalClient

enum OutgoingCallEventSyncMessageEvent {
    case callUpdated
    case callDeleted
}

protocol OutgoingCallEventSyncMessageManager {
    typealias CallEvent = OutgoingCallEventSyncMessageEvent

    /// Send a sync message with the state on the given call record.
    ///
    /// - Parameter callEventTimestamp
    /// The time at which the event that triggered this sync message occurred.
    func sendSyncMessage(
        callRecord: CallRecord,
        callEvent: CallEvent,
        callEventTimestamp: UInt64,
        tx: DBWriteTransaction,
    )
}

final class OutgoingCallEventSyncMessageManagerImpl: OutgoingCallEventSyncMessageManager {
    private let appReadiness: AppReadiness
    private let databaseStorage: SDSDatabaseStorage
    private let messageSenderJobQueue: MessageSenderJobQueue
    private let callRecordConversationIdAdapter: any CallRecordSyncMessageConversationIdAdapter
    private var logger: CallRecordLogger { .shared }

    init(
        appReadiness: AppReadiness,
        databaseStorage: SDSDatabaseStorage,
        messageSenderJobQueue: MessageSenderJobQueue,
        callRecordConversationIdAdapter: any CallRecordSyncMessageConversationIdAdapter,
    ) {
        self.appReadiness = appReadiness
        self.databaseStorage = databaseStorage
        self.messageSenderJobQueue = messageSenderJobQueue
        self.callRecordConversationIdAdapter = callRecordConversationIdAdapter
    }

    func sendSyncMessage(
        callRecord: CallRecord,
        callEvent: CallEvent,
        callEventTimestamp: UInt64,
        tx: DBWriteTransaction,
    ) {
        let outgoingCallEventType: OutgoingCallEvent.EventType? = {
            switch callEvent {
            case .callDeleted:
                return .deleted
            case .callUpdated:
                return OutgoingCallEvent.EventType(callRecord.callStatus)
            }
        }()
        guard let outgoingCallEventType else {
            return
        }

        let conversationId: Data
        do {
            conversationId = try callRecordConversationIdAdapter.getConversationId(callRecord: callRecord, tx: tx)
        } catch {
            owsFailDebug("\(error)")
            return
        }

        let outgoingCallEvent = OutgoingCallEvent(
            timestamp: callEventTimestamp,
            conversationId: conversationId,
            callId: callRecord.callId,
            callType: OutgoingCallEvent.CallType(callRecord.callType),
            eventDirection: OutgoingCallEvent.EventDirection(callRecord.callDirection),
            eventType: outgoingCallEventType,
        )

        sendSyncMessage(
            outgoingCallEvent: outgoingCallEvent,
            tx: tx,
        )
    }

    /// Enqueue the given call event to be sent to linked devices in a sync
    /// message.
    ///
    /// - Note
    /// This can be called before the app is finished launching. For example,
    /// IncompleteCallsJob runs on launch and can modify call state in the
    /// database that triggers a sync message. In that scenario, though, we
    /// won't have the required state set up yet to send a message.
    ///
    /// Instead, we can construct the sync message payload now, but fire the
    /// sync message send when we're ready. That's fine, since these messages
    /// can be delayed, and remote devices should be robust to out-of-order
    /// updates.
    private func sendSyncMessage(
        outgoingCallEvent callEvent: OutgoingCallEvent,
        tx syncTx: DBWriteTransaction,
    ) {
        if appReadiness.isAppReady {
            logger.info("Enqueuing call event sync message: \(callEvent.callType), \(callEvent.eventDirection), \(callEvent.eventType).")

            _sendSyncMessage(outgoingCallEvent: callEvent, tx: syncTx)
        } else {
            logger.info("Delaying call event sync message because app isn't ready.")

            appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
                self.databaseStorage.write { asyncTx in
                    self._sendSyncMessage(outgoingCallEvent: callEvent, tx: asyncTx)
                }
            }
        }
    }

    private func _sendSyncMessage(
        outgoingCallEvent: OutgoingCallEvent,
        tx: DBWriteTransaction,
    ) {
        guard let localThread = TSContactThread.getOrCreateLocalThread(transaction: tx) else {
            owsFailDebug("Missing local thread for sync message!")
            return
        }

        let outgoingCallEventMessage = OutgoingCallEventSyncMessage(
            localThread: localThread,
            event: outgoingCallEvent,
            tx: tx,
        )
        let preparedMessage = PreparedOutgoingMessage.preprepared(
            transientMessageWithoutAttachments: outgoingCallEventMessage,
        )
        messageSenderJobQueue.add(
            message: preparedMessage,
            transaction: tx,
        )
    }
}

// MARK: - Conversions for outgoing sync message types

private extension OutgoingCallEvent.CallType {
    init(_ callType: CallRecord.CallType) {
        switch callType {
        case .audioCall: self = .audio
        case .videoCall: self = .video
        case .groupCall: self = .group
        case .adHocCall: self = .adHocCall
        }
    }
}

private extension OutgoingCallEvent.EventDirection {
    init(_ callDirection: CallRecord.CallDirection) {
        switch callDirection {
        case .incoming: self = .incoming
        case .outgoing: self = .outgoing
        }
    }
}

private extension OutgoingCallEvent.EventType {
    init?(_ callStatus: CallRecord.CallStatus) {
        switch callStatus {
        case
            .individual(.pending),
            .individual(.incomingMissed),
            .group(.generic),
            .group(.ringing),
            .group(.ringingMissed),
            .group(.ringingMissedNotificationProfile):
            // Local-only statuses
            return nil
        case
            .individual(.accepted),
            .group(.joined),
            .callLink(.joined),
            .group(.ringingAccepted):
            self = .accepted
        case
            .individual(.notAccepted),
            .group(.ringingDeclined):
            self = .notAccepted
        case
            .callLink(.generic):
            // [CallLink] TODO: Verify the correct message is sent in this case.
            self = .observed
        }
    }
}
