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

import LibSignalClient

enum OutgoingCallEventSyncMessageEvent {
    case callUpdated
    case callDeleted
}

protocol OutgoingCallEventSyncMessageManager {
    typealias ConversationId = CallSyncMessageConversationId
    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(
        conversationId: ConversationId,
        callRecord: CallRecord,
        callEvent: CallEvent,
        callEventTimestamp: UInt64,
        tx: DBWriteTransaction
    )

    func sendSyncMessage(
        contactThread: TSContactThread,
        callRecord: CallRecord,
        callEvent: CallEvent,
        tx: DBWriteTransaction
    )
}

extension OutgoingCallEventSyncMessageManager {
    func sendSyncMessage(
        groupThread: TSGroupThread,
        callRecord: CallRecord,
        callEvent: CallEvent,
        callEventTimestamp: UInt64,
        tx: DBWriteTransaction
    ) {
        sendSyncMessage(
            conversationId: .group(groupId: groupThread.groupId),
            callRecord: callRecord,
            callEvent: callEvent,
            callEventTimestamp: callEventTimestamp,
            tx: tx
        )
    }
}

final class OutgoingCallEventSyncMessageManagerImpl: OutgoingCallEventSyncMessageManager {
    private let databaseStorage: SDSDatabaseStorage
    private let messageSenderJobQueue: MessageSenderJobQueue
    private let recipientDatabaseTable: RecipientDatabaseTable

    private var logger: CallRecordLogger { .shared }

    init(
        databaseStorage: SDSDatabaseStorage,
        messageSenderJobQueue: MessageSenderJobQueue,
        recipientDatabaseTable: RecipientDatabaseTable
    ) {
        self.databaseStorage = databaseStorage
        self.messageSenderJobQueue = messageSenderJobQueue
        self.recipientDatabaseTable = recipientDatabaseTable
    }

    func sendSyncMessage(
        contactThread: TSContactThread,
        callRecord: CallRecord,
        callEvent: CallEvent,
        tx: DBWriteTransaction
    ) {
        guard let contactServiceId = recipientDatabaseTable.fetchServiceId(
            contactThread: contactThread, tx: tx
        ) else {
            owsFailBeta("Missing contact service ID - how did we get here?")
            return
        }

        // [Calls] TODO: pass through the "call event timestamp" for 1:1 call events
        //
        // We currently use the timestamp of the call record when sending all
        // sync messages related to a 1:1 call. That's not quite right – we
        // should be using the timestamp of the event that triggered the sync
        // message, such as the user declining.
        //
        // This isn't a big deal for 1:1 calls though, since all 1:1 calls have
        // a well-defined "start time" that both participants know about: the
        // timestamp of the call offer message. That means no one will in
        // practice consume this timestamp for 1:1 calls, and we can get away
        // with it for now.

        sendSyncMessage(
            conversationId: .individual(contactServiceId: contactServiceId),
            callRecord: callRecord,
            callEvent: callEvent,
            callEventTimestamp: callRecord.callBeganTimestamp,
            tx: tx
        )
    }

    func sendSyncMessage(
        conversationId: ConversationId,
        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 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
    ) {
        let syncTx = SDSDB.shimOnlyBridge(syncTx)

        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: SDSAnyWriteTransaction
    ) {
        guard let localThread = TSContactThread.getOrCreateLocalThread(transaction: tx) else {
            owsFailDebug("Missing local thread for sync message!")
            return
        }

        let outgoingCallEventMessage = OutgoingCallEventSyncMessage(
            thread: 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
        }
    }
}

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):
            // Local-only statuses
            return nil
        case
                .individual(.accepted),
                .group(.joined),
                .group(.ringingAccepted):
            self = .accepted
        case
                .individual(.notAccepted),
                .group(.ringingDeclined):
            self = .notAccepted
        }
    }
}
