//
//  NavigationModelController.swift
//  NetNewsWire-iOS
//
//  Created by Maurice Parker on 4/21/19.
//  Copyright © 2019 Ranchero Software. All rights reserved.
//

import UIKit
import UserNotifications
import Account
import Articles
import Tree
import SafariServices
import SwiftUI
import Core

protocol MainControllerIdentifiable {
	var mainControllerIdentifier: MainControllerIdentifier { get }
}

enum MainControllerIdentifier {
	case none
	case mainFeed
	case mainTimeline
	case article
}

enum SearchScope: Int {
	case timeline = 0
	case global = 1
}

enum ShowFeedName {
	case none
	case byline
	case feed
}

struct SidebarItemNode: Hashable {
	var node: Node
	var sidebarItemID: SidebarItemIdentifier

	@MainActor init(_ node: Node) {
		self.node = node
		self.sidebarItemID = (node.representedObject as! SidebarItem).sidebarItemID!
	}
	
	func hash(into hasher: inout Hasher) {
		hasher.combine(sidebarItemID)
	}
}

@MainActor final class SceneCoordinator: NSObject, UndoableCommandRunner {

	var undoableCommands = [UndoableCommand]()
	var undoManager: UndoManager? {
		return rootSplitViewController.undoManager
	}
	
	private var activityManager = ActivityManager()
	
	private var rootSplitViewController: RootSplitViewController!
	private var sidebarViewController: SidebarViewController!
	private var timelineViewController: TimelineViewController?
	private var articleViewController: ArticleViewController?

	private var lastMainControllerToAppear = MainControllerIdentifier.none

