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

import Foundation
import GRDB
import SessionUtil
import SessionUtilitiesKit

// MARK: - Size Restrictions

public extension SessionUtil {
    static var libSessionMaxNameByteLength: Int { CONTACT_MAX_NAME_LENGTH }
    static var libSessionMaxNicknameByteLength: Int { CONTACT_MAX_NAME_LENGTH }
    static var libSessionMaxProfileUrlByteLength: Int { PROFILE_PIC_MAX_URL_LENGTH }
}

// MARK: - Contacts Handling

internal extension SessionUtil {
    static let columnsRelatedToContacts: [ColumnExpression] = [
        Contact.Columns.isApproved,
        Contact.Columns.isBlocked,
        Contact.Columns.didApproveMe,
        Profile.Columns.name,
        Profile.Columns.nickname,
        Profile.Columns.profilePictureUrl,
        Profile.Columns.profileEncryptionKey
    ]
    
    // MARK: - Incoming Changes
    
    static func handleContactsUpdate(
        _ db: Database,
        in conf: UnsafeMutablePointer<config_object>?,
        mergeNeedsDump: Bool,
        latestConfigSentTimestampMs: Int64
    ) throws {
        guard mergeNeedsDump else { return }
        guard conf != nil else { throw SessionUtilError.nilConfigObject }
        
        // The current users contact data is handled separately so exclude it if it's present (as that's
        // actually a bug)
        let userPublicKey: String = getUserHexEncodedPublicKey(db)
        let targetContactData: [String: ContactData] = try extractContacts(
            from: conf,
            latestConfigSentTimestampMs: latestConfigSentTimestampMs
        ).filter { $0.key != userPublicKey }
        
        // Since we don't sync 100% of the data stored against the contact and profile objects we
        // need to only update the data we do have to ensure we don't overwrite anything that doesn't
        // get synced
        try targetContactData
            .forEach { sessionId, data in
                // Note: We only update the contact and profile records if the data has actually changed
                // in order to avoid triggering UI updates for every thread on the home screen (the DB
                // observation system can't differ between update calls which do and don't change anything)
                let contact: Contact = Contact.fetchOrCreate(db, id: sessionId)
                let profile: Profile = Profile.fetchOrCreate(db, id: sessionId)
                let profileNameShouldBeUpdated: Bool = (
                    !data.profile.name.isEmpty &&
                    profile.name != data.profile.name &&
                    (profile.lastNameUpdate ?? 0) < (data.profile.lastNameUpdate ?? 0)
                )
                let profilePictureShouldBeUpdated: Bool = (
                    (
                        profile.profilePictureUrl != data.profile.profilePictureUrl ||
                        profile.profileEncryptionKey != data.profile.profileEncryptionKey
                    ) &&
                    (profile.lastProfilePictureUpdate ?? 0) < (data.profile.lastProfilePictureUpdate ?? 0)
                )
                
                if
                    profileNameShouldBeUpdated ||
                    profile.nickname != data.profile.nickname ||
                    profilePictureShouldBeUpdated
                {
                    try profile.save(db)
                    try Profile
                        .filter(id: sessionId)
                        .updateAll( // Handling a config update so don't use `updateAllAndConfig`
                            db,
                            [
                                (!profileNameShouldBeUpdated ? nil :
                                    Profile.Columns.name.set(to: data.profile.name)
                                ),
                                (!profileNameShouldBeUpdated ? nil :
                                    Profile.Columns.lastNameUpdate.set(to: data.profile.lastNameUpdate)
                                ),
                                (profile.nickname == data.profile.nickname ? nil :
                                    Profile.Columns.nickname.set(to: data.profile.nickname)
                                ),
                                (profile.profilePictureUrl != data.profile.profilePictureUrl ? nil :
                                    Profile.Columns.profilePictureUrl.set(to: data.profile.profilePictureUrl)
                                ),
                                (profile.profileEncryptionKey != data.profile.profileEncryptionKey ? nil :
                                    Profile.Columns.profileEncryptionKey.set(to: data.profile.profileEncryptionKey)
                                ),
                                (!profilePictureShouldBeUpdated ? nil :
                                    Profile.Columns.lastProfilePictureUpdate.set(to: data.profile.lastProfilePictureUpdate)
                                )
                            ].compactMap { $0 }
                        )
                }
                
                /// Since message requests have no reverse, we should only handle setting `isApproved`
                /// and `didApproveMe` to `true`. This may prevent some weird edge cases where a config message
                /// swapping `isApproved` and `didApproveMe` to `false`
                if
                    (contact.isApproved != data.contact.isApproved) ||
                    (contact.isBlocked != data.contact.isBlocked) ||
                    (contact.didApproveMe != data.contact.didApproveMe)
                {
                    try contact.save(db)
                    try Contact
                        .filter(id: sessionId)
                        .updateAll( // Handling a config update so don't use `updateAllAndConfig`
                            db,
                            [
                                (!data.contact.isApproved || contact.isApproved == data.contact.isApproved ? nil :
                                    Contact.Columns.isApproved.set(to: true)
                                ),
                                (contact.isBlocked == data.contact.isBlocked ? nil :
                                    Contact.Columns.isBlocked.set(to: data.contact.isBlocked)
                                ),
                                (!data.contact.didApproveMe || contact.didApproveMe == data.contact.didApproveMe ? nil :
                                    Contact.Columns.didApproveMe.set(to: true)
                                )
                            ].compactMap { $0 }
                        )
                }
                
                /// If the contact's `hidden` flag doesn't match the visibility of their conversation then create/delete the
                /// associated contact conversation accordingly
                let threadInfo: PriorityVisibilityInfo? = try? SessionThread
                    .filter(id: sessionId)
                    .select(.id, .variant, .pinnedPriority, .shouldBeVisible)
                    .asRequest(of: PriorityVisibilityInfo.self)
                    .fetchOne(db)
                let threadExists: Bool = (threadInfo != nil)
                let updatedShouldBeVisible: Bool = SessionUtil.shouldBeVisible(priority: data.priority)

                /// If we are hiding the conversation then kick the user from it if it's currently open
                if !updatedShouldBeVisible {
                    SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: [sessionId])
                }
                
                /// Create the thread if it doesn't exist, otherwise just update it's state
                if !threadExists {
                    try SessionThread(
                        id: sessionId,
                        variant: .contact,
                        creationDateTimestamp: data.created,
                        shouldBeVisible: updatedShouldBeVisible,
                        pinnedPriority: data.priority
                    ).save(db)
                }
                else {
                    let changes: [ConfigColumnAssignment] = [
                        (threadInfo?.shouldBeVisible == updatedShouldBeVisible ? nil :
                            SessionThread.Columns.shouldBeVisible.set(to: updatedShouldBeVisible)
                        ),
                        (threadInfo?.pinnedPriority == data.priority ? nil :
                            SessionThread.Columns.pinnedPriority.set(to: data.priority)
                        )
                    ].compactMap { $0 }
                    
                    try SessionThread
                        .filter(id: sessionId)
                        .updateAll( // Handling a config update so don't use `updateAllAndConfig`
                            db,
                            changes
                        )
                }
            }
        
