//
//  ProfileStatusesViewController.swift
//  Tusker
//
//  Created by Shadowfacts on 10/6/22.
//  Copyright © 2022 Shadowfacts. All rights reserved.
//

import UIKit
import Pachyderm
import Combine

class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
    
    weak var owner: ProfileViewController?
    let mastodonController: MastodonController
    let filterer: Filterer
    private(set) var accountID: String!
    let kind: Kind
    var initialHeaderMode: HeaderMode?
    weak var profileHeaderDelegate: ProfileHeaderViewDelegate?
    
    private(set) var controller: TimelineLikeController<TimelineItem>!
    let confirmLoadMore = PassthroughSubject<Void, Never>()
    private var newer: RequestRange?
    private var older: RequestRange?
    private var cancellables = Set<AnyCancellable>()
    
    var collectionView: UICollectionView! {
        view as? UICollectionView
    }
    private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
    private(set) var headerCell: ProfileHeaderCollectionViewCell?
    
    var reconfigureVisibleItemsOnEndDecelerating: Bool = false
    
    private(set) var state: State = .unloaded
    
    init(accountID: String?, kind: Kind, owner: ProfileViewController) {
        self.accountID = accountID
        self.kind = kind
        self.owner = owner
        self.mastodonController = owner.mastodonController
        self.filterer = Filterer(mastodonController: mastodonController, context: .account)
        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 Profile"))
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func loadView() {
        var config = UICollectionLayoutListConfiguration(appearance: .plain)
        config.backgroundColor = .appBackground
        config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
            (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
        }
        config.trailingSwipeActionsConfigurationProvider = { [unowned self] in
            (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
        }
        config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in
            guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
                return sectionSeparatorConfiguration
            }
            var config = sectionSeparatorConfiguration
            if item.hideSeparators {
                config.topSeparatorVisibility = .hidden
                config.bottomSeparatorVisibility = .hidden
            } else if case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item,
                      filterer.isKnownHide(state: filterState) {
                config.topSeparatorVisibility = .hidden
                config.bottomSeparatorVisibility = .hidden
            } else if case .status(_, _, _, _) = item {
                config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
                config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
            }
            return config
        }
        let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
            if case .header = dataSource.sectionIdentifier(for: sectionIndex) {
                var config = UICollectionLayoutListConfiguration(appearance: .plain)
                config.backgroundColor = .appBackground
                return .list(using: config, layoutEnvironment: environment)
            } else {
                let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
                section.readableContentInset(in: environment)
                return section
            }
        }
        view = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.delegate = self
        collectionView.dragDelegate = self
        collectionView.allowsFocus = true
        
        registerTimelineLikeCells()
        dataSource = createDataSource()
        
        #if !targetEnvironment(macCatalyst)
        collectionView.refreshControl = UIRefreshControl()
        collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
        #endif
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
                
        mastodonController.persistentContainer.accountSubject
            .receive(on: DispatchQueue.main)
            .filter { [unowned self] in $0 == self.accountID }
            .sink { [unowned self] id in
                switch state {
                case .unloaded:
                    Task { [self] in
                        await self.load()
                    }
                case .loaded, .setupInitialSnapshot:
                    var snapshot = self.dataSource.snapshot()
                    snapshot.reconfigureItems([.header(id)])
                    self.dataSource.apply(snapshot, animatingDifferences: true)
                }
            }
            .store(in: &cancellables)
        
        mastodonController.persistentContainer.relationshipSubject
            .receive(on: DispatchQueue.main)
            .filter { [unowned self] in $0 == self.accountID }
            .sink { [unowned self] id in
                switch state {
                case .unloaded:
                    break
                case .loaded, .setupInitialSnapshot:
                    var snapshot = self.dataSource.snapshot()
                    snapshot.reconfigureItems([.header(id)])
                    self.dataSource.apply(snapshot, animatingDifferences: true)
                }
            }
            .store(in: &cancellables)
        
        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> {
        collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell")
        let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Filterer.Result, NSAttributedString?, Bool)> { [unowned self] cell, indexPath, item in
            cell.delegate = self
            cell.showPinned = item.4
            cell.updateUI(statusID: item.0, state: item.1, filterResult: item.2, precomputedContent: item.3)
        }
        let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in
        }
        let noContentCell = UICollectionView.CellRegistration<ProfileNoContentCollectionViewCell, URL> { [unowned self] cell, _, item in
            cell.delegate = self
            cell.updateUI(accountURL: item)
        }
        return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
            switch itemIdentifier {
            case .header(let id):
                if let headerCell = self.headerCell {
                    headerCell.view?.updateUI(for: id)
                    return headerCell
                } else {
                    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "headerCell", for: indexPath) as! ProfileHeaderCollectionViewCell
                    switch self.initialHeaderMode {
                    case nil:
                        fatalError("missing initialHeaderMode")
                    case .createView:
                        let view = ProfileHeaderView.create()
                        view.delegate = self.profileHeaderDelegate
                        view.updateUI(for: id)
                        view.pagesSegmentedControl.setSelectedOption(self.owner!.currentPage, animated: false)
                        cell.addHeader(view)
                    case .useExistingView(let view):
                        view.updateUI(for: id)
                        cell.addHeader(view)
                    case .placeholder(height: let height):
                        _ = cell.addConstraint(height: height)
                    }
                    self.headerCell = cell
                    return cell
                }
            case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: let pinned):
                let (result, precomputedContent) = filterResult(state: filterState, statusID: id)
                switch result {
                case .allow, .warn(_):
                    return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, collapseState, result, precomputedContent, pinned))
                case .hide:
                    return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
                }
            case .loadingIndicator:
                return loadingIndicatorCell(for: indexPath)
            case .confirmLoadMore:
                return confirmLoadMoreCell(for: indexPath)
            case .noContent(let accountURL):
                return collectionView.dequeueConfiguredReusableCell(using: noContentCell, for: indexPath, item: accountURL)
            }
        }
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        clearSelectionOnAppear(animated: animated)
        
        Task {
            if case .notLoadedInitial = controller.state {
                await load()
            }
        }
    }
    
    func setAccountID(_ id: String) {
        self.accountID = id
        // TODO: maybe this function should be async?
        Task {
            await load()
        }
    }
    
    private func load() async {
        guard isViewLoaded,
              let accountID,
              state == .unloaded,
              let account = mastodonController.persistentContainer.account(for: accountID) else {
            return
        }
        
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.header, .pinned, .statuses])
        snapshot.appendItems([.header(accountID)], toSection: .header)
        await apply(snapshot, animatingDifferences: false)
        
        state = .setupInitialSnapshot
        
        Task {
            if let (all, _) = try? await mastodonController.run(Client.getRelationships(accounts: [accountID])),
               let relationship = all.first {
                self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
            }
        }
        
        await controller.loadInitial()
        await tryLoadPinned()
        
        var newSnapshot = dataSource.snapshot()
        if newSnapshot.numberOfItems(inSection: .pinned) == 0,
           newSnapshot.numberOfItems(inSection: .statuses) == 0,
           account.url.host != mastodonController.instanceURL.host {
            newSnapshot.appendItems([.noContent(accountURL: account.url)], toSection: .pinned)
            await apply(newSnapshot, animatingDifferences: true)
        }
        
        state = .loaded
        
        // remove any content inset that was added when switching pages to this VC
        collectionView.contentInset = .zero
    }
    
    private func reloadInitial() async {
        state = .unloaded
        await load()
    }
    
    private func tryLoadPinned() async {
        do {
            try await loadPinned()
        } catch {
            let config = ToastConfiguration(from: error, with: "Loading Pinned", in: self) { toast in
                toast.dismissToast(animated: true)
                await self.tryLoadPinned()
            }
            self.showToast(configuration: config, animated: true)
        }
    }
    
    private func loadPinned() async throws {
        guard case .statuses = kind,
              mastodonController.instanceFeatures.profilePinnedStatuses else {
            return
        }
        
        let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
        let (statuses, _) = try await mastodonController.run(request)
        
        await withCheckedContinuation { continuation in
            mastodonController.persistentContainer.addAll(statuses: statuses) {
                continuation.resume()
            }
        }
        
        var snapshot = dataSource.snapshot()
        let existingPinned = snapshot.itemIdentifiers(inSection: .pinned)
        let items = statuses.map {
            let item = Item.status(id: $0.id, collapseState: .unknown, filterState: .unknown, pinned: true)
            // try to keep the existing status state
            if let existing = existingPinned.first(where: { $0 == item }) {
                return existing
            } else {
                return item
            }
        }
        snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .pinned))
        snapshot.appendItems(items, toSection: .pinned)
        await apply(snapshot, animatingDifferences: true)
    }
    
    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 .status(_, _, _, _) = $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 func refresh() {
        guard case .loaded = state else {
            #if !targetEnvironment(macCatalyst)
            collectionView.refreshControl?.endRefreshing()
            #endif
            return
        }
        Task {
            if newer == nil {
                // no statuses were loaded initially, so reload the initial batch
                await reloadInitial()
            } else {
                // TODO: coalesce these data source updates
                // TODO: refresh profile
                await controller.loadNewer()
                await tryLoadPinned()
            }
            #if !targetEnvironment(macCatalyst)
            collectionView.refreshControl?.endRefreshing()
            #endif
        }
    }
    
    @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
        guard let userInfo = notification.userInfo,
              let statusIDs = userInfo["statusIDs"] as? [String] else {
            return
        }
        var snapshot = self.dataSource.snapshot()
        let toDelete = statusIDs
            .flatMap { id in
                // need to delete from both pinned and non-pinned sections
                [
                    Item.status(id: id, collapseState: .unknown, filterState: .unknown, pinned: false),
                    Item.status(id: id, collapseState: .unknown, filterState: .unknown, pinned: true),
                ]
            }
            .filter { item in
                snapshot.itemIdentifiers.contains(item)
            }
        if !toDelete.isEmpty {
            snapshot.deleteItems(toDelete)
            self.dataSource.apply(snapshot, animatingDifferences: true)
        }
    }
    
}

