<template>
  <!-- 时间分隔条：列表第一条 / 距上一条超过阈值时居中显示灰色时间 -->
  <div
    v-if="shouldShowTimeTip"
    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-disabled)]"
  >
    {{ formatTipTime(message.sendTime) }}
  </div>

  <!-- 好友会话事件（FRIEND_ADD / FRIEND_DELETE）：跟群广播事件同灰色样式，文案固定 -->
  <div
    v-if="isFriendChatTipMessage"
    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]"
  >
    {{ friendChatTipText }}
  </div>

  <!-- 群广播事件：跟好友事件同灰色样式，文案按 type 拼装 -->
  <div
    v-else-if="isGroupNotificationMessage"
    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]"
  >
    {{ groupNotificationText }}
  </div>

  <!-- 撤回消息：整行展示灰色 tip 文案 -->
  <div
    v-else-if="isRecall"
    class="flex items-center justify-center px-4 py-2 text-12px text-[var(--el-text-color-secondary)]"
  >
    {{ recallTip }}
  </div>

  <!-- 普通消息气泡 -->
  <div
    v-else
    class="flex gap-2 items-start px-4 py-2"
    :class="{ 'flex-row-reverse': message.selfSend }"
    @contextmenu.prevent="handleContextMenu"
  >
    <!-- 头像：点击弹 UserInfoCard 由 UserAvatar 内部承接 -->
    <UserAvatar
      :id="message.selfSend ? userStore.getUser?.id : message.senderId"
      :name="senderRealNickname"
      :url="message.selfSend ? userStore.getUser?.avatar : senderAvatar"
      :size="36"
    />

    <div class="flex flex-col gap-0.5 max-w-[70%]" :class="{ 'items-end': message.selfSend }">
      <!-- 群聊对方消息：气泡上方显示发送者昵称 -->
      <div
        v-if="showSenderName"
        class="mb-0.5 text-12px text-[var(--el-text-color-secondary)] leading-tight"
      >
        {{ senderDisplayName }}
      </div>
      <div class="flex gap-1.5 items-center" :class="{ 'flex-row-reverse': message.selfSend }">
        <!-- 消息内容：按 type 走 v-if 分支 -->
        <!-- 文本消息 -->
        <div
          v-if="isText"
          class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
          :class="bubbleClass('text')"
        >
          {{ textContent }}
        </div>
        <!-- 图片消息：点击大图预览，由 <el-image> 自身承接；上传中时套一层半透明遮罩显示进度 -->
        <div v-else-if="isImage && imagePayload" class="relative inline-block">
          <el-image
            class="max-w-[220px] rounded cursor-zoom-in"
            :src="imagePayload.thumbnailUrl || imagePayload.url"
            :preview-src-list="isUploading ? [] : [imagePayload.url]"
            :preview-teleported="true"
            fit="contain"
          />
          <div
            v-if="isUploading"
            class="absolute inset-0 flex items-center justify-center text-sm text-white bg-black bg-opacity-45 rounded pointer-events-none"
          >
            {{ uploadProgressText }}
          </div>
        </div>
        <!-- 文件消息：对齐微信观感 —— 文件名 + 大小靠左、按扩展名分配的大彩色图标贴右；上传中文件名下方插一条进度条 -->
        <div
          v-else-if="isFile && filePayload"
          class="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border rounded transition-colors"
          :class="[bubbleClass('file'), isUploading ? 'cursor-default' : 'cursor-pointer']"
          @click="handleFileClick"
        >
          <div class="flex-1 min-w-0">
            <div
              class="overflow-hidden text-sm font-medium truncate text-[var(--el-text-color-primary)]"
            >
              {{ filePayload.name }}
            </div>
            <div class="mt-1 text-12px text-[var(--el-text-color-secondary)]">
              {{ formatFileSize(filePayload.size) }}
            </div>
            <!-- 上传中：薄进度条 + 百分比文案 -->
            <div v-if="isUploading" class="flex gap-2 items-center mt-1.5">
              <div class="overflow-hidden flex-1 h-1 rounded bg-[var(--el-fill-color-dark)]">
                <div
                  class="h-full bg-[var(--el-color-primary)] transition-[width] duration-150"
                  :style="{ width: uploadProgress + '%' }"
                ></div>
              </div>
              <span class="text-11px text-[var(--el-text-color-secondary)] tabular-nums">
                {{ uploadProgressText }}
              </span>
            </div>
          </div>
          <Icon
            :icon="fileIconInfo.icon"
            :color="fileIconInfo.color"
            :size="40"
            class="flex-shrink-0"
          />
        </div>
        <!-- 语音消息 -->
        <div
          v-else-if="isVoice && voicePayload"
          class="relative flex gap-2 items-center min-w-[120px] px-3.5 py-2.5 rounded-lg cursor-pointer"
          :class="bubbleClass('voice')"
          @click="handleVoiceClick"
        >
          <Icon
            icon="ant-design:audio-outlined"
            :size="18"
            class="message-bubble__voice-icon"
            :class="{ 'im-voice-playing': voicePlaying }"
          />
          <span class="text-13px text-[var(--el-text-color-primary)]">
            {{ formatSeconds(voicePayload.duration) }}
          </span>
        </div>
        <!-- 视频消息：直接用原生 <video controls> 内嵌播放，poster 取后端给的封面图；
             不接入第三方播放器、不重写 UI，保持和图片 / 文件分支一样的轻量观感；上传中半透明遮罩显示进度 -->
        <div v-else-if="isVideo && videoPayload?.url" class="relative inline-block">
          <video
            class="max-w-[280px] max-h-[320px] rounded bg-black"
            :src="videoPayload.url"
            :poster="videoPayload.coverUrl"
            :controls="!isUploading"
            preload="metadata"
          ></video>
          <div
            v-if="isUploading"
            class="absolute inset-0 flex items-center justify-center text-sm text-white bg-black bg-opacity-45 rounded pointer-events-none"
          >
            {{ uploadProgressText }}
          </div>
        </div>
        <!-- 视频消息但 payload 解析失败 / 没 url：降级展示，避免出现裸的 [视频消息] -->
        <div
          v-else-if="isVideo"
          class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--el-text-color-secondary)] bg-[var(--el-fill-color-light)]"
        >
          [视频消息]
        </div>
        <!-- 表情贴图：裸 <img>，不套气泡（对齐微信观感：贴图本体就是装饰，再叠气泡显累赘） -->
        <div
          v-else-if="isFace && facePayload"
          class="inline-block"
        >
          <img
            :src="facePayload.url"
            :alt="facePayload.name || '表情'"
            :title="facePayload.name || ''"
            :width="facePayload.width"
            :height="facePayload.height"
            class="block max-w-[160px] max-h-[160px] object-contain"
            draggable="false"
          />
        </div>
        <!-- 名片消息：头像 + 昵称 + 「个人名片」标签；点击气泡弹被推荐用户的名片浮层
             参照微信观感：自己 / 对方都是浅灰白卡片不染绿，头像圆角块，底部分隔条灰字「个人名片」 -->
        <div
          v-else-if="isCard && cardPayload"
          class="flex flex-col w-[240px] rounded-md overflow-hidden cursor-pointer bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]"
          @click="handleCardClick"
        >
          <div class="flex gap-2.5 items-center px-3 py-2.5">
            <UserAvatar
              :id="cardPayload.userId"
              :url="cardPayload.avatar"
              :name="cardPayload.nickname"
              :size="40"
              :clickable="false"
            />
            <div class="flex-1 min-w-0 text-sm font-medium truncate text-[var(--el-text-color-primary)]">
              {{ cardPayload.nickname }}
            </div>
          </div>
          <div
            class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
          >
            个人名片
          </div>
        </div>
        <!-- 未知类型降级展示 -->
        <div
          v-else
          class="px-3.5 py-2.5 text-sm italic rounded-lg text-[var(--el-text-color-secondary)] bg-[var(--el-fill-color-light)]"
        >
          [不支持的消息类型]
        </div>

        <!-- 状态区：自己消息展示发送状态 + 已读/群回执；对方消息 + @自己时展示 @徽标 -->
        <div class="flex gap-1.5 items-center text-base">
          <template v-if="message.selfSend">
            <Icon
              v-if="showSendingLoading"
              icon="ant-design:loading-outlined"
              class="im-loading-spin"
            />
            <Icon
              v-else-if="message.status === ImMessageStatus.FAILED"
              icon="ant-design:warning-filled"
              color="#f56c6c"
              class="cursor-pointer"
              title="发送失败，点击重试"
              @click="handleResend"
            />
            <!-- 已读态（私聊） -->
            <span
              v-else-if="privateReadLabel"
              class="text-12px whitespace-nowrap"
              :class="
                message.status === ImMessageStatus.READ
                  ? 'text-[#409eff]'
                  : 'text-[var(--el-text-color-secondary)]'
              "
            >
              {{ privateReadLabel }}
            </span>
            <!-- 群回执：点击弹 popover 展示已读 / 未读成员列表 -->
            <MessageReadStatus
              v-else-if="showGroupReadStatus"
              :message="message"
              :group-id="conversationStore.activeConversation?.targetId || 0"
              :group-members="groupMembersForReadStatus"
              class="text-12px whitespace-nowrap text-[var(--el-text-color-secondary)]"
            />
          </template>
          <!-- 群 @ 我提示 -->
          <el-tag
            v-if="!message.selfSend && isAtMe"
            type="danger"
            size="small"
            effect="plain"
            class="ml-1"
          >
            @我
          </el-tag>
        </div>
      </div>
      <!-- 引用块：气泡下方，selfSend 时竖线在右侧 -->
      <ReplyPreview
        v-if="quote"
        :quote="quote"
        clickable
        :mirrored="message.selfSend"
        class="max-w-[280px]"
        @locate="emit('locate', $event)"
      />
    </div>
  </div>