        /// Delete any contact/thread records which aren't in the config message
        let syncedContactIds: [String] = targetContactData
            .map { $0.key }
            .appending(userPublicKey)
        let contactIdsToRemove: [String] = try Contact
            .filter(!syncedContactIds.contains(Contact.Columns.id))
            .select(.id)
            .asRequest(of: String.self)
            .fetchAll(db)
        let threadIdsToRemove: [String] = try SessionThread
            .filter(!syncedContactIds.contains(SessionThread.Columns.id))
            .filter(SessionThread.Columns.variant == SessionThread.Variant.contact)
            .filter(!SessionThread.Columns.id.like("\(SessionId.Prefix.blinded15.rawValue)%"))
            .filter(!SessionThread.Columns.id.like("\(SessionId.Prefix.blinded25.rawValue)%"))
            .select(.id)
            .asRequest(of: String.self)
            .fetchAll(db)
        
        /// When the user opens a brand new conversation this creates a "draft conversation" which has a hidden thread but no
        /// contact record, when we receive a contact update this "draft conversation" would be included in the
        /// `threadIdsToRemove` which would result in the user getting kicked from the screen and the thread removed, we
        /// want to avoid this (as it's essentially a bug) so find any conversations in this state and remove them from the list that
        /// will be pruned
        let threadT: TypedTableAlias<SessionThread> = TypedTableAlias()
        let contactT: TypedTableAlias<Contact> = TypedTableAlias()
        let draftConversationIds: [String] = try SQLRequest<String>("""
            SELECT \(threadT[.id])
            FROM \(SessionThread.self)
            LEFT JOIN \(Contact.self) ON \(contactT[.id]) = \(threadT[.id])
            WHERE (
                \(SQL("\(threadT[.id]) IN \(threadIdsToRemove)")) AND
                \(contactT[.id]) IS NULL
            )
        """).fetchAll(db)
        
