//
//  NotificationsCollectionViewController.swift
//  Tusker
//
//  Created by Shadowfacts on 5/6/23.
//  Copyright © 2023 Shadowfacts. All rights reserved.
//

import UIKit
import Pachyderm
import Combine
import Sentry

class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
    
    weak var mastodonController: MastodonController!
    private let filterer: Filterer
    
    private let allowedTypes: [Pachyderm.Notification.Kind]
    private let groupTypes = [Pachyderm.Notification.Kind.favourite, .reblog, .follow]
    
    private(set) var controller: TimelineLikeController<TimelineItem>!
    let confirmLoadMore = PassthroughSubject<Void, Never>()
    
    private(set) var collectionView: UICollectionView!
    private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
    
    var reconfigureVisibleItemsOnEndDecelerating: Bool = false
    
    private var newer: RequestRange?
    private var older: RequestRange?
    
    init(allowedTypes: [Pachyderm.Notification.Kind], mastodonController: MastodonController) {
        self.allowedTypes = allowedTypes
        self.mastodonController = mastodonController
        
        self.filterer = Filterer(mastodonController: mastodonController, context: .notifications)
        self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
        self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
        self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
        
        super.init(nibName: nil, bundle: nil)
        
        self.controller = TimelineLikeController(delegate: self, ownerType: String(describing: self))
        
        addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Notifications"))
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        var config = UICollectionLayoutListConfiguration(appearance: .plain)
        config.backgroundColor = .appBackground
        config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
            (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
        }
        config.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in
            let dismissAction = UIContextualAction(style: .destructive, title: "Dismiss") { _, _, completion in
                Task {
                    await self.dismissNotificationsInGroup(at: indexPath)
                    completion(true)
                }
            }
            dismissAction.accessibilityLabel = "Dismiss Notification"
            dismissAction.image = UIImage(systemName: "clear.fill")
            
            let cellConfig = (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
            let config = UISwipeActionsConfiguration(actions: (cellConfig?.actions ?? []) + [dismissAction])
            config.performsFirstActionWithFullSwipe = cellConfig?.performsFirstActionWithFullSwipe ?? false
            return config
        }
        config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in
            guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
                return sectionSeparatorConfiguration
            }
            var config = sectionSeparatorConfiguration
            if item.hidesSeparators {
                config.topSeparatorVisibility = .hidden
                config.bottomSeparatorVisibility = .hidden
            } else if case .group(_, _, .some(let filterState)) = item,
                      self.filterer.isKnownHide(state: filterState) {
                config.topSeparatorVisibility = .hidden
                config.bottomSeparatorVisibility = .hidden
            } else {
                config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
                config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
            }
            return config
        }
        let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
            let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
            section.readableContentInset(in: environment)
            return section
        }
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.delegate = self
        collectionView.dragDelegate = self
        collectionView.allowsFocus = true
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(collectionView)
        NSLayoutConstraint.activate([
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
        
        registerTimelineLikeCells()
        dataSource = createDataSource()
        
        #if !targetEnvironment(macCatalyst)
        collectionView.refreshControl = UIRefreshControl()
        collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
        #endif
        
        filterer.filtersChanged = { [unowned self] actionsChanged in
            self.reapplyFilters(actionsChanged: actionsChanged)
        }
        
        NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
    }
    
    private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
        let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (NotificationGroup, CollapseState, Filterer.Result, NSAttributedString?)> { [unowned self] cell, indexPath, itemIdentifier in
            cell.delegate = self
            let statusID = itemIdentifier.0.notifications.first!.status!.id
            let statusState = itemIdentifier.1
            cell.updateUI(statusID: statusID, state: statusState, filterResult: itemIdentifier.2, precomputedContent: itemIdentifier.3)
        }
        let actionGroupCell = UICollectionView.CellRegistration<ActionNotificationGroupCollectionViewCell, NotificationGroup> { [unowned self] cell, indexPath, itemIdentifier in
            cell.delegate = self
            cell.updateUI(group: itemIdentifier)
        }
        let followCell = UICollectionView.CellRegistration<FollowNotificationGroupCollectionViewCell, NotificationGroup> { [unowned self] cell, indexPath, itemIdentifier in
            cell.delegate = self
            cell.updateUI(group: itemIdentifier)
        }
        let followRequestCell = UICollectionView.CellRegistration<FollowRequestNotificationCollectionViewCell, Pachyderm.Notification> { [unowned self] cell, indexPath, itemIdentifier in
            cell.delegate = self
            cell.updateUI(notification: itemIdentifier)
        }
        let pollCell = UICollectionView.CellRegistration<PollFinishedNotificationCollectionViewCell, Pachyderm.Notification> { [unowned self] cell, indexPath, itemIdentifier in
            cell.delegate = self
            cell.updateUI(notification: itemIdentifier)
        }
        let updateCell = UICollectionView.CellRegistration<StatusUpdatedNotificationCollectionViewCell, Pachyderm.Notification> { [unowned self] cell, indexPath, itemIdentifier in
            cell.delegate = self
            cell.updateUI(notification: itemIdentifier)
        }
        let unknownCell = UICollectionView.CellRegistration<UICollectionViewListCell, ()> { cell, indexPath, itemIdentifier in
            var config = cell.defaultContentConfiguration()
            config.text = "Unknown Notification"
            cell.contentConfiguration = config
        }
        let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in
        }
        return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
            switch itemIdentifier {
            case .group(let group, let collapseState, let filterState):
                switch group.kind {
                case .status, .mention:
                    let (result, precomputedContent) = self.filterer.resolve(state: filterState!) {
                        let id = group.notifications.first!.status!.id
                        return (self.mastodonController.persistentContainer.status(for: id)!, false)
                    }
                    switch result {
                    case .allow, .warn(_):
                        return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (group, collapseState!, result, precomputedContent))
                    case .hide:
                        return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
                    }
                case .favourite, .reblog:
                    return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group)
                case .follow:
                    return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group)
                case .followRequest:
                    return collectionView.dequeueConfiguredReusableCell(using: followRequestCell, for: indexPath, item: group.notifications.first!)
                case .poll:
                    return collectionView.dequeueConfiguredReusableCell(using: pollCell, for: indexPath, item: group.notifications.first!)
                case .update:
                    return collectionView.dequeueConfiguredReusableCell(using: updateCell, for: indexPath, item: group.notifications.first!)
                default:
                    return collectionView.dequeueConfiguredReusableCell(using: unknownCell, for: indexPath, item: ())
                }
            case .loadingIndicator:
                return self.loadingIndicatorCell(for: indexPath)
            case .confirmLoadMore:
                return self.confirmLoadMoreCell(for: indexPath)
            }
        }
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        clearSelectionOnAppear(animated: animated)
        
        if case .notLoadedInitial = controller.state {
            Task {
                await controller.loadInitial()
            }
        }
    }
    
    @objc func refresh() {
        Task { @MainActor in
            if case .notLoadedInitial = controller.state {
                await controller.loadInitial()
            } else {
                await controller.loadNewer()
            }
            #if !targetEnvironment(macCatalyst)
            collectionView.refreshControl?.endRefreshing()
            #endif
        }
    }
    
    private func filterResult(state: FilterState, statusID: String) -> (Filterer.Result, NSAttributedString?) {
        let status = {
            let status = self.mastodonController.persistentContainer.status(for: statusID)!
            // if the status is a reblog of another one, filter based on that one
            if let reblogged = status.reblog {
                return (reblogged, true)
            } else {
                return (status, false)
            }
        }
        return filterer.resolve(state: state, status: status)
    }
    
    private func reapplyFilters(actionsChanged: Bool) {
        let visible = collectionView.indexPathsForVisibleItems
        let items = visible
            .compactMap { dataSource.itemIdentifier(for: $0) }
            .filter {
                if case .group(_, _, .some(_)) = $0 {
                    return true
                } else {
                    return false
                }
            }
        guard !items.isEmpty else {
            return
        }
        var snapshot = dataSource.snapshot()
        if actionsChanged {
            snapshot.reloadItems(items)
        } else {
            snapshot.reconfigureItems(items)
        }
        dataSource.apply(snapshot)
    }
    
    @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
        guard let userInfo = notification.userInfo,
              let statusIDs = userInfo["statusIDs"] as? [String] else {
            return
        }
        var snapshot = dataSource.snapshot()
        guard snapshot.sectionIdentifiers.contains(.notifications) else {
            return
        }
        let items = snapshot.itemIdentifiers(inSection: .notifications)
        let toDelete = statusIDs.flatMap { id in
            items.lazy.filter { $0.group?.notifications.first?.status?.id == id }
        }
        if !toDelete.isEmpty {
            snapshot.deleteItems(toDelete)
            dataSource.apply(snapshot, animatingDifferences: true)
        }
    }
    
    private func dismissNotificationsInGroup(at indexPath: IndexPath) async {
        guard case .group(let group, let collapseState, let filterState) = dataSource.itemIdentifier(for: indexPath) else {
            return
        }
        let notifications = group.notifications
        let dismissFailedIndices = await withTaskGroup(of: (Int, Bool).self) { group -> [Int] in
            for (index, notification) in notifications.enumerated() {
                group.addTask {
                    do {
                        _ = try await self.mastodonController.run(Notification.dismiss(id: notification.id))
                        return (index, true)
                    } catch {
                        return (index, false)
                    }
                }
            }
            return await group.reduce(into: [], { partialResult, value in
                if !value.1 {
                    partialResult.append(value.0)
                }
            })
        }
        var snapshot = dataSource.snapshot()
        if dismissFailedIndices.isEmpty {
            snapshot.deleteItems([.group(group, collapseState, filterState)])
        } else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count {
            let dismissFailed = dismissFailedIndices.sorted().map { notifications[$0] }
            snapshot.insertItems([.group(NotificationGroup(notifications: dismissFailed)!, collapseState, filterState)], afterItem: .group(group, collapseState, filterState))
            snapshot.deleteItems([.group(group, collapseState, filterState)])
        }
        await apply(snapshot, animatingDifferences: true)
    }
    
}

