import Foundation
import Observation
import UIKit
import WebKit

// 引擎过桥推来的设备 presence（对应 web store 的 DeviceInfo）。lastSeen 是 ms epoch。
// 不用 Decodable：过桥的 JS 值经 WKWebView 桥后类型不稳（bool 可能成 NSNumber、
// 整数 / 浮点混用），严格 JSONDecoder 一处不匹配就整组失败、列表全空。改手动容忍解析
// （对齐 web hub.ts handlePresence 的防御式取值）。
struct DeviceItem: Identifiable, Equatable
{
    // deviceID 是稳定标识（broker meta）：设备名与令牌解耦后，去重 / 判本机 / 跨端吊销都按它，
    // 改名只换 name 而 deviceID 不变。后端 presence 总会带；偶发缺失（代铸前的连接）回落 name。
    let deviceID: String
    let name: String
    let type: String
    let online: Bool
    let lastSeen: Double
    var id: String { deviceID.isEmpty ? name : deviceID }
}

// 引擎过桥推来的设备间文本消息（对应 web store 的 MessageRecord）。原生侧持久化（见 RecordsStore），
// 故重启存活、可手动删除——不再「关 app 即清」。
struct MessageItem: Identifiable, Equatable, Codable
{
    let id: String
    let direction: String   // incoming | outgoing
    let peerName: String
    let text: String
    let sentAt: Double
}

// 引擎过桥推来的传输记录（对应 web engine/main.ts 的 toWire 精简视图）。ice* 为选中候选
// 对诊断（揭示 P2P 是否实为 TURN 中继，解释慢速）。同样手动容忍解析。
struct TransferItem: Identifiable, Equatable, Codable
{
    let sessionId: String
    let direction: String
    let fileName: String
    let fileSize: Int
    let state: String
    let mode: String?
    let peerName: String
    let phase: String?
    let bytesTransferred: Int?
    let bytesPerSec: Double?
    let iceConn: String?
    let iceLocal: String?
    let iceRemote: String?
    var id: String { sessionId }
}

// 瞬时通知（#2）：设备移除 / 改名 / 剪贴板等操作结果统一走横幅，醒目、状态分明、自动消失、
// 可手动清除——取代原先散落在设备页 / 设置页且永不清除的灰色小字 deviceActionStatus / clipboardStatus。
struct Notice: Identifiable, Equatable
{
    enum Kind: Equatable { case success, error, info }
    let id = UUID()
    let kind: Kind
    let text: String
}

// 引擎日志条目（#3）：installLogBridge 过桥来的 console.warn/error 逐条入环形缓冲，供独立日志界面
// 查看最近 N 条（而非仅设置页一行 lastEngineLog）。at 为本机收到时刻，用于时间戳显示。
struct LogEntry: Identifiable, Equatable
{
    let id = UUID()
    let level: String   // warn | error | info
    let message: String
    let at: Date
}

// 待批准的他端登录请求（#8）：本机（已登录）扫到另一台设备显示的登录二维码（/link?r=&c=）后，经引擎
// 拉取该请求的设备信息，据此渲染安全确认（批准 / 拒绝）。requestId / code 来自二维码。
struct LoginRequestItem: Identifiable, Equatable
{
    let requestId: String
    let code: String
    let deviceName: String
    let deviceType: String
    let requestIp: String
    var id: String { requestId }
}

// 原生 ↔ 无头 JS 引擎的桥（对侧契约见 web/src/net/ios.ts、ios/PLAN.md §2）。
// 注册名为 "cdropEngine" 的 WKScriptMessageHandler 收 { id, method, payload }（RPC）或
// { notify, payload }（单向通知）；RPC 处理完经 evaluateJavaScript 调
// window.__cdropEngineResolve(id, ok, value) 回交；推命令经 window.__cdropEngineEvent。
@MainActor
@Observable
final class EngineController: NSObject
{
    // SwiftUI 观察的引擎态。
    var status: String = t("ios.engine.disconnected")
    var deviceName: String = ""

    // 引擎经桥推来的真实态，替换原生 UI 原先的 demo 数据。
    var devices: [DeviceItem] = []       // 在线 / 离线设备 presence（每次 presence 事件整组替换）
    var transfers: [TransferItem] = []   // 活跃传输（每次 transfers 事件整组替换）
    var history: [TransferItem] = []     // 已完成传输（transferDone 逐条前插，限长 30）
    var messages: [MessageItem] = []     // 设备间文本消息（引擎每次 message 事件整组替换；最新在前）

    // 诊断信号（设置页展示）：SSE 是否连上 / 是否在重连 + 累计收到的 presence 事件数 + 引擎
    // 最近一条 warn/error 日志。用来定位「设备空」卡在哪一环（未连接？401？静默挂起？）。
    var hubConnected = false
    var hubReconnecting = false
    var presenceCount = 0
    var lastEngineLog = ""

    // 引擎日志环形缓冲（#3）：log 事件逐条前插，限长 logsCap，供独立日志界面查看最近 N 条。
    var logs: [LogEntry] = []
    private static let logsCap = 300

    // 瞬时通知队列（#2）：操作结果横幅，前插展示、自动消失、可手动清除。
    var notices: [Notice] = []

    // 待确认的他端登录请求（#8）：扫码拿到信息后置位，驱动设备页弹批准确认；批准 / 拒绝 / 取消后清空。
    var pendingLoginRequest: LoginRequestItem?

