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

import SignalServiceKit

// The outcome of a CVC load is an update that describes how to 
// transition from the last render state to the new render state.
struct CVUpdate {

    enum CVUpdateType {
        // No view items in the load window were effected.
        case minor
        // Complicated or unexpected changes occurred in the load window;
        // the view should be reloaded.
        case reloadAll
        // A subset of view items in the load window were effected;
        // the view should be updated using the update items.
        //
        // TODO: Do we need shouldAnimateUpdates? How does this fit with the scroll action?
        case diff(items: [Item], shouldAnimateUpdate: Bool)

        // MARK: -

        public var debugName: String {
            switch self {
            case .minor:
                return "minor"
            case .reloadAll:
                return "reloadAll"
            case .diff:
                return "diff"
            }
        }
    }

    let type: CVUpdateType

    let renderState: CVRenderState
    let prevRenderState: CVRenderState

    let loadRequest: CVLoadRequest
    var loadType: CVLoadType { loadRequest.loadType }

    typealias Item = BatchUpdate<CVRenderItem>.Item
}

// MARK: -

extension CVUpdate {
    typealias ItemId = String

    // TODO: Eliminate.
    private static func itemId(for renderItem: CVRenderItem) -> ItemId {
        renderItem.interactionUniqueId
    }

    static func build(
        renderState: CVRenderState,
        prevRenderState: CVRenderState,
        loadRequest: CVLoadRequest
    ) -> CVUpdate {

        func buildUpdate(type: CVUpdateType) -> CVUpdate {
            CVUpdate(type: type,
                     renderState: renderState,
                     prevRenderState: prevRenderState,
                     loadRequest: loadRequest)
        }

        let loadType = loadRequest.loadType
        let oldStyle = prevRenderState.conversationStyle
        let newStyle = renderState.conversationStyle
        let didStyleChange = !newStyle.isEqualForCellRendering(oldStyle)

        if case .loadInitialMapping = loadType {
            // Don't do an incremental update for the initial load.
            return buildUpdate(type: .reloadAll)
        }

        var appearanceChangedItemIdSet = Set<ItemId>()
        var changedRenderItems = [CVRenderItem]()
        var oldRenderItemMap = [String: CVRenderItem]()
        for oldRenderItem in prevRenderState.items {
            oldRenderItemMap[itemId(for: oldRenderItem)] = oldRenderItem
        }
        for newRenderItem in renderState.items {
            let itemId = itemId(for: newRenderItem)
            guard let oldRenderItem = oldRenderItemMap[itemId] else {
                continue
            }

            // Whenever the style changes we should update all cells.
            if didStyleChange {
                changedRenderItems.append(newRenderItem)
                continue
            }

            switch newRenderItem.updateMode(other: oldRenderItem) {
            case .equal:
                continue
            case .stateChanged:
                // The item changed, so we need to update it.
                changedRenderItems.append(newRenderItem)
            case .appearanceChanged:
                // Take note of the fact that only the _appearance_ of the
                // item changed, not its state.
                appearanceChangedItemIdSet.insert(itemId)

                // The item changed, so we need to update it.
                changedRenderItems.append(newRenderItem)
            }
        }

        do {
            let batchUpdateItems = try BatchUpdate.build(viewType: .uiCollectionView,
                                                         oldValues: prevRenderState.items,
                                                         newValues: renderState.items,
                                                         changedValues: changedRenderItems)

            guard !batchUpdateItems.isEmpty else {
                return buildUpdate(type: .minor)
            }
            let oldItems = prevRenderState.items
            let shouldAnimateUpdate = Self.shouldAnimateUpdate(loadType: loadType,
                                                               updateItems: batchUpdateItems,
                                                               oldItemCount: oldItems.count,
                                                               appearanceChangedItemIdSet: appearanceChangedItemIdSet)

            return buildUpdate(type: .diff(
                items: batchUpdateItems,
                shouldAnimateUpdate: shouldAnimateUpdate
            ))
        } catch {
            owsFailDebug("Error: \(error)")
            return buildUpdate(type: .reloadAll)
        }
    }

    private static func shouldAnimateUpdate(loadType: CVLoadType,
                                            updateItems: [CVUpdate.Item],
                                            oldItemCount: Int,
                                            appearanceChangedItemIdSet: Set<ItemId>) -> Bool {

        switch loadType {
        case .loadInitialMapping, .loadOlder, .loadNewer, .loadNewest, .loadPageAroundInteraction:
            return false
        case .loadSameLocation:
            break
        }

        // If user sends a new outgoing message, animate the change.
        var shouldAnimateUpdate = true
        var onlyAppearanceUpdateChanges = true
        var previousItemCount = oldItemCount
        for updateItem in updateItems {
            guard shouldAnimateUpdate else {
                // Exit early if we already know that we're not just
                // inserting a new message at the bottom of the conversation.
                break
            }

            let renderItem = updateItem.value
            switch updateItem.updateType {
            case .delete:
                onlyAppearanceUpdateChanges = false
                previousItemCount = oldItemCount - 1
                continue
            case .insert(let newIndex):
                onlyAppearanceUpdateChanges = false
                switch renderItem.interactionType {
                case .incomingMessage, .outgoingMessage, .typingIndicator:
                    // Allow animated insert if this item is one from the last item, as last item is likely a typing indicator
                    if newIndex < previousItemCount - 1 {
                        shouldAnimateUpdate = false
                    }
                case .unreadIndicator:
                    break
                default:
                    shouldAnimateUpdate = true
                }
            case .move:
                onlyAppearanceUpdateChanges = false
            case .update(_, let newIndex):
                let itemId = Self.itemId(for: renderItem)
                let didOnlyAppearanceChange = appearanceChangedItemIdSet.contains(itemId)
                if didOnlyAppearanceChange {
                    continue
                }

                onlyAppearanceUpdateChanges = false
                switch renderItem.interactionType {
                case .incomingMessage, .outgoingMessage, .typingIndicator:
                    // We skip animations for the last _two_
                    // interactions, not one since there
                    // may be a typing indicator.
                    if newIndex + 2 > updateItems.count {
                        shouldAnimateUpdate = false
                    }
                default:
                    shouldAnimateUpdate = false
                }
            }
        }
        return shouldAnimateUpdate && !onlyAppearanceUpdateChanges
    }
}

// MARK: -

extension CVRenderItem: BatchUpdateValue {
    public var batchUpdateId: String {
        interactionUniqueId
    }
}