        /// Consolidate the ids which should be removed
        let combinedIds: [String] = contactIdsToRemove
            .appending(contentsOf: threadIdsToRemove)
            .filter { !draftConversationIds.contains($0) }
        
        if !combinedIds.isEmpty {
            SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: combinedIds)
            
            try Contact
                .filter(ids: combinedIds)
                .deleteAll(db)
            
            // Also need to remove any 'nickname' values since they are associated to contact data
            try Profile
                .filter(ids: combinedIds)
                .updateAll(
                    db,
                    Profile.Columns.nickname.set(to: nil)
                )
            
            // Delete the one-to-one conversations associated to the contact
            try SessionThread
                .deleteOrLeave(
                    db,
                    threadIds: combinedIds,
                    threadVariant: .contact,
                    groupLeaveType: .forced,
                    calledFromConfigHandling: true
                )
            
            try SessionUtil.remove(db, volatileContactIds: combinedIds)
        }
    }
    
    // MARK: - Outgoing Changes
    
    static func upsert(
        contactData: [SyncedContactInfo],
        in conf: UnsafeMutablePointer<config_object>?
    ) throws {
        guard conf != nil else { throw SessionUtilError.nilConfigObject }
        
        // The current users contact data doesn't need to sync so exclude it, we also don't want to sync
        // blinded message requests so exclude those as well
        let userPublicKey: String = getUserHexEncodedPublicKey()
        let targetContacts: [SyncedContactInfo] = contactData
            .filter {
                $0.id != userPublicKey &&
                SessionId(from: $0.id)?.prefix == .standard
            }
        
        // If we only updated the current user contact then no need to continue
        guard !targetContacts.isEmpty else { return }        
        
        // Update the name
        try targetContacts
            .forEach { info in
                var sessionId: [CChar] = info.id.cArray.nullTerminated()
                var contact: contacts_contact = contacts_contact()
                guard contacts_get_or_construct(conf, &contact, &sessionId) else {
                    /// It looks like there are some situations where this object might not get created correctly (and
                    /// will throw due to the implicit unwrapping) as a result we put it in a guard and throw instead
                    SNLog("Unable to upsert contact to SessionUtil: \(SessionUtil.lastError(conf))")
                    throw SessionUtilError.getOrConstructFailedUnexpectedly
                }
                
                // Assign all properties to match the updated contact (if there is one)
                if let updatedContact: Contact = info.contact {
                    contact.approved = updatedContact.isApproved
                    contact.approved_me = updatedContact.didApproveMe
                    contact.blocked = updatedContact.isBlocked
                    
                    // If we were given a `created` timestamp then set it to the min between the current
                    // setting and the value (as long as the current setting isn't `0`)
                    if let created: Int64 = info.created.map({ Int64(floor($0)) }) {
                        contact.created = (contact.created > 0 ? min(contact.created, created) : created)
                    }
                    
                    // Store the updated contact (needs to happen before variables go out of scope)
                    contacts_set(conf, &contact)
                }
                
                // Update the profile data (if there is one - users we have sent a message request to may
                // not have profile info in certain situations)
                if let updatedProfile: Profile = info.profile {
                    let oldAvatarUrl: String? = String(libSessionVal: contact.profile_pic.url)
                    let oldAvatarKey: Data? = Data(
                        libSessionVal: contact.profile_pic.key,
                        count: ProfileManager.avatarAES256KeyByteLength
                    )
                    
                    contact.name = updatedProfile.name.toLibSession()
                    contact.nickname = updatedProfile.nickname.toLibSession()
                    contact.profile_pic.url = updatedProfile.profilePictureUrl.toLibSession()
                    contact.profile_pic.key = updatedProfile.profileEncryptionKey.toLibSession()
                    
                    // Download the profile picture if needed (this can be triggered within
                    // database reads/writes so dispatch the download to a separate queue to
                    // prevent blocking)
                    if
                        oldAvatarUrl != (updatedProfile.profilePictureUrl ?? "") ||
                        oldAvatarKey != (updatedProfile.profileEncryptionKey ?? Data(repeating: 0, count: ProfileManager.avatarAES256KeyByteLength))
                    {
                        DispatchQueue.global(qos: .background).async {
                            ProfileManager.downloadAvatar(for: updatedProfile)
                        }
                    }
                    
                    // Store the updated contact (needs to happen before variables go out of scope)
                    contacts_set(conf, &contact)
                }
                
                // Store the updated contact (can't be sure if we made any changes above)
                contact.priority = (info.priority ?? contact.priority)
                contacts_set(conf, &contact)
            }
    }
}