</template>

<script lang="ts" setup>
import { computed, onBeforeUnmount, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { ElMessageBox } from 'element-plus'

import {
  ImMessageType,
  ImMessageStatus,
  ImGroupReceiptStatus,
  ImConversationType,
  ImFriendAddSource,
  ImGroupMemberRole,
  TIME_TIP_GAP_MS,
  isFriendChatTip,
  isGroupNotification,
  isMediaMessageType,
  isNormalMessage
} from '@/views/im/utils/constants'
import { pinGroupMessage as apiPinGroupMessage, cancelMuteMember } from '@/api/im/group'
import { removeGroupMember } from '@/api/im/group/member'
import {
  buildQuoteFromMessage,
  extractAddableFace,
  getQuoteFromMessage,
  parseMessage,
  getFileIconInfo,
  type TextMessage,
  type ImageMessage,
  type FileMessage,
  type AudioMessage,
  type VideoMessage,
  type CardMessage,
  type FaceMessage
} from '@/views/im/utils/message'
import { buildRecallTip } from '@/views/im/utils/conversation'
import { formatSeconds } from '@/utils/formatTime'
import { formatFileSize } from '@/utils/file'
import { useUserStore } from '@/store/modules/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useDraftStore } from '../../../../store/draftStore'
import { useFaceStore } from '../../../../store/faceStore'
import {
  getMemberDisplayName,
  getSenderDisplayName,
  getSenderRealNickname,
  resolveFriendNotificationText,
  resolveGroupNotificationText
} from '@/views/im/utils/user'
import { useImUiStore } from '../../../../store/uiStore'
import { useMessageSender } from '../../../../composables/useMessageSender'
import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader'
import { useMuteOverlay } from '../../../../composables/useMuteOverlay'
import type { Message } from '../../../../types'
import MessageReadStatus from './MessageReadStatus.vue'
import ReplyPreview from './ReplyPreview.vue'
import UserAvatar from '../../../../components/user/UserAvatar.vue'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'

defineOptions({ name: 'ImMessageItem' })

const props = defineProps<{
  message: Message
  /** 列表中的上一条消息，用于判断是否要在当前消息上方渲染时间分隔条；不传按列表第一条处理 */
  prevMessage?: Message
}>()

const emit = defineEmits<{
  /** 引用块点击 → MessagePanel 滚定位 + 高亮 */
  locate: [messageId: number]
  /** 禁言：需要父组件打开时长选择弹窗 */
  mute: [groupId: number, userId: number, displayName: string]
  /** 数据变更后刷新群信息 */
  reload: []
}>()

// ==================== Stores / Hooks ====================

const userStore = useUserStore()
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const draftStore = useDraftStore()
const faceStore = useFaceStore()
const uiStore = useImUiStore()
const { recall, sendRaw } = useMessageSender()
const { uploadAndSendMedia } = useMediaUploader()
const muteOverlay = useMuteOverlay()
// 仅用 confirm，避免 message 跟 props.message 同名冲突（vue/no-dupe-keys）
const { confirm: confirmDialog, success: successMessage } = useMessage()

// ==================== 消息类型判断 ====================

/** 是否在当前消息上方渲染时间分隔条：列表第一条 / 距上一条超过阈值；缺 sendTime 不渲染 */
const shouldShowTimeTip = computed(() => {
  if (!props.message.sendTime) {
    return false
  }
  if (!props.prevMessage?.sendTime) {
    return true
  }
  return props.message.sendTime - props.prevMessage.sendTime > TIME_TIP_GAP_MS
})

/** 是否文本消息 */
const isText = computed(() => props.message.type === ImMessageType.TEXT)
const isImage = computed(() => props.message.type === ImMessageType.IMAGE)
const isFile = computed(() => props.message.type === ImMessageType.FILE)
const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
const isVideo = computed(() => props.message.type === ImMessageType.VIDEO)
const isCard = computed(() => props.message.type === ImMessageType.CARD)
const isFace = computed(() => props.message.type === ImMessageType.FACE)

// ==================== 气泡配色 helper ====================
// self / other 两侧 ::before 三角靠 message-bubble--self/other 类承载；本 helper 把 4 处 selfSend ternary
// 集中到一处，新增 variant 直接在 switch 加 case；class 字面量保留以便 UnoCSS 静态扫描

/** 文本 / 文件 / 语音气泡的整体 class（含 selfSend 配色 + ::before 三角的 side class） */
function bubbleClass(variant: 'text' | 'file' | 'voice'): string[] {
  const isSelf = props.message.selfSend
  const side = isSelf ? 'message-bubble--self' : 'message-bubble--other'
  switch (variant) {
    case 'text':
      return [
        side,
        isSelf
          ? 'text-black bg-[#95ec69]'
          : 'text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)]'
      ]
    case 'file':
      return [
        side,
        isSelf
          ? 'bg-[#95ec69] border-[var(--el-border-color-lighter)]'
          : 'bg-[var(--el-bg-color)] border-[var(--el-border-color-light)] hover:border-[#409eff]'
      ]
    case 'voice':
      return [side, isSelf ? 'bg-[#95ec69]' : 'bg-[var(--el-fill-color-light)]']
  }
}

// TODO @AI：抽到 message.ts 里？作为一个工具方法？
/**
 * 时间分隔线文案：
 * - 今天：HH:mm
 * - 昨天：昨天 HH:mm
 * - 一周内：周X HH:mm
 * - 超过一周：MM-dd HH:mm
 */
function formatTipTime(timestamp: number): string {
  if (!timestamp) {
    return ''
  }
  const messageDate = new Date(timestamp)
  const now = new Date()
  const pad = (value: number) => value.toString().padStart(2, '0')
  const hourMinute = `${pad(messageDate.getHours())}:${pad(messageDate.getMinutes())}`
  const sameDay = (left: Date, right: Date) =>
    left.getFullYear() === right.getFullYear() &&
    left.getMonth() === right.getMonth() &&
    left.getDate() === right.getDate()
  if (sameDay(messageDate, now)) {
    return hourMinute
  }
  const yesterday = new Date(now)
  yesterday.setDate(now.getDate() - 1)
  if (sameDay(messageDate, yesterday)) {
    return `昨天 ${hourMinute}`
  }
  if (now.getTime() - messageDate.getTime() < 7 * 24 * 60 * 60 * 1000) {
    const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
    return `${weekNames[messageDate.getDay()]} ${hourMinute}`
  }
  return `${pad(messageDate.getMonth() + 1)}-${pad(messageDate.getDate())} ${hourMinute}`
}

// ==================== 事件消息（撤回 / 好友 / 群广播） ====================
// 这三类不走普通气泡，渲染成居中灰色 tip；判断 + 文案配对放一起，新增第四类事件只需在本块改完

/** 是否已撤回：pull / WS 两路都会调 recallMessage 把原消息更新为 type=RECALL，渲染只需识别 type */
const isRecall = computed(() => props.message.type === ImMessageType.RECALL)

/** 撤回提示文案：buildRecallTip 实时算 sender 名（按 conversation 上下文走 WeChat 优先级） */
const recallTip = computed(() => {
  const conversation = conversationStore.activeConversation
  return buildRecallTip(
    props.message.senderId,
    props.message.selfSend,
    conversation?.type ?? 0,
    conversation?.targetId ?? 0
  )
})

/** 是否会话内好友事件气泡（FRIEND_ADD / FRIEND_DELETE） */
const isFriendChatTipMessage = computed(() => isFriendChatTip(props.message.type))

/** 好友事件文案：FRIEND_ADD / FRIEND_DELETE 渲染成灰色提示，文案固定 */
const friendChatTipText = computed(() => resolveFriendNotificationText(props.message))

/** 是否群广播事件（GROUP_CREATE..GROUP_BANNED 段位，排除 GROUP_MEMBER_SETTING_UPDATE 个人信号） */
const isGroupNotificationMessage = computed(() => isGroupNotification(props.message.type))

/** 群广播事件文案：按 type 拼装（成员加入 / 退群 / 公告变更等） */
const groupNotificationText = computed(() => resolveGroupNotificationText(props.message))

// ==================== 消息内容解析 / payload ====================

/** 文本内容 */
const textContent = computed(() => parseMessage<TextMessage>(props.message.content)?.content ?? '')

/** 引用对象：气泡内嵌入展示；非引用消息返回 null，模板 v-if 不渲染 */
const quote = computed(() => getQuoteFromMessage(props.message.content))

const imagePayload = computed(() =>
  isImage.value ? parseMessage<ImageMessage>(props.message.content) : null
)
const filePayload = computed(() =>
  isFile.value ? parseMessage<FileMessage>(props.message.content) : null
)
const voicePayload = computed(() =>
  isVoice.value ? parseMessage<AudioMessage>(props.message.content) : null
)
const videoPayload = computed(() =>
  isVideo.value ? parseMessage<VideoMessage>(props.message.content) : null
)
const cardPayload = computed(() =>
  isCard.value ? parseMessage<CardMessage>(props.message.content) : null
)
const facePayload = computed(() =>
  isFace.value ? parseMessage<FaceMessage>(props.message.content) : null
)

/** 名片点击：弹被推荐用户的 UserInfoCard；陌生人名片走「名片」加好友来源 */
function handleCardClick(e: MouseEvent) {
  const card = cardPayload.value
  if (!card?.userId) {
    return
  }
  uiStore.openUserInfoCardAtEvent(
    {
      id: card.userId,
      nickname: card.nickname,
      avatar: card.avatar
    },
    e,
    ImFriendAddSource.CARD
  )
}

/** 媒体消息上传中：插入占位时 uploadProgress=0，ack 成功后被清成 undefined */
const isUploading = computed(() => props.message.uploadProgress != null)

/** 上传进度（0-100）；undefined 兜底为 0 避免遮罩文案出现 NaN */
const uploadProgress = computed(() => props.message.uploadProgress ?? 0)

/** 上传进度文案；图片/视频遮罩、文件进度条尾巴共用 */
const uploadProgressText = computed(() => `${uploadProgress.value}%`)

/**
 * 是否在气泡尾部显示「发送中」loading 转圈
 *
 * 图片 / 视频 / 文件气泡内嵌已有进度反馈（遮罩 / 进度条），外层 loading 不再叠加；
 * 语音气泡只有麦克风 + 时长，无内嵌进度，必须保留外层 loading 让用户感知正在发送
 */
const showSendingLoading = computed(
  () => props.message.status === ImMessageStatus.SENDING && (!isUploading.value || isVoice.value)
)

/** 文件类型图标 + 配色：按扩展名分发，跟 ReplyPreview 共用 getFileIconInfo */
const fileIconInfo = computed(() => getFileIconInfo(filePayload.value?.name))

/** 文件点击 → 新窗口下载；上传中点击无意义（url 还是 blob），直接跳过 */
function handleFileClick() {
  if (isUploading.value || !filePayload.value?.url) {
    return
  }
  window.open(filePayload.value.url, '_blank')
}

/** 语音点击 → 当前只做简单 Audio() 播放；真实项目可接入全局 Player */
const voicePlaying = ref(false)
let currentAudio: HTMLAudioElement | null = null
function handleVoiceClick() {
  if (!voicePayload.value?.url) {
    return
  }
  if (voicePlaying.value && currentAudio) {
    currentAudio.pause()
    voicePlaying.value = false
    return
  }
  currentAudio = new Audio(voicePayload.value.url)
  voicePlaying.value = true
  currentAudio.addEventListener('ended', () => {
    voicePlaying.value = false
    currentAudio = null
  })
  currentAudio.play().catch(() => {
    voicePlaying.value = false
  })
}

onBeforeUnmount(() => {
  if (currentAudio) {
    currentAudio.pause()
    currentAudio.src = ''
    currentAudio = null
  }
  voicePlaying.value = false
})

// ==================== 发送人 / 已读 / @ ====================

/** 群聊 + 对方消息 时，在气泡上方显示发送者昵称 */
const showSenderName = computed(() => {
  if (props.message.selfSend) {
    return false
  }
  return conversationStore.activeConversation?.type === ImConversationType.GROUP
})

/** 发送者头像；私聊的 conversation.avatar 就是对方头像（openConversation 入参约定） */
const senderAvatar = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || props.message.selfSend) {
    return ''
  }
  if (conversation.type === ImConversationType.GROUP) {
    const group = groupStore.getGroup(conversation.targetId)
    return group?.members?.find((member) => member.userId === props.message.senderId)?.avatar || ''
  }
  return conversation.avatar || ''
})

