// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.

import Foundation
import GRDB
import SessionSnodeKit
import SessionUtil
import SessionUtilitiesKit

// MARK: - SessionUtil

public enum SessionUtil {
    public struct ConfResult {
        let needsPush: Bool
        let needsDump: Bool
    }
    
    public struct IncomingConfResult {
        let needsPush: Bool
        let needsDump: Bool
        let messageHashes: [String]
        let latestSentTimestamp: TimeInterval
        
        var result: ConfResult { ConfResult(needsPush: needsPush, needsDump: needsDump) }
    }
    
    public struct OutgoingConfResult {
        let message: SharedConfigMessage
        let namespace: SnodeAPI.Namespace
        let obsoleteHashes: [String]
    }
    
    // MARK: - Configs
    
    fileprivate static var configStore: Atomic<[ConfigKey: Atomic<UnsafeMutablePointer<config_object>?>]> = Atomic([:])
    
    public static func config(for variant: ConfigDump.Variant, publicKey: String) -> Atomic<UnsafeMutablePointer<config_object>?> {
        let key: ConfigKey = ConfigKey(variant: variant, publicKey: publicKey)
        
        return (
            SessionUtil.configStore.wrappedValue[key] ??
            Atomic(nil)
        )
    }
    
    // MARK: - Variables
    
    internal static func syncDedupeId(_ publicKey: String) -> String {
        return "EnqueueConfigurationSyncJob-\(publicKey)"   // stringlint:disable
    }
    
    /// Returns `true` if there is a config which needs to be pushed, but returns `false` if the configs are all up to date or haven't been
    /// loaded yet (eg. fresh install)
    public static var needsSync: Bool {
        configStore
            .wrappedValue
            .contains { _, atomicConf in
                guard atomicConf.wrappedValue != nil else { return false }
                
                return config_needs_push(atomicConf.wrappedValue)
            }
    }
    
    public static var libSessionVersion: String { String(cString: LIBSESSION_UTIL_VERSION_STR) }
    
    internal static func lastError(_ conf: UnsafeMutablePointer<config_object>?) -> String {
        return (conf?.pointee.last_error.map { String(cString: $0) } ?? "Unknown")  // stringlint:disable
    }
    
    // MARK: - Loading
    
    public static func clearMemoryState() {
        SessionUtil.configStore.mutate { confStore in
            confStore.removeAll()
        }
    }
    
    public static func loadState(
        _ db: Database? = nil,
        userPublicKey: String,
        ed25519SecretKey: [UInt8]?
    ) {
        // Ensure we have the ed25519 key and that we haven't already loaded the state before
        // we continue
        guard
            let secretKey: [UInt8] = ed25519SecretKey,
            SessionUtil.configStore.wrappedValue.isEmpty
        else { return SNLog("[SessionUtil] Ignoring loadState for '\(userPublicKey)' due to existing state") }
        
        // If we weren't given a database instance then get one
        guard let db: Database = db else {
            Storage.shared.read { db in
                SessionUtil.loadState(db, userPublicKey: userPublicKey, ed25519SecretKey: secretKey)
            }
            return
        }
        
        // Retrieve the existing dumps from the database
        let existingDumps: Set<ConfigDump> = ((try? ConfigDump.fetchSet(db)) ?? [])
        let existingDumpVariants: Set<ConfigDump.Variant> = existingDumps
            .map { $0.variant }
            .asSet()
        let missingRequiredVariants: Set<ConfigDump.Variant> = ConfigDump.Variant.userVariants
            .asSet()
            .subtracting(existingDumpVariants)
        
        // Create the 'config_object' records for each dump
        SessionUtil.configStore.mutate { confStore in
            existingDumps.forEach { dump in
                confStore[ConfigKey(variant: dump.variant, publicKey: dump.publicKey)] = Atomic(
                    try? SessionUtil.loadState(
                        for: dump.variant,
                        secretKey: secretKey,
                        cachedData: dump.data
                    )
                )
            }
            
            missingRequiredVariants.forEach { variant in
                confStore[ConfigKey(variant: variant, publicKey: userPublicKey)] = Atomic(
                    try? SessionUtil.loadState(
                        for: variant,
                        secretKey: secretKey,
                        cachedData: nil
                    )
                )
            }
        }
        
        SNLog("[SessionUtil] Completed loadState for '\(userPublicKey)'")
    }
    