// MARK: - Outgoing Changes

internal extension SessionUtil {
    static func updatingContacts<T>(_ db: Database, _ updated: [T]) throws -> [T] {
        guard let updatedContacts: [Contact] = updated as? [Contact] else { throw StorageError.generic }
        
        // The current users contact data doesn't need to sync so exclude it, we also don't want to sync
        // blinded message requests so exclude those as well
        let userPublicKey: String = getUserHexEncodedPublicKey(db)
        let targetContacts: [Contact] = updatedContacts
            .filter {
                $0.id != userPublicKey &&
                SessionId(from: $0.id)?.prefix == .standard
            }
        
        // If we only updated the current user contact then no need to continue
        guard !targetContacts.isEmpty else { return updated }
        
        try SessionUtil.performAndPushChange(
            db,
            for: .contacts,
            publicKey: userPublicKey
        ) { conf in
            // When inserting new contacts (or contacts with invalid profile data) we want
            // to add any valid profile information we have so identify if any of the updated
            // contacts are new/invalid, and if so, fetch any profile data we have for them
            let newContactIds: [String] = targetContacts
                .compactMap { contactData -> String? in
                    var cContactId: [CChar] = contactData.id.cArray.nullTerminated()
                    var contact: contacts_contact = contacts_contact()
                    
                    guard
                        contacts_get(conf, &contact, &cContactId),
                        String(libSessionVal: contact.name, nullIfEmpty: true) != nil
                    else { return contactData.id }
                    
                    return nil
                }
            let newProfiles: [String: Profile] = try Profile
                .fetchAll(db, ids: newContactIds)
                .reduce(into: [:]) { result, next in result[next.id] = next }
            
            // Upsert the updated contact data
            try SessionUtil
                .upsert(
                    contactData: targetContacts
                        .map { contact in
                            SyncedContactInfo(
                                id: contact.id,
                                contact: contact,
                                profile: newProfiles[contact.id]
                            )
                        },
                    in: conf
                )
        }
        
        return updated
    }
    
    static func updatingProfiles<T>(_ db: Database, _ updated: [T]) throws -> [T] {
        guard let updatedProfiles: [Profile] = updated as? [Profile] else { throw StorageError.generic }
        
        // We should only sync profiles which are associated to contact data to avoid including profiles
        // for random people in community conversations so filter out any profiles which don't have an
        // associated contact
        let existingContactIds: [String] = (try? Contact
            .filter(ids: updatedProfiles.map { $0.id })
            .select(.id)
            .asRequest(of: String.self)
            .fetchAll(db))
            .defaulting(to: [])
        
        // If none of the profiles are associated with existing contacts then ignore the changes (no need
        // to do a config sync)
        guard !existingContactIds.isEmpty else { return updated }
        
        // Get the user public key (updating their profile is handled separately
        let userPublicKey: String = getUserHexEncodedPublicKey(db)
        let targetProfiles: [Profile] = updatedProfiles
            .filter {
                $0.id != userPublicKey &&
                SessionId(from: $0.id)?.prefix == .standard &&
                existingContactIds.contains($0.id)
            }
        
        // Update the user profile first (if needed)
        if let updatedUserProfile: Profile = updatedProfiles.first(where: { $0.id == userPublicKey }) {
            try SessionUtil.performAndPushChange(
                db,
                for: .userProfile,
                publicKey: userPublicKey
            ) { conf in
                try SessionUtil.update(
                    profile: updatedUserProfile,
                    in: conf
                )
            }
        }
        
        try SessionUtil.performAndPushChange(
            db,
            for: .contacts,
            publicKey: userPublicKey
        ) { conf in
            try SessionUtil
                .upsert(
                    contactData: targetProfiles
                        .map { SyncedContactInfo(id: $0.id, profile: $0) },
                    in: conf
                )
        }
        
        return updated
    }
}