    // 未读红点（#5）：消息 / 传输各自的未读计数，对应 tab 激活时清零。activeTab 记录当前所在
    // 标签，使「正在该页时到达的事件」不计未读。seenIncomingTransfers 去重，避免同一传入传输在
    // 活跃→完成两阶段被重复计数。
    var unreadMessages = 0
    var unreadTransfers = 0
    var activeTab = "transfer"
    private var seenIncomingTransfers: Set<String> = []

    // 引擎 JS 是否已就绪（收到 "ready" 通知）。门控 APNs 令牌登记：令牌可能先于引擎到达，
    // 故 didRegister 与 ready 双触发 flushPushRegistration，任一顺序都登记得上。
    private var engineReady = false

    // 引擎页地址：须安全源（WebRTC 在非安全 origin 可能被禁，见 PLAN R-iOS-3）。默认线上
    // https；可经环境变量 CDROP_ENGINE_URL 覆盖（本机测试指向 http://localhost——localhost
    // 是潜在可信源、算安全上下文，故 WebRTC 仍可用，同 Windows 桌面 loopback 思路）。
    private var engineURL: URL
    {
        if let s = ProcessInfo.processInfo.environment["CDROP_ENGINE_URL"], let u = URL(string: s)
        {
            return u
        }
        return URL(string: "https://drop.commilitia.net/engine.html")!
    }

    // 会话来源（登录后注入引擎 boot）。在 EngineWebView.makeUIView 里 boot 注入前同步接好（#11）。
    var auth: AuthManager?

    // 本机稳定设备 id（broker meta）：判本机（presence 里哪台是自己）与即时改名（PATCH
    // /api/devices/{device_id}）都用它，与设备名解耦——改名不变。
    var selfDeviceID: String { auth?.session?.deviceId ?? "" }

    private var webView: WKWebView?
    private let downloads = DownloadManager()

    // 待发文件暂存：id → 安全作用域 URL（文件选择器选中的文件）。引擎经 cdrop-file://<id>
    // 回取字节（下方 WKURLSchemeHandler 供给）。
    private var outgoing: [String: URL] = [:]

    // 历史 / 消息限长（原生持久化，跨重启存活）。
    private static let historyCap = 50
    private static let messagesCap = 200

    override init()
    {
        super.init()
        PushRegistry.shared.engine = self
        // 加载上次持久化的传输历史 / 消息记录，修「重启清空」。新事件经引擎桥增量追加。
        history = RecordsStore.load([TransferItem].self, "history") ?? []
        messages = RecordsStore.load([MessageItem].self, "messages") ?? []
    }

    // makeWebView：构建离屏 WebView——注入 __CDROP_BOOT__（device_type:"ios"）、注册消息
    // 处理器、注册 cdrop-file 自定义 scheme（发送侧给 JS 喂文件字节）。幂等。
    func makeWebView() -> WKWebView
    {
        if let existing = webView { return existing }

        let ucc = WKUserContentController()
        ucc.add(self, name: "cdropEngine")

        // boot 注入：把登录后的真实会话注入引擎（无会话则 null）。device_type 标 ios 供后端
        // 登记；api_base 默认空（引擎与 /api 同源，从 prod 加载时相对 URL 即可）。
        let debug = ProcessInfo.processInfo.environment["CDROP_DEBUG_SESSION"] == "1"
        let boot = "window.__CDROP_BOOT__ = { session: \(sessionJSON()), "
            + "device_name: \(jsString(currentDeviceName())), "
            + "api_base: \(jsString(apiBaseForBoot())), device_type: \"ios\", debug: \(debug) };"
        ucc.addUserScript(WKUserScript(source: boot,
                                       injectionTime: .atDocumentStart,
                                       forMainFrameOnly: true))

        let cfg = WKWebViewConfiguration()
        cfg.userContentController = ucc
        cfg.setURLSchemeHandler(self, forURLScheme: "cdrop-file")

        // 迁移到 Auth Broker 后引擎自刷走注入的 refresh_token（POST /api/auth/refresh 代理
        // broker），不再依赖 cdrop_session cookie，故无需把 URLSession 的 cookie 同步进 WebView。

        let wv = WKWebView(frame: .zero, configuration: cfg)
        // 始终重新拉取 engine.html（忽略本地缓存），否则引擎更新后 WebView 可能仍跑旧包，
        // 新命令（剪贴板 / 设备管理等）在旧引擎里没有对应 handler → 静默失效。engine.html 很
        // 小、且它引用的哈希化 bundle 仍按内容寻址正常缓存，开销可忽略。
        var request = URLRequest(url: engineURL)
        request.cachePolicy = .reloadIgnoringLocalCacheData
        wv.load(request)
        webView = wv
        deviceName = currentDeviceName()
        // 已登录、引擎启动 → 请求通知授权并注册远程通知（幂等）。令牌回来后经引擎桥上报。
        PushRegistry.shared.requestAuthorizationAndRegister()
        // 触发本地网络权限弹窗（#5）：使 WKWebView 内的 WebRTC 能收集 host 候选、同内网直连。
        LocalNetworkPermission.trigger()
        return wv
    }

