// PulseUpdates Controller
// Based on expo-updates AppController
// Main controller that orchestrates update lifecycle

import Foundation
import Network
import CryptoKit

// MARK: - Asset Path Resolution

/// Find a bundled asset file by filename and type.
/// Handles MIME types ("image/png"), plain extensions ("riv", "png"), and "unknown" type.
/// Falls back to scanning the directory for files matching the filename prefix.
internal func findBundledAssetPath(
    bundleRoot: String,
    filename: String,
    assetType: String,
    nsBundleDir: String?
) -> String? {
    let fileManager = FileManager.default

    // Derive extension from type
    let ext: String
    if assetType.contains("/") {
        // MIME type: extract after "/" (e.g., "image/png" -> "png")
        ext = assetType.components(separatedBy: "/").last ?? "png"
    } else if assetType != "unknown" {
        // Plain extension (e.g., "riv", "png")
        ext = assetType
    } else {
        ext = "unknown"
    }

    // Build search directories
    var searchDirs: [String] = []
    if let dir = nsBundleDir, !dir.isEmpty {
        searchDirs.append("\(bundleRoot)/assets/\(dir)")
        searchDirs.append("\(bundleRoot)/\(dir)")
    }
    searchDirs.append(bundleRoot)

    // Try with the derived extension
    if ext != "unknown" {
        for dir in searchDirs {
            let path = "\(dir)/\(filename).\(ext)"
            if fileManager.fileExists(atPath: path) {
                return path
            }
        }
    }

    // Fallback: scan directories for files matching the filename prefix
    // This handles "unknown" type and any other extension mismatches (e.g., .riv files
    // in builds where the embedded manifest had type="unknown" for non-standard assets)
    for dir in searchDirs {
        if let files = try? fileManager.contentsOfDirectory(atPath: dir) {
            for file in files where file.hasPrefix("\(filename).") && file != "\(filename)." {
                return "\(dir)/\(file)"
            }
        }
    }

    return nil
}

// MARK: - Controller Delegate

public protocol PulseControllerDelegate: AnyObject {
    func pulseController(_ controller: PulseController, didStartWithSuccess success: Bool)
}

// MARK: - Main Controller

public final class PulseController {

    // MARK: - Singleton

    public static let shared = PulseController()

    // MARK: - Public Properties

    public weak var delegate: PulseControllerDelegate?

    /// Whether the controller is active (updates are enabled)
    public private(set) var isActive: Bool = false

    /// Whether the controller has started
    public private(set) var isStarted: Bool = false

    /// Current configuration
    public private(set) var config: PulseUpdatesConfig?

    /// The currently launched update info
    public private(set) var launchedUpdate: PulseUpdateInfo?

    /// Whether we're running the embedded update
    public private(set) var isEmbeddedLaunch: Bool = true

    /// URL to the launch asset (JS bundle)
    public private(set) var launchAssetUrl: URL?

    /// Local assets map (asset key -> file URL)
    public private(set) var localAssets: [String: String] = [:]

    /// The manifest JSON for the launched update (for metadata like build number)
    public private(set) var launchedManifestJson: String?

    /// Error recovery handler
    public private(set) var errorRecovery: PulseErrorRecovery?

    /// Remote load status for error recovery
    public private(set) var remoteLoadStatus: PulseRemoteLoadStatus = .idle

    /// Stable, anonymous per-install device id (persisted in the pulse dir). Used to bucket staged
    /// rollouts server-side via the Pulse-Device-Id header and to attribute telemetry events.
    private var _deviceId: String?
    public var deviceId: String {
        if let id = _deviceId { return id }
        let file = directory.appendingPathComponent("device_id")
        if let data = try? Data(contentsOf: file),
           let s = String(data: data, encoding: .utf8) {
            let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
            if !trimmed.isEmpty { _deviceId = trimmed; return trimmed }
        }
        let id = UUID().uuidString
        do { try id.data(using: .utf8)?.write(to: file) }
        catch { pulseLog("deviceId: persist failed (\(error.localizedDescription)); a new id will be used next launch") }
        _deviceId = id
        return id
    }

    // MARK: - Private Properties

    private var database: PulseDatabase?
    private var launcher: PulseAppLauncher?
    private let directory: URL
    private let fileQueue = DispatchQueue(label: "app.pulse.files", qos: .userInitiated)

    // Embedded manifest cache
    private var embeddedManifest: PulseEmbeddedManifest?
    /// Embedded asset hashes map (hash -> file URL) - internal for download optimization
    internal var embeddedAssetHashes: [String: URL] = [:]
    private var embeddedBundleHash: String?

    // Cached manifest from last check (to avoid duplicate requests in fetch)
    // Only valid for the immediate check->fetch sequence, cleared on use or new check
    private var lastCheckManifest: PulseManifestModel?

    // Flag to prevent concurrent fetch operations (with lock for thread safety)
    private var isFetching = false
    private var pendingFetchCallbacks: [(Result<PulseFetchResult, Error>) -> Void] = []
    private let fetchLock = NSLock()

    // MARK: - Native Config (from Info.plist, like expo-updates)

    /// Load config from Info.plist (called before JS starts)
    private func loadNativeConfig() -> PulseUpdatesConfig? {
        let info = Bundle.main.infoDictionary

        // Check if PulseUpdates is configured in Info.plist
        guard let updateUrl = info?["PulseUpdatesURL"] as? String, !updateUrl.isEmpty else {
            pulseLog("No PulseUpdatesURL in Info.plist, native config disabled")
            return nil
        }

        let enabled = (info?["PulseUpdatesEnabled"] as? Bool) ?? true

        // Runtime version: embedded manifest > CFBundleShortVersionString
        let runtimeVersion: String
        if let embedded = embeddedManifest {
            runtimeVersion = embedded.runtimeVersion
        } else {
            runtimeVersion = (info?["CFBundleShortVersionString"] as? String)
                ?? (info?["CFBundleVersion"] as? String)
                ?? "unknown"
        }

        return PulseUpdatesConfig(
            enabled: enabled,
            updateUrl: updateUrl,
            runtimeVersion: runtimeVersion,
            checkOnLaunch: (info?["PulseUpdatesCheckOnLaunch"] as? String) ?? "ALWAYS",
            launchWaitMs: (info?["PulseUpdatesLaunchWaitMs"] as? Int) ?? 0,
            channel: info?["PulseUpdatesChannel"] as? String,
            signingKeyId: info?["PulseUpdatesSigningKeyId"] as? String,
            signingPublicKey: info?["PulseUpdatesSigningPublicKey"] as? String,
            requireSignature: (info?["PulseUpdatesRequireSignature"] as? Bool) ?? PulseUpdatesConfig.defaultRequireSignature
        )
    }

    /// Get the runtime version (from config or native sources)
    private var nativeRuntimeVersion: String {
        // Use config if available
        if let configVersion = config?.runtimeVersion {
            return configVersion
        }

        // Try embedded manifest
        if let embedded = embeddedManifest {
            return embedded.runtimeVersion
        }

        // Fall back to app version
        let info = Bundle.main.infoDictionary
        return (info?["CFBundleShortVersionString"] as? String)
            ?? (info?["CFBundleVersion"] as? String)
            ?? "unknown"
    }

    // MARK: - Initialization

    private init() {
        // Use Application Support directory
        let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
        self.directory = appSupport.appendingPathComponent("pulse", isDirectory: true)
    }