extension NotificationsCollectionViewController {
    enum Section: TimelineLikeCollectionViewSection {
        case notifications
        case footer
        
        static var entries: Self { .notifications }
    }
    enum Item: TimelineLikeCollectionViewItem {
        case group(NotificationGroup, CollapseState?, FilterState?)
        case loadingIndicator
        case confirmLoadMore
        
        static func fromTimelineItem(_ item: NotificationGroup) -> Self {
            switch item.kind {
            case .mention, .status:
                return .group(item, .unknown, .unknown)
            default:
                return .group(item, nil, nil)
            }
        }
        
        static func ==(lhs: Item, rhs: Item) -> Bool {
            switch (lhs, rhs) {
            case (.group(let a, _, _), .group(let b, _, _)):
                return a == b
            case (.loadingIndicator, .loadingIndicator):
                return true
            case (.confirmLoadMore, .confirmLoadMore):
                return true
            default:
                return false
            }
        }
        
        func hash(into hasher: inout Hasher) {
            switch self {
            case .group(let group, _, _):
                hasher.combine(0)
                hasher.combine(group)
            case .loadingIndicator:
                hasher.combine(1)
            case .confirmLoadMore:
                hasher.combine(1)
            }
        }
        
        var group: NotificationGroup? {
            if case .group(let group, _, _) = self {
                return group
            } else {
                return nil
            }
        }
        
