// PulseUpdates App Launcher
// Based on expo-updates AppLauncherWithDatabase
// Handles launching updates with asset fallback support

import Foundation

// MARK: - Launcher Protocol

protocol PulseAppLauncherDelegate: AnyObject {
    func appLauncher(_ launcher: PulseAppLauncher, didFinishWithSuccess success: Bool, error: Error?)
}

// MARK: - App Launcher

final class PulseAppLauncher {

    // MARK: - Public Properties

    weak var delegate: PulseAppLauncherDelegate?

    /// The update that was selected to launch
    private(set) var launchedUpdate: PulseUpdate?

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

    /// Map of asset keys to local file URLs (for expo-asset compatibility)
    private(set) var assetFilesMap: [String: String] = [:]

    /// Whether using embedded assets only (no downloaded assets)
    var isUsingEmbeddedAssets: Bool {
        return launchedUpdate?.isEmbedded ?? true
    }

    // MARK: - Private Properties

    private let database: PulseDatabase
    private let directory: URL
    private let selectionPolicy: PulseSelectionPolicy
    private let runtimeVersion: String

    private let launcherQueue = DispatchQueue(label: "app.pulse.launcher")
    private var completedAssets = 0
    private var launchError: Error?

    // Embedded manifest cache
    private var embeddedManifest: PulseEmbeddedManifest?

    // MARK: - Initialization

    init(
        database: PulseDatabase,
        directory: URL,
        selectionPolicy: PulseSelectionPolicy,
        runtimeVersion: String
    ) {
        self.database = database
        self.directory = directory
        self.selectionPolicy = selectionPolicy
        self.runtimeVersion = runtimeVersion
    }

    // MARK: - Public Methods

    /// Launch the best available update
    func launch(completion: @escaping (Bool, Error?) -> Void) {
        // Select update to launch
        do {
            let updates = try database.launchableUpdates(forRuntimeVersion: runtimeVersion)

            guard let selectedUpdate = selectionPolicy.selectUpdateToLaunch(from: updates) else {
                completion(false, PulseLauncherError.noLaunchableUpdate)
                return
            }

            launchedUpdate = selectedUpdate
            launchUpdate(selectedUpdate, completion: completion)
        } catch {
            completion(false, error)
        }
    }

    /// Launch a specific update
    func launch(update: PulseUpdate, completion: @escaping (Bool, Error?) -> Void) {
        launchedUpdate = update
        launchUpdate(update, completion: completion)
    }

    // MARK: - Private Methods

    private func launchUpdate(_ update: PulseUpdate, completion: @escaping (Bool, Error?) -> Void) {
        // Mark update as accessed
        try? database.markUpdateAccessed(update.updateId)

        // Handle embedded updates
        if update.status == .embedded {
            launchEmbeddedUpdate(update, completion: completion)
            return
        }

        // Handle development updates (no assets to verify)
        if update.status == .development {
            completion(true, nil)
            return
        }

        // Handle regular updates - ensure all assets exist
        ensureAllAssetsExist(for: update, completion: completion)
    }

    private func launchEmbeddedUpdate(_ update: PulseUpdate, completion: @escaping (Bool, Error?) -> Void) {
        // For embedded updates, use the bundle directly
        if let bundleUrl = Bundle.main.url(forResource: "main", withExtension: "jsbundle") {
            launchAssetUrl = bundleUrl

            // Build asset map from embedded manifest
            buildEmbeddedAssetFilesMap()

            completion(true, nil)
        } else {
            completion(false, PulseLauncherError.embeddedBundleNotFound)
        }
    }

