/**
 * df-relay WS 客户端(uni-app 跨端适配)
 *
 * 协议(对齐 crates/df-relay/src/relay.rs handle_connection):
 * 1. 建立 WS 连接到 `/ws/miniapp`
 * 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时)
 * 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close
 *
 * 跨端适配:
 * - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
 * - H5:uni.connectSocket 封装浏览器 WebSocket
 * - 统一经 uni API 封装,业务层不直接 new WebSocket
 *
 * 连接生命周期(P0/P1 审计后重构,根本性,非补丁):
 * - 所有"建连"经 reconnectNow() 幂等入口:clear 退避 timer + cleanup + openSocket
 *   connect()/resumeIfDisconnected()/scheduleReconnect 回调全部经此,杜绝双连竞态 + socket 泄漏
 * - openSocket() 幂等:首行 destroySocket 残留 socket,保证 this.socket 引用唯一
 * - destroySocket(task):显式注销 onOpen/onMessage/onClose/onError(覆盖空函数,微信无 offXxx)
 *   + close,防旧 socket 闭包回调污染新连接(单例 this 句柄被多 socket 捕获)
 * - cleanup() 彻底化:destroySocket + 停心跳 + 清所有 timer + 清 pendingQueue
 *   清队列防跨连接/跨会话陈旧命令补发(旧命令上下文已失效)
 * - 超时三重兜底:连接超时(onOpen 未到)/握手超时(hello_ack 丢失,>relay 10s)/
 *   onError 兜底(微信 socket onError 不保证 onClose 跟随,DNS/TLS/域名未加白场景)
 * - 心跳活性检测:lastInboundAt + 2x 间隔判定半开死连接(NAT 超时/网络切换)
 * - pendingQueue 上限 + TTL:防握手永卡时无限堆积内存泄漏
 *
 * 重连:指数退避(baseDelay * 2^min(n,6),封顶 maxDelay,带随机抖动)
 * 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
 * JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
 *           出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
 */

import { getConfig } from '@/config'
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'

/** WS 连接状态 */
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'

/** 入站事件回调(Event/Control 消息分派) */
export interface WsHandlers {
  /** 收到 Event 消息(payload 已解为 AiChatEvent JSON,业务层按 type discriminator 处理) */
  onEvent: (msg: BroadcastMessage) => void
  /** 收到 Control 消息(心跳响应 / 在线状态等) */
  onControl: (msg: ControlMessage) => void
  /** 状态变更(连接 / 断开 / 重连中) */
  onStatus: (status: WsStatus, detail?: string) => void
}

/** pendingQueue 带入队时间戳,flush 时按 TTL 丢弃陈旧命令 */
interface QueuedCmd {
  cmd: MiniCommand
  ts: number
}

/** 握手超时(>relay.rs:321 服务端 10s,留余量) */
const HANDSHAKE_TIMEOUT_MS = 12000
/** 连接超时(openSocket 后 onOpen 未到则重连;治 onError 不触发 onClose 场景) */
const CONNECT_TIMEOUT_MS = 8000
/** pendingQueue 上限(防握手永卡时无限堆积内存泄漏) */
const PENDING_MAX = 50
/** pendingQueue 单条 TTL(flush 时超时丢弃,防跨连接陈旧命令补发) */
const PENDING_TTL_MS = 60000
/** 心跳活性判定因子:超过 N 倍心跳间隔无入站消息判半开死连接 */
const HEARTBEAT_DEAD_FACTOR = 2
/** 最大重连次数(永久性故障如 relayHost 错/中继下线,停止无限退避,提示用户检查) */
const MAX_RECONNECT_ATTEMPTS = 20

/**
 * WS 客户端单例(miniapp 全局一个连接)
 *
 * 设计:单 socket + 单 handlers 回调集合(composable 订阅,业务层不直接持 socket)。
 * 握手/心跳/重连机制内部封装,业务层仅关心 send(Command) + onEvent。
 */