    // 登出复位：通知引擎断开 SSE、丢弃 WebView 并清空 UI 态。下次登录时 makeWebView 以新
    // 会话重建（boot 注入读 auth.session 的最新值）。
    func reset()
    {
        sendCommand("shutdown", payload: [:])
        webView = nil
        engineReady = false
        devices = []
        transfers = []
        history = []
        messages = []
        // 登出清空瞬时态：通知 / 日志 / 未读 / 已读传输集 / 待批准登录，不跨账号残留。
        notices = []
        logs = []
        unreadMessages = 0
        unreadTransfers = 0
        seenIncomingTransfers = []
        activeTab = "transfer"
        pendingLoginRequest = nil
        // 登出清空持久记录，不跨账号残留（重启保活仅针对同一登录态）。
        RecordsStore.clear("history")
        RecordsStore.clear("messages")
        status = t("ios.engine.disconnected")
        deviceName = ""
        PushRegistry.shared.reset()
        WidgetSessionStore.clear()
        // L1：释放暂存待发文件的安全作用域访问 + 清空 outgoing，登出不残留访问声明 / 条目。
        for (_, url) in outgoing { url.stopAccessingSecurityScopedResource() }
        outgoing.removeAll()
        // L2：清掉控件设备 id，下次登录铸全新控件身份。
        CDropAPI.clearWidgetDeviceID()
    }

    // 删除一条传输历史记录（D）/ 清空全部（持久化同步）。仅删本地展示记录，不影响已落盘文件。
    func deleteTransferRecord(_ sessionId: String)
    {
        history.removeAll { $0.sessionId == sessionId }
        RecordsStore.save(history, "history")
    }

    func clearHistory()
    {
        history.removeAll()
        RecordsStore.clear("history")
    }

    // 删除一条消息（H）/ 清空全部（持久化同步）。
    func deleteMessage(_ id: String)
    {
        messages.removeAll { $0.id == id }
        RecordsStore.save(messages, "messages")
    }

    func clearMessages()
    {
        messages.removeAll()
        RecordsStore.clear("messages")
    }

    // ---- 瞬时通知（#2） ----

    // notify：把一条操作结果作为横幅入队（前插，最新在上），并安排 4s 后自动消失。不去重——连续
    // 同类操作各给一条反馈更符合预期。空文案忽略。
    func notify(_ text: String, kind: Notice.Kind)
    {
        guard !text.isEmpty else { return }
        let notice = Notice(kind: kind, text: text)
        notices.insert(notice, at: 0)
        Task
        {
            try? await Task.sleep(for: .seconds(4))
            dismissNotice(notice.id)
        }
    }

    func dismissNotice(_ id: UUID)
    {
        notices.removeAll { $0.id == id }
    }

    // ---- 未读红点（#5） ----

    // setActiveTab：标签切换时调用。记录当前标签，并对刚进入的标签清未读（看到即已读）。
    func setActiveTab(_ tab: String)
    {
        activeTab = tab
        if tab == "messages" { unreadMessages = 0 }
        if tab == "transfer" { unreadTransfers = 0 }
    }

    // noteIncomingTransfer：传入传输首次出现（活跃或完成）时计一次未读，按 sessionId 去重，且仅当
    // 不在传输页时计。本机发出的传输不计。
    private func noteIncomingTransfer(_ item: TransferItem)
    {
        guard item.direction == "incoming", !seenIncomingTransfers.contains(item.sessionId) else { return }
        seenIncomingTransfers.insert(item.sessionId)
        if activeTab != "transfer" { unreadTransfers += 1 }
    }

    // ---- 引擎日志（#3） ----

    func clearLogs()
    {
        logs.removeAll()
    }

    // 收到的文件（Documents 沙盒，与 Files app 同一批）。供「收到的文件」视图浏览 / 转发。
    func receivedFiles() -> [URL] { downloads.receivedFiles() }
    func deleteReceivedFile(_ url: URL) { downloads.deleteReceivedFile(url) }

    // 按文件名在已落盘文件里找对应项（传输详情「打开文件」直达用）。落盘时同名会被
    // 唯一化为「name (1)」，故先精确匹配、再退回基名前缀匹配。
    func receivedFile(matching name: String) -> URL?
    {
        let files = downloads.receivedFiles()
        if let exact = files.first(where: { $0.lastPathComponent == name }) { return exact }
        let base = (name as NSString).deletingPathExtension
        return files.first { $0.lastPathComponent.hasPrefix(base) }
    }

    // 可发送目标：在线、且非本机（不发给自己）。发送 / 转发两处入口共用此过滤。
    func sendableDevices() -> [DeviceItem]
    {
        return devices.filter { $0.online && $0.name != deviceName }
    }

    // 剪贴板上行：读本机 UIPasteboard 文本交引擎上传云剪贴板（用户手势触发，前台读取合规）。
    func uploadClipboard()
    {
        let content = UIPasteboard.general.string ?? ""
        guard !content.isEmpty else { notify(t("ios.clipboard.empty"), kind: .info); return }
        sendCommand("clipboardUpload", payload: [ "content": content ])
    }

    // 剪贴板下行：让引擎拉一次云剪贴板，回来写入 UIPasteboard（见 handleNotify "clipboard"）。
    func pullClipboard()
    {
        sendCommand("clipboardPull", payload: [:])
    }

    // 设备管理：移除 / 吊销一台设备。按稳定 device_id（DELETE /api/devices/{device_id}）——
    // 之前传设备名致后端找不到而「移除失败」（#2）。
    func revokeDevice(_ deviceID: String)
    {
        sendCommand("revokeDevice", payload: [ "device_id": deviceID ])
    }

    // ---- 扫码登录他端（#8） ----