/** 头像色卡 fallback 文本：永远是真实昵称，不掺备注 */
const senderRealNickname = computed(() => {
  const conversation = conversationStore.activeConversation
  return getSenderRealNickname(
    props.message.senderId,
    conversation?.type ?? 0,
    conversation?.targetId ?? 0
  )
})

/** 气泡上方发送人显示名（仅群聊对方消息显示）：好友备注 > 群备注 > 真实昵称 */
const senderDisplayName = computed(() => {
  const conversation = conversationStore.activeConversation
  return getSenderDisplayName(
    props.message.senderId,
    conversation?.type ?? 0,
    conversation?.targetId ?? 0
  )
})

/** 私聊「已读 / 未读」态（仅对自己发送的私聊消息展示） */
const privateReadLabel = computed(() => {
  if (!props.message.selfSend) {
    return ''
  }
  if (conversationStore.activeConversation?.type !== ImConversationType.PRIVATE) {
    return ''
  }
  if (props.message.status === ImMessageStatus.READ) {
    return '已读'
  }
  if (props.message.status === ImMessageStatus.UNREAD) {
    return '未读'
  }
  return ''
})

/**是否需要显示群回执 popover：自己发的群消息且后端开启了回执（NO_RECEIPT 表示发送时未要求回执，不渲染） */
const showGroupReadStatus = computed(() => {
  if (!props.message.selfSend) {
    return false
  }
  if (conversationStore.activeConversation?.type !== ImConversationType.GROUP) {
    return false
  }
  const status = props.message.receiptStatus
  if (status === undefined || status === null) {
    return false
  }
  return status !== ImGroupReceiptStatus.NO_RECEIPT
})

