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

import LibSignalClient
import SignalCoreKit

extension DeleteForMeSyncMessage {
    public enum Outgoing {
        public struct ThreadDeletionContext {
            fileprivate enum DeletionType {
                case conversation(ConversationDelete)
                case localOnlyConversation(LocalOnlyConversationDelete)
            }

            fileprivate let wrapped: DeletionType

            fileprivate init(_ conversation: ConversationDelete) {
                wrapped = .conversation(conversation)
            }

            fileprivate init(_ localOnly: LocalOnlyConversationDelete) {
                wrapped = .localOnlyConversation(localOnly)
            }
        }
    }
}

/// Responsible for sending `DeleteForMe` sync messages related to deletions
/// originating on this device.
public protocol DeleteForMeOutgoingSyncMessageManager {
    typealias Outgoing = DeleteForMeSyncMessage.Outgoing

    /// Send a sync message for the given deleted interactions.
    /// - Important
    /// All the given interactions must belong to the given thread.
    func send(
        deletedInteractions: [TSInteraction],
        thread: TSThread,
        tx: any DBWriteTransaction
    )

    /// Send a sync message for the given thread deletion contexts.
    func send(
        threadDeletionContexts: [Outgoing.ThreadDeletionContext],
        tx: any DBWriteTransaction
    )

    /// Build a deletion context for the given thread.
    ///
    /// This must be done before the thread is deleted, since it will look up
    /// the interactions the thread contains.
    func buildThreadDeletionContext(
        thread: TSThread,
        isFullDelete: Bool,
        tx: any DBReadTransaction
    ) -> Outgoing.ThreadDeletionContext?
}

final class DeleteForMeOutgoingSyncMessageManagerImpl: DeleteForMeOutgoingSyncMessageManager {
    private let addressableMessageFinder: any DeleteForMeAddressableMessageFinder
    private let deleteForMeSyncMessageSettingsStore: any DeleteForMeSyncMessageSettingsStore
    private let recipientDatabaseTable: any RecipientDatabaseTable
    private let syncMessageSender: any Shims.SyncMessageSender
    private let threadStore: any ThreadStore
    private let tsAccountManager: any TSAccountManager

    private let logger = PrefixedLogger(prefix: "[DeleteForMe]")

    init(
        addressableMessageFinder: any DeleteForMeAddressableMessageFinder,
        deleteForMeSyncMessageSettingsStore: any DeleteForMeSyncMessageSettingsStore,
        recipientDatabaseTable: any RecipientDatabaseTable,
        syncMessageSender: any Shims.SyncMessageSender,
        threadStore: any ThreadStore,
        tsAccountManager: any TSAccountManager
    ) {
        self.addressableMessageFinder = addressableMessageFinder
        self.deleteForMeSyncMessageSettingsStore = deleteForMeSyncMessageSettingsStore
        self.recipientDatabaseTable = recipientDatabaseTable
        self.syncMessageSender = syncMessageSender
        self.threadStore = threadStore
        self.tsAccountManager = tsAccountManager
    }

    func send(
        deletedInteractions: [TSInteraction],
        thread: TSThread,
        tx: any DBWriteTransaction
    ) {
        guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else {
            logger.error("Skipping individual-message delete sync: not registered!")
            return
        }

        guard let conversationIdentifier = conversationIdentifier(thread: thread, tx: tx) else {
            return
        }

        let addressableMessages = deletedInteractions.compactMap { interaction -> Outgoing.AddressableMessage? in
            if let incomingMessage = interaction as? TSIncomingMessage {
                return Outgoing.AddressableMessage(incomingMessage: incomingMessage)
            } else if let outgoingMessage = interaction as? TSOutgoingMessage {
                return Outgoing.AddressableMessage(
                    outgoingMessage: outgoingMessage,
                    localIdentifiers: localIdentifiers
                )
            }

            return nil
        }

        if addressableMessages.isEmpty { return }

        for addressableMessageBatch in Batcher().batch(addressableMessages: addressableMessages) {
            /// The sync message supports sending individual-message deletes
            /// across multiple conversations in one message, but we don't have
            /// any UX affordances that'd let you do so in practice.
            let messageDeletes = Outgoing.MessageDeletes(
                conversationIdentifier: conversationIdentifier,
                addressableMessages: addressableMessageBatch
            )

            sendSyncMessage(
                contents: DeleteForMeOutgoingSyncMessage.Contents(
                    messageDeletes: [messageDeletes],
                    conversationDeletes: [],
                    localOnlyConversationDelete: []
                ),
                tx: tx
            )
        }
    }

