/**
 * DingTalk adapter — Stream Mode robot WebSocket via dingtalk-stream.
 *
 * Supports:
 * - inbound text, richText, picture, audio, video, file messages
 * - outbound text, markdown, image, file messages
 * - media upload/download via DingTalk OAPI
 * - permission confirmation through plain text replies
 */

import { ChannelAdapter } from '../base'
import { registerAdapter } from '../registry'
import type { ChannelType, IncomingMessage, OutboundMessage, ImPermissionRequest, DingTalkCardInstance, DingTalkCardTarget } from '../../types'
import type { PlatformContext } from '@/lib/types'
import type { DeliveryLayer } from '../../core/delivery'
import { DingTalkStreamRenderer } from './stream-renderer'
import type { StreamRenderContext } from '../stream-renderer'
import {
  createAICardForTarget,
  streamAICard,
  finishAICard,
  type AICardInstance,
  type AICardTarget,
} from './card'
import { DWClient, TOPIC_ROBOT, type DWClientDownStream } from 'dingtalk-stream'
import * as crypto from 'crypto'
import * as os from 'os'
import * as path from 'path'
import * as fs from 'fs'

type DingTalkRobotMessage = {
  conversationId?: string
  conversationType?: string
  chatbotUserId?: string
  msgId?: string
  senderNick?: string
  senderStaffId?: string
  senderId?: string
  sessionWebhook?: string
  robotCode?: string
  msgtype?: string
  text?: { content?: string; at?: { atDingtalkIds?: string[]; atMobiles?: string[] } }
  /** JSON string or parsed object with type-specific fields (downloadCode, pictureUrl, etc.) */
  content?: unknown
  /** Top-level audio field for backward compat (some SDK versions) */
  audio?: { recognition?: string }
  /** Top-level file field for backward compat */
  file?: { fileName?: string }
}

/** Parsed content from a DingTalk media message */
interface DingTalkMediaContent {
  text: string
  messageType: string
  imageUrls: string[]
  downloadCodes: string[]
  fileNames: string[]
}

const DEFAULT_ENDPOINT = 'https://api.dingtalk.com'
const DINGTALK_OAPI = 'https://oapi.dingtalk.com'

export class DingtalkAdapter extends ChannelAdapter {
  readonly channelType: ChannelType = 'dingtalk'
  readonly supportsMessageEditing = false
  readonly supportsCardStreaming = true

  private static readonly CONNECTION_DEAD_MS = 5 * 60 * 1000 // 5 minutes — truly dead, not just idle

  private client: DWClient | null = null
  private running = false
  private clientId = ''
  private clientSecret = ''
  private endpoint = DEFAULT_ENDPOINT
  private chatbotUserId = ''

  private tokenCache: { token: string; expiryMs: number } | null = null
  private oapiTokenCache: { token: string; expiryMs: number } | null = null
  private sessionWebhookByChat = new Map<string, string>()
  private messageQueue: IncomingMessage[] = []
  private messageWaiter: ((msg: IncomingMessage | null) => void) | null = null
  private permissionCallback: ((requestId: string, decision: 'allow' | 'deny') => void) | null = null
  private pendingPermByChat = new Map<string, { requestIdPrefix: string; senderId: string }>()
  private seenMessageIds = new Set<string>()
  private seenMessageIdTimer: ReturnType<typeof setTimeout> | null = null

  private lastEventTime = 0

  /** Pre-created AI Cards for queue-busy ACK (chatId → card instance) */
  private preCreatedCards = new Map<string, DingTalkCardInstance>()

  async start(config: Record<string, string>): Promise<void> {
    const clientId = (config.client_id || config.clientId || '').trim()
    const clientSecret = (config.client_secret || config.clientSecret || '').trim()
    if (!clientId || !clientSecret) {
      throw new Error('DingTalk client_id and client_secret are required')
    }

    await this.stop()

    this.clientId = clientId
    this.clientSecret = clientSecret
    this.endpoint = (config.endpoint || DEFAULT_ENDPOINT).trim().replace(/\/$/, '') || DEFAULT_ENDPOINT
    this.chatbotUserId = (config.chatbot_user_id || config.chatbotUserId || '').trim()

    this.client = new DWClient({
      clientId,
      clientSecret,
      keepAlive: true,
      debug: config.debug === 'true',
      endpoint: this.endpoint,
      // autoReconnect: false — BridgeManager owns reconnection lifecycle.
      // SDK's silent autoReconnect doesn't update adapter running/lastEventTime,
      // causing state desync and zombie sockets on stop().
      autoReconnect: false,
    } as unknown as ConstructorParameters<typeof DWClient>[0])

    this.client.registerCallbackListener(TOPIC_ROBOT, (res: DWClientDownStream) => {
      this.handleRobotCallback(res).catch((err) => {
        console.error('[DingTalk] callback handling error:', err instanceof Error ? err.message : err)
      })
    })

    // Track ALL inbound WS frames (SYSTEM KEEPALIVE/ping included) so the
    // consumeOne health check sees real connection activity, not just user
    // messages. Without this, 5 min of idle → false-positive dead connection.
    const originalOnDownStream = this.client.onDownStream.bind(this.client)
    this.client.onDownStream = (data: string) => {
      this.lastEventTime = Date.now()
      originalOnDownStream(data)
    }

    await this.client.connect()
    this.running = true
    this.lastEventTime = Date.now()
    console.log('[DingTalk] Connected via Stream Mode (autoReconnect disabled, lastEventTime tracks all WS frames)')
  }