/** 当前群成员（供 MessageReadStatus 计算未读名单；未加载完时兜底空数组不渲染） */
const groupMembersForReadStatus = computed<GroupMemberLite[]>(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return []
  }
  const group = groupStore.getGroup(conversation.targetId)
  return (group?.members || []).map((member) => {
    const friend = friendStore.getFriend(member.userId)
    return {
      userId: member.userId,
      showName: getMemberDisplayName(member, friend),
      nickname: member.nickname,
      avatar: member.avatar,
      status: member.status
    }
  })
})

/** 是否 @我（群消息展示小徽标） */
const isAtMe = computed(() => {
  const myId = Number(userStore.getUser?.id) || 0
  if (!myId) {
    return false
  }
  return (props.message.atUserIds || []).includes(myId)
})

// ==================== 右键菜单 / 操作 ====================

/** 右键菜单 key 常量；push 端和分发端从同一处取，typo 编译期就能抓 */
const MENU_KEYS = {
  REPLY: 'REPLY',
  PIN: 'PIN',
  ADD_TO_FACE: 'ADD_TO_FACE',
  MUTE: 'MUTE',
  UNMUTE: 'UNMUTE',
  KICK: 'KICK',
  RECALL: 'RECALL',
  DELETE: 'DELETE'
} as const
type MenuKey = (typeof MENU_KEYS)[keyof typeof MENU_KEYS]