extension ProfileStatusesViewController {
    enum State {
        case unloaded
        case setupInitialSnapshot
        case loaded
    }
}

extension ProfileStatusesViewController {
    enum Kind {
        case statuses, withReplies, onlyMedia
    }
    enum HeaderMode {
        case createView, useExistingView(ProfileHeaderView), placeholder(height: CGFloat)
    }
}

extension ProfileStatusesViewController {
    enum Section: TimelineLikeCollectionViewSection {
        case header
        case pinned
        case statuses
        case footer
        
        static var entries: Self { .statuses }
    }
    enum Item: TimelineLikeCollectionViewItem {
        typealias TimelineItem = String
        
        case header(String)
        // the status item must contain the pinned state, since a status can appear in both the pinned and regular sections simultaneously
        case status(id: String, collapseState: CollapseState, filterState: FilterState, pinned: Bool)
        case loadingIndicator
        case confirmLoadMore
        case noContent(accountURL: URL)
        
        static func fromTimelineItem(_ item: String) -> Self {
            return .status(id: item, collapseState: .unknown, filterState: .unknown, pinned: false)
        }
        
        static func ==(lhs: Item, rhs: Item) -> Bool {
            switch (lhs, rhs) {
            case let (.header(a), .header(b)):
                return a == b
            case let (.status(id: a, _, _, pinned: ap), .status(id: b, _, _, pinned: bp)):
                return a == b && ap == bp
            case (.loadingIndicator, .loadingIndicator):
                return true
            case (.confirmLoadMore, .confirmLoadMore):
                return true
            case (.noContent(let a), .noContent(let b)):
                return a == b
            default:
                return false
            }
        }
        
