import * as fs from 'fs'
import * as path from 'path'
import * as http from 'http'
import * as https from 'https'
import crypto from 'crypto'
import { fileURLToPath } from 'url'
import ExcelJS from 'exceljs'
import { getEmojiPath } from 'wechat-emojis'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { imageDecryptService } from './imageDecryptService'
import { chatService } from './chatService'
import { videoService } from './videoService'
import { voiceTranscribeService } from './voiceTranscribeService'
import { exportRecordService } from './exportRecordService'
import { EXPORT_HTML_STYLES } from './exportHtmlStyles'
import { LRUCache } from '../utils/LRUCache.js'

// ChatLab 格式类型定义
interface ChatLabHeader {
  version: string
  exportedAt: number
  generator: string
  description?: string
}

interface ChatLabMeta {
  name: string
  platform: string
  type: 'group' | 'private'
  groupId?: string
  groupAvatar?: string
}

interface ChatLabMember {
  platformId: string
  accountName: string
  groupNickname?: string
  avatar?: string
}

interface ChatLabMessage {
  sender: string
  accountName: string
  groupNickname?: string
  timestamp: number
  type: number
  content: string | null
  platformMessageId?: string
  replyToMessageId?: string
  chatRecords?: any[]  // 嵌套的聊天记录
}

interface ForwardChatRecordItem {
  datatype: number
  sourcename: string
  sourcetime: string
  sourceheadurl?: string
  datadesc?: string
  datatitle?: string
  fileext?: string
  datasize?: number
  chatRecordTitle?: string
  chatRecordDesc?: string
  chatRecordList?: ForwardChatRecordItem[]
}

interface ChatLabExport {
  chatlab: ChatLabHeader
  meta: ChatLabMeta
  members: ChatLabMember[]
  messages: ChatLabMessage[]
}

// 消息类型映射：微信 localType -> ChatLab type
const MESSAGE_TYPE_MAP: Record<number, number> = {
  1: 0,      // 文本 -> TEXT
  3: 1,      // 图片 -> IMAGE
  34: 2,     // 语音 -> VOICE
  43: 3,     // 视频 -> VIDEO
  49: 7,     // 链接/文件 -> LINK (需要进一步判断)
  34359738417: 7,  // 文件消息变体 -> LINK
  103079215153: 7, // 文件消息变体 -> LINK
  25769803825: 7,  // 文件消息变体 -> LINK
  47: 5,     // 表情包 -> EMOJI
  48: 8,     // 位置 -> LOCATION
  42: 27,    // 名片 -> CONTACT
  50: 23,    // 通话 -> CALL
  10000: 80, // 系统消息 -> SYSTEM
}

// 与 chatService 的资源消息识别保持一致，覆盖桌面微信里的多种文件消息 localType。
const FILE_APP_LOCAL_TYPES = [49, 34359738417, 103079215153, 25769803825] as const
const FILE_APP_LOCAL_TYPE_SET = new Set<number>(FILE_APP_LOCAL_TYPES)

export interface ExportOptions {
  format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
  contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
  dateRange?: { start: number; end: number } | null
  senderUsername?: string
  fileNameSuffix?: string
  fileNamingMode?: 'classic' | 'date-range'
  exportMedia?: boolean
  exportAvatars?: boolean
  exportImages?: boolean
  exportVoices?: boolean
  exportVideos?: boolean
  exportEmojis?: boolean
  exportFiles?: boolean
  maxFileSizeMb?: number
  exportVoiceAsText?: boolean
  excelCompactColumns?: boolean
  txtColumns?: string[]
  sessionLayout?: 'shared' | 'per-session'
  exportWriteLayout?: 'A' | 'B' | 'C'
  sessionNameWithTypePrefix?: boolean
  displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
  exportConcurrency?: number
}

const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
  { id: 'index', label: '序号' },
  { id: 'time', label: '时间' },
  { id: 'senderRole', label: '发送者身份' },
  { id: 'messageType', label: '消息类型' },
  { id: 'content', label: '内容' },
  { id: 'senderNickname', label: '发送者昵称' },
  { id: 'senderWxid', label: '发送者微信ID' },
  { id: 'senderRemark', label: '发送者备注' }
]

interface MediaExportItem {
  relativePath: string
  kind: 'image' | 'voice' | 'emoji' | 'video' | 'file'
  posterDataUrl?: string
}

interface ExportDisplayProfile {
  wxid: string
  nickname: string
  remark: string
  alias: string
  groupNickname: string
  displayName: string
}

type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' | 'file'
interface FileExportCandidate {
  sourcePath: string
  matchedBy: 'md5' | 'name'
  yearMonth?: string
  preferredMonth?: boolean
  mtimeMs: number
  searchOrder: number
}
interface FileAttachmentSearchRoot {
  accountDir: string
  msgFileRoot?: string
  fileStorageRoot?: string
}

export interface ExportProgress {
  current: number
  total: number
  currentSession: string
  currentSessionId?: string
  phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
  phaseProgress?: number
  phaseTotal?: number
  phaseLabel?: string
  collectedMessages?: number
  exportedMessages?: number
  estimatedTotalMessages?: number
  writtenFiles?: number
  mediaDoneFiles?: number
  mediaCacheHitFiles?: number
  mediaCacheMissFiles?: number
  mediaCacheFillFiles?: number
  mediaDedupReuseFiles?: number
  mediaBytesWritten?: number
}

interface MediaExportTelemetry {
  doneFiles: number
  cacheHitFiles: number
  cacheMissFiles: number
  cacheFillFiles: number
  dedupReuseFiles: number
  bytesWritten: number
}

interface MediaSourceResolution {
  sourcePath: string
  cacheHit: boolean
  cachePath?: string
  fileStat?: { size: number; mtimeMs: number }
  dedupeKey?: string
}

interface ExportTaskControl {
  shouldPause?: () => boolean
  shouldStop?: () => boolean
  recordCreatedFile?: (filePath: string) => void
  recordCreatedDir?: (dirPath: string) => void
}

interface ExportStatsResult {
  totalMessages: number
  voiceMessages: number
  cachedVoiceCount: number
  needTranscribeCount: number
  mediaMessages: number
  estimatedSeconds: number
  sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }>
}

interface ExportStatsSessionSnapshot {
  totalCount: number
  voiceCount: number
  imageCount: number
  videoCount: number
  emojiCount: number
  cachedVoiceCount: number
  lastTimestamp?: number
}

interface ExportStatsCacheEntry {
  createdAt: number
  result: ExportStatsResult
  sessions: Record<string, ExportStatsSessionSnapshot>
}

interface ExportAggregatedSessionMetric {
  totalMessages?: number
  voiceMessages?: number
  imageMessages?: number
  videoMessages?: number
  emojiMessages?: number
  lastTimestamp?: number
}

interface ExportAggregatedSessionStatsCacheEntry {
  createdAt: number
  data: Record<string, ExportAggregatedSessionMetric>
}

// 并发控制：限制同时执行的 Promise 数量
async function parallelLimit<T, R>(
  items: T[],
  limit: number,
  fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
  const results: R[] = new Array(items.length)
  let currentIndex = 0

  async function runNext(): Promise<void> {
    while (currentIndex < items.length) {
      const index = currentIndex++
      results[index] = await fn(items[index], index)
    }
  }

  // 启动 limit 个并发任务
  const workers = Array(Math.min(limit, items.length))
    .fill(null)
    .map(() => runNext())

  await Promise.all(workers)
  return results
}

class ExportService {
  private configService: ConfigService
  private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
  private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
  private inlineEmojiCache: LRUCache<string, string>
  private htmlStyleCache: string | null = null
  private exportStatsCache = new Map<string, ExportStatsCacheEntry>()
  private exportAggregatedSessionStatsCache = new Map<string, ExportAggregatedSessionStatsCacheEntry>()
  private readonly exportStatsCacheTtlMs = 2 * 60 * 1000
  private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000
  private readonly exportStatsCacheMaxEntries = 16
  private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED'
  private readonly PAUSE_ERROR_CODE = 'WEFLOW_EXPORT_PAUSE_REQUESTED'
  private mediaFileCachePopulatePending = new Map<string, Promise<string | null>>()
  private mediaFileCacheReadyDirs = new Set<string>()
  private mediaExportTelemetry: MediaExportTelemetry | null = null
  private mediaRunSourceDedupMap = new Map<string, string>()
  private mediaRunMissingImageKeys = new Set<string>()
  private activeChatImagePipelineCount = 0
  private chatImagePipelineWaiters: Array<() => void> = []
  private mediaFileCacheCleanupPending: Promise<void> | null = null
  private mediaFileCacheLastCleanupAt = 0
  private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
  private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024
  private readonly mediaFileCacheMaxFiles = 120000
  private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000
  private emojiCaptionCache = new Map<string, string | null>()
  private emojiCaptionPending = new Map<string, Promise<string | null>>()
  private emojiMd5ByCdnCache = new Map<string, string | null>()
  private emojiMd5ByCdnPending = new Map<string, Promise<string | null>>()
  private emoticonDbPathCache: string | null = null
  private emoticonDbPathCacheToken = ''
  private readonly emojiCaptionLookupConcurrency = 8

  constructor() {
    this.configService = new ConfigService()
    // 限制缓存大小，防止内存泄漏
    this.contactCache = new LRUCache(500) // 最多缓存500个联系人
    this.inlineEmojiCache = new LRUCache(100) // 最多缓存100个表情
  }

  private createStopError(): Error {
    const error = new Error('导出任务已停止')
    ;(error as Error & { code?: string }).code = this.STOP_ERROR_CODE
    return error
  }

  private createPauseError(): Error {
    const error = new Error('导出任务已暂停')
    ;(error as Error & { code?: string }).code = this.PAUSE_ERROR_CODE
    return error
  }

  setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
    this.runtimeConfig = config
    imageDecryptService.setRuntimeConfig({
      dbPath: config?.dbPath,
      myWxid: config?.myWxid,
      imageXorKey: config?.imageXorKey,
      imageAesKey: config?.imageAesKey
    })
  }

  private getConfiguredDbPath(): string {
    return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
  }

  private getConfiguredMyWxid(): string {
    return String(this.runtimeConfig?.myWxid || this.configService.getMyWxidCleaned() || '').trim()
  }

  private normalizeSessionIds(sessionIds: string[]): string[] {
    return Array.from(
      new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))
    )
  }

  private normalizeTimestampSeconds(value: unknown): number {
    const raw = Number(value)
    if (!Number.isFinite(raw) || raw <= 0) return 0
    let normalized = Math.floor(raw)
    // 兼容毫秒/微秒/纳秒时间戳输入，统一降到秒级。
    while (normalized > 10000000000) {
      normalized = Math.floor(normalized / 1000)
    }
    return normalized
  }

  private normalizeExportDateRange(dateRange?: { start: number; end: number } | null): { start: number; end: number } | null {
    if (!dateRange) return null
    let start = this.normalizeTimestampSeconds(dateRange.start)
    let end = this.normalizeTimestampSeconds(dateRange.end)
    if (start > 0 && end > 0 && start > end) {
      const tmp = start
      start = end
      end = tmp
    }
    if (start <= 0 && end <= 0) return null
    return { start, end }
  }

  private normalizeMaxFileSizeMb(value: unknown): number | undefined {
    const raw = Number(value)
    if (!Number.isFinite(raw) || raw <= 0) return undefined
    return Math.floor(raw)
  }

  private normalizeExportOptionsForRun(options: ExportOptions): ExportOptions {
    const normalizedDateRange = this.normalizeExportDateRange(options.dateRange)
    const normalizedMaxFileSizeMb = this.normalizeMaxFileSizeMb(options.maxFileSizeMb)
    const normalizedWriteLayout = this.resolveExportWriteLayout(options)
    return {
      ...options,
      dateRange: normalizedDateRange,
      maxFileSizeMb: normalizedMaxFileSizeMb,
      exportWriteLayout: normalizedWriteLayout
    }
  }

  private resolveExportWriteLayout(options?: Pick<ExportOptions, 'exportWriteLayout'> | null): 'A' | 'B' | 'C' {
    const optionLayout = options?.exportWriteLayout
    if (optionLayout === 'A' || optionLayout === 'B' || optionLayout === 'C') return optionLayout
    const rawWriteLayout = this.configService.get('exportWriteLayout')
    return rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
      ? rawWriteLayout
      : 'B'
  }

  private getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string {
    const normalized = this.normalizeExportDateRange(dateRange)
    if (!normalized) return 'all'
    const start = normalized.start
    const end = normalized.end
    return `${start}-${end}`
  }

  private buildExportStatsCacheKey(
    sessionIds: string[],
    options: Pick<ExportOptions, 'dateRange' | 'senderUsername'>,
    cleanedWxid?: string
  ): string {
    const normalizedIds = this.normalizeSessionIds(sessionIds).sort()
    const senderToken = String(options.senderUsername || '').trim()
    const dateToken = this.getExportStatsDateRangeToken(options.dateRange)
    const dbPath = this.getConfiguredDbPath()
    const wxidToken = String(cleanedWxid || this.cleanAccountDirName(this.getConfiguredMyWxid()) || '').trim()
    return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}`
  }

  private cloneExportStatsResult(result: ExportStatsResult): ExportStatsResult {
    return {
      ...result,
      sessions: result.sessions.map((item) => ({ ...item }))
    }
  }

  private pruneExportStatsCaches(): void {
    const now = Date.now()
    for (const [key, entry] of this.exportStatsCache.entries()) {
      if (now - entry.createdAt > this.exportStatsCacheTtlMs) {
        this.exportStatsCache.delete(key)
      }
    }
    for (const [key, entry] of this.exportAggregatedSessionStatsCache.entries()) {
      if (now - entry.createdAt > this.exportAggregatedSessionStatsCacheTtlMs) {
        this.exportAggregatedSessionStatsCache.delete(key)
      }
    }
  }

  private getExportStatsCacheEntry(key: string): ExportStatsCacheEntry | null {
    this.pruneExportStatsCaches()
    const entry = this.exportStatsCache.get(key)
    if (!entry) return null
    if (Date.now() - entry.createdAt > this.exportStatsCacheTtlMs) {
      this.exportStatsCache.delete(key)
      return null
    }
    return entry
  }

  private setExportStatsCacheEntry(key: string, entry: ExportStatsCacheEntry): void {
    this.pruneExportStatsCaches()
    this.exportStatsCache.set(key, entry)
    if (this.exportStatsCache.size <= this.exportStatsCacheMaxEntries) return
    const staleKeys = Array.from(this.exportStatsCache.entries())
      .sort((a, b) => a[1].createdAt - b[1].createdAt)
      .slice(0, Math.max(0, this.exportStatsCache.size - this.exportStatsCacheMaxEntries))
      .map(([cacheKey]) => cacheKey)
    for (const staleKey of staleKeys) {
      this.exportStatsCache.delete(staleKey)
    }
  }

  private getAggregatedSessionStatsCache(key: string): Record<string, ExportAggregatedSessionMetric> | null {
    this.pruneExportStatsCaches()
    const entry = this.exportAggregatedSessionStatsCache.get(key)
    if (!entry) return null
    if (Date.now() - entry.createdAt > this.exportAggregatedSessionStatsCacheTtlMs) {
      this.exportAggregatedSessionStatsCache.delete(key)
      return null
    }
    return entry.data
  }

  private setAggregatedSessionStatsCache(
    key: string,
    data: Record<string, ExportAggregatedSessionMetric>
  ): void {
    this.pruneExportStatsCaches()
    this.exportAggregatedSessionStatsCache.set(key, {
      createdAt: Date.now(),
      data
    })
    if (this.exportAggregatedSessionStatsCache.size <= this.exportStatsCacheMaxEntries) return
    const staleKeys = Array.from(this.exportAggregatedSessionStatsCache.entries())
      .sort((a, b) => a[1].createdAt - b[1].createdAt)
      .slice(0, Math.max(0, this.exportAggregatedSessionStatsCache.size - this.exportStatsCacheMaxEntries))
      .map(([cacheKey]) => cacheKey)
    for (const staleKey of staleKeys) {
      this.exportAggregatedSessionStatsCache.delete(staleKey)
    }
  }

  private isStopError(error: unknown): boolean {
    if (!error) return false
    if (typeof error === 'string') {
      return error.includes(this.STOP_ERROR_CODE) || error.includes('导出任务已停止')
    }
    if (error instanceof Error) {
      const code = (error as Error & { code?: string }).code
      return code === this.STOP_ERROR_CODE || error.message.includes(this.STOP_ERROR_CODE) || error.message.includes('导出任务已停止')
    }
    return false
  }

  private isPauseError(error: unknown): boolean {
    if (!error) return false
    if (typeof error === 'string') {
      return error.includes(this.PAUSE_ERROR_CODE) || error.includes('导出任务已暂停')
    }
    if (error instanceof Error) {
      const code = (error as Error & { code?: string }).code
      return code === this.PAUSE_ERROR_CODE || error.message.includes(this.PAUSE_ERROR_CODE) || error.message.includes('导出任务已暂停')
    }
    return false
  }

  private throwIfStopRequested(control?: ExportTaskControl): void {
    if (control?.shouldStop?.()) {
      throw this.createStopError()
    }
    if (control?.shouldPause?.()) {
      throw this.createPauseError()
    }
  }

  private async ensureExportDir(dirPath: string, control?: ExportTaskControl, dirCache?: Set<string>): Promise<void> {
    if (dirCache?.has(dirPath)) return
    const existed = await this.pathExists(dirPath)
    await fs.promises.mkdir(dirPath, { recursive: true })
    dirCache?.add(dirPath)
    if (!existed) {
      control?.recordCreatedDir?.(dirPath)
    }
  }

  private async recordCreatedFileBeforeWrite(filePath: string, control?: ExportTaskControl): Promise<void> {
    if (!control?.recordCreatedFile) return
    if (!await this.pathExists(filePath)) {
      control.recordCreatedFile(filePath)
    }
  }

  private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
    if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
    const raw = Math.floor(value)
    return Math.max(1, Math.min(raw, max))
  }

  private createProgressEmitter(onProgress?: (progress: ExportProgress) => void): {
    emit: (progress: ExportProgress, options?: { force?: boolean }) => void
    flush: () => void
  } {
    if (!onProgress) {
      return {
        emit: () => { /* noop */ },
        flush: () => { /* noop */ }
      }
    }

    let pending: ExportProgress | null = null
    let lastSentAt = 0
    let lastPhase = ''
    let lastSessionId = ''
    let lastCollected = 0
    let lastExported = 0
    const MIN_PROGRESS_EMIT_INTERVAL_MS = 400
    const MESSAGE_PROGRESS_DELTA_THRESHOLD = 1200

    const commit = (progress: ExportProgress) => {
      onProgress(progress)
      pending = null
      lastSentAt = Date.now()
      lastPhase = String(progress.phase || '')
      lastSessionId = String(progress.currentSessionId || '')
      lastCollected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected
      lastExported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported
    }

    const emit = (progress: ExportProgress, options?: { force?: boolean }) => {
      pending = progress
      const force = options?.force === true
      const now = Date.now()
      const phase = String(progress.phase || '')
      const sessionId = String(progress.currentSessionId || '')
      const collected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected
      const exported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported
      const collectedDelta = Math.abs(collected - lastCollected)
      const exportedDelta = Math.abs(exported - lastExported)
      const shouldEmit = force ||
        phase !== lastPhase ||
        sessionId !== lastSessionId ||
        collectedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD ||
        exportedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD ||
        (now - lastSentAt >= MIN_PROGRESS_EMIT_INTERVAL_MS)

      if (shouldEmit && pending) {
        commit(pending)
      }
    }

    const flush = () => {
      if (!pending) return
      commit(pending)
    }

    return { emit, flush }
  }

  private async pathExists(filePath: string): Promise<boolean> {
    try {
      await fs.promises.access(filePath, fs.constants.F_OK)
      return true
    } catch {
      return false
    }
  }

  private sanitizeExportFileNamePart(value: string): string {
    return String(value || '')
      .replace(/[<>:"\/\\|?*]/g, '_')
      .replace(/\.+$/, '')
      .trim()
  }

  private resolveFileAttachmentExtensionDir(msg: any, fileName: string): string {
    const rawExt = String(msg?.fileExt || '').trim() || path.extname(String(fileName || ''))
    const normalizedExt = rawExt.replace(/^\.+/, '').trim().toLowerCase()
    const safeExt = this.sanitizeExportFileNamePart(normalizedExt).replace(/\s+/g, '_')
    return safeExt || 'no-extension'
  }

  private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' {
    return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
  }

  private formatDateTokenBySeconds(seconds?: number): string | null {
    const normalizedSeconds = this.normalizeTimestampSeconds(seconds)
    if (normalizedSeconds <= 0) return null
    const date = new Date(normalizedSeconds * 1000)
    if (Number.isNaN(date.getTime())) return null
    const y = date.getFullYear()
    const m = `${date.getMonth() + 1}`.padStart(2, '0')
    const d = `${date.getDate()}`.padStart(2, '0')
    return `${y}${m}${d}`
  }

  private buildDateRangeFileNamePart(dateRange?: { start: number; end: number } | null): string {
    const start = this.formatDateTokenBySeconds(dateRange?.start)
    const end = this.formatDateTokenBySeconds(dateRange?.end)
    if (start && end) {
      if (start === end) return start
      return start < end ? `${start}-${end}` : `${end}-${start}`
    }
    if (start) return `${start}-至今`
    if (end) return `截至-${end}`
    return '全部时间'
  }

  private buildSessionExportBaseName(
    sessionId: string,
    displayName: string,
    options: ExportOptions
  ): string {
    const baseName = this.sanitizeExportFileNamePart(displayName || sessionId) || this.sanitizeExportFileNamePart(sessionId) || 'session'
    const suffix = this.sanitizeExportFileNamePart(options.fileNameSuffix || '')
    const namingMode = this.normalizeFileNamingMode(options.fileNamingMode)
    const parts = [baseName]
    if (suffix) parts.push(suffix)
    if (namingMode === 'date-range') {
      parts.push(this.buildDateRangeFileNamePart(options.dateRange))
    }
    return this.sanitizeExportFileNamePart(parts.join('_')) || 'session'
  }

  private async reserveUniqueOutputPath(preferredPath: string, reservedPaths: Set<string>): Promise<string> {
    const dir = path.dirname(preferredPath)
    const ext = path.extname(preferredPath)
    const base = path.basename(preferredPath, ext)

    for (let attempt = 0; attempt < 10000; attempt += 1) {
      const candidate = attempt === 0
        ? preferredPath
        : path.join(dir, `${base}_${attempt + 1}${ext}`)

      if (reservedPaths.has(candidate)) continue

      const exists = await this.pathExists(candidate)
      if (reservedPaths.has(candidate)) continue
      if (exists) continue

      reservedPaths.add(candidate)
      return candidate
    }

    const fallback = path.join(dir, `${base}_${Date.now()}${ext}`)
    reservedPaths.add(fallback)
    return fallback
  }

  private isCloneUnsupportedError(code: string | undefined): boolean {
    return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY'
  }

  private async copyFileOptimized(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string }> {
    const cloneFlag = typeof fs.constants.COPYFILE_FICLONE === 'number' ? fs.constants.COPYFILE_FICLONE : 0
    try {
      if (cloneFlag) {
        await fs.promises.copyFile(sourcePath, destPath, cloneFlag)
      } else {
        await fs.promises.copyFile(sourcePath, destPath)
      }
      return { success: true }
    } catch (e) {
      const code = (e as NodeJS.ErrnoException | undefined)?.code
      if (!this.isCloneUnsupportedError(code)) {
        return { success: false, code }
      }
    }

    try {
      await fs.promises.copyFile(sourcePath, destPath)
      return { success: true }
    } catch (e) {
      return { success: false, code: (e as NodeJS.ErrnoException | undefined)?.code }
    }
  }

  private getMediaFileCacheRoot(): string {
    return path.join(this.configService.getCacheBasePath(), 'export-media-files')
  }

  private createEmptyMediaTelemetry(): MediaExportTelemetry {
    return {
      doneFiles: 0,
      cacheHitFiles: 0,
      cacheMissFiles: 0,
      cacheFillFiles: 0,
      dedupReuseFiles: 0,
      bytesWritten: 0
    }
  }

  private resetMediaRuntimeState(): void {
    this.mediaExportTelemetry = this.createEmptyMediaTelemetry()
    this.mediaRunSourceDedupMap.clear()
    this.mediaRunMissingImageKeys.clear()
  }

  private clearMediaRuntimeState(): void {
    this.mediaExportTelemetry = null
    this.mediaRunSourceDedupMap.clear()
    this.mediaRunMissingImageKeys.clear()
  }

  private async runWithChatImagePipelineLimit<T>(fn: () => Promise<T>): Promise<T> {
    while (this.activeChatImagePipelineCount >= 2) {
      await new Promise<void>((resolve) => this.chatImagePipelineWaiters.push(resolve))
    }
    this.activeChatImagePipelineCount += 1
    try {
      return await fn()
    } finally {
      this.activeChatImagePipelineCount = Math.max(0, this.activeChatImagePipelineCount - 1)
      const next = this.chatImagePipelineWaiters.shift()
      if (next) next()
    }
  }

  private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
    const stats = this.mediaExportTelemetry
    if (!stats) return {}
    return {
      mediaDoneFiles: stats.doneFiles,
      mediaCacheHitFiles: stats.cacheHitFiles,
      mediaCacheMissFiles: stats.cacheMissFiles,
      mediaCacheFillFiles: stats.cacheFillFiles,
      mediaDedupReuseFiles: stats.dedupReuseFiles,
      mediaBytesWritten: stats.bytesWritten
    }
  }

  private noteMediaTelemetry(delta: Partial<MediaExportTelemetry>): void {
    if (!this.mediaExportTelemetry) return
    if (Number.isFinite(delta.doneFiles)) {
      this.mediaExportTelemetry.doneFiles += Math.max(0, Math.floor(Number(delta.doneFiles || 0)))
    }
    if (Number.isFinite(delta.cacheHitFiles)) {
      this.mediaExportTelemetry.cacheHitFiles += Math.max(0, Math.floor(Number(delta.cacheHitFiles || 0)))
    }
    if (Number.isFinite(delta.cacheMissFiles)) {
      this.mediaExportTelemetry.cacheMissFiles += Math.max(0, Math.floor(Number(delta.cacheMissFiles || 0)))
    }
    if (Number.isFinite(delta.cacheFillFiles)) {
      this.mediaExportTelemetry.cacheFillFiles += Math.max(0, Math.floor(Number(delta.cacheFillFiles || 0)))
    }
    if (Number.isFinite(delta.dedupReuseFiles)) {
      this.mediaExportTelemetry.dedupReuseFiles += Math.max(0, Math.floor(Number(delta.dedupReuseFiles || 0)))
    }
    if (Number.isFinite(delta.bytesWritten)) {
      this.mediaExportTelemetry.bytesWritten += Math.max(0, Math.floor(Number(delta.bytesWritten || 0)))
    }
  }

  private async ensureMediaFileCacheDir(dirPath: string): Promise<void> {
    if (this.mediaFileCacheReadyDirs.has(dirPath)) return
    await fs.promises.mkdir(dirPath, { recursive: true })
    this.mediaFileCacheReadyDirs.add(dirPath)
  }

  private async getMediaFileStat(sourcePath: string): Promise<{ size: number; mtimeMs: number } | null> {
    try {
      const stat = await fs.promises.stat(sourcePath)
      if (!stat.isFile()) return null
      return {
        size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0,
        mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0
      }
    } catch {
      return null
    }
  }

  private buildMediaFileCachePath(
    kind: 'image' | 'video' | 'emoji',
    sourcePath: string,
    fileStat: { size: number; mtimeMs: number }
  ): string {
    const normalizedSource = path.resolve(sourcePath)
    const rawKey = `${kind}\u001f${normalizedSource}\u001f${fileStat.size}\u001f${fileStat.mtimeMs}`
    const digest = crypto.createHash('sha1').update(rawKey).digest('hex')
    const ext = path.extname(normalizedSource) || ''
    return path.join(this.getMediaFileCacheRoot(), kind, digest.slice(0, 2), `${digest}${ext}`)
  }

  private async resolveMediaFileCachePath(
    kind: 'image' | 'video' | 'emoji',
    sourcePath: string
  ): Promise<{ cachePath: string; fileStat: { size: number; mtimeMs: number } } | null> {
    const fileStat = await this.getMediaFileStat(sourcePath)
    if (!fileStat) return null
    const cachePath = this.buildMediaFileCachePath(kind, sourcePath, fileStat)
    return { cachePath, fileStat }
  }

  private async populateMediaFileCache(
    kind: 'image' | 'video' | 'emoji',
    sourcePath: string
  ): Promise<string | null> {
    const resolved = await this.resolveMediaFileCachePath(kind, sourcePath)
    if (!resolved) return null
    const { cachePath } = resolved
    if (await this.pathExists(cachePath)) return cachePath

    const pending = this.mediaFileCachePopulatePending.get(cachePath)
    if (pending) return pending

    const task = (async () => {
      try {
        await this.ensureMediaFileCacheDir(path.dirname(cachePath))
        if (await this.pathExists(cachePath)) return cachePath

        const tempPath = `${cachePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
        const copied = await this.copyFileOptimized(sourcePath, tempPath)
        if (!copied.success) {
          await fs.promises.rm(tempPath, { force: true }).catch(() => { })
          return null
        }
        await fs.promises.rename(tempPath, cachePath).catch(async (error) => {
          const code = (error as NodeJS.ErrnoException | undefined)?.code
          if (code === 'EEXIST') {
            await fs.promises.rm(tempPath, { force: true }).catch(() => { })
            return
          }
          await fs.promises.rm(tempPath, { force: true }).catch(() => { })
          throw error
        })
        this.noteMediaTelemetry({ cacheFillFiles: 1 })
        return cachePath
      } catch {
        return null
      } finally {
        this.mediaFileCachePopulatePending.delete(cachePath)
      }
    })()

    this.mediaFileCachePopulatePending.set(cachePath, task)
    return task
  }

  private async resolvePreferredMediaSource(
    kind: 'image' | 'video' | 'emoji',
    sourcePath: string
  ): Promise<MediaSourceResolution> {
    const resolved = await this.resolveMediaFileCachePath(kind, sourcePath)
    if (!resolved) {
      return {
        sourcePath,
        cacheHit: false
      }
    }
    const dedupeKey = `${kind}\u001f${resolved.cachePath}`
    if (await this.pathExists(resolved.cachePath)) {
      return {
        sourcePath: resolved.cachePath,
        cacheHit: true,
        cachePath: resolved.cachePath,
        fileStat: resolved.fileStat,
        dedupeKey
      }
    }
    // 未命中缓存时异步回填，不阻塞当前导出路径
    void this.populateMediaFileCache(kind, sourcePath)
    return {
      sourcePath,
      cacheHit: false,
      cachePath: resolved.cachePath,
      fileStat: resolved.fileStat,
      dedupeKey
    }
  }

  private isHardlinkFallbackError(code: string | undefined): boolean {
    return code === 'EXDEV' || code === 'EPERM' || code === 'EACCES' || code === 'EINVAL' || code === 'ENOSYS' || code === 'ENOTSUP'
  }

  private async hardlinkOrCopyFile(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string; linked?: boolean }> {
    try {
      await fs.promises.link(sourcePath, destPath)
      return { success: true, linked: true }
    } catch (error) {
      const code = (error as NodeJS.ErrnoException | undefined)?.code
      if (code === 'EEXIST') {
        return { success: true, linked: true }
      }
      if (!this.isHardlinkFallbackError(code)) {
        return { success: false, code }
      }
    }

    const copied = await this.copyFileOptimized(sourcePath, destPath)
    if (!copied.success) return copied
    return { success: true, linked: false }
  }

  private async copyMediaWithCacheAndDedup(
    kind: 'image' | 'video' | 'emoji',
    sourcePath: string,
    destPath: string,
    control?: ExportTaskControl
  ): Promise<{ success: boolean; code?: string }> {
    const existedBeforeCopy = await this.pathExists(destPath)
    const resolved = await this.resolvePreferredMediaSource(kind, sourcePath)
    if (resolved.cacheHit) {
      this.noteMediaTelemetry({ cacheHitFiles: 1 })
    } else {
      this.noteMediaTelemetry({ cacheMissFiles: 1 })
    }

    const dedupeKey = resolved.dedupeKey
    if (dedupeKey) {
      const reusedPath = this.mediaRunSourceDedupMap.get(dedupeKey)
      if (reusedPath && reusedPath !== destPath && await this.pathExists(reusedPath)) {
        const reused = await this.hardlinkOrCopyFile(reusedPath, destPath)
        if (!reused.success) return reused
        this.noteMediaTelemetry({
          doneFiles: 1,
          dedupReuseFiles: 1,
          bytesWritten: resolved.fileStat?.size || 0
        })
        if (!existedBeforeCopy) {
          control?.recordCreatedFile?.(destPath)
        }
        return { success: true }
      }
    }

    const copied = resolved.cacheHit
      ? await this.hardlinkOrCopyFile(resolved.sourcePath, destPath)
      : await this.copyFileOptimized(resolved.sourcePath, destPath)
    if (!copied.success) return copied

    if (dedupeKey) {
      this.mediaRunSourceDedupMap.set(dedupeKey, destPath)
    }
    this.noteMediaTelemetry({
      doneFiles: 1,
      bytesWritten: resolved.fileStat?.size || 0
    })
    if (!existedBeforeCopy) {
      control?.recordCreatedFile?.(destPath)
    }
    return { success: true }
  }

  private triggerMediaFileCacheCleanup(force = false): void {
    const now = Date.now()
    if (!force && now - this.mediaFileCacheLastCleanupAt < this.mediaFileCacheCleanupIntervalMs) return
    if (this.mediaFileCacheCleanupPending) return
    this.mediaFileCacheLastCleanupAt = now

    this.mediaFileCacheCleanupPending = this.cleanupMediaFileCache().finally(() => {
      this.mediaFileCacheCleanupPending = null
    })
  }

  private async cleanupMediaFileCache(): Promise<void> {
    const root = this.getMediaFileCacheRoot()
    if (!await this.pathExists(root)) return
    const now = Date.now()
    const files: Array<{ filePath: string; size: number; mtimeMs: number }> = []
    const dirs: string[] = []

    const stack = [root]
    while (stack.length > 0) {
      const current = stack.pop() as string
      dirs.push(current)
      let entries: fs.Dirent[]
      try {
        entries = await fs.promises.readdir(current, { withFileTypes: true })
      } catch {
        continue
      }
      for (const entry of entries) {
        const entryPath = path.join(current, entry.name)
        if (entry.isDirectory()) {
          stack.push(entryPath)
          continue
        }
        if (!entry.isFile()) continue
        try {
          const stat = await fs.promises.stat(entryPath)
          if (!stat.isFile()) continue
          files.push({
            filePath: entryPath,
            size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0,
            mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0
          })
        } catch { }
      }
    }

    if (files.length === 0) return

    let totalBytes = files.reduce((sum, item) => sum + item.size, 0)
    let totalFiles = files.length
    const ttlThreshold = now - this.mediaFileCacheTtlMs
    const removalSet = new Set<string>()

    for (const item of files) {
      if (item.mtimeMs > 0 && item.mtimeMs < ttlThreshold) {
        removalSet.add(item.filePath)
        totalBytes -= item.size
        totalFiles -= 1
      }
    }

    if (totalBytes > this.mediaFileCacheMaxBytes || totalFiles > this.mediaFileCacheMaxFiles) {
      const ordered = files
        .filter((item) => !removalSet.has(item.filePath))
        .sort((a, b) => a.mtimeMs - b.mtimeMs)
      for (const item of ordered) {
        if (totalBytes <= this.mediaFileCacheMaxBytes && totalFiles <= this.mediaFileCacheMaxFiles) break
        removalSet.add(item.filePath)
        totalBytes -= item.size
        totalFiles -= 1
      }
    }

    if (removalSet.size === 0) return

    for (const filePath of removalSet) {
      await fs.promises.rm(filePath, { force: true }).catch(() => { })
    }

    dirs.sort((a, b) => b.length - a.length)
    for (const dirPath of dirs) {
      if (dirPath === root) continue
      await fs.promises.rmdir(dirPath).catch(() => { })
    }
  }

  private isMediaExportEnabled(options: ExportOptions): boolean {
    return options.exportMedia === true &&
      Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
  }

  private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean {
    return this.normalizeExportDateRange(dateRange) === null
  }

  private shouldUseFastTextCollection(options: ExportOptions): boolean {
    // 文本批量导出优先走轻量采集：不做媒体字段预提取，减少 CPU 与内存占用
    return !this.isMediaExportEnabled(options)
  }

  private getMediaContentType(options: ExportOptions): MediaContentType | null {
    const value = options.contentType
    if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji' || value === 'file') {
      return value
    }
    return null
  }

  private isMediaContentBatchExport(options: ExportOptions): boolean {
    return this.getMediaContentType(options) !== null
  }

  private getTargetMediaLocalTypes(options: ExportOptions): Set<number> {
    const mediaContentType = this.getMediaContentType(options)
    if (mediaContentType === 'voice') return new Set([34])
    if (mediaContentType === 'image') return new Set([3])
    if (mediaContentType === 'video') return new Set([43])
    if (mediaContentType === 'emoji') return new Set([47])
    if (mediaContentType === 'file') return new Set(FILE_APP_LOCAL_TYPES)

    const selected = new Set<number>()
    if (options.exportImages) selected.add(3)
    if (options.exportVoices) selected.add(34)
    if (options.exportVideos) selected.add(43)
    if (options.exportFiles) {
      for (const fileType of FILE_APP_LOCAL_TYPES) {
        selected.add(fileType)
      }
    }
    return selected
  }

  private isFileAppLocalType(localType: number): boolean {
    return FILE_APP_LOCAL_TYPE_SET.has(localType)
  }

  private isFileOnlyMediaFilter(targetMediaTypes: Set<number> | null): boolean {
    return Boolean(
      targetMediaTypes &&
      targetMediaTypes.size === FILE_APP_LOCAL_TYPES.length &&
      FILE_APP_LOCAL_TYPES.every((fileType) => targetMediaTypes.has(fileType))
    )
  }

  private getFileAppMessageHints(message: Record<string, any> | null | undefined): {
    xmlType?: string
    fileName?: string
    fileSize?: number
    fileExt?: string
    fileMd5?: string
  } {
    const xmlType = String(message?.xmlType ?? message?.xml_type ?? '').trim() || undefined
    const fileName = String(message?.fileName ?? message?.file_name ?? '').trim() || undefined
    const fileExt = String(message?.fileExt ?? message?.file_ext ?? '').trim() || undefined
    const fileSizeRaw = Number(message?.fileSize ?? message?.file_size ?? message?.total_len ?? message?.totalLen ?? message?.totallen ?? 0)
    const fileSize = Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : undefined
    const fileMd5Raw = String(message?.fileMd5 ?? message?.file_md5 ?? '').trim()
    const fileMd5 = /^[a-f0-9]{32}$/i.test(fileMd5Raw) ? fileMd5Raw.toLowerCase() : undefined
    return { xmlType, fileName, fileSize, fileExt, fileMd5 }
  }

  private hasFileAppMessageHints(message: Record<string, any> | null | undefined): boolean {
    const hints = this.getFileAppMessageHints(message)
    if (hints.xmlType) return hints.xmlType === '6'
    return Boolean(hints.fileName || hints.fileExt || hints.fileMd5 || hints.fileSize)
  }

  private isFileAppMessage(msg: {
    localType?: unknown
    xmlType?: unknown
    xml_type?: unknown
    content?: unknown
    fileName?: unknown
    file_name?: unknown
    fileSize?: unknown
    file_size?: unknown
    fileExt?: unknown
    file_ext?: unknown
    fileMd5?: unknown
    file_md5?: unknown
  }): boolean {
    const { xmlType, fileName, fileExt, fileMd5, fileSize } = this.getFileAppMessageHints(msg as Record<string, any>)
    if (xmlType) return xmlType === '6'
    if (fileName || fileExt || fileMd5 || fileSize) return true

    const normalized = this.normalizeAppMessageContent(String(msg?.content || ''))
    if (!normalized || (!normalized.includes('<appmsg') && !normalized.includes('<msg>'))) {
      return false
    }
    return this.extractAppMessageType(normalized) === '6'
  }

  private extractFileAppMessageMeta(content: string): {
    xmlType?: string
    fileName?: string
    fileSize?: number
    fileExt?: string
    fileMd5?: string
  } | null {
    const normalized = this.normalizeAppMessageContent(content || '')
    if (!normalized || (!normalized.includes('<appmsg') && !normalized.includes('<msg>'))) {
      return null
    }

    const xmlType = this.extractAppMessageType(normalized)
    if (!xmlType) return null

    const rawFileName = this.extractXmlValue(normalized, 'filename') || this.extractXmlValue(normalized, 'title')
    const rawFileExt = this.extractXmlValue(normalized, 'fileext')
    const rawFileSize =
      this.extractXmlValue(normalized, 'totallen') ||
      this.extractXmlValue(normalized, 'datasize') ||
      this.extractXmlValue(normalized, 'filesize')
    const rawFileMd5 =
      this.extractXmlValue(normalized, 'md5') ||
      this.extractXmlAttribute(normalized, 'appattach', 'md5') ||
      this.extractLooseHexMd5(normalized)
    const fileSize = Number.parseInt(rawFileSize, 10)
    const fileMd5 = String(rawFileMd5 || '').trim()

    return {
      xmlType,
      fileName: this.decodeHtmlEntities(rawFileName).trim() || undefined,
      fileSize: Number.isFinite(fileSize) && fileSize > 0 ? fileSize : undefined,
      fileExt: this.decodeHtmlEntities(rawFileExt).trim() || undefined,
      fileMd5: /^[a-f0-9]{32}$/i.test(fileMd5) ? fileMd5.toLowerCase() : undefined
    }
  }

  private resolveCollectMode(options: ExportOptions): MessageCollectMode {
    if (this.isMediaContentBatchExport(options)) {
      return 'media-fast'
    }
    return this.shouldUseFastTextCollection(options) ? 'text-fast' : 'full'
  }

  private resolveCollectParams(options: ExportOptions): { mode: MessageCollectMode; targetMediaTypes?: Set<number> } {
    const mode = this.resolveCollectMode(options)
    if (mode === 'media-fast') {
      const targetMediaTypes = this.getTargetMediaLocalTypes(options)
      if (targetMediaTypes.size > 0) {
        return { mode, targetMediaTypes }
      }
    }
    return { mode }
  }

  private resolveFastMediaStreamType(
    collectMode: MessageCollectMode,
    targetMediaTypes: Set<number> | null
  ): 'image' | 'video' | null {
    if (collectMode !== 'media-fast' || !targetMediaTypes || targetMediaTypes.size !== 1) return null
    if (targetMediaTypes.has(3)) return 'image'
    if (targetMediaTypes.has(43)) return 'video'
    return null
  }

  private async collectMessagesByFastMediaStream(
    sessionId: string,
    cleanedMyWxid: string,
    normalizedDateRange: { start: number; end: number } | null,
    useCursorTimeRange: boolean,
    normalizedSenderUsernameFilter: string,
    mediaType: 'image' | 'video',
    onCollectProgress?: (payload: { fetched: number }) => void,
    control?: ExportTaskControl
  ): Promise<{
    success: boolean
    rows: any[]
    senderUsernames: string[]
    firstTime: number | null
    lastTime: number | null
    error?: string
  }> {
    const rows: any[] = []
    const senderSet = new Set<string>()
    let firstTime: number | null = null
    let lastTime: number | null = null
    let offset = 0
    let hasMore = true
    const PAGE_LIMIT = 480

    while (hasMore) {
      this.throwIfStopRequested(control)
      const streamResult = await wcdbService.getMediaStream({
        sessionId,
        mediaType,
        beginTimestamp: useCursorTimeRange ? (normalizedDateRange?.start || 0) : 0,
        endTimestamp: useCursorTimeRange ? (normalizedDateRange?.end || 0) : 0,
        limit: PAGE_LIMIT,
        offset
      })
      if (!streamResult.success) {
        return {
          success: false,
          rows,
          senderUsernames: Array.from(senderSet),
          firstTime,
          lastTime,
          error: streamResult.error || '媒体快速流读取失败'
        }
      }

      const items = Array.isArray(streamResult.items) ? streamResult.items : []
      if (items.length === 0) {
        hasMore = false
        break
      }

      for (const item of items) {
        const createTime = this.normalizeRowTimestampSeconds(item?.createTime)
        if (normalizedDateRange) {
          if (createTime > 0 && normalizedDateRange.start > 0 && createTime < normalizedDateRange.start) continue
          if (createTime > 0 && normalizedDateRange.end > 0 && createTime > normalizedDateRange.end) continue
        }

        const localTypeRaw = Number(item?.localType || 0)
        let localType = Number.isFinite(localTypeRaw) ? Math.floor(localTypeRaw) : 0
        if (localType <= 0) {
          localType = mediaType === 'video' ? 43 : 3
        }
        const isSend = Number(item?.isSend) === 1
        const senderUsernameRaw = String(item?.senderUsername || '').trim()
        const actualSender = isSend ? cleanedMyWxid : (senderUsernameRaw || sessionId)
        if (normalizedSenderUsernameFilter && !this.isSameWxid(actualSender, normalizedSenderUsernameFilter)) {
          continue
        }
        senderSet.add(actualSender)

        const localIdRaw = Number(item?.localId || 0)
        const localId = Number.isFinite(localIdRaw) ? Math.floor(localIdRaw) : 0
        const serverIdRawToken = this.normalizeUnsignedIntToken(item?.serverId)
        const serverIdValue = Number.parseInt(serverIdRawToken, 10)

        const imageMd5 = String(item?.imageMd5 || '').trim().toLowerCase()
        const imageDatName = String(item?.imageDatName || '').trim().toLowerCase()
        const videoMd5 = String(item?.videoMd5 || '').trim().toLowerCase()

        rows.push({
          localId,
          serverId: Number.isFinite(serverIdValue) ? serverIdValue : 0,
          serverIdRaw: serverIdRawToken !== '0' ? serverIdRawToken : undefined,
          createTime,
          localType,
          content: String(item?.content || ''),
          senderUsername: actualSender,
          isSend,
          imageMd5: imageMd5 || undefined,
          imageDatName: imageDatName || undefined,
          videoMd5: videoMd5 || undefined
        })

        if (createTime > 0) {
          if (firstTime === null || createTime < firstTime) firstTime = createTime
          if (lastTime === null || createTime > lastTime) lastTime = createTime
        }
      }

      onCollectProgress?.({ fetched: rows.length })
      const nextOffset = Number(streamResult.nextOffset)
      const safeNextOffset = Number.isFinite(nextOffset) && nextOffset > offset
        ? Math.floor(nextOffset)
        : offset + items.length
      offset = safeNextOffset
      hasMore = Boolean(streamResult.hasMore) && items.length > 0
    }

    return {
      success: true,
      rows,
      senderUsernames: Array.from(senderSet),
      firstTime,
      lastTime
    }
  }

  private createCollectProgressReporter(
    sessionName: string,
    onProgress?: (progress: ExportProgress) => void,
    progressCurrent = 5
  ): ((payload: { fetched: number }) => void) | undefined {
    if (!onProgress) return undefined
    let lastReportAt = 0
    return ({ fetched }) => {
      const now = Date.now()
      if (now - lastReportAt < 350) return
      lastReportAt = now
      onProgress({
        current: progressCurrent,
        total: 100,
        currentSession: sessionName,
        phase: 'preparing',
        phaseLabel: `收集消息 ${fetched.toLocaleString()} 条`,
        collectedMessages: fetched
      })
    }
  }

  private shouldDecodeMessageContentInFastMode(localType: number): boolean {
    // 这些类型在文本导出里只需要占位符，无需解码完整 XML / 压缩内容
    if (localType === 3 || localType === 34 || localType === 42 || localType === 43) {
      return false
    }
    return true
  }

  private shouldDecodeMessageContentInMediaMode(
    localType: number,
    targetMediaTypes: Set<number> | null,
    options?: { allowFileProbe?: boolean }
  ): boolean {
    const allowFileProbe = options?.allowFileProbe === true
    if (!targetMediaTypes || (!targetMediaTypes.has(localType) && !allowFileProbe)) return false
    // 语音导出仅需要 localId 读取音频数据，不依赖 XML 内容
    if (localType === 34) return false
    // 图片/视频/表情/文件可能需要从 XML 提取 md5/datName/附件信息
    if (localType === 3 || localType === 43 || localType === 47 || this.isFileAppLocalType(localType) || allowFileProbe) return true
    return false
  }

  private cleanAccountDirName(dirName: string): string {
    const trimmed = dirName.trim()
    if (!trimmed) return trimmed
    if (trimmed.toLowerCase().startsWith('wxid_')) {
      const match = trimmed.match(/^(wxid_[^_]+)/i)
      if (match) return match[1]
      return trimmed
    }
    const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
    const cleaned = suffixMatch ? suffixMatch[1] : trimmed

    return cleaned
  }

  private getIntFromRow(row: Record<string, any>, keys: string[], fallback = 0): number {
    for (const key of keys) {
      const raw = row?.[key]
      if (raw === undefined || raw === null || raw === '') continue
      const parsed = Number.parseInt(String(raw), 10)
      if (Number.isFinite(parsed)) return parsed
    }
    return fallback
  }

  private parseDateTimeTextToSeconds(value: string): number {
    const raw = String(value || '').trim()
    if (!raw) return 0
    const compactDigits = this.parseCompactDateTimeDigitsToSeconds(raw)
    if (compactDigits > 0) return compactDigits

    // 优先处理带时区信息的格式（例如 2026-04-22T21:33:12Z / +08:00）
    if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(raw)) {
      const parsed = Date.parse(raw)
      const seconds = Math.floor(parsed / 1000)
      if (Number.isFinite(seconds) && seconds > 0) return seconds
    }

    const normalized = raw.replace('T', ' ').replace(/\.\d+$/, '').replace(/\//g, '-')
    const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ ](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/)
    if (!match) return 0
    const year = Number.parseInt(match[1], 10)
    const month = Number.parseInt(match[2], 10)
    const day = Number.parseInt(match[3], 10)
    const hour = Number.parseInt(match[4] || '0', 10)
    const minute = Number.parseInt(match[5] || '0', 10)
    const second = Number.parseInt(match[6] || '0', 10)
    if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return 0
    const dt = new Date(year, month - 1, day, hour, minute, second)
    const ts = Math.floor(dt.getTime() / 1000)
    return Number.isFinite(ts) && ts > 0 ? ts : 0
  }

  private parseCompactDateTimeDigitsToSeconds(value: string): number {
    const raw = String(value || '').trim()
    if (!/^\d{8}(?:\d{4}(?:\d{2})?)?$/.test(raw)) return 0

    const year = Number.parseInt(raw.slice(0, 4), 10)
    const month = Number.parseInt(raw.slice(4, 6), 10)
    const day = Number.parseInt(raw.slice(6, 8), 10)
    const hour = raw.length >= 12 ? Number.parseInt(raw.slice(8, 10), 10) : 0
    const minute = raw.length >= 12 ? Number.parseInt(raw.slice(10, 12), 10) : 0
    const second = raw.length >= 14 ? Number.parseInt(raw.slice(12, 14), 10) : 0

    if (!Number.isFinite(year) || year < 1990 || year > 2200) return 0
    if (!Number.isFinite(month) || month < 1 || month > 12) return 0
    if (!Number.isFinite(day) || day < 1 || day > 31) return 0
    if (!Number.isFinite(hour) || hour < 0 || hour > 23) return 0
    if (!Number.isFinite(minute) || minute < 0 || minute > 59) return 0
    if (!Number.isFinite(second) || second < 0 || second > 59) return 0

    const dt = new Date(year, month - 1, day, hour, minute, second)
    if (
      dt.getFullYear() !== year ||
      dt.getMonth() !== month - 1 ||
      dt.getDate() !== day ||
      dt.getHours() !== hour ||
      dt.getMinutes() !== minute ||
      dt.getSeconds() !== second
    ) {
      return 0
    }

    const ts = Math.floor(dt.getTime() / 1000)
    return Number.isFinite(ts) && ts > 0 ? ts : 0
  }

  private normalizeRowTimestampSeconds(value: unknown): number {
    if (value === undefined || value === null || value === '') return 0
    const rawText = String(value || '').trim()
    if (!rawText) return 0

    // 纯数字且看起来是年月日时间串时，优先按日期解析，避免误当作毫秒。
    const compactDigits = this.parseCompactDateTimeDigitsToSeconds(rawText)
    if (compactDigits > 0) return compactDigits

    const numeric = Number(rawText)
    if (Number.isFinite(numeric) && numeric > 0) {
      return this.normalizeTimestampSeconds(numeric)
    }

    return this.parseDateTimeTextToSeconds(rawText)
  }

  private getTimestampSecondsFromRow(row: Record<string, any>): number {
    const rawPrimary = this.getRowField(row, [
      'create_time', 'createTime', 'createtime',
      'msg_create_time', 'msgCreateTime',
      'msg_time', 'msgTime', 'time',
      'WCDB_CT_create_time'
    ])
    let primary = this.normalizeRowTimestampSeconds(rawPrimary)

    const rawSortSeq = this.getRowField(row, ['sort_seq', 'sortSeq', 'server_seq', 'serverSeq'])
    const sortSeqSeconds = this.normalizeRowTimestampSeconds(rawSortSeq)

    // 对异常小时间戳兜底（例如 parseInt("2026-...") => 2026），优先回退 sort_seq。
    if (primary > 0 && primary < 946684800 && sortSeqSeconds > 946684800) {
      return sortSeqSeconds
    }
    if (primary > 0) return primary
    if (sortSeqSeconds > 0) return sortSeqSeconds
    return 0
  }

  private getRowField(row: Record<string, any>, keys: string[]): any {
    for (const key of keys) {
      if (row && Object.prototype.hasOwnProperty.call(row, key)) {
        const value = row[key]
        if (value !== undefined && value !== null && value !== '') {
          return value
        }
      }
    }
    return undefined
  }

  private normalizeUnsignedIntToken(value: unknown): string {
    const raw = String(value ?? '').trim()
    if (!raw) return '0'
    if (/^\d+$/.test(raw)) {
      return raw.replace(/^0+(?=\d)/, '')
    }
    const num = Number(raw)
    if (!Number.isFinite(num) || num <= 0) return '0'
    return String(Math.floor(num))
  }

  private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string {
    const localId = this.normalizeUnsignedIntToken(msg?.localId)
    const createTime = this.normalizeUnsignedIntToken(msg?.createTime)
    const serverId = this.normalizeUnsignedIntToken(msg?.serverIdRaw ?? msg?.serverId)
    return `${localId}:${createTime}:${serverId}`
  }

  private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string {
    const localType = this.normalizeUnsignedIntToken(msg?.localType)
    return `${localType}_${this.getStableMessageKey(msg)}`
  }

  private getImageMissingRunCacheKey(
    sessionId: string,
    imageMd5?: unknown,
    imageDatName?: unknown
  ): string | null {
    const normalizedSessionId = String(sessionId || '').trim()
    const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase()
    const normalizedImageDatName = String(imageDatName || '').trim().toLowerCase()
    if (!normalizedSessionId) return null
    if (!normalizedImageMd5 && !normalizedImageDatName) return null

    const primaryToken = normalizedImageMd5 || normalizedImageDatName
    const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5
      ? normalizedImageDatName
      : ''
    return `${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
  }

  private normalizeEmojiMd5(value: unknown): string | undefined {
    const md5 = String(value || '').trim().toLowerCase()
    if (!/^[a-f0-9]{32}$/.test(md5)) return undefined
    return md5
  }

  private normalizeEmojiCaption(value: unknown): string | null {
    const caption = String(value || '').trim()
    if (!caption) return null
    return caption
  }

  private formatEmojiSemanticText(caption?: string | null): string {
    const normalizedCaption = this.normalizeEmojiCaption(caption)
    if (!normalizedCaption) return '[表情包]'
    return `[表情包：${normalizedCaption}]`
  }

  private extractLooseHexMd5(content: string): string | undefined {
    if (!content) return undefined
    const keyedMatch =
      /(?:emoji|sticker|md5)[^a-fA-F0-9]{0,32}([a-fA-F0-9]{32})/i.exec(content) ||
      /([a-fA-F0-9]{32})/i.exec(content)
    return this.normalizeEmojiMd5(keyedMatch?.[1] || keyedMatch?.[0])
  }

  private normalizeEmojiCdnUrl(value: unknown): string | undefined {
    let url = String(value || '').trim()
    if (!url) return undefined
    url = url.replace(/&amp;/g, '&')
    try {
      if (url.includes('%')) {
        url = decodeURIComponent(url)
      }
    } catch {
      // keep original URL if decoding fails
    }
    return url.trim() || undefined
  }

  private resolveStrictEmoticonDbPath(): string | null {
    const dbPath = this.getConfiguredDbPath()
    const rawWxid = this.getConfiguredMyWxid()
    const cleanedWxid = this.cleanAccountDirName(rawWxid)
    const token = `${dbPath}::${rawWxid}::${cleanedWxid}`
    if (token === this.emoticonDbPathCacheToken) {
      return this.emoticonDbPathCache
    }
    this.emoticonDbPathCacheToken = token
    this.emoticonDbPathCache = null

    const dbStoragePath =
      this.resolveDbStoragePathForExport(dbPath, cleanedWxid) ||
      this.resolveDbStoragePathForExport(dbPath, rawWxid)
    if (!dbStoragePath) return null

    const strictPath = path.join(dbStoragePath, 'emoticon', 'emoticon.db')
    if (fs.existsSync(strictPath)) {
      this.emoticonDbPathCache = strictPath
      return strictPath
    }
    return null
  }

  private resolveDbStoragePathForExport(basePath: string, wxid: string): string | null {
    if (!basePath) return null
    const normalized = basePath.replace(/[\\/]+$/, '')
    if (normalized.toLowerCase().endsWith('db_storage') && fs.existsSync(normalized)) {
      return normalized
    }
    const direct = path.join(normalized, 'db_storage')
    if (fs.existsSync(direct)) {
      return direct
    }
    if (!wxid) return null

    const viaWxid = path.join(normalized, wxid, 'db_storage')
    if (fs.existsSync(viaWxid)) {
      return viaWxid
    }

    try {
      const entries = fs.readdirSync(normalized)
      const lowerWxid = wxid.toLowerCase()
      const candidates = entries.filter((entry) => {
        const entryPath = path.join(normalized, entry)
        try {
          if (!fs.statSync(entryPath).isDirectory()) return false
        } catch {
          return false
        }
        const lowerEntry = entry.toLowerCase()
        return lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)
      })
      for (const entry of candidates) {
        const candidate = path.join(normalized, entry, 'db_storage')
        if (fs.existsSync(candidate)) {
          return candidate
        }
      }
    } catch {
      // keep null
    }

    return null
  }

  private async queryEmojiMd5ByCdnUrlFallback(cdnUrlRaw: string): Promise<string | null> {
    const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw)
    if (!cdnUrl) return null
    const emoticonDbPath = this.resolveStrictEmoticonDbPath()
    if (!emoticonDbPath) return null

    const candidates = Array.from(new Set([
      cdnUrl,
      cdnUrl.replace(/&/g, '&amp;')
    ]))

    for (const candidate of candidates) {
      const escaped = candidate.replace(/'/g, "''")
      const result = await wcdbService.execQuery(
        'message',
        emoticonDbPath,
        `SELECT md5, lower(hex(md5)) AS md5_hex FROM kNonStoreEmoticonTable WHERE cdn_url = '${escaped}' COLLATE NOCASE LIMIT 1`
      )
      const row = result.success && Array.isArray(result.rows) ? result.rows[0] : null
      const md5 = this.normalizeEmojiMd5(this.getRowField(row || {}, ['md5', 'md5_hex']))
      if (md5) return md5
    }

    return null
  }

  private async getEmojiMd5ByCdnUrl(cdnUrlRaw: string): Promise<string | null> {
    const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw)
    if (!cdnUrl) return null

    if (this.emojiMd5ByCdnCache.has(cdnUrl)) {
      return this.emojiMd5ByCdnCache.get(cdnUrl) ?? null
    }

    const pending = this.emojiMd5ByCdnPending.get(cdnUrl)
    if (pending) return pending

    const task = (async (): Promise<string | null> => {
      try {
        return await this.queryEmojiMd5ByCdnUrlFallback(cdnUrl)
      } catch {
        return null
      }
    })()

    this.emojiMd5ByCdnPending.set(cdnUrl, task)
    try {
      const md5 = await task
      this.emojiMd5ByCdnCache.set(cdnUrl, md5)
      return md5
    } finally {
      this.emojiMd5ByCdnPending.delete(cdnUrl)
    }
  }

  private async getEmojiCaptionByMd5(md5Raw: string): Promise<string | null> {
    const md5 = this.normalizeEmojiMd5(md5Raw)
    if (!md5) return null

    if (this.emojiCaptionCache.has(md5)) {
      return this.emojiCaptionCache.get(md5) ?? null
    }

    const pending = this.emojiCaptionPending.get(md5)
    if (pending) return pending

    const task = (async (): Promise<string | null> => {
      try {
        const nativeResult = await wcdbService.getEmoticonCaptionStrict(md5)
        if (nativeResult.success) {
          const nativeCaption = this.normalizeEmojiCaption(nativeResult.caption)
          if (nativeCaption) return nativeCaption
        }
      } catch {
        // ignore and return null
      }
      return null
    })()

    this.emojiCaptionPending.set(md5, task)
    try {
      const caption = await task
      if (caption) {
        this.emojiCaptionCache.set(md5, caption)
      } else {
        this.emojiCaptionCache.delete(md5)
      }
      return caption
    } finally {
      this.emojiCaptionPending.delete(md5)
    }
  }

  private async hydrateEmojiCaptionsForMessages(
    sessionId: string,
    messages: any[],
    control?: ExportTaskControl
  ): Promise<void> {
    if (!Array.isArray(messages) || messages.length === 0) return

    // 某些环境下游标行缺失 47 的 md5，先按 localId 回填详情再做 caption 查询。
    await this.backfillMediaFieldsFromMessageDetail(sessionId, messages, new Set([47]), control)

    const unresolvedByUrl = new Map<string, any[]>()

    const uniqueMd5s = new Set<string>()
    let scanIndex = 0
    for (const msg of messages) {
      if ((scanIndex++ & 0x7f) === 0) {
        this.throwIfStopRequested(control)
      }
      if (Number(msg?.localType) !== 47) continue

      const content = String(msg?.content || '')
      const normalizedMd5 = this.normalizeEmojiMd5(msg?.emojiMd5)
        || this.extractEmojiMd5(content)
        || this.extractLooseHexMd5(content)
      const normalizedCdnUrl = this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || this.extractEmojiUrl(content))
      if (normalizedCdnUrl) {
        msg.emojiCdnUrl = normalizedCdnUrl
      }
      if (!normalizedMd5) {
        if (normalizedCdnUrl) {
          const bucket = unresolvedByUrl.get(normalizedCdnUrl) || []
          bucket.push(msg)
          unresolvedByUrl.set(normalizedCdnUrl, bucket)
        } else {
          msg.emojiMd5 = undefined
          msg.emojiCaption = undefined
        }
        continue
      }

      msg.emojiMd5 = normalizedMd5
      uniqueMd5s.add(normalizedMd5)
    }

    const unresolvedUrls = Array.from(unresolvedByUrl.keys())
    if (unresolvedUrls.length > 0) {
      await parallelLimit(unresolvedUrls, this.emojiCaptionLookupConcurrency, async (url, index) => {
        if ((index & 0x0f) === 0) {
          this.throwIfStopRequested(control)
        }
        const resolvedMd5 = await this.getEmojiMd5ByCdnUrl(url)
        if (!resolvedMd5) return
        const attached = unresolvedByUrl.get(url) || []
        for (const msg of attached) {
          msg.emojiMd5 = resolvedMd5
          uniqueMd5s.add(resolvedMd5)
        }
      })
    }

    const md5List = Array.from(uniqueMd5s)
    if (md5List.length > 0) {
      await parallelLimit(md5List, this.emojiCaptionLookupConcurrency, async (md5, index) => {
        if ((index & 0x0f) === 0) {
          this.throwIfStopRequested(control)
        }
        await this.getEmojiCaptionByMd5(md5)
      })
    }

    let assignIndex = 0
    for (const msg of messages) {
      if ((assignIndex++ & 0x7f) === 0) {
        this.throwIfStopRequested(control)
      }
      if (Number(msg?.localType) !== 47) continue
      const md5 = this.normalizeEmojiMd5(msg?.emojiMd5)
      if (!md5) {
        msg.emojiCaption = undefined
        continue
      }
      const caption = this.emojiCaptionCache.get(md5) ?? null
      msg.emojiCaption = caption || undefined
    }
  }

  private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
    const wxid = this.getConfiguredMyWxid()
    const dbPath = this.getConfiguredDbPath()
    const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
    if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' }
    if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' }
    if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' }

    const cleanedWxid = this.cleanAccountDirName(wxid)
    const accountDir = this.configService.getAccountDir(dbPath, wxid)
    if (!accountDir) return { success: false, error: '无法找到账号目录' }
    const ok = await wcdbService.open(accountDir, decryptKey)
    if (!ok) return { success: false, error: 'WCDB 打开失败' }
    return { success: true, cleanedWxid }
  }

  private async getContactInfo(username: string): Promise<{ displayName: string; avatarUrl?: string }> {
    if (this.contactCache.has(username)) {
      return this.contactCache.get(username)!
    }

    const [nameResult, avatarResult] = await Promise.all([
      wcdbService.getDisplayNames([username]),
      wcdbService.getAvatarUrls([username])
    ])

    const displayName = (nameResult.success && nameResult.map ? nameResult.map[username] : null) || username
    const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined

    const info = { displayName, avatarUrl }
    this.contactCache.set(username, info)
    return info
  }

  private resolveSessionFilePrefix(sessionId: string, contact?: any): string {
    const normalizedSessionId = String(sessionId || '').trim()
    if (!normalizedSessionId) return '私聊_'
    if (normalizedSessionId.endsWith('@chatroom')) return '群聊_'
    if (normalizedSessionId.startsWith('gh_')) return '公众号_'

    const rawLocalType = contact?.local_type ?? contact?.localType ?? contact?.WCDB_CT_local_type
    const localType = Number.parseInt(String(rawLocalType ?? ''), 10)
    const quanPin = String(contact?.quan_pin ?? contact?.quanPin ?? contact?.WCDB_CT_quan_pin ?? '').trim()

    if (Number.isFinite(localType) && localType === 0 && quanPin) {
      return '曾经的好友_'
    }

    return '私聊_'
  }

  private async getSessionFilePrefix(sessionId: string): Promise<string> {
    const normalizedSessionId = String(sessionId || '').trim()
    if (!normalizedSessionId) return '私聊_'
    if (normalizedSessionId.endsWith('@chatroom')) return '群聊_'
    if (normalizedSessionId.startsWith('gh_')) return '公众号_'

    try {
      const contactResult = await wcdbService.getContact(normalizedSessionId)
      if (contactResult.success && contactResult.contact) {
        return this.resolveSessionFilePrefix(normalizedSessionId, contactResult.contact)
      }
    } catch {
      // ignore and use default private prefix
    }

    return '私聊_'
  }

  private async preloadContacts(
    usernames: Iterable<string>,
    cache: Map<string, { success: boolean; contact?: any; error?: string }>,
    limit = 8
  ): Promise<void> {
    const unique = Array.from(new Set(Array.from(usernames).filter(Boolean)))
    if (unique.length === 0) return
    await parallelLimit(unique, limit, async (username) => {
      if (cache.has(username)) return
      const result = await wcdbService.getContact(username)
      cache.set(username, result)
    })
  }

  private async preloadContactInfos(
    usernames: Iterable<string>,
    limit = 8
  ): Promise<Map<string, { displayName: string; avatarUrl?: string }>> {
    const infoMap = new Map<string, { displayName: string; avatarUrl?: string }>()
    const unique = Array.from(new Set(Array.from(usernames).filter(Boolean)))
    if (unique.length === 0) return infoMap

    await parallelLimit(unique, limit, async (username) => {
      const info = await this.getContactInfo(username)
      infoMap.set(username, info)
    })

    return infoMap
  }

  /**
   * 获取群成员群昵称。后端结果为唯一业务真值，前端仅做冲突净化防串号。
   */
  async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
    try {
      const dllResult = await wcdbService.getGroupNicknames(chatroomId)
      if (!dllResult.success || !dllResult.nicknames) {
        return new Map<string, string>()
      }
      return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
    } catch (e) {
      console.error('getGroupNicknamesForRoom service error:', e)
      return new Map<string, string>()
    }
  }

  private normalizeGroupNicknameIdentity(value: string): string {
    const raw = String(value || '').trim()
    if (!raw) return ''
    return raw.toLowerCase()
  }

  private buildTrustedGroupNicknameMap(
    entries: Iterable<[string, string]>,
    candidates: string[] = []
  ): Map<string, string> {
    const candidateSet = new Set(
      this.buildGroupNicknameIdCandidates(candidates)
        .map((id) => this.normalizeGroupNicknameIdentity(id))
        .filter(Boolean)
    )

    const buckets = new Map<string, Set<string>>()
    for (const [memberIdRaw, nicknameRaw] of entries) {
      const identity = this.normalizeGroupNicknameIdentity(memberIdRaw || '')
      if (!identity) continue
      if (candidateSet.size > 0 && !candidateSet.has(identity)) continue

      const nickname = this.normalizeGroupNickname(nicknameRaw || '')
      if (!nickname) continue

      const slot = buckets.get(identity)
      if (slot) {
        slot.add(nickname)
      } else {
        buckets.set(identity, new Set([nickname]))
      }
    }

    const trusted = new Map<string, string>()
    for (const [identity, nicknameSet] of buckets.entries()) {
      if (nicknameSet.size !== 1) continue
      trusted.set(identity, Array.from(nicknameSet)[0])
    }
    return trusted
  }

  private mergeGroupNicknameEntries(
    target: Map<string, string>,
    entries: Iterable<[string, string]>
  ): void {
    for (const [memberIdRaw, nicknameRaw] of entries) {
      const nickname = this.normalizeGroupNickname(nicknameRaw || '')
      if (!nickname) continue
      for (const alias of this.buildGroupNicknameIdCandidates([memberIdRaw])) {
        if (!alias) continue
        if (!target.has(alias)) target.set(alias, nickname)
        const lower = alias.toLowerCase()
        if (!target.has(lower)) target.set(lower, nickname)
      }
    }
  }

  private decodeExtBuffer(value: unknown): Buffer | null {
    if (!value) return null
    if (Buffer.isBuffer(value)) return value
    if (value instanceof Uint8Array) return Buffer.from(value)

    if (typeof value === 'string') {
      const raw = value.trim()
      if (!raw) return null

      if (this.looksLikeHex(raw)) {
        try { return Buffer.from(raw, 'hex') } catch { }
      }
      if (this.looksLikeBase64(raw)) {
        try { return Buffer.from(raw, 'base64') } catch { }
      }

      try { return Buffer.from(raw, 'hex') } catch { }
      try { return Buffer.from(raw, 'base64') } catch { }
      try { return Buffer.from(raw, 'utf8') } catch { }
      return null
    }

    return null
  }

  private readVarint(buffer: Buffer, offset: number, limit: number = buffer.length): { value: number; next: number } | null {
    let value = 0
    let shift = 0
    let pos = offset
    while (pos < limit && shift <= 53) {
      const byte = buffer[pos]
      value += (byte & 0x7f) * Math.pow(2, shift)
      pos += 1
      if ((byte & 0x80) === 0) return { value, next: pos }
      shift += 7
    }
    return null
  }

  private isLikelyGroupMemberId(value: string): boolean {
    const id = String(value || '').trim()
    if (!id) return false
    if (id.includes('@chatroom')) return false
    if (id.length < 4 || id.length > 80) return false
    return /^[A-Za-z][A-Za-z0-9_.@-]*$/.test(id)
  }

  private parseGroupNicknamesFromExtBuffer(buffer: Buffer, candidates: string[] = []): Map<string, string> {
    const nicknameMap = new Map<string, string>()
    if (!buffer || buffer.length === 0) return nicknameMap

    try {
      const candidateSet = new Set(this.buildGroupNicknameIdCandidates(candidates).map((id) => id.toLowerCase()))

      for (let i = 0; i < buffer.length - 2; i += 1) {
        if (buffer[i] !== 0x0a) continue

        const idLenInfo = this.readVarint(buffer, i + 1)
        if (!idLenInfo) continue
        const idLen = idLenInfo.value
        if (!Number.isFinite(idLen) || idLen <= 0 || idLen > 96) continue

        const idStart = idLenInfo.next
        const idEnd = idStart + idLen
        if (idEnd > buffer.length) continue

        const memberId = buffer.toString('utf8', idStart, idEnd).trim()
        if (!this.isLikelyGroupMemberId(memberId)) continue

        const memberIdLower = memberId.toLowerCase()
        if (candidateSet.size > 0 && !candidateSet.has(memberIdLower)) {
          i = idEnd - 1
          continue
        }

        const cursor = idEnd
        if (cursor >= buffer.length || buffer[cursor] !== 0x12) {
          i = idEnd - 1
          continue
        }

        const nickLenInfo = this.readVarint(buffer, cursor + 1)
        if (!nickLenInfo) {
          i = idEnd - 1
          continue
        }
        const nickLen = nickLenInfo.value
        if (!Number.isFinite(nickLen) || nickLen <= 0 || nickLen > 128) {
          i = idEnd - 1
          continue
        }

        const nickStart = nickLenInfo.next
        const nickEnd = nickStart + nickLen
        if (nickEnd > buffer.length) {
          i = idEnd - 1
          continue
        }

        const rawNick = buffer.toString('utf8', nickStart, nickEnd)
        const nickname = this.normalizeGroupNickname(rawNick.replace(/[\x00-\x1F\x7F]/g, '').trim())
        if (!nickname) {
          i = nickEnd - 1
          continue
        }

        const aliases = this.buildGroupNicknameIdCandidates([memberId])
        for (const alias of aliases) {
          if (!alias) continue
          if (!nicknameMap.has(alias)) nicknameMap.set(alias, nickname)
          const lower = alias.toLowerCase()
          if (!nicknameMap.has(lower)) nicknameMap.set(lower, nickname)
        }

        i = nickEnd - 1
      }
    } catch (e) {
      console.error('Failed to parse chat_room.ext_buffer in exportService:', e)
    }

    return nicknameMap
  }

  /**
   * 转换微信消息类型到 ChatLab 类型
   */
  private convertMessageType(localType: number, content: string): number {
    const normalized = this.normalizeAppMessageContent(content || '')
    if (this.isReadableSystemMessage(localType, normalized)) {
      return 80
    }

    const xmlTypeRaw = this.extractAppMessageType(normalized)
    const xmlType = xmlTypeRaw ? Number.parseInt(xmlTypeRaw, 10) : null
    const looksLikeAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')

    // 特殊处理 type 49 或 XML type
    if (looksLikeAppMessage || xmlType) {
      const subType = xmlType || 0
      switch (subType) {
        case 6: return 4   // 文件 -> FILE
        case 19: return 7  // 聊天记录 -> LINK (ChatLab 没有专门的聊天记录类型)
        case 33:
        case 36: return 24 // 小程序 -> SHARE
        case 57: return 25 // 引用回复 -> REPLY
        case 2000: return 99 // 转账 -> OTHER (ChatLab 没有转账类型)
        case 5:
        case 49: return 7  // 链接 -> LINK
        default:
          if (xmlType || looksLikeAppMessage) return 7 // 有 appmsg 但未知，默认为链接
      }
    }
    return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER
  }

  private isReadableSystemMessage(localType: number, content: string): boolean {
    if (localType === 10000) return true
    const normalized = this.normalizeAppMessageContent(content || '')
    return /<sysmsg\b/i.test(this.stripSenderPrefix(normalized))
  }

  /**
   * 解码消息内容
   */
  private decodeMessageContent(messageContent: any, compressContent: any): string {
    let content = this.decodeMaybeCompressed(compressContent)
    if (!content || content.length === 0) {
      content = this.decodeMaybeCompressed(messageContent)
    }
    return content
  }

  private decodeMaybeCompressed(raw: any): string {
    if (!raw) return ''
    if (typeof raw === 'string') {
      if (raw.length === 0) return ''
      if (/^[0-9]+$/.test(raw)) {
        return raw
      }
      // 只有当字符串足够长（超过16字符）且看起来像 hex 时才尝试解码
      if (raw.length > 16 && this.looksLikeHex(raw)) {
        const bytes = Buffer.from(raw, 'hex')
        if (bytes.length > 0) return this.decodeBinaryContent(bytes)
      }
      // 只有当字符串足够长（超过16字符）且看起来像 base64 时才尝试解码
      // 短字符串（如 "test", "home" 等）容易被误判为 base64
      if (raw.length > 16 && this.looksLikeBase64(raw)) {
        try {
          const bytes = Buffer.from(raw, 'base64')
          return this.decodeBinaryContent(bytes)
        } catch {
          return raw
        }
      }
      return raw
    }
    return ''
  }

  private decodeBinaryContent(data: Buffer): string {
    if (data.length === 0) return ''
    try {
      if (data.length >= 4) {
        const magic = data.readUInt32LE(0)
        if (magic === 0xFD2FB528) {
          const fzstd = require('fzstd')
          const decompressed = fzstd.decompress(data)
          return Buffer.from(decompressed).toString('utf-8')
        }
      }
      const decoded = data.toString('utf-8')
      const replacementCount = (decoded.match(/\uFFFD/g) || []).length
      if (replacementCount < decoded.length * 0.2) {
        return decoded.replace(/\uFFFD/g, '')
      }
      return data.toString('latin1')
    } catch {
      return ''
    }
  }

  private looksLikeHex(s: string): boolean {
    if (s.length % 2 !== 0) return false
    return /^[0-9a-fA-F]+$/.test(s)
  }

  private normalizeGroupNickname(value: string): string {
    const trimmed = (value || '').trim()
    if (!trimmed) return ''
    const cleaned = trimmed.replace(/[\x00-\x1F\x7F]/g, '')
    if (!cleaned) return ''
    if (/^[,"'“”‘’，、]+$/.test(cleaned)) return ''
    return cleaned
  }

  private buildGroupNicknameIdCandidates(values: Array<string | undefined | null>): string[] {
    const set = new Set<string>()
    for (const rawValue of values) {
      const raw = String(rawValue || '').trim()
      if (!raw) continue
      set.add(raw)
    }
    return Array.from(set)
  }

  private resolveGroupNicknameByCandidates(groupNicknamesMap: Map<string, string>, candidates: Array<string | undefined | null>): string {
    const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
    if (idCandidates.length === 0) return ''

    let resolved = ''
    for (const id of idCandidates) {
      const normalizedId = this.normalizeGroupNicknameIdentity(id)
      if (!normalizedId) continue
      const candidateNickname = this.normalizeGroupNickname(groupNicknamesMap.get(normalizedId) || '')
      if (!candidateNickname) continue
      if (!resolved) {
        resolved = candidateNickname
        continue
      }
      if (resolved !== candidateNickname) return ''
    }

    return resolved
  }

  /**
   * 根据用户偏好获取显示名称
   */
  private getPreferredDisplayName(
    wxid: string,
    nickname: string,
    remark: string,
    groupNickname: string,
    preference: 'group-nickname' | 'remark' | 'nickname' = 'remark'
  ): string {
    switch (preference) {
      case 'group-nickname':
        return groupNickname || remark || nickname || wxid
      case 'remark':
        return remark || nickname || wxid
      case 'nickname':
        return nickname || wxid
      default:
        return nickname || wxid
    }
  }

  private async resolveExportDisplayProfile(
    wxid: string,
    preference: ExportOptions['displayNamePreference'],
    getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>,
    groupNicknamesMap: Map<string, string>,
    fallbackDisplayName = '',
    extraGroupNicknameCandidates: Array<string | undefined | null> = []
  ): Promise<ExportDisplayProfile> {
    const resolvedWxid = String(wxid || '').trim() || String(fallbackDisplayName || '').trim() || 'unknown'
    const contactResult = resolvedWxid ? await getContact(resolvedWxid) : { success: false as const }
    const contact = contactResult.success ? contactResult.contact : null
    const nickname = String(contact?.nickName || contact?.nick_name || fallbackDisplayName || resolvedWxid)
    const remark = String(contact?.remark || '')
    const alias = String(contact?.alias || '')
    const groupNickname = this.resolveGroupNicknameByCandidates(
      groupNicknamesMap,
      [
        resolvedWxid,
        contact?.username,
        contact?.userName,
        contact?.encryptUsername,
        contact?.encryptUserName,
        alias,
        ...extraGroupNicknameCandidates
      ]
    ) || ''
    const displayName = this.getPreferredDisplayName(
      resolvedWxid,
      nickname,
      remark,
      groupNickname,
      preference || 'remark'
    )

    return {
      wxid: resolvedWxid,
      nickname,
      remark,
      alias,
      groupNickname,
      displayName
    }
  }

  /**
   * 从转账消息 XML 中提取并解析 "谁转账给谁" 描述
   * @param content 原始消息内容 XML
   * @param myWxid 当前用户 wxid
   * @param groupNicknamesMap 群昵称映射
   * @param getContactName 联系人名称解析函数
   * @returns "A 转账给 B" 或 null
   */
  private async resolveTransferDesc(
    content: string,
    myWxid: string,
    groupNicknamesMap: Map<string, string>,
    getContactName: (username: string) => Promise<string>
  ): Promise<string | null> {
    const normalizedContent = this.normalizeAppMessageContent(content || '')
    if (!normalizedContent) return null

    const xmlType = this.extractXmlValue(normalizedContent, 'type')
    if (xmlType && xmlType !== '2000') return null

    const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username')
    const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username')
    if (!payerUsername || !receiverUsername) return null

    const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''

    const resolveName = async (username: string): Promise<string> => {
      // 当前用户自己
      if (myWxid && (username === myWxid || username === cleanedMyWxid)) {
        const groupNick = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [username, myWxid, cleanedMyWxid])
        if (groupNick) return groupNick
        return '我'
      }
      // 群昵称
      const groupNick = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [username])
      if (groupNick) return groupNick
      // 联系人名称
      return getContactName(username)
    }

    const [payerName, receiverName] = await Promise.all([
      resolveName(payerUsername),
      resolveName(receiverUsername)
    ])

    return `${payerName} 转账给 ${receiverName}`
  }

  private isSameWxid(lhs?: string, rhs?: string): boolean {
    const left = new Set(this.buildGroupNicknameIdCandidates([lhs]).map((id) => id.toLowerCase()))
    if (left.size === 0) return false
    const right = this.buildGroupNicknameIdCandidates([rhs]).map((id) => id.toLowerCase())
    return right.some((id) => left.has(id))
  }

  private getTransferPrefix(content: string, myWxid?: string, senderWxid?: string, isSend?: boolean): '[转账]' | '[转账收款]' {
    const normalizedContent = this.normalizeAppMessageContent(content || '')
    if (!normalizedContent) return '[转账]'

    const paySubtype = this.extractXmlValue(normalizedContent, 'paysubtype')
    // 转账消息在部分账号数据中 `payer_username` 可能为空，优先用 `paysubtype` 判定
    // 实测：1=发起侧，3=收款侧
    if (paySubtype === '3') return '[转账收款]'
    if (paySubtype === '1') return '[转账]'

    const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username')
    const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username')
    const senderIsPayer = senderWxid ? this.isSameWxid(senderWxid, payerUsername) : false
    const senderIsReceiver = senderWxid ? this.isSameWxid(senderWxid, receiverUsername) : false

    // 实测字段语义：sender 命中 receiver_username 为转账发起侧，命中 payer_username 为收款侧
    if (senderWxid) {
      if (senderIsReceiver && !senderIsPayer) return '[转账]'
      if (senderIsPayer && !senderIsReceiver) return '[转账收款]'
    }

    // 兜底：按当前账号角色判断
    if (myWxid) {
      if (this.isSameWxid(myWxid, receiverUsername)) return '[转账]'
      if (this.isSameWxid(myWxid, payerUsername)) return '[转账收款]'
    }

    return '[转账]'
  }

  private isTransferExportContent(content: string): boolean {
    return content.startsWith('[转账]') || content.startsWith('[转账收款]')
  }

  private appendTransferDesc(content: string, transferDesc: string): string {
    const prefix = content.startsWith('[转账收款]') ? '[转账收款]' : '[转账]'
    return content.replace(prefix, `${prefix} (${transferDesc})`)
  }

  private looksLikeBase64(s: string): boolean {
    if (s.length % 4 !== 0) return false
    return /^[A-Za-z0-9+/=]+$/.test(s)
  }

  /**
   * 解析消息内容为可读文本
   * 注意：语音消息在这里返回占位符，实际转文字在导出时异步处理
   */
  private parseMessageContent(
    content: string,
    localType: number,
    sessionId?: string,
    createTime?: number,
    myWxid?: string,
    senderWxid?: string,
    isSend?: boolean,
    emojiCaption?: string
  ): string | null {
    if (!content && localType === 47) {
      return this.formatEmojiSemanticText(emojiCaption)
    }
    if (!content) return null

    const normalizedContent = this.normalizeAppMessageContent(content)
    const xmlType = this.extractAppMessageType(normalizedContent)

    switch (localType) {
      case 1: // 文本
        return this.stripSenderPrefix(content)
      case 3: return '[图片]'
      case 34: {
        // 语音消息 - 尝试获取转写文字
        const transcriptGetter = (voiceTranscribeService as unknown as {
          getCachedTranscript?: (sessionId: string, createTime: number) => string | null | undefined
        }).getCachedTranscript

        if (sessionId && createTime && typeof transcriptGetter === 'function') {
          const transcript = transcriptGetter(sessionId, createTime)
          if (transcript) {
            return `[语音消息] ${transcript}`
          }
        }
        return '[语音消息]'  // 占位符，导出时会替换为转文字结果
      }
      case 42: return '[名片]'
      case 43: return '[视频]'
      case 47: return this.formatEmojiSemanticText(emojiCaption)
      case 48: {
        const normalized48 = this.normalizeAppMessageContent(content)
        const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName')
        const locLabel = this.extractXmlAttribute(normalized48, 'location', 'label') || this.extractXmlValue(normalized48, 'label')
        const locLat = this.extractXmlAttribute(normalized48, 'location', 'x') || this.extractXmlAttribute(normalized48, 'location', 'latitude')
        const locLng = this.extractXmlAttribute(normalized48, 'location', 'y') || this.extractXmlAttribute(normalized48, 'location', 'longitude')
        const locParts: string[] = []
        if (locPoiname) locParts.push(locPoiname)
        if (locLabel && locLabel !== locPoiname) locParts.push(locLabel)
        if (locLat && locLng) locParts.push(`(${locLat},${locLng})`)
        return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]'
      }
      case 49: {
        const title = this.extractXmlValue(normalizedContent, 'title')
        const type = this.extractAppMessageType(normalizedContent)
        const songName = this.extractXmlValue(normalizedContent, 'songname')

        // 转账消息特殊处理
        if (type === '2000') {
          const feedesc = this.extractXmlValue(normalizedContent, 'feedesc')
          const payMemo = this.extractXmlValue(normalizedContent, 'pay_memo')
          const transferPrefix = this.getTransferPrefix(normalizedContent, myWxid, senderWxid, isSend)
          if (feedesc) {
            return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
          }
          return transferPrefix
        }

        if (type === '3') return songName ? `[音乐] ${songName}` : (title ? `[音乐] ${title}` : '[音乐]')
        if (type === '6') return title ? `[文件] ${title}` : '[文件]'
        if (type === '19') return this.formatForwardChatRecordContent(normalizedContent)
        if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]'
        if (type === '57') {
          const quoteDisplay = this.extractQuotedReplyDisplay(content)
          if (quoteDisplay) {
            return this.buildQuotedReplyText(quoteDisplay)
          }
          return title || '[引用消息]'
        }
        if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]'
        return title ? `[链接] ${title}` : '[链接]'
      }
      case 50: return this.parseVoipMessage(content)
      case 10000: return this.cleanSystemMessage(content)
      case 266287972401: return this.cleanSystemMessage(content)  // 拍一拍
      case 244813135921: {
        // 引用消息
        const quoteDisplay = this.extractQuotedReplyDisplay(content)
        if (quoteDisplay) {
          return this.buildQuotedReplyText(quoteDisplay)
        }
        const title = this.extractXmlValue(content, 'title')
        return title || '[引用消息]'
      }
      default:
        // 对于未知的 localType，检查 XML type 来判断消息类型
        if (xmlType) {
          const title = this.extractXmlValue(content, 'title')

          // 群公告消息（type 87）
          if (xmlType === '87') {
            const textAnnouncement = this.extractXmlValue(content, 'textannouncement')
            if (textAnnouncement) {
              return `[群公告] ${textAnnouncement}`
            }
            return '[群公告]'
          }

          // 转账消息
          if (xmlType === '2000') {
            const feedesc = this.extractXmlValue(content, 'feedesc')
            const payMemo = this.extractXmlValue(content, 'pay_memo')
            const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend)
            if (feedesc) {
              return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
            }
            return transferPrefix
          }

          // 其他类型
          if (xmlType === '3') return title ? `[音乐] ${title}` : '[音乐]'
          if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]'
          if (xmlType === '19') return this.formatForwardChatRecordContent(normalizedContent)
          if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]'
          if (xmlType === '57') {
            const quoteDisplay = this.extractQuotedReplyDisplay(content)
            if (quoteDisplay) {
              return this.buildQuotedReplyText(quoteDisplay)
            }
            return title || '[引用消息]'
          }
          if (xmlType === '53') return title ? `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` : '[接龙]'
          if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]'

          // 有 title 就返回 title
          if (title) return title
        }

        // 最后尝试提取文本内容
        return this.stripSenderPrefix(normalizedContent) || null
    }
  }

  private formatPlainExportContent(
    content: string,
    localType: number,
    options: { exportVoiceAsText?: boolean },
    voiceTranscript?: string,
    myWxid?: string,
    senderWxid?: string,
    isSend?: boolean,
    emojiCaption?: string
  ): string {
    const safeContent = content || ''
    const readableSystemText = this.extractReadableSystemMessageText(safeContent)
    if (readableSystemText && this.isReadableSystemMessage(localType, safeContent)) {
      return readableSystemText
    }

    if (localType === 3) return '[图片]'
    if (localType === 1) return this.stripSenderPrefix(safeContent)
    if (localType === 34) {
      if (options.exportVoiceAsText) {
        return voiceTranscript || '[语音消息 - 转文字失败]'
      }
      return '[其他消息]'
    }
    if (localType === 42) {
      const normalized = this.normalizeAppMessageContent(safeContent)
      const nickname =
        this.extractXmlValue(normalized, 'nickname') ||
        this.extractXmlValue(normalized, 'displayname') ||
        this.extractXmlValue(normalized, 'name')
      return nickname ? `[名片]${nickname}` : '[名片]'
    }
    if (localType === 43) {
      const normalized = this.normalizeAppMessageContent(safeContent)
      const lengthValue =
        this.extractXmlValue(normalized, 'playlength') ||
        this.extractXmlValue(normalized, 'playLength') ||
        this.extractXmlValue(normalized, 'length') ||
        this.extractXmlValue(normalized, 'duration')
      const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null
      return seconds ? `[视频]${seconds}s` : '[视频]'
    }
    if (localType === 47) {
      return this.formatEmojiSemanticText(emojiCaption)
    }
    if (localType === 48) {
      const normalized = this.normalizeAppMessageContent(safeContent)
      const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName')
      const locLabel = this.extractXmlAttribute(normalized, 'location', 'label') || this.extractXmlValue(normalized, 'label')
      const locLat = this.extractXmlAttribute(normalized, 'location', 'x') || this.extractXmlAttribute(normalized, 'location', 'latitude')
      const locLng = this.extractXmlAttribute(normalized, 'location', 'y') || this.extractXmlAttribute(normalized, 'location', 'longitude')
      const locParts: string[] = []
      if (locPoiname) locParts.push(locPoiname)
      if (locLabel && locLabel !== locPoiname) locParts.push(locLabel)
      if (locLat && locLng) locParts.push(`(${locLat},${locLng})`)
      return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]'
    }
    if (localType === 50) {
      return this.parseVoipMessage(safeContent)
    }
    if (localType === 10000 || localType === 266287972401) {
      return this.cleanSystemMessage(safeContent)
    }

    const normalized = this.normalizeAppMessageContent(safeContent)
    const isAppMessage = normalized.includes('<appmsg') || normalized.includes('<msg>')
    if (localType === 49 || isAppMessage) {
      const subTypeRaw = this.extractAppMessageType(normalized)
      const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0
      const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname')

      // 群公告消息（type 87）
      if (subType === 87) {
        const textAnnouncement = this.extractXmlValue(normalized, 'textannouncement')
        if (textAnnouncement) {
          return `[群公告]${textAnnouncement}`
        }
        return '[群公告]'
      }

      // 转账消息特殊处理
      if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) {
        const feedesc = this.extractXmlValue(normalized, 'feedesc')
        const payMemo = this.extractXmlValue(normalized, 'pay_memo')
        const transferPrefix = this.getTransferPrefix(normalized, myWxid, senderWxid, isSend)
        if (feedesc) {
          return payMemo ? `${transferPrefix}${feedesc} ${payMemo}` : `${transferPrefix}${feedesc}`
        }
        const amount = this.extractAmountFromText(
          [
            title,
            this.extractXmlValue(normalized, 'des'),
            this.extractXmlValue(normalized, 'money'),
            this.extractXmlValue(normalized, 'amount'),
            this.extractXmlValue(normalized, 'fee')
          ]
            .filter(Boolean)
            .join(' ')
        )
        return amount ? `${transferPrefix}${amount}` : transferPrefix
      }

      if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
        const songName = this.extractXmlValue(normalized, 'songname') || title || '音乐'
        return `[音乐]${songName}`
      }
      if (subType === 6) {
        const fileName = this.extractXmlValue(normalized, 'filename') || title || '文件'
        return `[文件]${fileName}`
      }
      if (title.includes('红包') || normalized.includes('hongbao')) {
        return `[红包]${title || '微信红包'}`
      }
      if (subType === 19 || normalized.includes('<recorditem')) {
        return this.formatForwardChatRecordContent(normalized)
      }
      if (subType === 33 || subType === 36) {
        const appName = this.extractXmlValue(normalized, 'appname') || title || '小程序'
        return `[小程序]${appName}`
      }
      if (subType === 57) {
        const quoteDisplay = this.extractQuotedReplyDisplay(safeContent)
        if (quoteDisplay) {
          return this.buildQuotedReplyText(quoteDisplay)
        }
        return title || '[引用消息]'
      }
      if (title) {
        return `[链接]${title}`
      }
      return '[其他消息]'
    }

    return '[其他消息]'
  }

  private formatQuotedReferencePreview(content: string, type?: string): string {
    const safeContent = content || ''
    const referType = Number.parseInt(String(type || ''), 10)
    if (!Number.isFinite(referType)) {
      const sanitized = this.sanitizeQuotedContent(safeContent)
      return sanitized || '[消息]'
    }

    if (referType === 49) {
      const normalized = this.normalizeAppMessageContent(safeContent)
      const title =
        this.extractXmlValue(normalized, 'title') ||
        this.extractXmlValue(normalized, 'filename') ||
        this.extractXmlValue(normalized, 'appname')
      if (title) return this.stripSenderPrefix(title)

      const subTypeRaw = this.extractAppMessageType(normalized)
      const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0
      if (subType === 6) return '[文件]'
      if (subType === 19) return '[聊天记录]'
      if (subType === 33 || subType === 36) return '[小程序]'
      return '[链接]'
    }

    return this.formatPlainExportContent(safeContent, referType, { exportVoiceAsText: false }) || '[消息]'
  }

  private resolveQuotedSenderUsername(fromusr?: string, chatusr?: string): string {
    const normalizedChatUsr = String(chatusr || '').trim()
    const normalizedFromUsr = String(fromusr || '').trim()

    if (normalizedChatUsr) {
      return normalizedChatUsr
    }

    if (normalizedFromUsr.endsWith('@chatroom')) {
      return ''
    }

    return normalizedFromUsr
  }

  private buildQuotedReplyText(display: {
    replyText: string
    quotedSender?: string
    quotedPreview: string
  }): string {
    const quoteLabel = display.quotedSender
      ? `${display.quotedSender}：${display.quotedPreview}`
      : display.quotedPreview
    if (display.replyText) {
      return `${display.replyText}[引用 ${quoteLabel}]`
    }
    return `[引用 ${quoteLabel}]`
  }

  private extractQuotedReplyDisplay(content: string): {
    replyText: string
    quotedSender?: string
    quotedPreview: string
  } | null {
    try {
      const normalized = this.normalizeAppMessageContent(content || '')
      const referMsgStart = normalized.indexOf('<refermsg>')
      const referMsgEnd = normalized.indexOf('</refermsg>')
      if (referMsgStart === -1 || referMsgEnd === -1) {
        return null
      }

      const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
      const quoteInfo = this.parseQuoteMessage(normalized)
      const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '')
      const quotedPreview = quoteInfo.content || this.formatQuotedReferencePreview(
        this.extractXmlValue(referMsgXml, 'content'),
        this.extractXmlValue(referMsgXml, 'type')
      )

      if (!replyText && !quotedPreview) {
        return null
      }

      return {
        replyText,
        quotedSender: quoteInfo.sender || undefined,
        quotedPreview: quotedPreview || '[消息]'
      }
    } catch {
      return null
    }
  }

  private isQuotedReplyMessage(localType: number, content: string): boolean {
    if (localType === 244813135921) return true
    const normalized = this.normalizeAppMessageContent(content || '')
    if (!(localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>'))) {
      return false
    }
    const subType = this.extractAppMessageType(normalized)
    return subType === '57' || normalized.includes('<refermsg>')
  }

  private async resolveQuotedReplyDisplayWithNames(args: {
    content: string
    isGroup: boolean
    displayNamePreference: ExportOptions['displayNamePreference']
    getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>
    groupNicknamesMap: Map<string, string>
    cleanedMyWxid: string
    rawMyWxid?: string
    myDisplayName?: string
  }): Promise<{
    replyText: string
    quotedSender?: string
    quotedPreview: string
  } | null> {
    const base = this.extractQuotedReplyDisplay(args.content)
    if (!base) return null
    if (base.quotedSender) return base

    const normalized = this.normalizeAppMessageContent(args.content || '')
    const referMsgStart = normalized.indexOf('<refermsg>')
    const referMsgEnd = normalized.indexOf('</refermsg>')
    if (referMsgStart === -1 || referMsgEnd === -1) {
      return base
    }

    const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
    const quotedSenderUsername = this.resolveQuotedSenderUsername(
      this.extractXmlValue(referMsgXml, 'fromusr'),
      this.extractXmlValue(referMsgXml, 'chatusr')
    )
    if (!quotedSenderUsername) {
      return base
    }

    const isQuotedSelf = this.isSameWxid(quotedSenderUsername, args.cleanedMyWxid)
    const fallbackDisplayName = isQuotedSelf
      ? (args.myDisplayName || quotedSenderUsername)
      : quotedSenderUsername

    const profile = await this.resolveExportDisplayProfile(
      quotedSenderUsername,
      args.displayNamePreference,
      args.getContact,
      args.groupNicknamesMap,
      fallbackDisplayName,
      isQuotedSelf ? [args.rawMyWxid, args.cleanedMyWxid] : []
    )

    return {
      ...base,
      quotedSender: profile.displayName || fallbackDisplayName || base.quotedSender
    }
  }

  private parseDurationSeconds(value: string): number | null {
    const numeric = Number(value)
    if (!Number.isFinite(numeric) || numeric <= 0) return null
    if (numeric >= 1000) return Math.round(numeric / 1000)
    return Math.round(numeric)
  }

  private extractAmountFromText(text: string): string | null {
    if (!text) return null
    const match = /([¥￥]\s*\d+(?:\.\d+)?|\d+(?:\.\d+)?)/.exec(text)
    return match ? match[1].replace(/\s+/g, '') : null
  }

  private stripSenderPrefix(content: string): string {
    return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '')
  }

  private getWeCloneTypeName(localType: number, content: string): string {
    if (localType === 1) return 'text'
    if (localType === 3) return 'image'
    if (localType === 47) return 'sticker'
    if (localType === 43) return 'video'
    if (localType === 34) return 'voice'
    if (localType === 48) return 'location'
    const normalized = this.normalizeAppMessageContent(content || '')
    const xmlType = this.extractAppMessageType(normalized)
    if (localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')) {
      if (xmlType === '6') return 'file'
      return 'text'
    }
    return 'text'
  }

  private getWeCloneSource(msg: any, typeName: string, mediaItem: MediaExportItem | null): string {
    if (mediaItem?.relativePath) {
      return mediaItem.relativePath
    }

    if (typeName === 'image') {
      return msg.imageDatName || ''
    }
    if (typeName === 'sticker') {
      return msg.emojiCdnUrl || ''
    }
    if (typeName === 'video') {
      return ''
    }
    if (typeName === 'file') {
      const xml = msg.content || ''
      return this.extractXmlValue(xml, 'filename') || this.extractXmlValue(xml, 'title') || ''
    }
    return ''
  }

  private escapeCsvCell(value: unknown): string {
    if (value === null || value === undefined) return ''
    const text = String(value)
    if (/[",\r\n]/.test(text)) {
      return `"${text.replace(/"/g, '""')}"`
    }
    return text
  }

  private formatIsoTimestamp(timestamp: number): string {
    return new Date(timestamp * 1000).toISOString()
  }

  /**
   * 从撤回消息内容中提取撤回者的 wxid
   * 撤回消息 XML 格式通常包含 <session> 或 <newmsgid> 等字段
   * 以及撤回者的 wxid 在某些字段中
   * @returns { isRevoke: true, isSelfRevoke: true } - 是自己撤回的消息
   * @returns { isRevoke: true, revokerWxid: string } - 是别人撤回的消息，提取到撤回者
   * @returns { isRevoke: false } - 不是撤回消息
   */
  private extractRevokerInfo(content: string): { isRevoke: boolean; isSelfRevoke?: boolean; revokerWxid?: string } {
    if (!content) return { isRevoke: false }

    // 检查是否是撤回消息
    if (!content.includes('revokemsg') && !content.includes('撤回')) {
      return { isRevoke: false }
    }

    // 检查是否是 "你撤回了" - 自己撤回
    if (content.includes('你撤回')) {
      return { isRevoke: true, isSelfRevoke: true }
    }

    // 尝试从 <session> 标签提取（格式: wxid_xxx）
    const sessionMatch = /<session>([^<]+)<\/session>/i.exec(content)
    if (sessionMatch) {
      const session = sessionMatch[1].trim()
      // 如果 session 是 wxid 格式，返回它
      if (session.startsWith('wxid_') || /^[a-zA-Z][a-zA-Z0-9_-]+$/.test(session)) {
        return { isRevoke: true, revokerWxid: session }
      }
    }

    // 尝试从 <fromusername> 提取
    const fromUserMatch = /<fromusername>([^<]+)<\/fromusername>/i.exec(content)
    if (fromUserMatch) {
      return { isRevoke: true, revokerWxid: fromUserMatch[1].trim() }
    }

    // 是撤回消息但无法提取撤回者
    return { isRevoke: true }
  }

  private extractXmlValue(xml: string, tagName: string): string {
    const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\/${tagName}>`, 'i')
    const match = regex.exec(xml)
    if (match) {
      return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
    }
    return ''
  }

  private extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
    const tagRegex = new RegExp(`<${tagName}\\s+[^>]*${attrName}\\s*=\\s*"([^"]*)"`, 'i')
    const match = tagRegex.exec(xml)
    return match ? match[1] : ''
  }

  private cleanSystemMessage(content: string): string {
    if (!content) return '[系统消息]'

    // 先尝试提取特定的系统消息内容
    // 1. 提取 sysmsg 中的文本内容
    const sysmsgTextMatch = /<sysmsg[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(content)
    if (sysmsgTextMatch) {
      content = sysmsgTextMatch[1]
    }

    // 2. 提取 revokemsg 撤回消息
    const revokeMatch = /<replacemsg><!\[CDATA\[(.*?)\]\]><\/replacemsg>/i.exec(content)
    if (revokeMatch) {
      return revokeMatch[1].trim()
    }

    // 3. 提取 pat 拍一拍消息（sysmsg 内的 template 格式）
    const patMatch = /<template><!\[CDATA\[(.*?)\]\]><\/template>/i.exec(content)
    if (patMatch) {
      // 移除模板变量占位符
      return patMatch[1]
        .replace(/\$\{([^}]+)\}/g, (_, varName) => {
          const varMatch = new RegExp(`<${varName}><!\\\[CDATA\\\[([^\]]*)\\\]\\\]><\/${varName}>`, 'i').exec(content)
          return varMatch ? varMatch[1] : ''
        })
        .replace(/<[^>]+>/g, '')
        .trim()
    }

    // 3.5 提取 <title> 内容（适用于 appmsg 格式的拍一拍等消息）
    const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content)
    if (titleMatch) {
      const title = titleMatch[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
      if (title) {
        return title
      }
    }

    // 4. 处理 CDATA 内容
    content = content.replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '')

    // 5. 移除所有 XML 标签
    return content
      .replace(/<img[^>]*>/gi, '')
      .replace(/<\/?[a-zA-Z0-9_:]+[^>]*>/g, '')
      .replace(/\s+/g, ' ')
      .trim() || '[系统消息]'
  }

  private extractReadableSystemMessageText(content: string): string {
    if (!content) return ''
    const normalized = this.normalizeAppMessageContent(content)
    const sysmsgMatch = /<sysmsg\b[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(this.stripSenderPrefix(normalized))
    const source = sysmsgMatch?.[1] || normalized
    const text =
      this.extractXmlValue(source, 'plain') ||
      this.extractXmlValue(source, 'text') ||
      ''
    return this.stripSenderPrefix(text).replace(/\s+/g, ' ').trim()
  }

  /**
   * 解析通话消息
   * 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
   * room_type: 0 = 语音通话, 1 = 视频通话
   */
  private parseVoipMessage(content: string): string {
    try {
      if (!content) return '[通话]'

      // 提取 msg 内容（中文通话状态）
      const msgMatch = /<msg><!\[CDATA\[(.*?)\]\]><\/msg>/i.exec(content)
      const msg = msgMatch?.[1]?.trim() || ''

      // 提取 room_type（0=视频，1=语音）
      const roomTypeMatch = /<room_type>(\d+)<\/room_type>/i.exec(content)
      const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1

      // 构建通话类型标签
      let callType: string
      if (roomType === 0) {
        callType = '视频通话'
      } else if (roomType === 1) {
        callType = '语音通话'
      } else {
        callType = '通话'
      }

      // 解析通话状态
      if (msg.includes('通话时长')) {
        const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg)
        const duration = durationMatch?.[1] || ''
        if (duration) {
          return `[${callType}] ${duration}`
        }
        return `[${callType}] 已接听`
      } else if (msg.includes('对方无应答')) {
        return `[${callType}] 对方无应答`
      } else if (msg.includes('已取消')) {
        return `[${callType}] 已取消`
      } else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) {
        return `[${callType}] 已在其他设备接听`
      } else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) {
        return `[${callType}] 对方已拒绝`
      } else if (msg.includes('忙线未接听') || msg.includes('忙线')) {
        return `[${callType}] 忙线未接听`
      } else if (msg.includes('未接听')) {
        return `[${callType}] 未接听`
      } else if (msg) {
        return `[${callType}] ${msg}`
      }

      return `[${callType}]`
    } catch (e) {
      return '[通话]'
    }
  }

  /**
   * 获取消息类型名称
   */
  private getMessageTypeName(localType: number, content?: string): string {
    // 检查 XML 中的 type 标签（支持大 localType 的情况）
    if (content) {
      const normalized = this.normalizeAppMessageContent(content)
      if (this.isReadableSystemMessage(localType, normalized)) {
        return '系统消息'
      }
      const xmlType = this.extractAppMessageType(normalized)

      if (xmlType) {
        switch (xmlType) {
          case '3': return '音乐消息'
          case '87': return '群公告'
          case '2000': return '转账消息'
          case '5': return '链接消息'
          case '6': return '文件消息'
          case '19': return '聊天记录'
          case '33':
          case '36': return '小程序消息'
          case '57': return '引用消息'
        }
      }
    }

    const typeNames: Record<number, string> = {
      1: '文本消息',
      3: '图片消息',
      34: '语音消息',
      42: '名片消息',
      43: '视频消息',
      47: '动画表情',
      48: '位置消息',
      49: '链接消息',
      50: '通话消息',
      10000: '系统消息',
      244813135921: '引用消息'
    }
    return typeNames[localType] || '其他消息'
  }

  /**
   * 格式化时间戳为可读字符串
   */
  private formatTimestamp(timestamp: number): string {
    const date = new Date(timestamp * 1000)
    const year = date.getFullYear()
    const month = String(date.getMonth() + 1).padStart(2, '0')
    const day = String(date.getDate()).padStart(2, '0')
    const hours = String(date.getHours()).padStart(2, '0')
    const minutes = String(date.getMinutes()).padStart(2, '0')
    const seconds = String(date.getSeconds()).padStart(2, '0')
    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
  }

  private normalizeTxtColumns(columns?: string[] | null): string[] {
    const fallback = ['index', 'time', 'senderRole', 'messageType', 'content']
    const selected = new Set((columns && columns.length > 0 ? columns : fallback).filter(Boolean))
    const ordered = TXT_COLUMN_DEFINITIONS.map((col) => col.id).filter((id) => selected.has(id))
    return ordered.length > 0 ? ordered : fallback
  }

  private sanitizeTxtValue(value: string): string {
    return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim()
  }

  private escapeHtml(value: string): string {
    return value.replace(/[&<>"']/g, c => {
      switch (c) {
        case '&': return '&amp;'
        case '<': return '&lt;'
        case '>': return '&gt;'
        case '"': return '&quot;'
        case "'": return '&#39;'
        default: return c
      }
    })
  }

  private escapeAttribute(value: string): string {
    return value.replace(/[&<>"'`]/g, c => {
      switch (c) {
        case '&': return '&amp;'
        case '<': return '&lt;'
        case '>': return '&gt;'
        case '"': return '&quot;'
        case "'": return '&#39;'
        case '`': return '&#96;'
        default: return c
      }
    })
  }

  private getAvatarFallback(name: string): string {
    if (!name) return '?'
    return [...name][0] || '?'
  }

  private renderMultilineText(value: string): string {
    return this.escapeHtml(value).replace(/\r?\n/g, '<br />')
  }

  private loadExportHtmlStyles(): string {
    if (this.htmlStyleCache !== null) {
      return this.htmlStyleCache
    }
    const candidates = [
      path.join(__dirname, 'exportHtml.css'),
      path.join(process.cwd(), 'electron', 'services', 'exportHtml.css')
    ]
    for (const filePath of candidates) {
      if (fs.existsSync(filePath)) {
        try {
          const content = fs.readFileSync(filePath, 'utf-8')
          if (content.trim().length > 0) {
            this.htmlStyleCache = content
            return content
          }
        } catch {
          continue
        }
      }
    }
    this.htmlStyleCache = EXPORT_HTML_STYLES
    return this.htmlStyleCache
  }

  /**
   * 解析合并转发的聊天记录 (Type 19)
   */
  private parseChatHistory(content: string): ForwardChatRecordItem[] | undefined {
    try {
      const normalized = this.normalizeAppMessageContent(content || '')
      const appMsgType = this.extractAppMessageType(normalized)
      if (appMsgType !== '19' && !normalized.includes('<recorditem')) {
        return undefined
      }

      const items: ForwardChatRecordItem[] = []
      const dedupe = new Set<string>()
      const recordItemRegex = /<recorditem>([\s\S]*?)<\/recorditem>/gi
      let recordItemMatch: RegExpExecArray | null
      while ((recordItemMatch = recordItemRegex.exec(normalized)) !== null) {
        const parsedItems = this.parseForwardChatRecordContainer(recordItemMatch[1] || '')
        for (const item of parsedItems) {
          const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}`
          if (!dedupe.has(dedupeKey)) {
            dedupe.add(dedupeKey)
            items.push(item)
          }
        }
      }

      if (items.length === 0 && normalized.includes('<dataitem')) {
        const fallbackItems = this.parseForwardChatRecordContainer(normalized)
        for (const item of fallbackItems) {
          const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}`
          if (!dedupe.has(dedupeKey)) {
            dedupe.add(dedupeKey)
            items.push(item)
          }
        }
      }

      return items.length > 0 ? items : undefined
    } catch (e) {
      console.error('ExportService: 解析聊天记录失败:', e)
      return undefined
    }
  }

  private parseForwardChatRecordContainer(containerXml: string): ForwardChatRecordItem[] {
    const source = containerXml || ''
    if (!source) return []

    const segments: string[] = [source]
    const decodedContainer = this.decodeHtmlEntities(source)
    if (decodedContainer !== source) {
      segments.push(decodedContainer)
    }

    const cdataRegex = /<!\[CDATA\[([\s\S]*?)\]\]>/g
    let cdataMatch: RegExpExecArray | null
    while ((cdataMatch = cdataRegex.exec(source)) !== null) {
      const cdataInner = cdataMatch[1] || ''
      if (cdataInner) {
        segments.push(cdataInner)
        const decodedInner = this.decodeHtmlEntities(cdataInner)
        if (decodedInner !== cdataInner) {
          segments.push(decodedInner)
        }
      }
    }

    const items: ForwardChatRecordItem[] = []
    const seen = new Set<string>()
    for (const segment of segments) {
      if (!segment) continue
      const dataItemRegex = /<dataitem\b([^>]*)>([\s\S]*?)<\/dataitem>/gi
      let dataItemMatch: RegExpExecArray | null
      while ((dataItemMatch = dataItemRegex.exec(segment)) !== null) {
        const parsed = this.parseForwardChatRecordDataItem(dataItemMatch[2] || '', dataItemMatch[1] || '')
        if (!parsed) continue
        const key = `${parsed.datatype}|${parsed.sourcename}|${parsed.sourcetime}|${parsed.datadesc || ''}|${parsed.datatitle || ''}`
        if (!seen.has(key)) {
          seen.add(key)
          items.push(parsed)
        }
      }
    }

    if (items.length > 0) return items
    const fallback = this.parseForwardChatRecordDataItem(source, '')
    return fallback ? [fallback] : []
  }

  private parseForwardChatRecordDataItem(body: string, attrs: string): ForwardChatRecordItem | null {
    const datatypeByAttr = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '')
    const datatypeRaw = datatypeByAttr?.[1] || this.extractXmlValue(body, 'datatype') || '0'
    const datatype = Number.parseInt(datatypeRaw, 10)
    const sourcename = this.decodeHtmlEntities(this.extractXmlValue(body, 'sourcename'))
    const sourcetime = this.extractXmlValue(body, 'sourcetime')
    const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl')
    const datadesc = this.decodeHtmlEntities(this.extractXmlValue(body, 'datadesc') || this.extractXmlValue(body, 'content'))
    const datatitle = this.decodeHtmlEntities(this.extractXmlValue(body, 'datatitle'))
    const fileext = this.extractXmlValue(body, 'fileext')
    const datasizeRaw = this.extractXmlValue(body, 'datasize')
    const datasize = datasizeRaw ? Number.parseInt(datasizeRaw, 10) : 0
    const nestedRecordXml = this.extractXmlValue(body, 'recordxml') || ''
    const nestedRecordList =
      datatype === 17 && nestedRecordXml
        ? this.parseForwardChatRecordContainer(nestedRecordXml)
        : undefined
    const chatRecordTitle = this.decodeHtmlEntities(
      (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'title')) || datatitle || ''
    )
    const chatRecordDesc = this.decodeHtmlEntities(
      (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'desc')) || datadesc || ''
    )

    if (!sourcename && !datadesc && !datatitle) return null

    return {
      datatype: Number.isFinite(datatype) ? datatype : 0,
      sourcename: sourcename || '',
      sourcetime: sourcetime || '',
      sourceheadurl: sourceheadurl || undefined,
      datadesc: datadesc || undefined,
      datatitle: datatitle || undefined,
      fileext: fileext || undefined,
      datasize: Number.isFinite(datasize) && datasize > 0 ? datasize : undefined,
      chatRecordTitle: chatRecordTitle || undefined,
      chatRecordDesc: chatRecordDesc || undefined,
      chatRecordList: nestedRecordList && nestedRecordList.length > 0 ? nestedRecordList : undefined
    }
  }

  private formatForwardChatRecordItemText(item: ForwardChatRecordItem): string {
    const desc = (item.datadesc || '').trim()
    const title = (item.datatitle || '').trim()
    if (desc) return desc
    if (title) return title
    switch (item.datatype) {
      case 3: return '[图片]'
      case 34: return '[语音消息]'
      case 43: return '[视频]'
      case 47: return '[表情包]'
      case 49:
      case 8: return title ? `[文件] ${title}` : '[文件]'
      case 17: return item.chatRecordDesc || title || '[聊天记录]'
      default: return '[消息]'
    }
  }

  private buildForwardChatRecordLines(record: ForwardChatRecordItem, depth = 0): string[] {
    const indent = depth > 0 ? `${'  '.repeat(Math.min(depth, 8))}` : ''
    const senderPrefix = record.sourcename ? `${record.sourcename}: ` : ''
    if (record.chatRecordList && record.chatRecordList.length > 0) {
      const nestedTitle = record.chatRecordTitle || record.datatitle || record.chatRecordDesc || '聊天记录'
      const header = `${indent}${senderPrefix}[转发的聊天记录]${nestedTitle}`
      const nestedLines = record.chatRecordList.flatMap((item) => this.buildForwardChatRecordLines(item, depth + 1))
      return [header, ...nestedLines]
    }
    const text = this.formatForwardChatRecordItemText(record)
    return [`${indent}${senderPrefix}${text}`]
  }

  private formatForwardChatRecordContent(content: string): string {
    const normalized = this.normalizeAppMessageContent(content || '')
    const forwardName =
      this.extractXmlValue(normalized, 'nickname') ||
      this.extractXmlValue(normalized, 'title') ||
      this.extractXmlValue(normalized, 'des') ||
      this.extractXmlValue(normalized, 'displayname') ||
      '聊天记录'
    const records = this.parseChatHistory(normalized)
    if (!records || records.length === 0) {
      return forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'
    }

    const lines = records.flatMap((record) => this.buildForwardChatRecordLines(record))
    return `${forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'}\n${lines.join('\n')}`
  }

  /**
   * 解码 HTML 实体
   */
  private decodeHtmlEntities(text: string): string {
    if (!text) return ''
    return text
      .replace(/&lt;/g, '<')
      .replace(/&gt;/g, '>')
      .replace(/&amp;/g, '&')
      .replace(/&quot;/g, '"')
      .replace(/&#39;/g, "'")
      .replace(/&apos;/g, "'")
  }

  private normalizeAppMessageContent(content: string): string {
    if (!content) return ''
    if (content.includes('&lt;') && content.includes('&gt;')) {
      return content
        .replace(/&lt;/g, '<')
        .replace(/&gt;/g, '>')
        .replace(/&amp;/g, '&')
        .replace(/&quot;/g, '"')
        .replace(/&#39;/g, "'")
    }
    return content
  }

  private extractFinderFeedDesc(content: string): string {
    if (!content) return ''
    const match = /<finderFeed[\s\S]*?<desc>([\s\S]*?)<\/desc>/i.exec(content)
    if (!match) return ''
    return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
  }

  private extractAppMessageType(content: string): string {
    if (!content) return ''
    const normalized = this.normalizeAppMessageContent(content)
    const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(normalized)
    if (appmsgMatch) {
      const appmsgInner = appmsgMatch[1]
        .replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
        .replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
      const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner)
      if (typeMatch) return typeMatch[1].trim()
    }
    if (!normalized.includes('<appmsg') && !normalized.includes('<msg>')) {
      return ''
    }
    const fallbackTypeMatch = /<type>(\d+)<\/type>/i.exec(normalized)
    return fallbackTypeMatch ? fallbackTypeMatch[1] : ''
  }

  private looksLikeWxid(text: string): boolean {
    if (!text) return false
    const trimmed = text.trim().toLowerCase()
    if (trimmed.startsWith('wxid_')) return true
    return /^wx[a-z0-9_-]{4,}$/.test(trimmed)
  }

  private sanitizeQuotedContent(content: string): string {
    if (!content) return ''
    let result = content
    result = result.replace(/wxid_[A-Za-z0-9_-]{3,}/g, '')
    result = result.replace(/^[\s:：\-]+/, '')
    result = result.replace(/[:：]{2,}/g, ':')
    result = result.replace(/^[\s:：\-]+/, '')
    result = result.replace(/\s+/g, ' ').trim()
    return result
  }

  private async resolveQuotedMessagesForExport(messages: any[], sessionId: string): Promise<void> {
    const svridsToResolve: Array<{ msg: any; svrid: string }> = []

    for (const msg of messages) {
      if (msg.replyToMessageId && msg.quotedContent === '[消息]') {
        svridsToResolve.push({ msg, svrid: msg.replyToMessageId })
      }
    }

    if (svridsToResolve.length === 0) return

    const results = await Promise.allSettled(
      svridsToResolve.map(({ svrid }) => wcdbService.getMessageByServerId(sessionId, svrid))
    )

    for (let i = 0; i < results.length; i++) {
      const result = results[i]
      const { msg } = svridsToResolve[i]

      if (result.status === 'fulfilled' && result.value.success && result.value.row) {
        const localType = parseInt(result.value.row.local_type || '0', 10)
        const rawMessageContent = result.value.row.message_content
        const rawCompressContent = result.value.row.compress_content
        const content = chatService['decodeMessageContent'](rawMessageContent, rawCompressContent)

        if (localType === 1) {
          msg.quotedContent = chatService['sanitizeQuotedContent'](content)
        } else if (localType === 3) {
          msg.quotedContent = '[图片]'
        } else if (localType === 34) {
          msg.quotedContent = '[语音]'
        } else if (localType === 43) {
          msg.quotedContent = '[视频]'
        } else if (localType === 47) {
          msg.quotedContent = '[动画表情]'
        } else if (localType === 49) {
          msg.quotedContent = '[链接]'
        }
      }
    }
  }

  private parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string; svrid?: string } {
    try {
      const normalized = this.normalizeAppMessageContent(content || '')
      const referMsgStart = normalized.indexOf('<refermsg>')
      const referMsgEnd = normalized.indexOf('</refermsg>')
      if (referMsgStart === -1 || referMsgEnd === -1) {
        return {}
      }

      const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
      let sender = this.extractXmlValue(referMsgXml, 'displayname')
      if (sender && this.looksLikeWxid(sender)) {
        sender = ''
      }

      const referContent = this.extractXmlValue(referMsgXml, 'content')
      const referType = this.extractXmlValue(referMsgXml, 'type')
      const svrid = this.extractXmlValue(referMsgXml, 'svrid')
      let displayContent = referContent

      switch (referType) {
        case '1':
          displayContent = this.extractPreferredQuotedText(referMsgXml)
          break
        case '3':
          displayContent = '[图片]'
          break
        case '34':
          displayContent = '[语音]'
          break
        case '43':
          displayContent = '[视频]'
          break
        case '47':
          displayContent = '[表情包]'
          break
        case '49':
          displayContent = '[链接]'
          break
        case '42':
          displayContent = '[名片]'
          break
        case '48':
          displayContent = '[位置]'
          break
        default:
          if (!referContent || referContent.includes('wxid_')) {
            displayContent = '[消息]'
          } else {
            displayContent = this.sanitizeQuotedContent(referContent)
          }
      }

      return {
        content: displayContent || undefined,
        sender: sender || undefined,
        type: referType || undefined
      }
    } catch {
      return {}
    }
  }

  private extractPreferredQuotedText(referMsgXml: string): string {
    if (!referMsgXml) return ''

    const sources = [this.decodeHtmlEntities(referMsgXml)]
    const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource')
    if (rawMsgSource) {
      const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource)
      if (decodedMsgSource) {
        sources.push(decodedMsgSource)
      }
    }

    const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content'))
    const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent)
    if (partialText) return partialText

    const candidateTags = [
      'selectedcontent',
      'selectedtext',
      'selectcontent',
      'selecttext',
      'quotecontent',
      'quotetext',
      'partcontent',
      'parttext',
      'excerpt',
      'summary',
      'preview'
    ]

    for (const source of sources) {
      for (const tag of candidateTags) {
        const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag))
        if (value) return value
      }
    }

    return fullContent
  }

  private extractPartialQuotedText(xml: string, fullContent: string): string {
    if (!xml || !fullContent) return ''

    const startChar = this.extractXmlValue(xml, 'start')
    const endChar = this.extractXmlValue(xml, 'end')
    const startIndexRaw = this.extractXmlValue(xml, 'startindex')
    const endIndexRaw = this.extractXmlValue(xml, 'endindex')
    const startIndex = Number.parseInt(startIndexRaw, 10)
    const endIndex = Number.parseInt(endIndexRaw, 10)

    if (startChar && endChar) {
      const startPos = fullContent.indexOf(startChar)
      if (startPos !== -1) {
        const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1)
        if (endPos !== -1 && endPos >= startPos) {
          const sliced = fullContent.slice(startPos, endPos + endChar.length).trim()
          if (sliced) return sliced
        }
      }
    }

    if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) {
      const chars = Array.from(fullContent)
      const sliced = chars.slice(startIndex, endIndex + 1).join('').trim()
      if (sliced) return sliced
    }

    return ''
  }

  private extractChatLabReplyToMessageId(content: string): string | undefined {
    try {
      const normalized = this.normalizeAppMessageContent(content || '')
      const referMsgStart = normalized.indexOf('<refermsg>')
      const referMsgEnd = normalized.indexOf('</refermsg>')
      if (referMsgStart === -1 || referMsgEnd === -1) {
        return undefined
      }

      const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
      const replyToMessageIdRaw = this.normalizeUnsignedIntToken(this.extractXmlValue(referMsgXml, 'svrid'))
      return replyToMessageIdRaw !== '0' ? replyToMessageIdRaw : undefined
    } catch {
      return undefined
    }
  }

  private getExportPlatformMessageId(msg: { serverIdRaw?: unknown; serverId?: unknown }): string | undefined {
    const value = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId)
    return value !== '0' ? value : undefined
  }

  private getExportReplyToMessageId(content: string): string | undefined {
    return this.extractChatLabReplyToMessageId(content)
  }

  private extractArkmeAppMessageMeta(content: string, localType: number): Record<string, any> | null {
    if (!content) return null

    const normalized = this.normalizeAppMessageContent(content)
    const looksLikeAppMsg =
      localType === 49 ||
      localType === 244813135921 ||
      normalized.includes('<appmsg') ||
      normalized.includes('<msg>')
    const hasReferMsg = normalized.includes('<refermsg>')
    const xmlType = this.extractAppMessageType(normalized)
    const isFinder =
      xmlType === '51' ||
      normalized.includes('<finder') ||
      normalized.includes('finderusername') ||
      normalized.includes('finderobjectid')
    const isMusic =
      xmlType === '3' ||
      normalized.includes('<musicurl') ||
      normalized.includes('<playurl>') ||
      normalized.includes('<dataurl>')

    if (!looksLikeAppMsg && !isFinder && !hasReferMsg) return null

    let appMsgKind: string | undefined
    if (isFinder) {
      appMsgKind = 'finder'
    } else if (xmlType === '2001') {
      appMsgKind = 'red-packet'
    } else if (isMusic) {
      appMsgKind = 'music'
    } else if (xmlType === '33' || xmlType === '36') {
      appMsgKind = 'miniapp'
    } else if (xmlType === '6') {
      appMsgKind = 'file'
    } else if (xmlType === '19') {
      appMsgKind = 'chat-record'
    } else if (xmlType === '2000') {
      appMsgKind = 'transfer'
    } else if (xmlType === '87') {
      appMsgKind = 'announcement'
    } else if (xmlType === '57' || hasReferMsg || localType === 244813135921) {
      appMsgKind = 'quote'
    } else if (xmlType === '53') {
      appMsgKind = 'solitaire'
    } else if (xmlType === '5' || xmlType === '49') {
      appMsgKind = 'link'
    } else if (looksLikeAppMsg) {
      appMsgKind = 'card'
    }

    const meta: Record<string, any> = {}
    if (xmlType) meta.appMsgType = xmlType
    else if (appMsgKind === 'quote') meta.appMsgType = '57'
    if (appMsgKind) meta.appMsgKind = appMsgKind

    const appMsgDesc = this.extractXmlValue(normalized, 'des') || this.extractXmlValue(normalized, 'desc')
    const appMsgAppName = this.extractXmlValue(normalized, 'appname')
    const appMsgSourceName =
      this.extractXmlValue(normalized, 'sourcename') ||
      this.extractXmlValue(normalized, 'sourcedisplayname')
    const appMsgSourceUsername = this.extractXmlValue(normalized, 'sourceusername')
    const appMsgThumbUrl =
      this.extractXmlValue(normalized, 'thumburl') ||
      this.extractXmlValue(normalized, 'cdnthumburl') ||
      this.extractXmlValue(normalized, 'cover') ||
      this.extractXmlValue(normalized, 'coverurl') ||
      this.extractXmlValue(normalized, 'thumbUrl') ||
      this.extractXmlValue(normalized, 'coverUrl')

    if (appMsgDesc) meta.appMsgDesc = appMsgDesc
    if (appMsgAppName) meta.appMsgAppName = appMsgAppName
    if (appMsgSourceName) meta.appMsgSourceName = appMsgSourceName
    if (appMsgSourceUsername) meta.appMsgSourceUsername = appMsgSourceUsername
    if (appMsgThumbUrl) meta.appMsgThumbUrl = appMsgThumbUrl

    if (appMsgKind === 'quote') {
      const quoteInfo = this.parseQuoteMessage(normalized)
      if (quoteInfo.content) meta.quotedContent = quoteInfo.content
      if (quoteInfo.sender) meta.quotedSender = quoteInfo.sender
      if (quoteInfo.type) meta.quotedType = quoteInfo.type
      if (quoteInfo.svrid) meta.quotedSvrid = quoteInfo.svrid
    }

    if (appMsgKind === 'link') {
      const linkCard = this.extractHtmlLinkCard(normalized, localType)
      const linkUrl = linkCard?.url || this.normalizeHtmlLinkUrl(
        this.extractXmlValue(normalized, 'shareurl') ||
        this.extractXmlValue(normalized, 'shorturl') ||
        this.extractXmlValue(normalized, 'dataurl')
      )
      if (linkCard?.title) meta.linkTitle = linkCard.title
      if (linkUrl) meta.linkUrl = linkUrl
      if (appMsgThumbUrl) meta.linkThumb = appMsgThumbUrl
    }

    if (isMusic) {
      const musicTitle =
        this.extractXmlValue(normalized, 'songname') ||
        this.extractXmlValue(normalized, 'title')
      const musicUrl =
        this.extractXmlValue(normalized, 'musicurl') ||
        this.extractXmlValue(normalized, 'playurl') ||
        this.extractXmlValue(normalized, 'songalbumurl')
      const musicDataUrl =
        this.extractXmlValue(normalized, 'dataurl') ||
        this.extractXmlValue(normalized, 'lowurl')
      const musicAlbumUrl = this.extractXmlValue(normalized, 'songalbumurl')
      const musicCoverUrl =
        this.extractXmlValue(normalized, 'thumburl') ||
        this.extractXmlValue(normalized, 'cdnthumburl') ||
        this.extractXmlValue(normalized, 'coverurl') ||
        this.extractXmlValue(normalized, 'cover')
      const musicSinger =
        this.extractXmlValue(normalized, 'singername') ||
        this.extractXmlValue(normalized, 'artist') ||
        this.extractXmlValue(normalized, 'albumartist')
      const musicAppName = this.extractXmlValue(normalized, 'appname')
      const musicSourceName = this.extractXmlValue(normalized, 'sourcename')
      const durationRaw =
        this.extractXmlValue(normalized, 'playlength') ||
        this.extractXmlValue(normalized, 'play_length') ||
        this.extractXmlValue(normalized, 'duration')
      const musicDuration = durationRaw ? this.parseDurationSeconds(durationRaw) : null

      if (musicTitle) meta.musicTitle = musicTitle
      if (musicUrl) meta.musicUrl = musicUrl
      if (musicDataUrl) meta.musicDataUrl = musicDataUrl
      if (musicAlbumUrl) meta.musicAlbumUrl = musicAlbumUrl
      if (musicCoverUrl) meta.musicCoverUrl = musicCoverUrl
      if (musicSinger) meta.musicSinger = musicSinger
      if (musicAppName) meta.musicAppName = musicAppName
      if (musicSourceName) meta.musicSourceName = musicSourceName
      if (musicDuration != null) meta.musicDuration = musicDuration
    }

    if (!isFinder) {
      return Object.keys(meta).length > 0 ? meta : null
    }

    const rawTitle = this.extractXmlValue(normalized, 'title')
    const finderFeedDesc = this.extractFinderFeedDesc(normalized)
    const finderTitle = (!rawTitle || rawTitle.includes('不支持')) ? finderFeedDesc : rawTitle
    const finderDesc = this.extractXmlValue(normalized, 'des') || this.extractXmlValue(normalized, 'desc')
    const finderUsername =
      this.extractXmlValue(normalized, 'finderusername') ||
      this.extractXmlValue(normalized, 'finder_username') ||
      this.extractXmlValue(normalized, 'finderuser')
    const finderNickname =
      this.extractXmlValue(normalized, 'findernickname') ||
      this.extractXmlValue(normalized, 'finder_nickname')
    const finderCoverUrl =
      this.extractXmlValue(normalized, 'thumbUrl') ||
      this.extractXmlValue(normalized, 'coverUrl') ||
      this.extractXmlValue(normalized, 'thumburl') ||
      this.extractXmlValue(normalized, 'coverurl')
    const finderAvatar = this.extractXmlValue(normalized, 'avatar')
    const durationRaw = this.extractXmlValue(normalized, 'videoPlayDuration') || this.extractXmlValue(normalized, 'duration')
    const finderDuration = durationRaw ? this.parseDurationSeconds(durationRaw) : null
    const finderObjectId =
      this.extractXmlValue(normalized, 'finderobjectid') ||
      this.extractXmlValue(normalized, 'finder_objectid') ||
      this.extractXmlValue(normalized, 'objectid') ||
      this.extractXmlValue(normalized, 'object_id')
    const finderUrl =
      this.extractXmlValue(normalized, 'url') ||
      this.extractXmlValue(normalized, 'shareurl')

    if (finderTitle) meta.finderTitle = finderTitle
    if (finderDesc) meta.finderDesc = finderDesc
    if (finderUsername) meta.finderUsername = finderUsername
    if (finderNickname) meta.finderNickname = finderNickname
    if (finderCoverUrl) meta.finderCoverUrl = finderCoverUrl
    if (finderAvatar) meta.finderAvatar = finderAvatar
    if (finderDuration != null) meta.finderDuration = finderDuration
    if (finderObjectId) meta.finderObjectId = finderObjectId
    if (finderUrl) meta.finderUrl = finderUrl

    return Object.keys(meta).length > 0 ? meta : null
  }

  private extractArkmeContactCardMeta(content: string, localType: number): Record<string, any> | null {
    if (!content || localType !== 42) return null

    const normalized = this.normalizeAppMessageContent(content)
    const readAttr = (attrName: string): string =>
      this.extractXmlAttribute(normalized, 'msg', attrName) || this.extractXmlValue(normalized, attrName)

    const contactCardWxid =
      readAttr('username') ||
      readAttr('encryptusername') ||
      readAttr('encrypt_user_name')
    const contactCardNickname = readAttr('nickname')
    const contactCardAlias = readAttr('alias')
    const contactCardRemark = readAttr('remark')
    const contactCardProvince = readAttr('province')
    const contactCardCity = readAttr('city')
    const contactCardSignature = readAttr('sign') || readAttr('signature')
    const contactCardAvatar =
      readAttr('smallheadimgurl') ||
      readAttr('bigheadimgurl') ||
      readAttr('headimgurl') ||
      readAttr('avatar')
    const sexRaw = readAttr('sex')
    const contactCardGender = sexRaw ? parseInt(sexRaw, 10) : NaN

    const meta: Record<string, any> = {
      cardKind: 'contact-card'
    }
    if (contactCardWxid) meta.contactCardWxid = contactCardWxid
    if (contactCardNickname) meta.contactCardNickname = contactCardNickname
    if (contactCardAlias) meta.contactCardAlias = contactCardAlias
    if (contactCardRemark) meta.contactCardRemark = contactCardRemark
    if (contactCardProvince) meta.contactCardProvince = contactCardProvince
    if (contactCardCity) meta.contactCardCity = contactCardCity
    if (contactCardSignature) meta.contactCardSignature = contactCardSignature
    if (contactCardAvatar) meta.contactCardAvatar = contactCardAvatar
    if (Number.isFinite(contactCardGender) && contactCardGender >= 0) {
      meta.contactCardGender = contactCardGender
    }

    return Object.keys(meta).length > 0 ? meta : null
  }

  private getInlineEmojiDataUrl(name: string): string | null {
    if (!name) return null
    const cached = this.inlineEmojiCache.get(name)
    if (cached) return cached
    const emojiPath = getEmojiPath(name as any)
    if (!emojiPath) return null
    const baseDir = path.dirname(require.resolve('wechat-emojis'))
    const absolutePath = path.join(baseDir, emojiPath)
    if (!fs.existsSync(absolutePath)) return null
    try {
      const buffer = fs.readFileSync(absolutePath)
      const dataUrl = `data:image/png;base64,${buffer.toString('base64')}`
      this.inlineEmojiCache.set(name, dataUrl)
      return dataUrl
    } catch {
      return null
    }
  }

  private renderTextWithEmoji(text: string): string {
    if (!text) return ''
    const parts = text.split(/\[(.*?)\]/g)
    const rendered = parts.map((part, index) => {
      if (index % 2 === 1) {
        const emojiDataUrl = this.getInlineEmojiDataUrl(part)
        if (emojiDataUrl) {
          // Cache full <img> tag to avoid re-escaping data URL every time
          const escapedName = this.escapeAttribute(part)
          return `<img class="inline-emoji" src="${emojiDataUrl}" alt="[${escapedName}]" />`
        }
        return this.escapeHtml(`[${part}]`)
      }
      return this.escapeHtml(part)
    })
    return rendered.join('')
  }

  private formatHtmlMessageText(
    content: string,
    localType: number,
    myWxid?: string,
    senderWxid?: string,
    isSend?: boolean,
    emojiCaption?: string
  ): string {
    if (!content && localType === 47) {
      return this.formatEmojiSemanticText(emojiCaption)
    }
    if (!content) return ''

    const readableSystemText = this.extractReadableSystemMessageText(content)
    if (readableSystemText && this.isReadableSystemMessage(localType, content)) {
      return readableSystemText
    }

    if (localType === 1) {
      return this.stripSenderPrefix(content)
    }

    if (localType === 34) {
      return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend, emojiCaption) || ''
    }

    return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend, emojiCaption)
  }

  private extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null {
    if (!content) return null

    const normalized = this.normalizeAppMessageContent(content)
    const isAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')
    if (!isAppMessage) return null

    const subType = this.extractAppMessageType(normalized)
    if (subType && subType !== '5' && subType !== '49') return null

    const url = [
      this.extractXmlValue(normalized, 'url'),
      this.extractXmlValue(normalized, 'shareurlopen'),
      this.extractXmlValue(normalized, 'shareurloriginal'),
      this.extractXmlValue(normalized, 'shareurl'),
      this.extractXmlValue(normalized, 'shorturl'),
      this.extractXmlValue(normalized, 'dataurl'),
      this.extractXmlValue(normalized, 'lowurl'),
      this.extractXmlValue(normalized, 'streamvideoweburl'),
      this.extractXmlValue(normalized, 'weburl')
    ]
      .map(candidate => this.normalizeHtmlLinkUrl(candidate))
      .find(Boolean) || ''
    if (!url) return null

    const title = this.stripSenderPrefix(
      this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url
    ) || url
    return { title, url }
  }

  private normalizeHtmlLinkUrl(rawUrl: string): string {
    const value = (rawUrl || '').trim().replace(/&amp;/gi, '&')
    if (!value) return ''

    const parseHttpUrl = (candidate: string): string => {
      try {
        const parsed = new URL(candidate)
        if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
          return parsed.toString()
        }
      } catch {
        return ''
      }
      return ''
    }

    if (value.startsWith('//')) {
      return parseHttpUrl(`https:${value}`)
    }

    const direct = parseHttpUrl(value)
    if (direct) return direct

    const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)
    const isDomainLike = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:[/:?#].*)?$/.test(value)
    if (!hasScheme && isDomainLike) {
      return parseHttpUrl(`https://${value}`)
    }

    return ''
  }

  private getLinkCardDisplayTitle(linkCard: { title: string; url: string }): string {
    const normalizedTitle = this.stripSenderPrefix(String(linkCard.title || '').trim())
    return normalizedTitle || linkCard.url || '链接'
  }

  private formatLinkCardExportText(
    content: string,
    localType: number,
    style: 'markdown' | 'append-url'
  ): string | null {
    const linkCard = this.extractHtmlLinkCard(content, localType)
    if (!linkCard?.url) return null

    const title = this.getLinkCardDisplayTitle(linkCard)
    if (style === 'markdown') {
      return `[${title}](${linkCard.url})`
    }

    const prefix = title && title !== linkCard.url ? `[链接] ${title}` : '[链接]'
    return `${prefix}\n${linkCard.url}`
  }

  private applyExcelLinkCardCell(cell: ExcelJS.Cell, content: string, localType: number): boolean {
    const linkCard = this.extractHtmlLinkCard(content, localType)
    if (!linkCard?.url) return false

    const title = this.getLinkCardDisplayTitle(linkCard)
    cell.value = {
      text: title,
      hyperlink: linkCard.url,
      tooltip: linkCard.url
    } as any
    cell.font = {
      ...(cell.font || {}),
      color: { argb: 'FF0563C1' },
      underline: true
    }
    return true
  }

  /**
   * 导出媒体文件到指定目录
   */
  private async exportMediaForMessage(
    msg: any,
    sessionId: string,
    mediaRootDir: string,
    mediaRelativePrefix: string,
    options: {
      exportImages?: boolean
      exportVoices?: boolean
      exportVideos?: boolean
      exportEmojis?: boolean
      exportFiles?: boolean
      maxFileSizeMb?: number
      exportVoiceAsText?: boolean
      includeVideoPoster?: boolean
      includeVoiceWithTranscript?: boolean
      dirCache?: Set<string>
      control?: ExportTaskControl
    }
  ): Promise<MediaExportItem | null> {
    const localType = msg.localType

    // 图片消息
    if (localType === 3 && options.exportImages) {
      const result = await this.exportImage(
        msg,
        sessionId,
        mediaRootDir,
        mediaRelativePrefix,
        options.dirCache,
        options.control
      )
      if (result) {
      }
      return result
    }

    // 语音消息
    if (localType === 34) {
      if (options.exportVoices) {
        return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
      }
      if (options.exportVoiceAsText) {
        return null
      }
    }

    // 动画表情
    if (localType === 47 && options.exportEmojis) {
      const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
      if (result) {
      }
      return result
    }

    if (localType === 43 && options.exportVideos) {
      return this.exportVideo(
        msg,
        sessionId,
        mediaRootDir,
        mediaRelativePrefix,
        options.dirCache,
        options.includeVideoPoster === true,
        options.control
      )
    }

    if (options.exportFiles && this.isFileAppMessage(msg)) {
      return this.exportFileAttachment(
        msg,
        mediaRootDir,
        mediaRelativePrefix,
        options.maxFileSizeMb,
        options.dirCache,
        options.control
      )
    }

    return null
  }

  /**
   * 导出图片文件
   */
  private async exportImage(
    msg: any,
    sessionId: string,
    mediaRootDir: string,
    mediaRelativePrefix: string,
    dirCache?: Set<string>,
    control?: ExportTaskControl
  ): Promise<MediaExportItem | null> {
    try {
      const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
      await this.ensureExportDir(imagesDir, control, dirCache)

      const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => {
        if (!imageMd5 && !imageDatName) return null
        return this.runWithChatImagePipelineLimit(async () => {
          const pickResolvedImagePath = (result: any): string | null => {
            if (!result?.success) return null
            const resolved = String(result.localPath || '').trim()
            return resolved || null
          }

          const resolveCachedPath = async (candidateMd5?: string, candidateDatName?: string): Promise<string | null> => {
            const cachedResult = await imageDecryptService.resolveCachedImage({
              sessionId,
              imageMd5: candidateMd5,
              imageDatName: candidateDatName,
              createTime: msg.createTime,
              preferFilePath: true,
              hardlinkOnly: true,
              disableUpdateCheck: true,
              allowCacheIndex: true,
              suppressEvents: true
            })
            return pickResolvedImagePath(cachedResult)
          }

          const cachedPath = await resolveCachedPath(imageMd5, imageDatName)
          if (cachedPath) {
            return cachedPath
          }

          const decryptResult = await imageDecryptService.decryptImage({
            sessionId,
            imageMd5,
            imageDatName,
            createTime: msg.createTime,
            force: false,
            preferFilePath: true,
            hardlinkOnly: true,
            allowCacheIndex: true
          })
          const decryptedPath = pickResolvedImagePath(decryptResult)
          if (decryptedPath) return decryptedPath

          const localId = Number(msg?.localId || 0)
          if (Number.isFinite(localId) && localId > 0) {
            const fallback = await chatService.getImageData(sessionId, String(localId))
            if (fallback.success && fallback.data) {
              const buffer = Buffer.from(fallback.data, 'base64')
              const mime = this.detectMimeType(buffer) || 'image/jpeg'
              return `data:${mime};base64,${fallback.data}`
            }
          }

          if (decryptResult.failureKind === 'decrypt_failed') {
            console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
          } else {
            console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
          }

          const thumbResult = await imageDecryptService.resolveCachedImage({
            sessionId,
            imageMd5,
            imageDatName,
            createTime: msg.createTime,
            preferFilePath: true,
            hardlinkOnly: true,
            disableUpdateCheck: true,
            allowCacheIndex: true,
            suppressEvents: true
          })
          if (thumbResult.success && thumbResult.localPath) {
            console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
            return thumbResult.localPath
          }
          return null
        })
      }

      // 使用消息对象中已提取的字段，先尝试快速导出。
      let imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase() || undefined
      let imageDatName = String(msg.imageDatName || '').trim().toLowerCase() || undefined
      const initialMissingRunCacheKey = this.getImageMissingRunCacheKey(sessionId, imageMd5, imageDatName)
      if (initialMissingRunCacheKey && this.mediaRunMissingImageKeys.has(initialMissingRunCacheKey)) {
        return null
      }
      let sourcePath = await tryResolveImagePath(imageMd5, imageDatName)

      // 快速流字段存在偏差时，按 localId 强制回填再重试一次，避免“导出进度前进但写入 0”。
      if (!sourcePath) {
        const localId = Number(msg?.localId || 0)
        if (Number.isFinite(localId) && localId > 0) {
          await this.backfillMediaFieldsFromMessageDetail(sessionId, [msg], new Set([3]), undefined, { force: true })
          imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase() || undefined
          imageDatName = String(msg.imageDatName || '').trim().toLowerCase() || undefined
          sourcePath = await tryResolveImagePath(imageMd5, imageDatName)
        }
      }

      if (!sourcePath) {
        const missingRunCacheKey = this.getImageMissingRunCacheKey(sessionId, imageMd5, imageDatName)
        console.log(`[Export] 缩略图也获取失败，所有方式均失败 → 将显示 [图片] 占位符`)
        if (missingRunCacheKey) {
          this.mediaRunMissingImageKeys.add(missingRunCacheKey)
        }
        return null
      }

      // 为每条消息生成稳定且唯一的文件名前缀，避免跨日期/消息发生同名覆盖
      const messageId = String(msg.localId || Date.now())
      const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '')

      // 从 data URL 或 file URL 获取实际路径
      if (sourcePath.startsWith('data:')) {
        // 是 data URL，需要保存为文件
        const base64Data = sourcePath.split(',')[1]
        const ext = this.getExtFromDataUrl(sourcePath)
        const fileName = `${messageId}_${imageKey}${ext}`
        const destPath = path.join(imagesDir, fileName)

        const buffer = Buffer.from(base64Data, 'base64')
        await this.recordCreatedFileBeforeWrite(destPath, control)
        await fs.promises.writeFile(destPath, buffer)
        this.noteMediaTelemetry({
          doneFiles: 1,
          cacheMissFiles: 1,
          bytesWritten: buffer.length
        })

        return {
          relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
          kind: 'image'
        }
      } else if (sourcePath.startsWith('file://')) {
        sourcePath = fileURLToPath(sourcePath)
      }

      // 复制文件
      const ext = path.extname(sourcePath) || '.jpg'
      const fileName = `${messageId}_${imageKey}${ext}`
      const destPath = path.join(imagesDir, fileName)
      const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath, control)
      if (!copied.success) {
        if (copied.code === 'ENOENT') {
          console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
        } else {
          console.log(`[Export] 复制图片失败 (localId=${msg.localId}): ${sourcePath}, code=${copied.code || 'UNKNOWN'} → 将显示 [图片] 占位符`)
        }
        return null
      }

      return {
        relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
        kind: 'image'
      }
    } catch (e) {
      console.error(`[Export] 导出图片异常 (localId=${msg.localId}):`, e, `→ 将显示 [图片] 占位符`)
      return null
    }
  }

  private async preloadMediaLookupCaches(
    _sessionId: string,
    messages: any[],
    options: { exportImages?: boolean; exportVideos?: boolean },
    control?: ExportTaskControl
  ): Promise<void> {
    if (!Array.isArray(messages) || messages.length === 0) return

    const md5Pattern = /^[a-f0-9]{32}$/i
    const imageMd5Set = new Set<string>()

    let scanIndex = 0
    for (const msg of messages) {
      if ((scanIndex++ & 0x7f) === 0) {
        this.throwIfStopRequested(control)
      }

      if (options.exportImages && msg?.localType === 3) {
        const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase()
        if (imageMd5) {
          imageMd5Set.add(imageMd5)
        }
        const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase()
        if (md5Pattern.test(imageDatName)) {
          imageMd5Set.add(imageDatName)
        }
      }

    }

    const preloadTasks: Array<Promise<void>> = []
    if (imageMd5Set.size > 0) {
      preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)))
    }
    if (preloadTasks.length === 0) return

    await Promise.all(preloadTasks.map((task) => task.catch(() => { })))
    this.throwIfStopRequested(control)
  }

  /**
   * 导出语音文件
   */
  private async preloadVoiceWavCache(
    sessionId: string,
    messages: any[],
    control?: ExportTaskControl
  ): Promise<void> {
    if (!Array.isArray(messages) || messages.length === 0) return

    const normalizedSessionId = String(sessionId || '').trim()
    if (!normalizedSessionId) return

    const normalized: Array<{
      localId: number
      createTime: number
      serverId?: string | number
      senderWxid?: string | null
    }> = []
    const seen = new Set<string>()

    for (const msg of messages) {
      const localIdRaw = Number(msg?.localId)
      const createTimeRaw = Number(msg?.createTime)
      const localId = Number.isFinite(localIdRaw) ? Math.max(0, Math.floor(localIdRaw)) : 0
      const createTime = Number.isFinite(createTimeRaw) ? Math.max(0, Math.floor(createTimeRaw)) : 0
      if (!localId || !createTime) continue
      const dedupeKey = this.getStableMessageKey(msg)
      if (seen.has(dedupeKey)) continue
      seen.add(dedupeKey)
      normalized.push({
        localId,
        createTime,
        serverId: msg?.serverId,
        senderWxid: msg?.senderUsername || null
      })
    }
    if (normalized.length === 0) return

    const chunkSize = 120
    for (let i = 0; i < normalized.length; i += chunkSize) {
      this.throwIfStopRequested(control)
      const chunk = normalized.slice(i, i + chunkSize)
      await chatService.preloadVoiceDataBatch(normalizedSessionId, chunk, {
        chunkSize: 48,
        decodeConcurrency: 3
      })
    }
  }

  /**
   * 导出语音文件
   */
  private async exportVoice(
    msg: any,
    sessionId: string,
    mediaRootDir: string,
    mediaRelativePrefix: string,
    dirCache?: Set<string>,
    control?: ExportTaskControl
  ): Promise<MediaExportItem | null> {
    try {
      const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
      await this.ensureExportDir(voicesDir, control, dirCache)

      const msgId = String(msg.localId)
      const safeSession = this.cleanAccountDirName(sessionId)
        .replace(/[^a-zA-Z0-9_-]/g, '_')
        .slice(0, 48) || 'session'
      const stableKey = this.getStableMessageKey(msg).replace(/:/g, '_')
      const fileName = `voice_${safeSession}_${stableKey || msgId}.wav`
      const destPath = path.join(voicesDir, fileName)

      // 如果已存在则跳过
      if (await this.pathExists(destPath)) {
        return {
          relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
          kind: 'voice'
        }
      }

      // 调用 chatService 获取语音数据
      const voiceResult = await chatService.getVoiceData(
        sessionId,
        msgId,
        Number.isFinite(Number(msg?.createTime)) ? Number(msg.createTime) : undefined,
        msg?.serverId,
        msg?.senderUsername || undefined
      )
      if (!voiceResult.success || !voiceResult.data) {
        return null
      }

      // voiceResult.data 是 base64 编码的 wav 数据
      const wavBuffer = Buffer.from(voiceResult.data, 'base64')
      await this.recordCreatedFileBeforeWrite(destPath, control)
      await fs.promises.writeFile(destPath, wavBuffer)
      this.noteMediaTelemetry({
        doneFiles: 1,
        bytesWritten: wavBuffer.length
      })

      return {
        relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
        kind: 'voice'
      }
    } catch (e) {
      return null
    }
  }

  /**
   * 转写语音为文字
   */
  private async transcribeVoice(sessionId: string, msgId: string, createTime: number, senderWxid: string | null): Promise<string> {
    try {
      const transcript = await chatService.getVoiceTranscript(sessionId, msgId, createTime, undefined, senderWxid || undefined)
      if (transcript.success && transcript.transcript) {
        return `[语音转文字] ${transcript.transcript}`
      }
      return `[语音消息 - 转文字失败: ${transcript.error || '未知错误'}]`
    } catch (e) {
      return `[语音消息 - 转文字失败: ${String(e)}]`
    }
  }

  /**
   * 导出表情文件
   */
  private async exportEmoji(
    msg: any,
    sessionId: string,
    mediaRootDir: string,
    mediaRelativePrefix: string,
    dirCache?: Set<string>,
    control?: ExportTaskControl
  ): Promise<MediaExportItem | null> {
    try {
      const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
      await this.ensureExportDir(emojisDir, control, dirCache)

      // 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
      const localPath = await chatService.downloadEmojiFile(msg)

      if (!localPath) {
        return null
      }

      // 确定目标文件名
      const ext = path.extname(localPath) || '.gif'
      const key = msg.emojiMd5 || String(msg.localId)
      const fileName = `${key}${ext}`
      const destPath = path.join(emojisDir, fileName)
      const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath, control)
      if (!copied.success) return null

      return {
        relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
        kind: 'emoji'
      }
    } catch (e) {
      console.error('ExportService: exportEmoji failed', e)
      return null
    }
  }

  /**
   * 导出视频文件
   */
  private async exportVideo(
    msg: any,
    sessionId: string,
    mediaRootDir: string,
    mediaRelativePrefix: string,
    dirCache?: Set<string>,
    includePoster = false,
    control?: ExportTaskControl
  ): Promise<MediaExportItem | null> {
    try {
      let videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
      const resolveVideoInfo = async (token: string) => {
        if (!token) return null
        const videoInfo = await videoService.getVideoInfo(token, { includePoster })
        if (!videoInfo.exists || !videoInfo.videoUrl) return null
        return videoInfo
      }

      let videoInfo = await resolveVideoInfo(videoMd5)
      if (!videoInfo) {
        const localId = Number(msg?.localId || 0)
        if (Number.isFinite(localId) && localId > 0) {
          await this.backfillMediaFieldsFromMessageDetail(sessionId, [msg], new Set([43]), undefined, { force: true })
          videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
          videoInfo = await resolveVideoInfo(videoMd5)
        }
      }
      if (!videoInfo) return null

      const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos')
      await this.ensureExportDir(videosDir, control, dirCache)

      const sourcePath = videoInfo.videoUrl
      const fileName = path.basename(sourcePath)
      const destPath = path.join(videosDir, fileName)

      const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control)
      if (!copied.success) return null

      return {
        relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName),
        kind: 'video',
        posterDataUrl: includePoster ? (videoInfo.coverUrl || videoInfo.thumbUrl) : undefined
      }
    } catch (e) {
      return null
    }
  }

  /**
   * 从消息内容提取图片 MD5
   */
  private extractImageMd5(content: string): string | undefined {
    if (!content) return undefined
    const match = /md5="([^"]+)"/i.exec(content)
    return match?.[1]
  }

  /**
   * 从消息内容提取图片 DAT 文件名
   */
  private extractImageDatName(content: string): string | undefined {
    if (!content) return undefined
    const candidate =
      this.extractXmlValue(content, 'imgname') ||
      this.extractXmlValue(content, 'cdnmidimgurl') ||
      this.extractXmlValue(content, 'cdnthumburl') ||
      this.extractXmlAttribute(content, 'img', 'imgname') ||
      this.extractXmlAttribute(content, 'img', 'cdnmidimgurl') ||
      this.extractXmlAttribute(content, 'img', 'cdnthumburl')
    return this.normalizeImageDatNameToken(candidate)
  }

  private normalizeImageDatNameToken(value: unknown): string | undefined {
    let text = String(value ?? '').trim()
    if (!text) return undefined
    text = text.replace(/&amp;/g, '&')
    try {
      if (text.includes('%')) text = decodeURIComponent(text)
    } catch { }

    const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text)
    if (datLike?.[1]) return datLike[1].toLowerCase()

    const base = text
      .split(/[?#]/, 1)[0]
      .replace(/^.*[\\/]/, '')
      .replace(/\.(?:t\.)?dat$/i, '')
      .trim()
    if (!base) return undefined

    const cdnToken = base.includes('_') ? base.split('_')[0] : base
    const exact = /^([a-fA-F0-9]{16,64})$/.exec(cdnToken)
    if (exact?.[1]) return exact[1].toLowerCase()

    const preferred32 = /([a-fA-F0-9]{32})(?![a-fA-F0-9])/i.exec(cdnToken)
    if (preferred32?.[1]) return preferred32[1].toLowerCase()
    const fallback = /([a-fA-F0-9]{16,64})(?![a-fA-F0-9])/i.exec(cdnToken)
    return fallback?.[1]?.toLowerCase()
  }

  private extractImageDatNameFromPackedRaw(raw: unknown): string | undefined {
    const buffer = this.decodePackedInfoBuffer(raw)
    if (!buffer || buffer.length === 0) return undefined
    const printable: number[] = []
    for (const byte of buffer) {
      printable.push(byte >= 0x20 && byte <= 0x7e ? byte : 0x20)
    }
    const text = Buffer.from(printable).toString('utf-8')
    const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text)
    if (datLike?.[1]) return datLike[1].toLowerCase()
    const fallback = /([0-9a-fA-F]{16,})/.exec(text)
    return fallback?.[1]?.toLowerCase()
  }

  private extractImageDatNameFromRow(row: Record<string, any>, content?: string): string | undefined {
    const byColumn = this.normalizeImageDatNameToken(this.getRowField(row, [
      'image_path',
      'imagePath',
      'image_dat_name',
      'imageDatName',
      'img_path',
      'imgPath',
      'img_name',
      'imgName'
    ]))
    if (byColumn) return byColumn

    const packedRaw = this.getRowField(row, [
      'packed_info_data',
      'packedInfoData',
      'packed_info_blob',
      'packedInfoBlob',
      'packed_info',
      'packedInfo',
      'BytesExtra',
      'bytes_extra',
      'WCDB_CT_packed_info',
      'reserved0',
      'Reserved0',
      'WCDB_CT_Reserved0'
    ])
    const byPacked = this.extractImageDatNameFromPackedRaw(packedRaw)
    if (byPacked) return byPacked

    return this.extractImageDatName(content || '')
  }

  /**
   * 从消息内容提取表情 URL
   */
  private extractEmojiUrl(content: string): string | undefined {
    if (!content) return undefined
    // 参考 echotrace 的正则：cdnurl\s*=\s*['"]([^'"]+)['"]
    const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content)
    if (attrMatch) {
      // 解码 &amp; 等实体
      let url = attrMatch[1].replace(/&amp;/g, '&')
      // URL 解码
      try {
        if (url.includes('%')) {
          url = decodeURIComponent(url)
        }
      } catch { }
      return url
    }
    // 备用：尝试 XML 标签形式
    const tagMatch = /cdnurl[^>]*>([^<]+)/i.exec(content)
    return tagMatch?.[1]
  }

  /**
   * 从消息内容提取表情 MD5
   */
  private extractEmojiMd5(content: string): string | undefined {
    if (!content) return undefined
    const match =
      /md5\s*=\s*['"]([a-fA-F0-9]{32})['"]/i.exec(content) ||
      /md5\s*=\s*([a-fA-F0-9]{32})/i.exec(content) ||
      /<md5>([a-fA-F0-9]{32})<\/md5>/i.exec(content)
    return this.normalizeEmojiMd5(match?.[1]) || this.extractLooseHexMd5(content)
  }

  private extractVideoMd5(content: string): string | undefined {
    if (!content) return undefined
    const attrMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
    if (attrMatch) {
      return attrMatch[1].toLowerCase()
    }
    const tagMatch = /<md5>([^<]+)<\/md5>/i.exec(content)
    return tagMatch?.[1]?.toLowerCase()
  }

  private decodePackedInfoBuffer(raw: unknown): Buffer | null {
    if (!raw) return null
    if (Buffer.isBuffer(raw)) return raw
    if (raw instanceof Uint8Array) return Buffer.from(raw)
    if (Array.isArray(raw)) return Buffer.from(raw)
    if (typeof raw === 'string') {
      const trimmed = raw.trim()
      if (!trimmed) return null
      const compactHex = trimmed.replace(/\s+/g, '')
      if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
        try {
          return Buffer.from(compactHex, 'hex')
        } catch { }
      }
      try {
        const decoded = Buffer.from(trimmed, 'base64')
        if (decoded.length > 0) return decoded
      } catch { }
      return null
    }
    if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) {
      return Buffer.from((raw as any).data)
    }
    return null
  }

  private normalizeVideoFileToken(value: unknown): string | undefined {
    let text = String(value || '').trim().toLowerCase()
    if (!text) return undefined
    text = text.replace(/^.*[\\/]/, '')
    text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
    text = text.replace(/_thumb$/, '')
    const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
    if (direct) {
      const suffix = /_raw$/i.test(text) ? '_raw' : ''
      return `${direct[1].toLowerCase()}${suffix}`
    }
    const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
    if (preferred32?.[1]) return preferred32[1].toLowerCase()
    const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
    return fallback?.[1]?.toLowerCase()
  }

  private extractVideoFileNameFromPackedRaw(raw: unknown): string | undefined {
    const buffer = this.decodePackedInfoBuffer(raw)
    if (!buffer || buffer.length === 0) return undefined
    const candidates: string[] = []
    let current = ''
    for (const byte of buffer) {
      const isHex =
        (byte >= 0x30 && byte <= 0x39) ||
        (byte >= 0x41 && byte <= 0x46) ||
        (byte >= 0x61 && byte <= 0x66)
      if (isHex) {
        current += String.fromCharCode(byte)
        continue
      }
      if (current.length >= 16) candidates.push(current)
      current = ''
    }
    if (current.length >= 16) candidates.push(current)
    if (candidates.length === 0) return undefined

    const exact32 = candidates.find((item) => item.length === 32)
    if (exact32) return exact32.toLowerCase()
    const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
    return fallback?.toLowerCase()
  }

  private extractVideoFileNameFromRow(row: Record<string, any>, content?: string): string | undefined {
    const packedRaw = this.getRowField(row, [
      'packed_info_data', 'packedInfoData',
      'packed_info_blob', 'packedInfoBlob',
      'packed_info', 'packedInfo',
      'BytesExtra', 'bytes_extra',
      'WCDB_CT_packed_info',
      'reserved0', 'Reserved0', 'WCDB_CT_Reserved0'
    ])
    const byPacked = this.extractVideoFileNameFromPackedRaw(packedRaw)
    if (byPacked) return byPacked

    const byColumn = this.normalizeVideoFileToken(this.getRowField(row, [
      'video_md5', 'videoMd5', 'raw_md5', 'rawMd5', 'video_file_name', 'videoFileName'
    ]))
    if (byColumn) return byColumn

    return this.normalizeVideoFileToken(this.extractVideoMd5(content || ''))
  }

  private isFileAttachmentAccountDir(dirPath: string): boolean {
    if (!dirPath) return false
    return fs.existsSync(path.join(dirPath, 'db_storage')) ||
      fs.existsSync(path.join(dirPath, 'msg', 'file')) ||
      fs.existsSync(path.join(dirPath, 'FileStorage', 'File')) ||
      fs.existsSync(path.join(dirPath, 'FileStorage', 'Image')) ||
      fs.existsSync(path.join(dirPath, 'FileStorage', 'Image2'))
  }

  private resolveAccountDirForFileExport(basePath: string, wxid: string): string | null {
    const cleanedWxid = this.cleanAccountDirName(wxid)
    if (!basePath || !cleanedWxid) return null

    const normalized = path.resolve(basePath.replace(/[\\/]+$/, ''))
    const parentDir = path.dirname(normalized)
    const dbStorageParent = path.basename(normalized).toLowerCase() === 'db_storage'
      ? path.dirname(normalized)
      : ''
    const fileInsideDbStorageParent = path.basename(parentDir).toLowerCase() === 'db_storage'
      ? path.dirname(parentDir)
      : ''
    const candidateBases = Array.from(new Set([
      normalized,
      parentDir,
      path.join(normalized, 'WeChat Files'),
      path.join(parentDir, 'WeChat Files'),
      dbStorageParent,
      fileInsideDbStorageParent
    ].filter(Boolean)))

    const lowerWxid = cleanedWxid.toLowerCase()
    const tryResolveBase = (candidateBase: string): string | null => {
      if (!candidateBase || !fs.existsSync(candidateBase)) return null
      if (this.isFileAttachmentAccountDir(candidateBase)) return candidateBase

      const direct = path.join(candidateBase, cleanedWxid)
      if (this.isFileAttachmentAccountDir(direct)) return direct

      try {
        const entries = fs.readdirSync(candidateBase, { withFileTypes: true })
        for (const entry of entries) {
          if (!entry.isDirectory()) continue
          const lowerEntry = entry.name.toLowerCase()
          if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) {
            const entryPath = path.join(candidateBase, entry.name)
            if (this.isFileAttachmentAccountDir(entryPath)) {
              return entryPath
            }
          }
        }
      } catch {
        return null
      }

      return null
    }

    for (const candidateBase of candidateBases) {
      const resolved = tryResolveBase(candidateBase)
      if (resolved) return resolved
    }

    return null
  }

  private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] {
    const dbPath = this.getConfiguredDbPath()
    const rawWxid = this.getConfiguredMyWxid()
    const cleanedWxid = this.cleanAccountDirName(rawWxid)
    if (!dbPath) return []

    const normalized = path.resolve(dbPath.replace(/[\\/]+$/, ''))
    const accountDirs = new Set<string>()
    const maybeAddAccountDir = (candidate: string | null | undefined) => {
      if (!candidate) return
      const resolved = path.resolve(candidate)
      if (this.isFileAttachmentAccountDir(resolved)) {
        accountDirs.add(resolved)
      }
    }

    maybeAddAccountDir(normalized)
    maybeAddAccountDir(path.dirname(normalized))

    const wxidCandidates = Array.from(new Set([cleanedWxid, rawWxid].filter(Boolean)))
    for (const wxid of wxidCandidates) {
      maybeAddAccountDir(this.resolveAccountDirForFileExport(normalized, wxid))
    }

    return Array.from(accountDirs).map((accountDir) => {
      const msgFileRoot = path.join(accountDir, 'msg', 'file')
      const fileStorageRoot = path.join(accountDir, 'FileStorage', 'File')
      return {
        accountDir,
        msgFileRoot: fs.existsSync(msgFileRoot) ? msgFileRoot : undefined,
        fileStorageRoot: fs.existsSync(fileStorageRoot) ? fileStorageRoot : undefined
      }
    }).filter((root) => Boolean(root.msgFileRoot || root.fileStorageRoot))
  }

  private buildPreferredFileYearMonths(createTime?: unknown): string[] {
    const raw = Number(createTime)
    if (!Number.isFinite(raw) || raw <= 0) return []
    const ts = raw > 1e12 ? raw : raw * 1000
    const date = new Date(ts)
    if (Number.isNaN(date.getTime())) return []
    const y = date.getFullYear()
    const m = String(date.getMonth() + 1).padStart(2, '0')
    return [`${y}-${m}`]
  }

  private async verifyFileHash(sourcePath: string, expectedMd5?: string): Promise<boolean> {
    const normalizedExpected = String(expectedMd5 || '').trim().toLowerCase()
    if (!normalizedExpected) return true
    if (!/^[a-f0-9]{32}$/i.test(normalizedExpected)) return true
    try {
      const hash = crypto.createHash('md5')
      await new Promise<void>((resolve, reject) => {
        const stream = fs.createReadStream(sourcePath)
        stream.on('data', chunk => hash.update(chunk))
        stream.on('end', () => resolve())
        stream.on('error', reject)
      })
      return hash.digest('hex').toLowerCase() === normalizedExpected
    } catch {
      return false
    }
  }

  private collectFileStorageCandidatesByName(rootDir: string, fileName: string, maxDepth = 3): string[] {
    const normalizedName = String(fileName || '').trim().toLowerCase()
    if (!rootDir || !normalizedName) return []

    const matches: string[] = []
    const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }]

    while (stack.length > 0) {
      const current = stack.pop()!
      let entries: fs.Dirent[]
      try {
        entries = fs.readdirSync(current.dir, { withFileTypes: true })
      } catch {
        continue
      }

      for (const entry of entries) {
        const entryPath = path.join(current.dir, entry.name)
        if (entry.isFile() && entry.name.toLowerCase() === normalizedName) {
          matches.push(entryPath)
          continue
        }
        if (entry.isDirectory() && current.depth < maxDepth) {
          stack.push({ dir: entryPath, depth: current.depth + 1 })
        }
      }
    }

    return matches
  }

  private getFileAttachmentLogContext(msg: any): Record<string, unknown> {
    return {
      localId: msg?.localId,
      createTime: msg?.createTime,
      localType: msg?.localType,
      xmlType: msg?.xmlType,
      fileName: msg?.fileName,
      fileMd5: msg?.fileMd5
    }
  }

  private logFileAttachmentEvent(
    level: 'warn' | 'error',
    action: string,
    msg: any,
    extra: Record<string, unknown> = {}
  ): void {
    const logger = level === 'error' ? console.error : console.warn
    logger(`[Export][File] ${action}`, {
      ...this.getFileAttachmentLogContext(msg),
      ...extra
    })
  }

  private recordFileAttachmentMiss(msg: any, action: string, extra: Record<string, unknown> = {}): void {
    this.logFileAttachmentEvent('warn', action, msg, extra)
    this.noteMediaTelemetry({ cacheMissFiles: 1 })
  }

  private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> {
    const fileName = String(msg?.fileName || '').trim()
    if (!fileName) return []

    const roots = this.resolveFileAttachmentSearchRoots()
    if (roots.length === 0) return []

    const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
    const preferredMonths = new Set(this.buildPreferredFileYearMonths(msg?.createTime))
    const candidates: FileExportCandidate[] = []
    const seen = new Set<string>()
    let searchOrder = 0

    const appendCandidate = async (sourcePath: string, yearMonth?: string) => {
      if (!sourcePath || !fs.existsSync(sourcePath)) return

      const resolvedPath = path.resolve(sourcePath)
      if (seen.has(resolvedPath)) return

      let stat: fs.Stats
      try {
        stat = await fs.promises.stat(resolvedPath)
      } catch {
        return
      }
      if (!stat.isFile()) return

      seen.add(resolvedPath)
      const matchedBy = normalizedMd5 && await this.verifyFileHash(resolvedPath, normalizedMd5) ? 'md5' : 'name'
      candidates.push({
        sourcePath: resolvedPath,
        matchedBy,
        yearMonth,
        preferredMonth: Boolean(yearMonth && preferredMonths.has(yearMonth)),
        mtimeMs: Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : 0,
        searchOrder: searchOrder++
      })
    }

    for (const root of roots) {
      if (root.msgFileRoot) {
        for (const month of preferredMonths) {
          await appendCandidate(path.join(root.msgFileRoot, month, fileName), month)
        }

        let monthDirs: string[] = []
        try {
          monthDirs = fs.readdirSync(root.msgFileRoot, { withFileTypes: true })
            .filter(entry => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name) && !preferredMonths.has(entry.name))
            .map(entry => entry.name)
            .sort()
        } catch {
          monthDirs = []
        }

        for (const month of monthDirs) {
          await appendCandidate(path.join(root.msgFileRoot, month, fileName), month)
        }
        await appendCandidate(path.join(root.msgFileRoot, fileName))
      }

      if (root.fileStorageRoot) {
        for (const candidatePath of this.collectFileStorageCandidatesByName(root.fileStorageRoot, fileName, 3)) {
          await appendCandidate(candidatePath)
        }
      }
    }

    candidates.sort((left, right) => {
      if (left.matchedBy !== right.matchedBy) {
        return left.matchedBy === 'md5' ? -1 : 1
      }
      if (left.preferredMonth !== right.preferredMonth) {
        return left.preferredMonth ? -1 : 1
      }
      if (left.mtimeMs !== right.mtimeMs) {
        return right.mtimeMs - left.mtimeMs
      }
      return left.searchOrder - right.searchOrder
    })

    return candidates
  }

  private async exportFileAttachment(
    msg: any,
    mediaRootDir: string,
    mediaRelativePrefix: string,
    maxFileSizeMb?: number,
    dirCache?: Set<string>,
    control?: ExportTaskControl
  ): Promise<MediaExportItem | null> {
    try {
      const fileNameRaw = String(msg?.fileName || '').trim()
      if (!fileNameRaw) return null

      const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw)
      const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir)
      await this.ensureExportDir(fileDir, control, dirCache)

      const candidates = await this.resolveFileAttachmentCandidates(msg)
      if (candidates.length === 0) {
        this.recordFileAttachmentMiss(msg, '附件候选未命中', {
          searchRoots: this.resolveFileAttachmentSearchRoots().map(root => root.accountDir)
        })
        return null
      }

      const maxBytes = Number.isFinite(maxFileSizeMb)
        ? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024))
        : 0

      const selected = candidates[0]
      const stat = await fs.promises.stat(selected.sourcePath)
      if (!stat.isFile()) {
        this.recordFileAttachmentMiss(msg, '附件候选不是普通文件', {
          sourcePath: selected.sourcePath
        })
        return null
      }
      if (maxBytes > 0 && stat.size > maxBytes) {
        this.recordFileAttachmentMiss(msg, '附件超过大小限制', {
          sourcePath: selected.sourcePath,
          size: stat.size,
          maxBytes
        })
        return null
      }

      const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
      if (normalizedMd5 && selected.matchedBy !== 'md5') {
        this.recordFileAttachmentMiss(msg, '附件哈希校验失败', {
          sourcePath: selected.sourcePath,
          expectedMd5: normalizedMd5
        })
        return null
      }

      const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
      const messageId = String(msg?.localId || Date.now())
      const destFileName = `${messageId}_${safeBaseName}`
      const destPath = path.join(fileDir, destFileName)
      const existedBeforeCopy = await this.pathExists(destPath)
      const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
      if (!copied.success) {
        this.recordFileAttachmentMiss(msg, '附件复制失败', {
          sourcePath: selected.sourcePath,
          destPath,
          code: copied.code
        })
        return null
      }

      if (!existedBeforeCopy) {
        control?.recordCreatedFile?.(destPath)
      }
      this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
      return {
        relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
        kind: 'file'
      }
    } catch (error) {
      this.logFileAttachmentEvent('error', '附件导出异常', msg, {
        error: error instanceof Error ? error.message : String(error || 'unknown')
      })
      this.noteMediaTelemetry({ cacheMissFiles: 1 })
      return null
    }
  }

  private extractLocationMeta(content: string, localType: number): {
    locationLat?: number
    locationLng?: number
    locationPoiname?: string
    locationLabel?: string
  } | null {
    if (!content || localType !== 48) return null

    const normalized = this.normalizeAppMessageContent(content)
    const rawLat = this.extractXmlAttribute(normalized, 'location', 'x') || this.extractXmlAttribute(normalized, 'location', 'latitude')
    const rawLng = this.extractXmlAttribute(normalized, 'location', 'y') || this.extractXmlAttribute(normalized, 'location', 'longitude')
    const locationPoiname =
      this.extractXmlAttribute(normalized, 'location', 'poiname') ||
      this.extractXmlValue(normalized, 'poiname') ||
      this.extractXmlValue(normalized, 'poiName')
    const locationLabel =
      this.extractXmlAttribute(normalized, 'location', 'label') ||
      this.extractXmlValue(normalized, 'label')

    const meta: {
      locationLat?: number
      locationLng?: number
      locationPoiname?: string
      locationLabel?: string
    } = {}

    if (rawLat) {
      const parsed = parseFloat(rawLat)
      if (Number.isFinite(parsed)) meta.locationLat = parsed
    }
    if (rawLng) {
      const parsed = parseFloat(rawLng)
      if (Number.isFinite(parsed)) meta.locationLng = parsed
    }
    if (locationPoiname) meta.locationPoiname = locationPoiname
    if (locationLabel) meta.locationLabel = locationLabel

    return Object.keys(meta).length > 0 ? meta : null
  }

  /**
   * 从 data URL 获取扩展名
   */
  private getExtFromDataUrl(dataUrl: string): string {
    if (dataUrl.includes('image/png')) return '.png'
    if (dataUrl.includes('image/gif')) return '.gif'
    if (dataUrl.includes('image/webp')) return '.webp'
    return '.jpg'
  }

  private getMediaLayout(outputPath: string, options: ExportOptions): {
    exportMediaEnabled: boolean
    mediaRootDir: string
    mediaRelativePrefix: string
  } {
    const exportMediaEnabled = options.exportMedia === true &&
      Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
    const outputDir = path.dirname(outputPath)
    const writeLayout = this.resolveExportWriteLayout(options)
    // A: type-first layout, text exports are placed under `texts/`, media is placed at sibling type directories.
    if (writeLayout === 'A' && path.basename(outputDir) === 'texts') {
      return {
        exportMediaEnabled,
        mediaRootDir: outputDir,
        mediaRelativePrefix: '..'
      }
    }
    const outputBaseName = path.basename(outputPath, path.extname(outputPath))
    const useSharedMediaLayout = options.sessionLayout === 'shared'
    const mediaRelativePrefix = useSharedMediaLayout
      ? path.posix.join('media', outputBaseName)
      : 'media'
    return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix }
  }

  private collectMediaMessagesForExport(messages: any[], options: ExportOptions): any[] {
    if (!this.isMediaExportEnabled(options)) return []

    return messages.filter((msg) => {
      const localType = Number(msg?.localType || 0)
      return (localType === 3 && options.exportImages) ||
        (localType === 47 && options.exportEmojis) ||
        (localType === 43 && options.exportVideos) ||
        (localType === 34 && options.exportVoices) ||
        (options.exportFiles === true && this.isFileAppMessage(msg))
    })
  }

  private getMediaDoneFilesCount(): number {
    return this.mediaExportTelemetry?.doneFiles ?? 0
  }

  private formatMediaPhaseLabel(processed: number, total: number, beforeDoneFiles: number): string {
    const safeProcessed = Math.max(0, Math.floor(processed || 0))
    const safeTotal = Math.max(0, Math.floor(total || 0))
    const writtenNow = Math.max(0, this.getMediaDoneFilesCount() - Math.max(0, Math.floor(beforeDoneFiles || 0)))
    return `导出媒体 ${Math.min(safeProcessed, safeTotal)}/${safeTotal}（已写入 ${writtenNow}）`
  }

  private buildFileOnlyExportFailure(
    options: ExportOptions,
    mediaMessages: any[],
    beforeDoneFiles: number
  ): { success: boolean; error?: string } | null {
    if (options.contentType !== 'file') return null
    if (!mediaMessages.some(msg => this.isFileAppMessage(msg))) return null
    if (this.getMediaDoneFilesCount() > beforeDoneFiles) return null

    return {
      success: false,
      error: '检测到文件消息，但未找到可导出的源文件，请检查数据库路径或文件存储目录配置'
    }
  }

  /**
   * 下载文件
   */
  private async downloadFile(url: string, destPath: string): Promise<boolean> {
    return new Promise((resolve) => {
      try {
        const protocol = url.startsWith('https') ? https : http
        const request = protocol.get(url, { timeout: 30000 }, (response) => {
          if (response.statusCode === 301 || response.statusCode === 302) {
            const redirectUrl = response.headers.location
            if (redirectUrl) {
              this.downloadFile(redirectUrl, destPath).then(resolve)
              return
            }
          }
          if (response.statusCode !== 200) {
            resolve(false)
            return
          }
          const fileStream = fs.createWriteStream(destPath)
          response.pipe(fileStream)
          fileStream.on('finish', () => {
            fileStream.close()
            resolve(true)
          })
          fileStream.on('error', (err) => {
            // 确保在错误情况下销毁流，释放文件句柄
            fileStream.destroy()
            resolve(false)
          })
          response.on('error', (err) => {
            // 确保在响应错误时也关闭文件句柄
            fileStream.destroy()
            resolve(false)
          })
        })
        request.on('error', () => resolve(false))
        request.on('timeout', () => {
          request.destroy()
          resolve(false)
        })
      } catch {
        resolve(false)
      }
    })
  }

  private async collectMessages(
    sessionId: string,
    cleanedMyWxid: string,
    dateRange?: { start: number; end: number } | null,
    senderUsernameFilter?: string,
    collectMode: MessageCollectMode = 'full',
    targetMediaTypes?: Set<number>,
    control?: ExportTaskControl,
    onCollectProgress?: (payload: { fetched: number }) => void,
    allowLiteFallback = true,
    allowRangeFallback = true,
    useCursorTimeRange = true,
    allowModeFallback = true
  ): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null; error?: string }> {
    const rows: any[] = []
    const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
    const senderSet = new Set<string>()
    let firstTime: number | null = null
    let lastTime: number | null = null
    const mediaTypeFilter = targetMediaTypes && targetMediaTypes.size > 0
      ? targetMediaTypes
      : null
    const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(mediaTypeFilter)

    const normalizedDateRange = this.normalizeExportDateRange(dateRange)
    const normalizedSenderUsernameFilter = String(senderUsernameFilter || '').trim()
    const beginTime = useCursorTimeRange ? (normalizedDateRange?.start || 0) : 0
    const endTime = useCursorTimeRange ? (normalizedDateRange?.end || 0) : 0
    
    const batchSize = (collectMode === 'text-fast' || collectMode === 'media-fast') ? 2000 : 500
    this.throwIfStopRequested(control)
    const fastMediaType = this.resolveFastMediaStreamType(collectMode, mediaTypeFilter)
    let usedFastMediaStream = false
    let usedLiteCursor = false
    if (fastMediaType) {
      const streamCollected = await this.collectMessagesByFastMediaStream(
        sessionId,
        cleanedMyWxid,
        normalizedDateRange,
        useCursorTimeRange,
        normalizedSenderUsernameFilter,
        fastMediaType,
        onCollectProgress,
        control
      )
      if (streamCollected.success) {
        usedFastMediaStream = true
        rows.push(...streamCollected.rows)
        for (const username of streamCollected.senderUsernames) {
          senderSet.add(username)
        }
        firstTime = streamCollected.firstTime
        lastTime = streamCollected.lastTime
      } else {
        console.warn(`[Export] 媒体快速流读取失败，回退游标链路: session=${sessionId}, type=${fastMediaType}, error=${streamCollected.error || 'unknown'}`)
      }
    }

    if (!usedFastMediaStream) {
      // 媒体导出链路必须优先保证字段完整性，否则会出现“进度前进但无文件写出”。
      // 轻量游标仅用于文本快路径；媒体快路径保留标准游标。
      usedLiteCursor = allowLiteFallback && collectMode === 'text-fast'
      let cursor = usedLiteCursor
        ? await wcdbService.openMessageCursorLite(
          sessionId,
          batchSize,
          true,
          beginTime,
          endTime
        )
        : await wcdbService.openMessageCursor(
          sessionId,
          batchSize,
          false,
          beginTime,
          endTime
        )
      if (!cursor.success || !cursor.cursor) {
        if (usedLiteCursor && allowLiteFallback) {
          console.warn(`[Export] 轻量游标打开失败，回退标准游标重试: ${cursor.error || '未知错误'}`)
          return this.collectMessages(
            sessionId,
            cleanedMyWxid,
            normalizedDateRange,
            senderUsernameFilter,
            collectMode,
            targetMediaTypes,
            control,
            onCollectProgress,
            false,
            allowRangeFallback,
            useCursorTimeRange,
            allowModeFallback
          )
        }
        console.error(`[Export] 打开游标失败: ${cursor.error || '未知错误'}`)
        return {
          rows,
          memberSet,
          firstTime,
          lastTime,
          error: cursor.error || '打开消息游标失败'
        }
      }

      try {
        let hasMore = true
        let batchCount = 0
        while (hasMore) {
          this.throwIfStopRequested(control)
          const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
          batchCount++
          
          if (!batch.success) {
            console.error(`[Export] 获取批次 ${batchCount} 失败: ${batch.error}`)
            break
          }
          
          if (!batch.rows) break
          
          let rowIndex = 0
          for (const row of batch.rows) {
          if ((rowIndex++ & 0x7f) === 0) {
            this.throwIfStopRequested(control)
          }
          const createTime = this.getTimestampSecondsFromRow(row)
          if (normalizedDateRange) {
            if (createTime > 0 && normalizedDateRange.start > 0 && createTime < normalizedDateRange.start) continue
            if (createTime > 0 && normalizedDateRange.end > 0 && createTime > normalizedDateRange.end) continue
          }

          const localType = this.getIntFromRow(row, [
            'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'
          ], 1)
          const rowFileHints = collectMode === 'text-fast'
            ? {}
            : this.getFileAppMessageHints(row)
          const allowFileProbe = collectMode !== 'text-fast' && fileOnlyMediaFilter && this.hasFileAppMessageHints(row)
          if (mediaTypeFilter && !mediaTypeFilter.has(localType) && !allowFileProbe) {
            continue
          }
          const shouldDecodeContent = collectMode === 'full'
            || (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType))
            || (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter, { allowFileProbe }))
          const content = shouldDecodeContent
            ? this.decodeMessageContent(row.message_content, row.compress_content)
            : ''
          const senderUsername = row.sender_username || ''
          const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
          const isSend = parseInt(isSendRaw, 10) === 1
          const localId = this.getIntFromRow(row, [
            'local_id', 'localId', 'LocalId',
            'msg_local_id', 'msgLocalId', 'MsgLocalId',
            'msg_id', 'msgId', 'MsgId', 'id',
            'WCDB_CT_local_id'
          ], 0)
          const rawServerIdValue = this.getRowField(row, [
            'server_id', 'serverId', 'ServerId',
            'msg_server_id', 'msgServerId', 'MsgServerId',
            'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId',
            'WCDB_CT_server_id'
          ])
          const serverIdRaw = this.normalizeUnsignedIntToken(rawServerIdValue)
          const serverId = this.getIntFromRow(row, [
            'server_id', 'serverId', 'ServerId',
            'msg_server_id', 'msgServerId', 'MsgServerId',
            'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId',
            'WCDB_CT_server_id'
          ], 0)

          // 确定实际发送者
          let actualSender: string
          if (localType === 10000 || localType === 266287972401) {
            // 系统消息特殊处理
            const revokeInfo = this.extractRevokerInfo(content)
            if (revokeInfo.isRevoke) {
              // 撤回消息
              if (revokeInfo.isSelfRevoke) {
                // "你撤回了" - 发送者是当前用户
                actualSender = cleanedMyWxid
              } else if (revokeInfo.revokerWxid) {
                // 提取到了撤回者的 wxid
                actualSender = revokeInfo.revokerWxid
              } else {
                // 无法确定撤回者，使用 sessionId
                actualSender = sessionId
              }
            } else {
              // 普通系统消息（如"xxx加入群聊"），发送者是群聊ID
              actualSender = sessionId
            }
          } else {
            actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
          }

          if (normalizedSenderUsernameFilter && !this.isSameWxid(actualSender, normalizedSenderUsernameFilter)) {
            continue
          }
          senderSet.add(actualSender)

          if (collectMode === 'text-fast') {
            rows.push({
              localId,
              serverId,
              serverIdRaw: serverIdRaw !== '0' ? serverIdRaw : undefined,
              createTime,
              localType,
              content,
              senderUsername: actualSender,
              isSend
            })
            if (firstTime === null || createTime < firstTime) firstTime = createTime
            if (lastTime === null || createTime > lastTime) lastTime = createTime
            continue
          }

          // 提取媒体相关字段（轻量模式下跳过）
          let imageMd5: string | undefined
          let imageDatName: string | undefined
          let emojiCdnUrl: string | undefined
          let emojiMd5: string | undefined
          let videoMd5: string | undefined
          let locationLat: number | undefined
          let locationLng: number | undefined
          let locationPoiname: string | undefined
          let locationLabel: string | undefined
          let chatRecordList: any[] | undefined
          let emojiCaption: string | undefined
          let xmlType: string | undefined
          let fileName: string | undefined
          let fileSize: number | undefined
          let fileExt: string | undefined
          let fileMd5: string | undefined

          if (localType === 48 && content) {
            const locationMeta = this.extractLocationMeta(content, localType)
            if (locationMeta) {
              locationLat = locationMeta.locationLat
              locationLng = locationMeta.locationLng
              locationPoiname = locationMeta.locationPoiname
              locationLabel = locationMeta.locationLabel
            }
          }

          if (localType === 47) {
            emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
            emojiMd5 = this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) || undefined
            const packedInfoRaw = String(row.packed_info || row.packedInfo || row.PackedInfo || '')
            const reserved0Raw = String(row.reserved0 || row.Reserved0 || '')
            const supplementalPayload = `${this.decodeMaybeCompressed(packedInfoRaw)}\n${this.decodeMaybeCompressed(reserved0Raw)}`
            if (content) {
              emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content)
              emojiMd5 = emojiMd5 || this.normalizeEmojiMd5(this.extractEmojiMd5(content))
            }
            emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(supplementalPayload)
            emojiMd5 = emojiMd5 || this.extractEmojiMd5(supplementalPayload) || this.extractLooseHexMd5(supplementalPayload)
          }

          if (collectMode === 'full' || collectMode === 'media-fast') {
            // 优先复用游标返回的字段，缺失时再回退到 XML 解析。
            imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
            imageDatName = localType === 3 ? this.extractImageDatNameFromRow(row, content) : undefined
            videoMd5 = this.extractVideoFileNameFromRow(row, content)
            xmlType = rowFileHints.xmlType
            fileName = rowFileHints.fileName
            fileExt = rowFileHints.fileExt
            fileSize = rowFileHints.fileSize
            fileMd5 = rowFileHints.fileMd5

            if (content && (this.isFileAppLocalType(localType) || allowFileProbe || this.hasFileAppMessageHints({ xmlType, fileName, fileSize, fileExt, fileMd5 }))) {
              const fileMeta = this.extractFileAppMessageMeta(content)
              if (fileMeta) {
                xmlType = fileMeta.xmlType || xmlType
                fileName = fileMeta.fileName || fileName
                fileSize = fileMeta.fileSize || fileSize
                fileExt = fileMeta.fileExt || fileExt
                fileMd5 = fileMeta.fileMd5 || fileMd5
              }
            }

            if (localType === 3 && content) {
              // 图片消息
              imageMd5 = imageMd5 || this.extractImageMd5(content)
              imageDatName = imageDatName || this.extractImageDatNameFromRow(row, content)
            } else if (localType === 43 && content) {
              // 视频消息
              videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content)
            } else if (collectMode === 'full' && content && (localType === 49 || content.includes('<appmsg') || content.includes('&lt;appmsg'))) {
              // 检查是否是聊天记录消息（type=19），兼容大 localType 的 appmsg
              const normalizedContent = this.normalizeAppMessageContent(content)
              const xmlType = this.extractAppMessageType(normalizedContent)
              if (xmlType === '19') {
                chatRecordList = this.parseChatHistory(normalizedContent)
              }
            }
          }

          if (fileOnlyMediaFilter && !this.isFileAppMessage({ localType, xmlType, content, fileName, fileExt, fileMd5, fileSize })) {
            continue
          }

          rows.push({
            localId,
            serverId,
            serverIdRaw: serverIdRaw !== '0' ? serverIdRaw : undefined,
            createTime,
            localType,
            content,
            senderUsername: actualSender,
            isSend,
            imageMd5,
            imageDatName,
            emojiCdnUrl,
            emojiMd5,
            emojiCaption,
            videoMd5,
            xmlType,
            fileName,
            fileSize,
            fileExt,
            fileMd5,
            locationLat,
            locationLng,
            locationPoiname,
            locationLabel,
            chatRecordList
          })

          if (firstTime === null || createTime < firstTime) firstTime = createTime
          if (lastTime === null || createTime > lastTime) lastTime = createTime
          }
          onCollectProgress?.({ fetched: rows.length })
          hasMore = batch.hasMore === true
        }
        
      } catch (err) {
        if (this.isStopError(err)) throw err
        console.error(`[Export] 收集消息异常:`, err)
      } finally {
        try {
          await wcdbService.closeMessageCursor(cursor.cursor)
        } catch (err) {
          console.error(`[Export] 关闭游标失败:`, err)
        }
      }
    }

    if (rows.length === 0 && usedLiteCursor && allowLiteFallback) {
      console.warn(`[Export] 轻量游标返回 0 条，回退标准游标重试: session=${sessionId}, range=${beginTime}-${endTime}`)
      return this.collectMessages(
        sessionId,
        cleanedMyWxid,
        normalizedDateRange,
        senderUsernameFilter,
        collectMode,
        targetMediaTypes,
        control,
        onCollectProgress,
        false,
        allowRangeFallback,
        useCursorTimeRange,
        allowModeFallback
      )
    }

    if (rows.length === 0 && collectMode === 'media-fast' && allowModeFallback) {
      console.warn(`[Export] media-fast 返回 0 条，回退 full 模式重试: session=${sessionId}`)
      return this.collectMessages(
        sessionId,
        cleanedMyWxid,
        normalizedDateRange,
        senderUsernameFilter,
        'full',
        mediaTypeFilter || undefined,
        control,
        onCollectProgress,
        false,
        allowRangeFallback,
        useCursorTimeRange,
        false
      )
    }

    if (rows.length === 0 && allowRangeFallback && normalizedDateRange && useCursorTimeRange) {
      console.warn(`[Export] 时间范围游标返回 0 条，回退为全量游标+本地过滤重试: session=${sessionId}, range=${normalizedDateRange.start}-${normalizedDateRange.end}`)
      return this.collectMessages(
        sessionId,
        cleanedMyWxid,
        normalizedDateRange,
        senderUsernameFilter,
        collectMode,
        targetMediaTypes,
        control,
        onCollectProgress,
        allowLiteFallback,
        false,
        false,
        allowModeFallback
      )
    }

    this.throwIfStopRequested(control)
    if (collectMode === 'media-fast' && mediaTypeFilter && rows.length > 0) {
      await this.backfillMediaFieldsFromMessageDetail(sessionId, rows, mediaTypeFilter, control)
    }

    this.throwIfStopRequested(control)
    if (senderSet.size > 0) {
      const usernames = Array.from(senderSet)
      const [nameResult, avatarResult] = await Promise.all([
        wcdbService.getDisplayNames(usernames),
        wcdbService.getAvatarUrls(usernames)
      ])

      const nameMap = nameResult.success && nameResult.map ? nameResult.map : {}
      const avatarMap = avatarResult.success && avatarResult.map ? avatarResult.map : {}

      for (const username of usernames) {
        const displayName = nameMap[username] || username
        const avatarUrl = avatarMap[username]
        memberSet.set(username, {
          member: {
            platformId: username,
            accountName: displayName
          },
          avatarUrl
        })
        this.contactCache.set(username, { displayName, avatarUrl })
      }
    }

    if (rows.length > 1) {
      rows.sort((a, b) => {
        const timeDelta = (a.createTime || 0) - (b.createTime || 0)
        if (timeDelta !== 0) return timeDelta
        return (a.localId || 0) - (b.localId || 0)
      })
    }

    return { rows, memberSet, firstTime, lastTime }
  }

  private async getRecentWcdbCursorLogSummary(sessionId: string): Promise<string | undefined> {
    try {
      const logResult = await wcdbService.getLogs()
      if (!logResult.success || !Array.isArray(logResult.logs)) return undefined
      const sid = String(sessionId || '').trim()
      const interesting = logResult.logs
        .filter((line) => {
          const text = String(line || '')
          if (sid && text.includes(sid)) return true
          return text.includes('QueryMessageBatch') ||
            text.includes('InitExportCursorHeap') ||
            text.includes('cursor_init') ||
            text.includes('fetch_message_batch') ||
            text.includes('open_message_cursor')
        })
        .slice(-8)
      if (interesting.length === 0) return undefined
      return interesting.join(' | ')
    } catch {
      return undefined
    }
  }

  private async buildNoMessagesError(
    sessionId: string,
    collected: { error?: string },
    fallback = '该会话在指定时间范围内没有消息'
  ): Promise<string> {
    if (collected.error) return collected.error
    const nativeLogSummary = await this.getRecentWcdbCursorLogSummary(sessionId)
    if (!nativeLogSummary) return fallback
    return `${fallback}；WCDB日志：${nativeLogSummary}`
  }

  private async backfillMediaFieldsFromMessageDetail(
    sessionId: string,
    rows: any[],
    targetMediaTypes: Set<number>,
    control?: ExportTaskControl,
    options?: { force?: boolean }
  ): Promise<void> {
    const force = options?.force === true
    const fileOnlyMediaFilter = this.isFileOnlyMediaFilter(targetMediaTypes)
    const needsBackfill = rows.filter((msg) => {
      if (force) {
        return Number(msg?.localId || 0) > 0
      }
      const isFileCandidate = this.isFileAppLocalType(Number(msg.localType || 0)) || (fileOnlyMediaFilter && this.hasFileAppMessageHints(msg))
      if (isFileCandidate) {
        return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt
      }
      if (!targetMediaTypes.has(msg.localType)) return false
      if (msg.localType === 3) return !msg.imageMd5 || !msg.imageDatName
      if (msg.localType === 47) return !msg.emojiMd5
      if (msg.localType === 43) return !msg.videoMd5
      return false
    })
    if (needsBackfill.length === 0) return

    const DETAIL_CONCURRENCY = 6
    await parallelLimit(needsBackfill, DETAIL_CONCURRENCY, async (msg) => {
      this.throwIfStopRequested(control)
      const localId = Number(msg.localId || 0)
      if (!Number.isFinite(localId) || localId <= 0) return

      try {
        const detail = await wcdbService.getMessageById(sessionId, localId)
        if (!detail.success || !detail.message) return

        const row = detail.message as any
        const rawMessageContent = this.getRowField(row, [
          'message_content', 'messageContent', 'msg_content', 'msgContent', 'strContent', 'content', 'WCDB_CT_message_content'
        ]) ?? ''
        const rawCompressContent = this.getRowField(row, [
          'compress_content', 'compressContent', 'msg_compress_content', 'msgCompressContent', 'WCDB_CT_compress_content'
        ]) ?? ''
        const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
        const packedInfoRaw = this.getRowField(row, ['packed_info', 'packedInfo', 'PackedInfo', 'WCDB_CT_packed_info']) ?? ''
        const reserved0Raw = this.getRowField(row, ['reserved0', 'Reserved0', 'WCDB_CT_Reserved0']) ?? ''
        const supplementalPayload = `${this.decodeMaybeCompressed(String(packedInfoRaw || ''))}\n${this.decodeMaybeCompressed(String(reserved0Raw || ''))}`

        if (msg.localType === 3) {
          const imageMd5 = (String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) || '').toLowerCase()
          const imageDatName = this.extractImageDatNameFromRow(row, content) || ''
          if (imageMd5) msg.imageMd5 = imageMd5
          if (imageDatName) msg.imageDatName = imageDatName
          return
        }

        if (msg.localType === 47) {
          const emojiMd5 =
            this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) ||
            this.extractEmojiMd5(content) ||
            this.extractEmojiMd5(supplementalPayload) ||
            this.extractLooseHexMd5(supplementalPayload)
          const emojiCdnUrl =
            String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() ||
            this.extractEmojiUrl(content) ||
            this.extractEmojiUrl(supplementalPayload)
          if (emojiMd5) msg.emojiMd5 = emojiMd5
          if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl
          return
        }

        if (msg.localType === 43) {
          const videoMd5 = String(this.extractVideoFileNameFromRow(row, content) || '').trim().toLowerCase()
          if (videoMd5) msg.videoMd5 = videoMd5
          return
        }

        if (this.isFileAppLocalType(Number(msg.localType || 0)) || this.hasFileAppMessageHints(msg)) {
          const rowFileHints = this.getFileAppMessageHints(row)
          const fileMeta = this.extractFileAppMessageMeta(content)
          const mergedFileMeta = {
            xmlType: fileMeta?.xmlType || rowFileHints.xmlType,
            fileName: fileMeta?.fileName || rowFileHints.fileName,
            fileSize: fileMeta?.fileSize || rowFileHints.fileSize,
            fileExt: fileMeta?.fileExt || rowFileHints.fileExt,
            fileMd5: fileMeta?.fileMd5 || rowFileHints.fileMd5
          }
          if (mergedFileMeta.xmlType) msg.xmlType = mergedFileMeta.xmlType
          if (mergedFileMeta.fileName) msg.fileName = mergedFileMeta.fileName
          if (mergedFileMeta.fileSize) msg.fileSize = mergedFileMeta.fileSize
          if (mergedFileMeta.fileExt) msg.fileExt = mergedFileMeta.fileExt
          if (mergedFileMeta.fileMd5) msg.fileMd5 = mergedFileMeta.fileMd5
        }
      } catch (error) {
        // 详情补取失败时保持降级导出（占位符），避免中断整批任务。
      }
    })
  }

  // 补齐群成员，避免只导出发言者导致头像缺失
  private async mergeGroupMembers(
    chatroomId: string,
    memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>,
    includeAvatars: boolean
  ): Promise<void> {
    const result = await wcdbService.getGroupMembers(chatroomId)
    if (!result.success || !result.members || result.members.length === 0) return

    const rawMembers = result.members as Array<{
      username?: string
      avatarUrl?: string
      nickname?: string
      displayName?: string
      remark?: string
      originalName?: string
    }>
    const usernames = rawMembers
      .map((member) => member.username)
      .filter((username): username is string => Boolean(username))
    if (usernames.length === 0) return

    const lookupUsernames = new Set<string>()
    for (const username of usernames) {
      lookupUsernames.add(username)
      const cleaned = this.cleanAccountDirName(username)
      if (cleaned && cleaned !== username) {
        lookupUsernames.add(cleaned)
      }
    }

    const [displayNames, avatarUrls] = await Promise.all([
      wcdbService.getDisplayNames(Array.from(lookupUsernames)),
      includeAvatars ? wcdbService.getAvatarUrls(Array.from(lookupUsernames)) : Promise.resolve({ success: true, map: {} as Record<string, string> })
    ])

    for (const member of rawMembers) {
      const username = member.username
      if (!username) continue

      const cleaned = this.cleanAccountDirName(username)
      const displayName = displayNames.success && displayNames.map
        ? (displayNames.map[username] || (cleaned ? displayNames.map[cleaned] : undefined) || username)
        : username
      const groupNickname = member.nickname || member.displayName || member.remark || member.originalName
      const avatarUrl = includeAvatars && avatarUrls.success && avatarUrls.map
        ? (avatarUrls.map[username] || (cleaned ? avatarUrls.map[cleaned] : undefined) || member.avatarUrl)
        : member.avatarUrl

      const existing = memberSet.get(username)
      if (existing) {
        if (displayName && existing.member.accountName === existing.member.platformId && displayName !== existing.member.platformId) {
          existing.member.accountName = displayName
        }
        if (groupNickname && !existing.member.groupNickname) {
          existing.member.groupNickname = groupNickname
        }
        if (!existing.avatarUrl && avatarUrl) {
          existing.avatarUrl = avatarUrl
        }
        memberSet.set(username, existing)
        continue
      }

      const chatlabMember: ChatLabMember = {
        platformId: username,
        accountName: displayName
      }
      if (groupNickname) {
        chatlabMember.groupNickname = groupNickname
      }
      memberSet.set(username, { member: chatlabMember, avatarUrl })
    }
  }

  private extractGroupMemberUsername(member: any): string {
    if (!member) return ''
    if (typeof member === 'string') return member.trim()
    return String(
      member.username ||
      member.userName ||
      member.user_name ||
      member.encryptUsername ||
      member.encryptUserName ||
      member.encrypt_username ||
      member.originalName ||
      ''
    ).trim()
  }

  private extractGroupSenderCountMap(groupStats: any, sessionId: string): Map<string, number> {
    const senderCountMap = new Map<string, number>()
    if (!groupStats || typeof groupStats !== 'object') return senderCountMap

    const sessions = (groupStats as any).sessions
    const sessionStats = sessions && typeof sessions === 'object'
      ? (sessions[sessionId] || sessions[String(sessionId)] || null)
      : null
    const senderRaw = (sessionStats && typeof sessionStats === 'object' && (sessionStats as any).senders && typeof (sessionStats as any).senders === 'object')
      ? (sessionStats as any).senders
      : ((groupStats as any).senders && typeof (groupStats as any).senders === 'object' ? (groupStats as any).senders : {})
    const idMap = (groupStats as any).idMap && typeof (groupStats as any).idMap === 'object'
      ? (groupStats as any).idMap
      : ((sessionStats && typeof sessionStats === 'object' && (sessionStats as any).idMap && typeof (sessionStats as any).idMap === 'object')
        ? (sessionStats as any).idMap
        : {})

    for (const [senderKey, rawCount] of Object.entries(senderRaw)) {
      const countNumber = Number(rawCount)
      if (!Number.isFinite(countNumber) || countNumber <= 0) continue
      const count = Math.max(0, Math.floor(countNumber))
      const mapped = typeof (idMap as any)[senderKey] === 'string' ? String((idMap as any)[senderKey]).trim() : ''
      const wxid = (mapped || String(senderKey || '').trim())
      if (!wxid) continue
      senderCountMap.set(wxid, (senderCountMap.get(wxid) || 0) + count)
    }

    return senderCountMap
  }

  private sumSenderCountsByIdentity(senderCountMap: Map<string, number>, wxid: string): number {
    const target = String(wxid || '').trim()
    if (!target) return 0
    let total = 0
    for (const [senderWxid, count] of senderCountMap.entries()) {
      if (!Number.isFinite(count) || count <= 0) continue
      if (this.isSameWxid(senderWxid, target)) {
        total += count
      }
    }
    return total
  }

  private async queryFriendFlagMap(usernames: string[]): Promise<Map<string, boolean>> {
    const result = new Map<string, boolean>()
    const unique = Array.from(
      new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
    )
    if (unique.length === 0) return result

    const query = await wcdbService.getContactFriendFlags(unique)
    if (query.success && query.map) {
      for (const [username, isFriend] of Object.entries(query.map)) {
        const normalized = String(username || '').trim()
        if (!normalized) continue
        result.set(normalized, Boolean(isFriend))
      }
    }

    for (const username of unique) {
      if (!result.has(username)) {
        result.set(username, false)
      }
    }

    return result
  }

  private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null {
    if (!avatarUrl) return null
    if (avatarUrl.startsWith('data:')) {
      const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(avatarUrl)
      if (!match) return null
      const mime = match[1].toLowerCase()
      const data = Buffer.from(match[2], 'base64')
      const ext = mime.includes('png') ? '.png'
        : mime.includes('gif') ? '.gif'
          : mime.includes('webp') ? '.webp'
            : '.jpg'
      return { data, ext, mime }
    }
    if (avatarUrl.startsWith('file://')) {
      try {
        const sourcePath = fileURLToPath(avatarUrl)
        const ext = path.extname(sourcePath) || '.jpg'
        return { sourcePath, ext }
      } catch {
        return null
      }
    }
    if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) {
      const url = new URL(avatarUrl)
      const ext = path.extname(url.pathname) || '.jpg'
      return { sourceUrl: avatarUrl, ext }
    }
    const sourcePath = avatarUrl
    const ext = path.extname(sourcePath) || '.jpg'
    return { sourcePath, ext }
  }

  private async downloadToBuffer(url: string, remainingRedirects = 2): Promise<{ data: Buffer; mime?: string } | null> {
    const client = url.startsWith('https:') ? https : http
    return new Promise((resolve) => {
      const request = client.get(url, (res) => {
        const status = res.statusCode || 0
        if (status >= 300 && status < 400 && res.headers.location && remainingRedirects > 0) {
          res.resume()
          const redirectedUrl = new URL(res.headers.location, url).href
          this.downloadToBuffer(redirectedUrl, remainingRedirects - 1)
            .then(resolve)
          return
        }
        if (status < 200 || status >= 300) {
          res.resume()
          resolve(null)
          return
        }
        const chunks: Buffer[] = []
        res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
        res.on('end', () => {
          const data = Buffer.concat(chunks)
          const mime = typeof res.headers['content-type'] === 'string' ? res.headers['content-type'] : undefined
          resolve({ data, mime })
        })
      })
      request.on('error', () => resolve(null))
      request.setTimeout(15000, () => {
        request.destroy()
        resolve(null)
      })
    })
  }

  private async exportAvatars(
    members: Array<{ username: string; avatarUrl?: string }>
  ): Promise<Map<string, string>> {
    const result = new Map<string, string>()
    if (members.length === 0) return result

    // 直接使用 URL，不转换为 base64（与 ciphertalk 保持一致）
    for (const member of members) {
      if (member.avatarUrl) {
        result.set(member.username, member.avatarUrl)
      }
    }

    return result
  }

  /**
   * 导出头像为外部文件（仅用于HTML格式）
   * 将头像保存到 avatars/ 子目录，返回相对路径
   */
  private async exportAvatarsToFiles(
    members: Array<{ username: string; avatarUrl?: string }>,
    outputDir: string,
    control?: ExportTaskControl
  ): Promise<Map<string, string>> {
    const result = new Map<string, string>()
    if (members.length === 0) return result

    // 创建 avatars 子目录
    const avatarsDir = path.join(outputDir, 'avatars')
    await this.ensureExportDir(avatarsDir, control)

    const AVATAR_CONCURRENCY = 8
    await parallelLimit(members, AVATAR_CONCURRENCY, async (member) => {
      const fileInfo = this.resolveAvatarFile(member.avatarUrl)
      if (!fileInfo) return
      try {
        let data: Buffer | null = null
        let mime = fileInfo.mime
        if (fileInfo.data) {
          data = fileInfo.data
        } else if (fileInfo.sourcePath && fs.existsSync(fileInfo.sourcePath)) {
          data = await fs.promises.readFile(fileInfo.sourcePath)
        } else if (fileInfo.sourceUrl) {
          const downloaded = await this.downloadToBuffer(fileInfo.sourceUrl)
          if (downloaded) {
            data = downloaded.data
            mime = downloaded.mime || mime
          }
        }
        if (!data) return

        // 优先使用内容检测出的 MIME 类型
        const detectedMime = this.detectMimeType(data)
        const finalMime = detectedMime || mime || this.inferImageMime(fileInfo.ext)

        // 根据 MIME 类型确定文件扩展名
        const ext = this.getExtensionFromMime(finalMime)

        // 清理用户名作为文件名（移除非法字符，限制长度）
        const sanitizedUsername = member.username
          .replace(/[<>:"/\\|?*@]/g, '_')
          .substring(0, 100)

        const filename = `${sanitizedUsername}${ext}`
        const avatarPath = path.join(avatarsDir, filename)

        // 跳过已存在文件
        try {
          await fs.promises.access(avatarPath)
        } catch {
          await this.recordCreatedFileBeforeWrite(avatarPath, control)
          await fs.promises.writeFile(avatarPath, data)
        }

        // 返回相对路径
        result.set(member.username, `avatars/${filename}`)
      } catch {
        return
      }
    })

    return result
  }

  private getExtensionFromMime(mime: string): string {
    switch (mime) {
      case 'image/png':
        return '.png'
      case 'image/gif':
        return '.gif'
      case 'image/webp':
        return '.webp'
      case 'image/bmp':
        return '.bmp'
      case 'image/jpeg':
      default:
        return '.jpg'
    }
  }


  private detectMimeType(buffer: Buffer): string | null {
    if (buffer.length < 4) return null

    // PNG: 89 50 4E 47
    if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
      return 'image/png'
    }

    // JPEG: FF D8 FF
    if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
      return 'image/jpeg'
    }

    // GIF: 47 49 46 38
    if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
      return 'image/gif'
    }

    // WEBP: RIFF ... WEBP
    if (buffer.length >= 12 &&
      buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
      buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
      return 'image/webp'
    }

    // BMP: 42 4D
    if (buffer[0] === 0x42 && buffer[1] === 0x4D) {
      return 'image/bmp'
    }

    return null
  }

  private inferImageMime(ext: string): string {
    switch (ext.toLowerCase()) {
      case '.png':
        return 'image/png'
      case '.gif':
        return 'image/gif'
      case '.webp':
        return 'image/webp'
      case '.bmp':
        return 'image/bmp'
      default:
        return 'image/jpeg'
    }
  }

  private getWeflowHeader(): { version: string; exportedAt: number; generator: string } {
    return {
      version: '1.0.3',
      exportedAt: Math.floor(Date.now() / 1000),
      generator: 'WeFlow'
    }
  }

  /**
   * 生成通用的导出元数据 (参考 ChatLab 格式)
   */
  private getExportMeta(
    sessionId: string,
    sessionInfo: { displayName: string },
    isGroup: boolean,
    sessionAvatar?: string
  ): { chatlab: ChatLabHeader; meta: ChatLabMeta } {
    return {
      chatlab: {
        version: '0.0.2',
        exportedAt: Math.floor(Date.now() / 1000),
        generator: 'WeFlow'
      },
      meta: {
        name: sessionInfo.displayName,
        platform: 'wechat',
        type: isGroup ? 'group' : 'private',
        ...(isGroup && { groupId: sessionId }),
        ...(sessionAvatar && { groupAvatar: sessionAvatar })
      }
    }
  }

  /**
   * 导出单个会话为 ChatLab 格式（并行优化版本）
   */
  async exportSessionToChatLab(
    sessionId: string,
    outputPath: string,
    options: ExportOptions,
    onProgress?: (progress: ExportProgress) => void,
    control?: ExportTaskControl
  ): Promise<{ success: boolean; error?: string }> {
    try {
      this.throwIfStopRequested(control)
      const conn = await this.ensureConnected()
      if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }

      const cleanedMyWxid = conn.cleanedWxid
      const isGroup = sessionId.includes('@chatroom')
      const rawMyWxid = this.getConfiguredMyWxid()

      const sessionInfo = await this.getContactInfo(sessionId)
      const myInfo = await this.getContactInfo(cleanedMyWxid)
      const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
      const getContactCached = async (username: string) => {
        if (contactCache.has(username)) {
          return contactCache.get(username)!
        }
        const result = await wcdbService.getContact(username)
        contactCache.set(username, result)
        return result
      }

      onProgress?.({
        current: 0,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'preparing'
      })

      const collectParams = this.resolveCollectParams(options)
      const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
      const collected = await this.collectMessages(
        sessionId,
        cleanedMyWxid,
        options.dateRange,
        options.senderUsername,
        collectParams.mode,
        collectParams.targetMediaTypes,
        control,
        collectProgressReporter
      )
      const allMessages = collected.rows
      const totalMessages = allMessages.length

      // 如果没有消息,不创建文件
      if (totalMessages === 0) {
        return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
      }

      await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control)

      const voiceMessages = options.exportVoiceAsText
        ? allMessages.filter(msg => msg.localType === 34)
        : []

      if (options.exportVoiceAsText && voiceMessages.length > 0) {
        await this.ensureVoiceModel(onProgress)
      }

      const senderUsernames = new Set<string>()
      let senderScanIndex = 0
      for (const msg of allMessages) {
        if ((senderScanIndex++ & 0x7f) === 0) {
          this.throwIfStopRequested(control)
        }
        if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
      }
      senderUsernames.add(sessionId)
      senderUsernames.add(cleanedMyWxid)
      await this.preloadContacts(senderUsernames, contactCache)

      if (isGroup) {
        this.throwIfStopRequested(control)
        await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
      }

      // ========== 获取群昵称并更新到 memberSet ==========
      const groupNicknameCandidates = isGroup
        ? this.buildGroupNicknameIdCandidates([
          ...Array.from(collected.memberSet.keys()),
          ...allMessages.map(msg => msg.senderUsername),
          cleanedMyWxid
        ])
        : []
      const groupNicknamesMap = isGroup
        ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
        : new Map<string, string>()

      // 将群昵称更新到 memberSet 中
      if (isGroup && groupNicknamesMap.size > 0) {
        for (const [username, info] of collected.memberSet) {
          // 尝试多种方式查找群昵称（支持大小写）
          const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [username]) || ''
          if (groupNickname) {
            info.member.groupNickname = groupNickname
          }
        }
      }

      const allMessagesInCursorOrder = allMessages

      const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)

      // ========== 阶段1：并行导出媒体文件 ==========
      const mediaMessages = this.collectMediaMessagesForExport(allMessagesInCursorOrder, options)

      const mediaCache = new Map<string, MediaExportItem | null>()
      const mediaDirCache = new Set<string>()
      const beforeMediaDoneFiles = this.getMediaDoneFilesCount()

      if (mediaMessages.length > 0) {
        await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
          exportImages: options.exportImages,
          exportVideos: options.exportVideos
        }, control)
        const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
        if (voiceMediaMessages.length > 0) {
          await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
        }

        onProgress?.({
          current: 20,
          total: 100,
          currentSession: sessionInfo.displayName,
          phase: 'exporting-media',
          phaseProgress: 0,
          phaseTotal: mediaMessages.length,
          phaseLabel: this.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
          ...this.getMediaTelemetrySnapshot(),
          estimatedTotalMessages: totalMessages
        })

        // 并行导出媒体，并发数跟随导出设置
        const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
        let mediaExported = 0
        await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
          this.throwIfStopRequested(control)
          const mediaKey = this.getMediaCacheKey(msg)
          if (!mediaCache.has(mediaKey)) {
            const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
              exportImages: options.exportImages,
              exportVoices: options.exportVoices,
              exportVideos: options.exportVideos,
              exportEmojis: options.exportEmojis,
              exportFiles: options.exportFiles,
              maxFileSizeMb: options.maxFileSizeMb,
              exportVoiceAsText: options.exportVoiceAsText,
              includeVideoPoster: options.format === 'html',
              dirCache: mediaDirCache,
              control
            })
            mediaCache.set(mediaKey, mediaItem)
          }
          mediaExported++
          if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
            onProgress?.({
              current: 20,
              total: 100,
              currentSession: sessionInfo.displayName,
              phase: 'exporting-media',
              phaseProgress: mediaExported,
              phaseTotal: mediaMessages.length,
              phaseLabel: this.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
              ...this.getMediaTelemetrySnapshot()
            })
          }
        })
      }
      const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
      if (fileOnlyExportFailure) return fileOnlyExportFailure

      // ========== 阶段2：并行语音转文字 ==========
      const voiceTranscriptMap = new Map<string, string>()

      if (voiceMessages.length > 0) {
        await this.preloadVoiceWavCache(sessionId, voiceMessages, control)

        onProgress?.({
          current: 40,
          total: 100,
          currentSession: sessionInfo.displayName,
          phase: 'exporting-voice',
          phaseProgress: 0,
          phaseTotal: voiceMessages.length,
          phaseLabel: `语音转文字 0/${voiceMessages.length}`,
          estimatedTotalMessages: totalMessages
        })

        // 并行转写语音，限制 4 个并发（转写比较耗资源）
        const VOICE_CONCURRENCY = 4
        let voiceTranscribed = 0
        await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
          this.throwIfStopRequested(control)
          const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
          voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript)
          voiceTranscribed++
          onProgress?.({
            current: 40,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'exporting-voice',
            phaseProgress: voiceTranscribed,
            phaseTotal: voiceMessages.length,
            phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
          })
        })
      }

      // ========== 阶段3：构建消息列表 ==========
      onProgress?.({
        current: 60,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'exporting',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: 0
      })

      const chatLabMessages: ChatLabMessage[] = []
      const senderProfileMap = new Map<string, ExportDisplayProfile>()
      let messageIndex = 0
      for (const msg of allMessages) {
        if ((messageIndex++ & 0x7f) === 0) {
          this.throwIfStopRequested(control)
        }
        const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
          platformId: msg.senderUsername,
          accountName: msg.senderUsername,
          groupNickname: undefined
        }

        // 如果 memberInfo 中没有群昵称，尝试从 groupNicknamesMap 获取
        const groupNickname = memberInfo.groupNickname
          || (isGroup ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [msg.senderUsername]) : '')
          || ''
        const senderProfile = isGroup
          ? await this.resolveExportDisplayProfile(
            msg.senderUsername || cleanedMyWxid,
            options.displayNamePreference,
            getContactCached,
            groupNicknamesMap,
            msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (memberInfo.accountName || msg.senderUsername || ''),
            msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
          )
          : {
            wxid: msg.senderUsername || cleanedMyWxid,
            nickname: memberInfo.accountName || msg.senderUsername || '',
            remark: '',
            alias: '',
            groupNickname,
            displayName: memberInfo.accountName || msg.senderUsername || ''
          }
        if (senderProfile.wxid && !senderProfileMap.has(senderProfile.wxid)) {
          senderProfileMap.set(senderProfile.wxid, senderProfile)
        }

        // 确定消息内容
        let content: string | null
        const mediaKey = this.getMediaCacheKey(msg)
        const mediaItem = mediaCache.get(mediaKey)
        if (msg.localType === 34 && options.exportVoiceAsText) {
          // 使用预先转写的文字
          content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
        } else if (mediaItem && msg.localType === 3) {
          content = mediaItem.relativePath
        } else {
          content = this.parseMessageContent(
            msg.content,
            msg.localType,
            sessionId,
            msg.createTime,
            cleanedMyWxid,
            msg.senderUsername,
            msg.isSend,
            msg.emojiCaption
          )
        }
        if (this.isReadableSystemMessage(msg.localType, msg.content)) {
          content = this.extractReadableSystemMessageText(msg.content) || content
        }

        // 转账消息：追加 "谁转账给谁" 信息
        if (content && this.isTransferExportContent(content) && msg.content) {
          const transferDesc = await this.resolveTransferDesc(
            msg.content,
            cleanedMyWxid,
            groupNicknamesMap,
            async (username) => {
              const info = await this.getContactInfo(username)
              return info.displayName || username
            }
          )
          if (transferDesc) {
            content = this.appendTransferDesc(content, transferDesc)
          }
        }

        const markdownLinkContent = this.formatLinkCardExportText(msg.content, msg.localType, 'markdown')
        if (markdownLinkContent) {
          content = markdownLinkContent
        }

        const message: ChatLabMessage = {
          sender: msg.senderUsername,
          accountName: senderProfile.displayName || memberInfo.accountName,
          groupNickname: (senderProfile.groupNickname || groupNickname) || undefined,
          timestamp: msg.createTime,
          type: this.convertMessageType(msg.localType, msg.content),
          content: content
        }

        const platformMessageId = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId)
        if (platformMessageId !== '0') {
          message.platformMessageId = platformMessageId
        }

        const replyToMessageId = this.extractChatLabReplyToMessageId(msg.content)
        if (replyToMessageId) {
          message.replyToMessageId = replyToMessageId
        }

        // 如果有聊天记录，添加为嵌套字段
        if (msg.chatRecordList && msg.chatRecordList.length > 0) {
          const chatRecords: any[] = []

          for (const record of msg.chatRecordList) {
            // 解析时间戳 (格式: "YYYY-MM-DD HH:MM:SS")
            let recordTimestamp = msg.createTime
            if (record.sourcetime) {
              try {
                const timeParts = record.sourcetime.match(/(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/)
                if (timeParts) {
                  const date = new Date(
                    parseInt(timeParts[1]),
                    parseInt(timeParts[2]) - 1,
                    parseInt(timeParts[3]),
                    parseInt(timeParts[4]),
                    parseInt(timeParts[5]),
                    parseInt(timeParts[6])
                  )
                  recordTimestamp = Math.floor(date.getTime() / 1000)
                }
              } catch (e) {
                console.error('解析聊天记录时间失败:', e)
              }
            }

            // 转换消息类型
            let recordType = 0 // TEXT
            let recordContent = record.datadesc || record.datatitle || ''

            switch (record.datatype) {
              case 1:
                recordType = 0 // TEXT
                break
              case 3:
                recordType = 1 // IMAGE
                recordContent = '[图片]'
                break
              case 8:
              case 49:
                recordType = 4 // FILE
                recordContent = record.datatitle ? `[文件] ${record.datatitle}` : '[文件]'
                break
              case 34:
                recordType = 2 // VOICE
                recordContent = '[语音消息]'
                break
              case 43:
                recordType = 3 // VIDEO
                recordContent = '[视频]'
                break
              case 47:
                recordType = 5 // EMOJI
                recordContent = '[表情包]'
                break
              default:
                recordType = 0
                recordContent = record.datadesc || record.datatitle || '[消息]'
            }

            const chatRecord: any = {
              sender: record.sourcename || 'unknown',
              accountName: record.sourcename || 'unknown',
              timestamp: recordTimestamp,
              type: recordType,
              content: recordContent
            }

            // 添加头像（如果启用导出头像）
            if (options.exportAvatars && record.sourceheadurl) {
              chatRecord.avatar = record.sourceheadurl
            }

            chatRecords.push(chatRecord)

            // 添加成员信息到 memberSet
            if (record.sourcename && !collected.memberSet.has(record.sourcename)) {
              const newMember: ChatLabMember = {
                platformId: record.sourcename,
                accountName: record.sourcename
              }
              if (options.exportAvatars && record.sourceheadurl) {
                newMember.avatar = record.sourceheadurl
              }
              collected.memberSet.set(record.sourcename, {
                member: newMember,
                avatarUrl: record.sourceheadurl
              })
            }
          }

          message.chatRecords = chatRecords
        }

        chatLabMessages.push(message)
        if ((chatLabMessages.length % 200) === 0 || chatLabMessages.length === totalMessages) {
          const exportProgress = 60 + Math.floor((chatLabMessages.length / totalMessages) * 20)
          onProgress?.({
            current: exportProgress,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'exporting',
            estimatedTotalMessages: totalMessages,
            collectedMessages: totalMessages,
            exportedMessages: chatLabMessages.length
          })
        }
      }

      const avatarMap = options.exportAvatars
        ? await this.exportAvatars(
          [
            ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
              username,
              avatarUrl: info.avatarUrl
            })),
            { username: sessionId, avatarUrl: sessionInfo.avatarUrl }
          ]
        )
        : new Map<string, string>()

      const sessionAvatar = avatarMap.get(sessionId)
      const members = await Promise.all(Array.from(collected.memberSet.values()).map(async (info) => {
        const profile = isGroup
          ? (senderProfileMap.get(info.member.platformId) || await this.resolveExportDisplayProfile(
            info.member.platformId,
            options.displayNamePreference,
            getContactCached,
            groupNicknamesMap,
            info.member.accountName || info.member.platformId,
            this.isSameWxid(info.member.platformId, cleanedMyWxid) ? [rawMyWxid, cleanedMyWxid] : []
          ))
          : null
        const member = profile
          ? {
            ...info.member,
            accountName: profile.displayName || info.member.accountName,
            groupNickname: profile.groupNickname || info.member.groupNickname
          }
          : info.member
        const avatar = avatarMap.get(info.member.platformId)
        return avatar ? { ...member, avatar } : member
      }))

      const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup, sessionAvatar)

      const chatLabExport: ChatLabExport = {
        chatlab,
        meta,
        members,
        messages: chatLabMessages
      }

      onProgress?.({
        current: 80,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'writing',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: totalMessages
      })

      if (options.format === 'chatlab-jsonl') {
        const lines: string[] = []
        lines.push(JSON.stringify({
          _type: 'header',
          chatlab: chatLabExport.chatlab,
          meta: chatLabExport.meta
        }))
        for (const member of chatLabExport.members) {
          this.throwIfStopRequested(control)
          lines.push(JSON.stringify({ _type: 'member', ...member }))
        }
        for (const message of chatLabExport.messages) {
          this.throwIfStopRequested(control)
          lines.push(JSON.stringify({ _type: 'message', ...message }))
        }
        this.throwIfStopRequested(control)
        await this.recordCreatedFileBeforeWrite(outputPath, control)
        await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8')
      } else {
        this.throwIfStopRequested(control)
        await this.recordCreatedFileBeforeWrite(outputPath, control)
        await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
      }

      onProgress?.({
        current: 100,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'complete',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: totalMessages,
        writtenFiles: 1
      })

      return { success: true }
    } catch (e) {
      if (this.isStopError(e)) {
        return { success: false, error: '导出任务已停止' }
      }
      if (this.isPauseError(e)) {
        return { success: false, error: '导出任务已暂停' }
      }
      return { success: false, error: String(e) }
    }
  }

  /**
   * 导出单个会话为详细 JSON 格式（原项目格式）- 并行优化版本
   */
  async exportSessionToDetailedJson(
    sessionId: string,
    outputPath: string,
    options: ExportOptions,
    onProgress?: (progress: ExportProgress) => void,
    control?: ExportTaskControl
  ): Promise<{ success: boolean; error?: string }> {
    try {
      this.throwIfStopRequested(control)
      const conn = await this.ensureConnected()
      if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }

      const cleanedMyWxid = conn.cleanedWxid
      const isGroup = sessionId.includes('@chatroom')
      const rawMyWxid = this.getConfiguredMyWxid()

      const sessionInfo = await this.getContactInfo(sessionId)
      const myInfo = await this.getContactInfo(cleanedMyWxid)

      const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
      const getContactCached = async (username: string) => {
        if (contactCache.has(username)) {
          return contactCache.get(username)!
        }
        const result = await wcdbService.getContact(username)
        contactCache.set(username, result)
        return result
      }

      onProgress?.({
        current: 0,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'preparing'
      })

      const collectParams = this.resolveCollectParams(options)
      const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
      const collected = await this.collectMessages(
        sessionId,
        cleanedMyWxid,
        options.dateRange,
        options.senderUsername,
        collectParams.mode,
        collectParams.targetMediaTypes,
        control,
        collectProgressReporter
      )
      const totalMessages = collected.rows.length

      // 如果没有消息,不创建文件
      if (totalMessages === 0) {
        return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
      }

      await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)

      // 解析引用消息
      await this.resolveQuotedMessagesForExport(collected.rows, sessionId)

      const voiceMessages = options.exportVoiceAsText
        ? collected.rows.filter(msg => msg.localType === 34)
        : []

      if (options.exportVoiceAsText && voiceMessages.length > 0) {
        await this.ensureVoiceModel(onProgress)
      }

      const senderUsernames = new Set<string>()
      let senderScanIndex = 0
      for (const msg of collected.rows) {
        if ((senderScanIndex++ & 0x7f) === 0) {
          this.throwIfStopRequested(control)
        }
        if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
      }
      senderUsernames.add(sessionId)
      await this.preloadContacts(senderUsernames, contactCache)
      const senderInfoMap = await this.preloadContactInfos([
        ...Array.from(senderUsernames.values()),
        cleanedMyWxid
      ])

      const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)

      // ========== 阶段1：并行导出媒体文件 ==========
      const mediaMessages = this.collectMediaMessagesForExport(collected.rows, options)

      const mediaCache = new Map<string, MediaExportItem | null>()
      const mediaDirCache = new Set<string>()
      const beforeMediaDoneFiles = this.getMediaDoneFilesCount()

      if (mediaMessages.length > 0) {
        await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
          exportImages: options.exportImages,
          exportVideos: options.exportVideos
        }, control)
        const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
        if (voiceMediaMessages.length > 0) {
          await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
        }

        onProgress?.({
          current: 15,
          total: 100,
          currentSession: sessionInfo.displayName,
          phase: 'exporting-media',
          phaseProgress: 0,
          phaseTotal: mediaMessages.length,
          phaseLabel: this.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
          ...this.getMediaTelemetrySnapshot(),
          estimatedTotalMessages: totalMessages
        })

        const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
        let mediaExported = 0
        await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
          this.throwIfStopRequested(control)
          const mediaKey = this.getMediaCacheKey(msg)
          if (!mediaCache.has(mediaKey)) {
            const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
              exportImages: options.exportImages,
              exportVoices: options.exportVoices,
              exportVideos: options.exportVideos,
              exportEmojis: options.exportEmojis,
              exportFiles: options.exportFiles,
              maxFileSizeMb: options.maxFileSizeMb,
              exportVoiceAsText: options.exportVoiceAsText,
              includeVideoPoster: options.format === 'html',
              dirCache: mediaDirCache,
              control
            })
            mediaCache.set(mediaKey, mediaItem)
          }
          mediaExported++
          if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
            onProgress?.({
              current: 15,
              total: 100,
              currentSession: sessionInfo.displayName,
              phase: 'exporting-media',
              phaseProgress: mediaExported,
              phaseTotal: mediaMessages.length,
              phaseLabel: this.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
              ...this.getMediaTelemetrySnapshot()
            })
          }
        })
      }
      const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
      if (fileOnlyExportFailure) return fileOnlyExportFailure

      // ========== 阶段2：并行语音转文字 ==========
      const voiceTranscriptMap = new Map<string, string>()

      if (voiceMessages.length > 0) {
        await this.preloadVoiceWavCache(sessionId, voiceMessages, control)

        onProgress?.({
          current: 35,
          total: 100,
          currentSession: sessionInfo.displayName,
          phase: 'exporting-voice',
          phaseProgress: 0,
          phaseTotal: voiceMessages.length,
          phaseLabel: `语音转文字 0/${voiceMessages.length}`,
          estimatedTotalMessages: totalMessages
        })

        const VOICE_CONCURRENCY = 4
        let voiceTranscribed = 0
        await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
          this.throwIfStopRequested(control)
          const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
          voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript)
          voiceTranscribed++
          onProgress?.({
            current: 35,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'exporting-voice',
            phaseProgress: voiceTranscribed,
            phaseTotal: voiceMessages.length,
            phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
          })
        })
      }

      // ========== 预加载群昵称（用于名称显示偏好） ==========
      const groupNicknameCandidates = isGroup
        ? this.buildGroupNicknameIdCandidates([
          ...Array.from(senderUsernames.values()),
          ...collected.rows.map(msg => msg.senderUsername),
          cleanedMyWxid
        ])
        : []
      const groupNicknamesMap = isGroup
        ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
        : new Map<string, string>()

      // ========== 阶段3：构建消息列表 ==========
      onProgress?.({
        current: 55,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'exporting',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: 0
      })

      const allMessages: any[] = []
      const senderProfileMap = new Map<string, {
        displayName: string
        nickname: string
        remark: string
        groupNickname: string
      }>()
      const transferCandidates: Array<{ xml: string; messageRef: any }> = []
      let needSort = false
      let lastCreateTime = Number.NEGATIVE_INFINITY
      let messageIndex = 0
      for (const msg of collected.rows) {
        if ((messageIndex++ & 0x7f) === 0) {
          this.throwIfStopRequested(control)
        }
        const senderInfo = senderInfoMap.get(msg.senderUsername) || { displayName: msg.senderUsername || '' }
        const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
        const source = sourceMatch ? sourceMatch[0] : ''

        let content: string | null
        const mediaKey = this.getMediaCacheKey(msg)
        const mediaItem = mediaCache.get(mediaKey)

        if (msg.localType === 34 && options.exportVoiceAsText) {
          content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
        } else if (mediaItem && msg.localType !== 47) {
          content = mediaItem.relativePath
        } else {
          content = this.parseMessageContent(
            msg.content,
            msg.localType,
            undefined,
            undefined,
            cleanedMyWxid,
            msg.senderUsername,
            msg.isSend,
            msg.emojiCaption
          )
        }
        if (this.isReadableSystemMessage(msg.localType, msg.content)) {
          content = this.extractReadableSystemMessageText(msg.content) || content
        }

        const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
          content: msg.content,
          isGroup,
          displayNamePreference: options.displayNamePreference,
          getContact: getContactCached,
          groupNicknamesMap,
          cleanedMyWxid,
          rawMyWxid,
          myDisplayName: myInfo.displayName || cleanedMyWxid
        })
        // 对于媒体消息，不要让引用信息覆盖媒体路径
        if (quotedReplyDisplay && !mediaItem) {
          content = this.buildQuotedReplyText(quotedReplyDisplay)
        }

        const appendedLinkContent = quotedReplyDisplay
          ? null
          : this.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
        if (appendedLinkContent) {
          content = appendedLinkContent
        }

        // 获取发送者信息用于名称显示
        const senderWxid = msg.senderUsername
        const contact = senderWxid
          ? (contactCache.get(senderWxid) ?? { success: false as const })
          : { success: false as const }
        const senderNickname = contact.success && contact.contact?.nickName
          ? contact.contact.nickName
          : (senderInfo.displayName || senderWxid)
        const senderRemark = contact.success && contact.contact?.remark ? contact.contact.remark : ''
        const senderGroupNickname = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid])

        // 使用用户偏好的显示名称
        const senderDisplayName = this.getPreferredDisplayName(
          senderWxid,
          senderNickname,
          senderRemark,
          senderGroupNickname,
          options.displayNamePreference || 'remark'
        )
        const existingSenderProfile = senderProfileMap.get(senderWxid)
        if (!existingSenderProfile) {
          senderProfileMap.set(senderWxid, {
            displayName: senderDisplayName,
            nickname: senderNickname,
            remark: senderRemark,
            groupNickname: senderGroupNickname
          })
        }

        const msgObj: any = {
          localId: allMessages.length + 1,
          createTime: msg.createTime,
          formattedTime: this.formatTimestamp(msg.createTime),
          type: this.getMessageTypeName(msg.localType, msg.content),
          localType: msg.localType,
          content,
          isSend: msg.isSend ? 1 : 0,
          senderUsername: msg.senderUsername,
          senderDisplayName,
          source,
          senderAvatarKey: msg.senderUsername
        }

        if (msg.localType === 47) {
          if (msg.emojiMd5) msgObj.emojiMd5 = msg.emojiMd5
          if (msg.emojiCdnUrl) msgObj.emojiCdnUrl = msg.emojiCdnUrl
          if (msg.emojiCaption) msgObj.emojiCaption = msg.emojiCaption
        }

        const platformMessageId = this.getExportPlatformMessageId(msg)
        if (platformMessageId) msgObj.platformMessageId = platformMessageId

        const replyToMessageId = this.getExportReplyToMessageId(msg.content)
        if (replyToMessageId) msgObj.replyToMessageId = replyToMessageId

        const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType)
        if (appMsgMeta) {
          if (
            options.format === 'arkme-json' ||
            (options.format === 'json' && (appMsgMeta.appMsgKind === 'quote' || appMsgMeta.appMsgKind === 'link'))
          ) {
            Object.assign(msgObj, appMsgMeta)
          }
        }
        if (quotedReplyDisplay) {
          if (quotedReplyDisplay.quotedSender) msgObj.quotedSender = quotedReplyDisplay.quotedSender
          if (quotedReplyDisplay.quotedPreview) msgObj.quotedContent = quotedReplyDisplay.quotedPreview
        }

        if (options.format === 'arkme-json') {
          const contactCardMeta = this.extractArkmeContactCardMeta(msg.content, msg.localType)
          if (contactCardMeta) {
            Object.assign(msgObj, contactCardMeta)
          }
        }

        if (content && this.isTransferExportContent(content) && msg.content) {
          transferCandidates.push({ xml: msg.content, messageRef: msgObj })
        }

        // 位置消息：附加结构化位置字段
        if (msg.localType === 48) {
          if (msg.locationLat != null) msgObj.locationLat = msg.locationLat
          if (msg.locationLng != null) msgObj.locationLng = msg.locationLng
          if (msg.locationPoiname) msgObj.locationPoiname = msg.locationPoiname
          if (msg.locationLabel) msgObj.locationLabel = msg.locationLabel
        }

        allMessages.push(msgObj)
        if (msg.createTime < lastCreateTime) needSort = true
        lastCreateTime = msg.createTime
        if ((allMessages.length % 200) === 0 || allMessages.length === totalMessages) {
          const exportProgress = 55 + Math.floor((allMessages.length / totalMessages) * 15)
          onProgress?.({
            current: exportProgress,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'exporting',
            estimatedTotalMessages: totalMessages,
            collectedMessages: totalMessages,
            exportedMessages: allMessages.length
          })
        }
      }

      if (transferCandidates.length > 0) {
        const transferNameCache = new Map<string, string>()
        const transferNamePromiseCache = new Map<string, Promise<string>>()
        const resolveDisplayNameByUsername = async (username: string): Promise<string> => {
          if (!username) return username
          const cachedName = transferNameCache.get(username)
          if (cachedName) return cachedName
          const pending = transferNamePromiseCache.get(username)
          if (pending) return pending
          const task = (async () => {
            const contactResult = contactCache.get(username) ?? await getContactCached(username)
            if (contactResult.success && contactResult.contact) {
              return contactResult.contact.remark || contactResult.contact.nickName || contactResult.contact.alias || username
            }
            return username
          })()
          transferNamePromiseCache.set(username, task)
          const resolved = await task
          transferNamePromiseCache.delete(username)
          transferNameCache.set(username, resolved)
          return resolved
        }

        const transferConcurrency = this.getClampedConcurrency(options.exportConcurrency, 4, 8)
        await parallelLimit(transferCandidates, transferConcurrency, async (item) => {
          this.throwIfStopRequested(control)
          const transferDesc = await this.resolveTransferDesc(
            item.xml,
            cleanedMyWxid,
            groupNicknamesMap,
            resolveDisplayNameByUsername
          )
          if (transferDesc && typeof item.messageRef.content === 'string') {
            item.messageRef.content = this.appendTransferDesc(item.messageRef.content, transferDesc)
          }
        })
      }

      if (needSort) {
        allMessages.sort((a, b) => a.createTime - b.createTime)
      }

      onProgress?.({
        current: 70,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'writing',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: totalMessages
      })

      // 获取会话的昵称和备注信息
      const sessionContact = contactCache.get(sessionId) ?? await getContactCached(sessionId)
      const sessionNickname = sessionContact.success && sessionContact.contact?.nickName
        ? sessionContact.contact.nickName
        : sessionInfo.displayName
      const sessionRemark = sessionContact.success && sessionContact.contact?.remark
        ? sessionContact.contact.remark
        : ''
      const sessionGroupNickname = isGroup
        ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [sessionId])
        : ''

      // 使用用户偏好的显示名称
      const sessionDisplayName = this.getPreferredDisplayName(
        sessionId,
        sessionNickname,
        sessionRemark,
        sessionGroupNickname,
        options.displayNamePreference || 'remark'
      )

      const weflow = this.getWeflowHeader()
      if (options.format === 'arkme-json' && isGroup) {
        this.throwIfStopRequested(control)
        await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
      }

      const avatarMap = options.exportAvatars
        ? await this.exportAvatars(
          [
            ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
              username,
              avatarUrl: info.avatarUrl
            })),
            { username: sessionId, avatarUrl: sessionInfo.avatarUrl },
            { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
          ]
        )
        : new Map<string, string>()

      const sessionPayload: any = {
        wxid: sessionId,
        nickname: sessionNickname,
        remark: sessionRemark,
        displayName: sessionDisplayName,
        type: isGroup ? '群聊' : '私聊',
        lastTimestamp: collected.lastTime,
        messageCount: allMessages.length,
        avatar: avatarMap.get(sessionId)
      }

      if (options.format === 'arkme-json') {
        const senderIdMap = new Map<string, number>()
        const senders: Array<{
          senderID: number
          wxid: string
          displayName: string
          nickname: string
          remark?: string
          groupNickname?: string
          avatar?: string
        }> = []
        const ensureSenderId = (senderWxidRaw: string): number => {
          const senderWxid = String(senderWxidRaw || '').trim() || 'unknown'
          const existed = senderIdMap.get(senderWxid)
          if (existed) return existed

          const senderID = senders.length + 1
          senderIdMap.set(senderWxid, senderID)

          const profile = senderProfileMap.get(senderWxid)
          const senderItem: {
            senderID: number
            wxid: string
            displayName: string
            nickname: string
            remark?: string
            groupNickname?: string
            avatar?: string
          } = {
            senderID,
            wxid: senderWxid,
            displayName: profile?.displayName || senderWxid,
            nickname: profile?.nickname || profile?.displayName || senderWxid
          }
          if (profile?.remark) senderItem.remark = profile.remark
          if (profile?.groupNickname) senderItem.groupNickname = profile.groupNickname
          const avatar = avatarMap.get(senderWxid)
          if (avatar) senderItem.avatar = avatar

          senders.push(senderItem)
          return senderID
        }

        const compactMessages = allMessages.map((message) => {
          this.throwIfStopRequested(control)
          const senderID = ensureSenderId(String(message.senderUsername || ''))
          const compactMessage: any = {
            localId: message.localId,
            createTime: message.createTime,
            formattedTime: message.formattedTime,
            type: message.type,
            localType: message.localType,
            content: message.content,
            isSend: message.isSend,
            senderID,
            source: message.source
          }
          if (message.platformMessageId) compactMessage.platformMessageId = message.platformMessageId
          if (message.replyToMessageId) compactMessage.replyToMessageId = message.replyToMessageId
          if (message.locationLat != null) compactMessage.locationLat = message.locationLat
          if (message.locationLng != null) compactMessage.locationLng = message.locationLng
          if (message.locationPoiname) compactMessage.locationPoiname = message.locationPoiname
          if (message.locationLabel) compactMessage.locationLabel = message.locationLabel
          if (message.appMsgType) compactMessage.appMsgType = message.appMsgType
          if (message.appMsgKind) compactMessage.appMsgKind = message.appMsgKind
          if (message.appMsgDesc) compactMessage.appMsgDesc = message.appMsgDesc
          if (message.appMsgAppName) compactMessage.appMsgAppName = message.appMsgAppName
          if (message.appMsgSourceName) compactMessage.appMsgSourceName = message.appMsgSourceName
          if (message.appMsgSourceUsername) compactMessage.appMsgSourceUsername = message.appMsgSourceUsername
          if (message.appMsgThumbUrl) compactMessage.appMsgThumbUrl = message.appMsgThumbUrl
          if (message.quotedContent) compactMessage.quotedContent = message.quotedContent
          if (message.quotedSender) compactMessage.quotedSender = message.quotedSender
          if (message.quotedType) compactMessage.quotedType = message.quotedType
          if (message.linkTitle) compactMessage.linkTitle = message.linkTitle
          if (message.linkUrl) compactMessage.linkUrl = message.linkUrl
          if (message.linkThumb) compactMessage.linkThumb = message.linkThumb
          if (message.emojiMd5) compactMessage.emojiMd5 = message.emojiMd5
          if (message.emojiCdnUrl) compactMessage.emojiCdnUrl = message.emojiCdnUrl
          if (message.emojiCaption) compactMessage.emojiCaption = message.emojiCaption
          if (message.finderTitle) compactMessage.finderTitle = message.finderTitle
          if (message.finderDesc) compactMessage.finderDesc = message.finderDesc
          if (message.finderUsername) compactMessage.finderUsername = message.finderUsername
          if (message.finderNickname) compactMessage.finderNickname = message.finderNickname
          if (message.finderCoverUrl) compactMessage.finderCoverUrl = message.finderCoverUrl
          if (message.finderAvatar) compactMessage.finderAvatar = message.finderAvatar
          if (message.finderDuration != null) compactMessage.finderDuration = message.finderDuration
          if (message.finderObjectId) compactMessage.finderObjectId = message.finderObjectId
          if (message.finderUrl) compactMessage.finderUrl = message.finderUrl
          if (message.musicTitle) compactMessage.musicTitle = message.musicTitle
          if (message.musicUrl) compactMessage.musicUrl = message.musicUrl
          if (message.musicDataUrl) compactMessage.musicDataUrl = message.musicDataUrl
          if (message.musicAlbumUrl) compactMessage.musicAlbumUrl = message.musicAlbumUrl
          if (message.musicCoverUrl) compactMessage.musicCoverUrl = message.musicCoverUrl
          if (message.musicSinger) compactMessage.musicSinger = message.musicSinger
          if (message.musicAppName) compactMessage.musicAppName = message.musicAppName
          if (message.musicSourceName) compactMessage.musicSourceName = message.musicSourceName
          if (message.musicDuration != null) compactMessage.musicDuration = message.musicDuration
          if (message.cardKind) compactMessage.cardKind = message.cardKind
          if (message.contactCardWxid) compactMessage.contactCardWxid = message.contactCardWxid
          if (message.contactCardNickname) compactMessage.contactCardNickname = message.contactCardNickname
          if (message.contactCardAlias) compactMessage.contactCardAlias = message.contactCardAlias
          if (message.contactCardRemark) compactMessage.contactCardRemark = message.contactCardRemark
          if (message.contactCardGender != null) compactMessage.contactCardGender = message.contactCardGender
          if (message.contactCardProvince) compactMessage.contactCardProvince = message.contactCardProvince
          if (message.contactCardCity) compactMessage.contactCardCity = message.contactCardCity
          if (message.contactCardSignature) compactMessage.contactCardSignature = message.contactCardSignature
          if (message.contactCardAvatar) compactMessage.contactCardAvatar = message.contactCardAvatar
          return compactMessage
        })

        const arkmeSession: any = {
          ...sessionPayload
        }
        let groupMembers: Array<{
          wxid: string
          displayName: string
          nickname: string
          remark: string
          alias: string
          groupNickname?: string
          isFriend: boolean
          messageCount: number
          avatar?: string
        }> | undefined

        if (isGroup) {
          const memberUsernames = Array.from(collected.memberSet.keys()).filter(Boolean)
          await this.preloadContacts(memberUsernames, contactCache)
          const friendLookupUsernames = this.buildGroupNicknameIdCandidates(memberUsernames)
          const friendFlagMap = await this.queryFriendFlagMap(friendLookupUsernames)
          const groupStatsResult = await wcdbService.getGroupStats(sessionId, 0, 0)
          const groupSenderCountMap = groupStatsResult.success && groupStatsResult.data
            ? this.extractGroupSenderCountMap(groupStatsResult.data, sessionId)
            : new Map<string, number>()

          groupMembers = []
          for (const memberWxid of memberUsernames) {
            this.throwIfStopRequested(control)
            const member = collected.memberSet.get(memberWxid)?.member
            const contactResult = await getContactCached(memberWxid)
            const contact = contactResult.success ? contactResult.contact : null
            const nickname = String(contact?.nickName || contact?.nick_name || member?.accountName || memberWxid)
            const remark = String(contact?.remark || '')
            const alias = String(contact?.alias || '')
            const groupNickname = member?.groupNickname || this.resolveGroupNicknameByCandidates(
              groupNicknamesMap,
              [memberWxid, contact?.username, contact?.userName, contact?.encryptUsername, contact?.encryptUserName, alias]
            ) || ''
            const displayName = this.getPreferredDisplayName(
              memberWxid,
              nickname,
              remark,
              groupNickname,
              options.displayNamePreference || 'remark'
            )

            const groupMember: {
              wxid: string
              displayName: string
              nickname: string
              remark: string
              alias: string
              groupNickname?: string
              isFriend: boolean
              messageCount: number
              avatar?: string
            } = {
              wxid: memberWxid,
              displayName,
              nickname,
              remark,
              alias,
              isFriend: this.buildGroupNicknameIdCandidates([memberWxid]).some((candidate) => friendFlagMap.get(candidate) === true),
              messageCount: this.sumSenderCountsByIdentity(groupSenderCountMap, memberWxid)
            }
            if (groupNickname) groupMember.groupNickname = groupNickname
            const avatar = avatarMap.get(memberWxid)
            if (avatar) groupMember.avatar = avatar
            groupMembers.push(groupMember)
          }
          groupMembers.sort((a, b) => {
            if (b.messageCount !== a.messageCount) return b.messageCount - a.messageCount
            return String(a.displayName || a.wxid).localeCompare(String(b.displayName || b.wxid), 'zh-CN')
          })
        }

        const arkmeExport: any = {
          weflow: {
            ...weflow,
            format: 'arkme-json'
          },
          session: arkmeSession,
          senders,
          messages: compactMessages
        }
        if (groupMembers) {
          arkmeExport.groupMembers = groupMembers
        }

        this.throwIfStopRequested(control)
        await this.recordCreatedFileBeforeWrite(outputPath, control)
        await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
      } else {
        const detailedExport: any = {
          weflow,
          session: sessionPayload,
          messages: allMessages
        }

        if (options.exportAvatars) {
          const avatars: Record<string, string> = {}
          for (const [username, relPath] of avatarMap.entries()) {
            avatars[username] = relPath
          }
          if (Object.keys(avatars).length > 0) {
            detailedExport.session = {
              ...detailedExport.session,
              avatar: avatars[sessionId]
            }
            ; (detailedExport as any).avatars = avatars
          }
        }

        this.throwIfStopRequested(control)
        await this.recordCreatedFileBeforeWrite(outputPath, control)
        await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
      }

      onProgress?.({
        current: 100,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'complete',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: totalMessages,
        writtenFiles: 1
      })

      return { success: true }
    } catch (e) {
      if (this.isStopError(e)) {
        return { success: false, error: '导出任务已停止' }
      }
      if (this.isPauseError(e)) {
        return { success: false, error: '导出任务已暂停' }
      }
      return { success: false, error: String(e) }
    }
  }

  /**
   * 导出单个会话为 Excel 格式（参考 echotrace 格式）
   */
  async exportSessionToExcel(
    sessionId: string,
    outputPath: string,
    options: ExportOptions,
    onProgress?: (progress: ExportProgress) => void,
    control?: ExportTaskControl
  ): Promise<{ success: boolean; error?: string }> {
    try {
      this.throwIfStopRequested(control)
      const conn = await this.ensureConnected()
      if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }

      const cleanedMyWxid = conn.cleanedWxid
      const isGroup = sessionId.includes('@chatroom')
      const rawMyWxid = this.getConfiguredMyWxid()

      const sessionInfo = await this.getContactInfo(sessionId)
      const myInfo = await this.getContactInfo(cleanedMyWxid)

      const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
      const getContactCached = async (username: string) => {
        if (contactCache.has(username)) {
          return contactCache.get(username)!
        }
        const result = await wcdbService.getContact(username)
        contactCache.set(username, result)
        return result
      }

      // 获取会话的备注信息
      const sessionContact = await getContactCached(sessionId)
      const sessionRemark = sessionContact.success && sessionContact.contact?.remark ? sessionContact.contact.remark : ''
      const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionId

      onProgress?.({
        current: 0,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'preparing'
      })

      const collectParams = this.resolveCollectParams(options)
      const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
      const collected = await this.collectMessages(
        sessionId,
        cleanedMyWxid,
        options.dateRange,
        options.senderUsername,
        collectParams.mode,
        collectParams.targetMediaTypes,
        control,
        collectProgressReporter
      )
      const totalMessages = collected.rows.length

      // 如果没有消息,不创建文件
      if (totalMessages === 0) {
        return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
      }

      await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)

      // 解析引用消息
      await this.resolveQuotedMessagesForExport(collected.rows, sessionId)

      const voiceMessages = options.exportVoiceAsText
        ? collected.rows.filter(msg => msg.localType === 34)
        : []

      if (options.exportVoiceAsText && voiceMessages.length > 0) {
        await this.ensureVoiceModel(onProgress)
      }

      const senderUsernames = new Set<string>()
      let senderScanIndex = 0
      for (const msg of collected.rows) {
        if ((senderScanIndex++ & 0x7f) === 0) {
          this.throwIfStopRequested(control)
        }
        if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
      }
      senderUsernames.add(sessionId)
      await this.preloadContacts(senderUsernames, contactCache)

      onProgress?.({
        current: 30,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'exporting',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: 0
      })

      // 创建 Excel 工作簿
      const workbook = new ExcelJS.Workbook()
      workbook.creator = 'WeFlow'
      workbook.created = new Date()

      const worksheet = workbook.addWorksheet('聊天记录')

      let currentRow = 1

      const useCompactColumns = options.excelCompactColumns === true

      // 第一行：会话信息标题
      const titleCell = worksheet.getCell(currentRow, 1)
      titleCell.value = '会话信息'
      titleCell.font = { name: 'Calibri', bold: true, size: 11 }
      titleCell.alignment = { vertical: 'middle', horizontal: 'left' }
      worksheet.getRow(currentRow).height = 25
      currentRow++

      // 第二行：会话详细信息
      worksheet.getCell(currentRow, 1).value = '微信ID'
      worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
      worksheet.mergeCells(currentRow, 2, currentRow, 3)
      worksheet.getCell(currentRow, 2).value = sessionId
      worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 }

      worksheet.getCell(currentRow, 4).value = '昵称'
      worksheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 }
      worksheet.getCell(currentRow, 5).value = sessionNickname
      worksheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 }

      if (isGroup) {
        worksheet.getCell(currentRow, 6).value = '备注'
        worksheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 }
        worksheet.mergeCells(currentRow, 7, currentRow, 8)
        worksheet.getCell(currentRow, 7).value = sessionRemark
        worksheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 }
      }
      worksheet.getRow(currentRow).height = 20
      currentRow++

      // 第三行：导出元数据
      const { chatlab, meta: exportMeta } = this.getExportMeta(sessionId, sessionInfo, isGroup)
      worksheet.getCell(currentRow, 1).value = '导出工具'
      worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
      worksheet.getCell(currentRow, 2).value = chatlab.generator
      worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 }

      worksheet.getCell(currentRow, 3).value = '导出版本'
      worksheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 }
      worksheet.getCell(currentRow, 4).value = chatlab.version
      worksheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 }

      worksheet.getCell(currentRow, 5).value = '平台'
      worksheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 }
      worksheet.getCell(currentRow, 6).value = exportMeta.platform
      worksheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 }

      worksheet.getCell(currentRow, 7).value = '导出时间'
      worksheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 }
      worksheet.getCell(currentRow, 8).value = this.formatTimestamp(chatlab.exportedAt)
      worksheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 }

      worksheet.getRow(currentRow).height = 20
      currentRow++

      // 表头行
      const includeGroupNicknameColumn = !useCompactColumns && isGroup
      const headers = useCompactColumns
        ? ['序号', '时间', '发送者身份', '消息类型', '内容']
        : includeGroupNicknameColumn
          ? ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容']
          : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容']
      const headerRow = worksheet.getRow(currentRow)
      headerRow.height = 22

      headers.forEach((header, index) => {
        const cell = headerRow.getCell(index + 1)
        cell.value = header
        cell.font = { name: 'Calibri', bold: true, size: 11 }
        cell.fill = {
          type: 'pattern',
          pattern: 'solid',
          fgColor: { argb: 'FFE8F5E9' }
        }
        cell.alignment = { vertical: 'middle', horizontal: 'center' }
      })
      currentRow++

      // 设置列宽
      worksheet.getColumn(1).width = 8   // 序号
      worksheet.getColumn(2).width = 20  // 时间
      if (useCompactColumns) {
        worksheet.getColumn(3).width = 18  // 发送者身份
        worksheet.getColumn(4).width = 12  // 消息类型
        worksheet.getColumn(5).width = 50  // 内容
      } else {
        worksheet.getColumn(3).width = 18  // 发送者昵称
        worksheet.getColumn(4).width = 25  // 发送者微信ID
        worksheet.getColumn(5).width = 18  // 发送者备注
        if (includeGroupNicknameColumn) {
          worksheet.getColumn(6).width = 18  // 群昵称
          worksheet.getColumn(7).width = 15  // 发送者身份
          worksheet.getColumn(8).width = 12  // 消息类型
          worksheet.getColumn(9).width = 50  // 内容
        } else {
          worksheet.getColumn(6).width = 15  // 发送者身份
          worksheet.getColumn(7).width = 12  // 消息类型
          worksheet.getColumn(8).width = 50  // 内容
        }
      }

      // 预加载群昵称 (仅群聊且完整列模式)
      const groupNicknameCandidates = isGroup
        ? this.buildGroupNicknameIdCandidates([
          ...collected.rows.map(msg => msg.senderUsername),
          cleanedMyWxid,
          rawMyWxid
        ])
        : []
      const groupNicknamesMap = isGroup
        ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
        : new Map<string, string>()


      // 填充数据
      const sortedMessages = collected.rows

      // 媒体导出设置
      const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)

      // ========== 并行预处理：媒体文件 ==========
      const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)

      const mediaCache = new Map<string, MediaExportItem | null>()
      const mediaDirCache = new Set<string>()
      const beforeMediaDoneFiles = this.getMediaDoneFilesCount()

      if (mediaMessages.length > 0) {
        await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
          exportImages: options.exportImages,
          exportVideos: options.exportVideos
        }, control)
        const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
        if (voiceMediaMessages.length > 0) {
          await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
        }

        onProgress?.({
          current: 35,
          total: 100,
          currentSession: sessionInfo.displayName,
          phase: 'exporting-media',
          phaseProgress: 0,
          phaseTotal: mediaMessages.length,
          phaseLabel: this.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
          ...this.getMediaTelemetrySnapshot(),
          estimatedTotalMessages: totalMessages
        })

        const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
        let mediaExported = 0
        await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
          this.throwIfStopRequested(control)
          const mediaKey = this.getMediaCacheKey(msg)
          if (!mediaCache.has(mediaKey)) {
            const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
              exportImages: options.exportImages,
              exportVoices: options.exportVoices,
              exportVideos: options.exportVideos,
              exportEmojis: options.exportEmojis,
              exportFiles: options.exportFiles,
              maxFileSizeMb: options.maxFileSizeMb,
              exportVoiceAsText: options.exportVoiceAsText,
              includeVideoPoster: options.format === 'html',
              dirCache: mediaDirCache,
              control
            })
            mediaCache.set(mediaKey, mediaItem)
          }
          mediaExported++
          if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
            onProgress?.({
              current: 35,
              total: 100,
              currentSession: sessionInfo.displayName,
              phase: 'exporting-media',
              phaseProgress: mediaExported,
              phaseTotal: mediaMessages.length,
              phaseLabel: this.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
              ...this.getMediaTelemetrySnapshot()
            })
          }
        })
      }
      const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
      if (fileOnlyExportFailure) return fileOnlyExportFailure

      // ========== 并行预处理：语音转文字 ==========
      const voiceTranscriptMap = new Map<string, string>()

      if (voiceMessages.length > 0) {
        await this.preloadVoiceWavCache(sessionId, voiceMessages, control)

        onProgress?.({
          current: 50,
          total: 100,
          currentSession: sessionInfo.displayName,
          phase: 'exporting-voice',
          phaseProgress: 0,
          phaseTotal: voiceMessages.length,
          phaseLabel: `语音转文字 0/${voiceMessages.length}`,
          estimatedTotalMessages: totalMessages
        })

        const VOICE_CONCURRENCY = 4
        let voiceTranscribed = 0
        await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
          this.throwIfStopRequested(control)
          const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
          voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript)
          voiceTranscribed++
          onProgress?.({
            current: 50,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'exporting-voice',
            phaseProgress: voiceTranscribed,
            phaseTotal: voiceMessages.length,
            phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
          })
        })
      }

      const shouldUseStreamingWriter = totalMessages > 20000
      if (shouldUseStreamingWriter) {
        return this.exportSessionToExcelStreaming({
          outputPath,
          options,
          sessionId,
          sessionInfo,
          myInfo,
          cleanedMyWxid,
          rawMyWxid,
          isGroup,
          sortedMessages,
          mediaCache,
          voiceTranscriptMap,
          getContactCached,
          groupNicknamesMap,
          onProgress,
          control,
          totalMessages
        })
      }

      onProgress?.({
        current: 65,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'exporting',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: 0
      })

      // ========== 写入 Excel 行 ==========
      const senderProfileCache = new Map<string, ExportDisplayProfile>()
      for (let i = 0; i < totalMessages; i++) {
        if ((i & 0x7f) === 0) {
          this.throwIfStopRequested(control)
        }
        const msg = sortedMessages[i]

        // 确定发送者信息
        let senderRole: string
        let senderWxid: string
        let senderNickname: string
        let senderRemark: string = ''
        let senderGroupNickname: string = ''  // 群昵称

        if (isGroup) {
          const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
          let senderProfile = senderProfileCache.get(senderProfileKey)
          if (!senderProfile) {
            senderProfile = await this.resolveExportDisplayProfile(
              msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
              options.displayNamePreference,
              getContactCached,
              groupNicknamesMap,
              msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
              msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
            )
            senderProfileCache.set(senderProfileKey, senderProfile)
          }
          senderWxid = senderProfile.wxid
          senderNickname = senderProfile.nickname
          senderRemark = senderProfile.remark
          senderGroupNickname = senderProfile.groupNickname
          senderRole = senderProfile.displayName
        } else if (msg.isSend) {
          // 我发送的消息
          senderRole = '我'
          senderWxid = cleanedMyWxid
          senderNickname = myInfo.displayName || cleanedMyWxid
          senderRemark = ''
        } else {
          // 单聊对方消息 - 用 getContact 获取联系人详情
          senderWxid = sessionId
          const contactDetail = await getContactCached(sessionId)
          if (contactDetail.success && contactDetail.contact) {
            senderNickname = contactDetail.contact.nickName || sessionId
            senderRemark = contactDetail.contact.remark || ''
            senderRole = senderRemark || senderNickname
          } else {
            senderNickname = sessionInfo.displayName || sessionId
            senderRemark = ''
            senderRole = senderNickname
          }
        }

        const row = worksheet.getRow(currentRow)
        row.height = 24

        const mediaKey = this.getMediaCacheKey(msg)
        const mediaItem = mediaCache.get(mediaKey)
        const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
        const contentValue = shouldUseTranscript
          ? this.formatPlainExportContent(
            msg.content,
            msg.localType,
            options,
            voiceTranscriptMap.get(this.getStableMessageKey(msg)),
            cleanedMyWxid,
            msg.senderUsername,
            msg.isSend,
            msg.emojiCaption
          )
          : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
            || this.formatPlainExportContent(
              msg.content,
              msg.localType,
              options,
              voiceTranscriptMap.get(this.getStableMessageKey(msg)),
              cleanedMyWxid,
              msg.senderUsername,
              msg.isSend,
              msg.emojiCaption
            ))

        // 转账消息：追加 "谁转账给谁" 信息
        let enrichedContentValue = contentValue
        if (this.isTransferExportContent(contentValue) && msg.content) {
          const transferDesc = await this.resolveTransferDesc(
            msg.content,
            cleanedMyWxid,
            groupNicknamesMap,
            async (username) => {
              const c = await getContactCached(username)
              if (c.success && c.contact) {
                return c.contact.remark || c.contact.nickName || c.contact.alias || username
              }
              return username
            }
          )
          if (transferDesc) {
            enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
          }
        }

        const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
          content: msg.content,
          isGroup,
          displayNamePreference: options.displayNamePreference,
          getContact: getContactCached,
          groupNicknamesMap,
          cleanedMyWxid,
          rawMyWxid,
          myDisplayName: myInfo.displayName || cleanedMyWxid
        })
        if (quotedReplyDisplay) {
          enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
        }

        const contentCellIndex = useCompactColumns ? 5 : (includeGroupNicknameColumn ? 9 : 8)
        const contentCell = worksheet.getCell(currentRow, contentCellIndex)

        worksheet.getCell(currentRow, 1).value = i + 1
        worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
        if (useCompactColumns) {
          worksheet.getCell(currentRow, 3).value = senderRole
          worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType, msg.content)
        } else if (includeGroupNicknameColumn) {
          worksheet.getCell(currentRow, 3).value = senderNickname
          worksheet.getCell(currentRow, 4).value = senderWxid
          worksheet.getCell(currentRow, 5).value = senderRemark
          worksheet.getCell(currentRow, 6).value = senderGroupNickname
          worksheet.getCell(currentRow, 7).value = senderRole
          worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType, msg.content)
        } else {
          worksheet.getCell(currentRow, 3).value = senderNickname
          worksheet.getCell(currentRow, 4).value = senderWxid
          worksheet.getCell(currentRow, 5).value = senderRemark
          worksheet.getCell(currentRow, 6).value = senderRole
          worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType, msg.content)
        }
        contentCell.value = enrichedContentValue
        if (!quotedReplyDisplay) {
          this.applyExcelLinkCardCell(contentCell, msg.content, msg.localType)
        }

        currentRow++

        // 每处理 100 条消息报告一次进度
        if ((i + 1) % 100 === 0) {
          const progress = 30 + Math.floor((i + 1) / sortedMessages.length * 50)
          onProgress?.({
            current: progress,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'exporting',
            estimatedTotalMessages: totalMessages,
            collectedMessages: totalMessages,
            exportedMessages: i + 1
          })
        }
      }

      onProgress?.({
        current: 90,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'writing',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: totalMessages
      })

      // 写入文件
      this.throwIfStopRequested(control)
      await this.recordCreatedFileBeforeWrite(outputPath, control)
      await workbook.xlsx.writeFile(outputPath)

      onProgress?.({
        current: 100,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'complete',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: totalMessages,
        writtenFiles: 1
      })

      return { success: true }
    } catch (e) {
      if (this.isStopError(e)) {
        return { success: false, error: '导出任务已停止' }
      }
      if (this.isPauseError(e)) {
        return { success: false, error: '导出任务已暂停' }
      }
      // 处理文件被占用的错误
      if (e instanceof Error) {
        if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
          return { success: false, error: '文件已经打开，请关闭后再导出' }
        }
      }

      return { success: false, error: String(e) }
    }
  }

  private async exportSessionToExcelStreaming(params: {
    outputPath: string
    options: ExportOptions
    sessionId: string
    sessionInfo: { displayName: string }
    myInfo: { displayName: string }
    cleanedMyWxid: string
    rawMyWxid: string
    isGroup: boolean
    sortedMessages: any[]
    mediaCache: Map<string, MediaExportItem | null>
    voiceTranscriptMap: Map<string, string>
    getContactCached: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>
    groupNicknamesMap: Map<string, string>
    onProgress?: (progress: ExportProgress) => void
    control?: ExportTaskControl
    totalMessages: number
  }): Promise<{ success: boolean; error?: string }> {
    const {
      outputPath,
      options,
      sessionId,
      sessionInfo,
      myInfo,
      cleanedMyWxid,
      rawMyWxid,
      isGroup,
      sortedMessages,
      mediaCache,
      voiceTranscriptMap,
      getContactCached,
      groupNicknamesMap,
      onProgress,
      control,
      totalMessages
    } = params

    try {
      const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
        filename: outputPath,
        useStyles: true,
        useSharedStrings: false
      })
      const worksheet = workbook.addWorksheet('聊天记录')
      const useCompactColumns = options.excelCompactColumns === true
      const includeGroupNicknameColumn = !useCompactColumns && isGroup
      const senderProfileCache = new Map<string, ExportDisplayProfile>()

      worksheet.columns = useCompactColumns
        ? [
          { width: 8 },
          { width: 20 },
          { width: 18 },
          { width: 12 },
          { width: 50 }
        ]
        : includeGroupNicknameColumn
          ? [
            { width: 8 },
            { width: 20 },
            { width: 18 },
            { width: 25 },
            { width: 18 },
            { width: 18 },
            { width: 15 },
            { width: 12 },
            { width: 50 }
          ]
          : [
            { width: 8 },
            { width: 20 },
            { width: 18 },
            { width: 25 },
            { width: 18 },
            { width: 15 },
            { width: 12 },
            { width: 50 }
          ]

      const appendRow = (values: any[]) => {
        const row = worksheet.addRow(values)
        row.commit()
      }

      appendRow(['会话信息'])
      appendRow(['微信ID', sessionId, '昵称', sessionInfo.displayName || sessionId])
      appendRow(['导出工具', 'WeFlow', '导出时间', this.formatTimestamp(Math.floor(Date.now() / 1000))])
      appendRow([])
      appendRow(useCompactColumns
        ? ['序号', '时间', '发送者身份', '消息类型', '内容']
        : includeGroupNicknameColumn
          ? ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容']
          : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容'])

      for (let i = 0; i < totalMessages; i++) {
        if ((i & 0x7f) === 0) this.throwIfStopRequested(control)
        const msg = sortedMessages[i]

        let senderRole: string
        let senderWxid: string
        let senderNickname: string
        let senderRemark = ''
        let senderGroupNickname = ''

        if (isGroup) {
          const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
          let senderProfile = senderProfileCache.get(senderProfileKey)
          if (!senderProfile) {
            senderProfile = await this.resolveExportDisplayProfile(
              msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
              options.displayNamePreference,
              getContactCached,
              groupNicknamesMap,
              msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
              msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
            )
            senderProfileCache.set(senderProfileKey, senderProfile)
          }
          senderWxid = senderProfile.wxid
          senderNickname = senderProfile.nickname
          senderRemark = senderProfile.remark
          senderGroupNickname = senderProfile.groupNickname
          senderRole = senderProfile.displayName
        } else if (msg.isSend) {
          senderRole = '我'
          senderWxid = cleanedMyWxid
          senderNickname = myInfo.displayName || cleanedMyWxid
        } else {
          senderWxid = sessionId
          const contactDetail = await getContactCached(sessionId)
          if (contactDetail.success && contactDetail.contact) {
            senderNickname = contactDetail.contact.nickName || sessionId
            senderRemark = contactDetail.contact.remark || ''
            senderRole = senderRemark || senderNickname
          } else {
            senderNickname = sessionInfo.displayName || sessionId
            senderRole = senderNickname
          }
        }

        const mediaKey = this.getMediaCacheKey(msg)
        const mediaItem = mediaCache.get(mediaKey)
        const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
        const contentValue = shouldUseTranscript
          ? this.formatPlainExportContent(
            msg.content,
            msg.localType,
            options,
            voiceTranscriptMap.get(this.getStableMessageKey(msg)),
            cleanedMyWxid,
            msg.senderUsername,
            msg.isSend,
            msg.emojiCaption
          )
          : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
            || this.formatPlainExportContent(
              msg.content,
              msg.localType,
              options,
              voiceTranscriptMap.get(this.getStableMessageKey(msg)),
              cleanedMyWxid,
              msg.senderUsername,
              msg.isSend,
              msg.emojiCaption
            ))

        let enrichedContentValue = contentValue
        if (this.isTransferExportContent(contentValue) && msg.content) {
          const transferDesc = await this.resolveTransferDesc(
            msg.content,
            cleanedMyWxid,
            groupNicknamesMap,
            async (username) => {
              const c = await getContactCached(username)
              if (c.success && c.contact) {
                return c.contact.remark || c.contact.nickName || c.contact.alias || username
              }
              return username
            }
          )
          if (transferDesc) {
            enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
          }
        }

        const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
          content: msg.content,
          isGroup,
          displayNamePreference: options.displayNamePreference,
          getContact: getContactCached,
          groupNicknamesMap,
          cleanedMyWxid,
          rawMyWxid,
          myDisplayName: myInfo.displayName || cleanedMyWxid
        })
        if (quotedReplyDisplay) {
          enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
        }

        const row = worksheet.addRow(useCompactColumns
          ? [
            i + 1,
            this.formatTimestamp(msg.createTime),
            senderRole,
            this.getMessageTypeName(msg.localType, msg.content),
            enrichedContentValue
          ]
          : includeGroupNicknameColumn
            ? [
              i + 1,
              this.formatTimestamp(msg.createTime),
              senderNickname,
              senderWxid,
              senderRemark,
              senderGroupNickname,
              senderRole,
              this.getMessageTypeName(msg.localType, msg.content),
              enrichedContentValue
            ]
            : [
              i + 1,
              this.formatTimestamp(msg.createTime),
              senderNickname,
              senderWxid,
              senderRemark,
              senderRole,
              this.getMessageTypeName(msg.localType, msg.content),
              enrichedContentValue
            ])
        if (!quotedReplyDisplay) {
          this.applyExcelLinkCardCell(
            row.getCell(useCompactColumns ? 5 : (includeGroupNicknameColumn ? 9 : 8)),
            msg.content,
            msg.localType
          )
        }
        row.commit()

        if ((i + 1) % 200 === 0) {
          onProgress?.({
            current: 65 + Math.floor((i + 1) / totalMessages * 25),
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'writing',
            estimatedTotalMessages: totalMessages,
            collectedMessages: totalMessages,
            exportedMessages: i + 1
          })
        }
      }

      worksheet.commit()
      await workbook.commit()

      onProgress?.({
        current: 100,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'complete',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: totalMessages,
        writtenFiles: 1
      })

      return { success: true }
    } catch (e) {
      if (this.isStopError(e)) {
        return { success: false, error: '导出任务已停止' }
      }
      if (this.isPauseError(e)) {
        return { success: false, error: '导出任务已暂停' }
      }
      if (e instanceof Error) {
        if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
          return { success: false, error: '文件已经打开，请关闭后再导出' }
        }
      }
      return { success: false, error: String(e) }
    }
  }

  /**
    * 确保语音转写模型已下载
    */
  private async ensureVoiceModel(onProgress?: (progress: ExportProgress) => void): Promise<boolean> {
    try {
      const status = await voiceTranscribeService.getModelStatus()
      if (status.success && status.exists) {
        return true
      }

      onProgress?.({
        current: 0,
        total: 100,
        currentSession: '正在下载 AI 模型',
        phase: 'preparing'
      })

      const downloadResult = await voiceTranscribeService.downloadModel((progress: any) => {
        if (progress.percent !== undefined) {
          onProgress?.({
            current: progress.percent,
            total: 100,
            currentSession: `正在下载 AI 模型 (${progress.percent.toFixed(0)}%)`,
            phase: 'preparing'
          })
        }
      })

      return downloadResult.success
    } catch (e) {
      console.error('Auto download model failed:', e)
      return false
    }
  }

  /**
   * 导出单个会话为 TXT 格式（默认与 Excel 精简列一致）
   */
  async exportSessionToTxt(
    sessionId: string,
    outputPath: string,
    options: ExportOptions,
    onProgress?: (progress: ExportProgress) => void,
    control?: ExportTaskControl
  ): Promise<{ success: boolean; error?: string }> {
    try {
      this.throwIfStopRequested(control)
      const conn = await this.ensureConnected()
      if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }

      const cleanedMyWxid = conn.cleanedWxid
      const isGroup = sessionId.includes('@chatroom')
      const rawMyWxid = this.getConfiguredMyWxid()
      const sessionInfo = await this.getContactInfo(sessionId)
      const myInfo = await this.getContactInfo(cleanedMyWxid)

      const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
      const getContactCached = async (username: string) => {
        if (contactCache.has(username)) {
          return contactCache.get(username)!
        }
        const result = await wcdbService.getContact(username)
        contactCache.set(username, result)
        return result
      }

      onProgress?.({
        current: 0,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'preparing'
      })

      const collectParams = this.resolveCollectParams(options)
      const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
      const collected = await this.collectMessages(
        sessionId,
        cleanedMyWxid,
        options.dateRange,
        options.senderUsername,
        collectParams.mode,
        collectParams.targetMediaTypes,
        control,
        collectProgressReporter
      )
      const totalMessages = collected.rows.length

      // 如果没有消息,不创建文件
      if (totalMessages === 0) {
        return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
      }

      await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)

      // 解析引用消息
      await this.resolveQuotedMessagesForExport(collected.rows, sessionId)

      const voiceMessages = options.exportVoiceAsText
        ? collected.rows.filter(msg => msg.localType === 34)
        : []

      if (options.exportVoiceAsText && voiceMessages.length > 0) {
        await this.ensureVoiceModel(onProgress)
      }

      const senderUsernames = new Set<string>()
      let senderScanIndex = 0
      for (const msg of collected.rows) {
        if ((senderScanIndex++ & 0x7f) === 0) {
          this.throwIfStopRequested(control)
        }
        if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
      }
      senderUsernames.add(sessionId)
      await this.preloadContacts(senderUsernames, contactCache)

      // 获取群昵称（用于转账描述等）
      const groupNicknameCandidates = isGroup
        ? this.buildGroupNicknameIdCandidates([
          ...Array.from(senderUsernames.values()),
          ...collected.rows.map(msg => msg.senderUsername),
          cleanedMyWxid,
          rawMyWxid
        ])
        : []
      const groupNicknamesMap = isGroup
        ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
        : new Map<string, string>()

      const sortedMessages = collected.rows

      const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
      const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)

      const mediaCache = new Map<string, MediaExportItem | null>()
      const mediaDirCache = new Set<string>()
      const beforeMediaDoneFiles = this.getMediaDoneFilesCount()

      if (mediaMessages.length > 0) {
        await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
          exportImages: options.exportImages,
          exportVideos: options.exportVideos
        }, control)
        const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
        if (voiceMediaMessages.length > 0) {
          await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
        }

        onProgress?.({
          current: 25,
          total: 100,
          currentSession: sessionInfo.displayName,
          phase: 'exporting-media',
          phaseProgress: 0,
          phaseTotal: mediaMessages.length,
          phaseLabel: this.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
          ...this.getMediaTelemetrySnapshot(),
          estimatedTotalMessages: totalMessages
        })

        const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
        let mediaExported = 0
        await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
          this.throwIfStopRequested(control)
          const mediaKey = this.getMediaCacheKey(msg)
          if (!mediaCache.has(mediaKey)) {
            const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
              exportImages: options.exportImages,
              exportVoices: options.exportVoices,
              exportVideos: options.exportVideos,
              exportEmojis: options.exportEmojis,
              exportFiles: options.exportFiles,
              maxFileSizeMb: options.maxFileSizeMb,
              exportVoiceAsText: options.exportVoiceAsText,
              includeVideoPoster: options.format === 'html',
              dirCache: mediaDirCache,
              control
            })
            mediaCache.set(mediaKey, mediaItem)
          }
          mediaExported++
          if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
            onProgress?.({
              current: 25,
              total: 100,
              currentSession: sessionInfo.displayName,
              phase: 'exporting-media',
              phaseProgress: mediaExported,
              phaseTotal: mediaMessages.length,
              phaseLabel: this.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
              ...this.getMediaTelemetrySnapshot()
            })
          }
        })
      }
      const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
      if (fileOnlyExportFailure) return fileOnlyExportFailure

      const voiceTranscriptMap = new Map<string, string>()

      if (voiceMessages.length > 0) {
        await this.preloadVoiceWavCache(sessionId, voiceMessages, control)

        onProgress?.({
          current: 45,
          total: 100,
          currentSession: sessionInfo.displayName,
          phase: 'exporting-voice',
          phaseProgress: 0,
          phaseTotal: voiceMessages.length,
          phaseLabel: `语音转文字 0/${voiceMessages.length}`,
          estimatedTotalMessages: totalMessages
        })

        const VOICE_CONCURRENCY = 4
        let voiceTranscribed = 0
        await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
          this.throwIfStopRequested(control)
          const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
          voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript)
          voiceTranscribed++
          onProgress?.({
            current: 45,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'exporting-voice',
            phaseProgress: voiceTranscribed,
            phaseTotal: voiceMessages.length,
            phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
          })
        })
      }

      onProgress?.({
        current: 60,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'exporting',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: 0
      })

      await this.recordCreatedFileBeforeWrite(outputPath, control)
      const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
      const writeChunk = async (chunk: string): Promise<void> => {
        await new Promise<void>((resolve, _reject) => {
          this.throwIfStopRequested(control)
          if (!stream.write(chunk)) {
            stream.once('drain', resolve)
          } else {
            resolve()
          }
        })
      }
      const WRITE_BATCH = 120
      let writeBuffer: string[] = []
      const flushWriteBuffer = async (): Promise<void> => {
        if (writeBuffer.length === 0) return
        await writeChunk(writeBuffer.join(''))
        writeBuffer = []
      }
      const senderProfileCache = new Map<string, ExportDisplayProfile>()

      for (let i = 0; i < totalMessages; i++) {
        if ((i & 0x7f) === 0) {
          this.throwIfStopRequested(control)
        }
        const msg = sortedMessages[i]
        const mediaKey = this.getMediaCacheKey(msg)
        const mediaItem = mediaCache.get(mediaKey)
        const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
        const contentValue = shouldUseTranscript
          ? this.formatPlainExportContent(
            msg.content,
            msg.localType,
            options,
            voiceTranscriptMap.get(this.getStableMessageKey(msg)),
            cleanedMyWxid,
            msg.senderUsername,
            msg.isSend,
            msg.emojiCaption
          )
          : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
            || this.formatPlainExportContent(
              msg.content,
              msg.localType,
              options,
              voiceTranscriptMap.get(this.getStableMessageKey(msg)),
              cleanedMyWxid,
              msg.senderUsername,
              msg.isSend,
              msg.emojiCaption
            ))

        // 转账消息：追加 "谁转账给谁" 信息
        let enrichedContentValue = contentValue
        if (this.isTransferExportContent(contentValue) && msg.content) {
          const transferDesc = await this.resolveTransferDesc(
            msg.content,
            cleanedMyWxid,
            groupNicknamesMap,
            async (username) => {
              const c = await getContactCached(username)
              if (c.success && c.contact) {
                return c.contact.remark || c.contact.nickName || c.contact.alias || username
              }
              return username
            }
          )
          if (transferDesc) {
            enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
          }
        }

        const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
          content: msg.content,
          isGroup,
          displayNamePreference: options.displayNamePreference,
          getContact: getContactCached,
          groupNicknamesMap,
          cleanedMyWxid,
          rawMyWxid,
          myDisplayName: myInfo.displayName || cleanedMyWxid
        })
        if (quotedReplyDisplay) {
          enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
        }

        const appendedLinkContent = quotedReplyDisplay
          ? null
          : this.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
        if (appendedLinkContent) {
          enrichedContentValue = appendedLinkContent
        }

        let senderRole: string
        let senderWxid: string
        let senderNickname: string
        let senderRemark = ''

        if (isGroup) {
          const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
          let senderProfile = senderProfileCache.get(senderProfileKey)
          if (!senderProfile) {
            senderProfile = await this.resolveExportDisplayProfile(
              msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
              options.displayNamePreference,
              getContactCached,
              groupNicknamesMap,
              msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
              msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
            )
            senderProfileCache.set(senderProfileKey, senderProfile)
          }
          senderWxid = senderProfile.wxid
          senderNickname = senderProfile.nickname
          senderRemark = senderProfile.remark
          senderRole = senderProfile.displayName
        } else if (msg.isSend) {
          senderRole = '我'
          senderWxid = cleanedMyWxid
          senderNickname = myInfo.displayName || cleanedMyWxid
        } else {
          senderWxid = sessionId
          const contactDetail = await getContactCached(sessionId)
          if (contactDetail.success && contactDetail.contact) {
            senderNickname = contactDetail.contact.nickName || sessionId
            senderRemark = contactDetail.contact.remark || ''
            senderRole = senderRemark || senderNickname
          } else {
            senderNickname = sessionInfo.displayName || sessionId
            senderRole = senderNickname
          }
        }

        writeBuffer.push(`${this.formatTimestamp(msg.createTime)} '${senderRole}'\n${enrichedContentValue}\n\n`)
        if (writeBuffer.length >= WRITE_BATCH) {
          await flushWriteBuffer()
        }

        if ((i + 1) % 200 === 0) {
          const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30)
          onProgress?.({
            current: progress,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'exporting',
            estimatedTotalMessages: totalMessages,
            collectedMessages: totalMessages,
            exportedMessages: i + 1
          })
        }
      }

      await flushWriteBuffer()

      onProgress?.({
        current: 92,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'writing',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: totalMessages
      })

      this.throwIfStopRequested(control)
      await new Promise<void>((resolve, reject) => {
        stream.on('error', reject)
        stream.end(() => resolve())
      })

      onProgress?.({
        current: 100,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'complete',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: totalMessages,
        writtenFiles: 1
      })

      return { success: true }
    } catch (e) {
      if (this.isStopError(e)) {
        return { success: false, error: '导出任务已停止' }
      }
      if (this.isPauseError(e)) {
        return { success: false, error: '导出任务已暂停' }
      }
      return { success: false, error: String(e) }
    }
  }

  /**
   * 导出单个会话为 WeClone CSV 格式
   */
  async exportSessionToWeCloneCsv(
    sessionId: string,
    outputPath: string,
    options: ExportOptions,
    onProgress?: (progress: ExportProgress) => void,
    control?: ExportTaskControl
  ): Promise<{ success: boolean; error?: string }> {
    try {
      this.throwIfStopRequested(control)
      const conn = await this.ensureConnected()
      if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }

      const cleanedMyWxid = conn.cleanedWxid
      const isGroup = sessionId.includes('@chatroom')
      const rawMyWxid = this.getConfiguredMyWxid()
      const sessionInfo = await this.getContactInfo(sessionId)
      const myInfo = await this.getContactInfo(cleanedMyWxid)

      const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
      const getContactCached = async (username: string) => {
        if (contactCache.has(username)) {
          return contactCache.get(username)!
        }
        const result = await wcdbService.getContact(username)
        contactCache.set(username, result)
        return result
      }

      onProgress?.({
        current: 0,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'preparing'
      })

      const collectParams = this.resolveCollectParams(options)
      const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
      const collected = await this.collectMessages(
        sessionId,
        cleanedMyWxid,
        options.dateRange,
        options.senderUsername,
        collectParams.mode,
        collectParams.targetMediaTypes,
        control,
        collectProgressReporter
      )
      let totalMessages = collected.rows.length
      if (totalMessages === 0) {
        return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
      }

      await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)

      const senderUsernames = new Set<string>()
      let senderScanIndex = 0
      for (const msg of collected.rows) {
        if ((senderScanIndex++ & 0x7f) === 0) {
          this.throwIfStopRequested(control)
        }
        if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
      }
      senderUsernames.add(sessionId)
      await this.preloadContacts(senderUsernames, contactCache)

      const groupNicknameCandidates = isGroup
        ? this.buildGroupNicknameIdCandidates([
          ...Array.from(senderUsernames.values()),
          ...collected.rows.map(msg => msg.senderUsername),
          cleanedMyWxid,
          rawMyWxid
        ])
        : []
      const groupNicknamesMap = isGroup
        ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
        : new Map<string, string>()

      const sortedMessages = collected.rows
        .filter((msg) => !this.isQuotedReplyMessage(msg.localType, msg.content || ''))
      totalMessages = sortedMessages.length
      if (totalMessages === 0) {
        return { success: false, error: '该会话在指定时间范围内没有可导出的消息' }
      }

      const voiceMessages = options.exportVoiceAsText
        ? sortedMessages.filter(msg => msg.localType === 34)
        : []

      if (options.exportVoiceAsText && voiceMessages.length > 0) {
        await this.ensureVoiceModel(onProgress)
      }

      const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
      const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)

      const mediaCache = new Map<string, MediaExportItem | null>()
      const mediaDirCache = new Set<string>()
      const beforeMediaDoneFiles = this.getMediaDoneFilesCount()

      if (mediaMessages.length > 0) {
        await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
          exportImages: options.exportImages,
          exportVideos: options.exportVideos
        }, control)
        const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
        if (voiceMediaMessages.length > 0) {
          await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
        }

        onProgress?.({
          current: 25,
          total: 100,
          currentSession: sessionInfo.displayName,
          phase: 'exporting-media',
          phaseProgress: 0,
          phaseTotal: mediaMessages.length,
          phaseLabel: this.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
          ...this.getMediaTelemetrySnapshot(),
          estimatedTotalMessages: totalMessages
        })

        const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
        let mediaExported = 0
        await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
          this.throwIfStopRequested(control)
          const mediaKey = this.getMediaCacheKey(msg)
          if (!mediaCache.has(mediaKey)) {
            const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
              exportImages: options.exportImages,
              exportVoices: options.exportVoices,
              exportVideos: options.exportVideos,
              exportEmojis: options.exportEmojis,
              exportFiles: options.exportFiles,
              maxFileSizeMb: options.maxFileSizeMb,
              exportVoiceAsText: options.exportVoiceAsText,
              includeVideoPoster: options.format === 'html',
              dirCache: mediaDirCache,
              control
            })
            mediaCache.set(mediaKey, mediaItem)
          }
          mediaExported++
          if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
            onProgress?.({
              current: 25,
              total: 100,
              currentSession: sessionInfo.displayName,
              phase: 'exporting-media',
              phaseProgress: mediaExported,
              phaseTotal: mediaMessages.length,
              phaseLabel: this.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
              ...this.getMediaTelemetrySnapshot()
            })
          }
        })
      }
      const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
      if (fileOnlyExportFailure) return fileOnlyExportFailure

      const voiceTranscriptMap = new Map<string, string>()

      if (voiceMessages.length > 0) {
        await this.preloadVoiceWavCache(sessionId, voiceMessages, control)

        onProgress?.({
          current: 45,
          total: 100,
          currentSession: sessionInfo.displayName,
          phase: 'exporting-voice',
          phaseProgress: 0,
          phaseTotal: voiceMessages.length,
          phaseLabel: `语音转文字 0/${voiceMessages.length}`,
          estimatedTotalMessages: totalMessages
        })

        const VOICE_CONCURRENCY = 4
        let voiceTranscribed = 0
        await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
          this.throwIfStopRequested(control)
          const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
          voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript)
          voiceTranscribed++
          onProgress?.({
            current: 45,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'exporting-voice',
            phaseProgress: voiceTranscribed,
            phaseTotal: voiceMessages.length,
            phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
          })
        })
      }

      onProgress?.({
        current: 60,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'exporting',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: 0
      })

      await this.recordCreatedFileBeforeWrite(outputPath, control)
      const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
      const writeChunk = async (chunk: string): Promise<void> => {
        await new Promise<void>((resolve, _reject) => {
          this.throwIfStopRequested(control)
          if (!stream.write(chunk)) {
            stream.once('drain', resolve)
          } else {
            resolve()
          }
        })
      }
      const WRITE_BATCH = 160
      let writeBuffer: string[] = []
      const flushWriteBuffer = async (): Promise<void> => {
        if (writeBuffer.length === 0) return
        await writeChunk(writeBuffer.join(''))
        writeBuffer = []
      }
      await writeChunk('\uFEFFid,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime\r\n')
      const senderProfileCache = new Map<string, ExportDisplayProfile>()

      for (let i = 0; i < totalMessages; i++) {
        if ((i & 0x7f) === 0) {
          this.throwIfStopRequested(control)
        }
        const msg = sortedMessages[i]
        const mediaKey = this.getMediaCacheKey(msg)
        const mediaItem = mediaCache.get(mediaKey) || null

        const typeName = this.getWeCloneTypeName(msg.localType, msg.content || '')
        let senderWxid = cleanedMyWxid
        if (!msg.isSend) {
          senderWxid = isGroup && msg.senderUsername
            ? msg.senderUsername
            : sessionId
        }

        let talker = myInfo.displayName || '我'
        if (isGroup) {
          const senderProfileKey = `${msg.isSend ? cleanedMyWxid : senderWxid}::${msg.isSend ? '1' : '0'}`
          let senderProfile = senderProfileCache.get(senderProfileKey)
          if (!senderProfile) {
            senderProfile = await this.resolveExportDisplayProfile(
              msg.isSend ? cleanedMyWxid : senderWxid,
              options.displayNamePreference,
              getContactCached,
              groupNicknamesMap,
              msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid,
              msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
            )
            senderProfileCache.set(senderProfileKey, senderProfile)
          }
          talker = senderProfile.displayName
        } else if (!msg.isSend) {
          const contactDetail = await getContactCached(senderWxid)
          const senderNickname = contactDetail.success && contactDetail.contact
            ? (contactDetail.contact.nickName || senderWxid)
            : senderWxid
          const senderRemark = contactDetail.success && contactDetail.contact
            ? (contactDetail.contact.remark || '')
            : ''
          const senderGroupNickname = isGroup
            ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid])
            : ''
          talker = this.getPreferredDisplayName(
            senderWxid,
            senderNickname,
            senderRemark,
            senderGroupNickname,
            options.displayNamePreference || 'remark'
          )
        }

        const msgText = msg.localType === 34 && options.exportVoiceAsText
          ? (voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]')
          : (this.parseMessageContent(
            msg.content,
            msg.localType,
            sessionId,
            msg.createTime,
            cleanedMyWxid,
            msg.senderUsername,
            msg.isSend,
            msg.emojiCaption
          ) || '')
        const src = this.getWeCloneSource(msg, typeName, mediaItem)
        const platformMessageId = this.getExportPlatformMessageId(msg) || ''

        const row = [
          i + 1,
          platformMessageId,
          typeName,
          msg.isSend ? 1 : 0,
          talker,
          msgText,
          src,
          this.formatIsoTimestamp(msg.createTime)
        ]

        writeBuffer.push(`${row.map((value) => this.escapeCsvCell(value)).join(',')}\r\n`)
        if (writeBuffer.length >= WRITE_BATCH) {
          await flushWriteBuffer()
        }

        if ((i + 1) % 200 === 0) {
          const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30)
          onProgress?.({
            current: progress,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'exporting',
            estimatedTotalMessages: totalMessages,
            collectedMessages: totalMessages,
            exportedMessages: i + 1
          })
        }
      }

      await flushWriteBuffer()

      onProgress?.({
        current: 92,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'writing',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: totalMessages
      })

      this.throwIfStopRequested(control)
      await new Promise<void>((resolve, reject) => {
        stream.on('error', reject)
        stream.end(() => resolve())
      })

      onProgress?.({
        current: 100,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'complete',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: totalMessages,
        writtenFiles: 1
      })

      return { success: true }
    } catch (e) {
      if (this.isStopError(e)) {
        return { success: false, error: '导出任务已停止' }
      }
      if (this.isPauseError(e)) {
        return { success: false, error: '导出任务已暂停' }
      }
      return { success: false, error: String(e) }
    }
  }

  private getVirtualScrollScript(): string {
    return `
      class ChunkedRenderer {
        constructor(container, data, renderItem) {
          this.container = container;
          this.data = data;
          this.renderItem = renderItem;
          this.batchSize = 100;
          this.rendered = 0;
          this.loading = false;

          this.list = document.createElement('div');
          this.list.className = 'message-list';
          this.container.appendChild(this.list);

          this.sentinel = document.createElement('div');
          this.sentinel.className = 'load-sentinel';
          this.container.appendChild(this.sentinel);

          this.renderBatch();

          this.observer = new IntersectionObserver((entries) => {
            if (entries[0].isIntersecting && !this.loading) {
              this.renderBatch();
            }
          }, { root: this.container, rootMargin: '600px' });
          this.observer.observe(this.sentinel);
        }

        renderBatch() {
          if (this.rendered >= this.data.length) return;
          this.loading = true;
          const end = Math.min(this.rendered + this.batchSize, this.data.length);
          const fragment = document.createDocumentFragment();
          for (let i = this.rendered; i < end; i++) {
            const wrapper = document.createElement('div');
            wrapper.innerHTML = this.renderItem(this.data[i], i);
            if (wrapper.firstElementChild) fragment.appendChild(wrapper.firstElementChild);
          }
          this.list.appendChild(fragment);
          this.rendered = end;
          this.loading = false;
        }

        setData(newData) {
          this.data = newData;
          this.rendered = 0;
          this.list.innerHTML = '';
          this.container.scrollTop = 0;
          if (this.data.length === 0) {
            this.list.innerHTML = '<div class="empty">暂无消息</div>';
            return;
          }
          this.renderBatch();
        }

        scrollToTime(timestamp) {
          const idx = this.data.findIndex(item => item.t >= timestamp);
          if (idx === -1) return;
          // Ensure all messages up to target are rendered
          while (this.rendered <= idx) {
            this.renderBatch();
          }
          const el = this.list.children[idx];
          if (el) {
            el.scrollIntoView({ behavior: 'smooth', block: 'center' });
            el.classList.add('highlight');
            setTimeout(() => el.classList.remove('highlight'), 2500);
          }
        }

        scrollToIndex(index) {
          while (this.rendered <= index) {
            this.renderBatch();
          }
          const el = this.list.children[index];
          if (el) {
            el.scrollIntoView({ behavior: 'smooth', block: 'center' });
          }
        }
      }
    `;
  }

  /**
   * 导出单个会话为 HTML 格式
   */
  async exportSessionToHtml(
    sessionId: string,
    outputPath: string,
    options: ExportOptions,
    onProgress?: (progress: ExportProgress) => void,
    control?: ExportTaskControl
  ): Promise<{ success: boolean; error?: string }> {
    try {
      this.throwIfStopRequested(control)
      const conn = await this.ensureConnected()
      if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }

      const cleanedMyWxid = conn.cleanedWxid
      const isGroup = sessionId.includes('@chatroom')
      const rawMyWxid = this.getConfiguredMyWxid()
      const sessionInfo = await this.getContactInfo(sessionId)
      const myInfo = await this.getContactInfo(cleanedMyWxid)
      const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
      const getContactCached = async (username: string) => {
        if (contactCache.has(username)) {
          return contactCache.get(username)!
        }
        const result = await wcdbService.getContact(username)
        contactCache.set(username, result)
        return result
      }

      onProgress?.({
        current: 0,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'preparing'
      })

      if (options.exportVoiceAsText) {
        await this.ensureVoiceModel(onProgress)
      }

      const collectParams = this.resolveCollectParams(options)
      const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
      const collected = await this.collectMessages(
        sessionId,
        cleanedMyWxid,
        options.dateRange,
        options.senderUsername,
        collectParams.mode,
        collectParams.targetMediaTypes,
        control,
        collectProgressReporter
      )

      // 如果没有消息,不创建文件
      if (collected.rows.length === 0) {
        return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
      }
      const totalMessages = collected.rows.length

      await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)

      const senderUsernames = new Set<string>()
      let senderScanIndex = 0
      for (const msg of collected.rows) {
        if ((senderScanIndex++ & 0x7f) === 0) {
          this.throwIfStopRequested(control)
        }
        if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
      }
      senderUsernames.add(sessionId)
      await this.preloadContacts(senderUsernames, contactCache)

      const groupNicknameCandidates = isGroup
        ? this.buildGroupNicknameIdCandidates([
          ...Array.from(senderUsernames.values()),
          ...collected.rows.map(msg => msg.senderUsername),
          cleanedMyWxid,
          rawMyWxid
        ])
        : []
      const groupNicknamesMap = isGroup
        ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
        : new Map<string, string>()

      if (isGroup) {
        this.throwIfStopRequested(control)
        await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
      }
      const sortedMessages = collected.rows

      const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
      const mediaMessages = this.collectMediaMessagesForExport(sortedMessages, options)

      const mediaCache = new Map<string, MediaExportItem | null>()
      const mediaDirCache = new Set<string>()
      const beforeMediaDoneFiles = this.getMediaDoneFilesCount()

      if (mediaMessages.length > 0) {
        await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
          exportImages: options.exportImages,
          exportVideos: options.exportVideos
        }, control)
        const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
        if (voiceMediaMessages.length > 0) {
          await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
        }

        onProgress?.({
          current: 20,
          total: 100,
          currentSession: sessionInfo.displayName,
          phase: 'exporting-media',
          phaseProgress: 0,
          phaseTotal: mediaMessages.length,
          phaseLabel: this.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
          ...this.getMediaTelemetrySnapshot(),
          estimatedTotalMessages: totalMessages
        })

        const MEDIA_CONCURRENCY = 6
        let mediaExported = 0
        await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
          this.throwIfStopRequested(control)
          const mediaKey = this.getMediaCacheKey(msg)
          if (!mediaCache.has(mediaKey)) {
            const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
              exportImages: options.exportImages,
              exportVoices: options.exportVoices,
              exportEmojis: options.exportEmojis,
              exportFiles: options.exportFiles,
              maxFileSizeMb: options.maxFileSizeMb,
              exportVoiceAsText: options.exportVoiceAsText,
              includeVideoPoster: options.format === 'html',
              includeVoiceWithTranscript: true,
              exportVideos: options.exportVideos,
              dirCache: mediaDirCache,
              control
            })
            mediaCache.set(mediaKey, mediaItem)
          }
          mediaExported++
          if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
            onProgress?.({
              current: 20,
              total: 100,
              currentSession: sessionInfo.displayName,
              phase: 'exporting-media',
              phaseProgress: mediaExported,
              phaseTotal: mediaMessages.length,
              phaseLabel: this.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
              ...this.getMediaTelemetrySnapshot()
            })
          }
        })
      }
      const fileOnlyExportFailure = this.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
      if (fileOnlyExportFailure) return fileOnlyExportFailure

      const useVoiceTranscript = options.exportVoiceAsText === true
      const voiceMessages = useVoiceTranscript
        ? sortedMessages.filter(msg => msg.localType === 34)
        : []
      const voiceTranscriptMap = new Map<string, string>()

      if (voiceMessages.length > 0) {
        await this.preloadVoiceWavCache(sessionId, voiceMessages, control)

        onProgress?.({
          current: 40,
          total: 100,
          currentSession: sessionInfo.displayName,
          phase: 'exporting-voice',
          phaseProgress: 0,
          phaseTotal: voiceMessages.length,
          phaseLabel: `语音转文字 0/${voiceMessages.length}`,
          estimatedTotalMessages: totalMessages
        })

        const VOICE_CONCURRENCY = 4
        let voiceTranscribed = 0
        await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
          this.throwIfStopRequested(control)
          const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
          voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript)
          voiceTranscribed++
          onProgress?.({
            current: 40,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'exporting-voice',
            phaseProgress: voiceTranscribed,
            phaseTotal: voiceMessages.length,
            phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
          })
        })
      }

      const avatarMap = options.exportAvatars
        ? await this.exportAvatarsToFiles(
          [
            ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
              username,
              avatarUrl: info.avatarUrl
            })),
            { username: sessionId, avatarUrl: sessionInfo.avatarUrl },
            { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
          ],
          path.dirname(outputPath),
          control
        )
        : new Map<string, string>()

      onProgress?.({
        current: 60,
        total: 100,
        currentSession: sessionInfo.displayName,
        phase: 'writing',
        estimatedTotalMessages: totalMessages,
        collectedMessages: totalMessages,
        exportedMessages: 0
      })

      // ================= BEGIN STREAM WRITING =================
      const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup)
      const htmlStyles = this.loadExportHtmlStyles()
      await this.recordCreatedFileBeforeWrite(outputPath, control)
      const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })

      const writePromise = (str: string) => {
        return new Promise<void>((resolve, reject) => {
          this.throwIfStopRequested(control)
          if (!stream.write(str)) {
            stream.once('drain', resolve)
          } else {
            resolve()
          }
        })
      }

      await writePromise(`<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>${this.escapeHtml(sessionInfo.displayName)} - 聊天记录</title>
    <style>${htmlStyles}</style>
  </head>
  <body>
    <div class="page">
      <div class="header">
        <h1 class="title">${this.escapeHtml(sessionInfo.displayName)}</h1>
        <div class="meta">
          <span>${sortedMessages.length} 条消息</span>
          <span>${isGroup ? '群聊' : '私聊'}</span>
          <span>${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))}</span>
        </div>
        <div class="controls">
          <input id="searchInput" type="search" placeholder="搜索消息..." />
          <input id="timeInput" type="datetime-local" />
          <button id="jumpBtn" type="button">跳转</button>
          <div class="stats">
            <span id="resultCount">共 ${sortedMessages.length} 条</span>
          </div>
        </div>
      </div>
      
      <div id="scrollContainer" class="scroll-container"></div>
      
    </div>
    
    <div class="image-preview" id="imagePreview">
      <img id="imagePreviewTarget" alt="预览" />
    </div>

    <!-- Data Injection -->
    <script>
      window.WEFLOW_DATA = [
`);

      // Pre-build avatar HTML lookup to avoid per-message rebuilds
      const avatarHtmlCache = new Map<string, string>()
      const senderProfileCache = new Map<string, ExportDisplayProfile>()
      const getAvatarHtml = (username: string, name: string): string => {
        const cached = avatarHtmlCache.get(username)
        if (cached !== undefined) return cached
        const avatarData = avatarMap.get(username)
        const html = avatarData
          ? `<img src="${this.escapeAttribute(encodeURI(avatarData))}" alt="${this.escapeAttribute(name)}" />`
          : `<span>${this.escapeHtml(this.getAvatarFallback(name))}</span>`
        avatarHtmlCache.set(username, html)
        return html
      }

      // Write messages in buffered chunks
      const WRITE_BATCH = 100
      let writeBuf: string[] = []

      for (let i = 0; i < totalMessages; i++) {
        if ((i & 0x7f) === 0) {
          this.throwIfStopRequested(control)
        }
        const msg = sortedMessages[i]
        const mediaKey = this.getMediaCacheKey(msg)
        const mediaItem = mediaCache.get(mediaKey) || null

        const isSenderMe = msg.isSend
        const senderInfo = collected.memberSet.get(msg.senderUsername)?.member
        const senderName = isGroup
          ? (() => {
            const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}`
            const cached = senderProfileCache.get(senderKey)
            if (cached) return cached.displayName
            return ''
          })()
          : (isSenderMe ? (myInfo.displayName || '我') : (sessionInfo.displayName || sessionId))
        const resolvedSenderName = isGroup && !senderName
          ? (await (async () => {
            const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}`
            const profile = await this.resolveExportDisplayProfile(
              isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
              options.displayNamePreference,
              getContactCached,
              groupNicknamesMap,
              isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''),
              isSenderMe ? [rawMyWxid, cleanedMyWxid] : []
            )
            senderProfileCache.set(senderKey, profile)
            return profile.displayName
          })())
          : senderName

        const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, resolvedSenderName)

        const timeText = this.formatTimestamp(msg.createTime)
        const typeName = this.getMessageTypeName(msg.localType, msg.content)
        const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
          content: msg.content,
          isGroup,
          displayNamePreference: options.displayNamePreference,
          getContact: getContactCached,
          groupNicknamesMap,
          cleanedMyWxid,
          rawMyWxid,
          myDisplayName: myInfo.displayName || cleanedMyWxid
        })

        let textContent = quotedReplyDisplay?.replyText || this.formatHtmlMessageText(
          msg.content,
          msg.localType,
          cleanedMyWxid,
          msg.senderUsername,
          msg.isSend,
          msg.emojiCaption
        )
        if (msg.localType === 34 && useVoiceTranscript) {
          textContent = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
        }
        if (mediaItem && msg.localType === 3) {
          textContent = ''
        }
        if (this.isTransferExportContent(textContent) && msg.content) {
          const transferDesc = await this.resolveTransferDesc(
            msg.content,
            cleanedMyWxid,
            groupNicknamesMap,
            async (username) => {
              const c = await getContactCached(username)
              if (c.success && c.contact) {
                return c.contact.remark || c.contact.nickName || c.contact.alias || username
              }
              return username
            }
          )
          if (transferDesc) {
            textContent = this.appendTransferDesc(textContent, transferDesc)
          }
        }

        const linkCard = quotedReplyDisplay ? null : this.extractHtmlLinkCard(msg.content, msg.localType)

        let mediaHtml = ''
        if (mediaItem?.kind === 'image') {
          const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath))
          mediaHtml = `<img class="message-media image previewable" src="${mediaPath}" data-full="${mediaPath}" alt="${this.escapeAttribute(typeName)}" />`
        } else if (mediaItem?.kind === 'emoji') {
          const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath))
          mediaHtml = `<img class="message-media emoji previewable" src="${mediaPath}" data-full="${mediaPath}" alt="${this.escapeAttribute(typeName)}" />`
        } else if (mediaItem?.kind === 'voice') {
          mediaHtml = `<audio class="message-media audio" controls src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></audio>`
        } else if (mediaItem?.kind === 'video') {
          const posterAttr = mediaItem.posterDataUrl ? ` poster="${this.escapeAttribute(mediaItem.posterDataUrl)}"` : ''
          mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>`
        }

        const textHtml = quotedReplyDisplay
          ? (() => {
            const quotedSenderHtml = quotedReplyDisplay.quotedSender
              ? `<div class="quoted-sender">${this.escapeHtml(quotedReplyDisplay.quotedSender)}</div>`
              : ''
            const quotedPreviewHtml = `<div class="quoted-text">${this.renderTextWithEmoji(quotedReplyDisplay.quotedPreview).replace(/\r?\n/g, '<br />')}</div>`
            const replyTextHtml = textContent
              ? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
              : ''
            return `<div class="quoted-message">${quotedSenderHtml}${quotedPreviewHtml}</div>${replyTextHtml}`
          })()
          : (linkCard
            ? `<div class="message-text"><a class="message-link-card" href="${this.escapeAttribute(linkCard.url)}" target="_blank" rel="noopener noreferrer">${this.renderTextWithEmoji(linkCard.title).replace(/\r?\n/g, '<br />')}</a></div>`
            : (textContent
              ? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
              : ''))
        const senderNameHtml = isGroup
          ? `<div class="sender-name">${this.escapeHtml(resolvedSenderName)}</div>`
          : ''
        const timeHtml = `<div class="message-time">${this.escapeHtml(timeText)}</div>`
        const messageBody = `${timeHtml}${senderNameHtml}<div class="message-content">${mediaHtml}${textHtml}</div>`
        const platformMessageId = this.getExportPlatformMessageId(msg)
        const replyToMessageId = this.getExportReplyToMessageId(msg.content)

        // Compact JSON object
        const itemObj: Record<string, any> = {
          i: i + 1, // index
          t: msg.createTime, // timestamp
          s: isSenderMe ? 1 : 0, // isSend
          a: avatarHtml, // avatar HTML
          b: messageBody // body HTML
        }
        if (platformMessageId) itemObj.p = platformMessageId
        if (replyToMessageId) itemObj.r = replyToMessageId

        writeBuf.push(JSON.stringify(itemObj))

        // Flush buffer periodically
        if (writeBuf.length >= WRITE_BATCH || i === sortedMessages.length - 1) {
          const isLast = i === sortedMessages.length - 1
          const chunk = writeBuf.join(',\n') + (isLast ? '\n' : ',\n')
          await writePromise(chunk)
          writeBuf = []
        }

        // Report progress occasionally
        if ((i + 1) % 500 === 0) {
          onProgress?.({
            current: 60 + Math.floor((i + 1) / sortedMessages.length * 30),
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'writing',
            estimatedTotalMessages: totalMessages,
            collectedMessages: totalMessages,
            exportedMessages: i + 1
          })
        }
      }

      await writePromise(`];
    </script>

    <script>
       ${this.getVirtualScrollScript()}

      const searchInput = document.getElementById('searchInput')
      const timeInput = document.getElementById('timeInput')
      const jumpBtn = document.getElementById('jumpBtn')
      const resultCount = document.getElementById('resultCount')
      const imagePreview = document.getElementById('imagePreview')
      const imagePreviewTarget = document.getElementById('imagePreviewTarget')
      const container = document.getElementById('scrollContainer')
      let imageZoom = 1

      // Initial Data
      let allData = window.WEFLOW_DATA || [];
      let currentList = allData;

      // Render Item Function
      const renderItem = (item, index) => {
         const isSenderMe = item.s === 1;
         const platformIdAttr = item.p ? \` data-platform-message-id="\${item.p}"\` : '';
         const replyToAttr = item.r ? \` data-reply-to-message-id="\${item.r}"\` : '';
         return \`
          <div class="message \${isSenderMe ? 'sent' : 'received'}" data-index="\${item.i}"\${platformIdAttr}\${replyToAttr}>
            <div class="message-row">
              <div class="avatar">\${item.a}</div>
              <div class="bubble">
                \${item.b}
              </div>
            </div>
          </div>
         \`;
      };
      
      const renderer = new ChunkedRenderer(container, currentList, renderItem);

      const updateCount = () => {
        resultCount.textContent = \`共 \${currentList.length} 条\`
      }

      // Search Logic
      let searchTimeout;
      searchInput.addEventListener('input', () => {
        clearTimeout(searchTimeout);
        searchTimeout = setTimeout(() => {
          const keyword = searchInput.value.trim().toLowerCase();
          if (!keyword) {
            currentList = allData;
          } else {
            currentList = allData.filter(item => {
               return item.b.toLowerCase().includes(keyword); 
            });
          }
          renderer.setData(currentList);
          updateCount();
        }, 300);
      })

      // Jump Logic
      jumpBtn.addEventListener('click', () => {
        const value = timeInput.value
        if (!value) return
        const target = Math.floor(new Date(value).getTime() / 1000)
        renderer.scrollToTime(target);
      })

      // Image Preview (Delegation)
      container.addEventListener('click', (e) => {
        const target = e.target;
        if (target.classList.contains('previewable')) {
           const full = target.getAttribute('data-full')
           if (!full) return
           imagePreviewTarget.src = full
           imageZoom = 1
           imagePreviewTarget.style.transform = 'scale(1)'
           imagePreview.classList.add('active')
        }
      });

      imagePreviewTarget.addEventListener('click', (event) => {
        event.stopPropagation()
      })

      imagePreviewTarget.addEventListener('dblclick', (event) => {
        event.stopPropagation()
        imageZoom = 1
        imagePreviewTarget.style.transform = 'scale(1)'
      })

      imagePreviewTarget.addEventListener('wheel', (event) => {
        event.preventDefault()
        const delta = event.deltaY > 0 ? -0.1 : 0.1
        imageZoom = Math.min(3, Math.max(0.5, imageZoom + delta))
        imagePreviewTarget.style.transform = \`scale(\${imageZoom})\`
      }, { passive: false })

      imagePreview.addEventListener('click', () => {
        imagePreview.classList.remove('active')
        imagePreviewTarget.src = ''
        imageZoom = 1
        imagePreviewTarget.style.transform = 'scale(1)'
      })

      updateCount()
    </script>
  </body>
</html>`);

      return new Promise((resolve, reject) => {
        stream.on('error', (err) => {
          // 确保在流错误时销毁流，释放文件句柄
          stream.destroy()
          reject(err)
        })
        
        stream.end(() => {
          onProgress?.({
            current: 100,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'complete',
            estimatedTotalMessages: totalMessages,
            collectedMessages: totalMessages,
            exportedMessages: totalMessages,
            writtenFiles: 1
          })
          resolve({ success: true })
        })
        stream.on('error', reject)
      })

    } catch (e) {
      if (this.isStopError(e)) {
        return { success: false, error: '导出任务已停止' }
      }
      if (this.isPauseError(e)) {
        return { success: false, error: '导出任务已暂停' }
      }
      return { success: false, error: String(e) }
    }
  }

  /**
   * 获取导出前的预估统计信息
   */
  async getExportStats(
    sessionIds: string[],
    options: ExportOptions
  ): Promise<ExportStatsResult> {
    const conn = await this.ensureConnected()
    if (!conn.success || !conn.cleanedWxid) {
      return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] }
    }
    const normalizedSessionIds = this.normalizeSessionIds(sessionIds)
    if (normalizedSessionIds.length === 0) {
      return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] }
    }
    const cacheKey = this.buildExportStatsCacheKey(normalizedSessionIds, options, conn.cleanedWxid)
    const cachedStats = this.getExportStatsCacheEntry(cacheKey)
    if (cachedStats) {
      const cachedResult = this.cloneExportStatsResult(cachedStats.result)
      const orderedSessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = []
      const sessionMap = new Map(cachedResult.sessions.map((item) => [item.sessionId, item] as const))
      for (const sessionId of normalizedSessionIds) {
        const cachedSession = sessionMap.get(sessionId)
        if (cachedSession) orderedSessions.push(cachedSession)
      }
      if (orderedSessions.length === cachedResult.sessions.length) {
        cachedResult.sessions = orderedSessions
      }
      return cachedResult
    }

    const cleanedMyWxid = conn.cleanedWxid
    const sessionsStats: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = []
    const sessionSnapshotMap: Record<string, ExportStatsSessionSnapshot> = {}
    let totalMessages = 0
    let voiceMessages = 0
    let cachedVoiceCount = 0
    let mediaMessages = 0

    const hasSenderFilter = Boolean(String(options.senderUsername || '').trim())
    const canUseAggregatedStats = this.isUnboundedDateRange(options.dateRange) && !hasSenderFilter

    // 快速路径：直接复用 ChatService 聚合统计，避免逐会话 collectMessages 扫全量消息。
    if (canUseAggregatedStats) {
      try {
        let aggregatedData = this.getAggregatedSessionStatsCache(cacheKey)
        if (!aggregatedData) {
          const statsResult = await chatService.getExportSessionStats(normalizedSessionIds, {
            includeRelations: false,
            allowStaleCache: true
          })
          if (statsResult.success && statsResult.data) {
            aggregatedData = statsResult.data as Record<string, ExportAggregatedSessionMetric>
            this.setAggregatedSessionStatsCache(cacheKey, aggregatedData)
          }
        }
        if (aggregatedData) {
          const cachedVoiceCountMap = chatService.getCachedVoiceTranscriptCountMap(normalizedSessionIds)
          const fastRows = await parallelLimit(
            normalizedSessionIds,
            8,
            async (sessionId): Promise<{
              sessionId: string
              displayName: string
              totalCount: number
              voiceCount: number
              cachedVoiceCount: number
              mediaCount: number
            }> => {
              let displayName = sessionId
              try {
                const sessionInfo = await this.getContactInfo(sessionId)
                displayName = sessionInfo.displayName || sessionId
              } catch {
                // 预估阶段显示名获取失败不阻塞统计
              }

              const metric = aggregatedData?.[sessionId]
              const totalCount = Number.isFinite(metric?.totalMessages)
                ? Math.max(0, Math.floor(metric?.totalMessages ?? 0))
                : 0
              const voiceCount = Number.isFinite(metric?.voiceMessages)
                ? Math.max(0, Math.floor(metric?.voiceMessages ?? 0))
                : 0
              const imageCount = Number.isFinite(metric?.imageMessages)
                ? Math.max(0, Math.floor(metric?.imageMessages ?? 0))
                : 0
              const videoCount = Number.isFinite(metric?.videoMessages)
                ? Math.max(0, Math.floor(metric?.videoMessages ?? 0))
                : 0
              const emojiCount = Number.isFinite(metric?.emojiMessages)
                ? Math.max(0, Math.floor(metric?.emojiMessages ?? 0))
                : 0
              const lastTimestamp = Number.isFinite(metric?.lastTimestamp)
                ? Math.max(0, Math.floor(metric?.lastTimestamp ?? 0))
                : undefined
              const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0)
              const sessionCachedVoiceCount = Math.min(
                voiceCount,
                Number.isFinite(cachedCountRaw) ? Math.max(0, Math.floor(cachedCountRaw)) : 0
              )

              sessionSnapshotMap[sessionId] = {
                totalCount,
                voiceCount,
                imageCount,
                videoCount,
                emojiCount,
                cachedVoiceCount: sessionCachedVoiceCount,
                lastTimestamp
              }

              return {
                sessionId,
                displayName,
                totalCount,
                voiceCount,
                cachedVoiceCount: sessionCachedVoiceCount,
                mediaCount: voiceCount + imageCount + videoCount + emojiCount
              }
            }
          )

          for (const row of fastRows) {
            totalMessages += row.totalCount
            voiceMessages += row.voiceCount
            cachedVoiceCount += row.cachedVoiceCount
            mediaMessages += row.mediaCount
            sessionsStats.push({
              sessionId: row.sessionId,
              displayName: row.displayName,
              totalCount: row.totalCount,
              voiceCount: row.voiceCount
            })
          }

          const needTranscribeCount = Math.max(0, voiceMessages - cachedVoiceCount)
          const estimatedSeconds = needTranscribeCount * 2
          const result: ExportStatsResult = {
            totalMessages,
            voiceMessages,
            cachedVoiceCount,
            needTranscribeCount,
            mediaMessages,
            estimatedSeconds,
            sessions: sessionsStats
          }
          this.setExportStatsCacheEntry(cacheKey, {
            createdAt: Date.now(),
            result: this.cloneExportStatsResult(result),
            sessions: { ...sessionSnapshotMap }
          })
          return result
        }
      } catch (error) {
        // 聚合统计失败时自动回退到慢路径，保证功能正确。
      }
    }

    // 回退路径：保留旧逻辑，支持有时间范围/发送者过滤等需要精确筛选的场景。
    for (const sessionId of normalizedSessionIds) {
      const sessionInfo = await this.getContactInfo(sessionId)
      const collected = await this.collectMessages(
        sessionId,
        cleanedMyWxid,
        options.dateRange,
        options.senderUsername,
        'text-fast'
      )
      const msgs = collected.rows
      let voiceCount = 0
      let imageCount = 0
      let videoCount = 0
      let emojiCount = 0
      let latestTimestamp = 0
      let cached = 0
      for (const msg of msgs) {
        if (msg.createTime > latestTimestamp) {
          latestTimestamp = msg.createTime
        }
        const localType = msg.localType
        if (localType === 34) {
          voiceCount++
          if (chatService.hasTranscriptCache(sessionId, String(msg.localId), msg.createTime)) {
            cached++
          }
          continue
        }
        if (localType === 3) imageCount++
        if (localType === 43) videoCount++
        if (localType === 47) emojiCount++
      }
      const mediaCount = voiceCount + imageCount + videoCount + emojiCount

      totalMessages += msgs.length
      voiceMessages += voiceCount
      cachedVoiceCount += cached
      mediaMessages += mediaCount
      sessionSnapshotMap[sessionId] = {
        totalCount: msgs.length,
        voiceCount,
        imageCount,
        videoCount,
        emojiCount,
        cachedVoiceCount: cached,
        lastTimestamp: latestTimestamp > 0 ? latestTimestamp : undefined
      }
      sessionsStats.push({
        sessionId,
        displayName: sessionInfo.displayName,
        totalCount: msgs.length,
        voiceCount
      })
    }

    const needTranscribeCount = Math.max(0, voiceMessages - cachedVoiceCount)
    // 预估：每条语音转文字约 2 秒
    const estimatedSeconds = needTranscribeCount * 2

    const result: ExportStatsResult = {
      totalMessages,
      voiceMessages,
      cachedVoiceCount,
      needTranscribeCount,
      mediaMessages,
      estimatedSeconds,
      sessions: sessionsStats
    }
    this.setExportStatsCacheEntry(cacheKey, {
      createdAt: Date.now(),
      result: this.cloneExportStatsResult(result),
      sessions: { ...sessionSnapshotMap }
    })
    return result
  }

  /**
   * 批量导出多个会话
   */
  async exportSessions(
    sessionIds: string[],
    outputDir: string,
    options: ExportOptions,
    onProgress?: (progress: ExportProgress) => void,
    control?: ExportTaskControl
  ): Promise<{
    success: boolean
    successCount: number
    failCount: number
    paused?: boolean
    stopped?: boolean
    pendingSessionIds?: string[]
    successSessionIds?: string[]
    failedSessionIds?: string[]
    failedSessionErrors?: Record<string, string>
    sessionOutputPaths?: Record<string, string>
    error?: string
  }> {
    let successCount = 0
    let failCount = 0
    const successSessionIds: string[] = []
    const failedSessionIds: string[] = []
    const failedSessionErrors: Record<string, string> = {}
    const sessionOutputPaths: Record<string, string> = {}
    const progressEmitter = this.createProgressEmitter(onProgress)
    let attachMediaTelemetry = false
    const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => {
      const payload = attachMediaTelemetry
        ? { ...progress, ...this.getMediaTelemetrySnapshot() }
        : progress
      progressEmitter.emit(payload, options)
    }

    try {
      const conn = await this.ensureConnected()
      if (!conn.success) {
        return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error }
      }

      this.resetMediaRuntimeState()
      const normalizedOptions = this.normalizeExportOptionsForRun(options)
      const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(normalizedOptions)
        ? { ...normalizedOptions, exportVoiceAsText: false }
        : normalizedOptions

      const exportMediaEnabled = effectiveOptions.exportMedia === true &&
        Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
      attachMediaTelemetry = exportMediaEnabled
      if (exportMediaEnabled) {
        this.triggerMediaFileCacheCleanup()
      }
      const writeLayout = this.resolveExportWriteLayout(effectiveOptions)
      const exportBaseDir = writeLayout === 'A'
        ? path.join(outputDir, 'texts')
        : outputDir
      const createdTaskDirs = new Set<string>()
      const reservedOutputPaths = new Set<string>()
      const ensureTaskDir = async (dirPath: string) => {
        if (createdTaskDirs.has(dirPath)) return
        await this.ensureExportDir(dirPath, control)
        createdTaskDirs.add(dirPath)
      }
      await ensureTaskDir(exportBaseDir)
      const sessionLayout = exportMediaEnabled
        ? (effectiveOptions.sessionLayout ?? 'per-session')
        : 'shared'
      let completedCount = 0
      const activeSessionRatios = new Map<string, number>()
      const computeAggregateCurrent = () => {
        let activeRatioSum = 0
        for (const ratio of activeSessionRatios.values()) {
          activeRatioSum += Math.max(0, Math.min(1, ratio))
        }
        return Math.min(sessionIds.length, completedCount + activeRatioSum)
      }
      const isTextContentBatchExport = effectiveOptions.contentType === 'text' && !exportMediaEnabled
      const defaultConcurrency = exportMediaEnabled ? 2 : (isTextContentBatchExport ? 1 : 4)
      const rawConcurrency = typeof effectiveOptions.exportConcurrency === 'number'
        ? Math.floor(effectiveOptions.exportConcurrency)
        : defaultConcurrency
      const maxSessionConcurrency = isTextContentBatchExport ? 1 : 6
      const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, maxSessionConcurrency))
      const sessionConcurrency = clampedConcurrency
      const queue = [...sessionIds]
      let pauseRequested = false
      let stopRequested = false
      const sessionMessageCountHints = new Map<string, number>()
      const sessionLatestTimestampHints = new Map<string, number>()
      const exportStatsCacheKey = this.buildExportStatsCacheKey(sessionIds, effectiveOptions, conn.cleanedWxid)
      const cachedStatsEntry = this.getExportStatsCacheEntry(exportStatsCacheKey)
      if (cachedStatsEntry?.sessions) {
        for (const sessionId of sessionIds) {
          const snapshot = cachedStatsEntry.sessions[sessionId]
          if (!snapshot) continue
          sessionMessageCountHints.set(sessionId, Math.max(0, Math.floor(snapshot.totalCount || 0)))
          if (Number.isFinite(snapshot.lastTimestamp) && Number(snapshot.lastTimestamp) > 0) {
            sessionLatestTimestampHints.set(sessionId, Math.floor(Number(snapshot.lastTimestamp)))
          }
        }
      }
      const canUseSessionSnapshotHints = isTextContentBatchExport &&
        this.isUnboundedDateRange(effectiveOptions.dateRange) &&
        !String(effectiveOptions.senderUsername || '').trim()
      const canFastSkipEmptySessions = false
      const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints
      const precheckSessionIds = canFastSkipEmptySessions
        ? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId))
        : []
      if (canFastSkipEmptySessions && precheckSessionIds.length > 0) {
        const EMPTY_SESSION_PRECHECK_LIMIT = 1200
        if (precheckSessionIds.length <= EMPTY_SESSION_PRECHECK_LIMIT) {
          let checkedCount = 0
          emitProgress({
            current: computeAggregateCurrent(),
            total: sessionIds.length,
            currentSession: '',
            currentSessionId: '',
            phase: 'preparing',
            phaseProgress: 0,
            phaseTotal: precheckSessionIds.length,
            phaseLabel: `预检查空会话 0/${precheckSessionIds.length}`
          })

          const PRECHECK_BATCH_SIZE = 160
          for (let i = 0; i < precheckSessionIds.length; i += PRECHECK_BATCH_SIZE) {
            if (control?.shouldStop?.()) {
              stopRequested = true
              break
            }
            if (control?.shouldPause?.()) {
              pauseRequested = true
              break
            }

            const batchSessionIds = precheckSessionIds.slice(i, i + PRECHECK_BATCH_SIZE)
            const countsResult = await wcdbService.getMessageCounts(batchSessionIds)
            if (countsResult.success && countsResult.counts) {
              for (const batchSessionId of batchSessionIds) {
                const count = countsResult.counts[batchSessionId]
                if (typeof count === 'number' && Number.isFinite(count) && count >= 0) {
                  sessionMessageCountHints.set(batchSessionId, Math.max(0, Math.floor(count)))
                }
              }
            }

            checkedCount = Math.min(precheckSessionIds.length, checkedCount + batchSessionIds.length)
            emitProgress({
              current: computeAggregateCurrent(),
              total: sessionIds.length,
              currentSession: '',
              currentSessionId: '',
              phase: 'preparing',
              phaseProgress: checkedCount,
              phaseTotal: precheckSessionIds.length,
              phaseLabel: `预检查空会话 ${checkedCount}/${precheckSessionIds.length}`
            })
          }
        } else {
          emitProgress({
            current: computeAggregateCurrent(),
            total: sessionIds.length,
            currentSession: '',
            currentSessionId: '',
            phase: 'preparing',
            phaseLabel: `会话较多，已跳过空会话预检查（${precheckSessionIds.length} 个）`
          })
        }
      }

      if (canUseSessionSnapshotHints && sessionIds.length > 0) {
        const missingHintSessionIds = sessionIds.filter((sessionId) => (
          !sessionMessageCountHints.has(sessionId) || !sessionLatestTimestampHints.has(sessionId)
        ))
        if (missingHintSessionIds.length > 0) {
          const sessionSet = new Set(missingHintSessionIds)
          const sessionsResult = await chatService.getSessions()
          if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) {
            for (const item of sessionsResult.sessions) {
              const username = String(item?.username || '').trim()
              if (!username) continue
              if (!sessionSet.has(username)) continue
              const messageCountHint = Number(item?.messageCountHint)
              if (
                !sessionMessageCountHints.has(username) &&
                Number.isFinite(messageCountHint) &&
                messageCountHint >= 0
              ) {
                sessionMessageCountHints.set(username, Math.floor(messageCountHint))
              }
              const lastTimestamp = Number(item?.lastTimestamp)
              if (
                !sessionLatestTimestampHints.has(username) &&
                Number.isFinite(lastTimestamp) &&
                lastTimestamp > 0
              ) {
                sessionLatestTimestampHints.set(username, Math.floor(lastTimestamp))
              }
            }
          }
        }
      }

      if (stopRequested) {
        return {
          success: true,
          successCount,
          failCount,
          stopped: true,
          pendingSessionIds: [...queue],
          successSessionIds,
          failedSessionIds,
          failedSessionErrors,
          sessionOutputPaths
        }
      }
      if (pauseRequested) {
        return {
          success: true,
          successCount,
          failCount,
          paused: true,
          pendingSessionIds: [...queue],
          successSessionIds,
          failedSessionIds,
          failedSessionErrors,
          sessionOutputPaths
        }
      }

      const runOne = async (sessionId: string): Promise<'done' | 'stopped' | 'paused'> => {
        try {
          this.throwIfStopRequested(control)
          const sessionInfo = await this.getContactInfo(sessionId)
          const messageCountHint = sessionMessageCountHints.get(sessionId)
          const latestTimestampHint = sessionLatestTimestampHints.get(sessionId)

          const sessionProgress = (progress: ExportProgress) => {
            const phaseTotal = Number.isFinite(progress.total) && progress.total > 0 ? progress.total : 100
            const phaseCurrent = Number.isFinite(progress.current) ? progress.current : 0
            const ratio = progress.phase === 'complete'
              ? 1
              : Math.max(0, Math.min(1, phaseCurrent / phaseTotal))
            activeSessionRatios.set(sessionId, ratio)
            emitProgress({
              ...progress,
              current: computeAggregateCurrent(),
              total: sessionIds.length,
              currentSession: sessionInfo.displayName,
              currentSessionId: sessionId
            }, { force: progress.phase === 'complete' })
          }

          sessionProgress({
            current: 0,
            total: 100,
            currentSession: sessionInfo.displayName,
            phase: 'preparing',
            phaseLabel: '准备导出'
          })

          const fileNamingMode = this.normalizeFileNamingMode(effectiveOptions.fileNamingMode)
          const safeName = this.buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions)
          const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false
          const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : ''
          const fileNameWithPrefix = `${sessionTypePrefix}${safeName}`
          const useSessionFolder = sessionLayout === 'per-session'
          const sessionDirName = sessionNameWithTypePrefix ? `${sessionTypePrefix}${safeName}` : safeName
          const sessionDir = useSessionFolder ? path.join(exportBaseDir, sessionDirName) : exportBaseDir

          if (useSessionFolder) {
            await ensureTaskDir(sessionDir)
          }

          let ext = '.json'
          if (effectiveOptions.format === 'chatlab-jsonl') ext = '.jsonl'
          else if (effectiveOptions.format === 'excel') ext = '.xlsx'
          else if (effectiveOptions.format === 'txt') ext = '.txt'
          else if (effectiveOptions.format === 'weclone') ext = '.csv'
          else if (effectiveOptions.format === 'html') ext = '.html'
          const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
          const canTrySkipUnchanged = canTrySkipUnchangedTextSessions &&
            typeof messageCountHint === 'number' &&
            messageCountHint >= 0 &&
            typeof latestTimestampHint === 'number' &&
            latestTimestampHint > 0 &&
            await this.pathExists(preferredOutputPath)
          if (canTrySkipUnchanged) {
            const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format)
            const hasNoDataChange = Boolean(
              latestRecord &&
              latestRecord.messageCount === messageCountHint &&
              Number(latestRecord.sourceLatestMessageTimestamp || 0) >= latestTimestampHint
            )
            if (hasNoDataChange) {
              successCount++
              successSessionIds.push(sessionId)
              sessionOutputPaths[sessionId] = preferredOutputPath
              activeSessionRatios.delete(sessionId)
              completedCount++
              emitProgress({
                current: computeAggregateCurrent(),
                total: sessionIds.length,
                currentSession: sessionInfo.displayName,
                currentSessionId: sessionId,
                phase: 'complete',
                phaseLabel: '无变化，已跳过',
                estimatedTotalMessages: Math.max(0, Math.floor(messageCountHint || 0)),
                exportedMessages: Math.max(0, Math.floor(messageCountHint || 0))
              }, { force: true })
              return 'done'
            }
          }

          const outputPath = fileNamingMode === 'date-range'
            ? await this.reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths)
            : preferredOutputPath

          let result: { success: boolean; error?: string }
          if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
            result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control)
          } else if (effectiveOptions.format === 'chatlab' || effectiveOptions.format === 'chatlab-jsonl') {
            result = await this.exportSessionToChatLab(sessionId, outputPath, effectiveOptions, sessionProgress, control)
          } else if (effectiveOptions.format === 'excel') {
            result = await this.exportSessionToExcel(sessionId, outputPath, effectiveOptions, sessionProgress, control)
          } else if (effectiveOptions.format === 'txt') {
            result = await this.exportSessionToTxt(sessionId, outputPath, effectiveOptions, sessionProgress, control)
          } else if (effectiveOptions.format === 'weclone') {
            result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress, control)
          } else if (effectiveOptions.format === 'html') {
            result = await this.exportSessionToHtml(sessionId, outputPath, effectiveOptions, sessionProgress, control)
          } else {
            result = { success: false, error: `不支持的格式: ${effectiveOptions.format}` }
          }

          if (!result.success && this.isStopError(result.error)) {
            activeSessionRatios.delete(sessionId)
            return 'stopped'
          }
          if (!result.success && this.isPauseError(result.error)) {
            activeSessionRatios.delete(sessionId)
            return 'paused'
          }

          if (result.success) {
            successCount++
            successSessionIds.push(sessionId)
            sessionOutputPaths[sessionId] = outputPath
            if (typeof messageCountHint === 'number' && messageCountHint >= 0) {
              exportRecordService.saveRecord(sessionId, effectiveOptions.format, messageCountHint, {
                sourceLatestMessageTimestamp: typeof latestTimestampHint === 'number' && latestTimestampHint > 0
                  ? latestTimestampHint
                  : undefined,
                outputPath
              })
            }
          } else {
            failCount++
            failedSessionIds.push(sessionId)
            failedSessionErrors[sessionId] = result.error || '导出失败'
            console.error(`导出 ${sessionId} 失败:`, result.error)
          }

          activeSessionRatios.delete(sessionId)
          completedCount++
          emitProgress({
            current: computeAggregateCurrent(),
            total: sessionIds.length,
            currentSession: sessionInfo.displayName,
            currentSessionId: sessionId,
            phase: 'complete',
            phaseLabel: result.success ? '完成' : '导出失败'
          }, { force: true })
          return 'done'
        } catch (error) {
          if (this.isStopError(error)) {
            activeSessionRatios.delete(sessionId)
            return 'stopped'
          }
          if (this.isPauseError(error)) {
            activeSessionRatios.delete(sessionId)
            return 'paused'
          }
          throw error
        }
      }

      if (isTextContentBatchExport) {
        // 文本内容批量导出使用串行调度，降低数据库与文件系统抢占，行为更贴近 wxdaochu。
        while (queue.length > 0) {
          if (control?.shouldStop?.()) {
            stopRequested = true
            break
          }
          if (control?.shouldPause?.()) {
            pauseRequested = true
            break
          }

          const sessionId = queue.shift()
          if (!sessionId) break
          const runState = await runOne(sessionId)
          await new Promise(resolve => setImmediate(resolve))
          if (runState === 'stopped') {
            stopRequested = true
            queue.unshift(sessionId)
            break
          }
          if (runState === 'paused') {
            pauseRequested = true
            queue.unshift(sessionId)
            break
          }
        }
      } else {
        const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => {
          while (queue.length > 0) {
            if (control?.shouldStop?.()) {
              stopRequested = true
              break
            }
            if (control?.shouldPause?.()) {
              pauseRequested = true
              break
            }

            const sessionId = queue.shift()
            if (!sessionId) break
            const runState = await runOne(sessionId)
            if (runState === 'stopped') {
              stopRequested = true
              queue.unshift(sessionId)
              break
            }
            if (runState === 'paused') {
              pauseRequested = true
              queue.unshift(sessionId)
              break
            }
          }
        })
        await Promise.all(workers)
      }

      const pendingSessionIds = [...queue]
      if (stopRequested && pendingSessionIds.length > 0) {
        return {
          success: true,
          successCount,
          failCount,
          stopped: true,
          pendingSessionIds,
          successSessionIds,
          failedSessionIds,
          failedSessionErrors,
          sessionOutputPaths
        }
      }
      if (pauseRequested) {
        return {
          success: true,
          successCount,
          failCount,
          paused: true,
          pendingSessionIds,
          successSessionIds,
          failedSessionIds,
          failedSessionErrors,
          sessionOutputPaths
        }
      }

      emitProgress({
        current: sessionIds.length,
        total: sessionIds.length,
        currentSession: '',
        currentSessionId: '',
        phase: 'complete'
      }, { force: true })
      progressEmitter.flush()

      const allFailed = successCount === 0 && failCount > 0
      const failureSummary = allFailed
        ? Object.values(failedSessionErrors).slice(0, 3).join('；') || '所有会话导出失败'
        : undefined
      return {
        success: !allFailed,
        successCount,
        failCount,
        successSessionIds,
        failedSessionIds,
        failedSessionErrors,
        sessionOutputPaths,
        error: failureSummary
      }
    } catch (e) {
      progressEmitter.flush()
      return { success: false, successCount, failCount, error: String(e) }
    } finally {
      this.clearMediaRuntimeState()
    }
  }
}

export const exportService = new ExportService()
