// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
//
// stringlint:disable

import Foundation
import Combine
import GRDB
import SessionUtilitiesKit
import SessionSnodeKit

public enum DisplayPictureDownloadJob: JobExecutor {
    public static var maxFailureCount: Int = 1
    public static var requiresThreadId: Bool = false
    public static var requiresInteractionId: Bool = false
    
    public static func run(
        _ job: Job,
        queue: DispatchQueue,
        success: @escaping (Job, Bool, Dependencies) -> (),
        failure: @escaping (Job, Error?, Bool, Dependencies) -> (),
        deferred: @escaping (Job, Dependencies) -> (),
        using dependencies: Dependencies
    ) {
        guard
            let detailsData: Data = job.details,
            let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData),
            let preparedDownload: HTTP.PreparedRequest<Data> = try? {
                switch details.target {
                    case .profile(_, let url, _), .group(_, let url, _):
                        guard let fileId: String = Attachment.fileId(for: url) else { return nil }
                        
                        return try FileServerAPI.preparedDownload(
                            fileId: fileId,
                            useOldServer: url.contains(FileServerAPI.oldServer),
                            using: dependencies
                        )
                        
                    case .community(let fileId, let roomToken, let server):
                        return dependencies[singleton: .storage].read(using: dependencies) { db in
                            try OpenGroupAPI.preparedDownloadFile(
                                db,
                                fileId: fileId,
                                from: roomToken,
                                on: server,
                                using: dependencies
                            )
                        }
                }
            }()
        else {
            SNLog("[DisplayPictureDownloadJob] Failing due to missing details")
            failure(job, JobRunnerError.missingRequiredDetails, true, dependencies)
            return
        }
            
        let fileName: String = DisplayPictureManager.generateFilename()
        let filePath: String = DisplayPictureManager.filepath(for: fileName)
        
        preparedDownload
            .send(using: dependencies)
            .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies)
            .receive(on: DispatchQueue.global(qos: .background), using: dependencies)
            .sinkUntilComplete(
                receiveCompletion: { result in
                    switch result {
                        case .finished: success(job, false, dependencies)
                        case .failure(let error): failure(job, error, true, dependencies)
                    }
                },
                receiveValue: { _, data in
                    // Check to make sure this download is still a valid update
                    guard dependencies[singleton: .storage].read({ db in details.isValidUpdate(db) }) == true else {
                        return
                    }
                    
                    guard
                        let decryptedData: Data = {
                            switch details.target {
                                case .community: return data    // Community data is unencrypted
                                case .profile(_, _, let encryptionKey), .group(_, _, let encryptionKey):
                                    return DisplayPictureManager.decryptData(data: data, key: encryptionKey)
                            }
                        }()
                    else {
                        SNLog("[DisplayPictureDownloadJob] Failed to decrypt display picture for \(details.target)")
                        failure(job, DisplayPictureError.writeFailed, true, dependencies)
                        return
                    }
                    
                    // Save the decrypted display picture to disk
                    try? decryptedData.write(to: URL(fileURLWithPath: filePath), options: [.atomic])
                    
                    guard UIImage(contentsOfFile: filePath) != nil else {
                        SNLog("[DisplayPictureDownloadJob] Failed to load display picture for \(details.target)")
                        failure(job, DisplayPictureError.writeFailed, true, dependencies)
                        return
                    }
                    
                    // Update the cache first (in case the DBWrite thread is blocked, this way other threads
                    // can retrieve from the cache and avoid triggering a download)
                    dependencies.mutate(cache: .displayPicture) { cache in
                        cache.imageData[fileName] = decryptedData
                    }
                    
                    // Store the updated information in the database
                    dependencies[singleton: .storage].write { db in
                        switch details.target {
                            case .profile(let id, let url, let encryptionKey):
                                _ = try? Profile
                                    .filter(id: id)
                                    .updateAllAndConfig(
                                        db,
                                        Profile.Columns.profilePictureUrl.set(to: url),
                                        Profile.Columns.profileEncryptionKey.set(to: encryptionKey),
                                        Profile.Columns.profilePictureFileName.set(to: fileName),
                                        Profile.Columns.lastProfilePictureUpdate.set(to: details.timestamp)
                                    )
                                
                            case .group(let id, let url, let encryptionKey):
                                _ = try? ClosedGroup
                                    .filter(id: id)
                                    .updateAllAndConfig(
                                        db,
                                        ClosedGroup.Columns.displayPictureUrl.set(to: url),
                                        ClosedGroup.Columns.displayPictureEncryptionKey.set(to: encryptionKey),
                                        ClosedGroup.Columns.displayPictureFilename.set(to: fileName),
                                        ClosedGroup.Columns.lastDisplayPictureUpdate.set(to: details.timestamp)
                                    )
                                
                            case .community(_, let roomToken, let server):
                                _ = try? OpenGroup
                                    .filter(id: OpenGroup.idFor(roomToken: roomToken, server: server))
                                    .updateAllAndConfig(
                                        db,
                                        OpenGroup.Columns.displayPictureFilename.set(to: fileName),
                                        OpenGroup.Columns.lastDisplayPictureUpdate.set(to: details.timestamp)
                                    )
                        }
                    }
                }
            )
    }
}