  async stop(): Promise<void> {
    this.running = false
    if (this.client) {
      try { this.client.disconnect() } catch { /* ignore */ }
      this.client = null
    }
    if (this.messageWaiter) {
      this.messageWaiter(null)
      this.messageWaiter = null
    }
    this.messageQueue = []
    this.sessionWebhookByChat.clear()
    this.pendingPermByChat.clear()
    this.seenMessageIds.clear()
    this.preCreatedCards.clear()
    if (this.seenMessageIdTimer) {
      clearTimeout(this.seenMessageIdTimer)
      this.seenMessageIdTimer = null
    }
    this.tokenCache = null
    this.oapiTokenCache = null
    this.lastEventTime = 0
  }

  isRunning(): boolean {
    return this.running
  }

  async consumeOne(signal: AbortSignal): Promise<IncomingMessage | null> {
    if (this.messageQueue.length > 0) {
      return this.messageQueue.shift()!
    }
    if (signal.aborted) return null

    return new Promise<IncomingMessage | null>((resolve) => {
      let resolved = false

      const cleanup = () => {
        if (resolved) return
        resolved = true
        if (this.messageWaiter === waiter) this.messageWaiter = null
      }

      // Connection health check: 5 minutes of zero events means the
      // SDK Stream connection is likely dead (TCP drop without close frame).
      const healthCheck = setInterval(() => {
        const sinceLastEvent = Date.now() - this.lastEventTime
        if (this.lastEventTime > 0 && sinceLastEvent >= DingtalkAdapter.CONNECTION_DEAD_MS) {
          console.warn(`[DingTalk] No events for ${Math.round(sinceLastEvent / 1000)}s — connection likely dead`)
          this.running = false
          clearInterval(healthCheck)
          cleanup()
          resolve(null)
        }
      }, 60_000)

      const waiter = (msg: IncomingMessage | null) => {
        signal.removeEventListener('abort', onAbort)
        clearInterval(healthCheck)
        cleanup()
        resolve(msg)
      }

      const onAbort = () => {
        clearInterval(healthCheck)
        cleanup()
        resolve(null)
      }

      this.messageWaiter = waiter
      signal.addEventListener('abort', onAbort, { once: true })
    })
  }

  async send(msg: OutboundMessage): Promise<string | undefined> {
    if (msg.deleteMessageId || msg.editMessageId) {
      return undefined
    }
    if (!msg.text.trim()) return undefined

    const webhook = this.sessionWebhookByChat.get(msg.chatId)
    if (webhook) {
      await this.sendViaSessionWebhook(webhook, msg.text, msg.parseMode)
      return undefined
    }

    await this.sendViaRobotApi(msg.chatId, msg.text)
    return undefined
  }

  async sendImage(chatId: string, imageBuffer: Buffer, caption?: string): Promise<void> {
    try {
      const oapiToken = await this.getOapiAccessToken()
      const ext = '.png'
      const tmpFile = path.join(os.tmpdir(), `dingtalk-upload-${crypto.randomUUID()}${ext}`)
      try {
        fs.writeFileSync(tmpFile, imageBuffer)
        const mediaId = await this.uploadMedia(tmpFile, 'image', oapiToken)
        if (mediaId) {
          await this.sendMediaViaRobotApi(chatId, 'sampleImageMsg', JSON.stringify({ mediaId }))
          if (caption) await this.send({ chatId, text: caption })
          return
        }
      } finally {
        fs.promises.unlink(tmpFile).catch(() => {})
      }
    } catch (err) {
      console.warn('[DingTalk] sendImage failed:', err instanceof Error ? err.message : err)
    }
    // Fallback to text
    await this.send({ chatId, text: caption ? `[图片] ${caption}` : '[图片]' })
  }

  async sendFile(chatId: string, fileBuffer: Buffer, filename: string, caption?: string): Promise<void> {
    try {
      const oapiToken = await this.getOapiAccessToken()
      const tmpFile = path.join(os.tmpdir(), `dingtalk-upload-${crypto.randomUUID()}-${filename}`)
      try {
        fs.writeFileSync(tmpFile, fileBuffer)
        const ext = path.extname(filename).toLowerCase().replace('.', '') || 'file'
        const mediaId = await this.uploadMedia(tmpFile, 'file', oapiToken)
        if (mediaId) {
          await this.sendMediaViaRobotApi(chatId, 'sampleFileMsg', JSON.stringify({
            mediaId,
            fileName: filename,
            fileType: ext,
          }))
          if (caption) await this.send({ chatId, text: caption })
          return
        }
      } finally {
        fs.promises.unlink(tmpFile).catch(() => {})
      }
    } catch (err) {
      console.warn('[DingTalk] sendFile failed:', err instanceof Error ? err.message : err)
    }
    // Fallback to text
    await this.send({ chatId, text: `📎 ${filename}${caption ? ` - ${caption}` : ''}` })
  }