        var isSelectable: Bool {
            switch self {
            case .group(_, _, _):
                return true
            default:
                return false
            }
        }
        
        var hidesSeparators: Bool {
            switch self {
            case .loadingIndicator, .confirmLoadMore:
                return true
            default:
                return false
            }
        }
    }
}

// MARK: TimelineLikeControllerDelegate
extension NotificationsCollectionViewController {
    typealias TimelineItem = NotificationGroup
    
    private static let pageSize = 40
    
    private func request(range: RequestRange) -> Request<[Pachyderm.Notification]> {
        if mastodonController.instanceFeatures.notificationsAllowedTypes {
            return Client.getNotifications(allowedTypes: allowedTypes, range: range)
        } else {
            var types = Set(Notification.Kind.allCases)
            types.remove(.unknown)
            allowedTypes.forEach { types.remove($0) }
            if !mastodonController.instanceFeatures.statusEditNotifications {
                types.remove(.update)
            }
            if !mastodonController.instanceFeatures.statusNotifications {
                types.remove(.status)
            }
            return Client.getNotifications(excludedTypes: Array(types), range: range)
        }
    }
    
    private func validateNotifications(_ notifications: [Pachyderm.Notification]) -> [Pachyderm.Notification] {
        return notifications.compactMap { notif in
            if notif.status == nil && (notif.kind == .mention || notif.kind == .reblog || notif.kind == .favourite || notif.kind == .status) {
                let crumb = Breadcrumb(level: .fatal, category: "notifications")
                crumb.data = [
                    "id": notif.id,
                    "type": notif.kind.rawValue,
                    "created_at": notif.createdAt.formatted(.iso8601),
                    "account": notif.account.id,
                ]
                SentrySDK.addBreadcrumb(crumb)
                return nil
            } else {
                return notif
            }
        }
    }
    