	private let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5)
	private let rebuildBackingStoresQueue = CoalescingQueue(name: "Rebuild The Backing Stores", interval: 0.5)
	private var fetchSerialNumber = 0
	private let fetchRequestQueue = FetchRequestQueue()
	
	/// Which Containers are expanded
	private var expandedTable = Set<ContainerIdentifier>()

	/// Which Containers used to be expanded. Reset by rebuilding the Shadow Table.
	private var lastExpandedTable = Set<ContainerIdentifier>()

	/// Which Feeds have the Read Articles Filter enabled
	private var readFilterEnabledTable = [SidebarItemIdentifier: Bool]()

	/// Flattened tree structure for the Sidebar
	private var shadowTable = [(sectionID: String, sidebarItemNodes: [SidebarItemNode])]()

	private(set) var preSearchTimelineFeed: SidebarItem?
	private var lastSearchString = ""
	private var lastSearchScope: SearchScope? = nil
	private var isSearching: Bool = false
	private var savedSearchArticles: ArticleArray? = nil
	private var savedSearchArticleIds: Set<String>? = nil

	private(set) var sortDirection = AppDefaults.shared.timelineSortDirection {
		didSet {
			if sortDirection != oldValue {
				sortParametersDidChange()
			}
		}
	}
	
	private(set) var groupByFeed = AppDefaults.shared.timelineGroupByFeed {
		didSet {
			if groupByFeed != oldValue {
				sortParametersDidChange()
			}
		}
	}
	
	var prefersStatusBarHidden = false
	
	private let treeControllerDelegate = FeedTreeControllerDelegate()
	private let treeController: TreeController
	
	var stateRestorationActivity: NSUserActivity {
		let activity = activityManager.stateRestorationActivity
		var userInfo = activity.userInfo ?? [AnyHashable: Any]()
		
		userInfo[UserInfoKey.windowState] = windowState()
		
		let articleState = articleViewController?.currentState
		userInfo[UserInfoKey.isShowingExtractedArticle] = articleState?.isShowingExtractedArticle ?? false
		userInfo[UserInfoKey.articleWindowScrollY] = articleState?.windowScrollY ?? 0

		activity.userInfo = userInfo
		return activity
	}
	
	var isNavigationDisabled = false

	var isRootSplitCollapsed: Bool {
		return rootSplitViewController.isCollapsed
	}

	var isReadFeedsFiltered: Bool {
		return treeControllerDelegate.isReadFiltered
	}
	
	var isReadArticlesFiltered: Bool {
		if let sidebarItemID = timelineFeed?.sidebarItemID, let readFilterEnabled = readFilterEnabledTable[sidebarItemID] {
			return readFilterEnabled
		} else {
			return timelineDefaultReadFilterType != .none
		}
	}
	
	var timelineDefaultReadFilterType: ReadFilterType {
		return timelineFeed?.defaultReadFilterType ?? .none
	}
	
	var rootNode: Node {
		return treeController.rootNode
	}
	
	// At some point we should refactor the current Feed IndexPath out and only use the timeline feed
	private(set) var currentFeedIndexPath: IndexPath?

	var timelineIconImage: IconImage? {
		guard let timelineFeed = timelineFeed else {
			return nil
		}
		return IconImageCache.shared.imageForFeed(timelineFeed)
	}
	
	private var exceptionArticleFetcher: ArticleFetcher?
	private(set) var timelineFeed: SidebarItem?
	
	var timelineMiddleIndexPath: IndexPath?
	
	private(set) var showFeedNames = ShowFeedName.none
	private(set) var showIcons = false

	var prevFeedIndexPath: IndexPath? {
		guard let indexPath = currentFeedIndexPath else {
			return nil
		}
		
		let prevIndexPath: IndexPath? = {
			if indexPath.row - 1 < 0 {
				for i in (0..<indexPath.section).reversed() {
					if shadowTable[i].sidebarItemNodes.count > 0 {
						return IndexPath(row: shadowTable[i].sidebarItemNodes.count - 1, section: i)
					}
				}
				return nil
			} else {
				return IndexPath(row: indexPath.row - 1, section: indexPath.section)
			}
		}()
		
		return prevIndexPath
	}
	
	var nextFeedIndexPath: IndexPath? {
		guard let indexPath = currentFeedIndexPath else {
			return nil
		}
		
		let nextIndexPath: IndexPath? = {
			if indexPath.row + 1 >= shadowTable[indexPath.section].sidebarItemNodes.count {
				for i in indexPath.section + 1..<shadowTable.count {
					if shadowTable[i].sidebarItemNodes.count > 0 {
						return IndexPath(row: 0, section: i)
					}
				}
				return nil
			} else {
				return IndexPath(row: indexPath.row + 1, section: indexPath.section)
			}
		}()
		
		return nextIndexPath
	}

	var isPrevArticleAvailable: Bool {
		guard let articleRow = currentArticleRow else {
			return false
		}
		return articleRow > 0
	}
	
	var isNextArticleAvailable: Bool {
		guard let articleRow = currentArticleRow else {
			return false
		}
		return articleRow + 1 < articles.count
	}
	
	var prevArticle: Article? {
		guard isPrevArticleAvailable, let articleRow = currentArticleRow else {
			return nil
		}
		return articles[articleRow - 1]
	}
	
	var nextArticle: Article? {
		guard isNextArticleAvailable, let articleRow = currentArticleRow else {
			return nil
		}
		return articles[articleRow + 1]
	}
	
	var firstUnreadArticleIndexPath: IndexPath? {
		for (row, article) in articles.enumerated() {
			if !article.status.read {
				return IndexPath(row: row, section: 0)
			}
		}
		return nil
	}
	
	var currentArticle: Article?

	private(set) var articles = ArticleArray() {
		didSet {
			timelineMiddleIndexPath = nil
			articleDictionaryNeedsUpdate = true
		}
	}

	private var articleDictionaryNeedsUpdate = true
	private var _idToArticleDictionary = [String: Article]()
	private var idToAticleDictionary: [String: Article] {
		if articleDictionaryNeedsUpdate {
			rebuildArticleDictionaries()
		}
		return _idToArticleDictionary
	}

	private var currentArticleRow: Int? {
		guard let article = currentArticle else { return nil }
		return articles.firstIndex(of: article)
	}

	var isTimelineUnreadAvailable: Bool {
		return timelineUnreadCount > 0
	}
	
	var isAnyUnreadAvailable: Bool {
		return appDelegate.unreadCount > 0
	}
	
	var timelineUnreadCount: Int = 0
	
	init(rootSplitViewController: RootSplitViewController) {
		self.rootSplitViewController = rootSplitViewController
		self.treeController = TreeController(delegate: treeControllerDelegate)

		super.init()
		
		self.sidebarViewController = rootSplitViewController.viewController(for: .primary) as? SidebarViewController
		self.sidebarViewController.coordinator = self
		if let navController = self.sidebarViewController?.navigationController {
			navController.delegate = self
			configureNavigationController(navController)
		}

		self.timelineViewController = rootSplitViewController.viewController(for: .supplementary) as? TimelineViewController
		self.timelineViewController?.coordinator = self
		if let navController = self.timelineViewController?.navigationController {
			navController.delegate = self
			configureNavigationController(navController)
		}

		self.articleViewController = rootSplitViewController.viewController(for: .secondary) as? ArticleViewController
		self.articleViewController?.coordinator = self
		if let navController = self.articleViewController?.navigationController {
			configureNavigationController(navController)
		}

		for sectionNode in treeController.rootNode.childNodes {
			markExpanded(sectionNode)
			shadowTable.append((sectionID: "", sidebarItemNodes: [SidebarItemNode]()))
		}
		
		NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidInitialize(_:)), name: .UnreadCountDidInitialize, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(containerChildrenDidChange(_:)), name: .ChildrenDidChange, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(batchUpdateDidPerform(_:)), name: .BatchUpdateDidPerform, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(accountStateDidChange(_:)), name: .AccountStateDidChange, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(userDidAddAccount(_:)), name: .UserDidAddAccount, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(userDidDeleteAccount(_:)), name: .UserDidDeleteAccount, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(userDidAddFeed(_:)), name: .UserDidAddFeed, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(importDownloadedTheme(_:)), name: .didEndDownloadingTheme, object: nil)
		NotificationCenter.default.addObserver(self, selector: #selector(themeDownloadDidFail(_:)), name: .didFailToImportThemeWithError, object: nil)
	}
	
	func restoreWindowState(_ activity: NSUserActivity?) {
		if let activity = activity, let windowState = activity.userInfo?[UserInfoKey.windowState] as? [AnyHashable: Any] {
			
			if let containerExpandedWindowState = windowState[UserInfoKey.containerExpandedWindowState] as? [[AnyHashable: AnyHashable]] {
				let containerIdentifers = containerExpandedWindowState.compactMap( { ContainerIdentifier(userInfo: $0) })
				expandedTable = Set(containerIdentifers)
			}
			
			if let readArticlesFilterState = windowState[UserInfoKey.readArticlesFilterState] as? [[AnyHashable: AnyHashable]: Bool] {
				for key in readArticlesFilterState.keys {
					if let sidebarItemID = SidebarItemIdentifier(userInfo: key) {
						readFilterEnabledTable[sidebarItemID] = readArticlesFilterState[key]
					}
				}
			}

//			if let isSidebarHidden = windowState[UserInfoKey.isSidebarHidden] as? Bool, isSidebarHidden {
//				DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
//					self.rootSplitViewController.preferredDisplayMode = .secondaryOnly
//				}
//			}

			rebuildBackingStores(initialLoad: true)

			// You can't assign the Feeds Read Filter until we've built the backing stores at least once or there is nothing
			// for state restoration to work with while we are waiting for the unread counts to initialize.
			if let readFeedsFilterState = windowState[UserInfoKey.readFeedsFilterState] as? Bool {
				treeControllerDelegate.isReadFiltered = readFeedsFilterState
			}
			
		} else {
			
			rebuildBackingStores(initialLoad: true)
			
		}
	}
	
	func handle(_ activity: NSUserActivity) {
		selectFeed(indexPath: nil) {
			guard let activityType = ActivityType(rawValue: activity.activityType) else { return }
			switch activityType {
			case .restoration:
				break
			case .selectFeed:
				self.handleSelectFeed(activity.userInfo)
			case .nextUnread:
				self.selectFirstUnreadInAllUnread()
			case .readArticle:
				self.handleReadArticle(activity.userInfo)
			case .addFeedIntent:
				self.showAddFeed()
			}
		}
	}
	
	func handle(_ response: UNNotificationResponse) {
		let userInfo = response.notification.request.content.userInfo
		handleReadArticle(userInfo)
	}
	
	func resetFocus() {
		if currentArticle != nil {
			timelineViewController?.focus()
		} else {
			sidebarViewController?.focus()
		}
	}
	
	func selectFirstUnreadInAllUnread() {
		markExpanded(SmartFeedsController.shared)
		self.ensureFeedIsAvailableToSelect(SmartFeedsController.shared.unreadFeed) {
			self.selectFeed(SmartFeedsController.shared.unreadFeed) {
				self.selectFirstUnreadArticleInTimeline()
			}
		}
	}

	func showSearch() {
		selectFeed(indexPath: nil) {
			self.rootSplitViewController.show(.supplementary)
			DispatchQueue.main.asyncAfter(deadline: .now()) {
				self.timelineViewController!.showSearchAll()
			}
		}
	}
	
	// MARK: Notifications
	
	@objc func unreadCountDidInitialize(_ notification: Notification) {
		guard notification.object is AccountManager else {
			return
		}
		
		if isReadFeedsFiltered {
			rebuildBackingStores()
		}
	}

	@objc func unreadCountDidChange(_ note: Notification) {
		// We will handle the filtering of unread feeds in unreadCountDidInitialize after they have all be calculated
		guard AccountManager.shared.isUnreadCountsInitialized else {
			return	
		}
		
		queueRebuildBackingStores()
	}

	@objc func statusesDidChange(_ note: Notification) {
		updateUnreadCount()
	}
	
	@objc func containerChildrenDidChange(_ note: Notification) {
		if timelineFetcherContainsAnyPseudoFeed() || timelineFetcherContainsAnyFolder() {
			fetchAndMergeArticlesAsync(animated: true) {
				self.timelineViewController?.reinitializeArticles(resetScroll: false)
				self.rebuildBackingStores()
			}
		} else {
			rebuildBackingStores()
		}
	}
	
	@objc func batchUpdateDidPerform(_ notification: Notification) {
		rebuildBackingStores()
	}
	
	@objc func displayNameDidChange(_ note: Notification) {
		rebuildBackingStores()
	}

	@objc func accountStateDidChange(_ note: Notification) {
		if timelineFetcherContainsAnyPseudoFeed() {
			fetchAndMergeArticlesAsync(animated: true) {
				self.timelineViewController?.reinitializeArticles(resetScroll: false)
				self.rebuildBackingStores()
			}
		} else {
			self.rebuildBackingStores()
		}
	}
	
	@objc func userDidAddAccount(_ note: Notification) {
		let expandNewAccount = {
			if let account = note.userInfo?[Account.UserInfoKey.account] as? Account,
				let node = self.treeController.rootNode.childNodeRepresentingObject(account) {
				self.markExpanded(node)
			}
		}
		
		if timelineFetcherContainsAnyPseudoFeed() {
			fetchAndMergeArticlesAsync(animated: true) {
				self.timelineViewController?.reinitializeArticles(resetScroll: false)
				self.rebuildBackingStores(updateExpandedNodes: expandNewAccount)
			}
		} else {
			self.rebuildBackingStores(updateExpandedNodes: expandNewAccount)
		}
	}

	@objc func userDidDeleteAccount(_ note: Notification) {
		let cleanupAccount = {
			if let account = note.userInfo?[Account.UserInfoKey.account] as? Account,
				let node = self.treeController.rootNode.childNodeRepresentingObject(account) {
				self.unmarkExpanded(node)
			}
		}
		
		if timelineFetcherContainsAnyPseudoFeed() {
			fetchAndMergeArticlesAsync(animated: true) {
				self.timelineViewController?.reinitializeArticles(resetScroll: false)
				self.rebuildBackingStores(updateExpandedNodes: cleanupAccount)
			}
		} else {
			self.rebuildBackingStores(updateExpandedNodes: cleanupAccount)
		}
	}

	@objc func userDidAddFeed(_ notification: Notification) {
		guard let feed = notification.userInfo?[UserInfoKey.feed] as? Feed else {
			return
		}
		discloseFeed(feed, animations: [.scroll, .navigation])
	}
	
	@objc func userDefaultsDidChange(_ note: Notification) {
		self.sortDirection = AppDefaults.shared.timelineSortDirection
		self.groupByFeed = AppDefaults.shared.timelineGroupByFeed
	}
	
	@objc func accountDidDownloadArticles(_ note: Notification) {
		guard let feeds = note.userInfo?[Account.UserInfoKey.feeds] as? Set<Feed> else {
			return
		}
		
		let shouldFetchAndMergeArticles = timelineFetcherContainsAnyFeed(feeds) || timelineFetcherContainsAnyPseudoFeed()
		if shouldFetchAndMergeArticles {
			queueFetchAndMergeArticles()
		}
	}
	
	@objc func willEnterForeground(_ note: Notification) {
		// Don't interfere with any fetch requests that we may have initiated before the app was returned to the foreground.
		// For example if you select Next Unread from the Home Screen Quick actions, you can start a request before we are
		// in the foreground.
		if !fetchRequestQueue.isAnyCurrentRequest {
			queueFetchAndMergeArticles()
		}
	}
	
	@objc func importDownloadedTheme(_ note: Notification) {
		guard let userInfo = note.userInfo,
			let url = userInfo["url"] as? URL else {
			return
		}
		
		DispatchQueue.main.async {
			self.importTheme(filename: url.path)
		}
	}
	
	@objc func themeDownloadDidFail(_ note: Notification) {
		guard let userInfo = note.userInfo,
			  let error = userInfo["error"] as? Error else {
				  return
			  }
		DispatchQueue.main.async {
			self.rootSplitViewController.presentError(error, dismiss: nil)
		}
	}

	// MARK: API
	
	func suspend() {
		fetchAndMergeArticlesQueue.performCallsImmediately()
		rebuildBackingStoresQueue.performCallsImmediately()
		fetchRequestQueue.cancelAllRequests()
	}
	
	func cleanUp(conditional: Bool) {
		if isReadFeedsFiltered {
			rebuildBackingStores()
		}
		if isReadArticlesFiltered && (AppDefaults.shared.refreshClearsReadArticles || !conditional) {
			refreshTimeline(resetScroll: false)
		}
	}
	
	func toggleReadFeedsFilter() {
		if isReadFeedsFiltered {
			treeControllerDelegate.isReadFiltered = false
		} else {
			treeControllerDelegate.isReadFiltered = true
		}
		rebuildBackingStores()
		sidebarViewController?.updateUI()
	}
	
	func toggleReadArticlesFilter() {
		guard let sidebarItemID = timelineFeed?.sidebarItemID else {
			return
		}

		if isReadArticlesFiltered {
			readFilterEnabledTable[sidebarItemID] = false
		} else {
			readFilterEnabledTable[sidebarItemID] = true
		}
		
		refreshTimeline(resetScroll: false)
	}

	func nodeFor(sidebarItemID: SidebarItemIdentifier) -> Node? {
		return treeController.rootNode.descendantNode(where: { node in
			if let feed = node.representedObject as? SidebarItem {
				return feed.sidebarItemID == sidebarItemID
			} else {
				return false
			}
		})
	}
	
	func numberOfSections() -> Int {
		return shadowTable.count
	}
	
	func numberOfRows(in section: Int) -> Int {
		return shadowTable[section].sidebarItemNodes.count
	}
	
	func nodeFor(_ section: Int) -> Node? {
		return treeController.rootNode.childAtIndex(section)
	}

	func nodeFor(_ indexPath: IndexPath) -> Node? {
		guard indexPath.section > -1 &&
				indexPath.row > -1 &&
				indexPath.section < shadowTable.count &&
				indexPath.row < shadowTable[indexPath.section].sidebarItemNodes.count else {
			return nil
		}
		return shadowTable[indexPath.section].sidebarItemNodes[indexPath.row].node
	}

	func indexPathFor(_ object: AnyObject) -> IndexPath? {
		guard let node = treeController.rootNode.descendantNodeRepresentingObject(object) else {
			return nil
		}
		return indexPathFor(node)
	}

	func indexPathFor(_ node: Node) -> IndexPath? {

		let sidebarItemNode = SidebarItemNode(node)

		for i in 0..<shadowTable.count {
			if let row = shadowTable[i].sidebarItemNodes.firstIndex(of: sidebarItemNode) {
				return IndexPath(row: row, section: i)
			}
		}
		return nil
	}
	
	func articleFor(_ articleID: String) -> Article? {
		return idToAticleDictionary[articleID]
	}
	
	func cappedIndexPath(_ indexPath: IndexPath) -> IndexPath {
		guard indexPath.section < shadowTable.count && indexPath.row < shadowTable[indexPath.section].sidebarItemNodes.count else {
			return IndexPath(row: shadowTable[shadowTable.count - 1].sidebarItemNodes.count - 1, section: shadowTable.count - 1)
		}
		return indexPath
	}
	
	func unreadCountFor(_ node: Node) -> Int {
		// The coordinator supplies the unread count for the currently selected feed
		if node.representedObject === timelineFeed as AnyObject {
			return timelineUnreadCount
		}
		if let unreadCountProvider = node.representedObject as? UnreadCountProvider {
			return unreadCountProvider.unreadCount
		}
		assertionFailure("This method should only be called for nodes that have an UnreadCountProvider as the represented object.")
		return 0
	}
	
	func refreshTimeline(resetScroll: Bool) {
		if let article = self.currentArticle, let account = article.account {
			exceptionArticleFetcher = SingleArticleFetcher(account: account, articleID: article.articleID)
		}
		fetchAndReplaceArticlesAsync(animated: true) {
			self.timelineViewController?.reinitializeArticles(resetScroll: resetScroll)
		}
	}
	
	func isExpanded(_ containerID: ContainerIdentifier) -> Bool {
		return expandedTable.contains(containerID)
	}
		
	func isExpanded(_ containerIdentifiable: ContainerIdentifiable) -> Bool {
		if let containerID = containerIdentifiable.containerID {
			return isExpanded(containerID)
		}
		return false
	}
		
	func isExpanded(_ node: Node) -> Bool {
		if let containerIdentifiable = node.representedObject as? ContainerIdentifiable {
			return isExpanded(containerIdentifiable)
		}
		return false
	}
		
	func expand(_ containerID: ContainerIdentifier) {
		markExpanded(containerID)
		rebuildBackingStores()
	}
	
	/// This is a special function that expects the caller to change the disclosure arrow state outside this function.
	/// Failure to do so will get the Sidebar into an invalid state.
	func expand(_ node: Node) {
		guard let containerID = (node.representedObject as? ContainerIdentifiable)?.containerID else { return }
		lastExpandedTable.insert(containerID)
		expand(containerID)
	}
	
	func expandAllSectionsAndFolders() {
		for sectionNode in treeController.rootNode.childNodes {
			markExpanded(sectionNode)
			for topLevelNode in sectionNode.childNodes {
				if topLevelNode.representedObject is Folder {
					markExpanded(topLevelNode)
				}
			}
		}
		rebuildBackingStores()
	}
	
	func collapse(_ containerID: ContainerIdentifier) {
		unmarkExpanded(containerID)
		rebuildBackingStores()
		clearTimelineIfNoLongerAvailable()
	}
	
	/// This is a special function that expects the caller to change the disclosure arrow state outside this function.
	/// Failure to do so will get the Sidebar into an invalid state.
	func collapse(_ node: Node) {
		guard let containerID = (node.representedObject as? ContainerIdentifiable)?.containerID else { return }
		lastExpandedTable.remove(containerID)
		collapse(containerID)
	}
	
	func collapseAllFolders() {
		for sectionNode in treeController.rootNode.childNodes {
			for topLevelNode in sectionNode.childNodes {
				if topLevelNode.representedObject is Folder {
					unmarkExpanded(topLevelNode)
				}
			}
		}
		rebuildBackingStores()
		clearTimelineIfNoLongerAvailable()
	}
	
	func feedIndexPathForCurrentTimeline() -> IndexPath? {
		guard let node = treeController.rootNode.descendantNodeRepresentingObject(timelineFeed as AnyObject) else {
			return nil
		}
		return indexPathFor(node)
	}
	
	func selectFeed(_ feed: SidebarItem?, animations: Animations = [], deselectArticle: Bool = true, completion: (() -> Void)? = nil) {
		let indexPath: IndexPath? = {
			if let feed = feed, let indexPath = indexPathFor(feed as AnyObject) {
				return indexPath
			} else {
				return nil
			}
		}()
		selectFeed(indexPath: indexPath, animations: animations, deselectArticle: deselectArticle, completion: completion)
	}
	
	func selectFeed(indexPath: IndexPath?, animations: Animations = [], deselectArticle: Bool = true, completion: (() -> Void)? = nil) {
		guard indexPath != currentFeedIndexPath else {
			completion?()
			return
		}
		
		currentFeedIndexPath = indexPath
		sidebarViewController.updateFeedSelection(animations: animations)

		if deselectArticle {
			selectArticle(nil)
		}

		if let ip = indexPath, let node = nodeFor(ip), let feed = node.representedObject as? SidebarItem {
			
			self.activityManager.selecting(feed: feed)
			self.rootSplitViewController.show(.supplementary)
			setTimelineFeed(feed, animated: false) {
				if self.isReadFeedsFiltered {
					self.rebuildBackingStores()
				}
				completion?()
			}
			
		} else {
			
			setTimelineFeed(nil, animated: false) {
				if self.isReadFeedsFiltered {
					self.rebuildBackingStores()
				}
				self.activityManager.invalidateSelecting()
				self.rootSplitViewController.show(.primary)
				completion?()
			}
		}
	}
	
	func selectPrevFeed() {
		if let indexPath = prevFeedIndexPath {
			selectFeed(indexPath: indexPath, animations: [.navigation, .scroll])
		}
	}
	
	func selectNextFeed() {
		if let indexPath = nextFeedIndexPath {
			selectFeed(indexPath: indexPath, animations: [.navigation, .scroll])
		}
	}
	
	func selectTodayFeed(completion: (() -> Void)? = nil) {
		markExpanded(SmartFeedsController.shared)
		self.ensureFeedIsAvailableToSelect(SmartFeedsController.shared.todayFeed) {
			self.selectFeed(SmartFeedsController.shared.todayFeed, animations: [.navigation, .scroll], completion: completion)
		}
	}

	func selectAllUnreadFeed(completion: (() -> Void)? = nil) {
		markExpanded(SmartFeedsController.shared)
		self.ensureFeedIsAvailableToSelect(SmartFeedsController.shared.unreadFeed) {
			self.selectFeed(SmartFeedsController.shared.unreadFeed, animations: [.navigation, .scroll], completion: completion)
		}
	}

	func selectStarredFeed(completion: (() -> Void)? = nil) {
		markExpanded(SmartFeedsController.shared)
		self.ensureFeedIsAvailableToSelect(SmartFeedsController.shared.starredFeed) {
			self.selectFeed(SmartFeedsController.shared.starredFeed, animations: [.navigation, .scroll], completion: completion)
		}
	}

	func selectArticle(_ article: Article?, animations: Animations = [], isShowingExtractedArticle: Bool? = nil, articleWindowScrollY: Int? = nil) {
		guard article != currentArticle else { return }
		
		currentArticle = article
		activityManager.reading(feed: timelineFeed, article: article)
		
		if article == nil {
			rootSplitViewController.show(.supplementary)
			timelineViewController?.updateArticleSelection(animations: animations)
			articleViewController?.article = nil
			return
		}

		rootSplitViewController.show(.secondary)

		// Mark article as read before navigating to it, so the read status does not flash unread/read on display
		markArticles(Set([article!]), statusKey: .read, flag: true)
		timelineViewController?.updateArticleSelection(animations: animations)
		articleViewController?.article = article
		if let isShowingExtractedArticle = isShowingExtractedArticle, let articleWindowScrollY = articleWindowScrollY {
			articleViewController?.restoreScrollPosition = (isShowingExtractedArticle, articleWindowScrollY)
		}
	}
	
	func beginSearching() {
		isSearching = true
		preSearchTimelineFeed = timelineFeed
		savedSearchArticles = articles
		savedSearchArticleIds = Set(articles.map { $0.articleID })
		setTimelineFeed(nil, animated: true)
		selectArticle(nil)
	}

	func endSearching() {
		if let oldTimelineFeed = preSearchTimelineFeed {
			emptyTheTimeline()
			timelineFeed = oldTimelineFeed
			replaceArticles(with: savedSearchArticles!, animated: true)
			timelineViewController?.reinitializeArticles(resetScroll: true)
		} else {
			setTimelineFeed(nil, animated: true)
		}
		
		lastSearchString = ""
		lastSearchScope = nil
		preSearchTimelineFeed = nil
		savedSearchArticleIds = nil
		savedSearchArticles = nil
		isSearching = false
		selectArticle(nil)
		timelineViewController?.focus()
	}
	
	func searchArticles(_ searchString: String, _ searchScope: SearchScope) {
		
		guard isSearching else { return }
		
		if searchString.count < 3 {
			setTimelineFeed(nil, animated: true)
			return
		}
		
		if searchString != lastSearchString || searchScope != lastSearchScope {
			
			switch searchScope {
			case .global:
				setTimelineFeed(SmartFeed(delegate: SearchFeedDelegate(searchString: searchString)), animated: true)
			case .timeline:
				setTimelineFeed(SmartFeed(delegate: SearchTimelineFeedDelegate(searchString: searchString, articleIDs: savedSearchArticleIds!)), animated: true)
			}
			
			lastSearchString = searchString
			lastSearchScope = searchScope
		}
		
	}
	
	func findPrevArticle(_ article: Article) -> Article? {
		guard let index = articles.firstIndex(of: article), index > 0 else {
			return nil
		}
		return articles[index - 1]
	}
	
	func findNextArticle(_ article: Article) -> Article? {
		guard let index = articles.firstIndex(of: article), index + 1 != articles.count else {
			return nil
		}
		return articles[index + 1]
	}
	
	func selectPrevArticle() {
		if let article = prevArticle {
			selectArticle(article, animations: [.navigation, .scroll])
		}
	}
	
	func selectNextArticle() {
		if let article = nextArticle {
			selectArticle(article, animations: [.navigation, .scroll])
		}
	}
	
	func selectFirstUnread() {
		if selectFirstUnreadArticleInTimeline() {
			activityManager.selectingNextUnread()
		}
	}
	
	func selectPrevUnread() {
		
		// This should never happen, but I don't want to risk throwing us
		// into an infinite loop searching for an unread that isn't there.
		if appDelegate.unreadCount < 1 {
			return
		}
		
		isNavigationDisabled = true
		defer {
			isNavigationDisabled = false
		}

		if selectPrevUnreadArticleInTimeline() {
			return
		}
		
		selectPrevUnreadFeedFetcher()
		selectPrevUnreadArticleInTimeline()
	}

	func selectNextUnread() {
		
		// This should never happen, but I don't want to risk throwing us
		// into an infinite loop searching for an unread that isn't there.
		if appDelegate.unreadCount < 1 {
			return
		}
		
		isNavigationDisabled = true
		defer {
			isNavigationDisabled = false
		}

		if selectNextUnreadArticleInTimeline() {
			return
		}

		if self.isSearching {
			self.timelineViewController?.hideSearch()
		}

		selectNextUnreadFeed() {
			self.selectNextUnreadArticleInTimeline()
		}

	}
	
	func scrollOrGoToNextUnread() {
		if articleViewController?.canScrollDown() ?? false {
			articleViewController?.scrollPageDown()
		} else {
			selectNextUnread()
		}
	}

	func scrollUp() {
		if articleViewController?.canScrollUp() ?? false {
			articleViewController?.scrollPageUp()
		}
	}
	
	func markAllAsRead(_ articles: [Article], completion: (() -> Void)? = nil) {
		markArticlesWithUndo(articles, statusKey: .read, flag: true, completion: completion)
	}
	
	func markAllAsReadInTimeline(completion: (() -> Void)? = nil) {
		markAllAsRead(articles) {
			self.rootSplitViewController.show(.primary)
			completion?()
		}
	}

	func canMarkAboveAsRead(for article: Article) -> Bool {
		let articlesAboveArray = articles.articlesAbove(article: article)
		return articlesAboveArray.canMarkAllAsRead()
	}

	func markAboveAsRead() {
		guard let currentArticle = currentArticle else {
			return
		}

		markAboveAsRead(currentArticle)
	}

	func markAboveAsRead(_ article: Article) {
		let articlesAboveArray = articles.articlesAbove(article: article)
		markAllAsRead(articlesAboveArray)
	}

	func canMarkBelowAsRead(for article: Article) -> Bool {
		let articleBelowArray = articles.articlesBelow(article: article)
		return articleBelowArray.canMarkAllAsRead()
	}

	func markBelowAsRead() {
		guard let currentArticle = currentArticle else {
			return
		}

		markBelowAsRead(currentArticle)
	}

	func markBelowAsRead(_ article: Article) {
		let articleBelowArray = articles.articlesBelow(article: article)
		markAllAsRead(articleBelowArray)
	}
	
	func markAsReadForCurrentArticle() {
		if let article = currentArticle {
			markArticlesWithUndo([article], statusKey: .read, flag: true)
		}
	}
	
	func markAsUnreadForCurrentArticle() {
		if let article = currentArticle {
			markArticlesWithUndo([article], statusKey: .read, flag: false)
		}
	}
	
	func toggleReadForCurrentArticle() {
		if let article = currentArticle {
			toggleRead(article)
		}
	}
	
	func toggleRead(_ article: Article) {
		guard !article.status.read || article.isAvailableToMarkUnread else { return }
		markArticlesWithUndo([article], statusKey: .read, flag: !article.status.read)
	}

	func toggleStarredForCurrentArticle() {
		if let article = currentArticle {
			toggleStar(article)
		}
	}
	
	func toggleStar(_ article: Article) {
		markArticlesWithUndo([article], statusKey: .starred, flag: !article.status.starred)
	}

	func timelineFeedIsEqualTo(_ feed: Feed) -> Bool {
		guard let timelineFeed = timelineFeed as? Feed else {
			return false
		}

		return timelineFeed == feed
	}

	func discloseFeed(_ feed: Feed, initialLoad: Bool = false, animations: Animations = [], completion: (() -> Void)? = nil) {
		if isSearching {
			timelineViewController?.hideSearch()
		}
		
		guard let account = feed.account else {
			completion?()
			return
		}
		
		let parentFolder = account.sortedFolders?.first(where: { $0.objectIsChild(feed) })
		
		markExpanded(account)
		if let parentFolder = parentFolder {
			markExpanded(parentFolder)
		}
	
		if let sidebarItemID = feed.sidebarItemID {
			self.treeControllerDelegate.addFilterException(sidebarItemID)
		}
		if let parentFolderFeedID = parentFolder?.sidebarItemID {
			self.treeControllerDelegate.addFilterException(parentFolderFeedID)
		}

		rebuildBackingStores(initialLoad: initialLoad, completion:  {
			self.treeControllerDelegate.resetFilterExceptions()
			self.selectFeed(feed, animations: animations, completion: completion)
		})
		
	}
	
	func showStatusBar() {
		prefersStatusBarHidden = false
		UIView.animate(withDuration: 0.15) {
			self.rootSplitViewController.setNeedsStatusBarAppearanceUpdate()
		}
	}
	
	func hideStatusBar() {
		prefersStatusBarHidden = true
		UIView.animate(withDuration: 0.15) {
			self.rootSplitViewController.setNeedsStatusBarAppearanceUpdate()
		}
	}
	
	func showSettings(scrollToArticlesSection: Bool = false) {
		let settingsNavController = UIStoryboard.settings.instantiateInitialViewController() as! UINavigationController
		let settingsViewController = settingsNavController.topViewController as! SettingsViewController
		settingsViewController.scrollToArticlesSection = scrollToArticlesSection
		settingsNavController.modalPresentationStyle = .formSheet
		settingsViewController.presentingParentController = rootSplitViewController
		rootSplitViewController.present(settingsNavController, animated: true)
	}

	func showAccountInspector(for account: Account) {
		let accountInspectorNavController =
			UIStoryboard.inspector.instantiateViewController(identifier: "AccountInspectorNavigationViewController") as! UINavigationController
		let accountInspectorController = accountInspectorNavController.topViewController as! AccountInspectorViewController
		accountInspectorNavController.modalPresentationStyle = .formSheet
		accountInspectorNavController.preferredContentSize = AccountInspectorViewController.preferredContentSizeForFormSheetDisplay
		accountInspectorController.isModal = true
		accountInspectorController.account = account
		rootSplitViewController.present(accountInspectorNavController, animated: true)
	}

	func showFeedInspector() {
		let timelineFeed = timelineFeed as? Feed
		let articleFeed = currentArticle?.feed
		guard let feed = timelineFeed ?? articleFeed else {
			return
		}
		showFeedInspector(for: feed)
	}
	
	func showFeedInspector(for feed: Feed) {
		let feedInspectorNavController =
			UIStoryboard.inspector.instantiateViewController(identifier: "FeedInspectorNavigationViewController") as! UINavigationController
		let feedInspectorController = feedInspectorNavController.topViewController as! FeedInspectorViewController
		feedInspectorNavController.modalPresentationStyle = .formSheet
		feedInspectorNavController.preferredContentSize = FeedInspectorViewController.preferredContentSizeForFormSheetDisplay
		feedInspectorController.feed = feed
		rootSplitViewController.present(feedInspectorNavController, animated: true)
	}

	func showAddFeed(initialFeed: String? = nil, initialFeedName: String? = nil) {
		
		// Since Add Feed can be opened from anywhere with a keyboard shortcut, we have to deselect any currently selected feeds
		selectFeed(nil)

		let addNavViewController = UIStoryboard.add.instantiateViewController(withIdentifier: "AddFeedViewControllerNav") as! UINavigationController
		
		let addViewController = addNavViewController.topViewController as! AddFeedViewController
		addViewController.initialFeed = initialFeed
		addViewController.initialFeedName = initialFeedName
		
		addNavViewController.modalPresentationStyle = .formSheet
		addNavViewController.preferredContentSize = AddFeedViewController.preferredContentSizeForFormSheetDisplay
		sidebarViewController.present(addNavViewController, animated: true)
	}
	
	func showAddFolder() {
		let addNavViewController = UIStoryboard.add.instantiateViewController(withIdentifier: "AddFolderViewControllerNav") as! UINavigationController
		addNavViewController.modalPresentationStyle = .formSheet
		addNavViewController.preferredContentSize = AddFolderViewController.preferredContentSizeForFormSheetDisplay
		sidebarViewController.present(addNavViewController, animated: true)
	}
	
	func showFullScreenImage(image: UIImage, imageTitle: String?, transitioningDelegate: UIViewControllerTransitioningDelegate) {
		let imageVC = UIStoryboard.main.instantiateController(ofType: ImageViewController.self)
		imageVC.image = image
		imageVC.imageTitle = imageTitle
		imageVC.modalPresentationStyle = .currentContext
		imageVC.transitioningDelegate = transitioningDelegate
		rootSplitViewController.present(imageVC, animated: true)
	}
	
	func homePageURLForFeed(_ indexPath: IndexPath) -> URL? {
		guard let node = nodeFor(indexPath),
			let feed = node.representedObject as? Feed,
			let homePageURL = feed.homePageURL,
			let url = URL(string: homePageURL) else {
				return nil
		}
		return url
	}
	
	func showBrowserForFeed(_ indexPath: IndexPath) {
		if let url = homePageURLForFeed(indexPath) {
			UIApplication.shared.open(url, options: [:])
		}
	}
	
	func showBrowserForCurrentFeed() {
		if let ip = currentFeedIndexPath, let url = homePageURLForFeed(ip) {
			UIApplication.shared.open(url, options: [:])
		}
	}
	
	func showBrowserForArticle(_ article: Article) {
		guard let url = article.preferredURL else { return }
		UIApplication.shared.open(url, options: [:])
	}

	func showBrowserForCurrentArticle() {
		guard let url = currentArticle?.preferredURL else { return }
		UIApplication.shared.open(url, options: [:])
	}
	
	func showInAppBrowser() {
		if currentArticle != nil {
			articleViewController?.openInAppBrowser()
		}
		else {
			sidebarViewController.openInAppBrowser()
		}
	}

	func navigateToFeeds() {
		sidebarViewController?.focus()
		selectArticle(nil)
	}
	
	func navigateToTimeline() {
		if currentArticle == nil && articles.count > 0 {
			selectArticle(articles[0])
		}
		timelineViewController?.focus()
	}
	
	func navigateToDetail() {
		articleViewController?.focus()
	}

	func toggleSidebar() {
		rootSplitViewController.preferredDisplayMode = rootSplitViewController.displayMode == .oneBesideSecondary ? .secondaryOnly : .oneBesideSecondary
	}
	
	func selectArticleInCurrentFeed(_ articleID: String, isShowingExtractedArticle: Bool? = nil, articleWindowScrollY: Int? = nil) {
		if let article = self.articles.first(where: { $0.articleID == articleID }) {
			self.selectArticle(article, isShowingExtractedArticle: isShowingExtractedArticle, articleWindowScrollY: articleWindowScrollY)
		}
	}
	
	func importTheme(filename: String) {
		do {
			try ArticleThemeImporter.importTheme(controller: rootSplitViewController, filename: filename)
		} catch {
			NotificationCenter.default.post(name: .didFailToImportThemeWithError, object: nil, userInfo: ["error" : error])
		}
		
	}
	
	/// This will dismiss the foremost view controller if the user
	/// has launched from an external action (i.e., a widget tap, or
	/// selecting an article via a notification).
	///
	/// The dismiss is only applicable if the view controller is a
	/// `SFSafariViewController` or `SettingsViewController`,
	/// otherwise, this function does nothing.
	func dismissIfLaunchingFromExternalAction() {
		guard let presentedController = sidebarViewController.presentedViewController else { return }

		if presentedController.isKind(of: SFSafariViewController.self) {
			presentedController.dismiss(animated: true, completion: nil)
		}

		// There's no obvious way to detect if the presented controller
		// is the SwiftUI UIHostingController<SettingsView>. Posting a notification
		// which it can react to seems to be the simplest solution.
		NotificationCenter.default.post(name: .LaunchedFromExternalAction, object: nil)
	}
}

