//
//  TimelineLikeController.swift
//  Tusker
//
//  Created by Shadowfacts on 9/19/22.
//  Copyright © 2022 Shadowfacts. All rights reserved.
//

import Foundation
import OSLog
import Combine

protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
    associatedtype TimelineItem: Sendable
    
    func loadInitial() async throws -> [TimelineItem]
    
    func loadNewer() async throws -> [TimelineItem]
    
    func canLoadOlder() async -> Bool
    
    func loadOlder() async throws -> [TimelineItem]
    
    func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem]
    
    func handleAddLoadingIndicator() async
    func handleRemoveLoadingIndicator() async
    func handleLoadAllError(_ error: Swift.Error) async
    func handleReplaceAllItems(_ timelineItems: [TimelineItem]) async
    func handleLoadNewerError(_ error: Swift.Error) async
    func handlePrependItems(_ timelineItems: [TimelineItem]) async
    func handleLoadOlderError(_ error: Swift.Error) async
    func handleAppendItems(_ timelineItems: [TimelineItem]) async
    func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async
    func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async
}

private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")

@MainActor
class TimelineLikeController<Item: Sendable> {
    
    private unowned var delegate: any TimelineLikeControllerDelegate<Item>
    private let ownerType: String
    
    @AsyncObservable private(set) var state = State.notLoadedInitial {
        willSet {
            guard state.canTransition(to: newValue) else {
                logger.error("\(self.ownerType, privacy: .public) State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)")
                fatalError("State \(state) cannot transition to \(newValue)")
            }
            logger.debug("\(self.ownerType, privacy: .public) State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)")
        }
    }
    
    init(delegate: any TimelineLikeControllerDelegate<Item>, ownerType: String) {
        self.delegate = delegate
        self.ownerType = ownerType
    }
    
    /// Waits for the controller to finish the current operation and arrive at the idle state.
    ///
    /// If the current state is `notLoadedInitial`, this will wait until the controller
    /// settles after the initial load.
    func finishPendingOperation() async {
        guard state != .idle else {
            return
        }
        for await state in $state where state == .idle {
            break
        }
    }
    
    func loadInitial() async {
        guard state == .notLoadedInitial || state == .idle else {
            return
        }
        let token = LoadAttemptToken()
        state = .loadingInitial(token, hasAddedLoadingIndicator: false)
        await emit(event: .addLoadingIndicator)
        state = .loadingInitial(token, hasAddedLoadingIndicator: true)
        do {
            let items = try await delegate.loadInitial()
            guard case .loadingInitial(token, _) = state else {
                return
            }
            await emit(event: .replaceAllItems(items, token))
            await emit(event: .removeLoadingIndicator)
            state = .idle
        } catch is CancellationError {
            return
        } catch {
            await emit(event: .removeLoadingIndicator)
            await emit(event: .loadAllError(error, token))
            state = .notLoadedInitial
        }
    }
    
    /// Used to indicate to the controller that the initial set of posts have been restored externally.
    func restoreInitial(doRestore: () async -> Void) async {
        guard state == .notLoadedInitial || state == .idle else {
            return
        }
        let token = LoadAttemptToken()
        state = .restoringInitial(token, hasAddedLoadingIndicator: false)
        await emit(event: .addLoadingIndicator)
        state = .restoringInitial(token, hasAddedLoadingIndicator: true)
        await doRestore()
        await emit(event: .removeLoadingIndicator)
        state = .idle
    }
    
    func loadNewer() async {
        guard state == .idle else {
            return
        }
        let token = LoadAttemptToken()
        state = .loadingNewer(token)
        do {
            let items = try await delegate.loadNewer()
            guard case .loadingNewer(token) = state else {
                return
            }
            await emit(event: .prependItems(items, token))
            state = .idle
        } catch is CancellationError {
            return
        } catch {
            await emit(event: .loadNewerError(error, token))
            state = .idle
        }
    }
    