    private static func loadState(
        for variant: ConfigDump.Variant,
        secretKey ed25519SecretKey: [UInt8],
        cachedData: Data?
    ) throws -> UnsafeMutablePointer<config_object>? {
        // Setup initial variables (including getting the memory address for any cached data)
        var conf: UnsafeMutablePointer<config_object>? = nil
        var error: [CChar] = [CChar](repeating: 0, count: 256)
        let cachedDump: (data: UnsafePointer<UInt8>, length: Int)? = cachedData?.withUnsafeBytes { unsafeBytes in
            return unsafeBytes.baseAddress.map {
                (
                    $0.assumingMemoryBound(to: UInt8.self),
                    unsafeBytes.count
                )
            }
        }
        
        // Try to create the object
        var secretKey: [UInt8] = ed25519SecretKey
        let result: Int32 = {
            switch variant {
                case .userProfile:
                    return user_profile_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), &error)

                case .contacts:
                    return contacts_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), &error)

                case .convoInfoVolatile:
                    return convo_info_volatile_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), &error)

                case .userGroups:
                    return user_groups_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), &error)
            }
        }()
        
        guard result == 0 else {
            SNLog("[SessionUtil Error] Unable to create \(variant.rawValue) config object: \(String(cString: error))")
            throw SessionUtilError.unableToCreateConfigObject
        }
        
        return conf
    }
    
    internal static func createDump(
        conf: UnsafeMutablePointer<config_object>?,
        for variant: ConfigDump.Variant,
        publicKey: String,
        timestampMs: Int64
    ) throws -> ConfigDump? {
        guard conf != nil else { throw SessionUtilError.nilConfigObject }
        
        // If it doesn't need a dump then do nothing
        guard config_needs_dump(conf) else { return nil }
        
        var dumpResult: UnsafeMutablePointer<UInt8>? = nil
        var dumpResultLen: Int = 0
        try CExceptionHelper.performSafely {
            config_dump(conf, &dumpResult, &dumpResultLen)
        }
        
        guard let dumpResult: UnsafeMutablePointer<UInt8> = dumpResult else { return nil }
        
        let dumpData: Data = Data(bytes: dumpResult, count: dumpResultLen)
        dumpResult.deallocate()
        
        return ConfigDump(
            variant: variant,
            publicKey: publicKey,
            data: dumpData,
            timestampMs: timestampMs
        )
    }
    
    // MARK: - Pushes
    
    public static func pendingChanges(
        _ db: Database,
        publicKey: String
    ) throws -> [OutgoingConfResult] {
        guard Identity.userExists(db) else { throw SessionUtilError.userDoesNotExist }
        
        let userPublicKey: String = getUserHexEncodedPublicKey(db)
        var existingDumpVariants: Set<ConfigDump.Variant> = try ConfigDump
            .select(.variant)
            .filter(ConfigDump.Columns.publicKey == publicKey)
            .asRequest(of: ConfigDump.Variant.self)
            .fetchSet(db)
        
        // Ensure we always check the required user config types for changes even if there is no dump
        // data yet (to deal with first launch cases)
        if publicKey == userPublicKey {
            ConfigDump.Variant.userVariants.forEach { existingDumpVariants.insert($0) }
        }
        
        // Ensure we always check the required user config types for changes even if there is no dump
        // data yet (to deal with first launch cases)
        return try existingDumpVariants
            .compactMap { variant -> OutgoingConfResult? in
                try SessionUtil
                    .config(for: variant, publicKey: publicKey)
                    .wrappedValue
                    .map { conf in
                        // Check if the config needs to be pushed
                        guard config_needs_push(conf) else { return nil }
                        
                        var cPushData: UnsafeMutablePointer<config_push_data>!
                        let configCountInfo: String = {
                            var result: String = "Invalid"  // stringlint:disable
                            
                            try? CExceptionHelper.performSafely {
                                switch variant {
                                    case .userProfile: result = "1 profile"
                                    case .contacts: result = "\(contacts_size(conf)) contacts"
                                    case .userGroups: result = "\(user_groups_size(conf)) group conversations"
                                    case .convoInfoVolatile: result = "\(convo_info_volatile_size(conf)) volatile conversations"
                                }
                            }
                            
                            return result
                        }()
                        
                        do {
                            try CExceptionHelper.performSafely {
                                cPushData = config_push(conf)
                            }
                        }
                        catch {
                            SNLog("[SessionUtil] Failed to generate push data for \(variant) config data, size: \(configCountInfo), error: \(error)")
                            throw error
                        }
                    
                        let pushData: Data = Data(
                            bytes: cPushData.pointee.config,
                            count: cPushData.pointee.config_len
                        )
                        let obsoleteHashes: [String] = [String](
                            pointer: cPushData.pointee.obsolete,
                            count: cPushData.pointee.obsolete_len,
                            defaultValue: []
                        )
                        let seqNo: Int64 = cPushData.pointee.seqno
                        cPushData.deallocate()
                        
                        return OutgoingConfResult(
                            message: SharedConfigMessage(
                                kind: variant.configMessageKind,
                                seqNo: seqNo,
                                data: pushData
                            ),
                            namespace: variant.namespace,
                            obsoleteHashes: obsoleteHashes
                        )
                    }
            }
    }
    
    public static func markingAsPushed(
        message: SharedConfigMessage,
        serverHash: String,
        publicKey: String
    ) -> ConfigDump? {
        return SessionUtil
            .config(for: message.kind.configDumpVariant, publicKey: publicKey)
            .mutate { conf in
                guard conf != nil else { return nil }
                
                // Mark the config as pushed
                var cHash: [CChar] = serverHash.cArray.nullTerminated()
                config_confirm_pushed(conf, message.seqNo, &cHash)
                
                // Update the result to indicate whether the config needs to be dumped
                guard config_needs_dump(conf) else { return nil }
                
                return try? SessionUtil.createDump(
                    conf: conf,
                    for: message.kind.configDumpVariant,
                    publicKey: publicKey,
                    timestampMs: (message.sentTimestamp.map { Int64($0) } ?? 0)
                )
            }
    }
    
    public static func configHashes(for publicKey: String) -> [String] {
        return Storage.shared
            .read { db -> Set<ConfigDump.Variant> in
                guard Identity.userExists(db) else { return [] }
                
                return try ConfigDump
                    .select(.variant)
                    .filter(ConfigDump.Columns.publicKey == publicKey)
                    .asRequest(of: ConfigDump.Variant.self)
                    .fetchSet(db)
            }
            .defaulting(to: [])
            .map { variant -> [String] in
                /// Extract all existing hashes for any dumps associated with the given `publicKey`
                guard
                    let conf = SessionUtil
                        .config(for: variant, publicKey: publicKey)
                        .wrappedValue,
                    let hashList: UnsafeMutablePointer<config_string_list> = config_current_hashes(conf)
                else { return [] }
                
                let result: [String] = [String](
                    pointer: hashList.pointee.value,
                    count: hashList.pointee.len,
                    defaultValue: []
                )
                hashList.deallocate()
                
                return result
            }
            .reduce([], +)
    }
    
    // MARK: - Receiving
    
    public static func handleConfigMessages(
        _ db: Database,
        messages: [SharedConfigMessage],
        publicKey: String
    ) throws {
        guard !messages.isEmpty else { return }
        guard !publicKey.isEmpty else { throw MessageReceiverError.noThread }
        
        let groupedMessages: [ConfigDump.Variant: [SharedConfigMessage]] = messages
            .sorted { lhs, rhs in lhs.seqNo < rhs.seqNo }
            .grouped(by: \.kind.configDumpVariant)
        
        let needsPush: Bool = try groupedMessages
            .sorted { lhs, rhs in lhs.key.processingOrder < rhs.key.processingOrder }
            .reduce(false) { prevNeedsPush, next -> Bool in
                let latestConfigSentTimestampMs: Int64 = Int64(next.value.compactMap { $0.sentTimestamp }.max() ?? 0)
                let needsPush: Bool = try SessionUtil
                    .config(for: next.key, publicKey: publicKey)
                    .mutate { conf in
                        // Merge the messages
                        var mergeHashes: [UnsafePointer<CChar>?] = next.value
                            .map { message in (message.serverHash ?? "").cArray.nullTerminated() }
                            .unsafeCopy()
                        var mergeData: [UnsafePointer<UInt8>?] = next.value
                            .map { message -> [UInt8] in message.data.bytes }
                            .unsafeCopy()
                        var mergeSize: [Int] = next.value.map { $0.data.count }
                        config_merge(conf, &mergeHashes, &mergeData, &mergeSize, next.value.count)
                        mergeHashes.forEach { $0?.deallocate() }
                        mergeData.forEach { $0?.deallocate() }
                        
                        // Apply the updated states to the database
                        do {
                            switch next.key {
                                case .userProfile:
                                    try SessionUtil.handleUserProfileUpdate(
                                        db,
                                        in: conf,
                                        mergeNeedsDump: config_needs_dump(conf),
                                        latestConfigSentTimestampMs: latestConfigSentTimestampMs
                                    )
                                    
                                case .contacts:
                                    try SessionUtil.handleContactsUpdate(
                                        db,
                                        in: conf,
                                        mergeNeedsDump: config_needs_dump(conf),
                                        latestConfigSentTimestampMs: latestConfigSentTimestampMs
                                    )
                                    
                                case .convoInfoVolatile:
                                    try SessionUtil.handleConvoInfoVolatileUpdate(
                                        db,
                                        in: conf,
                                        mergeNeedsDump: config_needs_dump(conf)
                                    )
                                    
                                case .userGroups:
                                    try SessionUtil.handleGroupsUpdate(
                                        db,
                                        in: conf,
                                        mergeNeedsDump: config_needs_dump(conf),
                                        latestConfigSentTimestampMs: latestConfigSentTimestampMs
                                    )
                            }
                        }
                        catch {
                            SNLog("[SessionUtil] Failed to process merge of \(next.key) config data")
                            throw error
                        }
                        
                        // Need to check if the config needs to be dumped (this might have changed
                        // after handling the merge changes)
                        guard config_needs_dump(conf) else {
                            try ConfigDump
                                .filter(
                                    ConfigDump.Columns.variant == next.key &&
                                    ConfigDump.Columns.publicKey == publicKey
                                )
                                .updateAll(
                                    db,
                                    ConfigDump.Columns.timestampMs.set(to: latestConfigSentTimestampMs)
                                )
                            
                            return config_needs_push(conf)
                        }
                        
                        try SessionUtil.createDump(
                            conf: conf,
                            for: next.key,
                            publicKey: publicKey,
                            timestampMs: latestConfigSentTimestampMs
                        )?.save(db)
                
                        return config_needs_push(conf)
                    }
                
                // Update the 'needsPush' state as needed
                return (prevNeedsPush || needsPush)
            }
        
        // Now that the local state has been updated, schedule a config sync if needed (this will
        // push any pending updates and properly update the state)
        guard needsPush else { return }
        
        db.afterNextTransactionNestedOnce(dedupeId: SessionUtil.syncDedupeId(publicKey)) { db in
            ConfigurationSyncJob.enqueue(db, publicKey: publicKey)
        }
    }
}