// MARK: - External Outgoing Changes

public extension SessionUtil {
    static func hide(_ db: Database, contactIds: [String]) throws {
        try SessionUtil.performAndPushChange(
            db,
            for: .contacts,
            publicKey: getUserHexEncodedPublicKey(db)
        ) { conf in
            // Mark the contacts as hidden
            try SessionUtil.upsert(
                contactData: contactIds
                    .map {
                        SyncedContactInfo(
                            id: $0,
                            priority: SessionUtil.hiddenPriority
                        )
                    },
                in: conf
            )
        }
    }
    
    static func remove(_ db: Database, contactIds: [String]) throws {
        guard !contactIds.isEmpty else { return }
        
        try SessionUtil.performAndPushChange(
            db,
            for: .contacts,
            publicKey: getUserHexEncodedPublicKey(db)
        ) { conf in
            contactIds.forEach { sessionId in
                var cSessionId: [CChar] = sessionId.cArray.nullTerminated()
                
                // Don't care if the contact doesn't exist
                contacts_erase(conf, &cSessionId)
            }
        }
    }
}

// MARK: - SyncedContactInfo

extension SessionUtil {
    struct SyncedContactInfo {
        let id: String
        let contact: Contact?
        let profile: Profile?
        let priority: Int32?
        let created: TimeInterval?
        
        init(
            id: String,
            contact: Contact? = nil,
            profile: Profile? = nil,
            priority: Int32? = nil,
            created: TimeInterval? = nil
        ) {
            self.id = id
            self.contact = contact
            self.profile = profile
            self.priority = priority
            self.created = created
        }
    }
}

// MARK: - ContactData

private struct ContactData {
    let contact: Contact
    let profile: Profile
    let priority: Int32
    let created: TimeInterval
}

// MARK: - ThreadCount

private struct ThreadCount: Codable, FetchableRecord {
    let id: String
    let interactionCount: Int
}

// MARK: - Convenience

private extension SessionUtil {
    static func extractContacts(
        from conf: UnsafeMutablePointer<config_object>?,
        latestConfigSentTimestampMs: Int64
    ) throws -> [String: ContactData] {
        var infiniteLoopGuard: Int = 0
        var result: [String: ContactData] = [:]
        var contact: contacts_contact = contacts_contact()
        let contactIterator: UnsafeMutablePointer<contacts_iterator> = contacts_iterator_new(conf)
        
        while !contacts_iterator_done(contactIterator, &contact) {
            try SessionUtil.checkLoopLimitReached(&infiniteLoopGuard, for: .contacts)
            
            let contactId: String = String(cString: withUnsafeBytes(of: contact.session_id) { [UInt8]($0) }
                .map { CChar($0) }
                .nullTerminated()
            )
            let contactResult: Contact = Contact(
                id: contactId,
                isApproved: contact.approved,
                isBlocked: contact.blocked,
                didApproveMe: contact.approved_me
            )
            let profilePictureUrl: String? = String(libSessionVal: contact.profile_pic.url, nullIfEmpty: true)
            let profileResult: Profile = Profile(
                id: contactId,
                name: String(libSessionVal: contact.name),
                lastNameUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000),
                nickname: String(libSessionVal: contact.nickname, nullIfEmpty: true),
                profilePictureUrl: profilePictureUrl,
                profileEncryptionKey: (profilePictureUrl == nil ? nil :
                    Data(
                        libSessionVal: contact.profile_pic.key,
                        count: ProfileManager.avatarAES256KeyByteLength
                    )
                ),
                lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000)
            )
            
            result[contactId] = ContactData(
                contact: contactResult,
                profile: profileResult,
                priority: contact.priority,
                created: TimeInterval(contact.created)
            )
            contacts_iterator_advance(contactIterator)
        }
        contacts_iterator_free(contactIterator) // Need to free the iterator
        
        return result
    }
}