    // MARK: - Public API

    /// Configure the controller (call before start)
    public func configure(_ config: PulseUpdatesConfig) {
        self.config = config
        self.isActive = config.enabled
    }

    /// Initialize without starting (for custom initialization)
    /// This is called synchronously by getBundleURL() before JS starts
    public func initializeWithoutStarting() {
        guard !isStarted else { return }

        // Mark as started to prevent re-initialization during reload
        isStarted = true

        createDirectories()
        initializeDatabase()
        loadEmbeddedManifest()

        // Load native config from Info.plist if not already configured by JS
        if config == nil {
            config = loadNativeConfig()
            if let cfg = config {
                isActive = cfg.enabled
                pulseLog("Loaded native config: url=\(cfg.updateUrl) runtime=\(cfg.runtimeVersion)")
            }
        }

        seedEmbeddedUpdateIfNeeded()
        runIntegrityCheck()

        // Synchronously select the best update for launch
        selectBestUpdateSync()

        // Wire crash-recovery on the getBundleURL launch path. This path never calls start(), so
        // without this the RCT fatal handler / RCTContentDidAppear observer would never install and
        // failed/successful launch recording (and rollback) would never run.
        if errorRecovery == nil {
            errorRecovery = PulseErrorRecovery()
            errorRecovery?.delegate = self
            errorRecovery?.startMonitoring()
        }

        // Seed launch-in-progress tracking for the selected update before the first root view renders.
        if let updateId = launchedUpdate?.updateId {
            try? database?.recordLaunchStart(updateId: updateId)
        }
    }

    /// Synchronously select the best update (used for initial launch)
    private func selectBestUpdateSync() {
        // Use config.runtimeVersion if available, otherwise fall back to native
        let runtimeVersion = config?.runtimeVersion ?? nativeRuntimeVersion

        pulseLog("selectBestUpdateSync: database=\(database != nil) runtimeVersion=\(runtimeVersion)")

        guard let database = database else {
            pulseLog("selectBestUpdateSync: no database")
            return
        }

        do {
            let updates = try database.launchableUpdates(forRuntimeVersion: runtimeVersion)
            pulseLog("selectBestUpdateSync: found \(updates.count) updates, runtimeVersion=\(runtimeVersion)")

            let policy = PulseDefaultSelectionPolicy()

            guard let selectedUpdate = policy.selectUpdateToLaunch(from: updates) else {
                pulseLog("selectBestUpdateSync: no update selected")
                return
            }

            pulseLog("selectBestUpdateSync: selected \(selectedUpdate.updateId) status=\(selectedUpdate.status) bundleHash=\(selectedUpdate.bundleHash ?? "nil")")

            // Set the launch URL based on selected update
            if selectedUpdate.status == .embedded {
                launchAssetUrl = Bundle.main.url(forResource: "main", withExtension: "jsbundle")
                isEmbeddedLaunch = true
                // For embedded, use only embedded assets
                localAssets = buildEmbeddedLocalAssets()
                pulseLog("selectBestUpdateSync: using embedded bundle, localAssets count=\(localAssets.count)")
            } else if let bundleHash = selectedUpdate.bundleHash {
                let bundlePath = directory.appendingPathComponent("assets/sha256/\(bundleHash.lowercased())")
                let exists = FileManager.default.fileExists(atPath: bundlePath.path)
                pulseLog("selectBestUpdateSync: bundlePath exists=\(exists)")

                // Re-verify the on-disk bundle against its content-addressed hash before handing it
                // to React Native. File existence alone is not enough — a truncated/tampered/partial
                // bundle must not be launched.
                if exists, !bundleFileMatchesHash(at: bundlePath, expectedHash: bundleHash) {
                    pulseLogError("selectBestUpdateSync: bundle hash mismatch for \(selectedUpdate.updateId), marking failed and falling back to embedded")
                    try? database.setStatus(.failed, forUpdateId: selectedUpdate.updateId)
                    launchEmbedded()
                    return
                }

                if exists {
                    launchAssetUrl = bundlePath
                    isEmbeddedLaunch = false

                    // Set launchedUpdate info
                    launchedUpdate = PulseUpdateInfo(
                        updateId: selectedUpdate.updateId,
                        runtimeVersion: selectedUpdate.runtimeVersion,
                        commitTime: selectedUpdate.commitTime,
                        status: selectedUpdate.status,
                        successfulLaunchCount: selectedUpdate.successfulLaunchCount,
                        failedLaunchCount: selectedUpdate.failedLaunchCount,
                        isEmbedded: selectedUpdate.isEmbedded,
                        bundleHash: selectedUpdate.bundleHash,
                        scopeKey: selectedUpdate.scopeKey
                    )

                    // Load manifest from database for metadata (build number, etc.)
                    if let manifest = selectedUpdate.manifest,
                       let data = try? JSONSerialization.data(withJSONObject: manifest, options: []),
                       let jsonString = String(data: data, encoding: .utf8) {
                        launchedManifestJson = jsonString
                        pulseLog("selectBestUpdateSync: loaded manifest json")
                    }

                    // Build localAssets: downloaded assets + embedded fallbacks
                    localAssets = buildLocalAssetsForUpdate(updateId: selectedUpdate.updateId, database: database)
                    pulseLog("selectBestUpdateSync: set launchAssetUrl to downloaded bundle, localAssets count=\(localAssets.count)")
                }
            }
        } catch {
            pulseLogError("selectBestUpdateSync error: \(error)")
        }
    }

    /// Re-hash an on-disk bundle file and compare against its expected content-addressed hash
    /// (lowercased hex SHA-256). Returns true only when the file exists and its digest matches.
    private func bundleFileMatchesHash(at fileUrl: URL, expectedHash: String) -> Bool {
        guard let handle = try? FileHandle(forReadingFrom: fileUrl) else { return false }
        defer { try? handle.close() }

        var hasher = SHA256()
        while true {
            let chunk = try? handle.read(upToCount: 1024 * 1024)
            guard let data = chunk, !data.isEmpty else { break }
            hasher.update(data: data)
        }
        let actual = hasher.finalize().map { String(format: "%02x", $0) }.joined()
        return actual == expectedHash.lowercased()
    }

