/**
 * Feishu (Lark) adapter — uses official @larksuiteoapi/node-sdk WSClient.
 * SDK handles WebSocket connection, heartbeat, and reconnection internally.
 * All outbound connections, no public endpoint needed.
 */

import * as Lark from '@larksuiteoapi/node-sdk'
import { ChannelAdapter } from '../base'
import { registerAdapter } from '../registry'
import type { ChannelType, IncomingMessage, OutboundMessage, ImPermissionRequest } from '../../types'
import type { PlatformContext } from '@/lib/types'
import type { DeliveryLayer } from '../../core/delivery'
import { shouldUseCard } from './card-decision'
import { buildMarkdownCard } from './card-builder'
import { ensureFeishuUserAuth } from './auth'
import { FeishuStreamRenderer } from './stream-renderer'
import type { StreamRenderContext } from '../stream-renderer'
import {
  createCardEntity,
  sendCardByCardId,
  streamCardContent,
  updateCardKitCard,
  setCardStreamingMode,
} from './cardkit-api'
import { messageConverters } from './messages'

// ---------------------------------------------------------------------------
// Adapter implementation
// ---------------------------------------------------------------------------

/** Message types that always trigger bot response in group chats (media attachments are inherently directed at the bot). */
const IMPLICIT_MENTION_TYPES = new Set(['image', 'file', 'audio', 'video', 'sticker', 'media'])

export class FeishuAdapter extends ChannelAdapter {
  readonly channelType: ChannelType = 'feishu'

  private client: Lark.Client | null = null
  private wsClient: InstanceType<typeof Lark.WSClient> | null = null
  private running = false
  /** Guard against orphaned WSClient connections delivering events after stop() */
  private stopped = false
  private botOpenId = ''
  /** All known bot IDs (open_id, user_id, union_id, bot_id) for mention matching. */
  private botIds = new Set<string>()
  private platform: 'feishu' | 'lark' = 'feishu'
  private appId = ''
  private appSecret = ''

  // Internal message queue: SDK events fill, consumeOne drains
  private messageQueue: IncomingMessage[] = []
  private messageWaiter: ((msg: IncomingMessage | null) => void) | null = null

  // Connection health: only reconnect after genuinely long silence (5 min)
  private lastEventTime = 0
  private static readonly CONNECTION_DEAD_MS = 300_000  // 5 minutes — truly dead, not just idle

  // Permission response callback
  private permissionCallback: ((requestId: string, decision: 'allow' | 'deny') => void) | null = null

  // Track pending permission request per chat for simplified "allow"/"deny" responses (P30: also tracks senderId)
  private pendingPermByChat = new Map<string, { requestIdPrefix: string; senderId: string }>() // chatId → { requestIdPrefix, senderId }

  // Reaction ID cache for add/remove pairing: "messageId:emojiType" → reactionId
  private _reactionCache = new Map<string, string>()

  // Adapter-level dedup: skip events the SDK re-delivers (Feishu server retry)
  private seenMessageIds = new Set<string>()
  private seenMessageIdTimer: ReturnType<typeof setTimeout> | null = null

  // ---------------------------------------------------------------------------
  // Lifecycle
  // ---------------------------------------------------------------------------

  async start(config: Record<string, string>): Promise<void> {
    this.stopped = false  // Reset on new start

    if (!config.app_id || !config.app_secret) {
      throw new Error('Feishu app_id and app_secret are required')
    }

    this.appId = config.app_id
    this.appSecret = config.app_secret
    this.platform = (config.platform === 'lark') ? 'lark' : 'feishu'
    const domain = this.platform === 'lark' ? Lark.Domain.Lark : Lark.Domain.Feishu

    const sdkConfig = {
      appId: config.app_id,
      appSecret: config.app_secret,
      domain,
      appType: Lark.AppType.SelfBuild,
    }

    // Create REST client for sending messages
    this.client = new Lark.Client(sdkConfig)

    // Fetch bot info for @mention detection
    await this.fetchBotInfo()

    // Create event dispatcher
    const dispatcher = new Lark.EventDispatcher({}).register({
      'im.message.receive_v1': async (data) => {
        this.handleIncomingMessage(data)
      },
      // Acknowledge reaction events — we don't need to process them,
      // but registering avoids SDK warnings about unhandled events.
      'im.message.reaction.created_v1': async () => {},
      'im.message.reaction.deleted_v1': async () => {},
    })

    // Create and start WSClient
    // autoReconnect: false — we handle reconnection via BridgeManager.
    // SDK's autoReconnect has 120s+ delay and conflicts with BridgeManager reconnection.
    this.wsClient = new Lark.WSClient({
      ...sdkConfig,
      loggerLevel: Lark.LoggerLevel.info,
      autoReconnect: false,
    })
    await this.wsClient.start({ eventDispatcher: dispatcher })

    this.running = true
    this.lastEventTime = Date.now()
    console.log(`[Feishu] Connected via SDK WSClient (${this.platform}), botOpenId=${this.botOpenId || 'unknown'}, botIds=[${[...this.botIds].join(', ')}]`)
  }