    func send(
        threadDeletionContexts: [Outgoing.ThreadDeletionContext],
        tx: any DBWriteTransaction
    ) {
        for deletionContextBatch in Batcher().batch(threadDeletionContexts: threadDeletionContexts) {
            var conversationDeletes = [Outgoing.ConversationDelete]()
            var localOnlyConversationDeletes = [Outgoing.LocalOnlyConversationDelete]()

            for context in deletionContextBatch {
                switch context.wrapped {
                case .conversation(let conversation):
                    conversationDeletes.append(conversation)
                case .localOnlyConversation(let localOnly):
                    localOnlyConversationDeletes.append(localOnly)
                }
            }

            sendSyncMessage(
                contents: DeleteForMeOutgoingSyncMessage.Contents(
                    messageDeletes: [],
                    conversationDeletes: conversationDeletes,
                    localOnlyConversationDelete: localOnlyConversationDeletes
                ),
                tx: tx
            )
        }
    }

    func buildThreadDeletionContext(
        thread: TSThread,
        isFullDelete: Bool,
        tx: any DBReadTransaction
    ) -> Outgoing.ThreadDeletionContext? {
        guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else {
            logger.error("Skipping deletion context creation: not registered!")
            return nil
        }

        guard let conversationIdentifier = conversationIdentifier(thread: thread, tx: tx) else {
            return nil
        }

        let mostRecentAddressableMessages: [Outgoing.AddressableMessage] = addressableMessageFinder
            .findMostRecentAddressableMessages(
                threadUniqueId: thread.uniqueId,
                maxCount: 5,
                localIdentifiers: localIdentifiers,
                tx: tx
            )

        if mostRecentAddressableMessages.isEmpty {
            return Outgoing.ThreadDeletionContext(Outgoing.LocalOnlyConversationDelete(
                conversationIdentifier: conversationIdentifier
            ))
        } else {
            return Outgoing.ThreadDeletionContext(Outgoing.ConversationDelete(
                conversationIdentifier: conversationIdentifier,
                mostRecentAddressableMessages: mostRecentAddressableMessages,
                isFullDelete: isFullDelete
            ))
        }
    }

    // MARK: -

    private func conversationIdentifier(
        thread: TSThread,
        tx: any DBReadTransaction
    ) -> Outgoing.ConversationIdentifier? {
        if
            let contactThread = thread as? TSContactThread,
            let contactServiceId = recipientDatabaseTable.fetchServiceId(contactThread: contactThread, tx: tx)
        {
            return .threadServiceId(serviceId: contactServiceId.serviceIdUppercaseString)
        } else if
            let contactThread = thread as? TSContactThread,
            let contactE164 = contactThread.contactPhoneNumber
        {
            return .threadE164(e164: contactE164)
        } else if let groupThread = thread as? TSGroupThread {
            return .threadGroupId(groupId: groupThread.groupId)
        }

        logger.warn("No conversation identifier for thread of type: \(type(of: thread)).")
        return nil
    }

    private func sendSyncMessage(
        contents: DeleteForMeOutgoingSyncMessage.Contents,
        tx: any DBWriteTransaction
    ) {
        guard deleteForMeSyncMessageSettingsStore.isSendingEnabled(tx: tx) else {
            logger.warn("Skipping delete-for-me sync message, feature not enabled!")
            return
        }

        guard let localThread = threadStore.getOrCreateLocalThread(tx: tx) else {
            logger.error("Missing local thread!")
            return
        }

        logger.info("Sending sync message: \(contents.messageDeletes.count) message deletes; \(contents.conversationDeletes.count) conversation deletes; \(contents.localOnlyConversationDelete.count) local-only conversation deletes.")

        syncMessageSender.sendSyncMessage(
            contents: contents,
            localThread: localThread,
            tx: tx
        )
    }
}

// MARK: - Batching

private extension DeleteForMeOutgoingSyncMessageManagerImpl {
    struct Batcher {
        /// The max number of deletes to include in a single sync message. Derived
        /// from envelope-math to estimate the max number that can fit into a single
        /// sync message, from an allowed-proto-size perspective, with wide margins.
        private enum MaxSyncMessageSizeConstants {
            static let maxInteractionsPerSyncMessage: Int = 500
            static let maxThreadContextsPerSyncMessage: Int = 100
        }