    private func ensureAllAssetsExist(for update: PulseUpdate, completion: @escaping (Bool, Error?) -> Void) {
        do {
            let assets = try database.assets(forUpdateId: update.updateId)

            // Initialize asset map with embedded assets (these are referenced directly, not copied)
            assetFilesMap = buildEmbeddedAssetsMap()

            if assets.isEmpty {
                // No assets to verify, just find the bundle
                if let bundleHash = update.bundleHash {
                    launchAssetUrl = bundleStorePath(hash: bundleHash)
                }
                completion(launchAssetUrl != nil, launchAssetUrl == nil ? PulseLauncherError.bundleNotFound : nil)
                return
            }

            // Separate assets into those we need to verify vs those already in embedded
            var assetsToVerify: [PulseAsset] = []

            for asset in assets {
                if asset.isLaunchAsset {
                    // Always verify the launch asset (bundle) - it must be downloaded
                    assetsToVerify.append(asset)
                } else {
                    let hashKey = asset.hash.lowercased()
                    // If asset hash is already in embedded map, we can use it directly
                    if assetFilesMap[hashKey] != nil {
                        // Already referenced from embedded, no action needed
                        continue
                    }
                    // Check if downloaded version exists
                    let localUrl = assetStorePath(hash: asset.hash)
                    if FileManager.default.fileExists(atPath: localUrl.path) {
                        // Use downloaded version
                        if let key = asset.key {
                            assetFilesMap[key] = localUrl.absoluteString
                        }
                        assetFilesMap[hashKey] = localUrl.absoluteString
                    } else {
                        // Need to verify/download this asset
                        assetsToVerify.append(asset)
                    }
                }
            }

            if assetsToVerify.isEmpty {
                // All assets accounted for, but we need the bundle
                if let bundleHash = update.bundleHash {
                    launchAssetUrl = bundleStorePath(hash: bundleHash)
                    if !FileManager.default.fileExists(atPath: launchAssetUrl!.path) {
                        completion(false, PulseLauncherError.bundleNotFound)
                        return
                    }
                }
                completion(launchAssetUrl != nil, nil)
                return
            }

            completedAssets = 0
            launchError = nil

            let totalAssets = assetsToVerify.count

            for asset in assetsToVerify {
                let localUrl = assetStorePath(hash: asset.hash)

                ensureAssetExists(asset: asset, localUrl: localUrl) { [weak self] exists in
                    guard let self = self else { return }

                    self.launcherQueue.async {
                        self.completedAssets += 1

                        if exists {
                            if asset.isLaunchAsset {
                                self.launchAssetUrl = localUrl
                            } else {
                                let hashKey = asset.hash.lowercased()
                                self.assetFilesMap[hashKey] = localUrl.absoluteString
                                if let key = asset.key {
                                    self.assetFilesMap[key] = localUrl.absoluteString
                                }
                            }
                        }

                        if self.completedAssets == totalAssets {
                            DispatchQueue.main.async {
                                let success = self.launchAssetUrl != nil
                                completion(success, success ? nil : self.launchError ?? PulseLauncherError.launchAssetMissing)
                            }
                        }
                    }
                }
            }
        } catch {
            completion(false, error)
        }
    }

    /// Ensures an asset exists locally, copying from bundle or downloading if needed
    private func ensureAssetExists(asset: PulseAsset, localUrl: URL, completion: @escaping (Bool) -> Void) {
        // Step 1: Check if asset exists at expected location
        checkAssetExists(at: localUrl) { [weak self] exists in
            if exists {
                completion(true)
                return
            }

            // Step 2: Try to copy from main bundle
            self?.maybeCopyAssetFromBundle(asset: asset, toUrl: localUrl) { success in
                if success {
                    completion(true)
                    return
                }

                // Step 3: Try to download the asset
                self?.downloadAsset(asset: asset, toUrl: localUrl) { downloadSuccess, error in
                    if let error = error, asset.isLaunchAsset {
                        self?.launchError = error
                    }
                    completion(downloadSuccess)
                }
            }
        }
    }

    private func checkAssetExists(at url: URL, completion: @escaping (Bool) -> Void) {
        DispatchQueue.global(qos: .userInitiated).async {
            let exists = FileManager.default.fileExists(atPath: url.path)
            self.launcherQueue.async {
                completion(exists)
            }
        }
    }