  async stop(): Promise<void> {
    this.running = false
    this.stopped = true  // Prevent orphaned WSClient from delivering events

    // Close WebSocket connection using SDK's close() method
    if (this.wsClient) {
      try {
        this.wsClient.close({ force: true })
      } catch (err) {
        console.warn('[Feishu] WSClient close error:', err instanceof Error ? err.message : err)
      }
      this.wsClient = null
    }
    this.client = null

    // Drain waiters
    if (this.messageWaiter) {
      this.messageWaiter(null)
      this.messageWaiter = null
    }
    this.messageQueue = []

    // Clear bot identity state
    this.botOpenId = ''
    this.botIds.clear()
    this.pendingPermByChat.clear()
    this.lastEventTime = 0
    this.seenMessageIds.clear()
    if (this.seenMessageIdTimer) {
      clearTimeout(this.seenMessageIdTimer)
      this.seenMessageIdTimer = null
    }

    console.log('[Feishu] Stopped')
  }

  isRunning(): boolean {
    return this.running
  }

  // ---------------------------------------------------------------------------
  // Message consumption (pull model)
  // ---------------------------------------------------------------------------

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

    // Guard: if signal already aborted, return immediately to avoid dangling waiter
    if (signal.aborted) return null

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

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

      // Connection health check: only after 5 minutes of zero events.
      // This catches genuinely dead WebSocket connections (TCP drop without close frame).
      // Normal idle periods (user not chatting) do NOT trigger reconnection.
      const healthCheck = setInterval(() => {
        const sinceLastEvent = Date.now() - this.lastEventTime
        if (this.lastEventTime > 0 && sinceLastEvent >= FeishuAdapter.CONNECTION_DEAD_MS) {
          console.warn(`[Feishu] No events for ${Math.round(sinceLastEvent / 1000)}s — connection likely dead`)
          this.running = false
          clearInterval(healthCheck)
          cleanup()
          resolve(null)
        }
      }, 60_000)  // Check every 60 seconds

      // IMPORTANT: set messageWaiter BEFORE addEventListener to prevent dangling waiter
      this.messageWaiter = (msg) => {
        signal.removeEventListener('abort', onAbort)
        clearInterval(healthCheck)
        cleanup()
        resolve(msg)
      }