/** 撤回时间窗：自己发送的消息超过这个时长就不能再撤回，菜单回退为「删除」（对齐微信 2 分钟） */
const RECALL_WINDOW_MS = 2 * 60 * 1000

/**
 * 右键菜单项：
 * - 引用：已落库（id≠0）+ 未撤回的消息可引用，引用块写入 draftStore.reply
 * - 撤回 / 删除：互斥；自己发送 + 已落库 + 未撤回 + 2 分钟内显示「撤回」（推服务器），其它显示「删除」（仅本地清）
 *
 * 好友事件气泡态不弹菜单
 */
async function handleContextMenu(e: MouseEvent) {
  if (isFriendChatTipMessage.value) {
    return
  }

  const items: Array<{
    key: MenuKey
    name: string
    disabled?: boolean
    divided?: boolean
    danger?: boolean
    icon?: string
  }> = []
  // 「引用」：已落库（id≠0）+ 未撤回；本地占位消息（id=0）不允许引用，避免引用一条还没拿到 id 的消息
  if (!!props.message.id && !isRecall.value) {
    items.push({
      key: MENU_KEYS.REPLY,
      name: '引用',
      icon: 'bxs:quote-alt-left'
    })
  }
  // 「置顶」：仅群聊 + 普通消息 + 已落库 + 未撤回 + 群主或管理员；已置顶不再展示，由置顶面板的「移除」入口承接
  // 不本地预判上限，让后端校验，超限时通过 error toast 反馈
  if (canPin.value) {
    items.push({
      key: MENU_KEYS.PIN,
      name: '置顶',
      icon: 'ant-design:pushpin-outlined'
    })
  }
  // 「添加到表情」：FACE / IMAGE 消息 + 已落库 + 未撤回；写入个人表情包，对照微信「添加到表情」
  if (canAddToFace.value) {
    items.push({
      key: MENU_KEYS.ADD_TO_FACE,
      name: '添加到表情',
      icon: 'ant-design:smile-outlined'
    })
  }
  // 「禁言 / 解禁 / 移除」：群聊 + 非自己消息 + 我是群主或管理员
  if (currentGroup.value && !props.message.selfSend && canManageSender.value) {
    const senderMember = currentGroup.value.members?.find(
      (m) => m.userId === props.message.senderId
    )
    const isMuted = senderMember?.muteEndTime && new Date(senderMember.muteEndTime) > new Date()
    if (isMuted) {
      items.push({
        key: MENU_KEYS.UNMUTE,
        name: '解除禁言',
        icon: 'ant-design:audio-outlined',
        divided: true
      })
    } else {
      items.push({
        key: MENU_KEYS.MUTE,
        name: '禁言',
        icon: 'ant-design:audio-muted-outlined',
        divided: true
      })
    }
    items.push({
      key: MENU_KEYS.KICK,
      name: '移除',
      icon: 'ant-design:user-delete-outlined',
      danger: true
    })
  }
  // 「撤回 / 删除」二选一：
  // - 自己发送 + 已落库（id≠0）+ 未撤回 + 在撤回窗口内 → 撤回（推服务器把消息态置 RECALL）
  // - 其它（对方消息 / 已撤回 / 超出撤回窗口）→ 删除（仅本地清，不动后端）
  // divided 把这一项和上面的「引用」隔开，danger 显红对齐微信
  const canRecall =
    props.message.selfSend &&
    !!props.message.id &&
    !isRecall.value &&
    Date.now() - props.message.sendTime <= RECALL_WINDOW_MS
  if (canRecall) {
    items.push({
      key: MENU_KEYS.RECALL,
      name: '撤回',
      icon: 'ant-design:undo-outlined',
      divided: true,
      danger: true
    })
  } else {
    items.push({
      key: MENU_KEYS.DELETE,
      name: '删除',
      icon: 'ant-design:delete-outlined',
      divided: true,
      danger: true
    })
  }

  // 把菜单渲染交给全局 uiStore（单例，避免每条消息都挂一份菜单 DOM）；callback 按 key 分发
  uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, items, async (item) => {
    if (item.key === MENU_KEYS.REPLY) {
      handleReply()
    } else if (item.key === MENU_KEYS.PIN) {
      await handlePin()
    } else if (item.key === MENU_KEYS.ADD_TO_FACE) {
      await handleAddToFace()
    } else if (item.key === MENU_KEYS.MUTE) {
      handleMute()
    } else if (item.key === MENU_KEYS.UNMUTE) {
      await handleUnmute()
    } else if (item.key === MENU_KEYS.KICK) {
      await handleKick()
    } else if (item.key === MENU_KEYS.RECALL) {
      await handleRecall()
    } else if (item.key === MENU_KEYS.DELETE) {
      handleDelete()
    }
  })
}