  async sendTypingIndicator(_chatId: string): Promise<void> {
    // DingTalk robot APIs do not expose a simple typing indicator.
  }

  async sendPermissionPrompt(req: ImPermissionRequest): Promise<void> {
    const inputSummary = Object.entries(req.toolInput)
      .map(([k, v]) => `${k}: ${String(v).slice(0, 80)}`)
      .join('\n')
    const prefix = req.requestId.slice(0, 8)

    this.pendingPermByChat.set(req.chatId, { requestIdPrefix: prefix, senderId: req.senderId })
    setTimeout(() => {
      const current = this.pendingPermByChat.get(req.chatId)
      if (current?.requestIdPrefix === prefix) {
        this.pendingPermByChat.delete(req.chatId)
      }
    }, 120_000)

    await this.send({
      chatId: req.chatId,
      text: `需要授权\n\nForge 想要使用 ${req.toolName}：\n${inputSummary}\n\n回复"允许"或"拒绝"，也可以回复 /perm ${prefix} allow 或 /perm ${prefix} deny`,
    })
  }

  onPermissionResponse(callback: (requestId: string, decision: 'allow' | 'deny') => void): void {
    this.permissionCallback = callback
  }

  // ---------------------------------------------------------------------------
  // Credentials access
  // ---------------------------------------------------------------------------

  /**
   * Return the DingTalk app credentials used by this adapter instance.
   */
  getCredentials(): { clientId: string; clientSecret: string } {
    return { clientId: this.clientId, clientSecret: this.clientSecret }
  }

  /**
   * Return DingTalk app credentials for platformContext (was inline in bridge-manager).
   */
  async ensureChannelAuth(_msg: IncomingMessage): Promise<Partial<PlatformContext> | undefined> {
    const creds = this.getCredentials()
    if (creds.clientId && creds.clientSecret) {
      return { dingtalk: { clientId: creds.clientId, clientSecret: creds.clientSecret } }
    }
    return undefined
  }

  /** 钉钉 AI Card 流式渲染（C-2）。 */
  createStreamRenderer(ctx: StreamRenderContext) {
    return new DingTalkStreamRenderer(ctx)
  }

  // ---------------------------------------------------------------------------
  // Platform capability hooks (C-3)
  // ---------------------------------------------------------------------------

  /** queue-busy 预建 AI Card（对应原 bridge 行 476-485）。 */
  async preCreateBusyCard(chatId: string, ackText: string): Promise<DingTalkCardInstance | undefined> {
    return (await this.preCreateCardWithAck(chatId, ackText)) ?? undefined
  }

  /**
   * 平台特化最终投递：用 AI Card 投递最终文本（对应原 bridge 行 762-773）。
   * 失败返回 false → bridge 走默认文本。
   */
  async deliverPlatformFinal(chatId: string, text: string, _delivery: DeliveryLayer): Promise<boolean> {
    try {
      const card = await this.createCard(chatId)
      if (card) {
        await this.finishCard(card, text)
        return true
      }
      return false
    } catch (err) {
      console.warn('[DingTalk] deliverPlatformFinal card failed, fallback to text:', err instanceof Error ? err.message : err)
      return false
    }
  }

  // ---------------------------------------------------------------------------
  // Config validation
  // ---------------------------------------------------------------------------

  validateConfig(config: Record<string, string>): { valid: boolean; error?: string } {
    const clientId = (config.client_id || config.clientId || '').trim()
    const clientSecret = (config.client_secret || config.clientSecret || '').trim()
    if (!clientId) return { valid: false, error: 'DingTalk client_id is required' }
    if (!clientSecret) return { valid: false, error: 'DingTalk client_secret is required' }
    if (clientId.length < 6 || clientSecret.length < 6) {
      return { valid: false, error: 'DingTalk credentials look too short' }
    }
    return { valid: true }
  }

  // ---------------------------------------------------------------------------
  // DingTalk AI Card lifecycle
  // ---------------------------------------------------------------------------

  /** Parse chatId to DingTalk AI Card target */
  private parseCardTarget(chatId: string): DingTalkCardTarget {
    if (chatId.startsWith('dm:')) {
      return { type: 'user', userId: chatId.replace('dm:', '') }
    }
    return { type: 'group', openConversationId: chatId.replace('group:', '') }
  }

  /** Convert internal AICardInstance to public DingTalkCardInstance */
  private toPublicCardInstance(card: AICardInstance): DingTalkCardInstance {
    return {
      cardInstanceId: card.cardInstanceId,
      accessToken: card.accessToken,
      tokenExpireTime: card.tokenExpireTime,
      inputingStarted: card.inputingStarted,
    }
  }

