//
//  LobbyZipUpgrader.swift
//  ylgamehall
//
//  H5 zip 升级：下载远端 game_zip → 临时目录解压 → 原子 rename 到 lobbyRoot。
//  详见 docs/Development-Plan.md ADR-008-G / Design §6.3.4 / msext NewRootVC.m:1789-1869。
//

import Foundation
import ZIPFoundation

public actor LobbyZipUpgrader {

    public static let shared = LobbyZipUpgrader()

    public enum Outcome: Sendable {
        /// 本地版本已是最新（含远端 gameZip 为空 / 版本号未提升的情况），未做任何下载
        case noop
        /// 完成升级。`from → to` 是版本号变化。
        case upgraded(from: Int, to: Int)
    }

    public enum UpgradeError: Error, Sendable {
        case badGameZipURL(String)
        case downloadFailed(any Error)
        case httpStatus(Int)
        case unzipFailed(any Error)
        case stagingMoveFailed(any Error)
    }

    public init() {}

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

    /// 比较远端 game_version 与本地 version.xml。需要升级则下载 + 解压 + 原子 rename。
    ///
    /// - Parameters:
    ///   - resolved: VersionResolver.resolve(...) 的结果
    ///   - onProgress: 下载进度回调 0.0…1.0，由 URLSession delegate 在后台线程派发；
    ///     UI 层负责把闭包 hop 到 MainActor。默认 no-op 兼容仅做版本对比的烟雾测试。
    /// - Returns: `.noop` 已是最新；`.upgraded(from:to:)` 完成升级
    public func upgradeIfNeeded(
        resolved: ResolvedVersion,
        onProgress: @escaping @Sendable (Double) -> Void = { _ in }
    ) async throws -> Outcome {
        let localGameVersion = LocalVersionReader.localGameVersion
        guard resolved.gameVersion > localGameVersion,
              let zipURLString = resolved.gameZip,
              let zipURL = URL(string: zipURLString)
        else {
            return .noop
        }

        // 1. 下载到 tmp。**必须用手动 downloadTask + 续作**，不能用 async 版
        //    `await session.download(from:)`：后者内部走 completion-handler 路径，
        //    session 级 delegate 的 didWriteData 全部被跳过，结果只有下载完成才补一次
        //    onProgress(1.0)，进度条瞬间从 0% 跳到 100%（Apple Guide "Downloading files
        //    from websites" 明确：要进度必须 delegate + manual task.resume()）。
        //    手动 task 路径下，didWriteData 由 URLSession 在 opQueue 上正常派发；
        //    didFinishDownloadingTo 内同步把临时文件 move 到 savedURL（否则 delegate
        //    返回后系统会立即清掉那个临时位置）；didCompleteWithError 里 resume 续作。
        let savedURL = SandboxPaths.caches
            .appendingPathComponent("lobby-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 UpgradeError.downloadFailed(error)
        }
        if let http = response as? HTTPURLResponse,
           !(200..<300).contains(http.statusCode) {
            try? FileManager.default.removeItem(at: savedURL)
            throw UpgradeError.httpStatus(http.statusCode)
        }
        let downloadedURL = savedURL
        // 下载完成兜底报 1.0：部分 server 不发 Content-Length 或末尾片段晚到，
        // 不显式补这一下，进度条可能停在 99% 直到解压结束才跳走
        onProgress(1.0)

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

        // 3. 原子 rename：删旧 lobbyRoot → 把 staging 移到位
        //    （msext "removeItem + ZipArchive overWrite:YES" 半途崩溃留半残；
        //     我们 staging 完整解压后再 rename，半途崩溃只留 staging，下次启动可清）
        let lobbyRoot = SandboxPaths.lobbyRoot
        do {
            if fm.fileExists(atPath: lobbyRoot.path) {
                try fm.removeItem(at: lobbyRoot)
            }
            // 父目录确保存在（Library/Caches 永远存在，无需创建中间路径）
            try fm.moveItem(at: staging, to: lobbyRoot)
        } catch {
            try? fm.removeItem(at: staging)
            throw UpgradeError.stagingMoveFailed(error)
        }

        return .upgraded(from: localGameVersion, to: resolved.gameVersion)
    }
}

/// URLSession 下载进度桥接：把 `didWriteData` 的 byte 计数换算成 0…1，回调上层；
/// 同时驱动下载完成的 CheckedContinuation。
///
/// 之所以放在文件内私有：仅 LobbyZipUpgrader 用，不构成对外 API。
/// `@unchecked Sendable`：URLSessionDownloadDelegate 协议本身不带 Sendable；
/// 内部可变状态（continuation / moveError）跨线程（actor → opQueue），用 NSLock 保护。
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` 处的临时文件。
    /// move 出错记录到 moveError，由 didCompleteWithError 统一抛回 continuation。
    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: "LobbyZipUpgrader.DownloadProgressDelegate",
                code: -1,
                userInfo: [NSLocalizedDescriptionKey: "download finished without response"]
            ))
        }
    }
}