    func loadInitial() async throws -> [NotificationGroup] {
        let request = self.request(range: .count(NotificationsCollectionViewController.pageSize))
        let (notifications, _) = try await mastodonController.run(request)
        
        if !notifications.isEmpty {
            self.newer = .after(id: notifications.first!.id, count: NotificationsCollectionViewController.pageSize)
            self.older = .before(id: notifications.last!.id, count: NotificationsCollectionViewController.pageSize)
        }
        
        let validated = validateNotifications(notifications)
        
        await withCheckedContinuation { continuation in
            mastodonController.persistentContainer.addAll(notifications: validated) {
                continuation.resume()
            }
        }
        
        return NotificationGroup.createGroups(notifications: validated, only: self.groupTypes)
    }
    
    func loadNewer() async throws -> [NotificationGroup] {
        guard let newer else {
            throw Error.noNewer
        }
        
        let request = self.request(range: newer)
        let (notifications, _) = try await mastodonController.run(request)
        
        if !notifications.isEmpty {
            self.newer = .after(id: notifications.first!.id, count: NotificationsCollectionViewController.pageSize)
        }
        
        let validated = validateNotifications(notifications)
        guard !validated.isEmpty else {
            throw Error.allCaughtUp
        }
        
        await withCheckedContinuation { continuation in
            mastodonController.persistentContainer.addAll(notifications: validated) {
                continuation.resume()
            }
        }
        
        let newerGroups = NotificationGroup.createGroups(notifications: validated, only: self.groupTypes)
        let existingGroups = dataSource.snapshot().itemIdentifiers(inSection: .notifications).compactMap(\.group)
        return NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes)
    }
    
    func handlePrependItems(_ timelineItems: [NotificationGroup]) async {
        let topItem = dataSource.snapshot().itemIdentifiers(inSection: .notifications).first
        
        // we always replace all, because new items are merged with existing ones
        await handleReplaceAllItems(timelineItems)
        
        // preserve the scroll position
        // todo: this won't work for cmd+r when not at top
        if let topID = topItem?.group?.notifications.first?.id {
            // the exact item may have changed, due to merging
            let newTopGroup = timelineItems.first {
                $0.notifications.contains {
                    $0.id == topID
                }
            }!
            if let newTopIndexPath = dataSource.indexPath(for: .group(newTopGroup, nil, nil)) {
                collectionView.scrollToItem(at: newTopIndexPath, at: .top, animated: false)
            }
        }
    }
    
    func loadOlder() async throws -> [NotificationGroup] {
        guard let older else {
            throw Error.noOlder
        }
        
        let request = self.request(range: older)
        let (notifications, _) = try await mastodonController.run(request)
        
        if !notifications.isEmpty {
            self.older = .before(id: notifications.last!.id, count: NotificationsCollectionViewController.pageSize)
        }
        
        let validated = validateNotifications(notifications)
        
        await withCheckedContinuation { continuation in
            mastodonController.persistentContainer.addAll(notifications: validated) {
                continuation.resume()
            }
        }
        
        let olderGroups = NotificationGroup.createGroups(notifications: validated, only: self.groupTypes)
        let existingGroups = dataSource.snapshot().itemIdentifiers(inSection: .notifications).compactMap(\.group)
        return NotificationGroup.mergeGroups(first: existingGroups, second: olderGroups, only: self.groupTypes)
    }
    
    func handleAppendItems(_ timelineItems: [NotificationGroup]) async {
        await handleReplaceAllItems(timelineItems)
    }
    
    enum Error: TimelineLikeCollectionViewError {
        case noNewer
        case noOlder
        case allCaughtUp
    }
}