    /// Build localAssets map for an OTA update (downloaded + embedded fallbacks)
    /// Store BOTH hash AND path-based keys for maximum compatibility:
    /// - hash: for SHA256 hash lookup (how expo-asset looks up)
    /// - path: for Metro path-based lookup (httpServerLocation/name.type)
    private func buildLocalAssetsForUpdate(updateId: String, database: PulseDatabase) -> [String: String] {
        // Start with embedded assets as base (fallback)
        var assets = buildEmbeddedLocalAssets()
        pulseLog("buildLocalAssetsForUpdate: embedded base count=\(assets.count)")

        // Get downloaded assets for this update from database
        do {
            let updateAssets = try database.assets(forUpdateId: updateId)
            pulseLog("buildLocalAssetsForUpdate: found \(updateAssets.count) assets in database for update \(updateId)")

            for asset in updateAssets where !asset.isLaunchAsset {
                // Check if downloaded asset exists (stored by SHA256 hash)
                let downloadedPath = directory.appendingPathComponent("assets/sha256/\(asset.hash.lowercased())")
                if FileManager.default.fileExists(atPath: downloadedPath.path) {
                    let localUri = downloadedPath.absoluteString

                    // Store by SHA256 hash (how expo-asset looks up assets)
                    assets[asset.hash] = localUri

                    // Also store by manifest key (MD5 hash) for expo-asset compatibility
                    if let key = asset.key {
                        // Normalize key: remove double "assets/assets/" prefix -> "assets/"
                        var normalizedKey = key
                        if normalizedKey.hasPrefix("assets/assets/") {
                            normalizedKey = String(normalizedKey.dropFirst("assets/".count))
                        }
                        assets[normalizedKey] = localUri
                    }
                } else {
                    // Downloaded file doesn't exist - use embedded fallback
                    // BUT we still need to add the MD5 key from OTA manifest pointing to the embedded asset!
                    // The embedded map has SHA256 keys, but JS looks up by MD5 hash (asset.key)
                    if let embeddedUri = assets[asset.hash], let key = asset.key {
                        // Add MD5 key pointing to embedded asset
                        assets[key] = embeddedUri
                        pulseLog("buildLocalAssetsForUpdate: using embedded fallback for key=\(key.prefix(16))... hash=\(asset.hash.prefix(16))...")
                    } else {
                        pulseLogWarn("buildLocalAssetsForUpdate: file not found AND no embedded fallback for hash=\(asset.hash.prefix(16))...")
                    }
                }
            }
        } catch {
            pulseLogError("buildLocalAssetsForUpdate error: \(error)")
        }

        return assets
    }

    /// Build localAssets map from embedded manifest for fallback
    /// Key is the manifest key (MD5 hash) which matches how expo-asset looks up assets
    private func buildEmbeddedLocalAssets() -> [String: String] {
        guard let manifest = embeddedManifest else { return [:] }

        var assets: [String: String] = [:]
        let bundleRoot = Bundle.main.bundlePath

        for asset in manifest.assets where !asset.isLaunchAsset {
            guard let filename = asset.nsBundleFilename, let assetType = asset.type else {
                continue
            }

            let bundlePath = findBundledAssetPath(
                bundleRoot: bundleRoot,
                filename: filename,
                assetType: assetType,
                nsBundleDir: asset.nsBundleDir
            )

            if let path = bundlePath {
                let localUri = URL(fileURLWithPath: path).absoluteString

                // Store by manifest key (MD5 hash - matches how expo-asset looks up)
                if let key = asset.key {
                    assets[key] = localUri
                }

                // Also store by SHA256 hash for fallback
                assets[asset.hash] = localUri
            }
        }

        return assets
    }

    /// Start the controller (call after configure, or auto-starts with native config)
    public func start() {
        guard !isStarted else { return }
        isStarted = true

        pulseLog("Starting controller")

        // Initialize
        createDirectories()
        initializeDatabase()
        loadEmbeddedManifest()

        // Load native config from Info.plist if not already configured by JS
        if config == nil {
            config = loadNativeConfig()
            if let cfg = config {
                isActive = cfg.enabled
                pulseLog("Loaded native config: url=\(cfg.updateUrl) runtime=\(cfg.runtimeVersion)")
            }
        }

        seedEmbeddedUpdateIfNeeded()
        runIntegrityCheck()

        // Create and start error recovery
        errorRecovery = PulseErrorRecovery()
        errorRecovery?.delegate = self
        errorRecovery?.startMonitoring()

        // Launch best available update
        launchBestUpdate { [weak self] success in
            guard let self = self else { return }

            if success {
                pulseLog("Started successfully with update: \(self.launchedUpdate?.updateId ?? "embedded")")
            } else {
                pulseLog("Failed to start, falling back to embedded")
                self.launchEmbedded()
            }

            self.delegate?.pulseController(self, didStartWithSuccess: success || self.launchAssetUrl != nil)

            // Check for updates on launch based on config
            self.performCheckOnLaunchIfNeeded()
        }
    }

    // MARK: - Check on Launch