/** 是否可「添加到表情」：FACE / IMAGE 消息 + 已落库 + 未撤回（GIF / 静图都允许） */
const canAddToFace = computed(() => {
  if (isRecall.value || !props.message.id) {
    return false
  }
  return extractAddableFace(props.message) !== null
})

/** 添加到个人表情：从 message 抽 url + 尺寸 + name 写入个人表情库；幂等失败时返回 false 走 toast 兜底 */
async function handleAddToFace() {
  const payload = extractAddableFace(props.message)
  if (!payload) {
    return
  }
  // TODO @AI：改成 data；更符合预期
  const ok = await faceStore.addFaceUserItem({
    ...payload,
    sourceMessageId: props.message.id
  })
  if (ok) {
    successMessage('已添加到表情')
  }
}

/** 当前激活会话对应的群（私聊场景为 undefined） */
const currentGroup = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || conversation.type !== ImConversationType.GROUP) {
    return undefined
  }
  return groupStore.getGroup(conversation.targetId)
})

/** 当前用户在该群里的角色；私聊或非群成员 → undefined */
const myGroupRole = computed(() => {
  const myId = Number(userStore.getUser?.id) || 0
  return currentGroup.value?.members?.find((m) => m.userId === myId)?.role
})

/** 是否可管理该消息发送人：我的角色高于目标角色（群主 > 管理员 > 普通成员）；目标角色未知时不展示 */
const canManageSender = computed(() => {
  if (!currentGroup.value || !myGroupRole.value) {
    return false
  }
  const senderMember = currentGroup.value.members?.find((m) => m.userId === props.message.senderId)
  // 成员缓存不完整时不展示管理操作，等成员数据补齐后再判断
  if (!senderMember?.role) {
    return false
  }
  // 角色数值：1=群主 2=管理员 3=普通成员；数值越小权限越高
  return myGroupRole.value < senderMember.role
})