      const onAbort = () => {
        clearInterval(healthCheck)
        cleanup()
        resolve(null)
      }
      signal.addEventListener('abort', onAbort, { once: true })
    })
  }

  // ---------------------------------------------------------------------------
  // Outbound messaging
  // ---------------------------------------------------------------------------

  async send(msg: OutboundMessage): Promise<string | undefined> {
    if (!this.client) throw new Error('Feishu client not initialized')

    // Edit existing message
    if (msg.editMessageId) {
      try {
        await this.client.im.message.update({
          path: { message_id: msg.editMessageId },
          data: {
            msg_type: 'text',
            content: JSON.stringify({ text: msg.text }),
          },
        })
        return msg.editMessageId
      } catch {
        // Fall through to send new message if edit fails
      }
    }

    // Delete message
    if (msg.deleteMessageId) {
      try {
        await this.client.im.message.delete({
          path: { message_id: msg.deleteMessageId },
        })
      } catch { /* ignore */ }
      return undefined
    }

    // Send new message
    const result = await this.client.im.message.create({
      params: { receive_id_type: 'chat_id' },
      data: {
        receive_id: msg.chatId,
        msg_type: 'text',
        content: JSON.stringify({ text: msg.text }),
      },
    })

    return result.data?.message_id
  }

  async sendImage(chatId: string, imageBuffer: Buffer, caption?: string): Promise<void> {
    if (!this.client) {
      if (caption) await this.send({ chatId, text: `[Image] ${caption}` })
      return
    }

    try {
      // Upload image first
      const apiBase = this.platform === 'lark' ? 'https://open.larksuite.com/open-apis' : 'https://open.feishu.cn/open-apis'
      const tenantToken = await this.getTenantToken()

      const formData = new FormData()
      formData.append('image_type', 'message')
      formData.append('image', new Blob([new Uint8Array(imageBuffer)], { type: 'image/png' }), 'image.png')

      const uploadRes = await fetch(`${apiBase}/im/v1/images`, {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${tenantToken}` },
        body: formData,
      })
      const uploadData = await uploadRes.json() as { data?: { image_key?: string } }
      const imageKey = uploadData.data?.image_key

      if (imageKey) {
        await this.client.im.message.create({
          params: { receive_id_type: 'chat_id' },
          data: { receive_id: chatId, msg_type: 'image', content: JSON.stringify({ image_key: imageKey }) },
        })
        if (caption) await this.send({ chatId, text: caption })
      } else {
        if (caption) await this.send({ chatId, text: `[Image] ${caption}` })
      }
    } catch (err) {
      console.warn('[Feishu] sendImage failed:', err instanceof Error ? err.message : err)
      if (caption) await this.send({ chatId, text: `[Image] ${caption}` })
    }
  }

  async sendFile(chatId: string, fileBuffer: Buffer, filename: string, caption?: string): Promise<void> {
    if (!this.client) {
      if (caption) await this.send({ chatId, text: `📎 ${filename}${caption ? ` — ${caption}` : ''}` })
      return
    }

    try {
      const apiBase = this.platform === 'lark' ? 'https://open.larksuite.com/open-apis' : 'https://open.feishu.cn/open-apis'
      const tenantToken = await this.getTenantToken()

      const formData = new FormData()
      formData.append('file_type', 'stream')
      formData.append('file_name', filename)
      formData.append('file', new Blob([new Uint8Array(fileBuffer)]), filename)

      const uploadRes = await fetch(`${apiBase}/im/v1/files`, {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${tenantToken}` },
        body: formData,
      })
      const uploadData = await uploadRes.json() as { data?: { file_key?: string } }
      const fileKey = uploadData.data?.file_key

      if (fileKey) {
        await this.client.im.message.create({
          params: { receive_id_type: 'chat_id' },
          data: { receive_id: chatId, msg_type: 'file', content: JSON.stringify({ file_key: fileKey }) },
        })
        if (caption) await this.send({ chatId, text: caption })
      } else {
        await this.send({ chatId, text: `📎 ${filename}${caption ? ` — ${caption}` : ''}` })
      }
    } catch (err) {
      console.warn('[Feishu] sendFile failed:', err instanceof Error ? err.message : err)
      await this.send({ chatId, text: `📎 ${filename}${caption ? ` — ${caption}` : ''}` })
    }
  }

  async sendTypingIndicator(_chatId: string): Promise<void> {
    // Feishu doesn't have a typing indicator API
  }

  /** Fetch a tenant_access_token for direct Feishu Open API calls. */
  private async getTenantToken(signal?: AbortSignal): Promise<string> {
    const apiBase = this.platform === 'lark' ? 'https://open.larksuite.com/open-apis' : 'https://open.feishu.cn/open-apis'
    const tokenRes = await fetch(`${apiBase}/auth/v3/tenant_access_token/internal`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
      signal,
    })
    if (!tokenRes.ok) {
      throw new Error(`Tenant token request failed: ${tokenRes.status}`)
    }
    const tokenData = await tokenRes.json() as { code?: number; tenant_access_token?: string; msg?: string }
    if (tokenData.code !== 0 || !tokenData.tenant_access_token) {
      throw new Error(`Tenant token response error: code=${tokenData.code} ${tokenData.msg ?? ''}`)
    }
    return tokenData.tenant_access_token
  }

  // ---------------------------------------------------------------------------
  // Reaction API
  // ---------------------------------------------------------------------------

  /** Add a reaction (emoji) to a Feishu message. emojiType like 'THUMBSUP', 'OK', etc. */
  async addReaction(messageId: string, emojiType: string): Promise<void> {
    if (!this.client) return
    try {
      const response = await this.client.im.messageReaction.create({
        path: { message_id: messageId },
        data: {
          reaction_type: { emoji_type: emojiType },
        },
      })
      // Store reaction_id for later removal
      const reactionId = (response?.data as Record<string, unknown> | undefined)?.reaction_id as string | undefined
      if (reactionId) {
        this._reactionCache.set(`${messageId}:${emojiType}`, reactionId)
      }
    } catch (err) {
      console.warn('[Feishu] addReaction failed:', err instanceof Error ? err.message : err)
    }
  }

  /** Remove a reaction from a Feishu message. Uses cached reaction_id when available, falls back to emojiType. */
  async removeReaction(messageId: string, emojiType: string): Promise<void> {
    if (!this.client) return
    try {
      // Try to find cached reaction_id first
      const cacheKey = `${messageId}:${emojiType}`
      const reactionId = this._reactionCache.get(cacheKey)
      if (reactionId) {
        await this.client.im.messageReaction.delete({
          path: { message_id: messageId, reaction_id: reactionId },
        })
        this._reactionCache.delete(cacheKey)
      } else {
        // Fallback: list reactions and find the one from this bot with matching emoji
        const listRes = await this.client.im.messageReaction.list({
          path: { message_id: messageId },
          params: { page_size: 100 },
        })
        const items = (listRes?.data as Record<string, unknown> | undefined)?.items as Array<Record<string, unknown>> | undefined
        if (items) {
          for (const item of items) {
            const reactionType = item.reaction_type as Record<string, unknown> | undefined
            if (reactionType?.emoji_type === emojiType) {
              // Found matching reaction — delete it by id
              const rid = item.reaction_id as string
              if (rid) {
                await this.client.im.messageReaction.delete({
                  path: { message_id: messageId, reaction_id: rid },
                })
                break
              }
            }
          }
        }
      }
    } catch (err) {
      console.warn('[Feishu] removeReaction failed:', err instanceof Error ? err.message : err)
    }
  }

  // ---------------------------------------------------------------------------
  // Message forwarding
  // ---------------------------------------------------------------------------

  /** Forward a message to another chat. */
  async forwardMessage(toChatId: string, messageId: string): Promise<string | undefined> {
    if (!this.client) return undefined
    try {
      const result = await this.client.im.message.forward({
        path: { message_id: messageId },
        data: { receive_id: toChatId },
        params: { receive_id_type: 'chat_id' },
      })
      return (result?.data as Record<string, unknown>)?.message_id as string | undefined
    } catch (err) {
      console.warn('[Feishu] forwardMessage failed:', err instanceof Error ? err.message : err)
      return undefined
    }
  }

  // ---------------------------------------------------------------------------
  // CardKit v2 Card API delegation
  // ---------------------------------------------------------------------------

  /** Create a CardKit card entity; returns card_id or null on failure. */
  async cardKitCreateCardEntity(card: Record<string, unknown>): Promise<string | null> {
    if (!this.client) throw new Error('Feishu client not initialized')
    return createCardEntity({ client: this.client, card })
  }

  /** Send a card message referencing an existing card_id. */
  async cardKitSendCardByCardId(
    chatId: string,
    cardId: string,
  ): Promise<{ messageId: string; chatId: string } | undefined> {
    if (!this.client) throw new Error('Feishu client not initialized')
    return sendCardByCardId({ client: this.client, chatId, cardId })
  }

  /** Stream content to a card element (CardKit v2 streaming API). */
  async cardKitStreamCardContent(
    cardId: string,
    elementId: string,
    content: string,
    sequence: number,
  ): Promise<void> {
    if (!this.client) throw new Error('Feishu client not initialized')
    return streamCardContent({ client: this.client, cardId, elementId, content, sequence })
  }

  /** Replace the full card content (used for final "complete" state). */
  async cardKitUpdateCard(
    cardId: string,
    card: Record<string, unknown>,
    sequence: number,
  ): Promise<void> {
    if (!this.client) throw new Error('Feishu client not initialized')
    return updateCardKitCard({ client: this.client, cardId, card, sequence })
  }

  /** Toggle streaming mode on a CardKit card. */
  async cardKitSetStreamingMode(
    cardId: string,
    streamingMode: boolean,
    sequence: number,
  ): Promise<void> {
    if (!this.client) throw new Error('Feishu client not initialized')
    return setCardStreamingMode({ client: this.client, cardId, streamingMode, sequence })
  }

  /**
   * Return the Feishu app credentials used by this adapter instance.
   */
  getCredentials(): { appId: string; appSecret: string; platform: 'feishu' | 'lark' } {
    return { appId: this.appId, appSecret: this.appSecret, platform: this.platform }
  }

  /**
   * Ensure the Feishu user has a valid OAuth token; returns it for platformContext.
   * Owns the full OAuth lifecycle (was bridge-manager.ensureFeishuAuth).
   */
  async ensureChannelAuth(msg: IncomingMessage): Promise<Partial<PlatformContext> | undefined> {
    const feishu = await ensureFeishuUserAuth(this, msg)
    return feishu ? { feishu } : undefined
  }

  /** 飞书流式渲染（CardKit streaming / placeholder），永远返回 FeishuStreamRenderer（C-2）。 */
  createStreamRenderer(ctx: StreamRenderContext) {
    return new FeishuStreamRenderer(ctx)
  }

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

  /** Feishu 使用 reaction 作为 typing indicator（对应原 bridge 行 501/610）。 */
  supportsReactionIndicator(): boolean {
    return true
  }

  /**
   * 平台特化最终投递：表格/代码块用交互卡片（对应原 bridge 行 776-794）。
   * shouldUseCard=false 或 sendCard 失败返回 false → bridge 走默认文本。
   */
  async deliverPlatformFinal(chatId: string, text: string, _delivery: DeliveryLayer): Promise<boolean> {
    if (!shouldUseCard(text)) return false
    const card = buildMarkdownCard(text)
    try {
      const msgId = await this.sendCard({ chatId, text, card: card as unknown as Record<string, unknown> })
      if (msgId) {
        console.log(`[Feishu] static card sent via deliverPlatformFinal: msgId=${msgId}`)
        return true
      }
      return false
    } catch (err) {
      console.warn('[Feishu] deliverPlatformFinal static card failed, fallback to text:', err instanceof Error ? err.message : err)
      return false
    }
  }

  /**
   * Send an OAuth authorization card to a chat.
   * Displays the verification URI and user code for Device Flow.
   */
  async sendOAuthCard(chatId: string, verificationUrl: string, userCode: string): Promise<void> {
    const card = this.buildOAuthCard(verificationUrl, userCode)
    await this.sendCard({ chatId, text: '', card })
  }

  /**
   * Send an OAuth authorization card to a user's DM (private chat).
   * Uses open_id as receive_id so Feishu auto-creates/resolves the p2p chat.
   * This avoids leaking verification codes in group chats.
   */
  async sendOAuthCardToUser(openId: string, verificationUrl: string, userCode: string): Promise<void> {
    if (!this.client) throw new Error('Feishu client not initialized')
    const card = this.buildOAuthCard(verificationUrl, userCode)
    try {
      await this.client.im.message.create({
        params: { receive_id_type: 'open_id' },
        data: {
          receive_id: openId,
          msg_type: 'interactive',
          content: JSON.stringify(card),
        },
      })
    } catch (err) {
      // open_id → DM delivery failed; log and give up rather than
      // leaking verification codes into a group chat by using chat_id.
      console.warn('[Feishu] sendOAuthCardToUser failed:', err instanceof Error ? err.message : err)
    }
  }

  /** Shared OAuth card builder — used by both sendOAuthCard and sendOAuthCardToUser. */
  private buildOAuthCard(verificationUrl: string, userCode: string): Record<string, unknown> {
    return {
      config: { wide_screen_mode: true },
      header: {
        title: { tag: 'plain_text', content: '🔐 飞书授权' },
        template: 'blue',
      },
      elements: [
        {
          tag: 'markdown',
          content: `## 请完成飞书授权\n\n1. 打开飞书手机端扫码或访问以下链接\n2. 输入验证码 **${userCode}**\n3. 确认授权`,
        },
        {
          tag: 'action',
          actions: [
            {
              tag: 'button',
              text: { tag: 'plain_text', content: '📱 前往授权' },
              type: 'link',
              url: verificationUrl,
            },
          ],
        },
        {
          tag: 'note',
          elements: [{ tag: 'plain_text', content: `验证码: ${userCode}  |  有效期 4 分钟` }],
        },
      ],
    }
  }

  /**
   * Send an interactive card via IM PATCH API.
   * Fallback when CardKit v2 is unavailable.
   */
  async sendCard(msg: OutboundMessage): Promise<string | undefined> {
    if (!this.client) throw new Error('Feishu client not initialized')
    const cardJson = msg.card
    if (!cardJson) {
      // No card payload — fall back to plain text
      return this.send(msg)
    }

    try {
      const result = await this.client.im.message.create({
        params: { receive_id_type: 'chat_id' as const },
        data: {
          receive_id: msg.chatId,
          msg_type: 'interactive',
          content: JSON.stringify(cardJson),
        },
      })
      return result.data?.message_id
    } catch (err) {
      console.warn('[Feishu] sendCard failed, falling back to text:', err instanceof Error ? err.message : err)
      return this.send(msg)
    }
  }

  /**
   * Update an interactive card via IM PATCH API.
   * Used for editing an already-sent card message.
   */
  async updateCard(messageId: string, card: Record<string, unknown>): Promise<void> {
    if (!this.client) throw new Error('Feishu client not initialized')
    try {
      await this.client.im.message.update({
        path: { message_id: messageId },
        data: {
          msg_type: 'interactive',
          content: JSON.stringify(card),
        },
      })
    } catch (err) {
      console.warn('[Feishu] updateCard failed:', err instanceof Error ? err.message : err)
      throw err
    }
  }

  // ---------------------------------------------------------------------------
  // Permission UI
  // ---------------------------------------------------------------------------

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

    // If there's already a pending request for this chat, the old one becomes stale (P23 fix)
    const existing = this.pendingPermByChat.get(req.chatId)
    if (existing) {
      console.warn(`[Feishu] Overwriting pending permission for chat=${req.chatId} (old=${existing.requestIdPrefix}, new=${req.requestId.slice(0, 8)})`)
    }
    // Track this request for simplified responses (P30: also track senderId for verification)
    this.pendingPermByChat.set(req.chatId, { requestIdPrefix: req.requestId.slice(0, 8), senderId: req.senderId })
    // Auto-cleanup after 120s to prevent stale entries from swallowing normal messages
    setTimeout(() => {
      const current = this.pendingPermByChat.get(req.chatId)
      if (current?.requestIdPrefix === req.requestId.slice(0, 8)) {
        this.pendingPermByChat.delete(req.chatId)
      }
    }, 120_000)

    const text = `🔐 需要你的授权\n\nForge 想要使用 ${req.toolName}：\n${inputSummary}\n\n回复"允许"或"拒绝"`

    await this.send({ chatId: req.chatId, text })
  }

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

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

  validateConfig(config: Record<string, string>): { valid: boolean; error?: string } {
    if (!config.app_id) return { valid: false, error: 'app_id is required' }
    if (!config.app_secret) return { valid: false, error: 'app_secret is required' }
    return { valid: true }
  }

  // ---------------------------------------------------------------------------
  // Internal: Bot info
  // ---------------------------------------------------------------------------

  private async fetchBotInfo(): Promise<void> {
    const success = await this.tryFetchBotInfo()
    if (!success && this.running) {
      // Schedule background retries with exponential backoff (P19 fix)
      this.retryFetchBotInfo()
    }
  }

  private async tryFetchBotInfo(): Promise<boolean> {
    try {
      const apiBase = this.platform === 'lark'
        ? 'https://open.larksuite.com/open-apis'
        : 'https://open.feishu.cn/open-apis'
      const tenantToken = await this.getTenantToken(AbortSignal.timeout(10_000))

      const res = await fetch(`${apiBase}/bot/v3/info`, {
        headers: { 'Authorization': `Bearer ${tenantToken}` },
        signal: AbortSignal.timeout(10_000),
      })
      if (res.ok) {
        const data = await res.json() as { bot?: { open_id?: string; bot_id?: string } }
        if (data.bot?.open_id) {
          this.botOpenId = data.bot.open_id
          this.botIds.add(data.bot.open_id)
        }
        if (data.bot?.bot_id) {
          this.botIds.add(data.bot.bot_id)
        }
        console.log(`[Feishu] Bot identity resolved: open_id=${this.botOpenId || 'unknown'}, botIds=[${[...this.botIds].join(', ')}]`)
        return this.botIds.size > 0
      } else {
        console.warn(`[Feishu] fetchBotInfo: bot info request failed (HTTP ${res.status})`)
        return false
      }
    } catch (err) {
      console.warn('[Feishu] Failed to fetch bot info:', err instanceof Error ? err.message : err)
      return false
    }
  }

  /** Background retry for fetchBotInfo with exponential backoff (P19 fix) */
  private retryFetchBotInfo(): void {
    const maxRetries = 3
    let attempt = 0
    const retry = () => {
      if (!this.running || this.botIds.size > 0) return
      attempt++
      const delay = Math.min(5000 * Math.pow(2, attempt - 1), 30_000) // 5s, 10s, 20s
      console.warn(`[Feishu] Bot info retry scheduled in ${delay}ms (attempt ${attempt}/${maxRetries})`)
      setTimeout(async () => {
        if (!this.running || this.botIds.size > 0) return
        const success = await this.tryFetchBotInfo()
        if (!success && attempt < maxRetries) {
          retry()
        } else if (!success) {
          console.warn('[Feishu] Could not resolve bot identity after retries — group @mention detection will not work')
        }
      }, delay)
    }
    retry()
  }

  // ---------------------------------------------------------------------------
  // Internal: Incoming message handler (called by SDK EventDispatcher)
  // ---------------------------------------------------------------------------

  private async handleIncomingMessage(data: unknown): Promise<void> {
    // Guard: discard events from orphaned WSClient after stop()
    if (this.stopped) {
      console.log('[Feishu] Discarding event from stopped adapter')
      return
    }
    this.lastEventTime = Date.now()
    try {
      const event = data as {
        message?: {
          message_id?: string
          message_type?: string
          chat_id?: string
          chat_type?: string
          content?: string
          mentions?: Array<{
            key?: string
            name?: string
            id?: { open_id?: string; union_id?: string; user_id?: string }
          }>
        }
        sender?: {
          sender_id?: { open_id?: string; union_id?: string; user_id?: string }
          sender_type?: string
          tenant_key?: string
        }
      }

      const message = event.message
      const sender = event.sender
      if (!message || !sender) {
        console.warn('[Feishu] Received event with missing message or sender')
        return
      }

      const chatId = message.chat_id || ''
      const chatType = message.chat_type || ''
      const senderId = sender.sender_id?.open_id || ''

      const msgId = message.message_id || ''
      console.log(`[Feishu] Incoming: type=${message.message_type}, chat=${chatId}, chatType=${chatType}, sender=${senderId}, senderType=${sender.sender_type}, msgId=${msgId}`)

      // Skip bot's own messages (sender_type 'app' = bot message)
      if (sender.sender_type === 'app') {
        console.log(`[Feishu] Skipping bot's own message: msgId=${msgId}`)
        return
      }

      // Skip messages from known bot IDs (extra safety)
      if (senderId && this.botIds.has(senderId)) {
        console.log(`[Feishu] Skipping message from known bot ID: ${senderId}`)
        return
      }

      // Adapter-level dedup: skip events the SDK/Feishu server re-delivers
      if (msgId) {
        if (this.seenMessageIds.has(msgId)) {
          console.log(`[Feishu] Adapter dedup: skipping already-seen msgId=${msgId}`)
          return
        }
        this.seenMessageIds.add(msgId)
        // Clear seen IDs periodically (every 5 minutes)
        if (!this.seenMessageIdTimer) {
          this.seenMessageIdTimer = setTimeout(() => {
            this.seenMessageIds.clear()
            this.seenMessageIdTimer = null
          }, 300_000)
        }
      }

      const isDm = chatType === 'p2p'
      const msgType = message.message_type || ''

      // Look up converter for this message type
      const converter = messageConverters.get(msgType)
      if (!converter) {
        console.log(`[Feishu] Skipping unhandled message type: ${msgType}`)
        return
      }

      try {
        const payload = await converter({
          msgType,
          rawContent: message.content || '',
          messageId: msgId,
          platform: this.platform,
          downloadResource: (fileKey, type) => this.downloadFeishuResource(msgId, fileKey, type),
          fetchMergeForwardContent: (mid) => this.fetchMergeForwardContent(mid),
        })
        if (!payload) return

        let text = payload.text

        // Text-only post-processing: permission commands and mention cleanup
        if (msgType === 'text') {
          if (text.startsWith('/perm ')) {
            this.handlePermCommand(text, chatId, senderId)
            return
          }
          if (this.handleSimplePermResponse(text, chatId, senderId)) {
            return
          }
          text = text.replace(/@_user_\d+/g, '').trim()
        }

        // Skip empty messages with no attachments
        if (!text.trim() && !payload.images?.length && !payload.files?.length) return

        // Media messages (image/file/audio/video/sticker) are always treated as
        // group mentions — the user is actively sharing content with the bot.
        const isGroupMention = !isDm
          ? (IMPLICIT_MENTION_TYPES.has(msgType) ? true : this.isBotMentioned(message.mentions))
          : false

        const incoming: IncomingMessage = {
          channelType: 'feishu',
          channelId: 'feishu',
          chatId,
          messageId: msgId,
          senderId,
          senderName: 'User',
          text,
          isDm,
          isGroupMention,
          ...(payload.images && { images: payload.images }),
          ...(payload.files && { files: payload.files }),
        }

        console.log(`[Feishu] Message parsed: isDm=${isDm}, isGroupMention=${isGroupMention}, text="${text.slice(0, 50)}${text.length > 50 ? '...' : ''}"`)
        this.enqueueMessage(incoming)
      } catch (err) {
        console.error(`[Feishu] Converter failed for ${msgType}:`, err instanceof Error ? err.message : err)
      }
    } catch (err) {
      console.error('[Feishu] Error handling incoming message:', err)
    }
  }

  /**
   * Check if bot is mentioned — matches against all known bot IDs
   * (open_id, user_id, union_id, bot_id).
   */
  private isBotMentioned(
    mentions?: Array<{
      key?: string
      name?: string
      id?: { open_id?: string; union_id?: string; user_id?: string }
    }>,
  ): boolean {
    if (!mentions || this.botIds.size === 0) {
      if (!mentions) console.log('[Feishu] isBotMentioned: no mentions in message')
      else console.log(`[Feishu] isBotMentioned: botIds empty, cannot detect mentions`)
      return false
    }
    const result = mentions.some((m) => {
      const ids = [m.id?.open_id, m.id?.user_id, m.id?.union_id].filter(Boolean) as string[]
      return ids.some((id) => this.botIds.has(id))
    })
    console.log(`[Feishu] isBotMentioned: ${result} (mentions=${JSON.stringify(mentions.map(m => m.id))}, botIds=[${[...this.botIds].join(', ')}])`)
    return result
  }

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

    const decision = parts[1] as 'allow' | 'deny'
    if (decision !== 'allow' && decision !== 'deny') return

    // Verify sender matches the original requester (P30 fix)
    const pending = this.pendingPermByChat.get(chatId)
    if (pending && pending.senderId !== senderId) {
      console.log(`[Feishu] Permission command from wrong user: expected=${pending.senderId}, got=${senderId}`)
      return
    }

    const requestIdPrefix = parts[2]
    this.pendingPermByChat.delete(chatId)
    this.permissionCallback?.(requestIdPrefix, decision)
  }

  /**
   * Handle simple permission responses: "allow", "deny", "允许", "拒绝", "y", "n", etc.
   * Only works if there's a pending permission request for this chat.
   * @returns true if the message was handled as a permission response.
   */
  private handleSimplePermResponse(text: string, chatId: string, senderId: string): boolean {
    const pending = this.pendingPermByChat.get(chatId)
    if (!pending) return false

    // Verify sender matches the original requester (P30 fix)
    if (pending.senderId !== senderId) {
      console.log(`[Feishu] 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'
    else if (denyWords.includes(normalized)) decision = 'deny'

    if (!decision) return false

    console.log(`[Feishu] Simple permission response: "${text}" → ${decision} (request=${pending.requestIdPrefix})`)
    this.pendingPermByChat.delete(chatId)
    this.permissionCallback?.(pending.requestIdPrefix, decision)
    return true
  }

  // ---------------------------------------------------------------------------
  // Internal: Post (rich text) content extraction
  // ---------------------------------------------------------------------------

  private extractPostText(raw: Record<string, unknown>): string {
    const LOCALE_PRIORITY = ['zh_cn', 'en_us', 'ja_jp']

    // Unwrap locale-wrapped content
    let body: { title?: string; content?: unknown[][] } | undefined
    if ('title' in raw || 'content' in raw) {
      body = raw as unknown as { title?: string; content?: unknown[][] }
    } else {
      for (const locale of LOCALE_PRIORITY) {
        const localeData = raw[locale]
        if (localeData != null && typeof localeData === 'object') {
          body = localeData as { title?: string; content?: unknown[][] }
          break
        }
      }
      if (!body) {
        const firstKey = Object.keys(raw)[0]
        if (firstKey) {
          const firstValue = raw[firstKey]
          if (firstValue != null && typeof firstValue === 'object') {
            body = firstValue as { title?: string; content?: unknown[][] }
          }
        }
      }
    }

    if (!body) return '[rich text message]'

    const lines: string[] = []
    if (body.title) lines.push(`**${body.title}**`, '')

    const contentBlocks = body.content ?? []
    for (const paragraph of contentBlocks) {
      if (!Array.isArray(paragraph)) continue
      let line = ''
      for (const el of paragraph) {
        line += this.renderPostElement(el as Record<string, unknown>)
      }
      if (line) lines.push(line)
    }

    return lines.join('\n').trim() || '[rich text message]'
  }

  private renderPostElement(el: Record<string, unknown>): string {
    const tag = String(el.tag ?? '')
    switch (tag) {
      case 'text': {
        let text = String(el.text ?? '')
        const style = el.style as string[] | undefined
        if (style?.includes('bold')) text = `**${text}**`
        if (style?.includes('italic')) text = `*${text}*`
        if (style?.includes('underline')) text = `<u>${text}</u>`
        if (style?.includes('lineThrough')) text = `~~${text}~~`
        if (style?.includes('codeInline')) text = `\`${text}\``
        return text
      }
      case 'a': {
        const text = String(el.text ?? el.href ?? '')
        const href = String(el.href ?? '')
        return href ? `[${text}](${href})` : text
      }
      case 'at': {
        const userId = String(el.user_id ?? '')
        if (userId === 'all') return '@all'
        const userName = String(el.user_name ?? userId)
        return `@${userName}`
      }
      case 'img':
        return '[image]'
      case 'media':
        return '[file]'
      case 'code_block': {
        const lang = String(el.language ?? '')
        const code = String(el.text ?? '')
        return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`
      }
      case 'hr':
        return '\n---\n'
      default:
        return String(el.text ?? '')
    }
  }

  // ---------------------------------------------------------------------------
  // Internal: Merge forward content fetching
  // ---------------------------------------------------------------------------

  private async fetchMergeForwardContent(messageId: string): Promise<string> {
    const apiBase = this.platform === 'lark' ? 'https://open.larksuite.com/open-apis' : 'https://open.feishu.cn/open-apis'

    try {
      // Get tenant token
      const tenantToken = await this.getTenantToken(AbortSignal.timeout(10_000))

      // Fetch sub-messages
      const res = await fetch(`${apiBase}/im/v1/messages/${messageId}/children?page_size=50`, {
        headers: { 'Authorization': `Bearer ${tenantToken}` },
        signal: AbortSignal.timeout(15_000),
      })
      if (!res.ok) throw new Error(`Fetch children failed: ${res.status}`)

      const data = await res.json() as {
        data?: {
          items?: Array<{
            body?: { content?: string }
            msg_type?: string
            sender?: { sender_id?: { open_id?: string }; sender_type?: string }
            create_time?: string
          }>
        }
      }

      const items = data.data?.items ?? []
      if (items.length === 0) return '<forwarded_messages/>'

      const parts: string[] = []
      for (const item of items) {
        const msgType = item.msg_type ?? 'text'
        const senderId = item.sender?.sender_id?.open_id ?? 'unknown'
        let content = ''

        if (msgType === 'text') {
          try {
            const parsed = JSON.parse(item.body?.content ?? '{}') as { text?: string }
            content = parsed.text ?? ''
          } catch {
            content = item.body?.content ?? ''
          }
        } else if (msgType === 'post') {
          try {
            const parsed = JSON.parse(item.body?.content ?? '{}') as Record<string, unknown>
            content = this.extractPostText(parsed)
          } catch {
            content = '[rich text]'
          }
        } else if (msgType === 'image') {
          content = '[image]'
        } else if (msgType === 'file') {
          content = '[file]'
        } else if (msgType === 'audio') {
          content = '[audio]'
        } else {
          content = `[${msgType}]`
        }

        const timestamp = item.create_time ? new Date(Number(item.create_time)).toISOString() : 'unknown'
        parts.push(`[${timestamp}] ${senderId}:\n    ${content.split('\n').join('\n    ')}`)
      }

      return `<forwarded_messages>\n${parts.join('\n')}\n</forwarded_messages>`
    } catch (err) {
      console.warn('[Feishu] fetchMergeForwardContent failed:', err instanceof Error ? err.message : err)
      return '<forwarded_messages/>'
    }
  }

  // ---------------------------------------------------------------------------
  // Internal: Download resource from Feishu message
  // ---------------------------------------------------------------------------

  private async downloadFeishuResource(messageId: string, fileKey: string, type: 'image' | 'file'): Promise<Buffer> {
    const apiBase = this.platform === 'lark' ? 'https://open.larksuite.com/open-apis' : 'https://open.feishu.cn/open-apis'

    // Get tenant token
    const tenantToken = await this.getTenantToken(AbortSignal.timeout(10_000))

    // Download resource
    const res = await fetch(`${apiBase}/im/v1/messages/${messageId}/resources/${fileKey}?type=${type}`, {
      headers: { 'Authorization': `Bearer ${tenantToken}` },
      signal: AbortSignal.timeout(30_000),
    })
    if (!res.ok) throw new Error(`Download failed: ${res.status}`)

    return Buffer.from(await res.arrayBuffer())
  }

  // ---------------------------------------------------------------------------
  // Internal: Message queue
  // ---------------------------------------------------------------------------

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

// Self-register
registerAdapter('feishu', FeishuAdapter)