// MARK: UISplitViewControllerDelegate

extension SceneCoordinator: UISplitViewControllerDelegate {

	func splitViewController(_ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column) -> UISplitViewController.Column {
		switch proposedTopColumn {
		case .supplementary:
			if currentFeedIndexPath != nil {
				return .supplementary
			} else {
				return .primary
			}
		case .secondary:
			if currentArticle != nil {
				return .secondary
			} else {
				if currentFeedIndexPath != nil {
					return .supplementary
				} else {
					return .primary
				}
			}
		default:
			return .primary
		}
	}

	func splitViewController(_ svc: UISplitViewController, willChangeTo displayMode: UISplitViewController.DisplayMode) {
		articleViewController?.splitViewControllerWillChangeTo(displayMode: displayMode)
	}
}

// MARK: UINavigationControllerDelegate

extension SceneCoordinator: UINavigationControllerDelegate {

	func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
		guard UIApplication.shared.applicationState != .background else {
			return
		}

		guard rootSplitViewController.isCollapsed else {
			return
		}

		defer {
			if let mainController = viewController as? MainControllerIdentifiable {
				lastMainControllerToAppear = mainController.mainControllerIdentifier
			} else if let mainController = (viewController as? UINavigationController)?.topViewController as? MainControllerIdentifiable {
				lastMainControllerToAppear = mainController.mainControllerIdentifier
			}
		}