  /**
   * Create an AI Card for the target chat.
   * If a pre-created card exists (from queue-busy ACK), reuse it.
   */
  async createCard(chatId: string): Promise<DingTalkCardInstance | null> {
    // Check for pre-created card from queue-busy ACK
    const preCreated = this.preCreatedCards.get(chatId)
    if (preCreated) {
      this.preCreatedCards.delete(chatId)
      console.log(`[DingTalk] Reusing pre-created card: ${preCreated.cardInstanceId}`)
      return preCreated
    }

    try {
      const token = await this.getAccessToken()
      const target = this.parseCardTarget(chatId)
      const card = await createAICardForTarget(token, this.clientId, this.endpoint, target as AICardTarget)
      if (card) {
        return this.toPublicCardInstance(card)
      }
      return null
    } catch (err) {
      console.warn('[DingTalk] createCard failed:', err instanceof Error ? err.message : err)
      return null
    }
  }

  /** Stream content update to an existing AI Card (typewriter effect). */
  async updateCard(card: DingTalkCardInstance, content: string, finished: boolean): Promise<void> {
    try {
      const token = await this.getAccessToken()
      await streamAICard(card as AICardInstance, content, finished, token, this.endpoint)
    } catch (err) {
      console.warn('[DingTalk] updateCard failed:', err instanceof Error ? err.message : err)
      throw err
    }
  }

  /** Finish an AI Card: send final content + switch to FINISHED status. */
  async finishCard(card: DingTalkCardInstance, content: string): Promise<void> {
    try {
      const token = await this.getAccessToken()
      await finishAICard(card as AICardInstance, content, token, this.endpoint)
    } catch (err) {
      console.warn('[DingTalk] finishCard failed:', err instanceof Error ? err.message : err)
      throw err
    }
  }

  /**
   * Pre-create an AI Card with ACK text for queue-busy scenario.
   * The card will be reused by createCard() when the message is actually processed.
   */
  async preCreateCardWithAck(chatId: string, ackText: string): Promise<DingTalkCardInstance | null> {
    try {
      const card = await this.createCard(chatId)
      if (card) {
        await this.updateCard(card, ackText, false)
        this.preCreatedCards.set(chatId, card)
        // Auto-cleanup after 5 minutes
        setTimeout(() => this.preCreatedCards.delete(chatId), 300_000)
      }
      return card
    } catch (err) {
      console.warn('[DingTalk] preCreateCardWithAck failed:', err instanceof Error ? err.message : err)
      return null
    }
  }

  private async handleRobotCallback(res: DWClientDownStream): Promise<void> {
    const messageId = res.headers?.messageId
    if (messageId) {
      try { this.client?.socketCallBackResponse(messageId, { success: true }) } catch { /* ignore */ }
    }

    this.lastEventTime = Date.now()

    let data: DingTalkRobotMessage
    try {
      data = JSON.parse(res.data || '{}') as DingTalkRobotMessage
    } catch {
      console.warn('[DingTalk] Failed to parse robot callback payload')
      return
    }

    const dedupeId = data.msgId || messageId
    if (dedupeId && this.isDuplicate(dedupeId)) return

    // Log message type for media debugging
    if (data.msgtype !== 'text') {
      console.log(`[DingTalk] Inbound msgtype=${data.msgtype}, content=${typeof data.content === 'string' ? data.content?.slice(0, 200) : JSON.stringify(data.content)?.slice(0, 200)}`)
    }

    const incoming = this.toIncomingMessage(data, messageId)
    if (!incoming) {
      console.log(`[DingTalk] toIncomingMessage returned null for msgtype=${data.msgtype}`)
      return
    }

    const isMediaMessage = ['picture', 'audio', 'video', 'file', 'richText'].includes(data.msgtype || '')

    // Download media attachments if this is a media message type
    if (isMediaMessage) {
      await this.processInboundMedia(data, incoming)
      console.log(`[DingTalk] Media processed: images=${incoming.images?.length || 0}, files=${incoming.files?.length || 0}`)
    }

    if (isMediaMessage && !incoming.text.trim() && !incoming.images?.length && !incoming.files?.length) {
      await this.send({
        chatId: incoming.chatId,
        text: '未能下载钉钉媒体文件，请确认机器人具备“企业内机器人发送消息权限”，或稍后重新发送该图片/文件。',
      })
      return
    }

    if (data.sessionWebhook) {
      this.sessionWebhookByChat.set(incoming.chatId, data.sessionWebhook)
    }

    if (incoming.text.startsWith('/perm ') && this.handlePermCommand(incoming.text, incoming.chatId, incoming.senderId)) {
      return
    }
    if (this.handleSimplePermResponse(incoming.text, incoming.chatId, incoming.senderId)) {
      return
    }

    this.enqueueMessage(incoming)
  }

  private toIncomingMessage(data: DingTalkRobotMessage, fallbackMessageId?: string): IncomingMessage | null {
    const isDm = data.conversationType === '1'
    const senderId = String(data.senderStaffId || data.senderId || '').trim()
    if (!senderId) return null

    const conversationId = String(data.conversationId || '').trim()
    const chatId = isDm ? `dm:${senderId}` : `group:${conversationId}`
    if (!isDm && !conversationId) return null

    const text = this.extractText(data).trim()
    // Media messages (picture, audio, video, file) may have empty text — still process
    const isMediaType = ['picture', 'audio', 'video', 'file', 'richText'].includes(data.msgtype || '')
    if (!text && !isMediaType) return null

    const botUserId = this.chatbotUserId || data.chatbotUserId || ''
    const atIds = data.text?.at?.atDingtalkIds || []
    const isGroupMention = !isDm && (
      !botUserId ||
      atIds.includes(botUserId) ||
      text.includes('@') ||
      text.includes('＠')
    )

    return {
      channelType: 'dingtalk',
      channelId: 'dingtalk',
      chatId,
      messageId: data.msgId || fallbackMessageId,
      senderId,
      senderName: data.senderNick || 'User',
      text,
      isDm,
      isGroupMention,
      rawEvent: data,
    }
  }