// MARK: - Internal Convenience

fileprivate extension SessionUtil {
    struct ConfigKey: Hashable {
        let variant: ConfigDump.Variant
        let publicKey: String
    }
}

// MARK: - Convenience

public extension SessionUtil {
    static func parseCommunity(url: String) -> (room: String, server: String, publicKey: String)? {
        var cFullUrl: [CChar] = url.cArray.nullTerminated()
        var cBaseUrl: [CChar] = [CChar](repeating: 0, count: COMMUNITY_BASE_URL_MAX_LENGTH)
        var cRoom: [CChar] = [CChar](repeating: 0, count: COMMUNITY_ROOM_MAX_LENGTH)
        var cPubkey: [UInt8] = [UInt8](repeating: 0, count: OpenGroup.pubkeyByteLength)
        
        guard
            community_parse_full_url(&cFullUrl, &cBaseUrl, &cRoom, &cPubkey) &&
            !String(cString: cRoom).isEmpty &&
            !String(cString: cBaseUrl).isEmpty &&
            cPubkey.contains(where: { $0 != 0 })
        else { return nil }
        
        // Note: Need to store them in variables instead of returning directly to ensure they
        // don't get freed from memory early (was seeing this happen intermittently during
        // unit tests...)
        let room: String = String(cString: cRoom)
        let baseUrl: String = String(cString: cBaseUrl)
        let pubkeyHex: String = Data(cPubkey).toHexString()
        
        return (room, baseUrl, pubkeyHex)
    }
    
    static func communityUrlFor(server: String, roomToken: String, publicKey: String) -> String {
        var cBaseUrl: [CChar] = server.cArray.nullTerminated()
        var cRoom: [CChar] = roomToken.cArray.nullTerminated()
        var cPubkey: [UInt8] = Data(hex: publicKey).cArray
        var cFullUrl: [CChar] = [CChar](repeating: 0, count: COMMUNITY_FULL_URL_MAX_LENGTH)
        community_make_full_url(&cBaseUrl, &cRoom, &cPubkey, &cFullUrl)
        
        return String(cString: cFullUrl)
    }
}
