import type {
  Group,
  ImGroupMessageNotification,
  ImMessageReadNotification,
  ImMessageReceiptNotification,
  ImNoConversationNotification,
  ImNotificationWebSocketDTO,
  ImPrivateMessageNotification,
  Message,
  WebSocketFrame
} from '../types'

import type { ImChannelMessageApi } from '#/api/im/message/channel'

import { acceptHMRUpdate, defineStore } from 'pinia'

import { readChannelMessages as apiReadChannelMessages } from '#/api/im/message/channel'
import { readGroupMessages as apiReadGroupMessages } from '#/api/im/message/group'
import { readPrivateMessages as apiReadPrivateMessages } from '#/api/im/message/private'
import { getCurrentUserId, getRefreshToken } from '#/views/im/utils/auth'

import { buildChannelConversationStub } from '../../utils/channel'
import {
  MESSAGE_GROUP_READ_ENABLED,
  MESSAGE_PRIVATE_READ_ENABLED,
  WS_RECONNECT_BASE_MS,
  WS_RECONNECT_JITTER_MS,
  WS_RECONNECT_MAX_MS
} from '../../utils/config'
import {
  ImContentType,
  ImConversationType,
  ImMessageReceiptStatus,
  ImMessageStatus,
  ImRtcCallMediaType,
  ImRtcParticipantStatus,
  ImWebSocketMessageType,
  isFriendChatTip,
  isFriendNotification,
  isGroupRequestNotification,
  isNormalMessage
} from '../../utils/constants'
import {
  getPrivateMessagePeerId,
  parseRtcCallPayload,
  playAudioTip,
  resolveCallEndReasonText
} from '../../utils/message'
import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user'
import { useConversationStore } from './conversationStore'
import { type FriendNotificationPayload, useFriendStore } from './friendStore'
import { useGroupRequestStore } from './groupRequestStore'
import { useGroupStore } from './groupStore'
import { useMessageStore } from './messageStore'
import {
  type ImRtcCallEndNotification,
  type ImRtcCallNotification,
  type ImRtcParticipantConnectedNotification,
  type ImRtcParticipantDisconnectedNotification,
  useRtcStore
} from './rtcStore'

/** FRIEND_DELETE 帧 payload 是否带 clear=true：clear 语义是清会话本身，跳过气泡渲染 */
const isFriendDeleteWithClear = (frame: ImPrivateMessageNotification): boolean => {
  if (frame.type !== ImContentType.FRIEND_DELETE) {
    return false
  }
  try {
    const payload = JSON.parse(frame.content || '{}') as { clear?: boolean }
    return payload.clear === true
  } catch {
    return false
  }
}

/** 从私聊消息帧解析好友通知 payload */
const parseFriendNotificationPayload = (
  frame: ImPrivateMessageNotification
): FriendNotificationPayload => JSON.parse(frame.content || '{}') as FriendNotificationPayload

/** 私聊消息帧是否可推断好友对端 */
const isPrivateMessageNotification = (
  frame: ImNoConversationNotification | ImPrivateMessageNotification
): frame is ImPrivateMessageNotification => 'senderId' in frame && 'receiverId' in frame

const RTC_LIVEKIT_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:'])
const RTC_MEDIA_TYPES = new Set<number>(Object.values(ImRtcCallMediaType))

/** 忽略普通实时帧持久化失败 */
function ignoreRealtimePersistError(promise: Promise<void>): void {
  void promise.catch(() => undefined)
}

interface WebSocketListenerSet {
  close: (event: CloseEvent) => void
  error: (event: Event) => void
  message: (event: MessageEvent) => void
  open: (event: Event) => void
}

const websocketListenerMap = new WeakMap<WebSocket, WebSocketListenerSet>()

/** 绑定 WebSocket 事件 */
function bindWebSocketListeners(socket: WebSocket, listeners: WebSocketListenerSet): void {
  websocketListenerMap.set(socket, listeners)
  socket.addEventListener('open', listeners.open)
  socket.addEventListener('message', listeners.message)
  socket.addEventListener('close', listeners.close)
  socket.addEventListener('error', listeners.error)
}

/** 解绑 WebSocket 事件 */
function unbindWebSocketListeners(socket: WebSocket): void {
  const listeners = websocketListenerMap.get(socket)
  if (!listeners) {
    return
  }
  socket.removeEventListener('open', listeners.open)
  socket.removeEventListener('message', listeners.message)
  socket.removeEventListener('close', listeners.close)
  socket.removeEventListener('error', listeners.error)
  websocketListenerMap.delete(socket)
}

/** 校验 LiveKit 连接地址 */
function isValidLiveKitUrl(url?: string): boolean {
  if (!url) {
    return false
  }
  try {
    return RTC_LIVEKIT_PROTOCOLS.has(new URL(url).protocol)
  } catch {
    return false
  }
}

/** 校验来电信令载荷 */
function isValidRtcInvitePayload(payload: ImRtcCallNotification): boolean {
  if (!payload.room || !payload.token || !isValidLiveKitUrl(payload.livekitUrl)) {
    return false
  }
  if (!RTC_MEDIA_TYPES.has(payload.mediaType) || !payload.inviterUserId) {
    return false
  }
  if (payload.conversationType === ImConversationType.PRIVATE) {
    return true
  }
  return payload.conversationType === ImConversationType.GROUP && !!payload.groupId
}

/**
 * WebSocket 私聊 DTO -> 前端 Message；targetId 是会话主键（对端 userId）
 * 不写发送人名字段：渲染层走 utils/user 实时算（备注 / 群昵称变更后历史消息自动刷新）
 */
const convertPrivateMessage = (
  websocketMessage: ImPrivateMessageNotification,
  currentUserId: number
): Message => ({
  id: websocketMessage.id,
  clientMessageId: websocketMessage.clientMessageId,
  type: websocketMessage.type,
  content: websocketMessage.content,
  status: websocketMessage.status,
  receiptStatus: websocketMessage.receiptStatus,
  sendTime: new Date(websocketMessage.sendTime).getTime(),
  senderId: websocketMessage.senderId,
  targetId: getPrivateMessagePeerId(websocketMessage, currentUserId),
  selfSend: websocketMessage.senderId === currentUserId
})

