<commit_before>/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import Foundation
import Shared
import Storage

extension UIGestureRecognizer {
    func cancel() {
        if isEnabled {
            isEnabled = false
            isEnabled = true
        }
    }
}

// MARK: Delegate for animation completion notifications.
enum TabAnimationType {
    case addTab
    case removedNonLastTab
    case removedLastTab
    case updateTab
    case moveTab
}

protocol TabDisplayCompletionDelegate: AnyObject {
    func completedAnimation(for: TabAnimationType)
}

// MARK: -

@objc protocol TabSelectionDelegate: AnyObject {
    func didSelectTabAtIndex(_ index: Int)
}

protocol TopTabCellDelegate: AnyObject {
    func tabCellDidClose(_ cell: UICollectionViewCell)
}

protocol TabDisplayer: AnyObject {
    typealias TabCellIdentifer = String
    var tabCellIdentifer: TabCellIdentifer { get set }

    func focusSelectedTab()
    func cellFactory(for cell: UICollectionViewCell, using tab: Tab) -> UICollectionViewCell
}

class TabDisplayManager: NSObject {
    var performingChainedOperations = false

    var dataStore = WeakList<Tab>()
    var operations = [(TabAnimationType, (() -> Void))]()
    weak var tabDisplayCompletionDelegate: TabDisplayCompletionDelegate?

    fileprivate let tabManager: TabManager
    fileprivate let collectionView: UICollectionView
    private var tabObservers: TabObservers!
    fileprivate weak var tabDisplayer: TabDisplayer?
    private let tabReuseIdentifer: String

    var searchedTabs: [Tab]?
    var searchActive: Bool {
        return searchedTabs != nil
    }

    private var tabsToDisplay: [Tab] {
        if let searchedTabs = searchedTabs {
            // tabs can be deleted while a search is active. Make sure the tab still exists in the tabmanager before displaying
            return searchedTabs.filter({ tabManager.tabs.contains($0) })
        }
        return self.isPrivate ? tabManager.privateTabs : tabManager.normalTabs
    }

    private(set) var isPrivate = false

    // Sigh. Dragging on the collection view is either an 'active drag' where the item is moved, or
    // that the item has been long pressed on (and not moved yet), and this gesture recognizer has been triggered
    var isDragging: Bool {
        return collectionView.hasActiveDrag || isLongPressGestureStarted
    }

    fileprivate var isLongPressGestureStarted: Bool {
        var started = false
        collectionView.gestureRecognizers?.forEach { recognizer in
            if let _ = recognizer as? UILongPressGestureRecognizer, recognizer.state == .began || recognizer.state == .changed {
                started = true
            }
        }
        return started
    }

    @discardableResult
    fileprivate func cancelDragAndGestures() -> Bool {
        let isActive = collectionView.hasActiveDrag || isLongPressGestureStarted
        collectionView.cancelInteractiveMovement()

        // Long-pressing a cell to initiate dragging, but not actually moving the cell, will not trigger the collectionView's internal 'interactive movement' vars/funcs, and cancelInteractiveMovement() will not work. The gesture recognizer needs to be cancelled in this case.
        collectionView.gestureRecognizers?.forEach { $0.cancel() }

        return isActive
    }

    init(collectionView: UICollectionView, tabManager: TabManager, tabDisplayer: TabDisplayer, reuseID: String) {
        self.collectionView = collectionView
        self.tabDisplayer = tabDisplayer
        self.tabManager = tabManager
        self.isPrivate = tabManager.selectedTab?.isPrivate ?? false
        self.tabReuseIdentifer = reuseID
        super.init()

        tabManager.addDelegate(self)
        self.tabObservers = registerFor(.didLoadFavicon, .didChangeURL, queue: .main)

        tabsToDisplay.forEach {
            self.dataStore.insert($0)
        }
        collectionView.reloadData()
    }

    func togglePrivateMode(isOn: Bool, createTabOnEmptyPrivateMode: Bool) {
        guard isPrivate != isOn else { return }

        isPrivate = isOn
        UserDefaults.standard.set(isPrivate, forKey: "wasLastSessionPrivate")

        searchedTabs = nil
        refreshStore()

        if createTabOnEmptyPrivateMode {
            //if private tabs is empty and we are transitioning to it add a tab
            if tabManager.privateTabs.isEmpty && isPrivate {
                tabManager.addTab(isPrivate: true)
            }
        }
        
        let tab = mostRecentTab(inTabs: tabsToDisplay) ?? tabsToDisplay.last
        if let tab = tab {
            tabManager.selectTab(tab)
        }
    }