    private func performCheckOnLaunchIfNeeded() {
        guard let config = config, config.enabled else { return }

        switch config.checkOnLaunch.uppercased() {
        case "NEVER":
            pulseLog("checkOnLaunch is NEVER, skipping automatic check")
            return
        case "WIFI_ONLY":
            guard isOnWifi() else {
                pulseLog("checkOnLaunch is WIFI_ONLY but not on WiFi, skipping")
                return
            }
        case "ALWAYS":
            break
        default:
            break
        }

        // Wait for launchWaitMs before checking
        let waitMs = config.launchWaitMs
        if waitMs > 0 {
            DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .milliseconds(waitMs)) { [weak self] in
                self?.performBackgroundUpdateCheck()
            }
        } else {
            DispatchQueue.global(qos: .utility).async { [weak self] in
                self?.performBackgroundUpdateCheck()
            }
        }
    }

    private func performBackgroundUpdateCheck() {
        pulseLog("Performing background update check")

        checkForUpdate { [weak self] (result: Result<PulseCheckResult, Error>) in
            switch result {
            case .success(let checkResult):
                if checkResult.isAvailable {
                    pulseLog("Background check: update available, fetching...")
                    self?.fetchUpdate { (fetchResult: Result<PulseFetchResult, Error>) in
                        switch fetchResult {
                        case .success(let fetch):
                            if fetch.isNew {
                                pulseLog("Background check: update downloaded and ready")
                                // Notify via event emitter that update is available
                                NotificationCenter.default.post(
                                    name: NSNotification.Name("PulseUpdatesAvailable"),
                                    object: nil,
                                    userInfo: ["manifest": fetch.manifest as Any]
                                )
                            }
                        case .failure(let error):
                            pulseLog("Background check: fetch failed - \(error)")
                        }
                    }
                } else {
                    pulseLog("Background check: no update available")
                }
            case .failure(let error):
                pulseLog("Background check failed: \(error)")
            }
        }
    }

    private func isOnWifi() -> Bool {
        // Use Network framework for WiFi detection
        let monitor = NWPathMonitor()
        var isWifi = false
        let semaphore = DispatchSemaphore(value: 0)

        monitor.pathUpdateHandler = { path in
            isWifi = path.usesInterfaceType(.wifi)
            semaphore.signal()
        }

        let queue = DispatchQueue(label: "wifi.check")
        monitor.start(queue: queue)
        _ = semaphore.wait(timeout: .now() + 1)
        monitor.cancel()

        return isWifi
    }

    /// Get the bundle URL for React Native
    public func launchAssetURL() -> URL? {
        if launchAssetUrl != nil {
            return launchAssetUrl
        }

        // Fall back to embedded bundle
        return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
    }

    /// Check for updates
    public func checkForUpdate(completion: @escaping (Result<PulseCheckResult, Error>) -> Void) {
        guard let config = config else {
            completion(.failure(PulseUpdatesError.notConfigured))
            return
        }

        guard config.enabled else {
            completion(.failure(PulseUpdatesError.notEnabled))
            return
        }

        // Clear any previous cached manifest before new check
        lastCheckManifest = nil

        PulseRemoteLoader.checkForUpdate(
            config: config,
            currentUpdateId: launchedUpdate?.updateId
        ) { [weak self] (result: Result<PulseCheckResult, Error>) in
            guard let self = self else {
                completion(result)
                return
            }

            // Cache the manifest for subsequent fetchUpdate call
            if case .success(let checkResult) = result, checkResult.isAvailable, let otaManifest = checkResult.manifest {
                // Compare commitTime: if embedded is newer, don't show update
                let embeddedCommitTime = self.embeddedManifest?.commitTime ?? Date.distantPast
                let otaCommitTime = otaManifest.commitTime ?? Date.distantPast

                if embeddedCommitTime >= otaCommitTime {
                    pulseLog("checkForUpdate: embedded is newer or same (embedded=\(embeddedCommitTime) >= ota=\(otaCommitTime)), no update needed")
                    completion(.success(PulseCheckResult(isAvailable: false)))
                    return
                }

                self.lastCheckManifest = otaManifest
                self.reportEvent("check", updateId: otaManifest.updateId)
                pulseLog("checkForUpdate: cached manifest for fetch (ota=\(otaCommitTime) > embedded=\(embeddedCommitTime))")
            }
            completion(result)
        }
    }

    /// Fetch and download an update
    /// - Parameter cachedManifest: Optional manifest from a previous checkForUpdate call to avoid duplicate request
    public func fetchUpdate(cachedManifest: PulseManifestModel? = nil, completion: @escaping (Result<PulseFetchResult, Error>) -> Void) {
        guard let config = config else {
            completion(.failure(PulseUpdatesError.notConfigured))
            return
        }

        guard config.enabled else {
            completion(.failure(PulseUpdatesError.notEnabled))
            return
        }

        // Thread-safe check and set of isFetching flag
        fetchLock.lock()
        if isFetching {
            pendingFetchCallbacks.append(completion)
            fetchLock.unlock()
            pulseLog("fetchUpdate: already fetching, queuing callback")
            return
        }
        isFetching = true
        fetchLock.unlock()

        // Use cached manifest from recent checkForUpdate if available
        var manifestToUse = cachedManifest
        if manifestToUse == nil, let cached = lastCheckManifest {
            pulseLog("fetchUpdate: using cached manifest from recent check")
            manifestToUse = cached
        }

        // Clear cache after use
        lastCheckManifest = nil

        PulseRemoteLoader.fetchUpdate(
            config: config,
            database: database,
            directory: directory,
            cachedManifest: manifestToUse
        ) { [weak self] result in
            guard let self = self else {
                completion(result)
                return
            }

            // Thread-safe reset of isFetching and get pending callbacks
            self.fetchLock.lock()
            self.isFetching = false
            let callbacks = self.pendingFetchCallbacks
            self.pendingFetchCallbacks.removeAll()
            self.fetchLock.unlock()

            // A freshly downloaded update marks this device "served" on the server, which is what
            // gates its launch outcomes into the crash-rate auto-rollback.
            if case .success(let fetchResult) = result, fetchResult.isNew {
                self.reportEvent("download", updateId: fetchResult.manifest?.updateId)
            }

            // Call original completion
            completion(result)

            // Call all queued callbacks with the same result
            for callback in callbacks {
                callback(result)
            }
        }
    }

    /// Reload the app with the new update
    public func reload(completion: ((Bool) -> Void)? = nil) {
        // Re-select the best update before reloading
        launchBestUpdate { success in
            // Signal React Native to reload with the new bundle
            NotificationCenter.default.post(name: NSNotification.Name("PulseUpdatesReload"), object: nil)
            completion?(success)
        }
    }

    /// Mark the app as ready (error recovery)
    /// Note: With the new error recovery system, this is handled automatically
    /// via RCTContentDidAppear notification. This method is kept for manual marking if needed.
    public func markAppReady() {
        if let updateId = launchedUpdate?.updateId {
            try? database?.recordSuccessfulLaunch(updateId: updateId)
            if !isEmbeddedLaunch { reportEvent("launch_success", updateId: updateId) }
        }

        // Run reaper after successful launch
        runReaper()
    }

    /// Fire-and-forget device telemetry to the server's /pulse/events endpoint (async URLSession, so
    /// it never blocks). Best-effort: failures are logged, never silently swallowed, and never affect
    /// the app. The server marks a device "served" on the "download" event — only served devices count
    /// toward crash-rate auto-rollback — and uses launch_success/launch_failure to drive that rate.
    /// appSlug + base are derived from the manifest URL (.../pulse/manifest/{appSlug}).
    private func reportEvent(_ type: String, updateId: String?) {
        guard let updateId = updateId, !updateId.isEmpty, let config = config else { return }
        let marker = "/pulse/manifest/"
        guard let range = config.updateUrl.range(of: marker) else {
            pulseLog("reportEvent(\(type)): updateUrl '\(config.updateUrl)' has no \(marker) — skipping")
            return
        }
        let base = String(config.updateUrl[..<range.lowerBound])
        let appSlug = String(config.updateUrl[range.upperBound...].prefix(while: { $0 != "/" && $0 != "?" }))
        guard let eventsUrl = URL(string: "\(base)/pulse/events") else { return }
        let body: [String: Any] = ["appSlug": appSlug, "updateId": updateId, "deviceId": deviceId, "type": type]
        guard let httpBody = try? JSONSerialization.data(withJSONObject: body) else { return }
        var req = URLRequest(url: eventsUrl)
        req.httpMethod = "POST"
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.timeoutInterval = 5
        req.httpBody = httpBody
        URLSession.shared.dataTask(with: req) { _, response, error in
            if let error = error {
                pulseLog("reportEvent(\(type)) failed: \(error.localizedDescription)")
            } else if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
                pulseLog("reportEvent(\(type)) -> HTTP \(http.statusCode)")
            }
        }.resume()
    }

    /// Report a fatal error for error recovery handling
    public func reportError(_ error: NSError) {
        errorRecovery?.handle(error: error)
    }

    /// Report a fatal exception for error recovery handling
    public func reportException(_ exception: NSException) {
        errorRecovery?.handle(exception: exception)
    }

    // MARK: - Private Methods

    private func createDirectories() {
        let dirs = [
            directory,
            directory.appendingPathComponent("updates"),
            directory.appendingPathComponent("assets/sha256"),
            directory.appendingPathComponent("bundles/sha256"),
            directory.appendingPathComponent("staging")
        ]

        for dir in dirs {
            try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        }
    }

    private func initializeDatabase() {
        do {
            database = PulseDatabase(directory: directory)
            try database?.open()
            pulseLog("Database initialized")
        } catch {
            pulseLog("Failed to initialize database: \(error)")
        }
    }

    private func loadEmbeddedManifest() {
        // Try pulse/ subdirectory first, then root
        let urlInPulse = Bundle.main.url(forResource: "embedded-manifest", withExtension: "json", subdirectory: "pulse")
        let urlInRoot = Bundle.main.url(forResource: "embedded-manifest", withExtension: "json")

        guard let url = urlInPulse ?? urlInRoot,
              let data = try? Data(contentsOf: url),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            pulseLog("No embedded manifest found or failed to parse")
            return
        }

        embeddedManifest = PulseEmbeddedManifest(json: json)

        // Cache embedded asset hashes
        if let manifest = embeddedManifest {
            var foundCount = 0
            var notFoundCount = 0
            let bundleRoot = Bundle.main.bundlePath

            for asset in manifest.assets {
                if let filename = asset.nsBundleFilename, let assetType = asset.type {
                    let bundlePath = findBundledAssetPath(
                        bundleRoot: bundleRoot,
                        filename: filename,
                        assetType: assetType,
                        nsBundleDir: asset.nsBundleDir
                    )

                    if let path = bundlePath {
                        embeddedAssetHashes[asset.hash.lowercased()] = URL(fileURLWithPath: path)
                        foundCount += 1
                    } else {
                        notFoundCount += 1
                    }
                } else {
                    if !asset.isLaunchAsset {
                        notFoundCount += 1
                    }
                }

                if asset.isLaunchAsset {
                    embeddedBundleHash = asset.hash.lowercased()
                }
            }
            pulseLog("Assets found in bundle: \(foundCount), not found: \(notFoundCount)")
        }

        pulseLog("Loaded embedded manifest: \(embeddedManifest?.updateId ?? "unknown"), embeddedAssetHashes count: \(embeddedAssetHashes.count)")
    }

    private func seedEmbeddedUpdateIfNeeded() {
        guard let embedded = embeddedManifest,
              let database = database else {
            return
        }

        let embeddedId = "embedded:\(embedded.updateId)"

        // Check if already seeded
        if let _ = try? database.update(withId: embeddedId) {
            return
        }

        // Create embedded update record
        // scopeKey from config, or default to "default" if not configured yet
        let update = PulseUpdate(
            updateId: embeddedId,
            scopeKey: config?.scopeKey ?? "default",
            runtimeVersion: embedded.runtimeVersion,
            commitTime: embedded.commitTime,
            status: .embedded,
            manifest: nil,
            bundleHash: embeddedBundleHash,
            lastAccessed: Date(),
            successfulLaunchCount: 0,
            failedLaunchCount: 0
        )

        do {
            try database.addUpdate(update)
            pulseLog("Seeded embedded update: \(embeddedId)")
        } catch {
            pulseLog("Failed to seed embedded update: \(error)")
        }
    }

    private func runIntegrityCheck() {
        guard let database = database else { return }

        let currentEmbeddedId = embeddedManifest.map { "embedded:\($0.updateId)" }

        PulseIntegrityCheck.run(
            database: database,
            directory: directory,
            currentEmbeddedId: currentEmbeddedId
        )
    }

    private func launchBestUpdate(completion: @escaping (Bool) -> Void) {
        guard let database = database,
              let config = config else {
            completion(false)
            return
        }

        let launcher = PulseAppLauncher(
            database: database,
            directory: directory,
            selectionPolicy: PulseDefaultSelectionPolicy(),
            runtimeVersion: config.runtimeVersion
        )

        self.launcher = launcher

        launcher.launch { [weak self] (success: Bool, error: Error?) in
            guard let self = self else {
                completion(false)
                return
            }

            if success {
                self.launchAssetUrl = launcher.launchAssetUrl
                self.localAssets = launcher.assetFilesMap
                self.isEmbeddedLaunch = launcher.isUsingEmbeddedAssets

                if let update = launcher.launchedUpdate {
                    self.launchedUpdate = PulseUpdateInfo(
                        updateId: update.updateId,
                        runtimeVersion: update.runtimeVersion,
                        commitTime: update.commitTime,
                        status: update.status,
                        successfulLaunchCount: update.successfulLaunchCount,
                        failedLaunchCount: update.failedLaunchCount,
                        isEmbedded: update.isEmbedded,
                        bundleHash: update.bundleHash,
                        scopeKey: update.scopeKey
                    )

                    // Load manifest from database for metadata (build number, etc.)
                    if let manifest = update.manifest,
                       let data = try? JSONSerialization.data(withJSONObject: manifest, options: []),
                       let jsonString = String(data: data, encoding: .utf8) {
                        self.launchedManifestJson = jsonString
                    }
                }
            }

            if let error = error {
                pulseLog("Launch error: \(error)")
            }

            completion(success)
        }
    }

    private func launchEmbedded() {
        launchAssetUrl = Bundle.main.url(forResource: "main", withExtension: "jsbundle")
        isEmbeddedLaunch = true

        // Build local assets from embedded manifest - use KEY for React Native compatibility
        if let manifest = embeddedManifest {
            for asset in manifest.assets where !asset.isLaunchAsset {
                if let key = asset.key, let url = embeddedAssetHashes[asset.hash.lowercased()] {
                    // Use KEY as dictionary key - React Native asset resolver looks up by key
                    localAssets[key] = url.absoluteString
                }
            }
        }
    }

    private func rollbackToPreviousUpdate() {
        pulseLog("Rolling back to previous update")

        // Mark the currently launched update as failed so the selection policy excludes it
        // (failedLaunchCount > 0 && successfulLaunchCount == 0) on the next selection.
        if let updateId = launchedUpdate?.updateId, !(launchedUpdate?.isEmbedded ?? true) {
            try? database?.recordFailedLaunch(updateId: updateId)
            try? database?.setStatus(.failed, forUpdateId: updateId)
            reportEvent("launch_failure", updateId: updateId)
            pulseLog("rollbackToPreviousUpdate: marked \(updateId) failed")
        }

        // Clear the current selection and re-select the best remaining update (previous-good, else embedded).
        launchAssetUrl = nil
        launchedUpdate = nil
        launchedManifestJson = nil
        isEmbeddedLaunch = true
        localAssets = [:]

        selectBestUpdateSync()
        if launchAssetUrl == nil {
            launchEmbedded()
        }

        // Ask React Native to reload with the rolled-back bundle.
        NotificationCenter.default.post(name: NSNotification.Name("PulseUpdatesReloadRequest"), object: nil)
    }

    private func runReaper() {
        guard let database = database,
              let launchedUpdate = launchedUpdate else {
            return
        }

        PulseReaper.reap(
            database: database,
            directory: directory,
            selectionPolicy: PulseDefaultSelectionPolicy(),
            launchedUpdateId: launchedUpdate.updateId
        )
    }
}