        func hash(into hasher: inout Hasher) {
            switch self {
            case .header(let id):
                hasher.combine(0)
                hasher.combine(id)
            case .status(id: let id, _, _, pinned: let pinned):
                hasher.combine(1)
                hasher.combine(id)
                hasher.combine(pinned)
            case .loadingIndicator:
                hasher.combine(2)
            case .confirmLoadMore:
                hasher.combine(3)
            case .noContent(let accountURL):
                hasher.combine(4)
                hasher.combine(accountURL)
            }
        }
        
        var hideSeparators: Bool {
            switch self {
            case .loadingIndicator, .confirmLoadMore, .noContent(_):
                return true
            default:
                return false
            }
        }
        
        var isSelectable: Bool {
            switch self {
            case .status(_, _, _, _):
                return true
            default:
                return false
            }
        }
    }
}

extension ProfileStatusesViewController: TimelineLikeControllerDelegate {
    typealias TimelineItem = String // status ID
    
    private func request(for range: RequestRange = .default) -> Request<[Status]> {
        switch kind {
        case .statuses:
            return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
        case .withReplies:
            return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false)
        case .onlyMedia:
            return Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false)
        }
    }
    
    func loadInitial() async throws -> [String] {
        let request = request()
        let (statuses, _) = try await mastodonController.run(request)
        
        if !statuses.isEmpty {
            newer = .after(id: statuses.first!.id, count: nil)
            older = .before(id: statuses.last!.id, count: nil)
        }
        
        return await withCheckedContinuation { continuation in
            mastodonController.persistentContainer.addAll(statuses: statuses) {
                continuation.resume(returning: statuses.map(\.id))
            }
        }
    }
    
    func loadNewer() async throws -> [String] {
        guard let newer else {
            throw Error.noNewer
        }
        
        let request = request(for: newer)
        let (statuses, _) = try await mastodonController.run(request)
        
        guard !statuses.isEmpty else {
            throw Error.allCaughtUp
        }
        
        self.newer = .after(id: statuses.first!.id, count: nil)
        
        return await withCheckedContinuation { continuation in
            mastodonController.persistentContainer.addAll(statuses: statuses) {
                continuation.resume(returning: statuses.map(\.id))
            }
        }
    }
    
    func loadOlder() async throws -> [String] {
        guard let older else {
            throw Error.noOlder
        }
        
        let request = request(for: older)
        let (statuses, _) = try await mastodonController.run(request)
        
        guard !statuses.isEmpty else {
            return []
        }
        
        self.older = .before(id: statuses.last!.id, count: nil)
        
        return await withCheckedContinuation { continuation in
            mastodonController.persistentContainer.addAll(statuses: statuses) {
                continuation.resume(returning: statuses.map(\.id))
            }
        }
    }
    
    enum Error: TimelineLikeCollectionViewError {
        case noNewer
        case noOlder
        case allCaughtUp
    }
}