    // The collection is showing this Tab as selected
    func indexOfCellDrawnAsPreviouslySelectedTab(currentlySelected: Tab) -> IndexPath? {
        for i in 0..<collectionView.numberOfItems(inSection: 0) {
            if let cell = collectionView.cellForItem(at: IndexPath(row: i, section: 0)) as? TopTabCell, cell.selectedTab {
                if let tab = dataStore.at(i), tab != currentlySelected {
                    return IndexPath(row: i, section: 0)
                } else {
                    return nil
                }
            }
        }
        return nil
    }
    
    func searchTabsAnimated() {
        let isUnchanged = (tabsToDisplay.count == dataStore.count) && tabsToDisplay.zip(dataStore).reduce(true) { $0 && $1.0 === $1.1 }
        if !tabsToDisplay.isEmpty && isUnchanged {
            return
        }

        operations.removeAll()
        dataStore.removeAll()
        tabsToDisplay.forEach {
            self.dataStore.insert($0)
        }

        // animates the changes
        collectionView.reloadSections(IndexSet(integer: 0))
    }

    func refreshStore(evenIfHidden: Bool = false) {
        operations.removeAll()
        dataStore.removeAll()
        tabsToDisplay.forEach {
            self.dataStore.insert($0)
        }
        collectionView.reloadData()

        if evenIfHidden {
            // reloadData() will reset the data for the collection view,
            // but if called when offscreen it will not render properly,
            // unless reloadItems is explicitly called on each item.
            // Avoid calling with evenIfHidden=true, as it can cause a blink effect as the cell is updated.
            // The cause of the blinking effect is unknown (and unusual).
            var indexPaths = [IndexPath]()
            for i in 0..<collectionView.numberOfItems(inSection: 0) {
                indexPaths.append(IndexPath(item: i, section: 0))
            }
            collectionView.reloadItems(at: indexPaths)
        }

    }