    func loadOlder() async {
        guard state == .idle else {
            return
        }
        let token = LoadAttemptToken()
        guard await delegate.canLoadOlder(),
              // Make sure we're still in the idle state before continuing on, since that may have chnaged while waiting for user input.
              // If the load more cell appears, then the users scrolls up and back down, the VC may kick off a second loadOlder task
              // but we only want one to proceed. The actor prevents a data race, and this prevents multiple simultaneousl loadOlder tasks from running.
              state == .idle else {
            return
        }
        state = .loadingOlder(token, hasAddedLoadingIndicator: false)
        await emit(event: .addLoadingIndicator)
        state = .loadingOlder(token, hasAddedLoadingIndicator: true)
        do {
            let items = try await delegate.loadOlder()
            guard case .loadingOlder(token, _) = state else {
                return
            }
            await emit(event: .appendItems(items, token))
            await emit(event: .removeLoadingIndicator)
            state = .idle
        } catch is CancellationError {
            return
        } catch {
            await emit(event: .removeLoadingIndicator)
            await emit(event: .loadOlderError(error, token))
            state = .idle
        }
    }
    
    func fillGap(in direction: TimelineGapDirection) async {
        guard state == .idle else {
            return
        }
        let token = LoadAttemptToken()
        state = .loadingGap(token, direction)
        do {
            let items = try await delegate.loadGap(in: direction)
            guard case .loadingGap(token, direction) = state else {
                return
            }
            await emit(event: .fillGap(items, direction, token))
            state = .idle
        } catch is CancellationError {
            return
        } catch {
            await emit(event: .loadGapError(error, direction, token))
            state = .idle
        }
    }
    
    private func transition(to newState: State) {
        self.state = newState
    }
    
    private func emit(event: Event) async {
        guard state.canEmit(event: event) else {
            logger.error("\(self.ownerType, privacy: .public) State \(self.state.debugDescription, privacy: .public) cannot emit event: \(event.debugDescription, privacy: .public)")
            fatalError("State \(state) cannot emit event: \(event)")
        }
        switch event {
        case .addLoadingIndicator:
            await delegate.handleAddLoadingIndicator()
        case .removeLoadingIndicator:
            await delegate.handleRemoveLoadingIndicator()
        case .loadAllError(let error, _):
            await delegate.handleLoadAllError(error)
        case .replaceAllItems(let items, _):
            await delegate.handleReplaceAllItems(items)
        case .loadNewerError(let error, _):
            await delegate.handleLoadNewerError(error)
        case .prependItems(let items, _):
            await delegate.handlePrependItems(items)
        case .loadOlderError(let error, _):
            await delegate.handleLoadOlderError(error)
        case .appendItems(let items, _):
            await delegate.handleAppendItems(items)
        case .loadGapError(let error, let direction, _):
            await delegate.handleLoadGapError(error, direction: direction)
        case .fillGap(let items, let direction, _):
            await delegate.handleFillGap(items, direction: direction)
        }
    }
    
    enum State: Equatable, CustomDebugStringConvertible {
        case notLoadedInitial
        case idle
        case restoringInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
        case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
        case loadingNewer(LoadAttemptToken)
        case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
        case loadingGap(LoadAttemptToken, TimelineGapDirection)
        
        var debugDescription: String {
            switch self {
            case .notLoadedInitial:
                return "notLoadedInitial"
            case .idle:
                return "idle"
            case .restoringInitial(let token, let hasAddedLoadingIndicator):
                return "restoringInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
            case .loadingInitial(let token, let hasAddedLoadingIndicator):
                return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
            case .loadingNewer(let token):
                return "loadingNewer(\(ObjectIdentifier(token)))"
            case .loadingOlder(let token, let hasAddedLoadingIndicator):
                return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
            case .loadingGap(let token, let direction):
                return "loadingGap(\(ObjectIdentifier(token)), \(direction))"
            }
        }
        
        func canTransition(to: State) -> Bool {
            switch self {
            case .notLoadedInitial:
                switch to {
                case .restoringInitial, .loadingInitial(_, _):
                    return true
                default:
                    return false
                }
            case .idle:
                switch to {
                case .restoringInitial(_, _), .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
                    return true
                default:
                    return false
                }
            case .restoringInitial(let token, let hasAddedLoadingIndicator):
                return to == .idle || (!hasAddedLoadingIndicator && to == .restoringInitial(token, hasAddedLoadingIndicator: true))
            case .loadingInitial(let token, let hasAddedLoadingIndicator):
                return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
            case .loadingNewer(_):
                return to == .idle
            case .loadingOlder(let token, let hasAddedLoadingIndicator):
                return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
            case .loadingGap(_, _):
                return to == .idle
            }
        }
        