extension ProfileStatusesViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) else {
            return
        }
        
        let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
        if indexPath.row == itemsInSection - 1 {
            Task {
                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 .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: _) = item else {
            return
        }
        if filterState.isWarning {
            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 status = mastodonController.persistentContainer.status(for: id)!
            selected(status: status.reblog?.id ?? id, state: collapseState.copy())
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
        return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
    }
    
    func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
        MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
    }
    
    func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool {
        switch dataSource.itemIdentifier(for: indexPath) {
        case .header(_), .loadingIndicator:
            return false
        default:
            return true
        }
    }
    
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        if reconfigureVisibleItemsOnEndDecelerating {
            reconfigureVisibleItemsOnEndDecelerating = false
            reconfigureVisibleCells()
        }
    }
}

extension ProfileStatusesViewController: UICollectionViewDragDelegate {
    func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? []
    }
}

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

extension ProfileStatusesViewController: MenuActionProvider {
}

extension ProfileStatusesViewController: 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 .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item {
            filterer.setResult(.allow, for: filterState)
            var snapshot = dataSource.snapshot()
            snapshot.reconfigureItems([item])
            dataSource.apply(snapshot, animatingDifferences: true)
        }
    }
}

extension ProfileStatusesViewController: TabBarScrollableViewController {
    func tabBarScrollToTop() {
        collectionView.scrollToTop()
    }
}

extension ProfileStatusesViewController: StatusBarTappableViewController {
    func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
        collectionView.scrollToTop()
        return .stop
    }
}
