//
//  AppSchemeHandler.swift
//  ylgamehall
//
//  WKURLSchemeHandler 实现，承接 `ylgame://h5/...` 自定义 scheme。
//  替代原 loadFileURL 路径，让大厅 + 所有子游戏跑在同一个稳定 origin
//  (`ylgame://h5`) 下，从而：
//    1) 共享 localStorage（同 scheme + 同 host = 同 security origin）
//    2) 摆脱 file:// 的 XHR/fetch null-origin 限制（Cocos H5 build 适配前置）
//    3) 给 `<audio>/<video>` 提供 Range 请求支持（Cocos 音频 seek）
//
//  路由：
//    ylgame://h5/lobby/<rest...>           → SandboxPaths.lobbyRoot/<rest...>
//    ylgame://h5/subgame/<dir>/<rest...>   → SandboxPaths.subGameRoot(dir)/<rest...>
//
//  设计 / Phase 见 docs/H5-Native-Implementation-Design.md §17（新增）/
//  docs/Development-Plan.md Phase 1.F。
//

import Foundation
@preconcurrency import WebKit
import os.log

// MARK: - 常量（项目前缀）

/// 自定义 scheme + host + 路径前缀常量。**所有成员 nonisolated**，让
/// `SandboxPaths`（nonisolated）能在任意上下文拼 URL（项目启用了
/// MainActor-by-default 默认隔离，未标 nonisolated 的 static 会被推断为
/// MainActor 隔离，背景 IO 队列无法访问）。
public enum AppScheme {
    /// scheme = "ylgame"（项目前缀，避免与其它 app 自定义 scheme 冲突）
    nonisolated public static let scheme = "ylgame"
    /// 单虚拟 host = "h5"。**所有 H5 页（大厅 + 所有子游戏）共用此 host**，
    /// 确保 same-origin → 共享 localStorage / IndexedDB / sessionStorage。
    nonisolated public static let host = "h5"
    /// 大厅根路径段：`/lobby/...`
    nonisolated public static let lobbyPrefix = "lobby"
    /// 子游戏根路径段：`/subgame/<dir>/...`
    nonisolated public static let subGamePrefix = "subgame"
}

// MARK: - Handler

/// 自定义 URL scheme 的 WKURLSchemeHandler。**单例**注册到所有 BridgedWebView 的
/// WKWebViewConfiguration（同一实例可挂多个 WKWebViewConfiguration；每个
/// configuration 内每个 scheme 只能注册一次）。
@MainActor
public final class AppSchemeHandler: NSObject, WKURLSchemeHandler {

    public static let shared = AppSchemeHandler()

    private static let log = Logger(subsystem: "ylgamehall", category: "AppSchemeHandler")

    /// 后台 IO 队列。所有 file IO + Range parsing 在此运行，避免阻塞主线程。
    private let ioQueue = DispatchQueue(
        label: "ylgamehall.app-scheme-handler.io",
        qos: .userInitiated,
        attributes: .concurrent
    )

    /// 跟踪进行中的 task。**仅主线程访问**（start / stop / deliver 都在 MainActor）。
    /// 用 ObjectIdentifier 作 key 是为了让背景 IO 队列只携带 Sendable 的 key 跨边界，
    /// 不把非 Sendable 的 `any WKURLSchemeTask` 捕获进 `@Sendable` 闭包。
    /// WKURLSchemeTask 协议规定 stop 后不能再向 task 发任何消息（否则 NSException），
    /// 用 `cancelledKeys` 标记取消、`tasksByKey` 持强引用配套使用。
    private var tasksByKey: [ObjectIdentifier: any WKURLSchemeTask] = [:]
    private var cancelledKeys: Set<ObjectIdentifier> = []

    private override init() {
        super.init()
    }

    // MARK: - WKURLSchemeHandler

    public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
        let key = ObjectIdentifier(urlSchemeTask)
        tasksByKey[key] = urlSchemeTask