    // 本机扫到另一台设备的登录二维码（/link?r=&c=）后，先拉该请求的设备信息（qrRequest，需本机完整
    // 会话），供确认页展示后再批准——不盲批。requestId / code 来自二维码。
    func fetchLoginRequest(requestId: String, code: String)
    {
        sendCommand("fetchLoginRequest", payload: [ "request_id": requestId, "code": code ])
    }

    // 批准当前待确认的他端登录：授予完整会话（原生只接受 full，受限访客是 Web/PWA 的权宜）。
    func approveLogin()
    {
        guard let req = pendingLoginRequest else { return }
        pendingLoginRequest = nil
        sendCommand("approveLogin", payload: [ "request_id": req.requestId, "code": req.code ])
    }

    func denyLogin()
    {
        guard let req = pendingLoginRequest else { return }
        pendingLoginRequest = nil
        sendCommand("denyLogin", payload: [ "request_id": req.requestId, "code": req.code ])
    }

    func cancelLoginRequest()
    {
        pendingLoginRequest = nil
    }

    // 主动取消一个进行中的传输（O）。引擎置该会话为 CANCELLED，随后 transfers / transferDone 回推。
    func cancelTransfer(_ sessionId: String)
    {
        sendCommand("cancelTransfer", payload: [ "sessionId": sessionId ])
    }

    // 立即切到中继（O）：跳过等待 ICE 直连的窗口，直接走 relay（同内网直连建不起来 / 不想等时用）。
    func switchToRelay(_ sessionId: String)
    {
        sendCommand("switchToRelay", payload: [ "sessionId": sessionId ])
    }

    // 设备间文本消息（发送）：把 { to=对端设备名, text } 交引擎经 POST /api/message 转发。乐观回显
    // 由引擎侧 store.addMessage 触发的 message 事件整组回推（含本条 outgoing），故这里不本地插。
    func sendMessage(to peerName: String, text: String)
    {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty, !peerName.isEmpty else { return }
        sendCommand("sendMessage", payload: [ "to": peerName, "text": trimmed ])
    }

    // 本机即时改名（设备名与令牌解耦）：把 { device_id=本机, name } 交引擎经 PATCH /api/devices/
    // {device_id} 只改名字——不换 token、不重连、不产生重复项；成功后引擎回报 "renamed"，本端据此
    // 落本地名 + 更新 Keychain 会话名（见 handleNotify）。无 device_id（异常）则不发。
    func renameSelf(to name: String)
    {
        let id = selfDeviceID
        let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !id.isEmpty, !trimmed.isEmpty else { return }
        sendCommand("renameSelf", payload: [ "device_id": id, "name": trimmed ])
    }

    // APNs 令牌登记：原生拿到令牌（PushRegistry.didRegister）或引擎刚就绪（ready）任一时机调用。
    // 仅在引擎已就绪 + 有令牌时经桥上报，把令牌交后端 /api/push/apns/register（用新鲜会话 token）。
    func flushPushRegistration()
    {
        guard engineReady, let token = PushRegistry.shared.deviceTokenHex else { return }
        sendCommand("registerPush", payload: [ "token": token, "locale": I18n.locale ])
    }

    // 控件会话代铸：引擎就绪后，若 App Group 尚无控件会话则让引擎铸一条（独立 device_id +
    // 「<设备> · 控件」标签）。已有则不重复铸——控件自刷拥有它，重复铸会轮换掉控件的新令牌。
    func provisionWidgetSessionIfNeeded()
    {
        guard engineReady, WidgetSessionStore.load() == nil else { return }
        let id = CDropAPI.widgetDeviceID()
        let label = t("ios.control.sessionLabel", [ "name": currentDeviceName() ])
        sendCommand("provisionWidgetSession", payload: [ "device_id": id, "device_name": label ])
    }

    // 推命令给引擎（原生 → JS，window.__cdropEngineEvent）。
    func sendCommand(_ name: String, payload: [String: Any])
    {
        guard let json = jsonString(payload) else { return }
        webView?.evaluateJavaScript("window.__cdropEngineEvent(\(jsString(name)), \(json));")
    }

    // sendFile：暂存选中文件、把 cdrop-file://<id> 引用 + 目标设备交给引擎发起传输。
    func sendFile(to target: String, fileURL: URL)
    {
        let ref = stageOutgoingFile(fileURL)
        var size = 0
        if let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
           let n = attrs[.size] as? Int { size = n }
        sendCommand("sendFile", payload: [
            "target": target,
            "url": ref,
            "name": fileURL.lastPathComponent,
            "size": size,
        ])
    }

    // Share Extension 经 App Group 收件箱交接来的待发文件（经 cdrop://share 唤起后导入）。
    // 非空即触发主界面的「选设备发送」弹窗。
    var pendingShareFiles: [URL] = []

    // 导入分享：先回收陈旧暂存，再读收件箱当前文件填入 pendingShareFiles（驱动弹窗）。
    func loadPendingShares()
    {
        AppGroup.pruneStale()
        pendingShareFiles = AppGroup.inboxFiles()
    }

    // 把待发的分享文件发往选中设备。先把每个文件从 App Group 收件箱 move 到 app 私有暂存
    // （同卷重命名，便宜），再发——收件箱当即清空，避免下次 loadPendingShares（再分享 / 重启）
    // 重列已发文件而重复发送（审查 M1）。传输异步从私有暂存读取，不再依赖收件箱。
    func sendShares(to device: String)
    {
        for url in pendingShareFiles
        {
            if let staged = moveShareToPrivate(url) { sendFile(to: device, fileURL: staged) }
            AppGroup.removeStaged(url)
        }
        pendingShareFiles = []
    }