class WsClient {
  private socket: UniApp.SocketTask | null = null
  private handlers: WsHandlers | null = null
  private status: WsStatus = 'disconnected'
  /** 握手是否已完成(收到首个广播或握手成功信号) */
  private handshaked = false
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null
  /** 握手超时定时器(handleOpen 发 Hello 后启,收 hello_ack 后清) */
  private handshakeTimer: ReturnType<typeof setTimeout> | null = null
  /** 连接超时定时器(openSocket 启,onOpen 到达后清) */
  private connectTimer: ReturnType<typeof setTimeout> | null = null
  private reconnectAttempts = 0
  /** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
  private manualClose = false
  /**
   * 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
   * 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
   * 业务层快速重发会被 send 守卫放行后静默丢失;入队保证握手稳定后顺序补发。
   * 带时间戳 + 上限 + TTL,防握手永卡时无限堆积(见 PENDING_MAX/PENDING_TTL_MS)。
   */
  private pendingQueue: QueuedCmd[] = []
  /** 最后入站消息时间戳(心跳活性检测,relay 无 pong 故用任意入站消息替代) */
  private lastInboundAt = 0

  /** 订阅(单订阅,composable 注册一次) */
  subscribe(h: WsHandlers): void {
    this.handlers = h
  }

  /** 读取当前状态 */
  getStatus(): WsStatus {
    return this.status
  }

  /** 主动连接 */
  connect(): void {
    if (this.status === 'connected' || this.status === 'connecting' || this.status === 'handshaking') {
      return
    }
    this.manualClose = false
    // 用户主动连接复位重连计数(可与达上限后的自动退避区分,允许手动重试)
    this.reconnectAttempts = 0
    // 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
    this.reconnectNow()
  }

  /** 主动断开(用户手动,不重连) */
  disconnect(): void {
    this.manualClose = true
    this.reconnectAttempts = 0
    // cleanup 内部清 pendingQueue + 注销 socket + 清所有 timer
    this.cleanup()
    this.setStatus('disconnected', 'manual close')
  }

  /**
   * 若已断开或正在退避重连则立即重连(onShow 时调用,小程序后台回前台)。
   *
   * 覆盖 `disconnected` 与 `reconnecting` 两种态:
   * - disconnected:onClose 已触发,直接重连;
   * - reconnecting:退避计时未到,抢占退避立即重连(治「切后台回前台卡在 reconnecting」头号症状)。
   *
   * 统一经 reconnectNow(幂等清理),保证抢占时不残留旧 socket/timer。
   */
  resumeIfDisconnected(): void {
    if (this.manualClose) return
    if (this.status === 'disconnected' || this.status === 'reconnecting') {
      // 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
      this.reconnectAttempts = 0
      this.reconnectNow()
    }
  }

  /**
   * 暂停后台定时器(小程序 onHide 调,P1-9):停心跳 setInterval。
   *
   * 微信后台定时器节流但仍触发,心跳持续发 ping 浪费 + 后台无入站致半开检测误判。
   * 不 disconnect(disconnect 清队列 + manualClose,不适合后台暂离),仅停心跳保留连接。
   * 重连 setTimeout 若后台断连触发,单次非循环,影响可接受(MVP 不暂停)。
   */
  pauseBackground(): void {
    this.stopHeartbeat()
  }

  /**
   * 恢复前台(小程序 onShow 调):连接在则重启心跳,断则重连。
   */
  resumeBackground(): void {
    if (this.status === 'connected' && this.handshaked) {
      this.startHeartbeat()
    } else {
      this.resumeIfDisconnected()
    }
  }

  /**
   * 发送 Command(miniapp → device)
   *
   * 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
   * BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
   */
  send(cmd: MiniCommand): boolean {
    console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
    if (!this.socket || !this.handshaked) {
      // 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
      // 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
      // 否则(manualClose=true / 长期 disconnected)不堆积陈旧命令。
      if (this.manualClose) {
        console.warn('[WsClient] 已主动断开,丢弃命令', cmd.cmd)
        return false
      }
      this.enqueuePending(cmd)
      return false
    }
    try {
      const text = JSON.stringify(cmd)
      this.socket.send({ data: text })
      return true
    } catch (e) {
      console.error('[WsClient] send 失败', e)
      return false
    }
  }