		// If we are showing the Feeds and only the feeds start clearing stuff
		if viewController === sidebarViewController && lastMainControllerToAppear == .mainTimeline {
			activityManager.invalidateCurrentActivities()
			selectFeed(nil, animations: [.scroll, .select, .navigation])
			return
		}

		// If we are using a phone and navigate away from the detail, clear up the article resources (including activity).
		// Don't clear it if we have pushed an ArticleViewController, but don't yet see it on the navigation stack.
		// This happens when we are going to the next unread and we need to grab another timeline to continue.  The
		// ArticleViewController will be pushed, but we will briefly show the Timeline.  Don't clear things out when that happens.
		if viewController === timelineViewController && lastMainControllerToAppear == .article {
			selectArticle(nil, animations: [.scroll, .select, .navigation])

			// Restore any bars hidden by the article controller
			showStatusBar()

			// We delay the showing of the navigation bars because it freaks out on iOS 15 with the new split view controller
			// if it is trying to show at the same time as the show timeline animation
			DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
				navigationController.setNavigationBarHidden(false, animated: true)
			}
			navigationController.setToolbarHidden(false, animated: true)
			return
		}
	}
}

// MARK: Private

private extension SceneCoordinator {

	func configureNavigationController(_ navController: UINavigationController) {

		let scrollEdge = UINavigationBarAppearance()
		scrollEdge.configureWithOpaqueBackground()
		scrollEdge.shadowColor = nil
		scrollEdge.shadowImage = UIImage()

		let standard = UINavigationBarAppearance()
		standard.shadowColor = .opaqueSeparator
		standard.shadowImage = UIImage()

		navController.navigationBar.standardAppearance = standard
		navController.navigationBar.compactAppearance = standard
		navController.navigationBar.scrollEdgeAppearance = scrollEdge
		navController.navigationBar.compactScrollEdgeAppearance = scrollEdge

		navController.navigationBar.tintColor = AppAssets.primaryAccentColor

		let toolbarAppearance = UIToolbarAppearance()
		navController.toolbar.standardAppearance = toolbarAppearance
		navController.toolbar.compactAppearance = toolbarAppearance
		navController.toolbar.scrollEdgeAppearance = toolbarAppearance
		navController.toolbar.tintColor = AppAssets.primaryAccentColor
	}