// MARK: - Integrity Check

final class PulseIntegrityCheck {
    static func run(database: PulseDatabase, directory: URL, currentEmbeddedId: String?) {
        DispatchQueue.global(qos: .utility).async {
            do {
                // Delete old embedded updates
                let embeddedUpdates = try database.embeddedUpdates()
                let toDelete = embeddedUpdates.filter { update in
                    guard let currentId = currentEmbeddedId else { return true }
                    return update.updateId != currentId
                }

                if !toDelete.isEmpty {
                    try database.deleteUpdates(toDelete.map { $0.updateId })
                    pulseLog("IntegrityCheck: Deleted \(toDelete.count) old embedded updates")

                    // Clean up unused assets
                    _ = try database.deleteUnusedAssets()
                }
            } catch {
                pulseLog("IntegrityCheck error: \(error)")
            }
        }
    }
}

// MARK: - Reaper

final class PulseReaper {
    /// Clean up old updates after successful launch
    /// Based on Expo's UpdatesReaper
    static func reap(
        database: PulseDatabase,
        directory: URL,
        selectionPolicy: PulseSelectionPolicy,
        launchedUpdateId: String
    ) {
        DispatchQueue.global(qos: .utility).async {
            do {
                let allUpdates = try database.allUpdates()

                guard let launchedUpdate = allUpdates.first(where: { $0.updateId == launchedUpdateId }) else {
                    return
                }

                // Convert to PulseUpdateInfo for selection policy
                let launchedInfo = PulseUpdateInfo(
                    updateId: launchedUpdate.updateId,
                    runtimeVersion: launchedUpdate.runtimeVersion,
                    commitTime: launchedUpdate.commitTime,
                    status: launchedUpdate.status,
                    successfulLaunchCount: launchedUpdate.successfulLaunchCount,
                    failedLaunchCount: launchedUpdate.failedLaunchCount,
                    isEmbedded: launchedUpdate.isEmbedded,
                    bundleHash: launchedUpdate.bundleHash,
                    scopeKey: launchedUpdate.scopeKey
                )

                let allInfos = allUpdates.map { update in
                    PulseUpdateInfo(
                        updateId: update.updateId,
                        runtimeVersion: update.runtimeVersion,
                        commitTime: update.commitTime,
                        status: update.status,
                        successfulLaunchCount: update.successfulLaunchCount,
                        failedLaunchCount: update.failedLaunchCount,
                        isEmbedded: update.isEmbedded,
                        bundleHash: update.bundleHash,
                        scopeKey: update.scopeKey
                    )
                }

                let toDeleteInfos = selectionPolicy.selectUpdatesToDelete(
                    launchedUpdate: launchedInfo,
                    allUpdates: allInfos
                )

                if toDeleteInfos.isEmpty {
                    return
                }

                let toDeleteIds = toDeleteInfos.map { $0.updateId }
                try database.deleteUpdates(toDeleteIds)

                // Delete unused assets from filesystem
                let unusedAssets = try database.deleteUnusedAssets()
                for asset in unusedAssets {
                    let assetPath = directory
                        .appendingPathComponent("assets/sha256")
                        .appendingPathComponent(asset.hash.lowercased())
                    try? FileManager.default.removeItem(at: assetPath)
                }

                // Also clean up unused bundles
                cleanupUnusedBundles(database: database, directory: directory)

                pulseLog("Reaper: Deleted \(toDeleteIds.count) updates, \(unusedAssets.count) assets")
            } catch {
                pulseLog("Reaper error: \(error)")
            }
        }
    }