/** 是否允许置顶（已置顶消息不再展示菜单项，由置顶面板的「移除」入口承接）：群聊 + 普通消息 + 已落库 + 未撤回 + 群主或管理员 + 未置顶 */
const canPin = computed(
  () =>
    !!currentGroup.value &&
    isNormalMessage(props.message.type) &&
    !!props.message.id &&
    !isRecall.value &&
    (myGroupRole.value === ImGroupMemberRole.OWNER ||
      myGroupRole.value === ImGroupMemberRole.ADMIN) &&
    !currentGroup.value.pinnedMessages?.some((m) => m.id === props.message.id)
)

/** 置顶消息：二次确认 → 调后端 pin-message；后端广播 GROUP_MESSAGE_PIN，本端 dispatcher 拉最新 pinnedMessages */
async function handlePin() {
  const group = currentGroup.value
  if (!group) {
    return
  }
  try {
    await ElMessageBox.confirm('将在当前群成员的聊天中置顶', '置顶消息', {
      confirmButtonText: '置顶',
      cancelButtonText: '取消',
      type: 'warning'
    })
    await apiPinGroupMessage({ groupId: group.id, messageId: props.message.id })
    successMessage('已置顶')
  } catch {}
}

/** 进入引用模式：把当前消息构造成 QuoteMessage 写入 draftStore，MessageInput 顶部引用条响应式出现 */
function handleReply() {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  draftStore.setReply(conversation, buildQuoteFromMessage(props.message))
}

/**
 * 撤回消息：弹确认框 → 调 useMessageSender.recall → 后端通过 WS RECALL 事件推回，
 * websocketStore 把对应 message 的 type 改成 RECALL，UI 自动切到"XX 撤回了一条消息"
 *
 * 不做乐观撤回：失败 / 超时 / 后端拒绝时本端状态可能与服务端漂移，统一让 WS 回推最稳
 */
async function handleRecall() {
  try {
    await confirmDialog('确定要撤回这条消息吗？', '撤回消息')
    await recall(props.message)
  } catch {}
}

/**
 * 失败消息点击重试
 *
 * - 媒体消息（image / file / voice / video）：_localFile 在内存就重走 uploadAndSendMedia（重新上传 + 占位 + 进度）
 * - 文本消息：移除 FAILED 占位 + 用原 content 走一遍 sendRaw 新建占位
 *
 * 媒体类型若 _localFile 已丢（理论上 IDB 恢复阶段就被 drop，进不到这里；保险起见仍走文本兜底）则按 sendRaw 重发，
 * 后端拒绝失效 blob URL 时再次 FAILED，用户可右键删除
 *
 * 不还原原 receipt：群回执是发送时的扩展选项、不会持久化到 message，强行猜测可能与原意不符；
 * 默认按"无回执"重发，绝大多数场景符合预期，要回执就重新发一次更直观
 */
