//
//  MastodonController.swift
//  Tusker
//
//  Created by Shadowfacts on 8/15/18.
//  Copyright © 2018 Shadowfacts. All rights reserved.
//

import Foundation
import Pachyderm
import Combine
import UserAccounts
import InstanceFeatures
import Sentry
import ComposeUI

private let oauthScopes = [Scope.read, .write, .follow]

class MastodonController: ObservableObject {

    static private(set) var all = [UserAccountInfo: MastodonController]()
    
    @available(*, message: "do something less dumb")
    static var first: MastodonController { all.first!.value }
    
    @MainActor
    static func getForAccount(_ account: UserAccountInfo) -> MastodonController {
        if let controller = all[account] {
            return controller
        } else {
            let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account)
            all[account] = controller
            return controller
        }
    }
    
    static func removeForAccount(_ account: UserAccountInfo) {
        all.removeValue(forKey: account)
    }
    
    static func resetAll() {
        all = [:]
    }
    
    private let transient: Bool
    nonisolated let persistentContainer: MastodonCachePersistentStore// = MastodonCachePersistentStore(for: accountInfo, transient: transient)

    let instanceURL: URL
    var accountInfo: UserAccountInfo?
    var accountPreferences: AccountPreferences!
    
    let client: Client!
    let instanceFeatures = InstanceFeatures()
    
    @Published private(set) var account: AccountMO?
    @Published private(set) var instance: Instance?
    @Published private(set) var instanceInfo: InstanceInfo!
    @Published private(set) var nodeInfo: NodeInfo!
    @Published private(set) var lists: [List] = []
    @Published private(set) var customEmojis: [Emoji]?
    @Published private(set) var followedHashtags: [FollowedHashtag] = []
    @Published private(set) var filters: [FilterMO] = []
    
    private var cancellables = Set<AnyCancellable>()
    
    private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
    private var ownInstanceRequest: URLSessionTask?
    
    var loggedIn: Bool {
        accountInfo != nil
    }

    // main-actor b/c fetchActiveAccountID and fetchActiveInstance use the viewContext
    @MainActor
    init(instanceURL: URL, accountInfo: UserAccountInfo?) {
        self.instanceURL = instanceURL
        self.accountInfo = accountInfo
        self.client = Client(baseURL: instanceURL, session: .appDefault)
        self.transient = accountInfo == nil
        self.persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
        
        self.client.clientID = accountInfo?.clientID
        self.client.clientSecret = accountInfo?.clientSecret
        self.client.accessToken = accountInfo?.accessToken
        
        if !transient {
            fetchActiveAccount()
            fetchActiveInstance()
        }
        
        $instanceInfo
            .compactMap { $0 }
            .combineLatest($nodeInfo)
            .sink { [unowned self] (instance, nodeInfo) in
                self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
            }
            .store(in: &cancellables)
        
        $instanceInfo
            .compactMap { $0 }
            .removeDuplicates(by: { $0.version == $1.version })
            .combineLatest($nodeInfo.removeDuplicates())
            .sink { (instance, nodeInfo) in
                setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo)
            }
            .store(in: &cancellables)
        
        $instance
            .compactMap { $0 }
            .sink { [unowned self] in
                self.updateActiveInstance(from: $0)
                self.instanceInfo = InstanceInfo(instance: $0)
            }
            .store(in: &cancellables)
        
        instanceFeatures.featuresUpdated
            .filter { [unowned self] _ in self.instanceFeatures.canFollowHashtags && self.followedHashtags.isEmpty }
            .sink { [unowned self] _ in
                Task {
                    await self.loadFollowedHashtags()
                }
            }
            .store(in: &cancellables)
    }
    
    @MainActor
    convenience init(instanceURL: URL, transient: Bool) {
        precondition(transient, "account info must be provided if transient is false")
        self.init(instanceURL: instanceURL, accountInfo: nil)
    }
    
    @discardableResult
    func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) -> URLSessionTask? {
        return client.run(request, completion: completion)
    }
    
    func runResponse<Result>(_ request: Request<Result>) async -> Response<Result> {
        let response = await withCheckedContinuation({ continuation in
            client.run(request) { response in
                continuation.resume(returning: response)
            }
        })
        return response
    }
    
    func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
        let response = await runResponse(request)
        try Task.checkCancellation()
        switch response {
        case .failure(let error):
            throw error
        case .success(let result, let pagination):
            return (result, pagination)
        }
    }
    
    /// - Returns: A tuple of client ID and client secret.
    func registerApp() async throws -> (String, String) {
        if let clientID = client.clientID,
           let clientSecret = client.clientSecret {
            return (clientID, clientSecret)
        } else {
            let app: RegisteredApplication = try await withCheckedThrowingContinuation({ continuation in
                client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: oauthScopes) { response in
                    switch response {
                    case .failure(let error):
                        continuation.resume(throwing: error)
                    case .success(let app, _):
                        continuation.resume(returning: app)
                    }
                }
            })
            self.client.clientID = app.clientID
            self.client.clientSecret = app.clientSecret
            return (app.clientID, app.clientSecret)
        }
    }
    
    /// - Returns: The access token
    func authorize(authorizationCode: String) async throws -> String {
        return try await withCheckedThrowingContinuation({ continuation in
            client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth", scopes: oauthScopes) { response in
                switch response {
                case .failure(let error):
                    continuation.resume(throwing: error)
                case .success(let settings, _):
                    self.client.accessToken = settings.accessToken
                    continuation.resume(returning: settings.accessToken)
                }
            }
        })
    }
    
    @MainActor
    func initialize() {
        precondition(!transient, "Cannot initialize transient MastodonController")
        
        // we want this to happen immediately, and synchronously so that the filters (which don't change that often)
        // are available when Filterers are constructed
        loadCachedFilters()
        
        loadAccountPreferences()
        
        lists = loadCachedLists()
        
        NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator)
            .receive(on: DispatchQueue.main)
            .sink { [unowned self] _ in
                self.loadAccountPreferences()
            }
            .store(in: &cancellables)
        
        Task {
            do {
                async let ownAccount = try getOwnAccount()
                async let ownInstance = try getOwnInstance()
                
                _ = try await (ownAccount, ownInstance)
                
                loadLists()
                _ = await loadFilters()
                await loadServerPreferences()
            } catch {
                Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
            }
        }
    }
    
    @MainActor
    private func loadAccountPreferences() {
        if let existing = try? persistentContainer.viewContext.fetch(AccountPreferences.fetchRequest(account: accountInfo!)).first {
            accountPreferences = existing
        } else {
            accountPreferences = AccountPreferences.default(account: accountInfo!, context: persistentContainer.viewContext)
            persistentContainer.save(context: persistentContainer.viewContext)
        }
    }
    
    func getOwnAccount(completion: ((Result<AccountMO, Client.Error>) -> Void)? = nil) {
        if let account {
            completion?(.success(account))
        } else {
            let request = Client.getSelfAccount()
            run(request) { response in
                switch response {
                case let .failure(error):
                    completion?(.failure(error))
                    
                case let .success(account, _):
                    let context = self.persistentContainer.viewContext
                    context.perform {
                        let accountMO: AccountMO
                        if let existing = self.persistentContainer.account(for: account.id, in: context) {
                            accountMO = existing
                            existing.updateFrom(apiAccount: account, container: self.persistentContainer)
                        } else {
                            accountMO = self.persistentContainer.addOrUpdateSynchronously(account: account, in: context)
                        }
                        accountMO.active = true
                        self.account = accountMO
                        completion?(.success(accountMO))
                    }
                }
            }
        }
    }
    
    func getOwnAccount() async throws -> AccountMO {
        if let account = account {
            return account
        } else {
            return try await withCheckedThrowingContinuation({ continuation in
                self.getOwnAccount { result in
                    continuation.resume(with: result)
                }
            })
        }
    }
    
    func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
        getOwnInstanceInternal(retryAttempt: 0) {
            if case let .success(instance) = $0 {
                completion?(instance)
            }
        }
    }
    
    @MainActor
    func getOwnInstance() async throws -> Instance {
        return try await withCheckedThrowingContinuation({ continuation in
            getOwnInstanceInternal(retryAttempt: 0) { result in
                continuation.resume(with: result)
            }
        })
    }
    
    private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<Instance, Client.Error>) -> Void)?) {
        // this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
        assert(Thread.isMainThread)
        
        if let instance = self.instance {
            completion?(.success(instance))
        } else {
            if let completion = completion {
                pendingOwnInstanceRequestCallbacks.append(completion)
            }
          
            if ownInstanceRequest == nil {
                let request = Client.getInstance()
                ownInstanceRequest = run(request) { (response) in
                    switch response {
                    case .failure(let error):
                        let delay: DispatchTimeInterval
                        switch retryAttempt {
                        case 0:
                            delay = .seconds(1)
                        case 1:
                            delay = .seconds(5)
                        case 2:
                            delay = .seconds(30)
                        case 3:
                            delay = .seconds(60)
                        default:
                            // if we've failed four times, just give up :/
                            for completion in self.pendingOwnInstanceRequestCallbacks {
                                completion(.failure(error))
                            }
                            self.pendingOwnInstanceRequestCallbacks = []
                            return
                        }
                        DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
                            // completion is nil because in this invocation of getOwnInstanceInternal we've already added it to the pending callbacks array
                            self.getOwnInstanceInternal(retryAttempt: retryAttempt + 1, completion: nil)
                        }

                    case let .success(instance, _):
                        DispatchQueue.main.async {
                            self.ownInstanceRequest = nil
                            self.instance = instance
                            
                            for completion in self.pendingOwnInstanceRequestCallbacks {
                                completion(.success(instance))
                            }
                            self.pendingOwnInstanceRequestCallbacks = []
                        }
                    }
                }
                
                client.nodeInfo { result in
                    switch result {
                    case let .failure(error):
                        print("Unable to get node info: \(error)")
                        
                    case let .success(nodeInfo, _):
                        DispatchQueue.main.async {
                            self.nodeInfo = nodeInfo
                        }
                    }
                }
            }
        }
    }
    
    // MainActor because the accountPreferences instance is bound to the view context
    @MainActor
    private func loadServerPreferences() async {
        guard instanceFeatures.hasServerPreferences,
            let (prefs, _) = try? await run(Client.getPreferences()) else {
            return
        }
        accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage
        accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility
        accountPreferences!.serverDefaultFederation = prefs.postingDefaultFederation ?? true
    }
    
    private func updateActiveInstance(from instance: Instance) {
        persistentContainer.performBackgroundTask { context in
            if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first {
                existing.update(from: instance)
            } else {
                let new = ActiveInstance(context: context)
                new.update(from: instance)
            }
            if context.hasChanges {
                try? context.save()
            }
        }
    }
    
    @MainActor
    private func fetchActiveAccount() {
        let req = AccountMO.fetchRequest()
        req.predicate = NSPredicate(format: "active = YES")
        if let activeAccount = try? persistentContainer.viewContext.fetch(req).first {
            account = activeAccount
        }
    }
    
    @MainActor
    private func fetchActiveInstance() {
        if let activeInstance = try? persistentContainer.viewContext.fetch(ActiveInstance.fetchRequest()).first {
            let info = InstanceInfo(activeInstance: activeInstance)
            self.instanceInfo = info
        }
    }
    
    func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) {
        if let emojis = self.customEmojis {
            completion(emojis)
        } else {
            let request = Client.getCustomEmoji()
            run(request) { (response) in
                if case let .success(emojis, _) = response {
                    DispatchQueue.main.async {
                        self.customEmojis = emojis
                    }
                    completion(emojis)
                } else {
                    completion([])
                }
            }
        }
    }
    
    private func loadLists() {
        let req = Client.getLists()
        run(req) { response in
            if case .success(let lists, _) = response {
                DispatchQueue.main.async {
                    self.lists = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
                }
                let context = self.persistentContainer.backgroundContext
                context.perform {
                    for list in lists {
                        if let existing = try? context.fetch(ListMO.fetchRequest(id: list.id)).first {
                            existing.updateFrom(apiList: list)
                        } else {
                            _ = ListMO(apiList: list, context: context)
                        }
                    }
                    self.persistentContainer.save(context: context)
                }
            }
        }
    }
    
    private func loadCachedLists() -> [List] {
        let req = ListMO.fetchRequest()
        guard let lists = try? persistentContainer.viewContext.fetch(req) else {
            return []
        }
        return lists.map(\.apiList).sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
    }
    
    func getCachedList(id: String) -> List? {
        let req = ListMO.fetchRequest(id: id)
        return (try? persistentContainer.viewContext.fetch(req).first).map(\.apiList)
    }
    
    @MainActor
    func addedList(_ list: List) {
        var new = self.lists
        new.append(list)
        new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
        self.lists = new
    }
    
    @MainActor
    func deletedList(_ list: List) {
        self.lists.removeAll(where: { $0.id == list.id })
    }
    
    @MainActor
    func updatedList(_ list: List) {
        var new = self.lists
        if let index = new.firstIndex(where: { $0.id == list.id }) {
            new[index] = list
        }
        new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
        self.lists = new
    }
    
    @MainActor
    private func loadFollowedHashtags() async {
        updateFollowedHashtags()
        
        let req = Client.getFollowedHashtags()
        if let (hashtags, _) = try? await run(req) {
            self.persistentContainer.updateFollowedHashtags(hashtags) {
                if case .success(let hashtags) = $0 {
                    self.followedHashtags = hashtags
                }
            }
        }
    }
    
    @MainActor
    func updateFollowedHashtags() {
        followedHashtags = (try? persistentContainer.viewContext.fetch(FollowedHashtag.fetchRequest())) ?? []
    }
    
    @MainActor
    func loadFilters() async {
        var apiFilters: [AnyFilter]?
        if instanceFeatures.filtersV2 {
            let req = Client.getFiltersV2()
            if let (filters, _) = try? await run(req) {
                apiFilters = filters.map { .v2($0) }
            }
        } else {
            let req = Client.getFiltersV1()
            if let (filters, _) = try? await run(req) {
                apiFilters = filters.map { .v1($0) }
            }
        }
        if let apiFilters {
            self.persistentContainer.updateFilters(apiFilters) {
                if case .success(let filters) = $0 {
                    self.filters = filters
                }
            }
        }
    }
    
    @MainActor
    private func loadCachedFilters() {
        filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? []
    }
    
    func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft {
        var acctsToMention = [String]()
        
        var visibility = if inReplyToID != nil {
            Preferences.shared.defaultReplyVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility)
        } else {
            Preferences.shared.defaultPostVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility)
        }
        var localOnly = instanceFeatures.localOnlyPosts && !accountPreferences!.serverDefaultFederation
        var contentWarning = ""
        
        if let inReplyToID = inReplyToID,
           let inReplyTo = persistentContainer.status(for: inReplyToID) {
            acctsToMention.append(inReplyTo.account.acct)
            acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
            visibility = min(visibility, inReplyTo.visibility)
            localOnly = instanceFeatures.localOnlyPosts && inReplyTo.localOnly
            
            if !inReplyTo.spoilerText.isEmpty {
                switch Preferences.shared.contentWarningCopyMode {
                case .doNotCopy:
                    break
                case .asIs:
                    contentWarning = inReplyTo.spoilerText
                case .prependRe:
                    if inReplyTo.spoilerText.lowercased().starts(with: "re:") {
                        contentWarning = inReplyTo.spoilerText
                    } else {
                        contentWarning = "re: \(inReplyTo.spoilerText)"
                    }
                }
            }
        }
        if let mentioningAcct = mentioningAcct {
            acctsToMention.append(mentioningAcct)
        }
        if let ownAccount = self.account {
            acctsToMention.removeAll(where: { $0 == ownAccount.acct })
        }
        acctsToMention = acctsToMention.uniques()
        
        return DraftsPersistentContainer.shared.createDraft(
            accountID: accountInfo!.id,
            text: text ?? acctsToMention.map { "@\($0) " }.joined(),
            contentWarning: contentWarning,
            inReplyToID: inReplyToID,
            visibility: visibility,
            language: accountPreferences!.serverDefaultLanguage,
            localOnly: localOnly
        )
    }
    
    func createDraft(editing status: StatusMO, source: StatusSource) -> Draft {
        precondition(status.id == source.id)
        let draft = DraftsPersistentContainer.shared.createEditDraft(
            accountID: accountInfo!.id,
            source: source,
            inReplyToID: status.inReplyToID,
            visibility: status.visibility,
            localOnly: status.localOnly,
            attachments: status.attachments,
            poll: status.poll
        )
        return draft
    }
    
}

private func setInstanceBreadcrumb(instance: InstanceInfo, nodeInfo: NodeInfo?) {
    let crumb = Breadcrumb(level: .info, category: "MastodonController")
    crumb.data = [
        "instance": [
            "version": instance.version
        ],
    ]
    if let nodeInfo {
        crumb.data!["nodeInfo"] = [
            "software": nodeInfo.software.name,
            "version": nodeInfo.software.version,
        ]
    }
    SentrySDK.addBreadcrumb(crumb)
}