  /** 入队(带上限 + 时间戳,TTL 丢弃见 flushPendingQueue) */
  private enqueuePending(cmd: MiniCommand): void {
    if (this.pendingQueue.length >= PENDING_MAX) {
      // 超上限丢弃最旧,防握手永卡时无限堆积内存泄漏
      this.pendingQueue.shift()
      console.warn('[WsClient] pendingQueue 超上限,丢弃最旧命令')
    }
    this.pendingQueue.push({ cmd, ts: Date.now() })
    console.log('[WsClient] 握手未完成,命令入队待补发', cmd.cmd, 'queue=', this.pendingQueue.length)
  }

  /**
   * 补发握手期间积压的命令队列(收到 hello_ack 后调)。
   *
   * list_conversations 已由握手逻辑自己发(不进队列),此处仅补发业务 send 积压。
   * 补发前置 handshaked=true,send 守卫放行,逐条直发 socket。
   * TTL 丢弃:入队超 PENDING_TTL_MS 的视为陈旧(上下文已失效),不补发。
   */
  private flushPendingQueue(): void {
    if (this.pendingQueue.length === 0) return
    const now = Date.now()
    console.log('[WsClient] 补发握手积压命令', this.pendingQueue.length)
    while (this.pendingQueue.length > 0) {
      const item = this.pendingQueue.shift()!
      if (now - item.ts > PENDING_TTL_MS) {
        console.warn('[WsClient] 补发时丢弃超时陈旧命令', item.cmd.cmd)
        continue
      }
      try {
        if (this.socket) {
          this.socket.send({ data: JSON.stringify(item.cmd) })
        }
      } catch (e) {
        console.error('[WsClient] 补发命令失败', item.cmd.cmd, e)
      }
    }
  }

  // ─── 连接生命周期(统一入口 + 幂等清理) ───────────────────

