/*
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
 */

import Foundation
import ZIPFoundation
import Alamofire
import Compression
import UIKit

@objc public class CapgoUpdater: NSObject {
    private var logger: Logger!

    private let versionCode: String = Bundle.main.versionCode ?? ""
    private let versionOs = UIDevice.current.systemVersion
    private let libraryDir: URL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!
    private let DEFAULT_FOLDER: String = ""
    private let bundleDirectory: String = "NoCloud/ionic_built_snapshots"
    private let INFO_SUFFIX: String = "_info"
    private let FALLBACK_VERSION: String = "pastVersion"
    private let NEXT_VERSION: String = "nextVersion"
    private let PREVIEW_FALLBACK_VERSION: String = "previewFallbackVersion"
    private var unzipPercent = 0
    private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"

    // Add this line to declare cacheFolder
    private let cacheFolder: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("capgo_downloads")

    public let CAP_SERVER_PATH: String = "serverBasePath"
    public var versionBuild: String = ""
    public var customId: String = ""
    public var pluginVersion: String = ""
    public var timeout: Double = 20
    public var statsUrl: String = ""
    public var channelUrl: String = ""
    public var defaultChannel: String = ""
    public var appId: String = ""
    public var deviceID = ""
    public var previewSession = false
    public var publicKey: String = ""

    // Cached key ID calculated once from publicKey
    private var cachedKeyId: String?

    // Flag to track if we received a 429 response - stops requests until app restart
    private static var rateLimitExceeded = false

    // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
    private static var rateLimitStatisticSent = false

    // Stats batching - queue events and send max once per second
    private var statsQueue: [StatsEvent] = []
    private let statsQueueLock = NSLock()
    private var statsFlushTimer: Timer?
    private static let statsFlushInterval: TimeInterval = 1.0

    private static func sanitizeHeaderValue(_ value: String) -> String {
        if value.isEmpty {
            return "unknown"
        }

        let filteredScalars = value.unicodeScalars.filter { scalar in
            let cp = scalar.value
            let isVisibleAscii = (0x20...0x7E).contains(cp)
            let isIso88591 = (0xA0...0xFF).contains(cp)
            return isVisibleAscii || isIso88591
        }

        let sanitized = String(String.UnicodeScalarView(filteredScalars)).trimmingCharacters(in: .whitespacesAndNewlines)
        return sanitized.isEmpty ? "unknown" : sanitized
    }

    static func buildUserAgent(appId: String, pluginVersion: String, versionOs: String) -> String {
        let safePluginVersion = sanitizeHeaderValue(pluginVersion)
        let safeAppId = sanitizeHeaderValue(appId)
        let safeVersionOs = sanitizeHeaderValue(versionOs)
        return "CapacitorUpdater/\(safePluginVersion) (\(safeAppId)) ios/\(safeVersionOs)"
    }

    private var userAgent: String {
        CapgoUpdater.buildUserAgent(appId: appId, pluginVersion: pluginVersion, versionOs: versionOs)
    }

    struct RequestResult {
        let data: Data?
        let response: HTTPURLResponse?
        let error: Error?
        let timedOut: Bool
    }

    private struct DownloadRequestResult {
        let fileURL: URL?
        let response: HTTPURLResponse?
        let error: Error?
        let timedOut: Bool
    }

    enum SecurePathError: Error {
        case emptyPath
        case windowsPath
        case absolutePath
        case pathTraversal
    }

    static func resolvePathInsideDirectory(baseDirectory: URL, relativePath: String) throws -> URL {
        if relativePath.isEmpty {
            throw SecurePathError.emptyPath
        }
        if relativePath.contains("\\") || relativePath.contains("\0") {
            throw SecurePathError.windowsPath
        }
        if (relativePath as NSString).isAbsolutePath {
            throw SecurePathError.absolutePath
        }

        let canonicalBase = baseDirectory.standardizedFileURL
        let canonicalBasePath = canonicalBase.path
        let normalizedBasePath = canonicalBasePath.hasSuffix("/") ? canonicalBasePath : "\(canonicalBasePath)/"
        let canonicalTarget = canonicalBase.appendingPathComponent(relativePath).standardizedFileURL
        let canonicalTargetPath = canonicalTarget.path

        if canonicalTargetPath != canonicalBasePath && !canonicalTargetPath.hasPrefix(normalizedBasePath) {
            throw SecurePathError.pathTraversal
        }

        return canonicalTarget
    }

    static func resolveManifestTargetPath(baseDirectory: URL, fileName: String) throws -> URL {
        let isBrotli = fileName.hasSuffix(".br")
        let targetFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
        return try resolvePathInsideDirectory(baseDirectory: baseDirectory, relativePath: targetFileName)
    }

    private func isTimedOutError(_ error: Error?) -> Bool {
        guard let nsError = error as NSError? else {
            return false
        }

        return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
    }

    private lazy var alamofireSession: Session = {
        let configuration = URLSessionConfiguration.ephemeral
        configuration.httpAdditionalHeaders = ["User-Agent": self.userAgent]
        configuration.httpCookieStorage = nil
        configuration.httpShouldSetCookies = false
        configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
        configuration.urlCache = nil
        return Session(configuration: configuration)
    }()
    private let networkResponseQueue = DispatchQueue(label: "ee.forgr.capacitor-updater.network-response", qos: .utility)