    // 把分享文件移到 app 私有暂存目录（保留原名），供传输异步读取；收件箱不再持有它。
    private func moveShareToPrivate(_ url: URL) -> URL?
    {
        let dir = FileManager.default.temporaryDirectory
            .appendingPathComponent("ShareOutbox/\(UUID().uuidString)", isDirectory: true)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        let dest = dir.appendingPathComponent(url.lastPathComponent)
        do { try FileManager.default.moveItem(at: url, to: dest); return dest }
        catch { return nil }
    }

    // 取消分享：删掉收件箱里这些待发文件 + 清空待发列表。
    func dismissShares()
    {
        for url in pendingShareFiles { AppGroup.removeStaged(url) }
        pendingShareFiles = []
    }

    // 图库选择（#14）：PhotosPicker 给的是 PhotosPickerItem，须先 loadTransferable 取字节，再落
    // 一个临时文件，才能复用既有 cdrop-file scheme 发送链路（按 Range 读盘、整文件不入内存）。
    // 返回临时文件 URL（在 temporaryDirectory，系统回收）；写盘失败回 nil。
    func stagePhotoData(_ data: Data, suggestedName: String) -> URL?
    {
        let dir = FileManager.default.temporaryDirectory
            .appendingPathComponent("PhotoOutbox/\(UUID().uuidString)", isDirectory: true)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        let name = suggestedName.isEmpty ? "photo.jpg" : suggestedName
        let dest = dir.appendingPathComponent(name)
        do { try data.write(to: dest); return dest }
        catch { return nil }
    }

    // 暂存待发文件，返回引擎用的 cdrop-file://<id> 引用。文件选择器给的 URL 是安全作用域
    // 资源，需 start...Access 才能读。
    private func stageOutgoingFile(_ url: URL) -> String
    {
        let id = UUID().uuidString
        _ = url.startAccessingSecurityScopedResource()
        outgoing[id] = url
        return "cdrop-file://\(id)"
    }

    // 发送侧按 [start,end) seek 读暂存的待发文件、回 base64（供 bridgeFileSource 经桥取片）。
    // url 形如 cdrop-file://<id>，id 即 stageOutgoingFile 的暂存键。无该文件 / 读失败抛错。
    fileprivate func readOutgoingSlice(url: String, start: Int, end: Int) throws -> String
    {
        guard let id = URL(string: url)?.host, let fileURL = outgoing[id]
        else
        {
            throw NSError(domain: "cdrop.engine", code: 404,
                          userInfo: [NSLocalizedDescriptionKey: "no staged file: \(url)"])
        }
        let handle = try FileHandle(forReadingFrom: fileURL)
        defer { try? handle.close() }
        try handle.seek(toOffset: UInt64(max(0, start)))
        let count = max(0, end - start)
        let data = (try handle.read(upToCount: count)) ?? Data()
        return data.base64EncodedString()
    }
}

// MARK: - JS → 原生（RPC + 通知）