        guard let url = urlSchemeTask.request.url else {
            finish(key: key, withError: AppSchemeError.invalidURL)
            return
        }
        let rangeHeader = urlSchemeTask.request.value(forHTTPHeaderField: "Range")

        // 路由 + 文件路径解析（主线程做，廉价）
        let resolved: ResolvedRequest
        do {
            resolved = try Self.resolveRequest(url: url, rangeHeader: rangeHeader)
        } catch {
            finish(key: key, withError: error)
            return
        }

        // 读盘 + 响应 → 后台队列。注意：闭包只捕获 Sendable 的 `key` / `resolved`，
        // 不捕获非 Sendable 的 `urlSchemeTask`，避免数据竞争告警。
        ioQueue.async { [weak self] in
            let result = Self.readAndBuildResponse(resolved: resolved)
            Task { @MainActor in
                self?.deliver(result: result, forKey: key)
            }
        }
    }

    public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
        let key = ObjectIdentifier(urlSchemeTask)
        // 标记取消，io 队列完成后 deliver 见此标志直接短路。tasksByKey 在 deliver
        // / finish 内清理，不在此处提前移除（避免与背景 IO 完成竞态）。
        cancelledKeys.insert(key)
    }

    // MARK: - Routing（nonisolated 纯函数）

    fileprivate struct ResolvedRequest {
        let fileURL: URL
        let url: URL
        let rangeHeader: String?
    }

    /// 把 `ylgame://h5/<prefix>/<rest>` 解析为沙盒实际文件路径。
    /// - lobby 前缀：rest 全段拼到 lobbyRoot
    /// - subgame 前缀：第一段是 dir，剩余拼到 subGameRoot(dir)
    nonisolated fileprivate static func resolveRequest(
        url: URL,
        rangeHeader: String?
    ) throws -> ResolvedRequest {
        guard url.scheme?.lowercased() == AppScheme.scheme,
              url.host?.lowercased() == AppScheme.host
        else {
            throw AppSchemeError.invalidURL
        }

        // path 形如 "/lobby/gameStart/index.html" 或 "/subgame/<dir>/<start>/index.html"
        let segments = url.path
            .split(separator: "/", omittingEmptySubsequences: true)
            .map(String.init)
        guard let prefix = segments.first else {
            throw AppSchemeError.invalidURL
        }

        let fileURL: URL
        switch prefix {
        case AppScheme.lobbyPrefix:
            let rest = segments.dropFirst().joined(separator: "/")
            fileURL = SandboxPaths.lobbyRoot.appendingPathComponent(rest)

        case AppScheme.subGamePrefix:
            guard segments.count >= 2 else {
                throw AppSchemeError.invalidURL
            }
            let dir = segments[1]
            let rest = segments.dropFirst(2).joined(separator: "/")
            fileURL = SandboxPaths.subGameRoot(dir).appendingPathComponent(rest)

        default:
            throw AppSchemeError.invalidURL
        }

        return ResolvedRequest(fileURL: fileURL, url: url, rangeHeader: rangeHeader)
    }

    // MARK: - Reading + response building（IO 队列调用）

    fileprivate enum BuildResult {
        case ok(HTTPURLResponse, Data)
        case fail(AppSchemeError)
    }

    nonisolated fileprivate static func readAndBuildResponse(
        resolved: ResolvedRequest
    ) -> BuildResult {
        let fileURL = resolved.fileURL
        let fm = FileManager.default

        guard fm.fileExists(atPath: fileURL.path),
              let attrs = try? fm.attributesOfItem(atPath: fileURL.path),
              let size = (attrs[.size] as? NSNumber)?.intValue
        else {
            return .fail(.fileNotFound(fileURL.path))
        }

        let mime = MimeMap.mime(forExtension: fileURL.pathExtension)

        // Range 请求 → 206
        if let rangeHeader = resolved.rangeHeader,
           let (start, end) = parseRange(rangeHeader, totalSize: size) {
            do {
                let handle = try FileHandle(forReadingFrom: fileURL)
                defer { try? handle.close() }
                try handle.seek(toOffset: UInt64(start))
                let length = end - start + 1
                let data = try handle.read(upToCount: length) ?? Data()
                let response = makePartialResponse(
                    url: resolved.url,
                    mime: mime,
                    start: start,
                    end: end,
                    totalSize: size,
                    contentLength: data.count
                )
                return .ok(response, data)
            } catch {
                return .fail(.readFailed(error))
            }
        }

        // 全文件 → 200
        do {
            let data = try Data(contentsOf: fileURL, options: [.mappedIfSafe])
            let response = makeFullResponse(
                url: resolved.url,
                mime: mime,
                contentLength: data.count
            )
            return .ok(response, data)
        } catch {
            return .fail(.readFailed(error))
        }
    }

    nonisolated private static func makeFullResponse(
        url: URL,
        mime: String,
        contentLength: Int
    ) -> HTTPURLResponse {
        let headers: [String: String] = [
            "Content-Type": mime,
            "Content-Length": "\(contentLength)",
            // 防御性 CORS：自定义 scheme 同源本不需要 preflight，但部分 Cocos build
            // 在 fetch 默认 mode='cors' 下会走 preflight，发回 * 兜底
            "Access-Control-Allow-Origin": "*",
            // 沙盒文件随升级路径改变（lobby zip 升级直接覆盖原目录），禁强缓存避免错版
            "Cache-Control": "no-cache",
            "Accept-Ranges": "bytes"
        ]
        return HTTPURLResponse(
            url: url,
            statusCode: 200,
            httpVersion: "HTTP/1.1",
            headerFields: headers
        )!
    }

    nonisolated private static func makePartialResponse(
        url: URL,
        mime: String,
        start: Int,
        end: Int,
        totalSize: Int,
        contentLength: Int
    ) -> HTTPURLResponse {
        let headers: [String: String] = [
            "Content-Type": mime,
            "Content-Length": "\(contentLength)",
            "Content-Range": "bytes \(start)-\(end)/\(totalSize)",
            "Access-Control-Allow-Origin": "*",
            "Cache-Control": "no-cache",
            "Accept-Ranges": "bytes"
        ]
        return HTTPURLResponse(
            url: url,
            statusCode: 206,
            httpVersion: "HTTP/1.1",
            headerFields: headers
        )!
    }

    /// 解析 Range 头：支持 `bytes=start-end` / `bytes=start-` / `bytes=-suffix`。
    /// 不支持 multi-range（逗号分隔），Cocos 不发。
    nonisolated private static func parseRange(
        _ header: String,
        totalSize: Int
    ) -> (Int, Int)? {
        guard header.lowercased().hasPrefix("bytes=") else { return nil }
        let spec = String(header.dropFirst("bytes=".count))
        if spec.contains(",") { return nil }
        let parts = spec.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
        guard parts.count == 2 else { return nil }
        let startStr = String(parts[0])
        let endStr = String(parts[1])
        // bytes=-N → 最后 N 字节
        if startStr.isEmpty, let suffix = Int(endStr), suffix > 0 {
            let start = max(totalSize - suffix, 0)
            return (start, totalSize - 1)
        }
        guard let start = Int(startStr), start >= 0, start < totalSize else { return nil }
        if endStr.isEmpty {
            return (start, totalSize - 1)
        }
        guard let end = Int(endStr), end >= start, end < totalSize else { return nil }
        return (start, end)
    }

    // MARK: - Delivery（主线程）

    private func deliver(result: BuildResult, forKey key: ObjectIdentifier) {
        // task 已被 stop 标记 → 一律不向 task 发任何消息（否则 NSException）。
        // 即使如此，仍要从两个表里清理引用避免泄漏。
        guard let urlSchemeTask = tasksByKey.removeValue(forKey: key) else { return }
        if cancelledKeys.remove(key) != nil { return }

        switch result {
        case .ok(let response, let data):
            urlSchemeTask.didReceive(response)
            urlSchemeTask.didReceive(data)
            urlSchemeTask.didFinish()
            Self.log.debug("→ \(response.statusCode, privacy: .public) \(response.url?.absoluteString ?? "", privacy: .public) \(data.count, privacy: .public)B")
        case .fail(let error):
            urlSchemeTask.didFailWithError(error.toNSError)
            Self.log.error("✗ \(error.debugDescription, privacy: .public)")
        }
    }

    private func finish(key: ObjectIdentifier, withError error: any Error) {
        guard let urlSchemeTask = tasksByKey.removeValue(forKey: key) else { return }
        if cancelledKeys.remove(key) != nil { return }
        let ns = (error as? AppSchemeError)?.toNSError ?? (error as NSError)
        urlSchemeTask.didFailWithError(ns)
    }
}