    public var notifyDownloadRaw: (String, Int, Bool, BundleInfo?) -> Void = { _, _, _, _  in }
    public func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
        let emit = {
            self.notifyDownloadRaw(id, percent, ignoreMultipleOfTen, bundle)
        }
        if Thread.isMainThread {
            emit()
        } else {
            DispatchQueue.main.async {
                emit()
            }
        }
    }
    public var notifyDownload: (String, Int) -> Void = { _, _  in }
    public var notifyListeners: (String, [String: Any]) -> Void = { _, _ in }

    public func setLogger(_ logger: Logger) {
        self.logger = logger
    }

    private func createRequest(url: URL, method: String, parameters: [String: Any]? = nil, expectsJSONResponse: Bool = false) -> URLRequest? {
        var request = URLRequest(url: url)
        request.httpMethod = method
        request.timeoutInterval = self.timeout
        request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
        if expectsJSONResponse || parameters != nil {
            request.setValue("application/json", forHTTPHeaderField: "Accept")
        }

        guard let parameters else {
            return request
        }

        guard JSONSerialization.isValidJSONObject(parameters) else {
            logger.error("Invalid JSON body for \(method) \(url.absoluteString)")
            return nil
        }

        do {
            request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            return request
        } catch {
            logger.error("Error encoding request body for \(method) \(url.absoluteString)")
            logger.debug("Error: \(error.localizedDescription)")
            return nil
        }
    }

    func performRequest(_ request: URLRequest, label: String) -> RequestResult {
        let waitTimeout = max(self.timeout + 5, 10)
        let semaphore = DispatchSemaphore(value: 0)
        var responseData: Data?
        var httpResponse: HTTPURLResponse?
        var requestError: Error?
        let dataRequest = self.alamofireSession.request(request).responseData(queue: self.networkResponseQueue) { response in
            responseData = response.data
            httpResponse = response.response
            requestError = response.error
            semaphore.signal()
        }
        dataRequest.resume()

        if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
            dataRequest.cancel()
            logger.error("\(label) timed out after \(Int(waitTimeout))s")
            return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: true)
        }

        return RequestResult(data: responseData, response: httpResponse, error: requestError, timedOut: false)
    }

    private func performDownloadRequest(_ request: URLRequest, label: String) -> DownloadRequestResult {
        let waitTimeout = max(self.timeout + 5, 10)
        let semaphore = DispatchSemaphore(value: 0)
        var tempFileURL: URL?
        var httpResponse: HTTPURLResponse?
        var requestError: Error?
        let temporaryDownloadURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        let destination: DownloadRequest.Destination = { _, _ in
            (temporaryDownloadURL, [.removePreviousFile, .createIntermediateDirectories])
        }
        let downloadRequest = self.alamofireSession.download(request, to: destination).response(queue: self.networkResponseQueue) { response in
            tempFileURL = response.fileURL
            httpResponse = response.response
            requestError = response.error
            semaphore.signal()
        }
        downloadRequest.resume()

        if semaphore.wait(timeout: .now() + waitTimeout) == .timedOut {
            downloadRequest.cancel()
            logger.error("\(label) timed out after \(Int(waitTimeout))s")
            return DownloadRequestResult(
                fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
                response: httpResponse,
                error: requestError,
                timedOut: true
            )
        }

        if isTimedOutError(requestError) {
            logger.error("\(label) timed out after \(Int(waitTimeout))s")
        }

        return DownloadRequestResult(
            fileURL: existingDownloadFileURL(tempFileURL, fallback: temporaryDownloadURL),
            response: httpResponse,
            error: requestError,
            timedOut: isTimedOutError(requestError)
        )
    }

    private func existingDownloadFileURL(_ fileURL: URL?, fallback: URL) -> URL? {
        let fileManager = FileManager.default
        if let fileURL, fileManager.fileExists(atPath: fileURL.path) {
            return fileURL
        }
        return fileManager.fileExists(atPath: fallback.path) ? fallback : nil
    }

    private func storeDownloadedFile(_ downloadedFileURL: URL, at tempPath: URL, existingBytes: Int64, response: HTTPURLResponse?) throws {
        let fileManager = FileManager.default
        if existingBytes > 0 && (response?.statusCode == 206 || response == nil) {
            let resumedData = try Data(contentsOf: downloadedFileURL)
            let fileHandle = try FileHandle(forWritingTo: tempPath)
            fileHandle.seek(toFileOffset: UInt64(existingBytes))
            fileHandle.write(resumedData)
            try fileHandle.close()
            try? fileManager.removeItem(at: downloadedFileURL)
            return
        }

        if fileManager.fileExists(atPath: tempPath.path) {
            try fileManager.removeItem(at: tempPath)
        }
        try fileManager.moveItem(at: downloadedFileURL, to: tempPath)
    }

    private func persistPartialDownload(_ downloadResult: DownloadRequestResult, id: String, tempPath: URL, existingBytes: Int64) {
        guard let downloadedFileURL = downloadResult.fileURL else {
            return
        }
        guard FileManager.default.fileExists(atPath: downloadedFileURL.path) else {
            return
        }
        if let statusCode = downloadResult.response?.statusCode, statusCode < 200 || statusCode >= 300 {
            return
        }

        do {
            try storeDownloadedFile(downloadedFileURL, at: tempPath, existingBytes: existingBytes, response: downloadResult.response)
            logger.info("Stored partial download for retry")
        } catch {
            logger.error("Failed to store partial download")
            logger.debug("Path: \(downloadedFileURL.path), Error: \(error)")
        }
    }

    deinit {
        // Invalidate the stats timer to prevent memory leaks
        statsFlushTimer?.invalidate()
        statsFlushTimer = nil

        // Flush any remaining stats before deallocation
        flushStatsQueue()
    }

    private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
        return (percent * (max - min)) / 100 + min
    }

    private func randomString(length: Int) -> String {
        let letters: String = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
        return String((0..<length).map { _ in letters.randomElement()! })
    }

    public func setPublicKey(_ publicKey: String) {
        // Empty string means no encryption - proceed normally
        if publicKey.isEmpty {
            self.publicKey = ""
            self.cachedKeyId = nil
            return
        }

        // Non-empty: must be a valid RSA key or crash
        guard RSAPublicKey.load(rsaPublicKey: publicKey) != nil else {
            fatalError("Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.")
        }

        self.publicKey = publicKey
        self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
    }

    public func getKeyId() -> String? {
        return self.cachedKeyId
    }

    private var isDevEnvironment: Bool {
        #if DEBUG
        return true
        #else
        return false
        #endif
    }

    private func isProd() -> Bool {
        return !self.isDevEnvironment && !self.isAppStoreReceiptSandbox() && !self.hasEmbeddedMobileProvision()
    }

    /**
     * Checks if there is sufficient disk space for a download.
     * Matches Android behavior: 2x safety margin, throws "insufficient_disk_space"
     * - Parameter estimatedSize: The estimated size of the download in bytes. Defaults to 50MB.
     */
    private func checkDiskSpace(estimatedSize: Int64 = 50 * 1024 * 1024) throws {
        let fileManager = FileManager.default
        guard let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
            return
        }

        do {
            let attributes = try fileManager.attributesOfFileSystem(forPath: documentDirectory.path)
            guard let freeSpace = attributes[.systemFreeSize] as? Int64 else {
                logger.warn("Could not determine free disk space, proceeding with download")
                return
            }

            let requiredSpace = estimatedSize * 2 // 2x safety margin like Android

            if freeSpace < requiredSpace {
                logger.error("Insufficient disk space. Available: \(freeSpace), Required: \(requiredSpace)")
                self.sendStats(action: "insufficient_disk_space")
                throw CustomError.insufficientDiskSpace
            }
        } catch let error as CustomError {
            throw error
        } catch {
            logger.warn("Error checking disk space: \(error.localizedDescription)")
        }
    }

    /**
     * Check if a 429 (Too Many Requests) response was received and set the flag
     */
    private func checkAndHandleRateLimitResponse(statusCode: Int?) -> Bool {
        if statusCode == 429 {
            // Send a statistic about the rate limit BEFORE setting the flag
            // Only send once to prevent infinite loop if the stat request itself gets rate limited
            if !previewSession && !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
                CapgoUpdater.rateLimitStatisticSent = true

                // Dispatch to background queue to avoid blocking the main thread
                DispatchQueue.global(qos: .utility).async {
                    self.sendRateLimitStatistic()
                }
            }
            CapgoUpdater.rateLimitExceeded = true
            logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.")
            return true
        }
        return false
    }

    /**
     * Send a synchronous statistic about rate limiting
     * Note: This method uses a semaphore to block until the request completes.
     * It MUST be called from a background queue to avoid blocking the main thread.
     */
    private func sendRateLimitStatistic() {
        guard !statsUrl.isEmpty else {
            return
        }

        let current = getCurrentBundle()
        var parameters = createInfoObject()
        parameters.action = "rate_limit_reached"
        parameters.version_name = current.getVersionName()
        parameters.old_version_name = ""

        // Send synchronously using semaphore (safe because we're on a background queue)
        let semaphore = DispatchSemaphore(value: 0)
        self.alamofireSession.request(
            self.statsUrl,
            method: .post,
            parameters: parameters.toParameters(),
            encoding: JSONEncoding.default,
            requestModifier: { $0.timeoutInterval = self.timeout }
        ).responseData { response in
            switch response.result {
            case .success:
                self.logger.info("Rate limit statistic sent")
            case let .failure(error):
                self.logger.error("Error sending rate limit statistic")
                self.logger.debug("Error: \(error.localizedDescription)")
            }
            semaphore.signal()
        }
        semaphore.wait()
    }

    // MARK: Private
    private func hasEmbeddedMobileProvision() -> Bool {
        guard Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") == nil else {
            return true
        }
        return false
    }

    private func isAppStoreReceiptSandbox() -> Bool {

        if isEmulator() {
            return false
        } else {
            guard let url: URL = Bundle.main.appStoreReceiptURL else {
                return false
            }
            guard url.lastPathComponent == "sandboxReceipt" else {
                return false
            }
            return true
        }
    }

    private func isEmulator() -> Bool {
        #if targetEnvironment(simulator)
        return true
        #else
        return false
        #endif
    }
    // Persistent path /var/mobile/Containers/Data/Application/8C0C07BE-0FD3-4FD4-B7DF-90A88E12B8C3/Library/NoCloud/ionic_built_snapshots/FOLDER
    // Hot Reload path /var/mobile/Containers/Data/Application/8C0C07BE-0FD3-4FD4-B7DF-90A88E12B8C3/Documents/FOLDER
    // Normal /private/var/containers/Bundle/Application/8C0C07BE-0FD3-4FD4-B7DF-90A88E12B8C3/App.app/public

    private func prepareFolder(source: URL) throws {
        if !FileManager.default.fileExists(atPath: source.path) {
            do {
                try FileManager.default.createDirectory(atPath: source.path, withIntermediateDirectories: true, attributes: nil)
            } catch {
                logger.error("Cannot create directory")
                logger.debug("Directory path: \(source.path)")
                throw CustomError.cannotCreateDirectory
            }
        }
    }

    private func deleteFolder(source: URL) throws {
        do {
            try FileManager.default.removeItem(atPath: source.path)
        } catch {
            logger.error("File not removed")
            logger.debug("Path: \(source.path)")
            throw CustomError.cannotDeleteDirectory
        }
    }

    private func unflatFolder(source: URL, dest: URL) throws -> Bool {
        let index: URL = source.appendingPathComponent("index.html")
        do {
            let files: [String] = try FileManager.default.contentsOfDirectory(atPath: source.path)
            if files.count == 1 && source.appendingPathComponent(files[0]).isDirectory && !FileManager.default.fileExists(atPath: index.path) {
                try FileManager.default.moveItem(at: source.appendingPathComponent(files[0]), to: dest)
                return true
            } else {
                try FileManager.default.moveItem(at: source, to: dest)
                return false
            }
        } catch {
            logger.error("File not moved")
            logger.debug("Source: \(source.path), Dest: \(dest.path)")
            throw CustomError.cannotUnflat
        }
    }

    private func resolveZipEntry(path: String, destUnZip: URL) throws -> URL {
        do {
            return try Self.resolvePathInsideDirectory(baseDirectory: destUnZip, relativePath: path)
        } catch SecurePathError.windowsPath {
            logger.error("Unzip failed: Windows path not supported")
            logger.debug("Invalid path: \(path)")
            self.sendStats(action: "windows_path_fail")
            throw CustomError.cannotUnzip
        } catch {
            self.sendStats(action: "canonical_path_fail")
            throw CustomError.cannotUnzip
        }
    }

    private func extractZipEntry(_ archive: Archive, entry: Entry, to destPath: URL) throws {
        let fileManager = FileManager.default

        switch entry.type {
        case .directory:
            try fileManager.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
        case .file:
            let parentDir = destPath.deletingLastPathComponent()
            try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)

            if fileManager.fileExists(atPath: destPath.path) {
                try fileManager.removeItem(at: destPath)
            }

            guard fileManager.createFile(atPath: destPath.path, contents: nil) else {
                throw CustomError.cannotUnzip
            }

            let fileHandle = try FileHandle(forWritingTo: destPath)
            defer {
                fileHandle.closeFile()
            }

            _ = try archive.extract(entry, bufferSize: 16 * 1024, skipCRC32: true) { data in
                if !data.isEmpty {
                    fileHandle.write(data)
                }
            }
        case .symlink:
            var linkData = Data()
            _ = try archive.extract(entry, bufferSize: 16 * 1024, skipCRC32: true) { data in
                linkData.append(data)
            }

            guard let linkPath = String(data: linkData, encoding: .utf8) else {
                throw CustomError.cannotUnzip
            }

            let parentDir = destPath.deletingLastPathComponent()
            try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)

            let isAbsolutePath = (linkPath as NSString).isAbsolutePath
            let linkURL = URL(fileURLWithPath: linkPath, relativeTo: isAbsolutePath ? nil : parentDir)
            let canonicalPath = linkURL.standardizedFileURL.path
            let canonicalDir = parentDir.standardizedFileURL.path
            let normalizedDir = canonicalDir.hasSuffix("/") ? canonicalDir : "\(canonicalDir)/"

            if canonicalPath != canonicalDir && !canonicalPath.hasPrefix(normalizedDir) {
                throw CustomError.cannotUnzip
            }

            if fileManager.fileExists(atPath: destPath.path) {
                try fileManager.removeItem(at: destPath)
            }

            try fileManager.createSymbolicLink(atPath: destPath.path, withDestinationPath: linkPath)
        }
    }

    private func saveDownloaded(sourceZip: URL, id: String, base: URL, notify: Bool) throws {
        try prepareFolder(source: base)
        let destPersist: URL = base.appendingPathComponent(id)
        let destUnZip: URL = libraryDir.appendingPathComponent(TEMP_UNZIP_PREFIX + randomString(length: 10))

        self.unzipPercent = 0
        self.notifyDownload(id: id, percent: 75)

        // Open the archive
        let archive: Archive
        do {
            archive = try Archive(url: sourceZip, accessMode: .read)
        } catch {
            self.sendStats(action: "unzip_fail")
            throw CustomError.cannotUnzip
        }

        // Create destination directory
        try FileManager.default.createDirectory(at: destUnZip, withIntermediateDirectories: true, attributes: nil)

        // Count total entries for progress
        let totalEntries = archive.reduce(0) { count, _ in count + 1 }
        var processedEntries = 0

        do {
            for entry in archive {
                let destPath = try resolveZipEntry(path: entry.path, destUnZip: destUnZip)

                if entry.type == .directory {
                    try FileManager.default.createDirectory(at: destPath, withIntermediateDirectories: true, attributes: nil)
                    processedEntries += 1
                    if notify && totalEntries > 0 {
                        let newPercent = self.calcTotalPercent(percent: Int(Double(processedEntries) / Double(totalEntries) * 100), min: 75, max: 81)
                        if newPercent != self.unzipPercent {
                            self.unzipPercent = newPercent
                            self.notifyDownload(id: id, percent: newPercent)
                        }
                    }
                    continue
                }

                // Create parent directories if needed
                let parentDir = destPath.deletingLastPathComponent()
                if !FileManager.default.fileExists(atPath: parentDir.path) {
                    try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil)
                }

                try self.extractZipEntry(archive, entry: entry, to: destPath)

                // Update progress
                processedEntries += 1
                if notify && totalEntries > 0 {
                    let newPercent = self.calcTotalPercent(percent: Int(Double(processedEntries) / Double(totalEntries) * 100), min: 75, max: 81)
                    if newPercent != self.unzipPercent {
                        self.unzipPercent = newPercent
                        self.notifyDownload(id: id, percent: newPercent)
                    }
                }
            }
        } catch {
            self.sendStats(action: "unzip_fail")
            try? FileManager.default.removeItem(at: destUnZip)
            throw error
        }

        if try unflatFolder(source: destUnZip, dest: destPersist) {
            try deleteFolder(source: destUnZip)
        }

        // Cleanup: remove the downloaded/decrypted zip after successful extraction
        do {
            if FileManager.default.fileExists(atPath: sourceZip.path) {
                try FileManager.default.removeItem(at: sourceZip)
            }
        } catch {
            logger.error("Could not delete source zip")
            logger.debug("Path: \(sourceZip.path), Error: \(error)")
        }
    }

    private func populateDeltaCacheAsync(for id: String) {
        DispatchQueue.global(qos: .utility).async { [weak self] in
            self?.populateDeltaCache(for: id)
        }
    }

    private func populateDeltaCache(for id: String) {
        let bundleDir = self.getBundleDirectory(id: id)
        let fileManager = FileManager.default

        guard fileManager.fileExists(atPath: bundleDir.path) else {
            logger.debug("Skip delta cache population: bundle dir missing")
            return
        }

        do {
            try fileManager.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
        } catch {
            logger.debug("Skip delta cache population: failed to create cache dir")
            return
        }

        guard let enumerator = fileManager.enumerator(at: bundleDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else {
            return
        }

        for case let fileURL as URL in enumerator {
            let resourceValues = try? fileURL.resourceValues(forKeys: [.isDirectoryKey])
            if resourceValues?.isDirectory == true {
                continue
            }

            let checksum = CryptoCipher.calcChecksum(filePath: fileURL)
            if checksum.isEmpty {
                continue
            }

            let cacheFile = cacheFolder.appendingPathComponent("\(checksum)_\(fileURL.lastPathComponent)")
            if fileManager.fileExists(atPath: cacheFile.path) {
                continue
            }

            do {
                try fileManager.copyItem(at: fileURL, to: cacheFile)
            } catch {
                logger.debug("Delta cache copy failed: \(fileURL.path)")
            }
        }
    }

    private func createInfoObject(appIdOverride: String? = nil) -> InfoObject {
        return InfoObject(
            platform: "ios",
            device_id: self.deviceID,
            app_id: appIdOverride ?? self.appId,
            custom_id: self.customId,
            version_build: self.versionBuild,
            version_code: self.versionCode,
            version_os: self.versionOs,
            version_name: self.getCurrentBundle().getVersionName(),
            plugin_version: self.pluginVersion,
            is_emulator: self.isEmulator(),
            is_prod: self.isProd(),
            action: nil,
            channel: nil,
            defaultChannel: self.defaultChannel,
            key_id: self.cachedKeyId
        )
    }

    public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
        let latest: AppVersion = AppVersion()
        func applyLatestResponse(_ value: AppVersionDec?) {
            if let url = value?.url {
                latest.url = url
            }
            if let checksum = value?.checksum {
                latest.checksum = checksum
            }
            if let version = value?.version {
                latest.version = version
            }
            if let major = value?.major {
                latest.major = major
            }
            if let breaking = value?.breaking {
                latest.breaking = breaking
            }
            if let error = value?.error {
                latest.error = error
            }
            if let kind = value?.kind {
                latest.kind = kind
            }
            if let message = value?.message {
                latest.message = message
            }
            if let sessionKey = value?.session_key {
                latest.sessionKey = sessionKey
            }
            if let data = value?.data {
                latest.data = data
            }
            if let manifest = value?.manifest {
                latest.manifest = manifest
            }
            if let link = value?.link {
                latest.link = link
            }
            if let comment = value?.comment {
                latest.comment = comment
            }
        }

        var parameters: InfoObject = self.createInfoObject(appIdOverride: appIdOverride)
        if let channel = channel {
            parameters.defaultChannel = channel
        }
        guard let request = createRequest(url: url, method: "POST", parameters: parameters.toParameters()) else {
            latest.message = "Error getting Latest"
            latest.error = "request_error"
            latest.kind = "failed"
            return latest
        }

        let result = performRequest(request, label: "getLatest")
        latest.statusCode = result.response?.statusCode ?? 0

        if result.timedOut {
            latest.message = "Error getting Latest"
            latest.error = "timeout_error"
            latest.kind = "failed"
            return latest
        }

        if let error = result.error {
            self.logger.error("Error getting latest version")
            self.logger.debug("Error: \(error.localizedDescription)")
            latest.message = "Error getting Latest"
            latest.error = "response_error"
            latest.kind = "failed"
            return latest
        }

        guard let data = result.data else {
            self.logger.error("Missing latest version response data")
            latest.message = "Error getting Latest"
            latest.error = "response_error"
            latest.kind = "failed"
            return latest
        }

        if self.checkAndHandleRateLimitResponse(statusCode: latest.statusCode) {
            latest.message = "Rate limit exceeded"
            latest.error = "rate_limit_exceeded"
            latest.kind = "failed"
            return latest
        }

        guard let responseValue = try? JSONDecoder().decode(AppVersionDec.self, from: data) else {
            self.logger.error("Error decoding latest version")
            latest.message = "Error getting Latest"
            latest.error = "decode_error"
            latest.kind = "failed"
            return latest
        }

        applyLatestResponse(responseValue)

        if latest.statusCode < 200 || latest.statusCode >= 300 {
            if latest.message == nil || latest.message?.isEmpty == true {
                latest.message = responseValue.message ?? "Server error: \(latest.statusCode)"
            }
            if latest.error == nil || latest.error?.isEmpty == true {
                latest.error = responseValue.error ?? "response_error"
            }
            if latest.kind == nil || latest.kind?.isEmpty == true {
                latest.kind = responseValue.kind ?? "failed"
            }
            return latest
        }

        return latest
    }

    private func setCurrentBundle(bundle: String) {
        UserDefaults.standard.set(bundle, forKey: self.CAP_SERVER_PATH)
        UserDefaults.standard.synchronize()
        logger.info("Current bundle set to: \((bundle ).isEmpty ? BundleInfo.ID_BUILTIN : bundle)")
    }

    static func shouldResetForForeignBundle(bundlePath: String?, isBuiltin: Bool, hasStoredBundleInfo: Bool) -> Bool {
        guard let bundlePath, !bundlePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
            return false
        }
        return !isBuiltin && !hasStoredBundleInfo
    }

    private func hasStoredBundleInfo(id: String) -> Bool {
        guard !id.isEmpty,
              id != BundleInfo.ID_BUILTIN,
              id != BundleInfo.VERSION_UNKNOWN else {
            return false
        }
        return UserDefaults.standard.object(forKey: "\(id)\(self.INFO_SUFFIX)") != nil
    }

    // Per-download temp file paths to prevent collisions when multiple downloads run concurrently
    private func tempDataPath(for id: String) -> URL {
        return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("package_\(id).tmp")
    }

    private func updateInfoPath(for id: String) -> URL {
        return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("update_\(id).dat")
    }

    private var tempData = Data()

    private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
        let actualHash =    CryptoCipher.calcChecksum(filePath: file)
        return actualHash == expectedHash
    }

    private func resolveManifestFileHash(entry: ManifestEntry, sessionKey: String) -> String? {
        guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
            return nil
        }
        if !self.publicKey.isEmpty && !sessionKey.isEmpty {
            do {
                fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
            } catch {
                logger.error("Checksum decryption failed while checking missing manifest files")
                logger.debug("File: \(entry.file_name ?? "unknown"), Error: \(error.localizedDescription)")
                return nil
            }
        }
        return fileHash
    }

    private func isManifestEntryAvailableLocally(entry: ManifestEntry, sessionKey: String) -> Bool {
        guard let fileName = entry.file_name,
              let fileHash = resolveManifestFileHash(entry: entry, sessionKey: sessionKey) else {
            return false
        }

        let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
        let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
        if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
            return true
        }

        let fileNameWithoutPath = (fileName as NSString).lastPathComponent
        let isBrotli = fileName.hasSuffix(".br")
        let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
        let cacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(cacheBaseName)")
        if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
            return true
        }

        if isBrotli {
            let legacyCacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(fileNameWithoutPath)")
            if FileManager.default.fileExists(atPath: legacyCacheFilePath.path) && verifyChecksum(file: legacyCacheFilePath, expectedHash: fileHash) {
                return true
            }
        }

        return false
    }

    public func getMissingBundleFiles(manifest: [ManifestEntry], sessionKey: String) -> [ManifestEntry] {
        return manifest.filter { entry in
            !isManifestEntryAvailableLocally(entry: entry, sessionKey: sessionKey)
        }
    }

    public func missingBundleFilesResult(manifest: [ManifestEntry], sessionKey: String) -> [String: Any] {
        let missing = getMissingBundleFiles(manifest: manifest, sessionKey: sessionKey)
        return [
            "missing": missing.map { $0.toDict() },
            "total": manifest.count,
            "missingCount": missing.count,
            "reusableCount": manifest.count - missing.count
        ]
    }

    private func manifestSizeUrl(from updateUrl: URL) -> URL {
        var components = URLComponents(url: updateUrl, resolvingAgainstBaseURL: false)
        let path = components?.path ?? updateUrl.path
        let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
        components?.path = trimmedPath == ""
            ? "/manifest_size"
            : "/\(trimmedPath)/manifest_size"
        components?.query = nil
        return components?.url ?? updateUrl.appendingPathComponent("manifest_size")
    }

    private func unavailableBundleSizeResult(manifest: [ManifestEntry], error: String) -> [String: Any] {
        return [
            "totalSize": 0,
            "knownFiles": 0,
            "unknownFiles": manifest.count,
            "files": manifest.map {
                var dict = $0.toDict()
                dict["error"] = error
                return dict
            }
        ]
    }

    public func getBundleDownloadSize(updateUrl: URL, version: String?, manifest: [ManifestEntry]) -> [String: Any] {
        if manifest.isEmpty {
            return [
                "totalSize": 0,
                "knownFiles": 0,
                "unknownFiles": 0,
                "files": []
            ]
        }

        var parameters = self.createInfoObject().toParameters()
        parameters["version"] = version ?? ""
        parameters["manifest"] = manifest.map { $0.toDict() }

        guard let request = createRequest(url: manifestSizeUrl(from: updateUrl), method: "POST", parameters: parameters) else {
            return unavailableBundleSizeResult(manifest: manifest, error: "request_error")
        }

        let result = performRequest(request, label: "getBundleDownloadSize")
        if result.timedOut {
            return unavailableBundleSizeResult(manifest: manifest, error: "timeout_error")
        }
        if let error = result.error {
            logger.error("Error getting bundle download size")
            logger.debug("Error: \(error.localizedDescription)")
            return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
        }
        guard let data = result.data,
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            return unavailableBundleSizeResult(manifest: manifest, error: "parse_error")
        }

        let statusCode = result.response?.statusCode ?? 0
        if statusCode < 200 || statusCode >= 300 {
            return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
        }

        return json
    }

    public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
        let id = self.randomString(length: 10)
        logger.info("downloadManifest start \(id)")
        let destFolder = self.getBundleDirectory(id: id)
        let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")

        // Check disk space before starting manifest download (estimate 100KB per file, minimum 50MB)
        let estimatedSize = Int64(max(manifest.count * 100 * 1024, 50 * 1024 * 1024))
        try checkDiskSpace(estimatedSize: estimatedSize)

        try FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
        try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)

        // Create and save BundleInfo before starting the download process
        let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "", link: link, comment: comment)
        self.saveBundleInfo(id: id, bundle: bundleInfo)

        // Send stats for manifest download start
        self.sendStats(action: "download_manifest_start", versionName: version)

        // Notify the start of the download process
        self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)

        let totalFiles = manifest.count

        // Keep this bounded because each manifest operation waits on a URLSession callback.
        manifestDownloadQueue.maxConcurrentOperationCount = min(8, max(1, totalFiles))

        // Thread-safe counters for concurrent operations
        let completedFiles = AtomicCounter()
        let hasError = AtomicBool(initialValue: false)
        var downloadError: Error?
        let errorLock = NSLock()

        // Create operations for each file
        var operations: [Operation] = []

        for entry in manifest {
            guard let fileName = entry.file_name,
                  let downloadUrl = entry.download_url else {
                let error = NSError(
                    domain: "ManifestEntryError",
                    code: 1,
                    userInfo: [
                        NSLocalizedDescriptionKey: "Manifest entry is missing file_name or download_url"
                    ]
                )
                errorLock.lock()
                if downloadError == nil {
                    downloadError = error
                }
                errorLock.unlock()
                hasError.value = true
                logger.error("Manifest entry is missing file_name or download_url")
                continue
            }
            guard let entryFileHash = entry.file_hash, !entryFileHash.isEmpty else {
                logger.error("Missing file_hash for manifest entry: \(entry.file_name ?? "unknown")")
                let error = NSError(
                    domain: "ManifestEntryError",
                    code: 2,
                    userInfo: [
                        NSLocalizedDescriptionKey: "Manifest entry is missing file_hash for \(entry.file_name ?? "unknown")"
                    ]
                )
                errorLock.lock()
                if downloadError == nil {
                    downloadError = error
                }
                errorLock.unlock()
                hasError.value = true
                continue
            }
            var fileHash = entryFileHash

            // Decrypt checksum if needed (done before creating operation)
            if !self.publicKey.isEmpty && !sessionKey.isEmpty {
                do {
                    fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
                } catch {
                    errorLock.lock()
                    downloadError = error
                    errorLock.unlock()
                    hasError.value = true
                    logger.error("Checksum decryption failed")
                    logger.debug("Bundle: \(id), File: \(fileName), Error: \(error)")
                    continue
                }
            }

            let finalFileHash = fileHash
            let fileNameWithoutPath = (fileName as NSString).lastPathComponent
            let isBrotli = fileName.hasSuffix(".br")
            let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
            let cacheFilePath = cacheFolder.appendingPathComponent("\(finalFileHash)_\(cacheBaseName)")
            let legacyCacheFilePath: URL? = isBrotli ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)") : nil

            let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
            let destFilePath: URL
            let builtinFilePath: URL
            do {
                destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
                builtinFilePath = try Self.resolvePathInsideDirectory(baseDirectory: builtinFolder, relativePath: fileName)
            } catch {
                logger.error("Invalid manifest file path: \(fileName)")
                self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
                errorLock.lock()
                if downloadError == nil {
                    downloadError = error
                }
                errorLock.unlock()
                hasError.value = true
                continue
            }

            // Create parent directories synchronously (before operations start)
            try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)

            let operation = BlockOperation { [weak self] in
                guard let self = self else { return }
                guard !hasError.value else { return } // Skip if error already occurred

                do {
                    // Try builtin first
                    if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
                        try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
                        self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
                    }
                    // Try cache
                    else if
                        self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: finalFileHash) ||
                            (legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
                        self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
                    }
                    // Download
                    else {
                        try self.downloadManifestFile(
                            downloadUrl: downloadUrl,
                            destFilePath: destFilePath,
                            cacheFilePath: cacheFilePath,
                            fileHash: finalFileHash,
                            fileName: fileName,
                            destFileName: destFileName,
                            isBrotli: isBrotli,
                            sessionKey: sessionKey,
                            version: version,
                            bundleId: id
                        )
                    }

                    let completed = completedFiles.increment()
                    let percent = self.calcTotalPercent(percent: Int((Double(completed) / Double(totalFiles)) * 100), min: 10, max: 70)
                    self.notifyDownload(id: id, percent: percent)

                } catch {
                    errorLock.lock()
                    if downloadError == nil {
                        downloadError = error
                    }
                    errorLock.unlock()
                    hasError.value = true
                    self.logger.error("Manifest file download failed: \(fileName)")
                    self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription)")
                }
            }

            operations.append(operation)
        }

        // Execute all operations concurrently and wait for completion
        manifestDownloadQueue.addOperations(operations, waitUntilFinished: true)

        if hasError.value {
            let resolvedError = downloadError ?? NSError(
                domain: "ManifestDownloadError",
                code: 1,
                userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
            )
            // Update bundle status to ERROR if download failed
            let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
            self.saveBundleInfo(id: id, bundle: errorBundle)
            throw resolvedError
        }

        // Update bundle status to PENDING after successful download
        let updatedBundle = bundleInfo.setStatus(status: BundleStatus.PENDING.storedValue)
        self.saveBundleInfo(id: id, bundle: updatedBundle)

        // Send stats for manifest download complete
        self.sendStats(action: "download_manifest_complete", versionName: version)

        self.notifyDownload(id: id, percent: 100, bundle: updatedBundle)
        logger.info("downloadManifest done \(id)")
        return updatedBundle
    }

    /// Downloads a single manifest file synchronously
    /// Used by downloadManifest for concurrent file downloads
    private func downloadManifestFile(
        downloadUrl: String,
        destFilePath: URL,
        cacheFilePath: URL,
        fileHash: String,
        fileName: String,
        destFileName: String,
        isBrotli: Bool,
        sessionKey: String,
        version: String,
        bundleId: String
    ) throws {
        guard let url = URL(string: downloadUrl) else {
            throw NSError(
                domain: "ManifestDownloadError",
                code: 1,
                userInfo: [NSLocalizedDescriptionKey: "Invalid manifest download URL for file \(fileName): \(downloadUrl)"]
            )
        }

        guard let request = createRequest(url: url, method: "GET") else {
            throw NSError(
                domain: "ManifestDownloadError",
                code: 2,
                userInfo: [NSLocalizedDescriptionKey: "Invalid manifest request for file \(fileName): \(downloadUrl)"]
            )
        }

        let result = performRequest(request, label: "downloadManifestFile \(fileName)")

        if result.timedOut {
            self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
            throw NSError(
                domain: NSURLErrorDomain,
                code: NSURLErrorTimedOut,
                userInfo: [NSLocalizedDescriptionKey: "Timed out downloading manifest file \(fileName) at url \(downloadUrl)"]
            )
        }

        if let error = result.error {
            self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
            self.logger.error("Manifest file download network error")
            self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
            throw error
        }

        guard let data = result.data else {
            self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
            throw NSError(
                domain: "ManifestDownloadError",
                code: 3,
                userInfo: [NSLocalizedDescriptionKey: "Manifest file response was empty for \(fileName) at url \(downloadUrl)"]
            )
        }

        let statusCode = result.response?.statusCode ?? 200
        if statusCode < 200 || statusCode >= 300 {
            self.sendStats(action: "download_manifest_file_fail", versionName: "\(version):\(fileName)")
            if let stringData = String(data: data, encoding: .utf8) {
                throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid. Data: \(stringData) for file \(fileName) at url \(downloadUrl)"])
            } else {
                throw NSError(domain: "StatusCodeError", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch. Status code (\(statusCode)) invalid for file \(fileName) at url \(downloadUrl)"])
            }
        }

        do {
            // Add decryption step if public key is set and sessionKey is provided
            var finalData = data
            if !self.publicKey.isEmpty && !sessionKey.isEmpty {
                let tempFile = self.cacheFolder.appendingPathComponent("temp_\(UUID().uuidString)")
                try finalData.write(to: tempFile)
                do {
                    try CryptoCipher.decryptFile(filePath: tempFile, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
                } catch {
                    self.sendStats(action: "decrypt_fail", versionName: version)
                    throw error
                }
                finalData = try Data(contentsOf: tempFile)
                try FileManager.default.removeItem(at: tempFile)
            }

            // Decompress Brotli if needed
            if isBrotli {
                guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
                    self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
                    throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
                }
                finalData = decompressedData
            }

            // Write to destination
            try finalData.write(to: destFilePath)

            // Always verify checksum when file_hash is present
            let calculatedChecksum = CryptoCipher.calcChecksum(filePath: destFilePath)
            CryptoCipher.logChecksumInfo(label: "Calculated checksum", hexChecksum: calculatedChecksum)
            CryptoCipher.logChecksumInfo(label: "Expected checksum", hexChecksum: fileHash)
            if calculatedChecksum != fileHash {
                try? FileManager.default.removeItem(at: destFilePath)
                self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
                throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
            }

            // Save to cache
            try finalData.write(to: cacheFilePath)

            self.logger.info("Manifest file downloaded and cached")
            self.logger.debug("Bundle: \(bundleId), File: \(fileName), Brotli: \(isBrotli), Encrypted: \(!self.publicKey.isEmpty && !sessionKey.isEmpty)")
        } catch {
            self.logger.error("Manifest file download failed")
            self.logger.debug("Bundle: \(bundleId), File: \(fileName), Error: \(error.localizedDescription)")
            throw error
        }
    }

    /// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
    /// This handles the race condition where OS can delete cache files between exists() check and copy
    private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
        // First quick check - if file doesn't exist, don't bother
        guard FileManager.default.fileExists(atPath: source.path) else {
            return false
        }

        // Verify checksum before copy
        guard verifyChecksum(file: source, expectedHash: expectedHash) else {
            return false
        }

        // Try to copy - if it fails (file deleted by OS between check and copy), return false
        do {
            try FileManager.default.copyItem(at: source, to: destination)
            return true
        } catch {
            // File was deleted between check and copy, or other IO error - caller should download instead
            logger.debug("Cache copy failed (likely OS eviction): \(error.localizedDescription)")
            return false
        }
    }

    private func decompressBrotli(data: Data, fileName: String) -> Data? {
        // Handle empty files
        if data.count == 0 {
            return data
        }

        // Handle the special EMPTY_BROTLI_STREAM case
        if data.count == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 {
            return Data()
        }

        // For small files, check if it's a minimal Brotli wrapper
        if data.count > 3 {
            let maxBytes = min(32, data.count)
            let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
            // Handle our minimal wrapper pattern
            if data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data.last == 0x03 {
                let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
                return data[range]
            }

            // Handle brotli.compress minimal wrapper (quality 0)
            if data[0] == 0x0b && data[1] == 0x02 && data[2] == 0x80 && data.last == 0x03 {
                let range = data.index(data.startIndex, offsetBy: 3)..<data.index(data.endIndex, offsetBy: -1)
                return data[range]
            }
        }

        // For all other cases, try standard decompression
        let outputBufferSize = 65536
        var outputBuffer = [UInt8](repeating: 0, count: outputBufferSize)
        var decompressedData = Data()

        let streamPointer = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
        var status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_BROTLI)

        guard status != COMPRESSION_STATUS_ERROR else {
            logger.error("Failed to initialize Brotli stream")
            logger.debug("File: \(fileName), Status: \(status)")
            return nil
        }

        defer {
            compression_stream_destroy(streamPointer)
            streamPointer.deallocate()
        }

        streamPointer.pointee.src_size = 0
        streamPointer.pointee.dst_ptr = UnsafeMutablePointer<UInt8>(&outputBuffer)
        streamPointer.pointee.dst_size = outputBufferSize

        let input = data

        while true {
            if streamPointer.pointee.src_size == 0 {
                streamPointer.pointee.src_size = input.count
                input.withUnsafeBytes { rawBufferPointer in
                    if let baseAddress = rawBufferPointer.baseAddress {
                        streamPointer.pointee.src_ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
                    } else {
                        logger.error("Failed to get base address for Brotli decompression")
                        logger.debug("File: \(fileName)")
                        status = COMPRESSION_STATUS_ERROR
                        return
                    }
                }
            }

            if status == COMPRESSION_STATUS_ERROR {
                let maxBytes = min(32, data.count)
                let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
                logger.error("Brotli decompression failed")
                logger.debug("File: \(fileName), First \(maxBytes) bytes: \(hexDump)")
                break
            }

            status = compression_stream_process(streamPointer, 0)

            let have = outputBufferSize - streamPointer.pointee.dst_size
            if have > 0 {
                decompressedData.append(outputBuffer, count: have)
            }

            if status == COMPRESSION_STATUS_END {
                break
            } else if status == COMPRESSION_STATUS_ERROR {
                logger.error("Brotli process failed")
                logger.debug("File: \(fileName), Status: \(status)")
                if let text = String(data: data, encoding: .utf8) {
                    let asciiCount = text.unicodeScalars.filter { $0.isASCII }.count
                    let totalCount = text.unicodeScalars.count
                    if totalCount > 0 && Double(asciiCount) / Double(totalCount) >= 0.8 {
                        logger.debug("Input appears to be plain text: \(text)")
                    }
                }

                let maxBytes = min(32, data.count)
                let hexDump = data.prefix(maxBytes).map { String(format: "%02x", $0) }.joined(separator: " ")
                logger.debug("Raw data: \(hexDump)")

                return nil
            }

            if streamPointer.pointee.dst_size == 0 {
                streamPointer.pointee.dst_ptr = UnsafeMutablePointer<UInt8>(&outputBuffer)
                streamPointer.pointee.dst_size = outputBufferSize
            }

            if input.count == 0 {
                logger.error("Zero input size for Brotli decompression")
                logger.debug("File: \(fileName)")
                break
            }
        }

        return status == COMPRESSION_STATUS_END ? decompressedData : nil
    }

    public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
        let id: String = self.randomString(length: 10)
        // Each download uses its own temp files keyed by bundle ID to prevent collisions
        if version != getLocalUpdateVersion(for: id) {
            cleanDownloadData(for: id)
        }
        ensureResumableFilesExist(for: id)
        saveDownloadInfo(version, for: id)

        // Check disk space before starting download (matches Android behavior)
        try checkDiskSpace()

        var checksum = ""
        var lastSentProgress = 0
        let totalReceivedBytes: Int64 = loadDownloadProgress(for: id) // Retrieving the amount of already downloaded data if exist, defined at 0 otherwise
        let tempPath = tempDataPath(for: id)
        let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
        self.saveBundleInfo(id: id, bundle: bundleInfo)

        // Send stats for zip download start
        self.sendStats(action: "download_zip_start", versionName: version)

        // Opening connection for streaming the bytes
        if totalReceivedBytes == 0 {
            self.notifyDownload(id: id, percent: 0, ignoreMultipleOfTen: true)
        }
        var mainError: NSError?

        guard var request = createRequest(url: url, method: "GET") else {
            self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
            throw NSError(
                domain: "DownloadError",
                code: 2,
                userInfo: [NSLocalizedDescriptionKey: "Invalid download request for \(url.absoluteString)"]
            )
        }

        if totalReceivedBytes > 0 {
            request.setValue("bytes=\(totalReceivedBytes)-", forHTTPHeaderField: "Range")
        }

        let downloadResult = performDownloadRequest(request, label: "download \(version)")

        if downloadResult.timedOut {
            persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
            mainError = NSError(
                domain: NSURLErrorDomain,
                code: NSURLErrorTimedOut,
                userInfo: [NSLocalizedDescriptionKey: "Timed out downloading bundle from \(url.absoluteString)"]
            )
        } else if let error = downloadResult.error {
            logger.error("Download failed")
            persistPartialDownload(downloadResult, id: id, tempPath: tempPath, existingBytes: totalReceivedBytes)
            mainError = error as NSError
        } else if let statusCode = downloadResult.response?.statusCode, statusCode < 200 || statusCode >= 300 {
            logger.error("Download failed")
            mainError = NSError(
                domain: "DownloadError",
                code: statusCode,
                userInfo: [NSLocalizedDescriptionKey: "Download request failed with status code \(statusCode)"]
            )
        } else if let downloadedFileURL = downloadResult.fileURL {
            do {
                try storeDownloadedFile(downloadedFileURL, at: tempPath, existingBytes: totalReceivedBytes, response: downloadResult.response)

                if lastSentProgress < 70 {
                    self.notifyDownload(id: id, percent: 70, ignoreMultipleOfTen: true)
                    lastSentProgress = 70
                }
                self.logger.info("Download complete")
            } catch let error as NSError {
                mainError = error
            } catch {
                mainError = error as NSError
            }
        } else {
            mainError = NSError(
                domain: "DownloadError",
                code: 1,
                userInfo: [NSLocalizedDescriptionKey: "Downloaded file is missing at \(tempPath.path)"]
            )
        }

        if mainError != nil {
            logger.error("Failed to download bundle")
            logger.debug("Error: \(String(describing: mainError))")
            self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
            throw mainError!
        }

        let finalPath = tempPath.deletingLastPathComponent().appendingPathComponent("\(id)")
        do {
            try CryptoCipher.decryptFile(filePath: tempPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
            try FileManager.default.moveItem(at: tempPath, to: finalPath)
        } catch {
            logger.error("Failed to decrypt file")
            logger.debug("Error: \(error)")
            self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
            cleanDownloadData(for: id)
            throw error
        }

        do {
            checksum = CryptoCipher.calcChecksum(filePath: finalPath)
            CryptoCipher.logChecksumInfo(label: "Calculated bundle checksum", hexChecksum: checksum)
            logger.info("Downloading: 80% (unzipping)")
            try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
            self.populateDeltaCacheAsync(for: id)

        } catch {
            logger.error("Failed to unzip file")
            logger.debug("Error: \(error)")
            self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
            // Best-effort cleanup of the decrypted zip file when unzip fails
            do {
                if FileManager.default.fileExists(atPath: finalPath.path) {
                    try FileManager.default.removeItem(at: finalPath)
                }
            } catch {
                logger.error("Could not delete failed zip")
                logger.debug("Path: \(finalPath.path), Error: \(error)")
            }
            cleanDownloadData(for: id)
            throw error
        }

        self.notifyDownload(id: id, percent: 90)
        logger.info("Downloading: 90% (wrapping up)")
        let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
        self.saveBundleInfo(id: id, bundle: info)
        self.cleanDownloadData(for: id)

        // Send stats for zip download complete
        self.sendStats(action: "download_zip_complete", versionName: version)

        self.notifyDownload(id: id, percent: 100, bundle: info)
        logger.info("Downloading: 100% (complete)")
        return info
    }
    private func ensureResumableFilesExist(for id: String) {
        let fileManager = FileManager.default
        let tempPath = tempDataPath(for: id)
        let infoPath = updateInfoPath(for: id)
        if !fileManager.fileExists(atPath: tempPath.path) {
            if !fileManager.createFile(atPath: tempPath.path, contents: Data()) {
                logger.error("Cannot ensure temp data file exists")
                logger.debug("Path: \(tempPath.path)")
            }
        }

        if !fileManager.fileExists(atPath: infoPath.path) {
            if !fileManager.createFile(atPath: infoPath.path, contents: Data()) {
                logger.error("Cannot ensure update info file exists")
                logger.debug("Path: \(infoPath.path)")
            }
        }
    }

    private func cleanDownloadData(for id: String) {
        let fileManager = FileManager.default
        let tempPath = tempDataPath(for: id)
        let infoPath = updateInfoPath(for: id)
        // Deleting package_<id>.tmp
        if fileManager.fileExists(atPath: tempPath.path) {
            do {
                try fileManager.removeItem(at: tempPath)
            } catch {
                logger.error("Could not delete temp data file")
                logger.debug("Path: \(tempPath), Error: \(error)")
            }
        }
        // Deleting update_<id>.dat
        if fileManager.fileExists(atPath: infoPath.path) {
            do {
                try fileManager.removeItem(at: infoPath)
            } catch {
                logger.error("Could not delete update info file")
                logger.debug("Path: \(infoPath), Error: \(error)")
            }
        }
    }

    private func savePartialData(startingAt byteOffset: UInt64, for id: String) {
        let fileManager = FileManager.default
        let tempPath = tempDataPath(for: id)
        do {
            // Check if package_<id>.tmp exist
            if !fileManager.fileExists(atPath: tempPath.path) {
                try self.tempData.write(to: tempPath, options: .atomicWrite)
            } else {
                // If yes, it start writing on it
                let fileHandle = try FileHandle(forWritingTo: tempPath)
                fileHandle.seek(toFileOffset: byteOffset) // Moving at the specified position to start writing
                fileHandle.write(self.tempData)
                fileHandle.closeFile()
            }
        } catch {
            logger.error("Failed to write partial data")
            logger.debug("Byte offset: \(byteOffset), Error: \(error)")
        }
        self.tempData.removeAll() // Clearing tempData to avoid writing the same data multiple times
    }

    private func saveDownloadInfo(_ version: String, for id: String) {
        let infoPath = updateInfoPath(for: id)
        do {
            try "\(version)".write(to: infoPath, atomically: true, encoding: .utf8)
        } catch {
            logger.error("Failed to save download progress")
            logger.debug("Error: \(error)")
        }
    }

    private func getLocalUpdateVersion(for id: String) -> String { // Return the version that was tried to be downloaded on last download attempt
        let infoPath = updateInfoPath(for: id)
        if !FileManager.default.fileExists(atPath: infoPath.path) {
            return "nil"
        }
        guard let versionString = try? String(contentsOf: infoPath),
              let version = Optional(versionString) else {
            return "nil"
        }
        return version
    }

    private func loadDownloadProgress(for id: String) -> Int64 {
        let fileManager = FileManager.default
        let tempPath = tempDataPath(for: id)
        do {
            let attributes = try fileManager.attributesOfItem(atPath: tempPath.path)
            if let fileSize = attributes[.size] as? NSNumber {
                return fileSize.int64Value
            }
        } catch {
            logger.error("Could not retrieve download progress size")
            logger.debug("Error: \(error)")
        }
        return 0
    }

    public func list(raw: Bool = false) -> [BundleInfo] {
        if !raw {
            // UserDefaults.standard.dictionaryRepresentation().values
            let dest: URL = libraryDir.appendingPathComponent(bundleDirectory)
            do {
                let files: [String] = try FileManager.default.contentsOfDirectory(atPath: dest.path)
                var res: [BundleInfo] = []
                logger.info("list File : \(dest.path)")
                if dest.exist {
                    for id: String in files {
                        res.append(self.getBundleInfo(id: id))
                    }
                }
                return res
            } catch {
                logger.info("No version available \(dest.path)")
                return []
            }
        } else {
            guard let regex = try? NSRegularExpression(pattern: "^[0-9A-Za-z]{10}_info$") else {
                logger.error("Invalid regex ?????")
                return []
            }
            return UserDefaults.standard.dictionaryRepresentation().keys.filter {
                let range = NSRange($0.startIndex..., in: $0)
                let matches = regex.matches(in: $0, range: range)
                return !matches.isEmpty
            }.map {
                $0.components(separatedBy: "_")[0]
            }.map {
                self.getBundleInfo(id: $0)
            }
        }

    }

    public func delete(id: String, removeInfo: Bool) -> Bool {
        let deleted: BundleInfo = self.getBundleInfo(id: id)
        if deleted.isBuiltin() || self.getCurrentBundleId() == id {
            logger.info("Cannot delete current or builtin bundle")
            logger.debug("Bundle ID: \(id)")
            return false
        }

        if let previewFallback = self.getPreviewFallbackBundle(),
           !previewFallback.isDeleted(),
           !previewFallback.isErrorStatus(),
           previewFallback.getId() == id {
            logger.info("Cannot delete the preview fallback bundle")
            logger.debug("Bundle ID: \(id)")
            return false
        }

        // Check if this is the next bundle and prevent deletion if it is
        if let next = self.getNextBundle(),
           !next.isDeleted() &&
            !next.isErrorStatus() &&
            next.getId() == id {
            logger.info("Cannot delete the next bundle")
            logger.debug("Bundle ID: \(id)")
            return false
        }

        let destPersist: URL = libraryDir.appendingPathComponent(bundleDirectory).appendingPathComponent(id)
        do {
            try FileManager.default.removeItem(atPath: destPersist.path)
        } catch {
            logger.error("Bundle folder not removed")
            logger.debug("Path: \(destPersist.path)")
            // even if, we don;t care. Android doesn't care
            if removeInfo {
                self.removeBundleInfo(id: id)
            }
            self.sendStats(action: "delete", versionName: deleted.getVersionName())
            return false
        }
        if removeInfo {
            self.removeBundleInfo(id: id)
        } else {
            self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
        }
        logger.info("Bundle deleted successfully")
        logger.debug("Version: \(deleted.getVersionName())")
        self.sendStats(action: "delete", versionName: deleted.getVersionName())
        return true
    }

    public func delete(id: String) -> Bool {
        return self.delete(id: id, removeInfo: true)
    }

    public func cleanupDeltaCache() {
        cleanupDeltaCache(threadToCheck: nil)
    }

    public func cleanupDeltaCache(threadToCheck: Thread?) {
        // Check if thread was cancelled
        if let thread = threadToCheck, thread.isCancelled {
            logger.warn("cleanupDeltaCache was cancelled before starting")
            return
        }

        let fileManager = FileManager.default
        guard fileManager.fileExists(atPath: cacheFolder.path) else {
            return
        }
        do {
            try fileManager.removeItem(at: cacheFolder)
            logger.info("Cleaned up delta cache folder")
        } catch {
            logger.error("Failed to cleanup delta cache")
            logger.debug("Error: \(error.localizedDescription)")
        }
    }

    public func cleanupDownloadDirectories(allowedIds: Set<String>) {
        cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: nil)
    }

    public func cleanupDownloadDirectories(allowedIds: Set<String>, threadToCheck: Thread?) {
        let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
        let fileManager = FileManager.default

        guard fileManager.fileExists(atPath: bundleRoot.path) else {
            return
        }

        do {
            let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])

            for url in contents {
                // Check if thread was cancelled
                if let thread = threadToCheck, thread.isCancelled {
                    logger.warn("cleanupDownloadDirectories was cancelled")
                    return
                }

                let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
                if resourceValues.isDirectory != true {
                    continue
                }

                let id = url.lastPathComponent

                if allowedIds.contains(id) {
                    continue
                }

                do {
                    try fileManager.removeItem(at: url)
                    self.removeBundleInfo(id: id)
                    logger.info("Deleted orphan bundle directory")
                    logger.debug("Bundle ID: \(id)")
                } catch {
                    logger.error("Failed to delete orphan bundle directory")
                    logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
                }
            }
        } catch {
            logger.error("Failed to enumerate bundle directory for cleanup")
            logger.debug("Error: \(error.localizedDescription)")
        }
    }

    public func cleanupOrphanedTempFolders(threadToCheck: Thread?) {
        let fileManager = FileManager.default

        do {
            let contents = try fileManager.contentsOfDirectory(at: libraryDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])

            for url in contents {
                // Check if thread was cancelled
                if let thread = threadToCheck, thread.isCancelled {
                    logger.warn("cleanupOrphanedTempFolders was cancelled")
                    return
                }

                let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
                if resourceValues.isDirectory != true {
                    continue
                }

                let folderName = url.lastPathComponent

                // Only delete folders with the temp unzip prefix
                if !folderName.hasPrefix(TEMP_UNZIP_PREFIX) {
                    continue
                }

                do {
                    try fileManager.removeItem(at: url)
                    logger.info("Deleted orphaned temp unzip folder")
                    logger.debug("Folder: \(folderName)")
                } catch {
                    logger.error("Failed to delete orphaned temp folder")
                    logger.debug("Folder: \(folderName), Error: \(error.localizedDescription)")
                }
            }
        } catch {
            logger.error("Failed to enumerate library directory for temp folder cleanup")
            logger.debug("Error: \(error.localizedDescription)")
        }

        // Also cleanup old download temp files (package_*.tmp and update_*.dat)
        cleanupOldDownloadTempFiles()
    }

    private func cleanupOldDownloadTempFiles() {
        let fileManager = FileManager.default
        guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
            return
        }

        do {
            let contents = try fileManager.contentsOfDirectory(at: documentsDir, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles])
            let oneHourAgo = Date().addingTimeInterval(-3600)

            for url in contents {
                let fileName = url.lastPathComponent
                // Only cleanup package_*.tmp and update_*.dat files
                let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
                    (fileName.hasPrefix("update_") && fileName.hasSuffix(".dat"))
                if !isDownloadTemp {
                    continue
                }

                // Only delete files older than 1 hour
                if let modDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate,
                   modDate < oneHourAgo {
                    do {
                        try fileManager.removeItem(at: url)
                        logger.debug("Deleted old download temp file: \(fileName)")
                    } catch {
                        logger.debug("Failed to delete old download temp file: \(fileName), Error: \(error.localizedDescription)")
                    }
                }
            }
        } catch {
            logger.debug("Failed to enumerate documents directory for temp file cleanup: \(error.localizedDescription)")
        }
    }

    public func getBundleDirectory(id: String) -> URL {
        return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
    }

    struct ResetState {
        let currentBundlePath: String
        let fallbackBundleId: String
        let nextBundleId: String?
    }

    func captureResetState() -> ResetState {
        ResetState(
            currentBundlePath: UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) ?? self.DEFAULT_FOLDER,
            fallbackBundleId: UserDefaults.standard.string(forKey: self.FALLBACK_VERSION) ?? BundleInfo.ID_BUILTIN,
            nextBundleId: UserDefaults.standard.string(forKey: self.NEXT_VERSION)
        )
    }

    func restoreResetState(_ state: ResetState) {
        let currentBundlePath = state.currentBundlePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            ? self.DEFAULT_FOLDER
            : state.currentBundlePath
        let fallbackBundleId = state.fallbackBundleId.isEmpty ? BundleInfo.ID_BUILTIN : state.fallbackBundleId

        self.setCurrentBundle(bundle: currentBundlePath)
        UserDefaults.standard.set(fallbackBundleId, forKey: self.FALLBACK_VERSION)
        if let nextBundleId = state.nextBundleId, !nextBundleId.isEmpty {
            UserDefaults.standard.set(nextBundleId, forKey: self.NEXT_VERSION)
        } else {
            UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
        }
        UserDefaults.standard.synchronize()
    }

    func prepareResetStateForTransition() {
        self.setCurrentBundle(bundle: "")
        self.setFallbackBundle(fallback: Optional<BundleInfo>.none)
        _ = self.setNextBundle(next: Optional<String>.none)
    }

    func finalizeResetTransition(previousBundleName: String, isInternal: Bool) {
        if !isInternal {
            self.sendStats(action: "reset", versionName: self.getCurrentBundle().getVersionName(), oldVersionName: previousBundleName)
        }
    }

    func canSet(bundle: BundleInfo) -> Bool {
        bundle.isBuiltin() || self.bundleExists(id: bundle.getId())
    }

    public func set(bundle: BundleInfo) -> Bool {
        return self.set(id: bundle.getId())
    }

    private func bundleExists(id: String) -> Bool {
        let destPersist: URL = self.getBundleDirectory(id: id)
        let indexPersist: URL = destPersist.appendingPathComponent("index.html")
        let bundleIndo: BundleInfo = self.getBundleInfo(id: id)
        if
            destPersist.exist &&
                destPersist.isDirectory &&
                !indexPersist.isDirectory &&
                indexPersist.exist &&
                !bundleIndo.isDeleted() {
            return true
        }
        return false
    }

    public func set(id: String) -> Bool {
        let newBundle: BundleInfo = self.getBundleInfo(id: id)
        if newBundle.isBuiltin() {
            self.reset()
            return true
        }
        if bundleExists(id: id) {
            let currentBundleName = self.getCurrentBundle().getVersionName()
            self.setCurrentBundle(bundle: self.getBundleDirectory(id: id).path)
            self.setBundleStatus(id: id, status: BundleStatus.PENDING)
            self.sendStats(action: "set", versionName: newBundle.getVersionName(), oldVersionName: currentBundleName)
            return true
        }
        self.setBundleStatus(id: id, status: BundleStatus.ERROR)
        self.sendStats(action: "set_fail", versionName: newBundle.getVersionName())
        return false
    }

    func stagePendingReload(bundle: BundleInfo) -> Bool {
        guard !bundle.isBuiltin(), bundleExists(id: bundle.getId()) else {
            return false
        }
        self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
        return true
    }

    func stagePreviewFallbackReload(bundle: BundleInfo) -> Bool {
        guard !bundle.isErrorStatus() else {
            return false
        }
        if bundle.isBuiltin() {
            self.setCurrentBundle(bundle: self.DEFAULT_FOLDER)
            return true
        }
        guard bundleExists(id: bundle.getId()) else {
            return false
        }
        self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
        return true
    }

    func finalizePendingReload(bundle: BundleInfo, previousBundleName: String) {
        guard !bundle.isBuiltin() else {
            return
        }
        self.sendStats(action: "set", versionName: bundle.getVersionName(), oldVersionName: previousBundleName)
    }

    public func autoReset() {
        let currentBundle: BundleInfo = self.getCurrentBundle()
        if !currentBundle.isBuiltin() && !self.bundleExists(id: currentBundle.getId()) {
            logger.info("Folder at bundle path does not exist. Triggering reset.")
            self.reset()
            return
        }
        let bundlePath = UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH)
        if Self.shouldResetForForeignBundle(
            bundlePath: bundlePath,
            isBuiltin: currentBundle.isBuiltin(),
            hasStoredBundleInfo: self.hasStoredBundleInfo(id: currentBundle.getId())
        ) {
            logger.info("Current bundle id is not one of the bundle ids stored by this plugin. Triggering reset.")
            self.reset()
        }
    }

    public func reset() {
        self.reset(isInternal: false)
    }

    public func reset(isInternal: Bool) {
        logger.info("reset: \(isInternal)")
        let currentBundleName = self.getCurrentBundle().getVersionName()
        self.prepareResetStateForTransition()
        self.finalizeResetTransition(previousBundleName: currentBundleName, isInternal: isInternal)
    }

    public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
        self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
        let fallback: BundleInfo = self.getFallbackBundle()
        let previewFallback = self.getPreviewFallbackBundle()
        let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
        logger.info("Fallback bundle is: \(fallback.toString())")
        logger.info("Version successfully loaded: \(bundle.toString())")
        if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() && !fallbackIsPreviewFallback {
            let res = self.delete(id: fallback.getId())
            if res {
                logger.info("Deleted previous bundle")
                logger.debug("Bundle: \(fallback.toString())")
            } else {
                logger.error("Failed to delete previous bundle")
                logger.debug("Bundle: \(fallback.toString())")
            }
        }
        self.setFallbackBundle(fallback: bundle)
    }

    public func setError(bundle: BundleInfo) {
        self.setBundleStatus(id: bundle.getId(), status: BundleStatus.ERROR)
    }

    func unsetChannel(defaultChannelKey: String, configDefaultChannel: String) -> SetChannel {
        let setChannel: SetChannel = SetChannel()

        // Clear persisted defaultChannel and revert to config value
        UserDefaults.standard.removeObject(forKey: defaultChannelKey)
        UserDefaults.standard.synchronize()
        self.defaultChannel = configDefaultChannel
        self.logger.info("Persisted defaultChannel cleared, reverted to config value: \(configDefaultChannel)")

        setChannel.status = "ok"
        setChannel.message = "Channel override removed"
        return setChannel
    }

    func setChannel(channel: String, defaultChannelKey: String, allowSetDefaultChannel: Bool) -> SetChannel {
        let setChannel: SetChannel = SetChannel()

        // Check if setting defaultChannel is allowed
        if !allowSetDefaultChannel {
            logger.error("setChannel is disabled by allowSetDefaultChannel config")
            setChannel.message = "setChannel is disabled by configuration"
            setChannel.error = "disabled_by_config"
            return setChannel
        }

        // Check if rate limit was exceeded
        if CapgoUpdater.rateLimitExceeded {
            logger.debug("Skipping setChannel due to rate limit (429). Requests will resume after app restart.")
            setChannel.message = "Rate limit exceeded"
            setChannel.error = "rate_limit_exceeded"
            return setChannel
        }

        if (self.channelUrl ).isEmpty {
            logger.error("Channel URL is not set")
            setChannel.message = "Channel URL is not set"
            setChannel.error = "missing_config"
            return setChannel
        }
        guard let channelURL = URL(string: self.channelUrl) else {
            logger.error("Invalid channel URL")
            setChannel.message = "Channel URL is invalid"
            setChannel.error = "invalid_config"
            return setChannel
        }
        var parameters: InfoObject = self.createInfoObject()
        parameters.channel = channel
        guard let request = createRequest(url: channelURL, method: "POST", parameters: parameters.toParameters()) else {
            setChannel.error = "Request failed: invalid request"
            return setChannel
        }

        let result = performRequest(request, label: "setChannel")

        if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
            setChannel.message = "Rate limit exceeded"
            setChannel.error = "rate_limit_exceeded"
            return setChannel
        }

        if result.timedOut {
            setChannel.error = "Request timed out"
            return setChannel
        }

        if let error = result.error {
            self.logger.error("Error setting channel")
            self.logger.debug("Error: \(error.localizedDescription)")
            setChannel.error = "Request failed: \(error.localizedDescription)"
            return setChannel
        }

        guard let data = result.data else {
            setChannel.error = "Request failed: empty response"
            return setChannel
        }

        guard let responseValue = try? JSONDecoder().decode(SetChannelDec.self, from: data) else {
            setChannel.error = "decode_error"
            return setChannel
        }

        let statusCode = result.response?.statusCode ?? 0
        if statusCode < 200 || statusCode >= 300 {
            setChannel.message = responseValue.message ?? "Server error: \(statusCode)"
            setChannel.error = responseValue.error ?? "response_error"
            return setChannel
        }

        if let error = responseValue.error {
            setChannel.error = error
        } else if responseValue.unset == true {
            UserDefaults.standard.removeObject(forKey: defaultChannelKey)
            UserDefaults.standard.synchronize()
            self.logger.info("Public channel requested, channel override removed")

            setChannel.status = responseValue.status ?? "ok"
            setChannel.message = responseValue.message ?? "Public channel requested, channel override removed. Device will use public channel automatically."
        } else {
            self.defaultChannel = channel
            UserDefaults.standard.set(channel, forKey: defaultChannelKey)
            UserDefaults.standard.synchronize()
            self.logger.info("defaultChannel persisted locally: \(channel)")

            setChannel.status = responseValue.status ?? ""
            setChannel.message = responseValue.message ?? ""
        }
        return setChannel
    }

    func getChannel() -> GetChannel {
        let getChannel: GetChannel = GetChannel()

        // Check if rate limit was exceeded
        if CapgoUpdater.rateLimitExceeded {
            logger.debug("Skipping getChannel due to rate limit (429). Requests will resume after app restart.")
            getChannel.message = "Rate limit exceeded"
            getChannel.error = "rate_limit_exceeded"
            return getChannel
        }

        if (self.channelUrl ).isEmpty {
            logger.error("Channel URL is not set")
            getChannel.message = "Channel URL is not set"
            getChannel.error = "missing_config"
            return getChannel
        }
        guard let channelURL = URL(string: self.channelUrl) else {
            logger.error("Invalid channel URL")
            getChannel.message = "Channel URL is invalid"
            getChannel.error = "invalid_config"
            return getChannel
        }
        let parameters: InfoObject = self.createInfoObject()
        guard let request = createRequest(url: channelURL, method: "PUT", parameters: parameters.toParameters()) else {
            getChannel.error = "Request failed: invalid request"
            return getChannel
        }

        let result = performRequest(request, label: "getChannel")

        if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
            getChannel.message = "Rate limit exceeded"
            getChannel.error = "rate_limit_exceeded"
            return getChannel
        }

        if result.timedOut {
            getChannel.error = "Request timed out"
            return getChannel
        }

        if let error = result.error {
            if let data = result.data, let bodyString = String(data: data, encoding: .utf8) {
                if bodyString.contains("channel_not_found") && result.response?.statusCode == 400 && !self.defaultChannel.isEmpty {
                    getChannel.channel = self.defaultChannel
                    getChannel.status = "default"
                    return getChannel
                }
            }

            self.logger.error("Error getting channel")
            self.logger.debug("Error: \(error.localizedDescription)")
            getChannel.error = "Request failed: \(error.localizedDescription)"
            return getChannel
        }

        guard let data = result.data else {
            getChannel.error = "Request failed: empty response"
            return getChannel
        }

        guard let responseValue = try? JSONDecoder().decode(GetChannelDec.self, from: data) else {
            getChannel.error = "decode_error"
            return getChannel
        }

        let statusCode = result.response?.statusCode ?? 0
        if let error = responseValue.error {
            if error == "channel_not_found", statusCode == 400, !self.defaultChannel.isEmpty {
                getChannel.channel = self.defaultChannel
                getChannel.status = "default"
                return getChannel
            }
            getChannel.error = error
            getChannel.message = responseValue.message ?? ""
            return getChannel
        }

        if statusCode < 200 || statusCode >= 300 {
            getChannel.message = responseValue.message ?? "Server error: \(statusCode)"
            getChannel.error = "response_error"
        } else {
            getChannel.status = responseValue.status ?? ""
            getChannel.message = responseValue.message ?? ""
            getChannel.channel = responseValue.channel ?? ""
            getChannel.allowSet = responseValue.allowSet ?? true
        }
        return getChannel
    }

    func listChannels() -> ListChannels {
        let listChannels: ListChannels = ListChannels()

        // Check if rate limit was exceeded
        if CapgoUpdater.rateLimitExceeded {
            logger.debug("Skipping listChannels due to rate limit (429). Requests will resume after app restart.")
            listChannels.error = "rate_limit_exceeded"
            return listChannels
        }

        if (self.channelUrl).isEmpty {
            logger.error("Channel URL is not set")
            listChannels.error = "Channel URL is not set"
            return listChannels
        }

        // Create info object and convert to query parameters
        let infoObject = self.createInfoObject()

        // Create query parameters from InfoObject
        var urlComponents = URLComponents(string: self.channelUrl)
        var queryItems: [URLQueryItem] = urlComponents?.queryItems ?? []

        // Convert InfoObject to dictionary using Mirror
        let mirror = Mirror(reflecting: infoObject)
        for child in mirror.children {
            if let key = child.label, let value = child.value as? CustomStringConvertible {
                queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
            } else if let key = child.label {
                // Handle optional values
                let mirror = Mirror(reflecting: child.value)
                if let value = mirror.children.first?.value {
                    queryItems.append(URLQueryItem(name: key, value: String(describing: value)))
                }
            }
        }

        urlComponents?.queryItems = queryItems

        guard let url = urlComponents?.url else {
            logger.error("Invalid channel URL")
            listChannels.error = "Invalid channel URL"
            return listChannels
        }

        guard let request = createRequest(url: url, method: "GET", expectsJSONResponse: true) else {
            listChannels.error = "Invalid channel URL"
            return listChannels
        }

        let result = performRequest(request, label: "listChannels")

        if self.checkAndHandleRateLimitResponse(statusCode: result.response?.statusCode) {
            listChannels.error = "rate_limit_exceeded"
            return listChannels
        }

        if result.timedOut {
            listChannels.error = "Request timed out"
            return listChannels
        }

        if let error = result.error {
            self.logger.error("Error listing channels")
            self.logger.debug("Error: \(error.localizedDescription)")
            listChannels.error = "Request failed: \(error.localizedDescription)"
            return listChannels
        }

        guard let data = result.data else {
            listChannels.error = "Request failed: empty response"
            return listChannels
        }

        guard let responseValue = try? JSONDecoder().decode(ListChannelsDec.self, from: data) else {
            listChannels.error = "decode_error"
            return listChannels
        }

        let statusCode = result.response?.statusCode ?? 0
        if let error = responseValue.error {
            listChannels.error = error
            return listChannels
        }

        if statusCode < 200 || statusCode >= 300 {
            listChannels.error = "response_error"
            return listChannels
        }

        if let channels = responseValue.channels {
            listChannels.channels = channels.map { channel in
                var channelDict: [String: Any] = [:]
                channelDict["id"] = channel.id ?? ""
                channelDict["name"] = channel.name ?? ""
                channelDict["public"] = channel.public ?? false
                channelDict["allow_self_set"] = channel.allow_self_set ?? false
                return channelDict
            }
        }

        return listChannels
    }

    private let operationQueue = OperationQueue()

    private let manifestDownloadQueue: OperationQueue = {
        let queue = OperationQueue()
        queue.name = "com.capgo.manifestDownload"
        queue.qualityOfService = .userInitiated
        return queue
    }()

    func sendStats(action: String, versionName: String? = nil, oldVersionName: String? = "") {
        sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: nil)
    }

    func sendStats(action: String, versionName: String?, oldVersionName: String?, metadata: [String: String]) {
        sendStatsWithMetadata(action: action, versionName: versionName, oldVersionName: oldVersionName, metadata: metadata)
    }

    private func sendStatsWithMetadata(action: String, versionName: String?, oldVersionName: String?, metadata: [String: String]?) {
        if previewSession {
            logger.debug("Skipping sendStats during preview session.")
            return
        }

        // Check if rate limit was exceeded
        if CapgoUpdater.rateLimitExceeded {
            logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
            return
        }

        guard !statsUrl.isEmpty else {
            return
        }

        let resolvedVersionName = versionName ?? getCurrentBundle().getVersionName()
        let info = createInfoObject()

        let event = StatsEvent(
            platform: info.platform,
            device_id: info.device_id,
            app_id: info.app_id,
            custom_id: info.custom_id,
            version_build: info.version_build,
            version_code: info.version_code,
            version_os: info.version_os,
            version_name: resolvedVersionName,
            old_version_name: oldVersionName ?? "",
            plugin_version: info.plugin_version,
            is_emulator: info.is_emulator,
            is_prod: info.is_prod,
            action: action,
            channel: info.channel,
            defaultChannel: info.defaultChannel,
            key_id: info.key_id,
            metadata: metadata,
            timestamp: Int64(Date().timeIntervalSince1970 * 1000)
        )

        statsQueueLock.lock()
        statsQueue.append(event)
        statsQueueLock.unlock()

        ensureStatsTimerStarted()
    }

    private func ensureStatsTimerStarted() {
        DispatchQueue.main.async { [weak self] in
            guard let self = self else { return }
            if self.statsFlushTimer == nil || !self.statsFlushTimer!.isValid {
                // Use closure-based timer to avoid strong reference cycle
                self.statsFlushTimer = Timer.scheduledTimer(
                    withTimeInterval: CapgoUpdater.statsFlushInterval,
                    repeats: true
                ) { [weak self] _ in
                    self?.flushStatsQueue()
                }
            }
        }
    }

    private func flushStatsQueue() {
        statsQueueLock.lock()
        guard !statsQueue.isEmpty else {
            statsQueueLock.unlock()
            return
        }
        let eventsToSend = statsQueue
        statsQueue.removeAll()
        statsQueueLock.unlock()

        operationQueue.maxConcurrentOperationCount = 1

        let operation = BlockOperation {
            let semaphore = DispatchSemaphore(value: 0)
            self.alamofireSession.request(
                self.statsUrl,
                method: .post,
                parameters: eventsToSend,
                encoder: JSONParameterEncoder.default,
                requestModifier: { $0.timeoutInterval = self.timeout }
            ).responseData { response in
                // Check for 429 rate limit
                if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
                    semaphore.signal()
                    return
                }

                switch response.result {
                case .success:
                    self.logger.info("Stats batch sent successfully")
                    self.logger.debug("Sent \(eventsToSend.count) events")
                case let .failure(error):
                    self.logger.error("Error sending stats batch")
                    self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
                }
                semaphore.signal()
            }
            semaphore.wait()
        }
        operationQueue.addOperation(operation)
    }

    public func getBundleInfo(id: String?) -> BundleInfo {
        var trueId = BundleInfo.VERSION_UNKNOWN
        if id != nil {
            trueId = id!
        }
        let result: BundleInfo
        if BundleInfo.ID_BUILTIN == trueId {
            result = BundleInfo(id: trueId, version: self.versionBuild, status: BundleStatus.SUCCESS, checksum: "")
        } else if BundleInfo.VERSION_UNKNOWN == trueId {
            result = BundleInfo(id: trueId, version: "", status: BundleStatus.ERROR, checksum: "")
        } else {
            do {
                result = try UserDefaults.standard.getObj(forKey: "\(trueId)\(self.INFO_SUFFIX)", castTo: BundleInfo.self)
            } catch {
                logger.error("Failed to parse bundle info")
                logger.debug("Bundle ID: \(trueId), Error: \(error.localizedDescription)")
                result = BundleInfo(id: trueId, version: "", status: BundleStatus.PENDING, checksum: "")
            }
        }
        return result
    }

    public func getBundleInfoByVersionName(version: String) -> BundleInfo? {
        let installed: [BundleInfo] = self.list()
        for i in installed {
            if i.getVersionName() == version {
                return i
            }
        }
        return nil
    }

    private func removeBundleInfo(id: String) {
        self.saveBundleInfo(id: id, bundle: nil)
    }

    public func saveBundleInfo(id: String, bundle: BundleInfo?) {
        if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
            logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
            return
        }
        if bundle == nil {
            logger.info("Removing info for bundle [\(id)]")
            UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
        } else {
            let update = bundle!.setId(id: id)
            logger.info("Storing info for bundle [\(id)] \(update.toString())")
            do {
                try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
            } catch {
                logger.error("Failed to save bundle info")
                logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
            }
        }
    }

    private func setBundleStatus(id: String, status: BundleStatus) {
        logger.info("Setting status for bundle [\(id)] to \(status)")
        let info = self.getBundleInfo(id: id)
        self.saveBundleInfo(id: id, bundle: info.setStatus(status: status.storedValue))
    }

    public func getCurrentBundle() -> BundleInfo {
        return self.getBundleInfo(id: self.getCurrentBundleId())
    }

    public func getCurrentBundleId() -> String {
        guard let bundlePath: String = UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) else {
            return BundleInfo.ID_BUILTIN
        }
        if (bundlePath).isEmpty {
            return BundleInfo.ID_BUILTIN
        }
        let bundleID: String = bundlePath.components(separatedBy: "/").last ?? bundlePath
        return bundleID
    }

    public func isUsingBuiltin() -> Bool {
        return (UserDefaults.standard.string(forKey: self.CAP_SERVER_PATH) ?? "") == self.DEFAULT_FOLDER
    }

    public func getFallbackBundle() -> BundleInfo {
        let id: String = UserDefaults.standard.string(forKey: self.FALLBACK_VERSION) ?? BundleInfo.ID_BUILTIN
        return self.getBundleInfo(id: id)
    }

    private func setFallbackBundle(fallback: BundleInfo?) {
        UserDefaults.standard.set(fallback == nil ? BundleInfo.ID_BUILTIN : fallback!.getId(), forKey: self.FALLBACK_VERSION)
        UserDefaults.standard.synchronize()
    }

    public func getNextBundle() -> BundleInfo? {
        let id: String? = UserDefaults.standard.string(forKey: self.NEXT_VERSION)
        return self.getBundleInfo(id: id)
    }

    public func getPreviewFallbackBundle() -> BundleInfo? {
        guard let id = UserDefaults.standard.string(forKey: self.PREVIEW_FALLBACK_VERSION) else {
            return nil
        }
        let bundle = self.getBundleInfo(id: id)
        if !bundle.isBuiltin() && !self.bundleExists(id: id) {
            _ = self.setPreviewFallbackBundle(fallback: nil)
            return nil
        }
        return bundle
    }

    public func setPreviewFallbackBundle(fallback: String?) -> Bool {
        guard let fallbackId = fallback else {
            UserDefaults.standard.removeObject(forKey: self.PREVIEW_FALLBACK_VERSION)
            UserDefaults.standard.synchronize()
            return true
        }
        let newBundle: BundleInfo = self.getBundleInfo(id: fallbackId)
        if !newBundle.isBuiltin() && !self.bundleExists(id: fallbackId) {
            return false
        }
        UserDefaults.standard.set(fallbackId, forKey: self.PREVIEW_FALLBACK_VERSION)
        UserDefaults.standard.synchronize()
        return true
    }

    public func setNextBundle(next: String?) -> Bool {
        guard let nextId: String = next else {
            UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
            UserDefaults.standard.synchronize()
            return false
        }
        let newBundle: BundleInfo = self.getBundleInfo(id: nextId)
        if !newBundle.isBuiltin() && !self.bundleExists(id: nextId) {
            return false
        }
        UserDefaults.standard.set(nextId, forKey: self.NEXT_VERSION)
        UserDefaults.standard.synchronize()
        self.setBundleStatus(id: nextId, status: BundleStatus.PENDING)
        self.sendStats(action: "set_next", versionName: newBundle.getVersionName(), oldVersionName: self.getCurrentBundle().getVersionName())
        self.notifyListeners("setNext", ["bundle": newBundle.toJSON()])
        return true
    }
}