// MARK: - DisplayPictureDownloadJob.Details

extension DisplayPictureDownloadJob {
    public enum Target: Codable, Hashable, CustomStringConvertible {
        case profile(id: String, url: String, encryptionKey: Data)
        case group(id: String, url: String, encryptionKey: Data)
        case community(imageId: String, roomToken: String, server: String)
        
        var isValid: Bool {
            switch self {
                case .profile(_, let url, let encryptionKey), .group(_, let url, let encryptionKey):
                    return (
                        !url.isEmpty &&
                        Attachment.fileId(for: url) != nil &&
                        encryptionKey.count == DisplayPictureManager.aes256KeyByteLength
                    )
                    
                case .community(let imageId, _, _): return !imageId.isEmpty
            }
        }
        
        // MARK: - CustomStringConvertible
        
        public var description: String {
            switch self {
                case .profile(let id, _, _): return "profile: \(id)"
                case .group(let id, _, _): return "group: \(id)"
                case .community(_, let roomToken, let server): return "room: \(roomToken) on server: \(server)"
            }
        }
    }
    
    public struct Details: Codable, Hashable {
        public let target: Target
        public let timestamp: TimeInterval
        
        // MARK: - Hashable
        
        public func hash(into hasher: inout Hasher) {
            /// We intentionally leave `timestamp` out of the hash value because when we insert the job we want
            /// it to prevent duplicate jobs from being added with the same `target` information and including
            /// the `timestamp` could likely result in multiple jobs downloading the same `target`
            target.hash(into: &hasher)
        }
        
        // MARK: - Initialization
        
        public init?(target: Target, timestamp: TimeInterval) {
            guard target.isValid else { return nil }
            
            self.target = target
            self.timestamp = timestamp
        }
        
        public init?(owner: DisplayPictureManager.Owner) {
            switch owner {
                case .user(let profile):
                    guard
                        let url: String = profile.profilePictureUrl,
                        let key: Data = profile.profileEncryptionKey,
                        let details: Details = Details(
                            target: .profile(id: profile.id, url: url, encryptionKey: key),
                            timestamp: (profile.lastProfilePictureUpdate ?? 0)
                        )
                    else { return nil }
                    
                    self = details
                    
                case .group(let group):
                    guard
                        let url: String = group.displayPictureUrl,
                        let key: Data = group.displayPictureEncryptionKey,
                        let details: Details = Details(
                            target: .group(id: group.id, url: url, encryptionKey: key),
                            timestamp: (group.lastDisplayPictureUpdate ?? 0)
                        )
                    else { return nil }
                    
                    self = details
                    
                case .community(let openGroup):
                    guard
                        let imageId: String = openGroup.imageId,
                        let details: Details = Details(
                            target: .community(
                                imageId: imageId,
                                roomToken: openGroup.roomToken,
                                server: openGroup.server
                            ),
                            timestamp: (openGroup.lastDisplayPictureUpdate ?? 0)
                        )
                    else { return nil }
                    
                    self = details
                    
                case .file: return nil
            }
        }
        
        // MARK: - Functions
        
        fileprivate func isValidUpdate(_ db: Database) -> Bool {
            switch self.target {
                case .profile(let id, let url, let encryptionKey):
                    guard let latestProfile: Profile = try? Profile.fetchOne(db, id: id) else { return false }
                    
                    return (
                        timestamp >= (latestProfile.lastProfilePictureUpdate ?? 0) || (
                            encryptionKey == latestProfile.profileEncryptionKey &&
                            url == latestProfile.profilePictureUrl
                        )
                    )
                    
                case .group(let id, let url, let encryptionKey):
                    guard let latestGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: id) else { return false }
                    
                    return (
                        timestamp >= (latestGroup.lastDisplayPictureUpdate ?? 0) || (
                            encryptionKey == latestGroup.displayPictureEncryptionKey &&
                            url == latestGroup.displayPictureUrl
                        )
                    )
                    
                case .community(let imageId, let roomToken, let server):
                    guard
                        let latestGroup: OpenGroup = try? OpenGroup.fetchOne(
                            db,
                            id: OpenGroup.idFor(roomToken: roomToken, server: server)
                        )
                    else { return false }
                    
                    return (
                        timestamp >= (latestGroup.lastDisplayPictureUpdate ?? 0) ||
                        imageId == latestGroup.imageId
                    )
            }
        }
    }
}