//
//  SubGameDownloader.swift
//  ylgamehall
//
//  子游戏 zip 下载 + 解压 + 升级。与 daoqi msext gameController.m 路径等价：
//
//    1) 目录不存在（首装）：
//       下载 → 解压 → rename 到 `{Caches}/{currentDir}/`，currentDir 不变
//       （msext: line 1243-1245 + uplevel:YES → downFileFromServer）
//
//    2) 目录存在 + 远端 game_version > 本地 version.xml（升级）：
//       advance directory（末尾追加 "1" → newDir，写 UserDefaults）→ 下载 →
//       解压 → rename 到 `{Caches}/{newDir}/`，旧目录残留
//       （msext: gameController.m:1410-1428 uplevel:download: 路径）
//
//    3) 目录存在 + 远端 ≤ 本地：直接复用 currentDir
//       （msext: initView）
//
//  与 LobbyZipUpgrader 思路一致；不复用是因为目录持久化 + advance 策略 sub-game 专有。
//

import Foundation
import ZIPFoundation

public actor SubGameDownloader {

    public static let shared = SubGameDownloader()

    public enum Outcome: Sendable {
        /// 缓存命中、远端版本未提升 → 直接复用 currentDir
        case alreadyExists(actualDirectory: String)
        /// 完成下载 + 解压。`fromVersion = 0` 表示首装。
        case upgraded(actualDirectory: String, from: Int, to: Int, zipBytes: Int64)

        /// 实际生效的子游戏目录名（持久化值，可能与 H5 传的 directory 不同）。
        public var actualDirectory: String {
            switch self {
            case .alreadyExists(let dir):          return dir
            case .upgraded(let dir, _, _, _):      return dir
            }
        }
    }

    public enum DownloadError: Error, Sendable {
        case missingDownloadURL
        case badDownloadURL(String)
        case downloadFailed(any Error)
        case httpStatus(Int)
        case unzipFailed(any Error)
        case stagingMoveFailed(any Error)
    }

    public init() {}

    /// URLSession 配置：60s 单请求 / 300s 总体（zip 体量约 1-10 MB）。
    /// 每次下载用此 config 临时建 session，绑 download delegate 接收 didWriteData
    /// 进度回调；ad-hoc delegate 不会触发 download-specific 方法，必须走 session 级。
    nonisolated private static var defaultConfig: URLSessionConfiguration {
        let cfg = URLSessionConfiguration.default
        cfg.timeoutIntervalForRequest = 60
        cfg.timeoutIntervalForResource = 300
        return cfg
    }

    /// 准备子游戏 H5 资源。
    ///
    /// - Parameters:
    ///   - directoryKey: H5 传的 Gamedirectory 字面值，作为 UserDefaults 持久化 key
    ///   - gameStart: H5 子游戏入口（msext 的 self.game_name，与 directoryKey 同名）
    ///   - remoteVersion: 远端 ResolvedVersion.gameVersion（VersionResolver 算出的）
    ///   - remoteZipURL: 远端 ResolvedVersion.gameZip 真实下载 URL
    ///   - onProgress: 0…1 进度回调，URLSession delegate 后台线程派发
    /// - Returns: Outcome，外层据其 `actualDirectory` 构造 loadFileURL 路径
    public func ensureReady(
        directoryKey: String,
        gameStart: String,
        remoteVersion: Int,
        remoteZipURL: String,
        onProgress: @escaping @Sendable (Double) -> Void = { _ in }
    ) async throws -> Outcome {
        // 首次访问把 directory 自身写入 UserDefaults（msext gameController.m:2187 等价）
        SubGameDirectoryStore.saveInitialIfNeeded(forKey: directoryKey)
        let currentDir = SubGameDirectoryStore.current(forKey: directoryKey)

        let fm = FileManager.default
        let indexPath = SandboxPaths.subGameIndex(currentDir, gameStart).path
        let indexExists = fm.fileExists(atPath: indexPath)

        // 分支 1：目录不存在（首装）—— msext line 1242-1246 等价。
        // 下载到 currentDir，不 advance（advance 是升级路径专有）。
        if !indexExists {
            let zipBytes = try await download(
                to: currentDir,
                zipURLString: remoteZipURL,
                onProgress: onProgress
            )
            return .upgraded(
                actualDirectory: currentDir,
                from: 0,
                to: remoteVersion,
                zipBytes: zipBytes
            )
        }

        // 分支 2 / 3：目录存在 —— 读本地 version.xml，按远端对比。
        let localVer = LocalVersionReader.subGameVersion(dir: currentDir, start: gameStart)
        guard remoteVersion > localVer else {
            // 分支 3：远端 ≤ 本地 → 复用（msext: initView 直接加载）
            return .alreadyExists(actualDirectory: currentDir)
        }

        // 分支 2：升级 —— advance 后下载到新目录。
        // msext gameController.m:1414-1422 等价
        let newDir = SubGameDirectoryStore.advance(forKey: directoryKey)
        let zipBytes = try await download(
            to: newDir,
            zipURLString: remoteZipURL,
            onProgress: onProgress
        )
        return .upgraded(
            actualDirectory: newDir,
            from: localVer,
            to: remoteVersion,
            zipBytes: zipBytes
        )
    }

    // MARK: - 私有：下载 + 解压 + rename 一体化

    /// 下载远端 zip → 解压到 staging → 原子 rename 到 `{Caches}/{actualDirectory}/`。
    /// 返回下载字节数。
    private func download(
        to actualDirectory: String,
        zipURLString: String,
        onProgress: @escaping @Sendable (Double) -> Void
    ) async throws -> Int64 {
        guard !zipURLString.isEmpty else {
            throw DownloadError.missingDownloadURL
        }
        guard let zipURL = URL(string: zipURLString) else {
            throw DownloadError.badDownloadURL(zipURLString)
        }

        // 1. 下载到 tmp。**必须用手动 downloadTask + 续作**，不能用 async 版
        //    `await session.download(from:)`：后者内部走 completion-handler 路径，
        //    session 级 delegate 的 didWriteData 全部被跳过，结果只有下载完成才补一次
        //    onProgress(1.0)，进度条瞬间从 0% 跳到 100%。详见 LobbyZipUpgrader 同款修复注释。
        let savedURL = SandboxPaths.caches
            .appendingPathComponent("subgame-download-\(UUID().uuidString).zip")
        let progressDelegate = DownloadProgressDelegate(
            onProgress: onProgress,
            moveDownloadedTo: savedURL
        )
        let opQueue = OperationQueue()
        opQueue.maxConcurrentOperationCount = 1
        let session = URLSession(
            configuration: Self.defaultConfig,
            delegate: progressDelegate,
            delegateQueue: opQueue
        )
        defer { session.invalidateAndCancel() }

        let response: URLResponse
        do {
            response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<URLResponse, any Error>) in
                progressDelegate.setContinuation(cont)
                session.downloadTask(with: zipURL).resume()
            }
        } catch {
            try? FileManager.default.removeItem(at: savedURL)
            throw DownloadError.downloadFailed(error)
        }
        if let http = response as? HTTPURLResponse,
           !(200..<300).contains(http.statusCode) {
            try? FileManager.default.removeItem(at: savedURL)
            throw DownloadError.httpStatus(http.statusCode)
        }
        let downloadedURL = savedURL
        onProgress(1.0)

        let fm = FileManager.default
        let zipBytes = (try? fm.attributesOfItem(atPath: downloadedURL.path)[.size] as? NSNumber)?
            .int64Value ?? 0

        // 2. 解压到隔离 staging
        let staging = SandboxPaths.caches
            .appendingPathComponent("subgame-staging-\(UUID().uuidString)")
        do {
            try fm.createDirectory(at: staging, withIntermediateDirectories: true)
            try fm.unzipItem(at: downloadedURL, to: staging)
        } catch {
            try? fm.removeItem(at: staging)
            throw DownloadError.unzipFailed(error)
        }
        try? fm.removeItem(at: downloadedURL)

        // 3. 原子 rename：删旧目标 → 把 staging 移到位
        //    zip 内部已含 `{gameStart}/` 子目录，解压到 staging 后整体 move 到
        //    `{Caches}/{actualDirectory}/`，结果就是 `{actualDirectory}/{gameStart}/index.html`
        let target = SandboxPaths.subGameRoot(actualDirectory)
        do {
            if fm.fileExists(atPath: target.path) {
                try fm.removeItem(at: target)
            }
            try fm.moveItem(at: staging, to: target)
        } catch {
            try? fm.removeItem(at: staging)
            throw DownloadError.stagingMoveFailed(error)
        }

        return zipBytes
    }
}