  private extractText(data: DingTalkRobotMessage): string {
    if (data.msgtype === 'text') {
      return data.text?.content || ''
    }

    const content = this.resolveContent(data)

    switch (data.msgtype) {
      case 'richText': {
        if (content && typeof content === 'object') {
          const obj = content as Record<string, unknown>
          const richList = obj.richTextList || obj.richText
          if (Array.isArray(richList)) {
            return richList
              .filter((item: Record<string, unknown>) => item.text && item.type !== 'skill' && !item.skillData)
              .map((item: Record<string, unknown>) => String(item.text))
              .join('')
          }
          if (typeof obj.text === 'string') return obj.text
        }
        return '[富文本消息]'
      }
      case 'picture':
        return ''
      case 'audio': {
        // DingTalk provides server-side ASR recognition text
        if (content && typeof content === 'object') {
          const obj = content as Record<string, unknown>
          const recognition = obj.recognition || data.audio?.recognition
          if (typeof recognition === 'string' && recognition.trim()) return recognition
        }
        return '[语音消息]'
      }
      case 'video':
        return ''
      case 'file': {
        if (content && typeof content === 'object') {
          const obj = content as Record<string, unknown>
          const fileName = obj.fileName || data.file?.fileName
          if (fileName) return `[文件: ${fileName}]`
        }
        return '[文件]'
      }
      default:
        if (content && typeof content === 'object') {
          const obj = content as Record<string, unknown>
          if (typeof obj.text === 'string') return obj.text
          if (typeof obj.title === 'string') return obj.title
        }
        return data.msgtype ? `[${data.msgtype}消息]` : ''
    }
  }

  private tryParseJson(text: string): unknown {
    try { return JSON.parse(text) } catch { return null }
  }

  private enqueueMessage(msg: IncomingMessage): void {
    if (this.messageWaiter) {
      const waiter = this.messageWaiter
      this.messageWaiter = null
      waiter(msg)
    } else {
      this.messageQueue.push(msg)
    }
  }

  private isDuplicate(messageId: string): boolean {
    if (this.seenMessageIds.has(messageId)) return true
    this.seenMessageIds.add(messageId)
    if (!this.seenMessageIdTimer) {
      this.seenMessageIdTimer = setTimeout(() => {
        this.seenMessageIds.clear()
        this.seenMessageIdTimer = null
      }, 300_000)
    }
    return false
  }

  private handlePermCommand(text: string, chatId: string, senderId: string): boolean {
    const parts = text.trim().split(/\s+/)
    if (parts.length < 3) return false

    let requestIdPrefix = ''
    let decision: 'allow' | 'deny' | null = null
    if (parts[1] === 'allow' || parts[1] === 'deny') {
      decision = parts[1]
      requestIdPrefix = parts[2]
    } else if (parts[2] === 'allow' || parts[2] === 'deny') {
      requestIdPrefix = parts[1]
      decision = parts[2]
    }
    if (!decision || !requestIdPrefix) return false

    const pending = this.pendingPermByChat.get(chatId)
    if (pending && pending.senderId !== senderId) {
      console.log(`[DingTalk] Permission command from wrong user: expected=${pending.senderId}, got=${senderId}`)
      return true
    }

    this.pendingPermByChat.delete(chatId)
    this.permissionCallback?.(requestIdPrefix, decision)
    return true
  }

  private handleSimplePermResponse(text: string, chatId: string, senderId: string): boolean {
    const pending = this.pendingPermByChat.get(chatId)
    if (!pending) return false
    if (pending.senderId !== senderId) {
      console.log(`[DingTalk] Permission response from wrong user: expected=${pending.senderId}, got=${senderId}`)
      return false
    }

    const normalized = text.trim().toLowerCase()
    const allowWords = ['allow', 'y', 'yes', 'ok', '允许', '好', '好的', '是', '可以', '同意']
    const denyWords = ['deny', 'n', 'no', '拒绝', '不', '不行', '否', '取消']

    let decision: 'allow' | 'deny' | null = null
    if (allowWords.includes(normalized)) decision = 'allow'
    if (denyWords.includes(normalized)) decision = 'deny'
    if (!decision) return false

    this.pendingPermByChat.delete(chatId)
    this.permissionCallback?.(pending.requestIdPrefix, decision)
    return true
  }