        func batch(addressableMessages: [Outgoing.AddressableMessage]) -> [[Outgoing.AddressableMessage]] {
            return batch(
                addressableMessages,
                maxBatchSize: MaxSyncMessageSizeConstants.maxInteractionsPerSyncMessage
            )
        }

        func batch(threadDeletionContexts: [Outgoing.ThreadDeletionContext]) -> [[Outgoing.ThreadDeletionContext]] {
            return batch(
                threadDeletionContexts,
                maxBatchSize: MaxSyncMessageSizeConstants.maxThreadContextsPerSyncMessage
            )
        }

        private func batch<T>(
            _ items: any Sequence<T>,
            maxBatchSize: Int
        ) -> [[T]] {
            var batches = [[T]]()

            var currentBatch = [T]()
            for item in items {
                if currentBatch.count < maxBatchSize {
                    currentBatch.append(item)
                } else {
                    batches.append(currentBatch)
                    currentBatch = [item]
                }
            }
            batches.append(currentBatch)

            return batches
        }
    }
}

// MARK: - Shims

extension DeleteForMeOutgoingSyncMessageManagerImpl {
    enum Shims {
        typealias SyncMessageSender = _DeleteForMeOutgoingSyncMessageManagerImpl_SyncMessageSender_Shim
    }

    enum Wrappers {
        typealias SyncMessageSender = _DeleteForMeOutgoingSyncMessageManagerImpl_SyncMessageSender_Wrapper
    }
}

protocol _DeleteForMeOutgoingSyncMessageManagerImpl_SyncMessageSender_Shim {
    func sendSyncMessage(
        contents: DeleteForMeOutgoingSyncMessage.Contents,
        localThread: TSContactThread,
        tx: any DBWriteTransaction
    )
}

final class _DeleteForMeOutgoingSyncMessageManagerImpl_SyncMessageSender_Wrapper: _DeleteForMeOutgoingSyncMessageManagerImpl_SyncMessageSender_Shim {
    private let messageSenderJobQueue: MessageSenderJobQueue

    init(_ messageSenderJobQueue: MessageSenderJobQueue) {
        self.messageSenderJobQueue = messageSenderJobQueue
    }

    func sendSyncMessage(
        contents: DeleteForMeOutgoingSyncMessage.Contents,
        localThread: TSContactThread,
        tx: any DBWriteTransaction
    ) {
        let sdsTx = SDSDB.shimOnlyBridge(tx)

        guard let syncMessage = DeleteForMeOutgoingSyncMessage(
            contents: contents,
            thread: localThread,
            tx: sdsTx
        ) else { return }

        messageSenderJobQueue.add(
            message: .preprepared(transientMessageWithoutAttachments: syncMessage),
            transaction: sdsTx
        )
    }
}

// MARK: - Mock

#if TESTABLE_BUILD

open class MockDeleteForMeOutgoingSyncMessageManager: DeleteForMeOutgoingSyncMessageManager {
    var sendInteractionMock: ((
        _ interactions: [TSInteraction]
    ) -> Void)!
    public func send(deletedInteractions: [TSInteraction], thread: TSThread, tx: any DBWriteTransaction) {
        sendInteractionMock(deletedInteractions)
    }

    var sendDeletionContextMock: ((
        _ threadContexts: [Outgoing.ThreadDeletionContext]
    ) -> Void)!
    public func send(threadDeletionContexts: [Outgoing.ThreadDeletionContext], tx: any DBWriteTransaction) {
        sendDeletionContextMock(threadDeletionContexts)
    }

    public func buildThreadDeletionContext(thread: TSThread, isFullDelete: Bool, tx: any DBReadTransaction) -> Outgoing.ThreadDeletionContext? {
        let conversationIdentifier: Outgoing.ConversationIdentifier = if let contactThread = thread as? TSContactThread {
            .threadServiceId(serviceId: contactThread.contactUUID!)
        } else if let groupThread = thread as? TSGroupThread {
            .threadGroupId(groupId: groupThread.groupId)
        } else {
            owsFail("Invalid thread!")
        }

        return Outgoing.ThreadDeletionContext(Outgoing.LocalOnlyConversationDelete(conversationIdentifier: conversationIdentifier))
    }
}

#endif