/**
 * WebSocket 群聊 DTO -> 前端 Message
 * 带 atUserIds / receiverUserIds 给 @ 标记和定向接收用；
 * receiptStatus / readCount 让多端同步收到自己发的群消息时回执 UI 立刻就有数据
 */
const convertGroupMessage = (
  websocketMessage: ImGroupMessageNotification,
  currentUserId: number
): Message => ({
  id: websocketMessage.id,
  clientMessageId: websocketMessage.clientMessageId,
  type: websocketMessage.type,
  content: websocketMessage.content,
  status: websocketMessage.status,
  sendTime: new Date(websocketMessage.sendTime).getTime(),
  senderId: websocketMessage.senderId,
  targetId: websocketMessage.groupId,
  selfSend: websocketMessage.senderId === currentUserId,
  atUserIds: websocketMessage.atUserIds || [],
  receiverUserIds: websocketMessage.receiverUserIds || [],
  receiptStatus: websocketMessage.receiptStatus,
  readCount: websocketMessage.readCount
})

/**
 * IM WebSocket Store
 *
 * 职责（不只是连通信，也是后端 IM 事件的统一入口 → 联动 conversationStore / friendStore / groupStore）：
 *
 * 1. 链路管理：建连 / 断连 / 心跳保活 / 自动重连
 * 2. 帧分发：dispatchFrame → dispatchPrivateFrame / dispatchGroupFrame，按会话和内容类型分流
 * 3. 缓冲：初始化加载期（conversationStore.loading=true）暂存消息，等 pull 完成后由 useMessagePuller 调 flushBuffer 回放
 * 4. 事件处理（按类型分发到对应 handle*，联动 conversation / friend / group store）：
 *    - 普通消息（TEXT / IMAGE / FILE / VOICE / VIDEO）：入库 + 当前会话自动已读 / 提示音
 *    - 已读 / 回执（READ / RECEIPT）：多端已读同步、对方读后回执
 *    - 好友变更（FRIEND_*）：同步 friendStore + 级联刷新私聊会话；FRIEND_ADD / FRIEND_DELETE 额外插入会话气泡
 *    - 群个人信号（GROUP_MEMBER_SETTING_UPDATE）：同步 groupStore + 级联刷新群聊会话
 *    - 群成员昵称变更（GROUP_MEMBER_NICKNAME_UPDATE）：同步 groupStore，不插入消息列表
 *    - 群广播事件（GROUP_*）：走 handleGroupMessage + applyGroupNotification 旁路（含 DISSOLVE / QUIT / KICK 自判清群）
 */