    /// Try to copy asset from the main bundle (embedded assets)
    private func maybeCopyAssetFromBundle(asset: PulseAsset, toUrl localUrl: URL, completion: @escaping (Bool) -> Void) {
        // Load embedded manifest if not cached
        if embeddedManifest == nil {
            embeddedManifest = loadEmbeddedManifest()
        }

        guard let embedded = embeddedManifest else {
            completion(false)
            return
        }

        // Find matching embedded asset by key or hash
        let matchingAsset = embedded.assets.first { embeddedAsset in
            (asset.key != nil && embeddedAsset.key == asset.key) ||
            embeddedAsset.hash.lowercased() == asset.hash.lowercased()
        }

        guard let match = matchingAsset,
              let bundleFilename = match.nsBundleFilename,
              let assetType = match.type else {
            completion(false)
            return
        }

        // Get bundle path using FileManager (handles deep nested paths)
        DispatchQueue.global(qos: .userInitiated).async {
            let bundleRoot = Bundle.main.bundlePath
            let sourcePath = findBundledAssetPath(
                bundleRoot: bundleRoot,
                filename: bundleFilename,
                assetType: assetType,
                nsBundleDir: match.nsBundleDir
            )

            guard let finalSourcePath = sourcePath else {
                self.launcherQueue.async {
                    completion(false)
                }
                return
            }

            // Copy to local URL
            do {
                // Ensure parent directory exists
                try FileManager.default.createDirectory(
                    at: localUrl.deletingLastPathComponent(),
                    withIntermediateDirectories: true
                )

                // Copy file
                try FileManager.default.copyItem(atPath: finalSourcePath, toPath: localUrl.path)

                self.launcherQueue.async {
                    completion(true)
                }
            } catch {
                pulseLogWarn("Failed to copy asset from bundle: \(error)")
                self.launcherQueue.async {
                    completion(false)
                }
            }
        }
    }

    /// Download asset from remote URL
    private func downloadAsset(asset: PulseAsset, toUrl localUrl: URL, completion: @escaping (Bool, Error?) -> Void) {
        guard let remoteUrl = asset.url else {
            completion(false, PulseLauncherError.assetUrlMissing)
            return
        }

        DispatchQueue.global(qos: .userInitiated).async {
            do {
                // Ensure parent directory exists
                try FileManager.default.createDirectory(
                    at: localUrl.deletingLastPathComponent(),
                    withIntermediateDirectories: true
                )

                // Download file
                let data = try Data(contentsOf: remoteUrl)

                // Verify hash if expected (hex, matching the manifest/DB encoding).
                if let expectedHash = asset.expectedHash {
                    let actualHash = PulseCrypto.sha256Hex(data)
                    if actualHash != expectedHash.lowercased() {
                        self.launcherQueue.async {
                            completion(false, PulseLauncherError.hashMismatch(expected: expectedHash, actual: actualHash))
                        }
                        return
                    }
                }

                // Write to file
                try data.write(to: localUrl)

                self.launcherQueue.async {
                    completion(true, nil)
                }
            } catch {
                pulseLogWarn("Failed to download asset: \(error)")
                self.launcherQueue.async {
                    completion(false, error)
                }
            }
        }
    }

    // MARK: - Asset Path Helpers

    private func assetStorePath(hash: String) -> URL {
        return directory
            .appendingPathComponent("assets")
            .appendingPathComponent("sha256")
            .appendingPathComponent(hash.lowercased())
    }

    private func bundleStorePath(hash: String) -> URL {
        // Bundle is stored in assets directory (same location as other assets)
        return directory
            .appendingPathComponent("assets")
            .appendingPathComponent("sha256")
            .appendingPathComponent(hash.lowercased())
    }

    // MARK: - Embedded Assets Map