	func markArticlesWithUndo(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool, completion: (() -> Void)? = nil) {
		guard let undoManager = undoManager,
			  let markReadCommand = MarkStatusCommand(initialArticles: articles, statusKey: statusKey, flag: flag, undoManager: undoManager, completion: completion) else {
			completion?()
			return
		}
		runCommand(markReadCommand)
	}
	
	func updateUnreadCount() {
		var count = 0
		for article in articles {
			if !article.status.read {
				count += 1
			}
		}
		timelineUnreadCount = count
	}
	
	func rebuildArticleDictionaries() {
		var idDictionary = [String: Article]()

		for article in articles {
			idDictionary[article.articleID] = article
		}

		_idToArticleDictionary = idDictionary
		articleDictionaryNeedsUpdate = false
	}
	
	func ensureFeedIsAvailableToSelect(_ feed: SidebarItem, completion: @escaping () -> Void) {
		addToFilterExeptionsIfNecessary(feed)
		addShadowTableToFilterExceptions()
		
		rebuildBackingStores(completion:  {
			self.treeControllerDelegate.resetFilterExceptions()
			completion()
		})
	}

	func addToFilterExeptionsIfNecessary(_ feed: SidebarItem?) {
		if isReadFeedsFiltered, let sidebarItemID = feed?.sidebarItemID {
			if feed is SmartFeed {
				treeControllerDelegate.addFilterException(sidebarItemID)
			} else if let folderFeed = feed as? Folder {
				if folderFeed.account?.existingFolder(withID: folderFeed.folderID) != nil {
					treeControllerDelegate.addFilterException(sidebarItemID)
				}
			} else if let feed = feed as? Feed {
				if feed.account?.existingFeed(withFeedID: feed.feedID) != nil {
					treeControllerDelegate.addFilterException(sidebarItemID)
					addParentFolderToFilterExceptions(feed)
				}
			}
		}
	}
	