async function handleResend() {
  if (props.message.status !== ImMessageStatus.FAILED) {
    return
  }
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  // 禁言 / 封禁时拦截：避免重试绕过 MessageInput 的 muteOverlay 又走一次 sendRaw 让后端拒
  if (muteOverlay.value) {
    return
  }
  const message = props.message
  const file = message._localFile

  // 媒体类型 + _localFile 在 → 重走 uploadAndSendMedia；type 分发 + 旧元数据复用统一在 mediaTypeHandlers 表里
  if (isMediaMessageType(message.type) && file) {
    const handler = mediaTypeHandlers[message.type]
    if (handler) {
      const oldQuote = getQuoteFromMessage(message.content) ?? undefined
      const context = handler.extractResendContext(message.content)
      conversationStore.removeMessage(conversation.type, conversation.targetId, {
        id: message.id,
        clientMessageId: message.clientMessageId
      })
      await uploadAndSendMedia({
        file,
        type: message.type,
        quote: oldQuote,
        conversation,
        context
      })
      return
    }
  }

  // 文本类型 / 媒体类型但 _localFile 已丢：原 content 走 sendRaw 重发
  conversationStore.removeMessage(conversation.type, conversation.targetId, {
    id: message.id,
    clientMessageId: message.clientMessageId
  })
  await sendRaw(message.type, message.content, {
    atUserIds: message.atUserIds
  })
}

/**
 * 删除消息：本地软删，仅从 conversationStore.messages 移除，不调后端
 * 区别于"撤回"：服务端没动，多端登录时其它客户端 / 群里其他人依然能看到这条
 */
/** 禁言：emit 给父组件打开时长选择弹窗（避免 MessageItem 过重） */
function handleMute() {
  const group = currentGroup.value
  if (!group) {
    return
  }
  const name = senderDisplayName.value || '该成员'
  emit('mute', group.id, props.message.senderId, name)
}

/** 解除禁言 */
async function handleUnmute() {
  const group = currentGroup.value
  if (!group) {
    return
  }
  try {
    await confirmDialog('确定解除该成员的禁言吗？', '解除禁言')
    await cancelMuteMember({ groupId: group.id, userId: props.message.senderId })
    successMessage('已解除禁言')
    emit('reload')
  } catch {}
}

/** 移除群成员 */
async function handleKick() {
  const group = currentGroup.value
  if (!group) {
    return
  }
  const name = senderDisplayName.value || '该成员'
  try {
    await confirmDialog(`确定将「${name}」移出群聊吗？`, '移除成员')
    await removeGroupMember({ groupId: group.id, memberUserIds: [props.message.senderId] })
    successMessage('已移除')
    emit('reload')
  } catch {}
}

function handleDelete() {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return
  }
  conversationStore.removeMessage(conversation.type, conversation.targetId, {
    id: props.message.id,
    clientMessageId: props.message.clientMessageId
  })
}
</script>

<style scoped>
/* 气泡尾巴：小三角伪元素，指向对应头像（对方在左、自己在右），对齐微信观感
   - 用 border 4 边色画三角：透明 3 边 + 实色 1 边，省一张图片
   - 颜色对应气泡背景，留 1px 视觉吃进去；UnoCSS 写不顺手，索性用 scoped CSS */
.message-bubble--other::before,
.message-bubble--self::before {
  content: '';
  position: absolute;
  top: 12px;
  width: 0;
  height: 0;
  border-style: solid;
}
.message-bubble--other::before {
  left: -5px;
  border-width: 5px 6px 5px 0;
  border-color: transparent var(--el-fill-color-light) transparent transparent;
}
.message-bubble--self::before {
  right: -5px;
  border-width: 5px 0 5px 6px;
  border-color: transparent transparent transparent #95ec69;
}

/* el-icon 在暗色模式下全局 color 被 .el-icon{color:var(--color)} 干扰；
   这里把 voice 图标的 fill 锁死，避免字体色跟随主题变白；
   file 图标已迁到 Iconify 按扩展名走彩色，不在这里强制 */
.message-bubble__voice-icon :deep(svg) {
  fill: #606266 !important;
}
.message-bubble__voice-icon.im-voice-playing :deep(svg) {
  fill: #409eff !important;
}

/* 播放中的脉冲动画：keyframes 用 UnoCSS 不好写，保留 scoped */
.im-voice-playing {
  animation: im-voice-icon-pulse 0.8s infinite;
}

@keyframes im-voice-icon-pulse {
  0%,
  100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.15);
  }
}

/* SENDING 状态的转圈动画：el-icon 自带 .is-loading 旋转，迁到 Iconify 后丢了，自己补一份 */
.im-loading-spin {
  animation: im-loading-spin 1s linear infinite;
}
@keyframes im-loading-spin {
  to {
    transform: rotate(360deg);
  }
}
</style>