    /// Clean up bundle files that are no longer referenced
    private static func cleanupUnusedBundles(database: PulseDatabase, directory: URL) {
        do {
            let allUpdates = try database.allUpdates()
            let referencedHashes = Set(allUpdates.compactMap { $0.bundleHash?.lowercased() })

            let bundlesDir = directory.appendingPathComponent("bundles/sha256")
            guard let items = try? FileManager.default.contentsOfDirectory(atPath: bundlesDir.path) else {
                return
            }

            for item in items {
                if !referencedHashes.contains(item.lowercased()) {
                    let bundlePath = bundlesDir.appendingPathComponent(item)
                    try? FileManager.default.removeItem(at: bundlePath)
                    pulseLog("Reaper: Deleted unused bundle \(item)")
                }
            }
        } catch {
            pulseLog("Reaper bundle cleanup error: \(error)")
        }
    }
}

// MARK: - Remote Loader

final class PulseRemoteLoader {

    static func checkForUpdate(
        config: PulseUpdatesConfig,
        currentUpdateId: String?,
        completion: @escaping (Result<PulseCheckResult, Error>) -> Void
    ) {
        guard let request = buildManifestRequest(config: config, currentUpdateId: currentUpdateId) else {
            completion(.failure(PulseUpdatesError.invalidManifest(reason: "Invalid update URL")))
            return
        }

        URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                completion(.failure(PulseUpdatesError.networkError(underlying: error)))
                return
            }

            guard let httpResponse = response as? HTTPURLResponse else {
                completion(.success(PulseCheckResult(isAvailable: false)))
                return
            }

            // 204 = no update available
            if httpResponse.statusCode == 204 {
                completion(.success(PulseCheckResult(isAvailable: false)))
                return
            }

            guard httpResponse.statusCode == 200, let data = data else {
                completion(.success(PulseCheckResult(isAvailable: false)))
                return
            }