	func addParentFolderToFilterExceptions(_ feed: SidebarItem) {
		guard let node = treeController.rootNode.descendantNodeRepresentingObject(feed as AnyObject),
			let folder = node.parent?.representedObject as? Folder,
			let folderFeedID = folder.sidebarItemID else {
				return
		}
		
		treeControllerDelegate.addFilterException(folderFeedID)
	}
	
	func addShadowTableToFilterExceptions() {
		for section in shadowTable {
			for sidebarItemNode in section.sidebarItemNodes {
				if let sidebarItem = sidebarItemNode.node.representedObject as? SidebarItem, let sidebarItemID = sidebarItem.sidebarItemID {
					treeControllerDelegate.addFilterException(sidebarItemID)
				}
			}
		}
	}
	
	func queueRebuildBackingStores() {
		rebuildBackingStoresQueue.add(self, #selector(rebuildBackingStoresWithDefaults))
	}

	@objc func rebuildBackingStoresWithDefaults() {
		rebuildBackingStores()
	}
	
	func rebuildBackingStores(initialLoad: Bool = false, updateExpandedNodes: (() -> Void)? = nil, completion: (() -> Void)? = nil) {
		if !BatchUpdate.shared.isPerforming {
			addToFilterExeptionsIfNecessary(timelineFeed)
			treeController.rebuild()
			treeControllerDelegate.resetFilterExceptions()
			
			updateExpandedNodes?()
			let changes = rebuildShadowTable()
			sidebarViewController.reloadFeeds(initialLoad: initialLoad, changes: changes, completion: completion)
		}
	}
	
	func rebuildShadowTable() -> ShadowTableChanges {
		var newShadowTable = [(sectionID: String, sidebarItemNodes: [SidebarItemNode])]()

		for i in 0..<treeController.rootNode.numberOfChildNodes {
			
			var sidebarItemNodes = [SidebarItemNode]()
			let sectionNode = treeController.rootNode.childAtIndex(i)!
			
			if isExpanded(sectionNode) {
				for node in sectionNode.childNodes {
					let sidebarItemNode = SidebarItemNode(node)
					sidebarItemNodes.append(sidebarItemNode)
					if isExpanded(node) {
						for child in node.childNodes {
							let childNode = SidebarItemNode(child)
							sidebarItemNodes.append(childNode)
						}
					}
				}
			}

			let sectionID = (sectionNode.representedObject as? Account)?.accountID ?? ""
			newShadowTable.append((sectionID: sectionID, sidebarItemNodes: sidebarItemNodes))
		}
		
		// If we have a current Feed IndexPath it is no longer valid and needs reset.
		if currentFeedIndexPath != nil {
			currentFeedIndexPath = indexPathFor(timelineFeed as AnyObject)
		}
		
		// Compute the differences in the shadow table rows and the expanded table entries
		var changes = [ShadowTableChanges.RowChanges]()
		let expandedTableDifference = lastExpandedTable.symmetricDifference(expandedTable)

		for (section, newSectionRows) in newShadowTable.enumerated() {
			var moves = Set<ShadowTableChanges.Move>()
			var inserts = Set<Int>()
			var deletes = Set<Int>()
			
			let oldFeedNodes = shadowTable.first(where: { $0.sectionID == newSectionRows.sectionID })?.sidebarItemNodes ?? [SidebarItemNode]()

			let diff = newSectionRows.sidebarItemNodes.difference(from: oldFeedNodes).inferringMoves()
			for change in diff {
				switch change {
				case .insert(let offset, _, let associated):
					if let associated = associated {
						moves.insert(ShadowTableChanges.Move(associated, offset))
					} else {
						inserts.insert(offset)
					}
				case .remove(let offset, _, let associated):
					if let associated = associated {
						moves.insert(ShadowTableChanges.Move(offset, associated))
					} else {
						deletes.insert(offset)
					}
				}
			}
			
			// We need to reload the difference in expanded rows to get the disclosure arrows correct when programmatically changing their state
			var reloads = Set<Int>()

			for (index, newFeedNode) in newSectionRows.sidebarItemNodes.enumerated() {
				if let newFeedNodeContainerID = (newFeedNode.node.representedObject as? Container)?.containerID {
					if expandedTableDifference.contains(newFeedNodeContainerID) {
						reloads.insert(index)
					}
				}
			}

			changes.append(ShadowTableChanges.RowChanges(section: section, deletes: deletes, inserts: inserts, reloads: reloads, moves: moves))
		}

		lastExpandedTable = expandedTable

		// Compute the difference in the shadow table sections
		var moves = Set<ShadowTableChanges.Move>()
		var inserts = Set<Int>()
		var deletes = Set<Int>()
		
		let oldSections = shadowTable.map { $0.sectionID }
		let newSections = newShadowTable.map { $0.sectionID }
		let diff = newSections.difference(from: oldSections).inferringMoves()
		for change in diff {
			switch change {
			case .insert(let offset, _, let associated):
				if let associated = associated {
					moves.insert(ShadowTableChanges.Move(associated, offset))
				} else {
					inserts.insert(offset)
				}
			case .remove(let offset, _, let associated):
				if let associated = associated {
					moves.insert(ShadowTableChanges.Move(offset, associated))
				} else {
					deletes.insert(offset)
				}
			}
		}

		shadowTable = newShadowTable
		
		return ShadowTableChanges(deletes: deletes, inserts: inserts, moves: moves, rowChanges: changes)
	}
	
	func shadowTableContains(_ sidebarItem: SidebarItem) -> Bool {
		for section in shadowTable {
			for sidebarItemNode in section.sidebarItemNodes {
				if let node = sidebarItemNode.node.representedObject as? SidebarItem, node.sidebarItemID == sidebarItem.sidebarItemID {
					return true
				}
			}
		}
		return false
	}
	
	func clearTimelineIfNoLongerAvailable() {
		if let feed = timelineFeed, !shadowTableContains(feed) {
			selectFeed(nil, deselectArticle: true)
		}
	}

	func setTimelineFeed(_ feed: SidebarItem?, animated: Bool, completion: (() -> Void)? = nil) {
		timelineFeed = feed
		
		fetchAndReplaceArticlesAsync(animated: animated) {
			self.timelineViewController?.reinitializeArticles(resetScroll: true)
			completion?()
		}
	}
	
	func updateShowNamesAndIcons() {
		
		if timelineFeed is Feed {
			showFeedNames = {
				for article in articles {
					if !article.byline().isEmpty {
						return .byline
					}
				}
				return .none
			}()
		} else {
			showFeedNames = .feed
		}

		if showFeedNames == .feed {
			self.showIcons = true
			return
		}
		
		if showFeedNames == .none {
			self.showIcons = false
			return
		}
		
		for article in articles {
			if let authors = article.authors {
				for author in authors {
					if author.avatarURL != nil {
						self.showIcons = true
						return
					}
				}
			}
		}
		
		self.showIcons = false
	}
	
	func markExpanded(_ containerID: ContainerIdentifier) {
		expandedTable.insert(containerID)
	}

	func markExpanded(_ containerIdentifiable: ContainerIdentifiable) {
		if let containerID = containerIdentifiable.containerID {
			markExpanded(containerID)
		}
	}
	
	func markExpanded(_ node: Node) {
		if let containerIdentifiable = node.representedObject as? ContainerIdentifiable {
			markExpanded(containerIdentifiable)
		}
	}
	
	func unmarkExpanded(_ containerID: ContainerIdentifier) {
		expandedTable.remove(containerID)
	}

	func unmarkExpanded(_ containerIdentifiable: ContainerIdentifiable) {
		if let containerID = containerIdentifiable.containerID {
			unmarkExpanded(containerID)
		}
	}

	func unmarkExpanded(_ node: Node) {
		if let containerIdentifiable = node.representedObject as? ContainerIdentifiable {
			unmarkExpanded(containerIdentifiable)
		}
	}

	// MARK: Select Prev Unread

	@discardableResult
	func selectPrevUnreadArticleInTimeline() -> Bool {
		let startingRow: Int = {
			if let articleRow = currentArticleRow {
				return articleRow
			} else {
				return articles.count - 1
			}
		}()
		
		return selectPrevArticleInTimeline(startingRow: startingRow)
	}
	
	func selectPrevArticleInTimeline(startingRow: Int) -> Bool {
		
		guard startingRow >= 0 else {
			return false
		}
		
		for i in (0...startingRow).reversed() {
			let article = articles[i]
			if !article.status.read {
				selectArticle(article)
				return true
			}
		}
		
		return false
		
	}
	
	func selectPrevUnreadFeedFetcher() {
		
		let indexPath: IndexPath = {
			if currentFeedIndexPath == nil {
				return IndexPath(row: 0, section: 0)
			} else {
				return currentFeedIndexPath!
			}
		}()

		// Increment or wrap around the IndexPath
		let nextIndexPath: IndexPath = {
			if indexPath.row - 1 < 0 {
				if indexPath.section - 1 < 0 {
					return IndexPath(row: shadowTable[shadowTable.count - 1].sidebarItemNodes.count - 1, section: shadowTable.count - 1)
				} else {
					return IndexPath(row: shadowTable[indexPath.section - 1].sidebarItemNodes.count - 1, section: indexPath.section - 1)
				}
			} else {
				return IndexPath(row: indexPath.row - 1, section: indexPath.section)
			}
		}()
		
		if selectPrevUnreadFeedFetcher(startingWith: nextIndexPath) {
			return
		}
		let maxIndexPath = IndexPath(row: shadowTable[shadowTable.count - 1].sidebarItemNodes.count - 1, section: shadowTable.count - 1)
		selectPrevUnreadFeedFetcher(startingWith: maxIndexPath)
		
	}
	
	@discardableResult
	func selectPrevUnreadFeedFetcher(startingWith indexPath: IndexPath) -> Bool {
		
		for i in (0...indexPath.section).reversed() {
			
			let startingRow: Int = {
				if indexPath.section == i {
					return indexPath.row
				} else {
					return shadowTable[i].sidebarItemNodes.count - 1
				}
			}()
			
			for j in (0...startingRow).reversed() {
				
				let prevIndexPath = IndexPath(row: j, section: i)
				guard let node = nodeFor(prevIndexPath), let unreadCountProvider = node.representedObject as? UnreadCountProvider else {
					assertionFailure()
					return true
				}
				
				if isExpanded(node) {
					continue
				}
				
				if unreadCountProvider.unreadCount > 0 {
					selectFeed(indexPath: prevIndexPath, animations: [.scroll, .navigation])
					return true
				}
				
			}
			
		}
		
		return false
		
	}
	
	// MARK: Select Next Unread
	
	@discardableResult
	func selectFirstUnreadArticleInTimeline() -> Bool {
		return selectNextArticleInTimeline(startingRow: 0, animated: true)
	}
	
	@discardableResult
	func selectNextUnreadArticleInTimeline() -> Bool {
		let startingRow: Int = {
			if let articleRow = currentArticleRow {
				return articleRow + 1
			} else {
				return 0
			}
		}()
		
		return selectNextArticleInTimeline(startingRow: startingRow, animated: false)
	}
	
	func selectNextArticleInTimeline(startingRow: Int, animated: Bool) -> Bool {
		
		guard startingRow < articles.count else {
			return false
		}
		
		for i in startingRow..<articles.count {
			let article = articles[i]
			if !article.status.read {
				selectArticle(article, animations: [.scroll, .navigation])
				return true
			}
		}
		
		return false
		
	}
	
	func selectNextUnreadFeed(completion: @escaping () -> Void) {
		
		let indexPath: IndexPath = {
			if currentFeedIndexPath == nil {
				return IndexPath(row: -1, section: 0)
			} else {
				return currentFeedIndexPath!
			}
		}()
		
		// Increment or wrap around the IndexPath
		let nextIndexPath: IndexPath = {
			if indexPath.row + 1 >= shadowTable[indexPath.section].sidebarItemNodes.count {
				if indexPath.section + 1 >= shadowTable.count {
					return IndexPath(row: 0, section: 0)
				} else {
					return IndexPath(row: 0, section: indexPath.section + 1)
				}
			} else {
				return IndexPath(row: indexPath.row + 1, section: indexPath.section)
			}
		}()
		
		selectNextUnreadFeed(startingWith: nextIndexPath) { found in
			if !found {
				self.selectNextUnreadFeed(startingWith: IndexPath(row: 0, section: 0)) { _ in
					completion()
				}
			} else {
				completion()
			}
		}
		
	}
	
	func selectNextUnreadFeed(startingWith indexPath: IndexPath, completion: @escaping (Bool) -> Void) {
		
		for i in indexPath.section..<shadowTable.count {
			
			let startingRow: Int = {
				if indexPath.section == i {
					return indexPath.row
				} else {
					return 0
				}
			}()

			for j in startingRow..<shadowTable[i].sidebarItemNodes.count {

				let nextIndexPath = IndexPath(row: j, section: i)
				guard let node = nodeFor(nextIndexPath), let unreadCountProvider = node.representedObject as? UnreadCountProvider else {
					assertionFailure()
					completion(false)
					return
				}

				if isExpanded(node) {
					continue
				}
				
				if unreadCountProvider.unreadCount > 0 {
					selectFeed(indexPath: nextIndexPath, animations: [.scroll, .navigation], deselectArticle: false) {
						self.currentArticle = nil
						completion(true)
					}
					return
				}
				
			}
			
		}
		
		completion(false)
		
	}
	
	// MARK: Fetching Articles
	
	func emptyTheTimeline() {
		if !articles.isEmpty {
			replaceArticles(with: Set<Article>(), animated: false)
		}
	}
	
	func sortParametersDidChange() {
		replaceArticles(with: Set(articles), animated: true)
	}
		
	func replaceArticles(with unsortedArticles: Set<Article>, animated: Bool) {
		let sortedArticles = Array(unsortedArticles).sortedByDate(sortDirection, groupByFeed: groupByFeed)
		replaceArticles(with: sortedArticles, animated: animated)
	}
	
	func replaceArticles(with sortedArticles: ArticleArray, animated: Bool) {
		if articles != sortedArticles {
			articles = sortedArticles
			updateShowNamesAndIcons()
			updateUnreadCount()
			timelineViewController?.reloadArticles(animated: animated)
		}
	}
	
	func queueFetchAndMergeArticles() {
		fetchAndMergeArticlesQueue.add(self, #selector(fetchAndMergeArticlesAsync))
	}

	@objc func fetchAndMergeArticlesAsync() {
		fetchAndMergeArticlesAsync(animated: true) {
			self.timelineViewController?.reinitializeArticles(resetScroll: false)
			self.timelineViewController?.restoreSelectionIfNecessary(adjustScroll: false)
		}
	}
	
	func fetchAndMergeArticlesAsync(animated: Bool = true, completion: (() -> Void)? = nil) {
		
		guard let timelineFeed = timelineFeed else {
			return
		}
		
		fetchUnsortedArticlesAsync(for: [timelineFeed]) { [weak self] (unsortedArticles) in
			// Merge articles by articleID. For any unique articleID in current articles, add to unsortedArticles.
			guard let strongSelf = self else {
				return
			}
			let unsortedArticleIDs = unsortedArticles.articleIDs()
			var updatedArticles = unsortedArticles
			for article in strongSelf.articles {
				if !unsortedArticleIDs.contains(article.articleID) {
					updatedArticles.insert(article)
				}
				if article.account?.existingFeed(withFeedID: article.feedID) == nil {
					updatedArticles.remove(article)
				}
			}

			strongSelf.replaceArticles(with: updatedArticles, animated: animated)
			completion?()
		}
		
	}
	
	func cancelPendingAsyncFetches() {
		fetchSerialNumber += 1
		fetchRequestQueue.cancelAllRequests()
	}

	func fetchAndReplaceArticlesAsync(animated: Bool, completion: @escaping () -> Void) {
		// To be called when we need to do an entire fetch, but an async delay is okay.
		// Example: we have the Today feed selected, and the calendar day just changed.

		cancelPendingAsyncFetches()
		emptyTheTimeline()

		guard let timelineFeed = timelineFeed else {
			completion()
			return
		}
		
		var fetchers = [ArticleFetcher]()
		fetchers.append(timelineFeed)
		if exceptionArticleFetcher != nil {
			fetchers.append(exceptionArticleFetcher!)
			exceptionArticleFetcher = nil
		}
		
		fetchUnsortedArticlesAsync(for: fetchers) { [weak self] (articles) in
			self?.replaceArticles(with: articles, animated: animated)
			completion()
		}
	}

	func fetchUnsortedArticlesAsync(for representedObjects: [Any], completion: @escaping ArticleSetBlock) {
		// The callback will *not* be called if the fetch is no longer relevant — that is,
		// if it’s been superseded by a newer fetch, or the timeline was emptied, etc., it won’t get called.
		precondition(Thread.isMainThread)
		cancelPendingAsyncFetches()
		
		let fetchers = representedObjects.compactMap { $0 as? ArticleFetcher }
		let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, readFilterEnabledTable: readFilterEnabledTable, fetchers: fetchers) { [weak self] (articles, operation) in
			precondition(Thread.isMainThread)
			guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else {
				return
			}
			completion(articles)
		}
		
		fetchRequestQueue.add(fetchOperation)
	}

	func timelineFetcherContainsAnyPseudoFeed() -> Bool {
		if timelineFeed is PseudoFeed {
			return true
		}
		return false
	}
	
	func timelineFetcherContainsAnyFolder() -> Bool {
		if timelineFeed is Folder {
			return true
		}
		return false
	}
	
	func timelineFetcherContainsAnyFeed(_ feeds: Set<Feed>) -> Bool {
		
		// Return true if there’s a match or if a folder contains (recursively) one of feeds
		
		if let feed = timelineFeed as? Feed {
			for oneFeed in feeds {
				if feed.feedID == oneFeed.feedID || feed.url == oneFeed.url {
					return true
				}
			}
		} else if let folder = timelineFeed as? Folder {
			for oneFeed in feeds {
				if folder.hasFeed(with: oneFeed.feedID) || folder.hasFeed(withURL: oneFeed.url) {
					return true
				}
			}
		}
		
		return false
		
	}

	// MARK: NSUserActivity

	func windowState() -> [AnyHashable: Any] {
		let containerExpandedWindowState = expandedTable.map( { $0.userInfo })
		var readArticlesFilterState = [[AnyHashable: AnyHashable]: Bool]()
		for key in readFilterEnabledTable.keys {
			readArticlesFilterState[key.userInfo] = readFilterEnabledTable[key]
		}
		return [
			UserInfoKey.readFeedsFilterState: isReadFeedsFiltered,
			UserInfoKey.containerExpandedWindowState: containerExpandedWindowState,
			UserInfoKey.readArticlesFilterState: readArticlesFilterState,
			UserInfoKey.isSidebarHidden: rootSplitViewController.displayMode == .secondaryOnly
		]
	}
	
	func handleSelectFeed(_ userInfo: [AnyHashable : Any]?) {
		guard let userInfo = userInfo,
			let feedIdentifierUserInfo = userInfo[UserInfoKey.feedIdentifier] as? [AnyHashable : AnyHashable],
			let sidebarItemID = SidebarItemIdentifier(userInfo: feedIdentifierUserInfo) else {
				return
		}

		treeControllerDelegate.addFilterException(sidebarItemID)
		
		switch sidebarItemID {
		
		case .smartFeed:
			guard let smartFeed = SmartFeedsController.shared.find(by: sidebarItemID) else { return }

			markExpanded(SmartFeedsController.shared)
			rebuildBackingStores(initialLoad: true, completion:  {
				self.treeControllerDelegate.resetFilterExceptions()
				if let indexPath = self.indexPathFor(smartFeed) {
					self.selectFeed(indexPath: indexPath) {
						self.sidebarViewController.focus()
					}
				}
			})
		
		case .script:
			break
		
		case .folder(let accountID, let folderName):
			guard let accountNode = self.findAccountNode(accountID: accountID),
				let account = accountNode.representedObject as? Account else {
				return
			}

			markExpanded(account)
			
			rebuildBackingStores(initialLoad: true, completion:  {
				self.treeControllerDelegate.resetFilterExceptions()
				
				if let folderNode = self.findFolderNode(folderName: folderName, beginningAt: accountNode), let indexPath = self.indexPathFor(folderNode) {
					self.selectFeed(indexPath: indexPath) {
						self.sidebarViewController.focus()
					}
				}
			})
		
		case .feed(let accountID, let feedID):
			guard let accountNode = findAccountNode(accountID: accountID),
				let account = accountNode.representedObject as? Account,
				let feed = account.existingFeed(withFeedID: feedID) else {
				return
			}
			
			self.discloseFeed(feed, initialLoad: true) {
				self.sidebarViewController.focus()
			}
		}
	}
	
	func handleReadArticle(_ userInfo: [AnyHashable : Any]?) {
		guard let userInfo = userInfo else { return }
		
		guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable : Any],
			  let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String,
			  let accountName = articlePathUserInfo[ArticlePathKey.accountName] as? String,
			  let feedID = articlePathUserInfo[ArticlePathKey.feedID] as? String,
			  let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String,
			  let accountNode = findAccountNode(accountID: accountID, accountName: accountName),
			  let account = accountNode.representedObject as? Account else {
				  return
			  }
		
		exceptionArticleFetcher = SingleArticleFetcher(account: account, articleID: articleID)

		if restoreFeedSelection(userInfo, accountID: accountID, feedID: feedID, articleID: articleID) {
			return
		}
		
		guard let feed = account.existingFeed(withFeedID: feedID) else {
			return
		}
		
		discloseFeed(feed) {
			self.selectArticleInCurrentFeed(articleID)
		}
	}
	