  private async sendViaSessionWebhook(webhook: string, text: string, parseMode?: 'markdown' | 'plain'): Promise<void> {
    const token = await this.getAccessToken()
    const isMarkdown = parseMode === 'markdown' || /[#*>`[\]\n]/.test(text)
    const body = isMarkdown
      ? { msgtype: 'markdown', markdown: { title: 'Forge', text } }
      : { msgtype: 'text', text: { content: text } }

    await this.dingtalkFetch(webhook, body, token)
  }

  private async sendViaRobotApi(chatId: string, text: string): Promise<void> {
    const token = await this.getAccessToken()
    const isDm = chatId.startsWith('dm:')
    const targetId = chatId.replace(/^(dm|group):/, '')
    const url = isDm
      ? `${this.endpoint}/v1.0/robot/oToMessages/batchSend`
      : `${this.endpoint}/v1.0/robot/groupMessages/send`
    const body: Record<string, unknown> = {
      robotCode: this.clientId,
      msgKey: 'sampleText',
      msgParam: JSON.stringify({ content: text }),
    }
    if (isDm) body.userIds = [targetId]
    else body.openConversationId = targetId

    await this.dingtalkFetch(url, body, token)
  }

  private async getAccessToken(): Promise<string> {
    const now = Date.now()
    if (this.tokenCache && this.tokenCache.expiryMs > now + 60_000) {
      return this.tokenCache.token
    }

    const res = await fetch(`${this.endpoint}/v1.0/oauth2/accessToken`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ appKey: this.clientId, appSecret: this.clientSecret }),
    })
    const data = await res.json().catch(() => ({})) as { accessToken?: string; expireIn?: number; message?: string }
    if (!res.ok || !data.accessToken) {
      throw new Error(`DingTalk access token failed: ${data.message || res.statusText}`)
    }
    this.tokenCache = {
      token: data.accessToken,
      expiryMs: now + Number(data.expireIn || 7200) * 1000,
    }
    return data.accessToken
  }

  private async dingtalkFetch(url: string, body: unknown, token: string): Promise<void> {
    const res = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-acs-dingtalk-access-token': token,
      },
      body: JSON.stringify(body),
    })
    if (!res.ok) {
      const text = await res.text().catch(() => '')
      throw new Error(`DingTalk send failed: ${res.status} ${text.slice(0, 300)}`)
    }
  }

  // ---------------------------------------------------------------------------
  // Media: OAPI token
  // ---------------------------------------------------------------------------

  private async getOapiAccessToken(): Promise<string> {
    const now = Date.now()
    if (this.oapiTokenCache && this.oapiTokenCache.expiryMs > now + 60_000) {
      return this.oapiTokenCache.token
    }

    const res = await fetch(`${DINGTALK_OAPI}/gettoken?appkey=${encodeURIComponent(this.clientId)}&appsecret=${encodeURIComponent(this.clientSecret)}`)
    const data = await res.json().catch(() => ({})) as { access_token?: string; errcode?: number; errmsg?: string }
    if (!res.ok || data.errcode !== 0 || !data.access_token) {
      throw new Error(`DingTalk OAPI token failed: ${data.errmsg || res.statusText}`)
    }
    this.oapiTokenCache = {
      token: data.access_token,
      expiryMs: now + 7200 * 1000,
    }
    return data.access_token
  }

  // ---------------------------------------------------------------------------
  // Media: Inbound — download media from DingTalk
  // ---------------------------------------------------------------------------

  /** Resolve the content object from a DingTalk message (handles both string and object forms) */
  private resolveContent(data: DingTalkRobotMessage): Record<string, unknown> | null {
    if (data.content && typeof data.content === 'object') return data.content as Record<string, unknown>
    if (data.content && typeof data.content === 'string') {
      return this.tryParseJson(data.content) as Record<string, unknown> | null
    }
    return null
  }

  /** Download an image from URL and return as Buffer */
  private async downloadImageByUrl(url: string): Promise<Buffer | null> {
    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(30_000) })
      if (!res.ok) return null
      return Buffer.from(await res.arrayBuffer())
    } catch (err) {
      console.warn('[DingTalk] Image download failed:', err instanceof Error ? err.message : err)
      return null
    }
  }

  /** Exchange a downloadCode for a download URL, then download the file */
  private async downloadMediaByCode(downloadCode: string): Promise<Buffer | null> {
    try {
      const token = await this.getAccessToken()
      const res = await fetch(`${this.endpoint}/v1.0/robot/messageFiles/download`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'x-acs-dingtalk-access-token': token,
        },
        body: JSON.stringify({
          downloadCode,
          robotCode: this.clientId,
        }),
        signal: AbortSignal.timeout(30_000),
      })
      const responseText = await res.text().catch(() => '')
      const data = responseText ? this.tryParseJson(responseText) as { downloadUrl?: string; code?: string; message?: string } | null : null
      if (!res.ok || !data?.downloadUrl) {
        console.warn('[DingTalk] downloadCode exchange failed:', JSON.stringify(data || { status: res.status, body: responseText.slice(0, 300) }))
        return null
      }
      return this.downloadImageByUrl(data.downloadUrl)
    } catch (err) {
      console.warn('[DingTalk] downloadMediaByCode failed:', err instanceof Error ? err.message : err)
      return null
    }
  }

  /** Exchange a downloadCode + fileName for a file download URL, then download */
  private async downloadFileByCode(downloadCode: string, fileName: string): Promise<Buffer | null> {
    try {
      const token = await this.getAccessToken()
      const res = await fetch(`${this.endpoint}/v1.0/robot/messageFiles/download`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'x-acs-dingtalk-access-token': token,
        },
        body: JSON.stringify({
          downloadCode,
          robotCode: this.clientId,
        }),
        signal: AbortSignal.timeout(30_000),
      })
      const responseText = await res.text().catch(() => '')
      const data = responseText ? this.tryParseJson(responseText) as { downloadUrl?: string; code?: string; message?: string } | null : null
      if (!res.ok || !data?.downloadUrl) {
        console.warn('[DingTalk] file downloadCode exchange failed:', JSON.stringify(data || { status: res.status, body: responseText.slice(0, 300), fileName }))
        return null
      }
      // Download the file from the resolved URL
      const fileRes = await fetch(data.downloadUrl, { signal: AbortSignal.timeout(60_000) })
      if (!fileRes.ok) return null
      return Buffer.from(await fileRes.arrayBuffer())
    } catch (err) {
      console.warn('[DingTalk] downloadFileByCode failed:', err instanceof Error ? err.message : err)
      return null
    }
  }

  /**
   * Process inbound media attachments for a message.
   * Mutates the incoming message to add images/files arrays.
   */
  private async processInboundMedia(data: DingTalkRobotMessage, incoming: IncomingMessage): Promise<void> {
    const content = this.resolveContent(data)
    if (!content) return

    const images: Array<{ data: string; mimeType: string; name?: string }> = []
    const files: Array<{ data: Buffer; name: string; mimeType: string; size: number }> = []

    const msgtype = data.msgtype || ''

    if (msgtype === 'picture') {
      const pictureUrl = String(content.pictureUrl || '')
      const downloadCode = String(content.downloadCode || content.pictureDownloadCode || '')
      console.log(`[DingTalk] picture: pictureUrl=${pictureUrl ? pictureUrl.slice(0, 80) + '...' : 'none'}, downloadCode=${downloadCode ? downloadCode.slice(0, 20) + '...' : 'none'}`)

      if (pictureUrl) {
        const buf = await this.downloadImageByUrl(pictureUrl)
        if (buf) images.push({ data: buf.toString('base64'), mimeType: 'image/jpeg', name: 'photo.jpg' })
      }
      if (downloadCode && !pictureUrl) {
        const buf = await this.downloadMediaByCode(downloadCode)
        if (buf) images.push({ data: buf.toString('base64'), mimeType: 'image/jpeg', name: 'photo.jpg' })
      }
    } else if (msgtype === 'audio') {
      // DingTalk already provides ASR text for many voice messages. When we
      // have recognized text, treat the voice as text-only; passing the raw
      // AMR/Opus file to the Agent distracts models that cannot decode audio.
      if (incoming.text.trim() && incoming.text.trim() !== '[语音消息]') return

      const downloadCode = String(content.downloadCode || '')
      const fileName = String(content.fileName || 'audio.amr')
      if (downloadCode) {
        const buf = await this.downloadFileByCode(downloadCode, fileName)
        if (buf) {
          const ext = path.extname(fileName).toLowerCase()
          const mimeType = ext === '.mp3' ? 'audio/mpeg' : ext === '.wav' ? 'audio/wav' : 'audio/amr'
          files.push({ data: buf, name: fileName, mimeType, size: buf.length })
        }
      }
    } else if (msgtype === 'video') {
      const downloadCode = String(content.downloadCode || '')
      const fileName = String(content.fileName || 'video.mp4')
      if (downloadCode) {
        const buf = await this.downloadFileByCode(downloadCode, fileName)
        if (buf) {
          files.push({ data: buf, name: fileName, mimeType: 'video/mp4', size: buf.length })
        }
      }
    } else if (msgtype === 'file') {
      const downloadCode = String(content.downloadCode || '')
      const fileName = String(content.fileName || data.file?.fileName || 'file')
      if (downloadCode) {
        const buf = await this.downloadFileByCode(downloadCode, fileName)
        if (buf) {
          const ext = path.extname(fileName).toLowerCase()
          const mimeType = this.guessMimeType(ext)
          files.push({ data: buf, name: fileName, mimeType, size: buf.length })
        }
      }
    } else if (msgtype === 'richText') {
      // richText can contain image items with pictureUrl or pictureDownloadCode
      const richList = content.richTextList || content.richText
      console.log(`[DingTalk] richText: richTextList=${Array.isArray(richList) ? `array[${richList.length}]` : JSON.stringify(richList)?.slice(0, 100) || 'none'}`)
      if (Array.isArray(richList)) {
        for (const item of richList) {
          const rec = item as Record<string, unknown>
          // Stream Mode uses pictureDownloadCode; webhook mode uses pictureUrl
          const itemDownloadCode = rec.downloadCode || rec.pictureDownloadCode
          if (itemDownloadCode) {
            const code = String(itemDownloadCode)
            console.log(`[DingTalk] richText item: downloadCode=${code.slice(0, 30)}...`)
            const buf = await this.downloadMediaByCode(code)
            if (buf) images.push({ data: buf.toString('base64'), mimeType: 'image/jpeg', name: 'photo.jpg' })
            else console.warn(`[DingTalk] richText downloadCode download failed`)
          } else if (rec.pictureUrl) {
            const url = String(rec.pictureUrl)
            console.log(`[DingTalk] richText item: pictureUrl=${url.slice(0, 60)}...`)
            const buf = await this.downloadImageByUrl(url)
            if (buf) images.push({ data: buf.toString('base64'), mimeType: 'image/jpeg', name: 'photo.jpg' })
            else console.warn(`[DingTalk] richText pictureUrl download failed`)
          }
        }
      }
      // Also check for top-level downloadCode (some richText messages include them)
      const dc = String(content.downloadCode || content.pictureDownloadCode || '')
      if (dc) {
        const buf = await this.downloadMediaByCode(dc)
        if (buf) images.push({ data: buf.toString('base64'), mimeType: 'image/jpeg', name: 'photo.jpg' })
      }
    }

    if (images.length > 0) incoming.images = images
    if (files.length > 0) incoming.files = files
  }

  /** Guess MIME type from file extension */
  private guessMimeType(ext: string): string {
    const map: Record<string, string> = {
      '.pdf': 'application/pdf',
      '.doc': 'application/msword',
      '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
      '.xls': 'application/vnd.ms-excel',
      '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
      '.ppt': 'application/vnd.ms-powerpoint',
      '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
      '.zip': 'application/zip',
      '.txt': 'text/plain',
      '.md': 'text/markdown',
      '.json': 'application/json',
      '.csv': 'text/csv',
      '.mp4': 'video/mp4',
      '.mp3': 'audio/mpeg',
      '.wav': 'audio/wav',
      '.amr': 'audio/amr',
      '.png': 'image/png',
      '.jpg': 'image/jpeg',
      '.jpeg': 'image/jpeg',
      '.gif': 'image/gif',
      '.webp': 'image/webp',
    }
    return map[ext] || 'application/octet-stream'
  }

  // ---------------------------------------------------------------------------
  // Media: Outbound — upload and send media
  // ---------------------------------------------------------------------------

  /** Upload a local file to DingTalk OAPI and return the mediaId */
  private async uploadMedia(filePath: string, mediaType: 'image' | 'file' | 'video' | 'voice', oapiToken: string): Promise<string | null> {
    try {
      if (!fs.existsSync(filePath)) {
        console.warn(`[DingTalk] Upload file not found: ${filePath}`)
        return null
      }

      const stats = fs.statSync(filePath)
      const maxSize = mediaType === 'image' ? 10 * 1024 * 1024
        : mediaType === 'voice' ? 2 * 1024 * 1024
        : 20 * 1024 * 1024

      if (stats.size > maxSize) {
        console.warn(`[DingTalk] File too large: ${(stats.size / 1024 / 1024).toFixed(2)}MB (max ${maxSize / 1024 / 1024}MB for ${mediaType})`)
        return null
      }

      const ext = path.extname(filePath).toLowerCase()
      const contentType = mediaType === 'image'
        ? (ext === '.png' ? 'image/png' : 'image/jpeg')
        : mediaType === 'voice'
          ? (ext === '.mp3' ? 'audio/mpeg' : 'audio/amr')
          : 'application/octet-stream'

      const formData = new FormData()
      formData.append('media', new Blob([new Uint8Array(fs.readFileSync(filePath))], { type: contentType }), path.basename(filePath))

      const res = await fetch(`${DINGTALK_OAPI}/media/upload?access_token=${oapiToken}&type=${mediaType}`, {
        method: 'POST',
        body: formData,
        signal: AbortSignal.timeout(60_000),
      })

      const data = await res.json().catch(() => ({})) as { media_id?: string; errcode?: number; errmsg?: string }
      if (!res.ok || data.errcode !== 0 || !data.media_id) {
        console.warn(`[DingTalk] Upload failed: ${data.errmsg || res.statusText}`)
        return null
      }

      // Return mediaId with the @ prefix (DingTalk expects it for message sending)
      return data.media_id.startsWith('@') ? data.media_id : `@${data.media_id}`
    } catch (err) {
      console.warn('[DingTalk] uploadMedia failed:', err instanceof Error ? err.message : err)
      return null
    }
  }

  /** Send a media message via the Robot Proactive Message API */
  private async sendMediaViaRobotApi(chatId: string, msgKey: string, msgParam: string): Promise<void> {
    const token = await this.getAccessToken()
    const isDm = chatId.startsWith('dm:')
    const targetId = chatId.replace(/^(dm|group):/, '')
    const url = isDm
      ? `${this.endpoint}/v1.0/robot/oToMessages/batchSend`
      : `${this.endpoint}/v1.0/robot/groupMessages/send`

    const body: Record<string, unknown> = {
      robotCode: this.clientId,
      msgKey,
      msgParam,
    }
    if (isDm) body.userIds = [targetId]
    else body.openConversationId = targetId

    await this.dingtalkFetch(url, body, token)
  }
}

registerAdapter('dingtalk', DingtalkAdapter)