extension EngineController: WKScriptMessageHandler
{
    func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage)
    {
        guard let body = message.body as? [String: Any] else { return }
        if let id = body["id"] as? Int
        {
            handleRPC(id: id,
                      method: body["method"] as? String ?? "",
                      payload: body["payload"] as? [String: Any] ?? [:])
            return
        }
        if let notify = body["notify"] as? String
        {
            handleNotify(notify, payload: body["payload"])
        }
    }

    private func handleRPC(id: Int, method: String, payload: [String: Any])
    {
        do
        {
            switch method
            {
            case "saveDownload":
                let name = payload["name"] as? String ?? "download"
                let path = try downloads.saveWhole(name: name, data: decodeBase64(payload["data"]))
                resolve(id: id, ok: true, value: path)
            case "beginDownload":
                try downloads.begin(sessionId: payload["sessionId"] as? String ?? "")
                resolve(id: id, ok: true, value: nil)
            case "appendDownload":
                try downloads.append(sessionId: payload["sessionId"] as? String ?? "",
                                     data: decodeBase64(payload["data"]))
                resolve(id: id, ok: true, value: nil)
            case "finalizeDownload":
                let path = try downloads.finalize(sessionId: payload["sessionId"] as? String ?? "",
                                                  name: payload["name"] as? String ?? "download")
                resolve(id: id, ok: true, value: path)
            case "abortDownload":
                downloads.abort(sessionId: payload["sessionId"] as? String ?? "")
                resolve(id: id, ok: true, value: nil)
            case "readFileSlice":
                // 发送侧取片：原生 seek 暂存的待发文件读 [start,end)、回 base64（见 bridgeFileSource）。
                let b64 = try readOutgoingSlice(url: payload["url"] as? String ?? "",
                                                start: Self.intOf(payload["start"]),
                                                end: Self.intOf(payload["end"]))
                resolve(id: id, ok: true, value: b64)
            default:
                resolve(id: id, ok: false, value: "unknown method: \(method)")
            }
        }
        catch
        {
            resolve(id: id, ok: false, value: "\(error)")
        }
    }

    private func handleNotify(_ name: String, payload: Any?)
    {
        switch name
        {
        case "ready":
            status = t("ios.engine.ready")
            engineReady = true
            flushPushRegistration()
            provisionWidgetSessionIfNeeded()
        case "probe":
            // 本机 WebRTC-in-WKWebView 探针结果（验 arch A 命门 R-iOS-3）。
            if let p = payload as? [String: Any]
            {
                let secure = (p["secure"] as? Bool) ?? false
                let rtc = (p["rtc"] as? String) ?? "?"
                let host = (p["host"] as? Int) ?? 0
                let srflx = (p["srflx"] as? Int) ?? 0
                status = "安全:\(secure) RTC:\(rtc) host:\(host) srflx:\(srflx)"
            }
        case "presence":
            // 注意：桥来的 JS 数组是 NSArray，`as? [[String: Any]]` 这种嵌套泛型条件转换
            // 在 Foundation 桥接下常整体失败（即便数据合法）→ 列表全空。故先转 [Any] 再逐
            // 元素 as? [String: Any]，这是先前「健壮解析」仍漏掉的真正失败点。
            if let p = payload as? [String: Any], let raw = p["devices"] as? [Any]
            {
                devices = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseDevice) }
            }
        case "transfers":
            if let p = payload as? [String: Any], let raw = p["active"] as? [Any]
            {
                transfers = raw.compactMap { ($0 as? [String: Any]).flatMap(Self.parseTransfer) }
                for item in transfers { noteIncomingTransfer(item) }   // 传入传输首现计未读红点（#5）
                syncBackgroundTask()
            }
        case "message":
            // 引擎推来的单条新消息（增量）：前插 + 去重 + 限长 + 落盘。原生为持久真源，故不被
            // 引擎重启后的空列表覆盖（重启清空问题的根因）。
            if let p = payload as? [String: Any],
               let m = p["message"] as? [String: Any],
               let item = Self.parseMessage(m)
            {
                let isNew = !messages.contains { $0.id == item.id }
                messages.removeAll { $0.id == item.id }
                messages.insert(item, at: 0)
                if messages.count > Self.messagesCap
                {
                    messages.removeLast(messages.count - Self.messagesCap)
                }
                RecordsStore.save(messages, "messages")
                // 新到的对端消息：不在消息页时计未读红点（#5）。本机发出的乐观回显（outgoing）不计。
                if isNew, item.direction == "incoming", activeTab != "messages" { unreadMessages += 1 }
            }
        case "transferDone":
            // 完成项移出活跃、前插历史；按 sessionId 去重避免重复事件叠加。
            if let p = payload as? [String: Any], let item = Self.parseTransfer(p)
            {
                noteIncomingTransfer(item)   // 极快传输可能跳过活跃阶段，这里补计未读（#5，已 seen 则不重复）
                transfers.removeAll { $0.sessionId == item.sessionId }
                history.removeAll { $0.sessionId == item.sessionId }
                history.insert(item, at: 0)
                if history.count > Self.historyCap { history.removeLast(history.count - Self.historyCap) }
                syncBackgroundTask()
                RecordsStore.save(history, "history")   // 持久化，跨重启存活
            }
        case "sendStarted":
            // 随后的 transfers 事件会带出该活跃项，这里无需额外处理。
            break
        case "hubState":
            if let p = payload as? [String: Any]
            {
                hubConnected = Self.boolOf(p["connected"])
                hubReconnecting = Self.boolOf(p["reconnecting"])
                presenceCount = Self.intOf(p["presenceCount"])
            }
        case "log":
            if let p = payload as? [String: Any], let msg = p["msg"] as? String
            {
                lastEngineLog = msg
                logs.insert(LogEntry(level: p["level"] as? String ?? "info", message: msg, at: Date()), at: 0)
                if logs.count > Self.logsCap { logs.removeLast(logs.count - Self.logsCap) }
            }
        case "clipboard":
            // 引擎拉回的云剪贴板内容 → 写入本机 UIPasteboard。
            if let p = payload as? [String: Any], let content = p["content"] as? String, !content.isEmpty
            {
                UIPasteboard.general.string = content
                notify(t("ios.clipboard.pulled"), kind: .success)
            }
            else
            {
                notify(t("ios.clipboard.empty"), kind: .info)
            }
        case "clipboardUploaded":
            notify(t("ios.clipboard.uploaded"), kind: .success)
        case "deviceRevoked":
            notify(t("ios.devices.revoked"), kind: .success)
        case "loginRequest":
            // 扫码拿到的他端登录请求信息（#8）→ 置位驱动设备页弹批准确认。
            if let p = payload as? [String: Any],
               let rid = p["request_id"] as? String, let code = p["code"] as? String
            {
                pendingLoginRequest = LoginRequestItem(
                    requestId: rid, code: code,
                    deviceName: p["device_name"] as? String ?? "",
                    deviceType: p["device_type"] as? String ?? "",
                    requestIp: p["request_ip"] as? String ?? "")
            }
        case "loginApproved":
            notify(t("ios.scan.approved"), kind: .success)
        case "loginDenied":
            notify(t("ios.scan.denied"), kind: .success)
        case "loginFailed":
            pendingLoginRequest = nil
            notify(t("ios.scan.failed"), kind: .error)
        case "identityUpdated":
            // 引擎据 /api/me 取到真实显示名（QR 登录注入的是 UUID 名）→ 更新会话身份 + Keychain，
            // 设置页据此显示真名而非 UUID（#12）。
            if let p = payload as? [String: Any], let name = p["name"] as? String, !name.isEmpty
            {
                auth?.updateIdentity(name: name, avatar: p["avatar"] as? String)
            }
        case "renamed":
            // 本机即时改名成功（PATCH /api/devices/{device_id} 已落库 + 重广播 presence）→ 落本地名
            // 并更新 Keychain 会话名，使重启后 boot 注入新名。
            if let p = payload as? [String: Any], let name = p["name"] as? String, !name.isEmpty
            {
                DeviceNameStore.value = name
                auth?.updateDeviceName(name)
                deviceName = name
                notify(t("ios.settings.deviceName.success"), kind: .success)
            }
        case "error":
            // 复用现有 error 通知：剪贴板 / 吊销失败也走这里，分流到对应状态行。
            if let p = payload as? [String: Any]
            {
                let stage = p["stage"] as? String ?? ""
                let msg = p["message"] as? String ?? ""
                if stage == "clipboard" { notify(t("ios.clipboard.failed"), kind: .error) }
                else if stage == "revoke"
                {
                    notify(msg == "step_up_required"
                        ? t("ios.devices.revokeStepUp") : t("ios.devices.revokeFailed"), kind: .error)
                }
                else { status = "错误：\(msg)" }
            }
        case "sessionRotated":
            // 引擎自刷致 broker 轮换 refresh → 更新 Keychain，重启免用失效旧 refresh。
            if let p = payload as? [String: Any],
               let access = p["access_token"] as? String,
               let refresh = p["refresh_token"] as? String
            {
                auth?.updateSession(accessToken: access, refreshToken: refresh)
            }
        case "widgetSession":
            // 引擎代铸的控件专用设备会话 → 存 App Group 供控制中心控件读取 / 自刷。
            if let p = payload as? [String: Any],
               let access = p["access_token"] as? String,
               let refresh = p["refresh_token"] as? String,
               let userId = p["user_id"] as? String
            {
                WidgetSessionStore.save(WidgetSession(accessToken: access, refreshToken: refresh, userId: userId))
            }
        case "authExpired":
            // 引擎确证会话已失效（refresh 过期 / 吊销）→ 清 Keychain 回登录页。
            auth?.logout()
            reset()
        default:
            break
        }
    }

    private func resolve(id: Int, ok: Bool, value: Any?)
    {
        webView?.evaluateJavaScript("window.__cdropEngineResolve(\(id), \(ok), \(jsonValue(value)));")
    }

    // 活跃传输变化时驱动后台续传任务：transfers 即活跃集（完成项已移入 history），故按其
    // 数量与聚合字节进度通知 BackgroundTaskManager 提交 / 更新 / 完成（见 R-iOS-1 / R-iOS-3）。
    private func syncBackgroundTask()
    {
        let total = transfers.reduce(0) { $0 + $1.fileSize }
        let done = transfers.reduce(0) { $0 + ($1.bytesTransferred ?? 0) }
        let fraction = total > 0 ? Double(done) / Double(total) : 0
        BackgroundTaskManager.shared.sync(activeCount: transfers.count, fraction: fraction)
    }
}