        func canEmit(event: Event) -> Bool {
            switch event {
            case .addLoadingIndicator:
                switch self {
                case .restoringInitial(_, _), .loadingInitial(_, _), .loadingOlder(_, _):
                    return true
                default:
                    return false
                }
            case .removeLoadingIndicator:
                switch self {
                case .restoringInitial(_, hasAddedLoadingIndicator: true), .loadingInitial(_, hasAddedLoadingIndicator: true), .loadingOlder(_, hasAddedLoadingIndicator: true):
                    return true
                default:
                    return false
                }
            case .loadAllError(_, let token), .replaceAllItems(_, let token):
                switch self {
                case .loadingInitial(token, _):
                    return true
                default:
                    return false
                }
            case .loadNewerError(_, let token), .prependItems(_, let token):
                switch self {
                case .loadingNewer(token):
                    return true
                default:
                    return false
                }
            case .loadOlderError(_, let token), .appendItems(_, let token):
                switch self {
                case .loadingOlder(token, _):
                    return true
                default:
                    return false
                }
            case .loadGapError(_, let direction, let token), .fillGap(_, let direction, let token):
                switch self {
                case .loadingGap(token, direction):
                    return true
                default:
                    return false
                }
            }
        }
    }
    
    enum Event: CustomDebugStringConvertible {
        case addLoadingIndicator
        case removeLoadingIndicator
        case loadAllError(Error, LoadAttemptToken)
        case replaceAllItems([Item], LoadAttemptToken)
        case loadNewerError(Error, LoadAttemptToken)
        case prependItems([Item], LoadAttemptToken)
        case loadOlderError(Error, LoadAttemptToken)
        case appendItems([Item], LoadAttemptToken)
        case loadGapError(Error, TimelineGapDirection, LoadAttemptToken)
        case fillGap([Item], TimelineGapDirection, LoadAttemptToken)
        
        var debugDescription: String {
            switch self {
            case .addLoadingIndicator:
                return "addLoadingIndicator"
            case .removeLoadingIndicator:
                return "removeLoadingIndicator"
            case .loadAllError(let error, let token):
                return "loadAllError(\(error), \(token))"
            case .replaceAllItems(_, let token):
                return "replaceAllItems(<omitted>, \(token))"
            case .loadNewerError(let error, let token):
                return "loadNewerError(\(error), \(token))"
            case .prependItems(_, let token):
                return "prependItems(<omitted>, \(token))"
            case .loadOlderError(let error, let token):
                return "loadOlderError(\(error), \(token))"
            case .appendItems(_, let token):
                return "appendItems(<omitted>, \(token))"
            case .loadGapError(let error, let direction, let token):
                return "loadGapError(\(error), \(direction), \(token))"
            case .fillGap(_, let direction, let token):
                return "loadGapError(<omitted>, \(direction), \(token))"
            }
        }
    }
    
    class LoadAttemptToken: Equatable {
        static func ==(lhs: LoadAttemptToken, rhs: LoadAttemptToken) -> Bool {
            return lhs === rhs
        }
    }
    
}

enum TimelineGapDirection {
    /// Fill in below the gap. I.e., statuses that are immediately newer than the status below the gap.
    case below
    /// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap.
    case above
    
    var accessibilityLabel: String {
        switch self {
        case .below:
            return "Newer"
        case .above:
            return "Older"
        }
    }
}

// I would love to be able to do this with @Observable, but it's not clear how to do so.
@propertyWrapper
private class AsyncObservable<Value>: ObservableObject {
    @Published var wrappedValue: Value
    
    var projectedValue: AsyncPublisher<Published<Value>.Publisher> {
        $wrappedValue.values
    }
    
    init(wrappedValue: Value) {
        self.wrappedValue = wrappedValue
    }
}