extension NotificationsCollectionViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        guard case .notifications = dataSource.sectionIdentifier(for: indexPath.section) else {
            return
        }
        let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
        if indexPath.row == itemsInSection - 1 {
            Task {
                // Because of grouping, all cells from the first load may fit on screen,
                // in which case, we try to load older while still in the loadingInitial state.
                // So, wait for that to finish before trying to load more.
                await controller.finishPendingOperation()
                await controller.loadOlder()
            }
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
        return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false
    }
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let item = dataSource.itemIdentifier(for: indexPath),
              case .group(let group, let collapseState, let filterState) = item else {
            return
        }
        switch group.kind {
        case .mention, .status, .poll, .update:
            if let filterState,
               filterState.isWarning == true {
                filterer.setResult(.allow, for: filterState)
                collectionView.deselectItem(at: indexPath, animated: true)
                var snapshot = dataSource.snapshot()
                snapshot.reconfigureItems([item])
                dataSource.apply(snapshot, animatingDifferences: true)
            } else {
                let statusID = group.notifications.first!.status!.id
                let state = collapseState?.copy() ?? .unknown
                selected(status: statusID, state: state)
            }
        case .favourite, .reblog:
            let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog
            let statusID = group.notifications.first!.status!.id
            let accountIDs = group.notifications.map(\.account.id)
            let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController)
            show(vc)
        case .follow:
            let accountIDs = group.notifications.map(\.account.id)
            switch accountIDs.count {
            case 0:
                collectionView.deselectItem(at: indexPath, animated: true)
            case 1:
                selected(account: accountIDs.first!)
            default:
                let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: mastodonController)
                vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title")
                show(vc)
            }
        case .followRequest:
            selected(account: group.notifications.first!.account.id)
        case .unknown:
            collectionView.deselectItem(at: indexPath, animated: true)
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
        guard case .group(let group, let collapseState, _) = dataSource.itemIdentifier(for: indexPath),
              let cell = collectionView.cellForItem(at: indexPath) else {
            return nil
        }
        switch group.kind {
        case .mention, .status, .poll, .update:
            guard let statusID = group.notifications.first?.status?.id,
                  let status = mastodonController.persistentContainer.status(for: statusID) else {
                return nil
            }
            let state = collapseState?.copy() ?? .unknown
            return UIContextMenuConfiguration {
                ConversationViewController(for: statusID, state: state, mastodonController: self.mastodonController)
            } actionProvider: { _ in
                UIMenu(children: self.actionsForStatus(status, source: .view(cell), includeStatusButtonActions: group.kind == .poll || group.kind == .update))
            }
        case .favourite, .reblog:
            return UIContextMenuConfiguration(previewProvider: {
                let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog
                let statusID = group.notifications.first!.status!.id
                let accountIDs = group.notifications.map(\.account.id)
                return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController)
            })
        case .follow:
            let accountIDs = group.notifications.map(\.account.id)
            return UIContextMenuConfiguration {
                if accountIDs.count == 1 {
                    return ProfileViewController(accountID: accountIDs.first!, mastodonController: self.mastodonController)
                } else {
                    let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: self.mastodonController)
                    vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title")
                    return vc
                }
            } actionProvider: { _ in
                if accountIDs.count == 1 {
                    return UIMenu(children: self.actionsForProfile(accountID: accountIDs.first!, source: .view(cell)))
                } else {
                    return nil
                }
            }
        case .followRequest:
            let accountID = group.notifications.first!.account.id
            return UIContextMenuConfiguration {
                ProfileViewController(accountID: accountID, mastodonController: self.mastodonController)
            } actionProvider: { _ in
                let cell = cell as! FollowRequestNotificationCollectionViewCell
                let acceptRejectChildren = [
                    UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }),
                    UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }),
                ]
                let acceptRejectMenu: UIMenu
                if #available(iOS 16.0, *) {
                    acceptRejectMenu = UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren)
                } else {
                    acceptRejectMenu = UIMenu(options: .displayInline, children: acceptRejectChildren)
                }
                return UIMenu(children: [
                    acceptRejectMenu,
                    UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))),
                ])
            }
        case .unknown:
            return nil
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
        MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
    }
    
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        if reconfigureVisibleItemsOnEndDecelerating {
            reconfigureVisibleItemsOnEndDecelerating = false
            reconfigureVisibleCells()
        }
    }
}