export const useImWebSocketStore = defineStore('imWebSocketStore', {
  state: () => ({
    socket: null as null | WebSocket,
    isConnected: false,
    reconnectTimer: null as null | ReturnType<typeof setTimeout>,
    /** 连续重连失败次数；onopen 成功 / disconnect 主动断开后清零，用于指数退避 */
    reconnectAttempts: 0,
    heartbeatTimer: null as null | ReturnType<typeof setInterval>,
    messageBuffer: [] as Array<
      | { conversationType: typeof ImConversationType.CHANNEL; payload: ImChannelMessageApi.ChannelMessageRespVO }
      | {
          conversationType: typeof ImConversationType.GROUP
          payload: ImGroupMessageNotification
        }
      | {
          conversationType: typeof ImConversationType.PRIVATE
          payload: ImPrivateMessageNotification
        }
    > // 初始化加载期内，先把普通消息丢进缓冲区，pull 完成后再一次性回放
  }),

  actions: {
    /**
     * 取出缓冲区消息并清空（由 useMessagePuller 在 pull 完成后调用，统一回放给 conversationStore）
     * 配合 messageBuffer 实现：在 conversationStore.loading 期间收到的 WS 消息先暂存，避免和 pull 的 minId 游标打架
     */
    flushBuffer() {
      const msgs = [...this.messageBuffer]
      this.messageBuffer = []
      return msgs
    },

    /** 直接丢弃缓冲帧不回放（cancelPull / 离开 IM 调用，防止下次进 IM 把旧 session 帧回放进新 store） */
    discardBuffer() {
      this.messageBuffer = []
    },

    /**
     * 连接 WebSocket
     * 复用 yudao 内置 /infra/ws 通道，后端通过 sendObject(type, content) 下发
     *
     * 调用契约：切账号 / token 刷新前必须先 `disconnect()` 再 `connect()`；
     * 本方法不感知 token 变化，旧 socket 在 CONNECTING / OPEN 状态会直接复用旧 token，可能拿到错误身份
     */
    connect() {
      // 鉴权用 refreshToken（生命周期更长；access token 过期后服务端会通过 frame 通知重登）
      const refreshToken = getRefreshToken()
      if (!refreshToken) {
        console.warn('[IM WS] refreshToken 为空，跳过连接')
        return
      }
      // 旧 socket 还在 CONNECTING / OPEN 直接复用，避免叠加多份 onmessage 监听导致重复消息 / 提示音 / 已读上报
      const existingSocket = this.socket
      if (
        existingSocket &&
        (existingSocket.readyState === WebSocket.OPEN ||
          existingSocket.readyState === WebSocket.CONNECTING)
      ) {
        return
      }
      // 旧 socket 已 CLOSING / CLOSED：解绑回调 + 清引用再 new，避免老 handler 仍持有 store 引用阻碍 GC
      if (existingSocket) {
        unbindWebSocketListeners(existingSocket)
        this.socket = null
      }
      const url = `${this.buildWsUrl()}/infra/ws?token=${refreshToken}`
      const socket = new WebSocket(url)
      this.socket = socket

      bindWebSocketListeners(socket, {
        // 连接建立：标记上线 + 启动心跳保活；重连退避计数归零
        open: () => {
          this.isConnected = true
          this.reconnectAttempts = 0
          console.log('[IM WS] connected')
          this.startHeartbeat()
        },

        // 收到帧：'pong' 是心跳应答直接吞掉；其余按 WebSocketFrame 解析后交给 dispatchFrame 分流
        message: (event) => {
          if (event.data === 'pong') {
            return
          }
          try {
            const frame = JSON.parse(event.data) as WebSocketFrame
            this.dispatchFrame(frame)
          } catch (error) {
            console.error('[IM WS] message parse error:', error)
          }
        },

        // 服务端关闭 / 网络断：标记下线，按指数退避自动重连
        close: () => {
          this.isConnected = false
          console.log('[IM WS] disconnected')
          this.reconnect()
        },

        // 异常时不主动 reconnect，主动 close() 让 close 成为唯一重连入口
        error: (error) => {
          console.error('[IM WS] error:', error)
          this.isConnected = false
          this.socket?.close()
        }
      })
    },

    /** 拼接 WebSocket 基础地址 */
    buildWsUrl(): string {
      // VITE_BASE_URL 可能是 http:// 或 https:// 开头，替换成 ws:// 或 wss://；如果没配置，就用当前页面的协议 + host
      const baseUrl = (import.meta as any).env?.VITE_BASE_URL as string | undefined
      if (baseUrl && baseUrl.length > 0) {
        return baseUrl.replace(/^http/, 'ws')
      }
      // 当前页面协议 + host（如 http://localhost:8080），替换成 ws://localhost:8080
      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
      const host = window.location.host
      return `${protocol}//${host}`
    },

    /**
     * 按 IM 通知帧分发
     */
    dispatchFrame(frame: WebSocketFrame) {
      if (frame.type !== ImWebSocketMessageType.NOTIFICATION) {
        console.debug('[IM WS] 未识别事件', frame)
        return
      }

      const notification = this.safeParse(frame.content) as ImNotificationWebSocketDTO | null
      if (!notification?.payload || !notification.contentType) {
        return
      }
      const payload = {
        ...notification.payload,
        type: notification.contentType
      }
      switch (notification.conversationType) {
        case ImConversationType.CHANNEL: {
          this.dispatchChannelFrame(payload as ImChannelMessageApi.ChannelMessageRespVO)
          break
        }
        case ImConversationType.GROUP: {
          this.dispatchGroupFrame(payload as ImGroupMessageNotification)
          break
        }
        case ImConversationType.NONE: {
          this.dispatchNoConversationFrame(payload as ImNoConversationNotification)
          break
        }
        case ImConversationType.PRIVATE: {
          this.dispatchPrivateFrame(payload as ImPrivateMessageNotification)
          break
        }
        default: {
          console.debug('[IM WS] 未识别通知', notification)
        }
      }
    },

    /**
     * 无会话通知分发
     */
    dispatchNoConversationFrame(websocketMessage: ImNoConversationNotification) {
      if (isFriendNotification(websocketMessage.type)) {
        this.handleFriendNotification(websocketMessage)
        return
      }
      if (isGroupRequestNotification(websocketMessage.type)) {
        this.handleGroupRequestNotification(websocketMessage)
        return
      }
      switch (websocketMessage.type) {
        case ImContentType.RTC_CALL:
        case ImContentType.RTC_PARTICIPANT_CONNECTED:
        case ImContentType.RTC_PARTICIPANT_DISCONNECTED: {
          this.handleRtcSignaling(websocketMessage)
          break
        }
        default: {
          console.debug('[IM WS] 未识别无会话通知', websocketMessage)
        }
      }
    },

    /**
     * 频道帧分发：按 payload.type 分到 READ（多端已读同步）或普通素材推送
     */
    dispatchChannelFrame(websocketMessage: ImChannelMessageApi.ChannelMessageRespVO) {
      if (websocketMessage.type === ImContentType.READ) {
        this.handleChannelRead(websocketMessage)
        return
      }
      ignoreRealtimePersistError(this.handleChannelMessage(websocketMessage))
    },

    /** 频道 READ：自己其它终端在某频道里标为已读，本端同步清零该频道未读 */
    handleChannelRead(websocketMessage: ImChannelMessageApi.ChannelMessageRespVO) {
      void useConversationStore()
        .applyConversationReadList([
          {
            id: websocketMessage.id,
            conversationType: ImConversationType.CHANNEL,
            targetId: websocketMessage.channelId,
            messageId: websocketMessage.id
          }
        ])
        .catch((error) => console.warn('[IM WS] 频道已读同步失败', error))
    },

    /**
     * 频道消息实时入会话；频道消息单向 + 无状态机，直接 insertMessage 即可
     * pull 与 WS 拿到同一条 id 时，messageStore.insertMessage 内部按 id 去重，不会重复
     */
    handleChannelMessage(websocketMessage: ImChannelMessageApi.ChannelMessageRespVO): Promise<void> {
      const conversationStore = useConversationStore()
      const messageStore = useMessageStore()
      // 离线加载期间先缓冲，等 pull 完成后再统一回放，避免重复或顺序错乱
      if (conversationStore.loading) {
        this.messageBuffer.push({
          conversationType: ImConversationType.CHANNEL,
          payload: websocketMessage
        })
        return Promise.resolve()
      }
      const sendTimeMs =
        typeof websocketMessage.sendTime === 'number'
          ? websocketMessage.sendTime
          : new Date(websocketMessage.sendTime).getTime()
      const conversation = conversationStore.getConversation(
        ImConversationType.CHANNEL,
        websocketMessage.channelId
      )
      const isActive =
        conversationStore.activeConversation?.type === ImConversationType.CHANNEL &&
        conversationStore.activeConversation?.targetId === websocketMessage.channelId
      // 频道单向订阅，receiptStatus 表达「我是否已读这条」：会话打开即已读 DONE，否则 PENDING（与 pull 口径一致）
      const persistPromise = messageStore.insertMessage(
        buildChannelConversationStub(websocketMessage.channelId),
        {
          id: websocketMessage.id,
          clientMessageId: '',
          type: websocketMessage.type,
          content: websocketMessage.content,
          status: ImMessageStatus.NORMAL,
          receiptStatus: isActive ? ImMessageReceiptStatus.DONE : ImMessageReceiptStatus.PENDING,
          sendTime: sendTimeMs,
          senderId: 0,
          targetId: websocketMessage.channelId,
          selfSend: false,
          materialId: websocketMessage.materialId
        }
      )
      if (isActive) {
        // 窗口打开 = 已读：本端清未读 + 上报服务端读位置，避免读位置滞后
        const readReported = conversationStore.isReportedReadPositionCovered(
          ImConversationType.CHANNEL,
          websocketMessage.channelId,
          websocketMessage.id
        )
        conversationStore.markConversationRead(
          ImConversationType.CHANNEL,
          websocketMessage.channelId,
          websocketMessage.id
        )
        if (!readReported) {
          apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
            .then(() =>
              conversationStore.markConversationReadReported(
                ImConversationType.CHANNEL,
                websocketMessage.channelId,
                websocketMessage.id
              )
            )
            .catch((error) => {
              console.warn(
                '[IM WS] 频道自动已读上报失败',
                {
                  conversationType: ImConversationType.CHANNEL,
                  channelId: websocketMessage.channelId,
                  messageId: websocketMessage.id
                },
                error
              )
            })
        }
      } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
        // 非当前会话且未免打扰：响一下提示音
        playAudioTip()
      }
      return persistPromise
    },

    /** content 既可能已是对象也可能是 JSON 字符串（后端用 Map 序列化下发） */
    safeParse(raw: unknown): null | Record<string, any> {
      if (!raw) {
        return null
      }
      if (typeof raw === 'object') {
        return raw as Record<string, any>
      }
      try {
        return JSON.parse(raw as string)
      } catch (error) {
        console.error('[IM WS] content 解析失败', error)
        return null
      }
    },

    // ==================== 普通消息 ====================

    /**
     * 私聊统一帧分发：按 payload.type（ImContentType）分到已读 / 回执 / 好友通知 / 普通消息
     *
     * 消息通知、已读通知、回执通知由外层 contentType 统一分发
     */
    dispatchPrivateFrame(websocketMessage: ImPrivateMessageNotification) {
      try {
        switch (websocketMessage.type) {
          case ImContentType.READ: {
            this.handlePrivateRead(websocketMessage as ImMessageReadNotification)
            break
          }
          case ImContentType.RECEIPT: {
            this.handlePrivateReceipt(websocketMessage as ImMessageReceiptNotification)
            break
          }
          case ImContentType.RTC_CALL_END: {
            // 入库 + 关闭通话窗 + 渲染聊天 tip（私聊场景）
            this.handleRtcCallEnd(websocketMessage)
            ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage))
            break
          }
          default: {
            if (isFriendChatTip(websocketMessage.type)) {
              this.handleFriendNotification(websocketMessage)
              // FRIEND_DELETE 的 clear=true 语义是清会话本身，跳过气泡避免在已清会话里写入虚拟消息
              if (!isFriendDeleteWithClear(websocketMessage)) {
                ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage))
              }
            } else {
              // TEXT / IMAGE / FILE / VOICE / VIDEO 等普通消息
              ignoreRealtimePersistError(this.handlePrivateMessage(websocketMessage))
            }
          }
        }
      } catch (error) {
        // 单条帧的处理异常不应阻断后续帧；打印完整 websocketMessage 便于排查
        console.warn('[IM WS] dispatchPrivateFrame 处理失败', websocketMessage, error)
      }
    },

    /**
     * 群聊统一帧分发：按 payload.type（ImContentType）分到已读 / 回执 / 群个人信号 / 普通消息
     *
     * GROUP_MEMBER_SETTING_UPDATE / GROUP_MEMBER_NICKNAME_UPDATE 是成员资料同步信号；其它群广播事件走 handleGroupMessage 入库 + 触发 applyGroupNotification 旁路
     */
    dispatchGroupFrame(websocketMessage: ImGroupMessageNotification) {
      try {
        switch (websocketMessage.type) {
          case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: {
            this.handleGroupMemberNicknameUpdate(websocketMessage)
            break
          }
          case ImContentType.GROUP_MEMBER_SETTING_UPDATE: {
            this.handleGroupMemberSettingUpdate(websocketMessage)
            break
          }
          case ImContentType.READ: {
            this.handleGroupRead(websocketMessage as ImMessageReadNotification)
            break
          }
          case ImContentType.RECEIPT: {
            this.handleGroupReceipt(websocketMessage as ImMessageReceiptNotification)
            break
          }
          case ImContentType.RTC_CALL_END: {
            // 入库 + 移除胶囊条 + 关闭通话窗（如果当前在该群通话内）
            this.handleRtcCallEnd(websocketMessage)
            ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
            break
          }
          case ImContentType.RTC_CALL_START: {
            // 入库 + 渲染聊天 tip；同时用 START payload 先生成最小胶囊条，后续 getActiveCall / 参与者事件再补齐成员
            this.handleRtcCallStart(websocketMessage)
            ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
            break
          }
          default: {
            // TEXT / IMAGE / FILE / VOICE / VIDEO + GROUP_* 群广播事件
            ignoreRealtimePersistError(this.handleGroupMessage(websocketMessage))
          }
        }
      } catch (error) {
        // 单条帧的处理异常不应阻断后续帧；打印完整 websocketMessage 便于排查
        console.warn('[IM WS] dispatchGroupFrame 处理失败', websocketMessage, error)
      }
    },

    /**
     * 私聊普通消息（TEXT / IMAGE / FILE / VOICE / VIDEO）入库 + 自动已读
     *
     * 流程：
     * 1. 离线加载期缓冲（避开与 pull 回填的竞态）
     * 2. 计算 selfSend / peerId 维度，拉好友信息回填展示字段
     * 3. 撤回 TIP 直接转走 recallMessage，不进消息列表
     * 4. 构造前端 Message，插入到对应私聊会话
     * 5. 当前会话激活时自动上报已读；否则非免打扰响提示音
     */
    handlePrivateMessage(websocketMessage: ImPrivateMessageNotification): Promise<void> {
      const conversationStore = useConversationStore()
      const friendStore = useFriendStore()
      const currentUserId = getCurrentUserId()

      // 0. 防御层：senderId / receiverId 均不含当前用户的私聊帧直接丢弃，避免后端路由 / 多端串号污染会话
      //    （FRIEND_* 等系统通知也走这条通道，但 fromUserId=senderId、toUserId=receiverId 仍是当前用户视角）
      if (
        currentUserId &&
        websocketMessage.senderId !== currentUserId &&
        websocketMessage.receiverId !== currentUserId
      ) {
        console.warn('[IM WS] 丢弃不属于当前用户的私聊帧', websocketMessage)
        return Promise.resolve()
      }

      // 1. 离线加载期间先缓冲，等 pull 完成后再统一回放，避免重复或顺序错乱
      if (conversationStore.loading) {
        this.messageBuffer.push({
          conversationType: ImConversationType.PRIVATE,
          payload: websocketMessage
        })
        return Promise.resolve()
      }

      // 2. selfSend / peerId：自己发的消息属于「发给 receiverId 的会话」，别人发的属于「发送者的会话」
      const selfSend = websocketMessage.senderId === currentUserId
      const peerId = getPrivateMessagePeerId(websocketMessage, currentUserId)
      // 未知对端（陌生人加好友前先收到消息等场景）：异步补拉一次，下次再渲染就有 name/avatar
      const friend = friendStore.getFriend(peerId)
      if (!friend) {
        friendStore.fetchFriendInfo(peerId).catch(() => undefined)
      }
      // 会话标题永远跟「对端」走（不管谁发的消息）；这里只算一次给 insertMessage 用
      const peerDisplayName = friend ? getFriendDisplayName(friend) : ''

      // 3. 后端撤回：下发一条 RECALL 消息，content 为 `{"messageId": xxx}`（对齐 ImContentTypeEnum.RECALL → RecallMessage）
      // 这里拦截下来改走 recallMessage（把原消息更新为 RECALL 态），不让它作为新消息进列表
      if (websocketMessage.type === ImContentType.RECALL) {
        return useMessageStore().recallMessage(
          ImConversationType.PRIVATE,
          peerId,
          websocketMessage.content
        )
      }

      // 4. 后端 DTO → 前端 Message：发送人名渲染时实时算，不写入消息字段
      const message = convertPrivateMessage(websocketMessage, currentUserId)
      const persistPromise = useMessageStore().insertMessage(
        {
          type: ImConversationType.PRIVATE,
          targetId: peerId,
          name: peerDisplayName || String(peerId),
          avatar: friend?.avatar || '',
          silent: friend?.silent
        },
        message
      )

      // 5. 仅对方消息才走「自动已读 / 提示音」分支：自己发的不会触发
      if (!selfSend) {
        const conversation = conversationStore.getConversation(ImConversationType.PRIVATE, peerId)
        const isActive =
          conversationStore.activeConversation?.type === ImConversationType.PRIVATE &&
          conversationStore.activeConversation?.targetId === peerId
        if (isActive) {
          // 聊天窗口打开 = 实际看到了：本端清未读；私聊已读开启时再上报后端，让对方 UI 立刻切到"已读"
          // 已读位置直接用刚到的消息 id（这条就是当前会话最大 id）
          const readReported = conversationStore.isReportedReadPositionCovered(
            ImConversationType.PRIVATE,
            peerId,
            websocketMessage.id
          )
          conversationStore.markConversationRead(
            ImConversationType.PRIVATE,
            peerId,
            websocketMessage.id
          )
          if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) {
            apiReadPrivateMessages(peerId, websocketMessage.id)
              .then(() =>
                conversationStore.markConversationReadReported(
                  ImConversationType.PRIVATE,
                  peerId,
                  websocketMessage.id
                )
              )
              .catch((error) => {
                console.warn(
                  '[IM WS] 私聊自动已读上报失败',
                  {
                    conversationType: ImConversationType.PRIVATE,
                    peerId,
                    messageId: websocketMessage.id
                  },
                  error
                )
              })
          }
        } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
          // 非当前会话且未免打扰：响一下提示音（带节流，详见 playAudioTip）；FRIEND_* 等系统事件不响
          playAudioTip()
        }
      }
      return persistPromise
    },

    /** 私聊 READ 事件：自己的其它终端在对方会话里标为已读，本端同步清零未读；私聊已读关闭时兜底忽略 */
    handlePrivateRead(websocketMessage: ImMessageReadNotification) {
      if (!MESSAGE_PRIVATE_READ_ENABLED) {
        return
      }
      if (!websocketMessage.id || !websocketMessage.receiverId) {
        return
      }
      void useConversationStore()
        .applyConversationReadList([
          {
            id: websocketMessage.id,
            conversationType: ImConversationType.PRIVATE,
            targetId: websocketMessage.receiverId,
            messageId: websocketMessage.id
          }
        ])
        .catch((error) => console.warn('[IM WS] 私聊已读同步失败', error))
    },

    /**
     * 私聊 RECEIPT 事件：对方读了我的消息，把和对方会话里自己发的消息标为已读
     * 后端将 maxReadId 编码在通知的 id 字段，
     * 这里据此卡边界，避免把"回执在路上时刚发的消息"误标为已读；私聊已读关闭时兜底忽略
     */
    handlePrivateReceipt(websocketMessage: ImMessageReceiptNotification) {
      if (!MESSAGE_PRIVATE_READ_ENABLED) {
        return
      }
      if (!websocketMessage.id) {
        return
      }
      if (!websocketMessage.senderId) {
        return
      }
      useMessageStore().applyMessageReadReceipt({
        conversationType: ImConversationType.PRIVATE,
        targetId: websocketMessage.senderId,
        privateReadMaxId: websocketMessage.id
      })
    },

    /**
     * 群聊普通消息入库 + 自动已读（结构与 handlePrivateMessage 对称）
     *
     * 流程：
     * 1. 离线加载期缓冲
     * 2. 未知群时拉群详情兜底
     * 3. 撤回 TIP 直接转走
     * 4. 构造 Message + at 字段，插入到对应群聊会话（发送人名渲染时实时算）
     * 5. 当前会话激活时自动上报已读（带 lastMessageId）；否则非免打扰响提示音
     */
    handleGroupMessage(websocketMessage: ImGroupMessageNotification): Promise<void> {
      const conversationStore = useConversationStore()
      const groupStore = useGroupStore()
      const currentUserId = getCurrentUserId()
      const selfSend = websocketMessage.senderId === currentUserId

      // 0. 防御层：定向群消息 receiverUserIds 非空且未包含当前用户时丢弃
      //    自己发的（selfSend）始终通过；全员可见（receiverUserIds 为空 / 缺失）也通过
      const receiverUserIds = websocketMessage.receiverUserIds
      if (
        currentUserId &&
        !selfSend &&
        Array.isArray(receiverUserIds) &&
        receiverUserIds.length > 0 &&
        !receiverUserIds.includes(currentUserId)
      ) {
        console.warn('[IM WS] 丢弃不属于当前用户的定向群消息', websocketMessage)
        return Promise.resolve()
      }

      // 1. 离线加载期缓冲（与私聊对称）
      if (conversationStore.loading) {
        this.messageBuffer.push({
          conversationType: ImConversationType.GROUP,
          payload: websocketMessage
        })
        return Promise.resolve()
      }

      // 2. 未知群时自动拉群详情 + 成员（被拉入群但还没收到 GROUP_CREATE 时的兜底）
      const group = groupStore.getGroup(websocketMessage.groupId)
      if (!group) {
        groupStore.fetchGroupInfo(websocketMessage.groupId).catch(() => undefined)
      }

      // 3. 后端撤回：下发一条 RECALL 消息，content 为 `{"messageId": xxx}`
      // 这里拦截下来改走 recallMessage（把原消息更新为 RECALL 态）
      if (websocketMessage.type === ImContentType.RECALL) {
        return useMessageStore().recallMessage(
          ImConversationType.GROUP,
          websocketMessage.groupId,
          websocketMessage.content
        )
      }

      // 4. 后端 DTO → 前端 Message：发送人名渲染时实时算，不写入消息字段
      const message = convertGroupMessage(websocketMessage, currentUserId)
      const persistPromise = useMessageStore().insertMessage(
        {
          type: ImConversationType.GROUP,
          targetId: websocketMessage.groupId,
          name: group ? getGroupDisplayName(group) : String(websocketMessage.groupId),
          avatar: group?.avatar || '',
          silent: group?.silent
        },
        message
      )

      // 5. 仅对方消息才走「自动已读 / 提示音」（与私聊对称）
      if (!selfSend) {
        const conversation = conversationStore.getConversation(
          ImConversationType.GROUP,
          websocketMessage.groupId
        )
        const isActive =
          conversationStore.activeConversation?.type === ImConversationType.GROUP &&
          conversationStore.activeConversation?.targetId === websocketMessage.groupId
        if (isActive) {
          // 群已读上报需要带 messageId（群消息以"读到第几条"的游标为准，区别于私聊只标 receiverId）；群已读关闭时仅本地清零
          const readReported = conversationStore.isReportedReadPositionCovered(
            ImConversationType.GROUP,
            websocketMessage.groupId,
            websocketMessage.id
          )
          conversationStore.markConversationRead(
            ImConversationType.GROUP,
            websocketMessage.groupId,
            websocketMessage.id
          )
          if (MESSAGE_GROUP_READ_ENABLED && !readReported) {
            apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
              .then(() =>
                conversationStore.markConversationReadReported(
                  ImConversationType.GROUP,
                  websocketMessage.groupId,
                  websocketMessage.id
                )
              )
              .catch((error) => {
                console.warn(
                  '[IM WS] 群聊自动已读上报失败',
                  {
                    conversationType: ImConversationType.GROUP,
                    groupId: websocketMessage.groupId,
                    messageId: websocketMessage.id
                  },
                  error
                )
              })
          }
        } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) {
          // GROUP_* 群广播事件等系统消息不响提示音
          playAudioTip()
        }
      }
      return persistPromise
    },

    // ==================== 群聊已读 / 回执 ====================

    /** 群聊 READ：自己其它终端在某群里标为已读，本端同步清零该群未读 + @ 红字；群已读关闭时兜底忽略 */
    handleGroupRead(websocketMessage: ImMessageReadNotification) {
      if (!MESSAGE_GROUP_READ_ENABLED) {
        return
      }
      const readMessageId = websocketMessage.readId || websocketMessage.id
      if (!readMessageId || !websocketMessage.groupId) {
        return
      }
      void useConversationStore()
        .applyConversationReadList([
          {
            id: readMessageId,
            conversationType: ImConversationType.GROUP,
            targetId: websocketMessage.groupId,
            messageId: readMessageId
          }
        ])
        .catch((error) => console.warn('[IM WS] 群聊已读同步失败', error))
    },

    /** 群聊 RECEIPT：更新某条群消息的 readCount / receiptStatus；群已读关闭时兜底忽略 */
    handleGroupReceipt(websocketMessage: ImMessageReceiptNotification) {
      if (!MESSAGE_GROUP_READ_ENABLED) {
        return
      }
      if (!websocketMessage.id || !websocketMessage.groupId) {
        return
      }
      useMessageStore().applyMessageReadReceipt({
        conversationType: ImConversationType.GROUP,
        targetId: websocketMessage.groupId,
        groupMessageId: websocketMessage.id,
        readCount: websocketMessage.readCount,
        receiptStatus: websocketMessage.receiptStatus
      })
    },

    // ==================== 好友通知（1201-1210 段位） ====================

    /**
     * 算 FRIEND_ADD / FRIEND_DELETE 帧的「对端 userId」：
     *    becomeFriends 单条入库后双方收到同一份 payload，payload.friendUserId 固定是 toUserId，本端真正的对端要从帧 sender / receiver 反推
     */
    computeFriendPeerId(frame: ImPrivateMessageNotification): number {
      const currentUserId = getCurrentUserId()
      return getPrivateMessagePeerId(frame, currentUserId)
    },

    /**
     * 好友通知统一入口：按 type 分发到 friendStore 内部 dispatcher
     */
    handleFriendNotification(
      websocketMessage: ImNoConversationNotification | ImPrivateMessageNotification
    ) {
      const payload = isPrivateMessageNotification(websocketMessage)
        ? parseFriendNotificationPayload(websocketMessage)
        : (websocketMessage as unknown as FriendNotificationPayload)
      const friendStore = useFriendStore()
      switch (websocketMessage.type) {
        case ImContentType.FRIEND_ADD: {
          friendStore.applyFriendAddNotification(
            payload,
            isPrivateMessageNotification(websocketMessage)
              ? this.computeFriendPeerId(websocketMessage)
              : payload.friendUserId
          )
          break
        }
        case ImContentType.FRIEND_BLOCK: {
          friendStore.applyFriendBlockNotification(payload)
          break
        }
        case ImContentType.FRIEND_DELETE: {
          friendStore.applyFriendDeleteNotification(
            payload,
            isPrivateMessageNotification(websocketMessage)
              ? this.computeFriendPeerId(websocketMessage)
              : payload.friendUserId
          )
          break
        }
        case ImContentType.FRIEND_INFO_UPDATED: {
          friendStore.applyFriendInfoUpdatedNotification(payload)
          break
        }
        case ImContentType.FRIEND_REQUEST_APPROVED: {
          friendStore.applyFriendRequestApprovedNotification(payload)
          break
        }
        case ImContentType.FRIEND_REQUEST_RECEIVED: {
          friendStore.applyFriendRequestReceivedNotification(payload)
          break
        }
        case ImContentType.FRIEND_REQUEST_REJECTED: {
          friendStore.applyFriendRequestRejectedNotification(payload)
          break
        }
        case ImContentType.FRIEND_UNBLOCK: {
          friendStore.applyFriendUnblockNotification(payload)
          break
        }
        case ImContentType.FRIEND_UPDATE: {
          friendStore.applyFriendUpdateNotification(payload)
          break
        }
        default: {
          console.debug('[IM WS] 未识别好友通知', websocketMessage)
        }
      }
    },

    // ==================== 加群申请通知（1503 / 1505 / 1506） ====================

    /**
     * 加群申请通知统一入口：分发到 groupRequestStore，驱动横幅 + Drawer 同步
     */
    handleGroupRequestNotification(websocketMessage: ImNoConversationNotification) {
      const payload = websocketMessage as { requestId?: number }
      if (!payload.requestId) {
        return
      }
      const groupRequestStore = useGroupRequestStore()
      switch (websocketMessage.type) {
        case ImContentType.GROUP_REQUEST_APPROVED:
        case ImContentType.GROUP_REQUEST_REJECTED: {
          groupRequestStore.removeGroupRequestById(payload.requestId)
          break
        }
        case ImContentType.GROUP_REQUEST_RECEIVED: {
          groupRequestStore.addGroupRequestById(payload.requestId).catch(() => undefined)
          break
        }
        default: {
          break
        }
      }
    },

    // ==================== 群关系事件（承载于群聊通道，按 inner type 分流） ====================

    /**
     * GROUP_MEMBER_SETTING_UPDATE：多端同步成员个人设置变更（silent / groupRemark）
     *
     * payload 携带变更字段，按非 null 字段直接局部更新；省一次 fetchGroupMemberList 接口
     */
    handleGroupMemberSettingUpdate(websocketMessage: ImGroupMessageNotification) {
      // content 解析失败由外层 dispatchGroupFrame 的 try-catch 兜底（含 websocketMessage 打印），不重复 catch
      const payload: { groupRemark?: string; silent?: boolean; } = JSON.parse(
        websocketMessage.content || '{}'
      )
      const groupStore = useGroupStore()
      const group = groupStore.getGroup(websocketMessage.groupId)
      if (!group) {
        return
      }
      const fields: Partial<Group> = {}
      if (payload.silent != null) {
        fields.silent = payload.silent
      }
      if (payload.groupRemark != null) {
        fields.groupRemark = payload.groupRemark
      }
      if (Object.keys(fields).length > 0) {
        groupStore.updateGroupFields(websocketMessage.groupId, fields)
      }
    },

    /** GROUP_MEMBER_NICKNAME_UPDATE：同步成员在群里的昵称 */
    handleGroupMemberNicknameUpdate(websocketMessage: ImGroupMessageNotification) {
      useGroupStore().applyGroupNotification(
        websocketMessage.groupId,
        websocketMessage.type,
        websocketMessage.content
      )
    },

    // ==================== 心跳 / 重连 ====================

    /** 心跳包：纯文本 'ping'，对应服务端 'pong'（后端这层用纯字符串约定，避免 JSON 解析开销） */
    sendHeartBeat() {
      if (this.socket && this.isConnected) {
        this.socket.send('ping')
      }
    },

    /** 主动断开（切换用户 / 退出登录时用）：关 socket + 停心跳 + 取消待重连 */
    disconnect() {
      if (this.socket) {
        // close() 异步触发 onclose / onerror，回调里会无条件 reconnect；
        // 主动关闭路径必须先全部解绑，否则 onclose 会引发自动重连，CONNECTING 期间的 in-flight message 也可能被老 onmessage 投递到 stale 上下文
        unbindWebSocketListeners(this.socket)
        this.socket.close()
        this.socket = null
      }
      // onclose 已被解绑，不会再帮我们设 isConnected=false，这里手动复位
      this.isConnected = false
      this.stopHeartbeat()
      if (this.reconnectTimer) {
        clearTimeout(this.reconnectTimer)
        this.reconnectTimer = null
      }
      // 主动断开（切账号 / 退出）：清零退避计数，下次 connect 重新从最短间隔起算
      this.reconnectAttempts = 0
    },

    /**
     * 自动重连：指数退避 base * 2^attempt（封顶 max）+ 0~jitter ms 随机偏移
     *
     * onclose 是唯一入口；onerror 不再调本方法（浏览器规范两者必同时触发，避免计数 +2）
     * 不设次数上限，频率封顶在 WS_RECONNECT_MAX_MS（约 30s）持续重试，直到链路恢复或主动 disconnect
     */
    reconnect() {
      this.stopHeartbeat()
      if (this.reconnectTimer) {
        clearTimeout(this.reconnectTimer)
        this.reconnectTimer = null
      }
      const backoff = Math.min(
        WS_RECONNECT_BASE_MS * 2 ** this.reconnectAttempts,
        WS_RECONNECT_MAX_MS
      )
      const delay = backoff + Math.floor(Math.random() * WS_RECONNECT_JITTER_MS)
      this.reconnectAttempts++
      console.log(`[IM WS] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
      this.reconnectTimer = setTimeout(() => {
        this.connect()
      }, delay)
    },

    /** 心跳 5 秒一次，保活 + 探活（链路断了 onclose 会触发，由 reconnect 兜底） */
    startHeartbeat() {
      if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
      this.heartbeatTimer = setInterval(() => {
        if (this.socket && this.isConnected) {
          this.sendHeartBeat()
        }
      }, 5000)
    },

    /** 停心跳：disconnect / 重连前调，避免老 timer 在新 socket 上继续触发 sendHeartBeat */
    stopHeartbeat() {
      if (this.heartbeatTimer) {
        clearInterval(this.heartbeatTimer)
        this.heartbeatTimer = null
      }
    },

    // ==================== 实时通话信令分发 ====================

    /**
     * 通话信令分发：1601 RTC_CALL（按 status 区分 INVITING / JOINED / REJECTED / NO_ANSWER / LEFT）+ 1602 / 1603 参与者加入 / 离开
     * <p>
     * 单一 dispatcher，按 type 分发到 rtcStore
     */
    handleRtcSignaling(websocketMessage: ImNoConversationNotification) {
      const rtcStore = useRtcStore()
      switch (websocketMessage.type) {
        case ImContentType.RTC_CALL: {
          const payload = websocketMessage as unknown as ImRtcCallNotification
          switch (payload.status) {
            case ImRtcParticipantStatus.INVITING: {
              if (!isValidRtcInvitePayload(payload)) {
                console.warn('[IM WS] RTC_CALL invite payload 不合法', payload)
                return
              }
              // 当前已在通话中：忽略新来电；后端层面也会拒绝，这里是兜底
              if (!rtcStore.isActive) {
                rtcStore.showIncoming(payload)
              }
              break
            }
            case ImRtcParticipantStatus.JOINED:
            case ImRtcParticipantStatus.LEFT: {
              // ACCEPT / HUNGUP 暂不需要本端额外响应；rtcStore 状态由 1602/1603 + END 维护
              break
            }
            case ImRtcParticipantStatus.NO_ANSWER: {
              // 群通话单人振铃超时；信令独立保留语义，处理与 REJECTED 一致
              rtcStore.applyParticipantNoAnswer(payload)
              break
            }
            case ImRtcParticipantStatus.REJECTED: {
              rtcStore.applyParticipantRejected(payload)
              break
            }
            default: {
              console.warn('[IM WS] 未识别的 RTC_CALL status', payload)
            }
          }
          return
        }
        case ImContentType.RTC_PARTICIPANT_CONNECTED: {
          const payload = websocketMessage as unknown as ImRtcParticipantConnectedNotification
          if (payload?.room && payload.userId) {
            rtcStore.applyParticipantConnected(payload)
          }
          return
        }
        case ImContentType.RTC_PARTICIPANT_DISCONNECTED: {
          const payload = websocketMessage as unknown as ImRtcParticipantDisconnectedNotification
          if (payload?.room && payload.userId) {
            rtcStore.applyParticipantDisconnected(payload)
          }
        }
      }
    },

    /** RTC_CALL_START 通话开始 */
    handleRtcCallStart(websocketMessage: ImGroupMessageNotification) {
      const payload = parseRtcCallPayload(websocketMessage.content)
      if (!payload?.room || !payload.mediaType || !payload.inviterUserId) {
        console.warn('[IM WS] RTC_CALL_START payload 不合法', {
          groupId: websocketMessage.groupId,
          messageId: websocketMessage.id,
          contentLength: websocketMessage.content?.length ?? 0
        })
        return
      }
      useRtcStore().setGroupCall({
        room: payload.room,
        groupId: websocketMessage.groupId,
        mediaType: payload.mediaType,
        inviterId: payload.inviterUserId,
        joinedUserIds: [payload.inviterUserId],
        inviteeIds: []
      })
    },

    /**
     * RTC_CALL_END 通话结束；私聊 + 群聊都走这一条；payload 携带 conversationType 区分
     * <p>
     * 私聊：关闭当前通话窗
     * 群聊：移除胶囊条；如本端在该群通话内则关闭通话窗
     */
    handleRtcCallEnd(
      websocketMessage: ImGroupMessageNotification | ImPrivateMessageNotification
    ) {
      const payload = this.safeParse(websocketMessage.content) as ImRtcCallEndNotification | null
      if (!payload?.room) {
        return
      }
      const rtcStore = useRtcStore()
      const isGroup = payload.conversationType === ImConversationType.GROUP
      // 群通话：移除对应房间的胶囊条
      const groupId = (websocketMessage as ImGroupMessageNotification).groupId
      if (isGroup && groupId) {
        rtcStore.removeGroupCall(groupId, payload.room)
      }
      // 通话窗 / 来电窗指向同一 room 时关闭：
      //   RUNNING / INVITING 阶段对比 call.room；INCOMING 阶段对比 incomingPayload.room
      const matchCall = rtcStore.call?.room === payload.room
      const matchIncoming = rtcStore.incomingPayload?.room === payload.room
      if (rtcStore.isActive && (matchCall || matchIncoming)) {
        const reasonText = resolveCallEndReasonText(payload.endReason)
        console.info('[Call] end:', reasonText)
        rtcStore.reset()
      }
    }
  }
})

export const useImWebSocketStoreWithOut = () => {
  return useImWebSocketStore()
}

// dev: 让 Pinia 的 actions / state 改动支持 HMR，避免每次改 store 都得硬刷
// 否则 Vite 把新模块推下来后，老 store 实例的 action 闭包仍指向旧函数体
if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useImWebSocketStore, import.meta.hot))
}