	func restoreFeedSelection(_ userInfo: [AnyHashable : Any], accountID: String, feedID: String, articleID: String) -> Bool {
		guard let feedIdentifierUserInfo = userInfo[UserInfoKey.feedIdentifier] as? [AnyHashable : AnyHashable],
			  let sidebarItemID = SidebarItemIdentifier(userInfo: feedIdentifierUserInfo),
			  let isShowingExtractedArticle = userInfo[UserInfoKey.isShowingExtractedArticle] as? Bool,
			  let articleWindowScrollY = userInfo[UserInfoKey.articleWindowScrollY] as? Int else {
				  return false
			  }

		switch sidebarItemID {

		case .script:
			return false

		case .smartFeed, .folder:
			let found = selectFeedAndArticle(sidebarItemID: sidebarItemID, articleID: articleID, isShowingExtractedArticle: isShowingExtractedArticle, articleWindowScrollY: articleWindowScrollY)
			if found {
				treeControllerDelegate.addFilterException(sidebarItemID)
			}
			return found
		
		case .feed:
			let found = selectFeedAndArticle(sidebarItemID: sidebarItemID, articleID: articleID, isShowingExtractedArticle: isShowingExtractedArticle, articleWindowScrollY: articleWindowScrollY)
			if found {
				treeControllerDelegate.addFilterException(sidebarItemID)
				if let sidebarItemNode = nodeFor(sidebarItemID: sidebarItemID), let folder = sidebarItemNode.parent?.representedObject as? Folder, let folderFeedID = folder.sidebarItemID {
					treeControllerDelegate.addFilterException(folderFeedID)
				}
			}
			return found
			
		}
		
	}
	