// MARK: - 发送侧自定义 scheme（cdrop-file）

extension EngineController: WKURLSchemeHandler
{
    // 发送侧：原生把暂存的待发文件经 cdrop-file://<id> 喂给引擎（JS fetch 它得到字节，
    // 见 PLAN §2 / R-iOS-4）。引擎（rangeSource）按块带 `Range` 头拉取——这里 seek 文件
    // 仅读该区间、回 206 Partial Content，整文件永不入内存，避大文件 jetsam。无 Range
    // 头时回退整文件（防御路径；引擎正常总带 Range）。
    func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask)
    {
        guard let url = urlSchemeTask.request.url, let id = url.host, let fileURL = outgoing[id]
        else
        {
            respond(urlSchemeTask, status: 404)
            return
        }
        guard let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
              let total = attrs[.size] as? Int,
              let handle = try? FileHandle(forReadingFrom: fileURL)
        else
        {
            respond(urlSchemeTask, status: 404)
            return
        }
        defer { try? handle.close() }

        if let range = Self.parseByteRange(urlSchemeTask.request.value(forHTTPHeaderField: "Range"), total: total)
        {
            let data: Data
            do
            {
                try handle.seek(toOffset: UInt64(range.lowerBound))
                data = (try handle.read(upToCount: range.count)) ?? Data()
            }
            catch
            {
                respond(urlSchemeTask, status: 500)
                return
            }
            let resp = HTTPURLResponse(url: url, statusCode: 206, httpVersion: "HTTP/1.1",
                headerFields: [
                    "Content-Type": "application/octet-stream",
                    "Content-Length": "\(data.count)",
                    "Content-Range": "bytes \(range.lowerBound)-\(range.upperBound - 1)/\(total)",
                    "Accept-Ranges": "bytes",
                ])!
            urlSchemeTask.didReceive(resp)
            urlSchemeTask.didReceive(data)
            urlSchemeTask.didFinish()
            return
        }

        let data = (try? handle.readToEnd()) ?? Data()
        let resp = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
            headerFields: [
                "Content-Type": "application/octet-stream",
                "Content-Length": "\(data.count)",
                "Accept-Ranges": "bytes",
            ])!
        urlSchemeTask.didReceive(resp)
        urlSchemeTask.didReceive(data)
        urlSchemeTask.didFinish()
    }

    func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
    {
        // 每次请求同步返回（一块即一次 read），无长任务需取消。
    }

    // 解析 HTTP `Range: bytes=start-end`（end 含端）。返回半开区间 [lowerBound, upperBound)。
    // end 缺省（"bytes=start-"）取到 EOF；越界裁剪到文件尾；非法 / 不可满足返回 nil。
    static func parseByteRange(_ header: String?, total: Int) -> Range<Int>?
    {
        guard let header, header.hasPrefix("bytes="), total > 0 else { return nil }
        let spec = header.dropFirst("bytes=".count)
        let parts = spec.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
        guard parts.count == 2, let start = Int(parts[0]), start >= 0, start < total else { return nil }
        let endInclusive: Int
        if parts[1].isEmpty { endInclusive = total - 1 }
        else if let e = Int(parts[1]) { endInclusive = min(e, total - 1) }
        else { return nil }
        guard endInclusive >= start else { return nil }
        return start ..< (endInclusive + 1)
    }

    private func respond(_ task: any WKURLSchemeTask, status: Int)
    {
        guard let url = task.request.url,
              let resp = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)
        else { return }
        task.didReceive(resp)
        task.didFinish()
    }
}