  /**
   * 幂等立即建连入口(clear 退避 timer + cleanup + openSocket)。
   *
   * 所有需要"立即开新连接"的路径(connect/resumeIfDisconnected/scheduleReconnect 回调)
   * 统一经此,保证:
   * - 不残留挂起的 reconnectTimer(防退避 timer 与 openSocket 双触发建两个 socket);
   * - 旧 socket 被 cleanup 注销+关闭(防 this.socket 引用覆盖后句柄泄漏);
   * - pendingQueue 被清(防跨连接/跨会话陈旧命令补发)。
   */
  private reconnectNow(): void {
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer)
      this.reconnectTimer = null
    }
    this.cleanup()
    this.openSocket()
  }

  private openSocket(): void {
    // 幂等:残留 socket 先注销+关闭(reconnectNow 已 cleanup,此为双保险)
    if (this.socket) {
      this.destroySocket(this.socket)
      this.socket = null
    }
    const cfg = getConfig()
    this.setStatus('connecting')
    console.log('[WsClient] 连接', cfg.relayHost)

    let task: UniApp.SocketTask
    try {
      task = uni.connectSocket({
        url: cfg.relayHost,
        complete: () => {
          // complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
        },
      })
    } catch (e) {
      console.error('[WsClient] connectSocket 异常', e)
      this.scheduleReconnect()
      return
    }
    this.socket = task

    task.onOpen(() => this.handleOpen())
    task.onMessage((res) => this.handleMessage(res))
    task.onClose((res) => this.handleClose(res))
    task.onError((res) => this.handleError(res))

    // 连接超时:onOpen 未到则重连(治 onError 不触发 onClose 的场景:
    // DNS 解析失败/TLS 握手失败/relayHost 不可达/wss 域名未加白名单)
    this.connectTimer = setTimeout(() => {
      this.connectTimer = null
      if (this.status === 'connecting') {
        console.warn('[WsClient] 连接超时,onOpen 未到,重连')
        this.cleanup()
        this.scheduleReconnect()
      }
    }, CONNECT_TIMEOUT_MS)
  }

  /** WS 已建立连接 → 立即发 Hello 握手帧 + 启握手超时 */
  private handleOpen(): void {
    // onOpen 到达,取消连接超时
    if (this.connectTimer) {
      clearTimeout(this.connectTimer)
      this.connectTimer = null
    }
    this.setStatus('handshaking')
    const cfg = getConfig()
    const hello: Hello = {
      kind: 'miniapp',
      device_id: cfg.deviceId,
      token: cfg.token,
    }
    try {
      this.socket?.send({ data: JSON.stringify(hello) })
    } catch (e) {
      console.error('[WsClient] 发送 Hello 失败', e)
      // 失败先 cleanup(关坏 socket + 清监听器)再退避,与 handleHandshakeResponse 失败路径对齐
      this.cleanup()
      this.scheduleReconnect()
      return
    }
    // 握手超时:hello_ack 丢失(网络抖动/relay send_text 失败吞错 relay.rs:253-257)则重连。
    // >relay.rs:321 服务端 10s,留余量。握手成功/失败时 handleHandshakeResponse 会清此 timer。
    this.handshakeTimer = setTimeout(() => {
      this.handshakeTimer = null
      if (!this.handshaked) {
        console.warn('[WsClient] 握手超时,hello_ack 未收到,重连')
        this.cleanup()
        this.scheduleReconnect()
      }
    }, HANDSHAKE_TIMEOUT_MS)
  }

  /** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
  private handleMessage(res: { data: string | ArrayBuffer }): void {
    // 协议纯 JSON 文本帧(relay 发文本),ArrayBuffer 非预期 —— 加 warn 可观测
    // (防二进制帧静默丢弃致握手期 hello_ack 被吃却无日志)
    if (typeof res.data !== 'string') {
      console.warn('[WsClient] 收到非预期二进制帧,丢弃', (res.data as ArrayBuffer).byteLength)
      return
    }
    const text = res.data
    if (!text) return
    // 活性:任意入站帧更新(含 relay 回的 pong 心跳响应,用于半连接检测)
    this.lastInboundAt = Date.now()

    // 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
    if (!this.handshaked) {
      this.handleHandshakeResponse(text)
      return
    }

    // 解析 BroadcastMessage(device 事件经 relay 包成完整 BroadcastMessage 下发)
    let msg: BroadcastMessage
    try {
      msg = JSON.parse(text) as BroadcastMessage
    } catch (e) {
      console.warn('[WsClient] 入站消息 JSON 解析失败', e, text.slice(0, 200))
      return
    }

    // 按 kind 分派:Event → 业务层;Control → 心跳响应等
    if (msg.kind === 'event') {
      this.handlers?.onEvent(msg)
    } else if (msg.kind === 'control') {
      this.handlers?.onControl(msg.payload as ControlMessage)
    }
    // command 是 device→miniapp 反向(不应在此端收到,忽略)
  }

  /** 处理握手响应(relay 发 hello_ack 显式 ack;失败时发 error 帧 + 关连接) */
  private handleHandshakeResponse(text: string): void {
    // 结构化解析(优先):对齐 relay.rs:255 实发 hello_ack 字面量
    //   成功:{"kind":"control","payload":{"control_kind":"hello_ack"}}
    //   失败:{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"} (relay.rs:200-238)
    //   注:hello_ack 的 control_kind 在 payload 内(与 error 顶层字段结构不同,须分别判)。
    let parsedOk = false
    let parsed: { kind?: string; error?: string; payload?: { control_kind?: string } } | null = null
    try {
      parsed = JSON.parse(text) as { kind?: string; error?: string; payload?: { control_kind?: string } }
      parsedOk = true
    } catch (e) {
      // JSON.parse 失败:降级回旧 includes 兜底,保证非 JSON 帧不误判
      console.warn('[WsClient] 握手响应 JSON 解析失败,降级 includes 兜底', e, text.slice(0, 200))
    }

    if (parsedOk && parsed) {
      // 显式 error 字段存在即握手失败(对齐 relay.rs:203/220/233 error 顶层字段)
      if (parsed.error !== undefined) {
        console.error('[WsClient] 握手失败', parsed.error, text)
        this.setStatus('disconnected', `handshake failed: ${parsed.error}`)
        this.clearHandshakeTimer()
        // 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
        this.cleanup()
        return
      }
      // 显式 ack 信号:kind==='control' 且 payload.control_kind==='hello_ack'
      // (对齐 relay.rs:255 实发字面量;ControlMessage 枚举未含 hello_ack 故不强转类型)
      if (parsed.kind === 'control' && parsed.payload?.control_kind === 'hello_ack') {
        this.clearHandshakeTimer()
        this.handshaked = true
        this.reconnectAttempts = 0
        this.setStatus('connected')
        this.startHeartbeat()
        // F-#95:握手成功后立即发 list_conversations —— device 在线探测 + 首屏会话列表同步。
        // device 在线则 route_list_conversations 推回 AiConversationList(useAiChat 置 deviceOnline=true);
        // device 离线则无响应,文案保持「已连接中继」诚实表述。
        // 此时 handshaked=true,send 守卫放行;relay 纯透传入站文本帧作 payload。
        this.send({ cmd: 'list_conversations', args: {} })
        // 补发握手期间积压的业务命令(治握手成功到首帧竞态丢命令)
        this.flushPendingQueue()
        // hello_ack 是纯控制帧无业务 payload,无需转 handleMessage 处理
        return
      }
      // 解析成功但既非 error 也非 hello_ack:异常帧,丢弃等下一条(保守不误判握手通过)
      console.warn('[WsClient] 握手阶段收到非 ack/error 控制帧,丢弃', text.slice(0, 200))
      return
    }

    // 降级兜底(JSON.parse 失败走此分支):保留旧 includes 判定,防协议演进/非 JSON 帧
    if (text.includes('"error"')) {
      console.error('[WsClient] 握手失败(includes 兜底)', text)
      this.setStatus('disconnected', `handshake failed: ${text}`)
      this.clearHandshakeTimer()
      this.cleanup()
      return
    }
    // 兜底:非错误消息视为握手通过(覆盖旧实现行为,防解析失败的 hello_ack 帧)
    this.clearHandshakeTimer()
    this.handshaked = true
    this.reconnectAttempts = 0
    this.setStatus('connected')
    this.startHeartbeat()
    this.send({ cmd: 'list_conversations', args: {} })
    // 补发握手期间积压的业务命令(降级兜底路径同样补发)
    this.flushPendingQueue()
    this.handleMessage({ data: text })
  }

  private handleClose(res: { code: number; reason: string }): void {
    console.log('[WsClient] 连接关闭', res.code, res.reason)
    this.cleanup()
    if (!this.manualClose) {
      this.scheduleReconnect()
    } else {
      this.setStatus('disconnected', 'manual close')
    }
  }

  private handleError(res: { errMsg: string }): void {
    console.error('[WsClient] socket 错误', res.errMsg)
    // 微信 socket onError 不保证 onClose 跟随(DNS/TLS 失败/域名未加白名单/onOpen 也不触发)。
    // 若尚在 connecting/handshaking(未进过 connected),主动兜底重连,防永卡。
    // connected 态的半开死连接由心跳活性检测(P1-2)兜底,此处不重连(可能 onClose 随后跟随)。
    if (this.status === 'connecting' || this.status === 'handshaking') {
      this.cleanup()
      this.scheduleReconnect()
    }
  }

  /** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'} + 半开死连接检测) */
  private startHeartbeat(): void {
    this.stopHeartbeat()
    const cfg = getConfig()
    // 连接成功初始化活性时间(首次心跳检查不立即判死)
    this.lastInboundAt = Date.now()
    this.heartbeatTimer = setInterval(() => {
      // 死连接检测:NAT 超时/网络切换致 TCP 半开(onClose 不触发,socket 仍 open)。
      // relay 无 pong,用「任意入站消息」更新 lastInboundAt;超过 N 倍间隔无入站判死,主动重连。
      const now = Date.now()
      if (now - this.lastInboundAt > cfg.heartbeatInterval * HEARTBEAT_DEAD_FACTOR) {
        console.warn('[WsClient] 心跳活性检测:连接疑似半开死连接,主动重连')
        this.cleanup()
        this.scheduleReconnect()
        return
      }
      const ping: ControlMessage = { control_kind: 'ping' }
      // 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
      try {
        this.socket?.send({ data: JSON.stringify(ping) })
      } catch (e) {
        console.warn('[WsClient] 心跳发送失败', e)
      }
      // 注:cfg.heartbeatInterval 读但不显式用(已通过 setInterval 传入),保 cfg 引用
      void cfg
    }, cfg.heartbeatInterval)
  }

  private stopHeartbeat(): void {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer)
      this.heartbeatTimer = null
    }
  }

  /**
   * 指数退避重连(baseDelay * 2^min(attempt,6),封顶 maxDelay,带随机抖动)。
   *
   * - attempt 软上限 6:超 6 不再翻倍,防长尾退避爬到 maxDelay 后卡死;
   * - 抖动因子 0.5~1.0:防多客户端重连风暴(雷同退避同步触发);
   * - manualClose/disconnect 时复位 reconnectAttempts(见 disconnect)。
   *
   * 回调统一经 reconnectNow(幂等清理 + openSocket),不裸调 openSocket。
   */
  private scheduleReconnect(): void {
    if (this.reconnectTimer) return
    // 永久性故障上限:relayHost 错/中继下线时停止无限退避,避免长跑空耗 + 误导用户「重连中」。
    // 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
    if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
      console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
      this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
      this.cleanup()
      return
    }
    const cfg = getConfig()
    const attempt = this.reconnectAttempts++
    const exp = Math.min(attempt, 6)
    const base = Math.min(cfg.reconnectBaseDelay * Math.pow(2, exp), cfg.reconnectMaxDelay)
    const jitter = 0.5 + Math.random() * 0.5
    const delay = Math.round(base * jitter)
    this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
    console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null
      this.reconnectNow()
    }, delay)
  }

  /** 清握手超时定时器(握手成功/失败时调) */
  private clearHandshakeTimer(): void {
    if (this.handshakeTimer) {
      clearTimeout(this.handshakeTimer)
      this.handshakeTimer = null
    }
  }

  /**
   * 注销并关闭指定 socket task(防旧 socket 闭包回调污染新连接)。
   *
   * 微信 SocketTask 无 offXxx API,用「覆盖空函数」解绑:onXxx 多次调用覆盖最后者。
   * 必须对传入 task 操作(非 this.socket),避免 this.socket 时序指向新 socket 时误注销。
   */
  private destroySocket(task: UniApp.SocketTask): void {
    try { task.onOpen(() => {}) } catch { /* 注销忽略 */ }
    try { task.onMessage(() => {}) } catch { /* 注销忽略 */ }
    try { task.onClose(() => {}) } catch { /* 注销忽略 */ }
    try { task.onError(() => {}) } catch { /* 注销忽略 */ }
    try {
      task.close({})
    } catch {
      // 已关闭的 socket close 抛错忽略
    }
  }

  /**
   * 彻底清理:停心跳 + 清所有 timer + 注销关闭 socket + 清队列 + 复位 handshaked。
   *
   * 清 pendingQueue:断连/重连/抢占时,旧命令上下文已失效,不补发到新连接(防串话)。
   */
  private cleanup(): void {
    this.stopHeartbeat()
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer)
      this.reconnectTimer = null
    }
    this.clearHandshakeTimer()
    if (this.connectTimer) {
      clearTimeout(this.connectTimer)
      this.connectTimer = null
    }
    if (this.socket) {
      this.destroySocket(this.socket)
      this.socket = null
    }
    this.handshaked = false
    this.pendingQueue = []
  }

  private setStatus(status: WsStatus, detail?: string): void {
    this.status = status
    this.handlers?.onStatus(status, detail)
  }
}

/** 全局单例(miniapp 进程内一个连接) */
export const wsClient = new WsClient()