    /// Build map of embedded assets (hash -> file URL, key -> file URL)
    /// Uses FileManager to find assets with deep nested paths
    private func buildEmbeddedAssetsMap() -> [String: String] {
        guard let embedded = embeddedManifest ?? loadEmbeddedManifest() else {
            return [:]
        }

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

        for asset in embedded.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 url = URL(fileURLWithPath: path).absoluteString
                // Store by hash (lowercased for consistency)
                map[asset.hash.lowercased()] = url
                // Also store by key for Metro compatibility
                if let key = asset.key {
                    map[key] = url
                }
            }
        }

        return map
    }

    private func buildEmbeddedAssetFilesMap() {
        assetFilesMap = buildEmbeddedAssetsMap()
    }

    // MARK: - Embedded Manifest

    private func loadEmbeddedManifest() -> PulseEmbeddedManifest? {
        // Try pulse/ subdirectory first, then root
        let paths = ["pulse/embedded-manifest.json", "embedded-manifest.json"]
        for path in paths {
            let url = Bundle.main.bundleURL.appendingPathComponent(path)
            if let data = try? Data(contentsOf: url),
               let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
                return PulseEmbeddedManifest(json: json)
            }
        }
        return nil
    }
}

// MARK: - Launcher Errors

enum PulseLauncherError: Error, LocalizedError {
    case noLaunchableUpdate
    case embeddedBundleNotFound
    case bundleNotFound
    case launchAssetMissing
    case assetUrlMissing
    case hashMismatch(expected: String, actual: String)

    var errorDescription: String? {
        switch self {
        case .noLaunchableUpdate:
            return "No launchable update found"
        case .embeddedBundleNotFound:
            return "Embedded bundle not found in main bundle"
        case .bundleNotFound:
            return "Bundle not found in cache"
        case .launchAssetMissing:
            return "Launch asset is missing"
        case .assetUrlMissing:
            return "Asset URL is missing"
        case .hashMismatch(let expected, let actual):
            return "Asset hash mismatch: expected \(expected), got \(actual)"
        }
    }
}

// MARK: - Embedded Manifest Model

struct PulseEmbeddedManifest {
    let updateId: String
    let runtimeVersion: String
    let commitTime: Date
    let assets: [PulseEmbeddedAsset]

    init?(json: [String: Any]) {
        // Support both formats: wrapped in "manifest" key or directly at root
        let manifest = (json["manifest"] as? [String: Any]) ?? json

        guard let updateId = manifest["updateId"] as? String,
              let runtimeVersion = manifest["runtimeVersion"] as? String else {
            return nil
        }

        self.updateId = updateId
        self.runtimeVersion = runtimeVersion

        if let commitTimeMs = manifest["commitTime"] as? Double {
            self.commitTime = Date(timeIntervalSince1970: commitTimeMs / 1000)
        } else if let createdAt = manifest["createdAt"] as? String {
            // Parse ISO 8601 date
            let formatter = ISO8601DateFormatter()
            formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
            self.commitTime = formatter.date(from: createdAt) ?? Date()
        } else {
            self.commitTime = Date()
        }

        var parsedAssets: [PulseEmbeddedAsset] = []

        // Parse assets array
        if let assetsJson = manifest["assets"] as? [[String: Any]] {
            for assetJson in assetsJson {
                if let asset = PulseEmbeddedAsset(json: assetJson) {
                    parsedAssets.append(asset)
                }
            }
        }

        // Parse launch asset (either separate or from bundle field)
        if let launchAssetJson = manifest["launchAsset"] as? [String: Any],
           var launchAsset = PulseEmbeddedAsset(json: launchAssetJson) {
            launchAsset.isLaunchAsset = true
            parsedAssets.append(launchAsset)
        } else if let bundleJson = manifest["bundle"] as? [String: Any] {
            // Create launch asset from bundle field
            var bundleAsset = PulseEmbeddedAsset(
                key: "bundle",
                hash: (bundleJson["hash"] as? String) ?? "",
                type: bundleJson["contentType"] as? String,
                nsBundleDir: nil,
                nsBundleFilename: nil
            )
            bundleAsset.isLaunchAsset = true
            parsedAssets.append(bundleAsset)
        }

        self.assets = parsedAssets
    }
}