    // The user has tapped the close button or has swiped away the cell
    func closeActionPerformed(forCell cell: UICollectionViewCell) {
        if isDragging {
            return<commit_msg>Fix #4913 - [iPad]Focused tab not in view when re-opening the app in private mode (#4937)<commit_after>/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import Foundation
import Shared
import Storage

extension UIGestureRecognizer {
    func cancel() {
        if isEnabled {
            isEnabled = false
            isEnabled = true
        }
    }
}

// MARK: Delegate for animation completion notifications.
enum TabAnimationType {
    case addTab
    case removedNonLastTab
    case removedLastTab
    case updateTab
    case moveTab
}

protocol TabDisplayCompletionDelegate: AnyObject {
    func completedAnimation(for: TabAnimationType)
}

// MARK: -

@objc protocol TabSelectionDelegate: AnyObject {
    func didSelectTabAtIndex(_ index: Int)
}

protocol TopTabCellDelegate: AnyObject {
    func tabCellDidClose(_ cell: UICollectionViewCell)
}

protocol TabDisplayer: AnyObject {
    typealias TabCellIdentifer = String
    var tabCellIdentifer: TabCellIdentifer { get set }

    func focusSelectedTab()
    func cellFactory(for cell: UICollectionViewCell, using tab: Tab) -> UICollectionViewCell
}

class TabDisplayManager: NSObject {
    var performingChainedOperations = false

    var dataStore = WeakList<Tab>()
    var operations = [(TabAnimationType, (() -> Void))]()
    weak var tabDisplayCompletionDelegate: TabDisplayCompletionDelegate?

    fileprivate let tabManager: TabManager
    fileprivate let collectionView: UICollectionView
    private var tabObservers: TabObservers!
    fileprivate weak var tabDisplayer: TabDisplayer?
    private let tabReuseIdentifer: String

    var searchedTabs: [Tab]?
    var searchActive: Bool {
        return searchedTabs != nil
    }

    private var tabsToDisplay: [Tab] {
        if let searchedTabs = searchedTabs {
            // tabs can be deleted while a search is active. Make sure the tab still exists in the tabmanager before displaying
            return searchedTabs.filter({ tabManager.tabs.contains($0) })
        }
        return self.isPrivate ? tabManager.privateTabs : tabManager.normalTabs
    }

    private(set) var isPrivate = false

    // Sigh. Dragging on the collection view is either an 'active drag' where the item is moved, or
    // that the item has been long pressed on (and not moved yet), and this gesture recognizer has been triggered
    var isDragging: Bool {
        return collectionView.hasActiveDrag || isLongPressGestureStarted
    }

    fileprivate var isLongPressGestureStarted: Bool {
        var started = false
        collectionView.gestureRecognizers?.forEach { recognizer in
            if let _ = recognizer as? UILongPressGestureRecognizer, recognizer.state == .began || recognizer.state == .changed {
                started = true
            }
        }
        return started
    }

    @discardableResult
    fileprivate func cancelDragAndGestures() -> Bool {
        let isActive = collectionView.hasActiveDrag || isLongPressGestureStarted
        collectionView.cancelInteractiveMovement()

        // Long-pressing a cell to initiate dragging, but not actually moving the cell, will not trigger the collectionView's internal 'interactive movement' vars/funcs, and cancelInteractiveMovement() will not work. The gesture recognizer needs to be cancelled in this case.
        collectionView.gestureRecognizers?.forEach { $0.cancel() }

        return isActive
    }

    init(collectionView: UICollectionView, tabManager: TabManager, tabDisplayer: TabDisplayer, reuseID: String) {
        self.collectionView = collectionView
        self.tabDisplayer = tabDisplayer
        self.tabManager = tabManager
        self.isPrivate = tabManager.selectedTab?.isPrivate ?? false
        self.tabReuseIdentifer = reuseID
        super.init()

        tabManager.addDelegate(self)
        self.tabObservers = registerFor(.didLoadFavicon, .didChangeURL, queue: .main)

        tabsToDisplay.forEach {
            self.dataStore.insert($0)
        }
        collectionView.reloadData()
    }

    func togglePrivateMode(isOn: Bool, createTabOnEmptyPrivateMode: Bool) {
        guard isPrivate != isOn else { return }

        isPrivate = isOn
        UserDefaults.standard.set(isPrivate, forKey: "wasLastSessionPrivate")

        searchedTabs = nil
        refreshStore()

        if createTabOnEmptyPrivateMode {
            //if private tabs is empty and we are transitioning to it add a tab
            if tabManager.privateTabs.isEmpty && isPrivate {
                tabManager.addTab(isPrivate: true)
            }
        }
        
        let tab = mostRecentTab(inTabs: tabsToDisplay) ?? tabsToDisplay.last
        if let tab = tab {
            tabManager.selectTab(tab)
        }
    }

    // The collection is showing this Tab as selected
    func indexOfCellDrawnAsPreviouslySelectedTab(currentlySelected: Tab) -> IndexPath? {
        for i in 0..<collectionView.numberOfItems(inSection: 0) {
            if let cell = collectionView.cellForItem(at: IndexPath(row: i, section: 0)) as? TopTabCell, cell.selectedTab {
                if let tab = dataStore.at(i), tab != currentlySelected {
                    return IndexPath(row: i, section: 0)
                } else {
                    return nil
                }
            }
        }
        return nil
    }
    
    func searchTabsAnimated() {
        let isUnchanged = (tabsToDisplay.count == dataStore.count) && tabsToDisplay.zip(dataStore).reduce(true) { $0 && $1.0 === $1.1 }
        if !tabsToDisplay.isEmpty && isUnchanged {
            return
        }

        operations.removeAll()
        dataStore.removeAll()
        tabsToDisplay.forEach {
            self.dataStore.insert($0)
        }

        // animates the changes
        collectionView.reloadSections(IndexSet(integer: 0))
    }

    func refreshStore(evenIfHidden: Bool = false) {
        operations.removeAll()
        dataStore.removeAll()
        tabsToDisplay.forEach {
            self.dataStore.insert($0)
        }
        collectionView.reloadData()

        if evenIfHidden {
            // reloadData() will reset the data for the collection view,
            // but if called when offscreen it will not render properly,
            // unless reloadItems is explicitly called on each item.
            // Avoid calling with evenIfHidden=true, as it can cause a blink effect as the cell is updated.
            // The cause of the blinking effect is unknown (and unusual).
            var indexPaths = [IndexPath]()
            for i in 0..<collectionView.numberOfItems(inSection: 0) {
                indexPaths.append(IndexPath(item: i, section: 0))
            }
            collectionView.reloadItems(at: indexPaths)
        }

        tabDisplayer?.focusSelectedTab()
    }

    // The user has tapped the close button or has swiped away the cell
    func closeActionPerformed(forCell cell: UICollectionViewCell) {
        if isDragging {
            return
{'fraction_non_alphanumeric': 0.07446429842671033, 'fraction_numerical': 0.0037018580479817754, 'mean_word_length': 2.5014955134596213, 'pattern_counts': {'":': 0, '<': 9, '<?xml version=': 0, '>': 13, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '2004411', 'n_tokens_mistral': 3822, 'n_tokens_neox': 3662, 'n_words': 1126}