//
//  WebContainerViewController.swift
//  ylgamehall
//
//  H5 大厅承载容器：BridgedWebView 全屏铺满 + Splash 覆盖层
//  + 启动流水线（ensureReady → fetch → resolve → upgrade → loadFileURL）。
//  详见 docs/H5-Native-Implementation-Design.md §6.3.5。
//

import UIKit
import WebKit
import Network

public final class WebContainerViewController: UIViewController {

    // MARK: - UI

    private let bridgedWebView = BridgedWebView(label: "lobby")

    /// 大厅私有的定位服务（msext NewRootVC.m:1568 `configLocationManager` 等价）。
    /// 与子游戏那一个互不影响 —— 这是 msext 的原始结构，不可再合并成单例。
    private let locationService = LocationService(owner: "lobby")
    private let splash = SplashOverlay()

    // MARK: - Handlers (有状态的 handler 注册器持有，无状态的走 enum 静态 register)

    private let shakeHandler = ShakeHandler()

    // MARK: - .subGameDidReturn 观察者（子游戏 backgameData pop 后回大厅，反向 callback getWebdata）
    //
    // 与 msext NewRootVC.m:593 等价：永久挂钩，viewDidLoad 期间一次性 install，
    // 不随 viewWillDisappear teardown。详见 setupExternalSubscriptions 注释。

    private var subGameReturnObserver: NSObjectProtocol?
    private var subGameReturnObserverInstalled = false

    // MARK: - Boot pipeline 内部错误分类
    //
    // 用 enum 区分"终态阻塞"与"系统级错误"：
    // - shortText / ipaUpgrade 是运营杀手锏，弹窗后永停（无重试按钮）
    // - 其它（网络 / 解析 / 解压 / 下载失败）→ showFatalAlert，提供重试

    private enum BootError: Error {
        /// 短文本响应 或 showmessage 非空：契约 §3.2 运营杀手锏 #1/#2
        case operationalMessage(String)
        /// IPA 需要升级，弹窗 + Safari 外链
        case ipaUpgradeRequired(downloadURL: String)
        /// 远端 RemoteConfig 缺 audio_domain / audio_bucket：后台配置错误，启动期致命
        case audioConfigMissing
    }

    // MARK: - Lifecycle

    public override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black

        setupBridgedWebView()
        setupSplash()

        bridgedWebView.webView.navigationDelegate = self
        registerBridgeHandlers()