extension NotificationsCollectionViewController: UICollectionViewDragDelegate {
    func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        guard case .group(let group, _, _) = dataSource.itemIdentifier(for: indexPath) else {
            return []
        }
        switch group.kind {
        case .mention, .status:
            // not combiend with .poll and .update below, b/c TimelineStatusCollectionViewCell handles checking whether the poll view is tracking
            let cell = collectionView.cellForItem(at: indexPath) as! TimelineStatusCollectionViewCell
            return cell.dragItemsForBeginning(session: session)
        case .poll, .update:
            let status = group.notifications.first!.status!
            let provider = NSItemProvider(object: URL(status.url!)! as NSURL)
            let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: mastodonController.accountInfo!.id)
            activity.displaysAuxiliaryScene = true
            provider.registerObject(activity, visibility: .all)
            return [UIDragItem(itemProvider: provider)]
        case .favourite, .reblog:
            return []
        case .follow, .followRequest:
            guard group.notifications.count == 1 else {
                return []
            }
            let account = group.notifications.first!.account
            let provider = NSItemProvider(object: account.url as NSURL)
            let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: mastodonController.accountInfo!.id)
            activity.displaysAuxiliaryScene = true
            provider.registerObject(activity, visibility: .all)
            return [UIDragItem(itemProvider: provider)]
        case .unknown:
            return []
        }
    }
}

extension NotificationsCollectionViewController: TuskerNavigationDelegate {
    var apiController: MastodonController! { mastodonController }
}

extension NotificationsCollectionViewController: MenuActionProvider {
}

extension NotificationsCollectionViewController: StatusCollectionViewCellDelegate {
    func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
        if let indexPath = collectionView.indexPath(for: cell) {
            var snapshot = dataSource.snapshot()
            snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!])
            dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
        }
    }
    
    func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
        if let indexPath = collectionView.indexPath(for: cell),
           let item = dataSource.itemIdentifier(for: indexPath),
           case .group(_, _, .some(let filterState)) = item {
            filterer.setResult(.allow, for: filterState)
            var snapshot = dataSource.snapshot()
            snapshot.reconfigureItems([item])
            dataSource.apply(snapshot, animatingDifferences: true)
        }
    }
}