// MARK: - Errors

public enum AppSchemeError: Error, CustomDebugStringConvertible, Sendable {
    case invalidURL
    case fileNotFound(String)
    case readFailed(any Error)

    public var debugDescription: String {
        switch self {
        case .invalidURL:          return "invalid url"
        case .fileNotFound(let p): return "file not found: \(p)"
        case .readFailed(let e):   return "read failed: \(e)"
        }
    }

    var toNSError: NSError {
        switch self {
        case .invalidURL:
            return NSError(
                domain: "AppSchemeHandler",
                code: -1,
                userInfo: [NSLocalizedDescriptionKey: "invalid url"]
            )
        case .fileNotFound(let p):
            return NSError(
                domain: "AppSchemeHandler",
                code: 404,
                userInfo: [NSLocalizedDescriptionKey: "not found: \(p)"]
            )
        case .readFailed(let e):
            return e as NSError
        }
    }
}

// MARK: - MIME map

/// 扩展名 → MIME 映射。Cocos H5 build 常用扩展全打表，未命中走
/// `application/octet-stream`（WKWebView 仍能下载，只是不会自动按内容类型行为）。
/// `nonisolated` 让 IO 队列能直接调用（项目 MainActor-by-default）。
enum MimeMap {
    nonisolated static func mime(forExtension ext: String) -> String {
        let lower = ext.lowercased()
        return table[lower] ?? "application/octet-stream"
    }