        Task { @MainActor in
            await runBootPipeline()
        }
    }

    /// 注册所有 H5 → Native handler 的单一聚合点。
    /// Phase 1.15 仅 vibrator；Phase 2.A 扩展到 8 个 handler 模块。
    private func registerBridgeHandlers() {
        let bridge = bridgedWebView.bridge

        // Phase 1.15
        VibratorHandler.register(on: bridge)                  // §3.1 ［10］［11］［12］扩展

        // Phase 2.A 大厅简单 handler（无外部依赖）
        ClipboardHandler.register(on: bridge)                 // §3.1 ［13］［14］
        shakeHandler.register(on: bridge)                     // §3.1 ［7］［8］［9］ + §3.2 ［5］
        VoicePlayingHandler.register(on: bridge)              // §3.1 ［6］
        DeviceInfoHandler.register(on: bridge)                // §3.1 ［21］ + §3.2 ［1］
        BrowserHandler.register(on: bridge)                   // §3.1 ［16］
        OpenSaomaHandler.register(on: bridge)                 // §3.1 ［22］空 stub
        StartLocationHandler.register(on: bridge, label: "lobby", location: locationService) // §3.1 ［20］Phase 5 完整实现，Phase 2 stub

        // Phase 3.A 本地音频 + 3.C/3.D stub
        // assetsRoot 闭包 = 大厅 H5 根目录（lobbyIndex 父目录），lobby 不做升级
        // 路径不变，但 closure 形式保持与子游戏对称
        LocalAudioHandler.register(
            on: bridge,
            assetsRoot: { SandboxPaths.lobbyIndex.deletingLastPathComponent() }
        )                                                     // §3.1 ［3］srcIsloop 完整实现
        // §3.1 ［4］prepareaudio 完整实现 / ［5］mediaTypeAudio Phase 3.F stub
        // 大厅 isSubGame=false：录音上传成功后仅触发 getaudiourl，不触发 recordSuccess
        // （对齐 daoqi NewRootVC 无 recordSuccess callHandler 的行为）
        RemoteAudioHandler.register(
            on: bridge,
            hostVC: { [weak self] in self },
            isSubGame: false
        )

        // Phase 4.A 登录 + 分享 stub（Phase 4.B 微信 SDK / Phase 4.C QQ URL Scheme 后升级）
        AccreditLoginHandler.register(on: bridge)             // §3.1 ［1］Phase 4.E 完整微信 SDK
        FriendsShareHandler.register(on: bridge, mode: .lobby) // §3.1 ［2］大厅路径：直发微信，3 type 分支（对齐 msext NewRootVC.m）

        // Phase 6.4 / 7 子游戏 + 弹层入口 stub（让 H5 业务期调时不报 "no handler"）
        SwitchOverGameHandler.register(on: bridge)            // §3.1 ［17］Phase 6 完整实现
        OpenurlTitleDataHandler.register(on: bridge)          // §3.1 ［15］Phase 7 完整实现
    }

    // MARK: - 摇一摇支持
    // motionEnded 必须 first responder 才触发；canBecomeFirstResponder = true +
    // viewDidAppear becomeFirstResponder。详见 Design §8.7.1。

    public override var canBecomeFirstResponder: Bool { true }

    public override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // 外部订阅生命周期：栈顶时挂钩，避免子游戏 push 上来后双发（§2.4.2）
        setupExternalSubscriptions()
    }

    public override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        becomeFirstResponder()
    }

    public override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        teardownExternalSubscriptions()
    }

    public override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        shakeHandler.handleMotionEnded(motion)
    }

    public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .landscape }
    public override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { .landscapeRight }
    public override var prefersStatusBarHidden: Bool { false }

    // MARK: - WebView 全屏布局（与 daoqi msext 同款）
    //
    // 原项目 NewRootVC.m:256 用 `CGRectMake(0, 0, DEVW, DEVH)` 直接全屏铺满 WKWebView，
    // 没有任何 letterbox。H5 viewport 自身处理设计稿（1280×720）→ 设备实际宽高的伸缩。
    //
    // 早期 ylgamehall 设计文档（§6.3.5）曾用 16:9 letterbox（左右/上下补黑边），但实测
    // 现代 iPhone 横屏比例约 19.5:9（比 16:9 更宽）→ 左右黑边过大体验差，且偏离 msext
    // 上线多年的视觉习惯。回到与原项目一致的全屏铺满路径。

    private func setupBridgedWebView() {
        bridgedWebView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(bridgedWebView)

        NSLayoutConstraint.activate([
            bridgedWebView.topAnchor.constraint(equalTo: view.topAnchor),
            bridgedWebView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            bridgedWebView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            bridgedWebView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])
    }

    private func setupSplash() {
        splash.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(splash)
        NSLayoutConstraint.activate([
            splash.topAnchor.constraint(equalTo: view.topAnchor),
            splash.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            splash.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            splash.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])
        splash.update(text: "拼命启动中...", progress: nil)
    }

    // MARK: - Boot pipeline

    private func runBootPipeline() async {
        // 重新进入 pipeline 时（重试路径）：清除所有错误态监听 + 重置 splash
        stopBootRetryWaiting()
        stopWaitingForUserReturnFromSettings()
        splash.hideError()
        splash.isHidden = false
        splash.update(text: "拼命启动中...", progress: nil)

        do {
            try await runBootPipelineSteps()
        } catch BootError.operationalMessage(let msg) {
            // 运营杀手锏：modal alert（业务流程信号，不是技术错误）
            showBlockingAlert(message: msg)
        } catch BootError.ipaUpgradeRequired(let dl) {
            // IPA 升级 modal alert（业务流程信号）
            showIPAUpgradeAlert(downloadURL: dl)
        } catch BootError.audioConfigMissing {
            // 后台配置错误：远端 RemoteConfig 缺 audio_domain / audio_bucket。
            // 用户/运营无法自助处理，与运营公告同款 modal 永停。
            showBlockingAlert(message: "音频服务暂不可用（配置缺失），请联系客服。")
        } catch {
            // 网络 / 权限拒 / 本地资源等可重试错误 → 在 splash 上展示错误态。
            // 异步 snapshot NWPath 区分"真断网"和"权限被拒"，文案和按钮语义按 kind 精确分流。
            let path = await Self.currentPathSnapshot()
            let kind = Self.classifyBootError(error, path: path)

            // 首次启动权限授予竞争：iOS 弹权限对话框时,pipeline 的第一个网络请求
            // 已经在跑;如果用户没在请求 timeout 前点"允许",请求会 fail 且 NWPath
            // 报 wifiDenied / cellularDenied —— 但用户可能"正准备点允许"。此时立
            // 即弹"前往设置"会让用户困惑("我明明点了允许还弹")。
            //
            // 策略：只在 kind.isPermissionDenied 时,短暂观察 NWPath；5s 内若变
            // satisfied → 静默重跑 pipeline(等价用户点允许后我们自动重试);超时
            // 或仍拒绝 → 走原逻辑弹"前往设置"。真拒绝的长尾场景多等 5s 可接受。
            if kind.isPermissionDenied,
               await Self.waitForNetworkPermissionResolution(timeout: 5.0) {
                await runBootPipeline()
                return
            }

            presentBootError(error, kind: kind)
        }
    }

    /// 在 splash 上展示错误态（不弹 modal alert）。raw error 仍 print 到 Xcode console
    /// 便于开发期排查。文案 + 按钮语义按 `kind` 精确切换：
    /// - 真断网 / 超时 / 服务器不可达 → "重试"（重跑 runBootPipeline）
    ///   + NWPathMonitor 静默监听网络恢复自动重试一次
    /// - 权限被拒（wifiDenied / cellularDenied）→ "前往设置"（跳 iOS 设置打开权限）
    ///   + 监听 didBecomeActive，用户从设置回 app 时自动重试
    /// - 本地资源失败 → "重试"（同上但通常重试无用，需重启 app）
    private func presentBootError(_ error: any Error, kind: BootErrorKind) {
        print("[Boot] presentBootError kind=\(kind) underlying:", error)

        let title: String
        let message: String
        let actionTitle: String
        switch kind {
        case .noNetworkAccess:
            title = "当前未联网"
            message = "请检查 Wi-Fi 或蜂窝数据后重试。"
            actionTitle = "重试"
        case .wifiDenied, .cellularDenied:
            // 文案与 iOS Settings → 本应用 → 「无线数据」combined toggle 对齐:
            // 该开关有"关闭 / WLAN / WLAN 与蜂窝数据"三档,是用户唯一可操作的
            // 入口。NWPath 的 wifiDenied vs cellularDenied 内部区分保留在
            // BootErrorKind 里(供日志排查),但用户文案不再区分——两种情况用户
            // 都要走同一条路径(打开"无线数据"),精确区分反而让"文案对不上设置项"。
            title = "未授权使用无线数据"
            message = "请前往 iOS 设置 → 本应用 → 打开「无线数据」，授权后将自动重试。"
            actionTitle = "前往设置"
        case .networkTimeout:
            title = "网络较慢"
            message = "连接超时，请稍后重试。"
            actionTitle = "重试"
        case .networkCannotReach:
            title = "暂时无法连接服务器"
            message = "网络似乎不太通畅，请稍后重试。"
            actionTitle = "重试"
        case .localResourceMissing:
            title = "资源加载失败"
            message = "请尝试重启 app；若仍未恢复，请重新安装。"
            actionTitle = "重试"
        case .unknown:
            title = "启动遇到问题"
            message = "请稍后重试。"
            actionTitle = "重试"
        }

        if kind.isPermissionDenied {
            // 权限拒：按钮 → 跳系统设置；同时挂 didBecomeActive 监听，用户改完权限
            // 切回 app 时自动重试（无需用户再点按钮）
            splash.onRetry = { [weak self] in
                guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
                UIApplication.shared.open(url)
                self?.startWaitingForUserReturnFromSettings()
            }
        } else {
            // 网络 / 本地资源类：按钮 → 直接重跑 pipeline
            splash.onRetry = { [weak self] in
                Task { @MainActor in await self?.runBootPipeline() }
            }
            // 同时静默后台监听网络恢复（仅网络类有意义；本地资源类失败时 path 通常 satisfied
            // 不会触发自动重试，无副作用）
            startBootRetryWaiting()
        }

        splash.showError(title: title, message: message, actionTitle: actionTitle)
    }

    /// 用户跳 iOS 设置后，监听 app didBecomeActive 自动重试 runBootPipeline。
    /// 仅一次性：触发后自动取消监听。错误态期间用户多次进出设置也只触发一次重试。
    private var settingsReturnObserver: NSObjectProtocol?

    private func startWaitingForUserReturnFromSettings() {
        stopWaitingForUserReturnFromSettings()
        settingsReturnObserver = NotificationCenter.default.addObserver(
            forName: UIApplication.didBecomeActiveNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            guard let self else { return }
            MainActor.assumeIsolated {
                self.stopWaitingForUserReturnFromSettings()
                print("[Boot] 用户从系统设置返回 app，自动重试启动流水线")
                Task { @MainActor in await self.runBootPipeline() }
            }
        }
    }

    private func stopWaitingForUserReturnFromSettings() {
        if let obs = settingsReturnObserver {
            NotificationCenter.default.removeObserver(obs)
            settingsReturnObserver = nil
        }
    }

    /// 启动期 NWPathMonitor。仅在 splash 错误态期间存活；进入大厅或用户主动重试时销毁。
    /// 仅在"从无网变有网"时触发自动重试，避免初始 satisfied 状态导致死循环。
    private var bootRetryPathMonitor: NWPathMonitor?

    private func startBootRetryWaiting() {
        stopBootRetryWaiting()
        let m = NWPathMonitor()
        let queue = DispatchQueue(label: "ylgamehall.boot-retry", qos: .utility)
        nonisolated(unsafe) var previousSatisfied: Bool? = nil
        m.pathUpdateHandler = { [weak self] path in
            let isSatisfied = (path.status == .satisfied)
            let prev = previousSatisfied
            previousSatisfied = isSatisfied
            // 仅从 false 转 true 触发自动重试；初始 nil（首次回调）不触发
            guard prev == false, isSatisfied else { return }
            Task { @MainActor in
                guard let self else { return }
                print("[Boot] 网络恢复，自动重试启动流水线")
                await self.runBootPipeline()
            }
        }
        m.start(queue: queue)
        bootRetryPathMonitor = m
    }

    private func stopBootRetryWaiting() {
        bootRetryPathMonitor?.cancel()
        bootRetryPathMonitor = nil
    }

    private func runBootPipelineSteps() async throws {
        // 1. 首装解压（仅首次安装走真正的 unzip；之后 fileExists 短路 ≤ 1ms）
        try await ResourceUnzipper.shared.ensureReady()

        // 2. 拉远端渠道配置（**强约束**：必须拿到正确远端 config 才进大厅）
        //
        // 关键决策：**不能**在远端 config 失败时走 fallback 直接进大厅。
        // 原因：远端 config 包含「本地 appVersion 是否落后于远端」的升级判定，
        // 跳过 = 用户用旧版本不被强制升级 = 业务风险。
        //
        // 失败处理 UX（行业主流：停在 splash 显示错误态 + 自动重试）：
        // - 抛出网络错误 → 外层 runBootPipeline catch → splash.showError
        // - 同时启动 NWPathMonitor，网络从无变有时自动重试一次
        // - 用户也可手动点 splash 上的「重试」/「前往系统设置」按钮
        // - 永不弹 modal alert / 永不暴露原始 NSError
        splash.update(text: "拉取配置中...", progress: nil)

        let resolved: ResolvedVersion
        let outcome = try await RemoteConfigClient.shared.fetch()
        switch outcome {
        case .shortText(let msg):
            // 运营杀手锏 #1：服务端响应被替换为短文本，前端弹窗永停
            throw BootError.operationalMessage(msg)

        case .parsed(let cfg):
            let bc = BundleConfig.shared

            // 单一接口、单一 4 层 fallback：所有字段（版本 + 音频 + showmessage）
            // 共用 VersionResolver.resolve(...)，统一从 agent → game → channel → market
            // 倒序找第一个非空，整链都未声明的字段 fallback 到 RemoteConfig 顶层。
            let r = VersionResolver.resolve(
                config: cfg,
                agentId: bc.agent,
                channelId: bc.channel,
                marketId: bc.market,
                gameId: bc.gameId
            )
            print("[VersionResolver] resolved appVer=\(r.appVersion) gameVer=\(r.gameVersion) audioDomain=\(r.audioDomain ?? "<nil>") audioBucket=\(r.audioBucket ?? "<nil>")")

            // 七牛 audio_domain / audio_bucket 缺失视为后台配置错误,启动期致命
            // ——不允许使用空值上传录音。
            guard let audioDomain = r.audioDomain, !audioDomain.isEmpty,
                  let audioBucket = r.audioBucket, !audioBucket.isEmpty
            else {
                throw BootError.audioConfigMissing
            }
            await QiniuConfig.shared.update(cdnDomain: audioDomain, bucketName: audioBucket)

            // 运营杀手锏 #2：showmessage 非空 → 弹窗永停
            if let msg = r.showmessage, !msg.isEmpty {
                throw BootError.operationalMessage(msg)
            }

            // IPA 升级（远端 appVersion > 本地）：弹窗 + Safari 外链永停
            if r.appVersion > LocalVersionReader.localAppVersion,
               let dl = r.appDownload {
                throw BootError.ipaUpgradeRequired(downloadURL: dl)
            }

            // 过渡文案：config 已拉到、进入 zip 升级前的"建连等首字节"静默阶段。
            // 不补这一步，用户会看到"拉取配置中..."一直显示到 zip CDN 冷 DNS+TLS 握完
            // 才跳到"下载更新中 1%"，慢网络下感知像卡死。这里把 UI 状态机补齐到与
            // 实际工作阶段对应。
            splash.update(text: "校验版本中...", progress: nil)

            // H5 zip 升级失败仍致命（用户体验 ≠ 静默用旧 zip）—— 抛出由外层 splash.showError
            // LobbyZipUpgrader.onProgress 由 URLSession delegate 后台队列调度，
            // 必须显式 hop 到 MainActor 才能动 splash UI。
            _ = try await LobbyZipUpgrader.shared.upgradeIfNeeded(
                resolved: r,
                onProgress: { [weak self] p in
                    Task { @MainActor in
                        self?.splash.update(
                            text: String(format: "下载更新中 %d%%", Int(p * 100)),
                            progress: p
                        )
                    }
                }
            )

            resolved = r
        }

        // 6. 在 loadFileURL 之前写 4 个 app_*.js（与原 msext NewRootVC.initJSdata 等价时序）
        //    详见 docs/H5-Native-Implementation-Design.md §7.5
        try writeAppDataFiles(resolved: resolved)

        // 7. 加载本地 H5（Phase 1.F：从 loadFileURL 迁到 AppSchemeHandler）
        //    URL：ylgame://h5/lobby/<gameStart>/index.html
        //    AppSchemeHandler 把 /lobby/<rest> 反向映射到 lobbyRoot/<rest> 读盘。
        //    跨目录引用（H5 用 ../foo.js）自动落回 lobbyRoot，等价 file:// 的
        //    allowingReadAccessTo 语义。
        splash.update(text: "加载大厅...", progress: nil)
        bridgedWebView.webView.load(URLRequest(url: SandboxPaths.lobbyIndexAppURL))
    }

    /// 写 4 个 app_*.js 文件首次值（loadFileURL 之前调一次，monitor 已启动）。
    /// 业务期变化的桥事件挂钩由 `setupExternalSubscriptions` / `teardownExternalSubscriptions`
    /// 配对管理（详见 Design §2.4.2 ExternalSubscriptions 生命周期），避免大厅 push
    /// 子游戏后两个 VC 都在监听导致 H5 收到双倍桥事件。
    private func writeAppDataFiles(resolved: ResolvedVersion) throws {
        let writer = AppDataWriter(
            bundleConfig: .shared,
            resolvedVersion: resolved,
            containerRole: .lobby
        )

        // 启动 2 个 currentXxx 类 monitor（幂等）以便读首次值；
        // AppLifecycleObserver 不需要 currentXxx，挪到 setup 启动
        NetworkMonitor.shared.start()
        BatteryMonitor.shared.start()

        // 首次写入：4 个文件全部落盘
        try writer.writeInitial()
        try writer.writeBattery(BatteryMonitor.shared.currentLevel)
        try writer.writeNetwork(NetworkMonitor.shared.currentCode)
    }

    // MARK: - 外部订阅生命周期（Phase 2.C / Design §2.4.2）
    //
    // 把 battery / network / appservice 三个外部事件源的订阅挂钩与 VC 生命周期
    // 配对：viewWillAppear → setup，viewWillDisappear → teardown。
    // 配套约束：未来 Phase 6 子游戏 push 上来时，大厅的 viewWillDisappear 会自动
    // 触发 teardown，避免桥事件双发（参 §2.4.3 栈深 ≤ 2）。
    //
    // 业务期变化时**不重写文件**，改为直接 evaluateJavaScript 重新赋值 window.app_*
    // 全局变量（H5 已加载完，<script src> 不会再 fetch；重新赋值后 H5 业务期同步读
    // app_xxx 永远拿到最新值）。同时触发 §3.2 反向 callback 推送给注册了 handler 的
    // H5 业务。详见 docs/H5-Native-Implementation-Design.md §7.5.2 / §7.5.4。

    private func setupExternalSubscriptions() {
        // 幂等启动 3 个 monitor（writeAppDataFiles 可能已经启动了 NetworkMonitor /
        // BatteryMonitor；AppLifecycleObserver 在 setup 内首次启动）
        NetworkMonitor.shared.start()
        BatteryMonitor.shared.start()
        AppLifecycleObserver.shared.start()

        let bridge = bridgedWebView.bridge
        let webView = bridgedWebView.webView

        // §3.2 ［2］ getBattery — UIDevice 电量变化
        BatteryMonitor.shared.onChange = { level in
            let value = String(format: "%.2f", level)
            Task { @MainActor in
                _ = try? await webView.evaluateJavaScript("window.app_getbattery=\(value);")
            }
            bridge.call("getBattery", data: .string(value), callback: nil)
        }

        // §3.2 ［3］ getnetwork — NWPath 变化（1 无网 / 2 WiFi / 3 蜂窝）
        NetworkMonitor.shared.onChange = { code in
            Task { @MainActor in
                _ = try? await webView.evaluateJavaScript("window.app_getnetwork=\(code);")
            }
            bridge.call("getnetwork", data: .string("\(code)"), callback: nil)
        }

        // §3.2 ［4］ appservice — App 前后台切换（值错位沿用历史 msext WKWebView 路径）
        // 真·进入后台 → "2"，真·回到前台 → "1"。参 daoqi/msext NewRootVC.m:1773/1782、
        // gameController.m:1323/1332（AppDelegate post 的 NSNotification 名也是错位的，
        // 净结果是 H5 收到的桥值与"自然语义"相反，必须照抄）。
        AppLifecycleObserver.shared.onBackground = {
            bridge.call("appservice", data: .string("2"), callback: nil)
        }
        AppLifecycleObserver.shared.onForeground = {
            bridge.call("appservice", data: .string("1"), callback: nil)
        }

        // §3.2 ［12］ getWebdata — 反向把 backgameData / 弹层 data 入参透传给大厅 H5。
        //
        // 与 msext NewRootVC.m:593 等价：observer 在 viewDidLoad 期间一次性挂钩，
        // **永久 listen，不随 viewWillDisappear teardown**。这样：
        //
        // 1. 子游戏 backgameData 自己 pop 回大厅：
        //    BackGameDataHandler → AppCoordinator.popSubGame → popViewController
        //    sub-game.viewWillDisappear → sub-game 的 observer 解绑（sub-game 走 lifecycle-tied）
        //    再 post → 仅大厅 observer 触发 → 大厅 H5 收到 getWebdata（msext 单发等价）
        //
        // 2. 子游戏内弹层 backgameData pop（弹层 push 自 sub-game）：
        //    OverlayViewController → AppCoordinator.popOverlay → popViewController
        //    overlay.viewWillDisappear → sub-game.viewWillAppear → sub-game observer 重新挂钩
        //    再 post → 大厅 + sub-game observer 同时触发 → 大厅 H5 + sub-game H5 双发收到
        //    （msext NewRootVC + gameController 双观察者等价行为）
        //
        // 用 installed 标志保证多次 viewWillAppear 不重复挂钩（避免重复触发）。
        if !subGameReturnObserverInstalled {
            subGameReturnObserverInstalled = true
            subGameReturnObserver = NotificationCenter.default.addObserver(
                forName: .subGameDidReturn,
                object: nil,
                queue: .main
            ) { note in
                // queue=.main 保证 closure 在主线程；显式 hop 让 Swift 6 actor 推断通过
                let data = (note.userInfo?["data"] as? String) ?? ""
                MainActor.assumeIsolated {
                    bridge.call("getWebdata", data: .string(data), callback: nil)
                }
            }
        }
    }

    private func teardownExternalSubscriptions() {
        // 仅解绑 onChange / on{Background,Foreground} 回调，monitor 继续在
        // 后台运行（保持 currentXxx 状态新鲜），下次 setup 时直接 resume。
        // 单例 monitor 全局只有一个 closure 引用，子游戏 setup 时会覆盖；
        // 大厅 viewWillDisappear 时 teardown 释放对当前 bridge/webView 的引用
        // 避免双发。
        //
        // subGameReturnObserver 是永久挂钩（与 msext NewRootVC observer 等价），
        // **不在此 teardown** —— 详见 setupExternalSubscriptions 注释。
        BatteryMonitor.shared.onChange = nil
        NetworkMonitor.shared.onChange = nil
        AppLifecycleObserver.shared.onBackground = nil
        AppLifecycleObserver.shared.onForeground = nil
    }

    // MARK: - Alerts (terminal / retry states)

    private var appDisplayName: String {
        (Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String)
            ?? (Bundle.main.infoDictionary?["CFBundleName"] as? String)
            ?? "提示"
    }

    private func showBlockingAlert(message: String) {
        let alert = UIAlertController(
            title: "\(appDisplayName) 提醒",
            message: message,
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "确定", style: .default))
        present(alert, animated: true)
    }

    private func showIPAUpgradeAlert(downloadURL: String) {
        // 行为目标：点「确定」直接弹系统安装窗 + App 自动切后台（用户回到桌面），
        // 用户在桌面上点「安装」即可看到 App 图标下载进度，App 进程留在内存不退出。
        //
        // iOS 实际行为：调 itms-services 时系统弹安装确认窗，但本 App 仍处于 inactive
        // 状态（弹窗是模态覆盖在 App 上），用户点「安装」后弹窗 dismiss、App 回到 active
        // —— 系统**不会主动**切后台。要让用户回桌面，需要本 App 主动调私有 selector
        // `suspend` 把自己切后台（等价于用户按 Home / 上滑）。
        // 本项目企业签 / 渠道分发，不上架 AppStore，可以用私有 API。
        //
        // URL 形态：itms-services 直链 / .plist 直链 / 其它（Safari）—— 详见 makeInstallURL。
        // Safari 路径不调 suspend：用户已经在 Safari，suspend 反而破坏 Safari 引导页体验。
        let alert = UIAlertController(
            title: "需要升级",
            message: "检测到新版本，点击「确定」开始升级，系统会自动下载并安装。",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
            guard let url = Self.makeInstallURL(from: downloadURL) else { return }
            UIApplication.shared.open(url)
            if url.scheme?.lowercased() == "itms-services" {
                // 0.3s 等系统接收 itms-services URL（确认弹窗 schedule 完成），
                // 然后把 App 切后台。系统弹窗仍会覆盖在桌面上正常显示。
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                    Self.suspendAppToHomeScreen()
                }
            }
        })
        present(alert, animated: true)
    }

    /// 把本 App 切到后台（用户看到桌面），App 进程留在内存。
    /// 私有 selector `suspend` 等价于按 Home / 上滑回桌面的系统行为。
    /// `UIControl.sendAction` 内部会处理 selector 不响应的情况，比 `perform()` 安全。
    private static func suspendAppToHomeScreen() {
        let sel = NSSelectorFromString("suspend")
        UIControl().sendAction(sel, to: UIApplication.shared, for: nil)
    }

    /// 把远端 `appDownload` 规范化为 iOS 系统可直接接管的安装协议，避免跳 Safari 中转。
    ///
    /// 三种 raw URL 形态：
    /// - `itms-services://...` → 原样返回（iOS 直接弹安装窗）
    /// - `https?://...*.plist`（企业签 manifest 直链）→ 自动包成
    ///   `itms-services://?action=download-manifest&url=<encoded>`，iOS 直接弹安装窗
    /// - 其它（网页 / 短链等）→ 原样跳转，由 Safari 接管。这种情形下后台需要把
    ///   URL 改成上面两种之一才能跳过 Safari 中转
    private static func makeInstallURL(from raw: String) -> URL? {
        let trimmed = raw.trimmingCharacters(in: .whitespaces)
        let lower = trimmed.lowercased()
        if lower.hasPrefix("itms-services://") {
            return URL(string: trimmed)
        }
        if (lower.hasPrefix("https://") || lower.hasPrefix("http://")),
           lower.hasSuffix(".plist") {
            let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? trimmed
            return URL(string: "itms-services://?action=download-manifest&url=\(encoded)")
        }
        return URL(string: trimmed)
    }

    /// 启动期错误的精确分类。先看 NWPath.unsatisfiedReason 区分"真断网"和"权限被拒"
    /// （iOS 14+ 两类都报 NSURLError -1009，但 path 里有 wifiDenied / cellularDenied
    /// 标记），fallback 看 NSURLError code。
    /// 给 `presentBootError` 决定 splash 错误态的标题/正文/按钮语义用。
    private enum BootErrorKind {
        case noNetworkAccess          // 真没网（飞行模式、无 Wi-Fi 无蜂窝）
        case wifiDenied               // 用户在 iOS 设置里禁了本 app 的 Wi-Fi 访问
        case cellularDenied           // 用户在 iOS 设置里禁了本 app 的蜂窝数据访问
        case networkTimeout
        case networkCannotReach
        case localResourceMissing
        case unknown

        /// 该错误是否由"用户拒绝授权"导致（按钮应导向系统设置而非重试）
        var isPermissionDenied: Bool {
            switch self {
            case .wifiDenied, .cellularDenied: return true
            default: return false
            }
        }
    }

    private static func classifyBootError(_ error: Error, path: NWPath?) -> BootErrorKind {
        // 剥洋葱：RemoteConfigError.allRetriesFailed(underlying:) → 内层
        var actual: any Error = error
        if case let RemoteConfigError.allRetriesFailed(inner) = error {
            actual = inner
        }
        let ns = actual as NSError
        guard ns.domain == NSURLErrorDomain else {
            // 非网络错误（FileManager / Codable 等）视作本地资源问题
            return .localResourceMissing
        }

        // 优先看 NWPath.unsatisfiedReason 区分权限拒/真断网（iOS 14.2+）。
        // iOS 把这两类都映射成 NSURLError -1009，只有 NWPath 能给出"为什么不可用"。
        if let path, path.status == .unsatisfied {
            if #available(iOS 14.2, *) {
                switch path.unsatisfiedReason {
                case .wifiDenied:     return .wifiDenied
                case .cellularDenied: return .cellularDenied
                case .notAvailable:   return .noNetworkAccess
                default:              break
                }
            }
        }

        // Fallback：按 NSURLError code 分
        switch ns.code {
        case NSURLErrorNotConnectedToInternet:
            return .noNetworkAccess
        case NSURLErrorTimedOut:
            return .networkTimeout
        case NSURLErrorCannotFindHost,
             NSURLErrorCannotConnectToHost,
             NSURLErrorNetworkConnectionLost,
             NSURLErrorDNSLookupFailed,
             NSURLErrorInternationalRoamingOff,
             NSURLErrorCallIsActive,
             NSURLErrorDataNotAllowed:
            return .networkCannotReach
        default:
            return .networkCannotReach
        }
    }

    /// 同步快照当前 NWPath：start 一个临时 NWPathMonitor 等首次 pathUpdateHandler 触发。
    /// 用于在错误态 UI 展示前判断"是真没网还是权限被拒"。
    private static func currentPathSnapshot() async -> NWPath {
        await withCheckedContinuation { (cont: CheckedContinuation<NWPath, Never>) in
            let monitor = NWPathMonitor()
            // resumed 标记避免 monitor 多次 update 触发重复 resume（Network framework
            // 在某些设备上启动期会回调多次）
            nonisolated(unsafe) var resumed = false
            monitor.pathUpdateHandler = { path in
                guard !resumed else { return }
                resumed = true
                monitor.cancel()
                cont.resume(returning: path)
            }
            monitor.start(queue: DispatchQueue.global(qos: .userInitiated))
        }
    }

    /// 等首次启动权限对话框有决定 —— 最长 `timeout` 秒内观察 NWPath 是否变
    /// `.satisfied`。用于消除"用户点允许太慢就弹前往设置"的误报体验。
    /// 返回 true 表示已授权(可静默重试 pipeline);false 表示超时或明确拒绝
    /// (应走原逻辑弹前往设置)。
    private static func waitForNetworkPermissionResolution(timeout: TimeInterval) async -> Bool {
        await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
            let monitor = NWPathMonitor()
            nonisolated(unsafe) var resumed = false

            DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
                guard !resumed else { return }
                resumed = true
                monitor.cancel()
                cont.resume(returning: false)
            }

            monitor.pathUpdateHandler = { path in
                guard !resumed else { return }
                if path.status == .satisfied {
                    resumed = true
                    monitor.cancel()
                    cont.resume(returning: true)
                }
            }
            monitor.start(queue: DispatchQueue.global(qos: .userInitiated))
        }
    }
}

// MARK: - WKNavigationDelegate

extension WebContainerViewController: WKNavigationDelegate {
    public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        // H5 大厅已经渲染完毕，此时把 splash 从 0.3s 内淡出再 removeFromSuperview，
        // 避免黑屏闪烁也避免后续 superview 再持有这层不必要的视图。
        UIView.animate(withDuration: 0.3, animations: { [weak self] in
            self?.splash.alpha = 0
        }, completion: { [weak self] _ in
            self?.splash.removeFromSuperview()
        })
    }
}