            do {
                let manifest = try JSONDecoder().decode(PulseManifestModel.self, from: data)

                // Check runtime version
                if manifest.runtimeVersion != config.runtimeVersion {
                    pulseLog("Runtime version mismatch: manifest=\(manifest.runtimeVersion) expected=\(config.runtimeVersion)")
                    completion(.success(PulseCheckResult(isAvailable: false)))
                    return
                }

                // Signature gate (fail-closed in release).
                // If signatures are required but no key material is configured, or the
                // manifest is unsigned, REFUSE the update so the app keeps running the
                // embedded/known-good bundle instead of applying an unverified one.
                let hasKeyMaterial = config.signingPublicKey != nil && config.signingKeyId != nil
                if config.requireSignature {
                    guard hasKeyMaterial else {
                        pulseLogError("REFUSING remote update \(manifest.updateId): requireSignature is enabled but signingPublicKey/signingKeyId are not configured. Falling back to the embedded bundle. Configure PulseUpdatesSigningPublicKey and PulseUpdatesSigningKeyId, or set PulseUpdatesRequireSignature=NO to intentionally allow unsigned updates.")
                        completion(.failure(PulseUpdatesError.signatureRequiredButMissing))
                        return
                    }
                    guard manifest.signature != nil else {
                        pulseLogError("REFUSING remote update \(manifest.updateId): requireSignature is enabled but the manifest carries no signature. Falling back to the embedded bundle.")
                        completion(.failure(PulseUpdatesError.signatureRequiredButMissing))
                        return
                    }
                    guard verifyManifestSignature(manifest: manifest, manifestJson: data, config: config) else {
                        completion(.failure(PulseUpdatesError.signatureVerificationFailed))
                        return
                    }
                } else if hasKeyMaterial, manifest.signature != nil {
                    // Signatures not strictly required (e.g. DEBUG), but key material is
                    // present, so still verify WHEN a signature is provided. An unsigned
                    // manifest is allowed through here (verifyManifestSignature would reject
                    // a nil signature, which would wrongly block unsigned DEBUG updates).
                    guard verifyManifestSignature(manifest: manifest, manifestJson: data, config: config) else {
                        completion(.failure(PulseUpdatesError.signatureVerificationFailed))
                        return
                    }
                }

                // Check if this is the same update
                if manifest.updateId == currentUpdateId {
                    completion(.success(PulseCheckResult(isAvailable: false)))
                    return
                }

                pulseLog("Update available: \(manifest.updateId)")
                completion(.success(PulseCheckResult(isAvailable: true, manifest: manifest)))

            } catch {
                completion(.failure(PulseUpdatesError.invalidManifest(reason: error.localizedDescription)))
            }
        }.resume()
    }

    static func fetchUpdate(
        config: PulseUpdatesConfig,
        database: PulseDatabase?,
        directory: URL,
        cachedManifest: PulseManifestModel? = nil,
        completion: @escaping (Result<PulseFetchResult, Error>) -> Void
    ) {
        // If we already have a manifest from a previous check, use it directly
        if let manifest = cachedManifest {
            pulseLog("fetchUpdate: using cached manifest, skipping check")
            downloadUpdate(manifest: manifest, config: config, database: database, directory: directory) { (downloadResult: Result<Void, Error>) in
                switch downloadResult {
                case .failure(let error):
                    completion(.failure(error))
                case .success:
                    completion(.success(PulseFetchResult(isNew: true, manifest: manifest)))
                }
            }
            return
        }

        // No cached manifest, need to check first
        checkForUpdate(config: config, currentUpdateId: PulseController.shared.launchedUpdate?.updateId) { (result: Result<PulseCheckResult, Error>) in
            switch result {
            case .failure(let error):
                completion(.failure(error))

            case .success(let checkResult):
                guard checkResult.isAvailable, let manifest = checkResult.manifest else {
                    completion(.success(PulseFetchResult(isNew: false)))
                    return
                }

                // Download the update
                downloadUpdate(manifest: manifest, config: config, database: database, directory: directory) { (downloadResult: Result<Void, Error>) in
                    switch downloadResult {
                    case .failure(let error):
                        completion(.failure(error))
                    case .success:
                        completion(.success(PulseFetchResult(isNew: true, manifest: manifest)))
                    }
                }
            }
        }
    }

    // MARK: - Private Methods

    private static func buildManifestRequest(config: PulseUpdatesConfig, currentUpdateId: String?) -> URLRequest? {
        guard let url = URL(string: config.updateUrl) else { return nil }

        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.setValue("2", forHTTPHeaderField: "Pulse-Protocol-Version")
        request.setValue("ios", forHTTPHeaderField: "X-Pulse-Platform")
        request.setValue(config.runtimeVersion, forHTTPHeaderField: "X-Pulse-Runtime-Version")
        // Stable device id so the server can bucket this device into staged rollouts (without it,
        // a device is held back from any partial rollout until it reaches 100%).
        request.setValue(PulseController.shared.deviceId, forHTTPHeaderField: "Pulse-Device-Id")

        if let channel = config.channel {
            request.setValue(channel, forHTTPHeaderField: "X-Pulse-Channel-Name")
        }

        if let currentId = currentUpdateId {
            request.setValue(currentId, forHTTPHeaderField: "X-Pulse-Current-Update-Id")
        }

        return request
    }

    private static func downloadUpdate(
        manifest: PulseManifestModel,
        config: PulseUpdatesConfig,
        database: PulseDatabase?,
        directory: URL,
        completion: @escaping (Result<Void, Error>) -> Void
    ) {
        let updateId = manifest.updateId
        let bundleHash = manifest.bundle.hash.lowercased()
        let stagingDir = directory.appendingPathComponent("staging/\(updateId)")

        // Create staging directory
        try? FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true)

        // Convert manifest to dictionary for storage
        var manifestDict: [String: Any]?
        if let data = try? JSONEncoder().encode(manifest),
           let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
            manifestDict = dict
        }

        // Store update in database
        let update = PulseUpdate(
            updateId: updateId,
            scopeKey: config.scopeKey,
            runtimeVersion: manifest.runtimeVersion,
            commitTime: manifest.commitTime ?? Date(),
            status: .downloading,
            manifest: manifestDict,
            bundleHash: bundleHash,
            lastAccessed: Date(),
            successfulLaunchCount: 0,
            failedLaunchCount: 0
        )

        try? database?.addUpdate(update)

        // Download bundle
        downloadBundle(manifest: manifest, directory: directory, stagingDir: stagingDir, database: database, updateId: updateId) { (bundleResult: Result<Void, Error>) in
            switch bundleResult {
            case .failure(let error):
                try? database?.setStatus(.failed, forUpdateId: updateId)
                cleanupStaging(stagingDir)
                completion(.failure(error))

            case .success:
                // Download assets
                downloadAssets(manifest: manifest, directory: directory, stagingDir: stagingDir, database: database, updateId: updateId) { (assetsResult: Result<Void, Error>) in
                    switch assetsResult {
                    case .failure(let error):
                        try? database?.setStatus(.failed, forUpdateId: updateId)
                        cleanupStaging(stagingDir)
                        completion(.failure(error))

                    case .success:
                        try? database?.setStatus(.ready, forUpdateId: updateId)
                        cleanupStaging(stagingDir)
                        pulseLog("Update \(updateId) downloaded and ready")
                        completion(.success(()))
                    }
                }
            }
        }
    }

    private static func downloadBundle(
        manifest: PulseManifestModel,
        directory: URL,
        stagingDir: URL,
        database: PulseDatabase?,
        updateId: String,
        completion: @escaping (Result<Void, Error>) -> Void
    ) {
        let bundleHash = manifest.bundle.hash.lowercased()
        // Store bundle in assets directory (same as other assets) so launcher can find it
        let destination = directory.appendingPathComponent("assets/sha256/\(bundleHash)")

        // Store bundle as launch asset in database
        func storeBundleInDatabase() {
            var dbAsset = PulseAsset(key: "bundle", hash: bundleHash)
            dbAsset.type = "application/javascript"

            if let assetId = try? database?.addAsset(dbAsset) {
                try? database?.linkAsset(assetId: assetId, toUpdate: updateId, assetKey: "bundle", assetHash: bundleHash, isLaunchAsset: true)
            }
        }

        // Already cached?
        if FileManager.default.fileExists(atPath: destination.path) {
            storeBundleInDatabase()
            completion(.success(()))
            return
        }

        guard let url = URL(string: manifest.bundle.url) else {
            completion(.failure(PulseUpdatesError.downloadFailed(underlying: NSError(domain: "PulseUpdates", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid bundle URL"]))))
            return
        }

        let tempPath = stagingDir.appendingPathComponent("bundle.tmp")

        downloadFile(from: url, to: tempPath) { (result: Result<Void, Error>) in
            switch result {
            case .failure(let error):
                completion(.failure(error))
            case .success:
                // Verify hash
                guard let fileHash = sha256Hex(fileUrl: tempPath), fileHash == bundleHash else {
                    completion(.failure(PulseUpdatesError.hashMismatch(expected: bundleHash, actual: "unknown")))
                    return
                }

                // Move to final destination
                do {
                    try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
                    if FileManager.default.fileExists(atPath: destination.path) {
                        try FileManager.default.removeItem(at: destination)
                    }
                    try FileManager.default.moveItem(at: tempPath, to: destination)
                } catch {
                    completion(.failure(error))
                    return
                }

                // Store in database as launch asset
                storeBundleInDatabase()

                completion(.success(()))
            }
        }
    }

    private static func downloadAssets(
        manifest: PulseManifestModel,
        directory: URL,
        stagingDir: URL,
        database: PulseDatabase?,
        updateId: String,
        completion: @escaping (Result<Void, Error>) -> Void
    ) {
        let assets = manifest.assets
        guard !assets.isEmpty else {
            completion(.success(()))
            return
        }

        let group = DispatchGroup()
        var downloadError: Error?
        let embeddedHashes = PulseController.shared.embeddedAssetHashes

        for asset in assets {
            group.enter()

            let assetHash = asset.hash.lowercased()
            let destination = directory.appendingPathComponent("assets/sha256/\(assetHash)")

            // Already cached?
            if FileManager.default.fileExists(atPath: destination.path) {
                // Link to update
                storeAssetInDatabase(asset: asset, hash: assetHash, database: database, updateId: updateId)
                group.leave()
                continue
            }

            // Already exists in embedded assets? Skip download and use embedded
            if PulseController.shared.embeddedAssetHashes[assetHash] != nil {
                pulseLog("Asset \(assetHash.prefix(16))... exists in embedded, skipping download")
                storeAssetInDatabase(asset: asset, hash: assetHash, database: database, updateId: updateId)
                group.leave()
                continue
            }

            guard let url = URL(string: asset.url) else {
                group.leave()
                continue
            }

            let tempPath = stagingDir.appendingPathComponent("asset-\(assetHash).tmp")

            downloadFile(from: url, to: tempPath) { (result: Result<Void, Error>) in
                switch result {
                case .failure(let error):
                    downloadError = error
                case .success:
                    // Verify hash
                    if let fileHash = sha256Hex(fileUrl: tempPath), fileHash == assetHash {
                        try? FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
                        try? FileManager.default.removeItem(at: destination)
                        try? FileManager.default.moveItem(at: tempPath, to: destination)

                        storeAssetInDatabase(asset: asset, hash: assetHash, database: database, updateId: updateId)
                    } else {
                        downloadError = PulseUpdatesError.hashMismatch(expected: assetHash, actual: "unknown")
                    }
                }
                group.leave()
            }
        }

        group.notify(queue: .global(qos: .userInitiated)) {
            if let error = downloadError {
                completion(.failure(error))
            } else {
                completion(.success(()))
            }
        }
    }

    private static func storeAssetInDatabase(asset: PulseAssetModel, hash: String, database: PulseDatabase?, updateId: String) {
        var dbAsset = PulseAsset(key: asset.key, hash: hash)
        dbAsset.type = asset.contentType
        dbAsset.nsBundleDir = asset.nsBundleDir
        dbAsset.nsBundleFilename = asset.nsBundleFilename

        if let assetId = try? database?.addAsset(dbAsset) {
            try? database?.linkAsset(assetId: assetId, toUpdate: updateId, assetKey: asset.key, assetHash: hash, isLaunchAsset: false)
        }
    }

    private static func downloadFile(from url: URL, to destination: URL, completion: @escaping (Result<Void, Error>) -> Void) {
        URLSession.shared.downloadTask(with: url) { tempUrl, response, error in
            if let error = error {
                completion(.failure(PulseUpdatesError.downloadFailed(underlying: error)))
                return
            }

            if let httpResponse = response as? HTTPURLResponse,
               httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
                completion(.failure(PulseUpdatesError.downloadFailed(underlying: NSError(domain: "HTTP", code: httpResponse.statusCode, userInfo: nil))))
                return
            }

            guard let tempUrl = tempUrl else {
                completion(.failure(PulseUpdatesError.downloadFailed(underlying: NSError(domain: "PulseUpdates", code: 0, userInfo: [NSLocalizedDescriptionKey: "No download file"]))))
                return
            }

            do {
                try? FileManager.default.removeItem(at: destination)
                try? FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
                try FileManager.default.moveItem(at: tempUrl, to: destination)
                completion(.success(()))
            } catch {
                completion(.failure(PulseUpdatesError.downloadFailed(underlying: error)))
            }
        }.resume()
    }

    private static func cleanupStaging(_ dir: URL) {
        try? FileManager.default.removeItem(at: dir)
    }

    private static func sha256Hex(fileUrl: URL) -> String? {
        guard let handle = try? FileHandle(forReadingFrom: fileUrl) else { return nil }
        defer { try? handle.close() }

        var hasher = SHA256()
        while true {
            let data = try? handle.read(upToCount: 1024 * 1024)
            guard let chunk = data, !chunk.isEmpty else { break }
            hasher.update(data: chunk)
        }
        let digest = hasher.finalize()
        return digest.map { String(format: "%02x", $0) }.joined()
    }

    private static func verifyManifestSignature(manifest: PulseManifestModel, manifestJson: Data, config: PulseUpdatesConfig) -> Bool {
        guard let signature = manifest.signature else { return false }
        guard signature.alg.lowercased() == "ed25519" else { return false }
        guard let keyId = config.signingKeyId, signature.keyId == keyId else { return false }
        guard let publicKeyBase64 = config.signingPublicKey,
              let publicKeyData = Data(base64Encoded: publicKeyBase64),
              let signatureData = Data(base64Encoded: signature.sig) else { return false }

        let canonical = canonicalizeManifestJson(manifestJson)

        guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData) else { return false }
        return publicKey.isValidSignature(signatureData, for: canonical)
    }

    private static func canonicalizeManifestJson(_ data: Data) -> Data {
        guard let json = try? JSONSerialization.jsonObject(with: data),
              var dict = json as? [String: Any] else { return data }

        dict.removeValue(forKey: "signature")
        let canonical = canonicalizeJson(dict)
        return canonical.data(using: .utf8) ?? data
    }

    private static func canonicalizeJson(_ value: Any) -> String {
        if let dict = value as? [String: Any] {
            let keys = dict.keys.sorted()
            let items = keys.compactMap { key -> String? in
                guard let val = dict[key] else { return nil }
                return "\"\(escapeJson(key))\":\(canonicalizeJson(val))"
            }
            return "{\(items.joined(separator: ","))}"
        }

        if let array = value as? [Any] {
            return "[\(array.map { canonicalizeJson($0) }.joined(separator: ","))]"
        }

        if value is NSNull { return "null" }

        if let number = value as? NSNumber {
            if CFGetTypeID(number) == CFBooleanGetTypeID() {
                return number.boolValue ? "true" : "false"
            }
            return number.stringValue
        }

        if let string = value as? String {
            return "\"\(escapeJson(string))\""
        }

        return "null"
    }

    private static func escapeJson(_ input: String) -> String {
        var result = ""
        for char in input {
            switch char {
            case "\"": result += "\\\""
            case "\\": result += "\\\\"
            case "\n": result += "\\n"
            case "\r": result += "\\r"
            case "\t": result += "\\t"
            default:
                if char.asciiValue ?? 0 < 32 {
                    result += String(format: "\\u%04x", char.asciiValue ?? 0)
                } else {
                    result.append(char)
                }
            }
        }
        return result
    }
}

// MARK: - Error Recovery Delegate

extension PulseController: PulseErrorRecoveryDelegate {

    func getLaunchedUpdate() -> PulseUpdateInfo? {
        return self.launchedUpdate
    }

    var checkOnLaunch: String {
        return config?.checkOnLaunch ?? "ALWAYS"
    }

    func relaunch(completion: @escaping (Error?, Bool) -> Void) {
        // Try to launch a different update from cache
        launchBestUpdate { success in
            if success {
                // Trigger a reload of React Native
                NotificationCenter.default.post(
                    name: NSNotification.Name("PulseUpdatesReloadRequest"),
                    object: nil
                )
            }
            completion(nil, success)
        }
    }

    func loadRemoteUpdate() {
        remoteLoadStatus = .loading
        fetchUpdate { [weak self] result in
            switch result {
            case .success(let fetchResult):
                self?.remoteLoadStatus = fetchResult.isNew ? .newUpdateLoaded : .idle
            case .failure:
                self?.remoteLoadStatus = .idle
            }
            self?.errorRecovery?.notify(newRemoteLoadStatus: self?.remoteLoadStatus ?? .idle)
        }
    }

    func markFailedLaunchForLaunchedUpdate() {
        guard let updateId = launchedUpdate?.updateId else { return }
        try? database?.recordFailedLaunch(updateId: updateId)
    }

    func markSuccessfulLaunchForLaunchedUpdate() {
        guard let updateId = launchedUpdate?.updateId else { return }
        try? database?.recordSuccessfulLaunch(updateId: updateId)
    }

    func throwException(_ exception: NSException) {
        exception.raise()
    }
}

import CryptoKit