// MARK: - 通知解析（手动容忍，见 DeviceItem 注释）

extension EngineController
{
    // 桥来的数值经 WKWebView 后可能是 NSNumber / Int / Double / Bool，任一字段类型不符都
    // 不应让整组失败。下列取值器逐字段宽松转换，缺失给默认。
    static func parseDevice(_ d: [String: Any]) -> DeviceItem?
    {
        guard let name = d["name"] as? String, let type = d["type"] as? String else { return nil }
        // 引擎推来的是 web store 的 DeviceInfo（camelCase：deviceId / lastSeen）。
        return DeviceItem(deviceID: d["deviceId"] as? String ?? "",
                          name: name,
                          type: type,
                          online: boolOf(d["online"]),
                          lastSeen: doubleOf(d["lastSeen"]))
    }

    static func parseMessage(_ m: [String: Any]) -> MessageItem?
    {
        guard let id = m["id"] as? String, let text = m["text"] as? String else { return nil }
        return MessageItem(id: id,
                           direction: m["direction"] as? String ?? "incoming",
                           peerName: m["peerName"] as? String ?? "",
                           text: text,
                           sentAt: doubleOf(m["sentAt"]))
    }

    static func parseTransfer(_ t: [String: Any]) -> TransferItem?
    {
        guard let sessionId = t["sessionId"] as? String else { return nil }
        let ice = t["ice"] as? [String: Any]
        return TransferItem(sessionId: sessionId,
                            direction: t["direction"] as? String ?? "outgoing",
                            fileName: t["fileName"] as? String ?? "",
                            fileSize: intOf(t["fileSize"]),
                            state: t["state"] as? String ?? "PENDING",
                            mode: t["mode"] as? String,
                            peerName: t["peerName"] as? String ?? "",
                            phase: t["phase"] as? String,
                            bytesTransferred: t["bytesTransferred"].map { intOf($0) },
                            bytesPerSec: t["bytesPerSec"].map { doubleOf($0) },
                            iceConn: ice?["conn"] as? String,
                            iceLocal: ice?["local"] as? String,
                            iceRemote: ice?["remote"] as? String)
    }

    static func boolOf(_ v: Any?) -> Bool
    {
        if let b = v as? Bool { return b }
        if let n = v as? NSNumber { return n.boolValue }
        return false
    }

    static func intOf(_ v: Any?) -> Int
    {
        if let i = v as? Int { return i }
        if let n = v as? NSNumber { return n.intValue }
        if let d = v as? Double { return Int(d) }
        return 0
    }

    static func doubleOf(_ v: Any?) -> Double
    {
        if let d = v as? Double { return d }
        if let n = v as? NSNumber { return n.doubleValue }
        if let i = v as? Int { return Double(i) }
        return 0
    }
}

// MARK: - 编码助手

private extension EngineController
{
    func decodeBase64(_ v: Any?) -> Data
    {
        guard let s = v as? String, let d = Data(base64Encoded: s) else { return Data() }
        return d
    }

    func jsonString(_ obj: [String: Any]) -> String?
    {
        guard JSONSerialization.isValidJSONObject(obj),
              let d = try? JSONSerialization.data(withJSONObject: obj),
              let s = String(data: d, encoding: .utf8)
        else { return nil }
        return s
    }

    // 把任意值编码成可嵌入 JS 的字面量（字符串走 JSON 转义；nil → null）。借数组包裹再
    // 剥括号，绕过 JSONSerialization 不接受顶层标量的限制。
    func jsonValue(_ v: Any?) -> String
    {
        guard let v = v else { return "null" }
        guard let d = try? JSONSerialization.data(withJSONObject: [v]),
              let s = String(data: d, encoding: .utf8)
        else { return "null" }
        return String(s.dropFirst().dropLast())
    }

    func jsString(_ s: String) -> String
    {
        return jsonValue(s)
    }

    // 引擎 boot 注入用：会话 JSON（无会话则 null）、设备名、api_base。
    func sessionJSON() -> String
    {
        guard let s = auth?.session else { return "null" }
        return "{ access_token: \(jsString(s.accessToken)), "
            + "refresh_token: \(jsString(s.refreshToken)), "
            + "user: { id: \(jsString(s.user.id)), name: \(jsString(s.user.name)) } }"
    }

    func currentDeviceName() -> String
    {
        return auth?.session?.deviceName ?? "iPhone"
    }

    func apiBaseForBoot() -> String
    {
        return ProcessInfo.processInfo.environment["CDROP_API_BASE"] ?? ""
    }
}