	func findAccountNode(accountID: String, accountName: String? = nil) -> Node? {
		if let node = treeController.rootNode.descendantNode(where: { ($0.representedObject as? Account)?.accountID == accountID }) {
			return node
		}

		if let accountName = accountName, let node = treeController.rootNode.descendantNode(where: { ($0.representedObject as? Account)?.nameForDisplay == accountName }) {
			return node
		}

		return nil
	}
	
	func findFolderNode(folderName: String, beginningAt startingNode: Node) -> Node? {
		if let node = startingNode.descendantNode(where: { ($0.representedObject as? Folder)?.nameForDisplay == folderName }) {
			return node
		}
		return nil
	}

	func findFeedNode(feedID: String, beginningAt startingNode: Node) -> Node? {
		if let node = startingNode.descendantNode(where: { ($0.representedObject as? Feed)?.feedID == feedID }) {
			return node
		}
		return nil
	}
	
	func selectFeedAndArticle(sidebarItemID: SidebarItemIdentifier, articleID: String, isShowingExtractedArticle: Bool, articleWindowScrollY: Int) -> Bool {
		guard let sidebarItemNode = nodeFor(sidebarItemID: sidebarItemID), let indexPath = indexPathFor(sidebarItemNode) else { return false }

		selectFeed(indexPath: indexPath) {
			self.selectArticleInCurrentFeed(articleID, isShowingExtractedArticle: isShowingExtractedArticle, articleWindowScrollY: articleWindowScrollY)
		}
		
		return true
	}
	
}