/// URLSession 下载进度桥接：把 didWriteData 的 byte 计数换算成 0…1，回调上层；
/// 同时驱动下载完成的 CheckedContinuation。
///
/// 与 LobbyZipUpgrader 同款实现就地复制（两处使用，不抽公共 — 三处出现时再合并）。
private final class DownloadProgressDelegate: NSObject,
                                              URLSessionDownloadDelegate,
                                              @unchecked Sendable {
    private let onProgress: @Sendable (Double) -> Void
    private let moveDownloadedTo: URL
    private let lock = NSLock()
    private var continuation: CheckedContinuation<URLResponse, any Error>?
    private var moveError: (any Error)?

    init(onProgress: @escaping @Sendable (Double) -> Void, moveDownloadedTo: URL) {
        self.onProgress = onProgress
        self.moveDownloadedTo = moveDownloadedTo
        super.init()
    }

    /// 由 actor 在启动 downloadTask 前调一次。必须在 `task.resume()` 之前调，
    /// 否则 didCompleteWithError 可能先于 setContinuation 发生（极端情况下短下载）。
    func setContinuation(_ c: CheckedContinuation<URLResponse, any Error>) {
        lock.lock(); defer { lock.unlock() }
        continuation = c
    }

    func urlSession(_ session: URLSession,
                    downloadTask: URLSessionDownloadTask,
                    didWriteData bytesWritten: Int64,
                    totalBytesWritten: Int64,
                    totalBytesExpectedToWrite: Int64) {
        guard totalBytesExpectedToWrite > 0 else { return }
        let p = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
        onProgress(min(max(p, 0), 1))
    }

    /// 必须在 delegate 方法返回前同步把临时文件 move 到 `moveDownloadedTo`：
    /// 系统会在本方法返回后立即清理 `location` 处的临时文件。
    func urlSession(_ session: URLSession,
                    downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {
        do {
            try? FileManager.default.removeItem(at: moveDownloadedTo)
            try FileManager.default.moveItem(at: location, to: moveDownloadedTo)
        } catch {
            lock.lock(); defer { lock.unlock() }
            moveError = error
        }
    }

    func urlSession(_ session: URLSession,
                    task: URLSessionTask,
                    didCompleteWithError error: (any Error)?) {
        lock.lock(); defer { lock.unlock() }
        guard let c = continuation else { return }
        continuation = nil
        if let error {
            c.resume(throwing: error)
        } else if let me = moveError {
            c.resume(throwing: me)
        } else if let response = task.response {
            c.resume(returning: response)
        } else {
            c.resume(throwing: NSError(
                domain: "SubGameDownloader.DownloadProgressDelegate",
                code: -1,
                userInfo: [NSLocalizedDescriptionKey: "download finished without response"]
            ))
        }
    }
}