    nonisolated private static let table: [String: String] = [
        // 文本类
        "html":  "text/html; charset=utf-8",
        "htm":   "text/html; charset=utf-8",
        "js":    "text/javascript; charset=utf-8",
        "mjs":   "text/javascript; charset=utf-8",
        "css":   "text/css; charset=utf-8",
        "json":  "application/json; charset=utf-8",
        "txt":   "text/plain; charset=utf-8",
        "xml":   "application/xml; charset=utf-8",
        "atlas": "text/plain; charset=utf-8",  // Cocos atlas 文件
        "fnt":   "text/plain; charset=utf-8",  // Cocos bitmap font
        "plist": "application/x-plist",
        "wasm":  "application/wasm",

        // 图片
        "png":   "image/png",
        "jpg":   "image/jpeg",
        "jpeg":  "image/jpeg",
        "gif":   "image/gif",
        "webp":  "image/webp",
        "svg":   "image/svg+xml",
        "ico":   "image/x-icon",
        "bmp":   "image/bmp",

        // 音频
        "mp3":   "audio/mpeg",
        "ogg":   "audio/ogg",
        "oga":   "audio/ogg",
        "wav":   "audio/wav",
        "m4a":   "audio/mp4",
        "aac":   "audio/aac",

        // 视频
        "mp4":   "video/mp4",
        "m4v":   "video/mp4",
        "webm":  "video/webm",
        "mov":   "video/quicktime",

        // 字体
        "ttf":   "font/ttf",
        "otf":   "font/otf",
        "woff":  "font/woff",
        "woff2": "font/woff2",
        "eot":   "application/vnd.ms-fontobject",

        "bin":   "application/octet-stream"
    ]
}