struct PulseEmbeddedAsset {
    let key: String?
    let hash: String
    let type: String?
    let nsBundleDir: String?
    let nsBundleFilename: String?
    var isLaunchAsset: Bool = false

    init(key: String?, hash: String, type: String?, nsBundleDir: String?, nsBundleFilename: String?) {
        self.key = key
        self.hash = hash
        self.type = type
        self.nsBundleDir = nsBundleDir
        self.nsBundleFilename = nsBundleFilename
    }

    init?(json: [String: Any]) {
        guard let hash = json["hash"] as? String else {
            return nil
        }

        self.hash = hash
        self.key = json["key"] as? String
        self.type = json["type"] as? String ?? json["contentType"] as? String
        self.nsBundleDir = json["mainBundleDir"] as? String ?? json["nsBundleDir"] as? String
        self.nsBundleFilename = json["mainBundleFilename"] as? String ?? json["nsBundleFilename"] as? String
    }
}

// MARK: - Selection Policy Protocol

protocol PulseSelectionPolicy {
    /// Select which update to launch from available updates
    func selectUpdateToLaunch(from updates: [PulseUpdate]) -> PulseUpdate?

    /// Select which updates to delete (for reaper)
    func selectUpdatesToDelete(launchedUpdate: PulseUpdateInfo, allUpdates: [PulseUpdateInfo]) -> [PulseUpdateInfo]
}

// MARK: - Default Selection Policy

struct PulseDefaultSelectionPolicy: PulseSelectionPolicy {

    /// Select the best update to launch
    /// Priority: 1) Ready updates (newest by commitTime), 2) Embedded updates (fallback)
    /// Excludes updates that failed to launch and were never confirmed good
    /// (failedLaunchCount > 0 && successfulLaunchCount == 0) so a bad update can't be re-selected.
    func selectUpdateToLaunch(from updates: [PulseUpdate]) -> PulseUpdate? {
        // Exclude updates that failed to launch and were never confirmed good.
        let healthy = updates.filter { !($0.failedLaunchCount > 0 && $0.successfulLaunchCount == 0) }

        // First, try to find the newest ready (downloaded) update
        let readyUpdates = healthy.filter { $0.status == .ready }
        if let newest = readyUpdates.sorted(by: { $0.commitTime > $1.commitTime }).first {
            return newest
        }

        // Fall back to embedded update (embedded is always launchable).
        return healthy.first { $0.status == .embedded } ?? updates.first { $0.status == .embedded }
    }

    /// Select updates to delete - keeps launched update and one backup
    /// Based on Expo's SelectionPolicyFilterAwareLoader
    func selectUpdatesToDelete(launchedUpdate: PulseUpdateInfo, allUpdates: [PulseUpdateInfo]) -> [PulseUpdateInfo] {
        var toDelete: [PulseUpdateInfo] = []
        var newestBackup: PulseUpdateInfo?

        for update in allUpdates {
            // Never delete the currently launched update
            if update.updateId == launchedUpdate.updateId {
                continue
            }

            // Never delete embedded updates (handled by integrity check)
            if update.isEmbedded {
                continue
            }

            // Mark older updates for deletion, but track the newest as potential backup
            if update.commitTime < launchedUpdate.commitTime {
                toDelete.append(update)

                if newestBackup == nil || update.commitTime > newestBackup!.commitTime {
                    newestBackup = update
                }
            }
        }

        // Keep one backup (the newest older update) for rollback capability
        if let backup = newestBackup {
            toDelete.removeAll { $0.updateId == backup.updateId }
        }

        return toDelete
    }
}

// MARK: - Crypto Helper

struct PulseCrypto {
    /// Lowercase hex SHA-256. Manifests and the asset DB store hashes as hex, so all
    /// integrity comparisons must use hex (not base64).
    static func sha256Hex(_ data: Data) -> String {
        var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
        data.withUnsafeBytes {
            _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
        }
        return hash.map { String(format: "%02x", $0) }.joined()
    }
}

import CommonCrypto
