/**
 * Genspark IM channel plugin for OpenClaw.
 *
 * Connects to Genspark Server via WS to receive IM messages,
 * dispatches them to the OpenClaw agent, sends agent replies back via Server HTTP API.
 *
 * Config in openclaw.json:
 *   channels.genspark-im.accounts.default.serverUrl = "https://xxx.ngrok-free.app"
 *   channels.genspark-im.accounts.default.vmName = "shane-xxx-vm"
 *   (GSK token read from ~/.genspark-tool-cli/config.json)
 */
import type { ChannelPlugin, OpenClawPluginApi } from 'openclaw/plugin-sdk'
import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk'
import { readFileSync, readdirSync, statSync, unlinkSync } from 'fs'
import { writeFile, mkdir } from 'fs/promises'
import { resolve, dirname, join, extname } from 'path'
import { execFile } from 'child_process'
import { promisify } from 'util'

const execFileAsync = promisify(execFile)
import { fileURLToPath } from 'url'
import { homedir } from 'os'
import { randomUUID } from 'crypto'

// ---------------------------------------------------------------------------
// Version — read from package.json, checked by server for upgrade prompts
// ---------------------------------------------------------------------------

const __pluginDir = dirname(fileURLToPath(import.meta.url))

const PLUGIN_VERSION: string = (() => {
  try {
    const pkg = JSON.parse(readFileSync(resolve(__pluginDir, '..', 'package.json'), 'utf-8'))
    return pkg.version || '0.0.0'
  } catch { return '0.0.0' }
})()

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

interface PluginConfig {
  serverUrl: string
  gskToken: string
}

interface AuthResult {
  httpToken: string
  httpTokenTtl: number
  wsToken: string
  wsUrl: string
  chatId: string
  chatName: string
}

interface IncomingAttachment {
  type: string  // "image" | "file"
  url: string
  name?: string
}

interface IncomingMessage {
  chat_type: string
  chat_id: string
  sender: string
  sender_name?: string
  message: string
  message_id?: number
  replayed?: boolean
  from_bot?: boolean
  attachments?: IncomingAttachment[]
}

interface DownloadedMedia {
  mediaPaths: string[]
  mediaTypes: string[]
}

const INBOUND_MEDIA_DIR = join(homedir(), '.openclaw', 'media', 'inbound')

// Map common extensions to MIME types
function guessMimeType(url: string, attType: string): string {
  const ext = extname(new URL(url, 'https://placeholder').pathname).toLowerCase()
  const map: Record<string, string> = {
    '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
    '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
    '.pdf': 'application/pdf', '.doc': 'application/msword',
    '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    '.txt': 'text/plain', '.csv': 'text/csv', '.json': 'application/json',
  }
  if (map[ext]) return map[ext]
  return attType === 'image' ? 'image/jpeg' : 'application/octet-stream'
}

function guessExtension(url: string, attType: string): string {
  const ext = extname(new URL(url, 'https://placeholder').pathname).toLowerCase()
  if (ext && ext.length <= 5) return ext
  return attType === 'image' ? '.jpg' : '.bin'
}

// Cleanup: keep at most this many files in the inbound media directory
const MAX_INBOUND_FILES = 200

/**
 * Remove oldest files when the inbound media directory exceeds MAX_INBOUND_FILES.
 * Best-effort — errors are silently ignored.
 */
function cleanupInboundMedia(log: any): void {
  try {
    const entries = readdirSync(INBOUND_MEDIA_DIR)
      .map(name => {
        const p = join(INBOUND_MEDIA_DIR, name)
        try { return { path: p, mtime: statSync(p).mtimeMs } } catch { return null }
      })
      .filter((e): e is { path: string; mtime: number } => e !== null)
      .sort((a, b) => a.mtime - b.mtime) // oldest first

    const toRemove = entries.length - MAX_INBOUND_FILES
    if (toRemove <= 0) return

    for (let i = 0; i < toRemove; i++) {
      try { unlinkSync(entries[i].path) } catch { /* ignore */ }
    }
    log?.info?.(`[genspark-im] Cleaned up ${toRemove} old inbound media files`)
  } catch { /* directory may not exist yet */ }
}

/**
 * Resize an image buffer to max 1280px on the longest side using ImageMagick.
 * Returns the resized buffer, or the original if resize fails or ImageMagick unavailable.
 * This keeps base64-encoded image data in session transcripts under ~200KB.
 */
async function resizeImageIfNeeded(buf: Buffer, mimeType: string, log: any): Promise<Buffer> {
  // Only resize actual images, skip SVG/PDF/etc
  if (!mimeType.startsWith('image/') || mimeType === 'image/svg+xml') return buf
  // Skip if already small enough (< 200KB → base64 < ~270KB, acceptable)
  if (buf.byteLength < 200 * 1024) return buf

  // Ensure the temp directory exists before writing any temp files
  await mkdir(INBOUND_MEDIA_DIR, { recursive: true })

  const tmpIn = join(INBOUND_MEDIA_DIR, `resize-in-${randomUUID()}.tmp`)
  const tmpOut = join(INBOUND_MEDIA_DIR, `resize-out-${randomUUID()}.jpg`)
  const { unlink } = await import('fs/promises')

  try {
    await writeFile(tmpIn, buf)
    // Resize to max 1280px on longest side, convert to JPEG for compression
    await execFileAsync('convert', [tmpIn, '-resize', '1280x1280>', '-quality', '82', tmpOut])
    const { readFile } = await import('fs/promises')
    const resized = await readFile(tmpOut)
    log?.info?.(`[genspark-im] Resized image: ${buf.byteLength} → ${resized.byteLength} bytes`)
    return resized
  } catch (e) {
    log?.warn?.(`[genspark-im] ImageMagick resize failed, using original: ${e}`)
    return buf
  } finally {
    // Always clean up temp files, whether resize succeeded or failed
    unlink(tmpIn).catch(() => {})
    unlink(tmpOut).catch(() => {})
  }
}

/**
 * Download attachments and save via core's saveMediaBuffer (when channelRuntime available)
 * or fall back to direct disk write.
 *
 * Using core's saveMediaBuffer is critical: it automatically resizes/optimizes images
 * before saving, which prevents session file bloat. Without it, a full-resolution PNG
 * (~1.5MB) gets base64-encoded into the session transcript on every turn, eventually
 * causing LLM API failures. Feishu uses the same approach via core.channel.media.saveMediaBuffer.
 *
 * Automatically cleans up old files to prevent unbounded disk growth (fallback path only).
 */
async function downloadAttachments(
  attachments: IncomingAttachment[],
  log: any,
  channelRuntime?: any
): Promise<DownloadedMedia> {
  const result: DownloadedMedia = { mediaPaths: [], mediaTypes: [] }
  if (!attachments.length) return result

  const maxBytes = 30 * 1024 * 1024  // 30MB default, matches Feishu

  for (const att of attachments) {
    if (!att.url) continue
    try {
      const resp = await fetch(att.url)
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`)

      const rawBuf = Buffer.from(await resp.arrayBuffer())
      const mimeType = guessMimeType(att.url, att.type)
      // Resize images before saving — keeps session transcript base64 lean
      const buf = await resizeImageIfNeeded(rawBuf, mimeType, log)

      // Prefer core's saveMediaBuffer: it optimizes/resizes images before saving,
      // keeping session transcripts lean (same as Feishu plugin).
      // Note: channelRuntime is already PluginRuntime["channel"], so media is at top level.
      if (channelRuntime?.media?.saveMediaBuffer) {
        const saved = await channelRuntime.media.saveMediaBuffer(buf, mimeType, 'inbound', maxBytes)
        result.mediaPaths.push(saved.path)
        result.mediaTypes.push(saved.contentType ?? mimeType)
        log?.info?.(`[genspark-im] Downloaded ${att.type} via core saveMediaBuffer to ${saved.path} (${saved.contentType ?? mimeType})`)
      } else {
        // Fallback: direct write (no image optimization — use only if core API unavailable)
        await mkdir(INBOUND_MEDIA_DIR, { recursive: true })
        cleanupInboundMedia(log)
        const ext = guessExtension(att.url, att.type)
        const fileName = `${randomUUID()}${ext}`
        const filePath = join(INBOUND_MEDIA_DIR, fileName)
        await writeFile(filePath, buf)
        result.mediaPaths.push(filePath)
        result.mediaTypes.push(mimeType)
        log?.info?.(`[genspark-im] Downloaded ${att.type} via fallback to ${filePath} (${mimeType})`)
      }
    } catch (e) {
      log?.error?.(`[genspark-im] Failed to download ${att.type} from ${att.url}: ${e}`)
    }
  }
  return result
}

interface SendResult {
  success: boolean
  tokenExpired: boolean
  serverStatus?: number | string
  serverMessage?: string
}

// Gateway context passed from startAccount
interface GatewayCtx {
  cfg: any
  accountId: string
  abortSignal: AbortSignal
  log: any
  channelRuntime: any
  setStatus?: (patch: Record<string, any>) => void
}

// ---------------------------------------------------------------------------
// Config resolution
// ---------------------------------------------------------------------------

function readGskCliConfig(): { apiKey: string; baseUrl: string } {
  const result = { apiKey: '', baseUrl: '' }
  try {
    const p = resolve(homedir(), '.genspark-tool-cli/config.json')
    const content = JSON.parse(readFileSync(p, 'utf-8'))
    result.apiKey = content.api_key || ''
    result.baseUrl = content.base_url || ''
  } catch {
    /* not on a Claw VM */
  }
  return result
}

function resolvePluginConfig(): PluginConfig {
  const cliCfg = readGskCliConfig()
  const serverUrl = cliCfg.baseUrl
  const gskToken = cliCfg.apiKey

  if (!serverUrl) throw new Error('serverUrl not configured in ~/.genspark-tool-cli/config.json')
  if (!gskToken) throw new Error('GSK token not found in ~/.genspark-tool-cli/config.json')

  return { serverUrl: serverUrl.replace(/\/$/, ''), gskToken }
}

// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------

async function authenticate(cfg: PluginConfig): Promise<AuthResult> {
  const url = `${cfg.serverUrl}/api/im/bot/auth`
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${cfg.gskToken}`,
    },
    body: JSON.stringify({ plugin_version: PLUGIN_VERSION }),
  })

  const json = (await res.json()) as any
  if (json.status !== 0) {
    throw new Error(`Auth failed: ${json.message || JSON.stringify(json)}`)
  }

  const d = json.data
  const wsBase = cfg.serverUrl
    .replace('https://', 'wss://')
    .replace('http://', 'ws://')
  const wsUrl = `${wsBase}/ws/multiplayer/im/bot`

  return {
    httpToken: d.http_token,
    httpTokenTtl: d.http_token_ttl || 3600,
    wsToken: d.ws_token,
    wsUrl,
    chatId: d.chat_id,
    chatName: d.chat_name,
  }
}

// ---------------------------------------------------------------------------
// API client — send message via Server HTTP
// ---------------------------------------------------------------------------

const STATUS_TOKEN_EXPIRED = -7

async function sendMessage(
  serverUrl: string,
  httpToken: string,
  chatType: string,
  chatId: string,
  replyText: string,
  metadata?: Record<string, any>
): Promise<SendResult> {
  const body: Record<string, any> = {
    chat_type: chatType,
    chat_id: chatId,
    reply_text: replyText,
  }
  if (metadata) {
    body.metadata = metadata
  }
  const res = await fetch(`${serverUrl}/api/im/bot/send_message`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${httpToken}`,
    },
    body: JSON.stringify(body),
  })

  const json = (await res.json()) as any
  if (json.status !== 0) {
    const expired =
      json.status === STATUS_TOKEN_EXPIRED ||
      (json.message || '').toLowerCase().includes('expired')
    return { success: false, tokenExpired: expired, serverStatus: json.status, serverMessage: json.message }
  }
  return { success: true, tokenExpired: false }
}

// Wrapper that handles token refresh + retry
async function sendWithRetry(
  cfg: PluginConfig,
  getToken: (forceRefresh?: boolean) => Promise<string>,
  chatType: string,
  chatId: string,
  text: string,
  log: any,
  metadata?: Record<string, any>
): Promise<void> {
  let token = await getToken()
  let result = await sendMessage(cfg.serverUrl, token, chatType, chatId, text, metadata)
  if (result.tokenExpired) {
    log?.info?.('[genspark-im] Token expired, force-refreshing and retrying...')
    token = await getToken(true)
    result = await sendMessage(cfg.serverUrl, token, chatType, chatId, text, metadata)
  }
  if (!result.success) {
    const msg = `[genspark-im] Failed to send reply to ${chatId} (status=${result.serverStatus}, message=${result.serverMessage})`
    log?.error?.(msg)
    throw new Error(msg)
  }
}

// ---------------------------------------------------------------------------
// API client — send media message via Server HTTP
// ---------------------------------------------------------------------------

async function sendMediaMessage(
  serverUrl: string,
  httpToken: string,
  chatType: string,
  chatId: string,
  mediaUrl: string,
  mediaType: string,
  caption?: string,
  fileName?: string,
  metadata?: Record<string, any>
): Promise<SendResult> {
  const body: Record<string, any> = {
    chat_type: chatType,
    chat_id: chatId,
    media_url: mediaUrl,
    media_type: mediaType,
  }
  if (caption) body.caption = caption
  if (fileName) body.file_name = fileName
  if (metadata) body.metadata = metadata

  const res = await fetch(`${serverUrl}/api/im/bot/send_media`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${httpToken}`,
    },
    body: JSON.stringify(body),
  })

  const json = (await res.json()) as any
  if (json.status !== 0) {
    const expired =
      json.status === STATUS_TOKEN_EXPIRED ||
      (json.message || '').toLowerCase().includes('expired')
    return { success: false, tokenExpired: expired, serverStatus: json.status, serverMessage: json.message }
  }
  return { success: true, tokenExpired: false }
}

async function sendMediaWithRetry(
  cfg: PluginConfig,
  getToken: (forceRefresh?: boolean) => Promise<string>,
  chatType: string,
  chatId: string,
  mediaUrl: string,
  mediaType: string,
  log: any,
  caption?: string,
  fileName?: string,
  metadata?: Record<string, any>
): Promise<void> {
  let token = await getToken()
  let result = await sendMediaMessage(cfg.serverUrl, token, chatType, chatId, mediaUrl, mediaType, caption, fileName, metadata)
  if (result.tokenExpired) {
    log?.info?.('[genspark-im] Token expired (media), force-refreshing and retrying...')
    token = await getToken(true)
    result = await sendMediaMessage(cfg.serverUrl, token, chatType, chatId, mediaUrl, mediaType, caption, fileName, metadata)
  }
  if (!result.success) {
    const msg = `[genspark-im] Failed to send media to ${chatId} (status=${result.serverStatus}, message=${result.serverMessage})`
    log?.error?.(msg)
    throw new Error(msg)
  }
}

// ---------------------------------------------------------------------------
// API client — update bot profile via Server HTTP
// ---------------------------------------------------------------------------

async function updateBotProfile(
  serverUrl: string,
  httpToken: string,
  name?: string,
  avatar?: string
): Promise<SendResult> {
  const body: Record<string, any> = {}
  if (name !== undefined) body.name = name
  if (avatar !== undefined) body.avatar = avatar

  const res = await fetch(`${serverUrl}/api/im/bot/update_profile`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${httpToken}`,
    },
    body: JSON.stringify(body),
  })

  const json = (await res.json()) as any
  if (json.status !== 0) {
    const expired =
      json.status === STATUS_TOKEN_EXPIRED ||
      (json.message || '').toLowerCase().includes('expired')
    return { success: false, tokenExpired: expired, serverStatus: json.status, serverMessage: json.message }
  }
  return { success: true, tokenExpired: false }
}

async function updateProfileWithRetry(
  cfg: PluginConfig,
  getToken: (forceRefresh?: boolean) => Promise<string>,
  log: any,
  name?: string,
  avatar?: string
): Promise<void> {
  let token = await getToken()
  let result = await updateBotProfile(cfg.serverUrl, token, name, avatar)
  if (result.tokenExpired) {
    log?.info?.('[genspark-im] Token expired (profile), force-refreshing and retrying...')
    token = await getToken(true)
    result = await updateBotProfile(cfg.serverUrl, token, name, avatar)
  }
  if (!result.success) {
    const msg = `[genspark-im] Failed to update bot profile (status=${result.serverStatus}, message=${result.serverMessage})`
    log?.error?.(msg)
    throw new Error(msg)
  }
}

// ---------------------------------------------------------------------------
// WS client — using Node.js 22 native WebSocket
// ---------------------------------------------------------------------------

const PING_INTERVAL_MS = 10_000

// Dedup: track processed CometChat message IDs to avoid replaying already-handled messages
const PROCESSED_MSG_IDS = new Set<number>()
const MAX_PROCESSED_IDS = 500

function trackProcessedMessage(messageId: number | undefined): boolean {
  if (!messageId) return false  // no ID to dedup against
  if (PROCESSED_MSG_IDS.has(messageId)) return true  // already processed
  PROCESSED_MSG_IDS.add(messageId)
  // Evict oldest entries when set grows too large
  if (PROCESSED_MSG_IDS.size > MAX_PROCESSED_IDS) {
    const iter = PROCESSED_MSG_IDS.values()
    PROCESSED_MSG_IDS.delete(iter.next().value!)
  }
  return false
}



// Reconnect backoff: 50ms, 500ms, 1s, 2s, 5s, 10s, 30s, 60s, 5min (max)
const RECONNECT_DELAYS = [50, 500, 1000, 2000, 5000, 10_000, 30_000, 60_000, 300_000]
function getReconnectDelay(attempt: number): number {
  const base = RECONNECT_DELAYS[Math.min(attempt, RECONNECT_DELAYS.length - 1)]
  return Math.floor(base * (0.5 + Math.random()))  // jitter: 0.5x to 1.5x
}

function connectWs(opts: {
  wsUrl: string
  wsToken: string
  onMessage: (msg: IncomingMessage) => void
  onOpen?: () => void
  onClose: () => void
  onEvent?: () => void
  log: any
}): WebSocket {
  const fullUrl = `${opts.wsUrl}?token=${opts.wsToken}`
  opts.log?.info?.(`[genspark-im] WS connecting to ${opts.wsUrl}`)

  const ws = new WebSocket(fullUrl)
  let pingTimer: ReturnType<typeof setInterval> | null = null

  ws.addEventListener('open', () => {
    opts.log?.info?.('[genspark-im] WS connected')
    opts.onOpen?.()
    pingTimer = setInterval(() => {
      if (ws.readyState === WebSocket.OPEN) ws.send('ping')
    }, PING_INTERVAL_MS)
  })

  ws.addEventListener('message', (event: MessageEvent) => {
    const text =
      typeof event.data === 'string' ? event.data : String(event.data)
    if (text === 'pong' || text === 'ping') {
      if (text === 'ping') ws.send('pong')
      // Report ping/pong as event so health monitor knows we're alive
      opts.onEvent?.()
      return
    }
    try {
      const msg: IncomingMessage = JSON.parse(text)
      opts.onMessage(msg)
    } catch {
      opts.log?.error?.(`[genspark-im] WS parse error: ${text}`)
    }
  })

  ws.addEventListener('close', (event: CloseEvent) => {
    opts.log?.info?.(
      `[genspark-im] WS closed: code=${event.code} reason=${event.reason}`
    )
    if (pingTimer) clearInterval(pingTimer)
    opts.onClose()
  })

  ws.addEventListener('error', (event: Event) => {
    opts.log?.error?.(
      `[genspark-im] WS error: ${(event as any).message || 'unknown'}`
    )
  })

  return ws
}

// ---------------------------------------------------------------------------
// ACK — tell server the message was received and processed
// ---------------------------------------------------------------------------

function sendAck(ws: WebSocket, msg: IncomingMessage): void {
  if (!msg.message_id || ws.readyState !== WebSocket.OPEN) return
  try {
    ws.send(JSON.stringify({
      type: 'ack',
      message_id: msg.message_id,
      sender_uid: msg.sender,
      chat_id: msg.chat_id,
      chat_type: msg.chat_type,
    }))
  } catch { /* best-effort */ }
}

// ---------------------------------------------------------------------------
// Monitor — token lifecycle + WS connect + agent dispatch
// ---------------------------------------------------------------------------

async function runMonitor(
  cfg: PluginConfig,
  gatewayCtx: GatewayCtx
): Promise<void> {
  const { abortSignal, log } = gatewayCtx
  let currentHttpToken = ''
  let tokenExpiresAt = 0
  let refreshTimer: ReturnType<typeof setTimeout> | null = null

  async function refreshToken(): Promise<AuthResult> {
    const auth = await authenticate(cfg)
    currentHttpToken = auth.httpToken
    tokenExpiresAt = Date.now() + auth.httpTokenTtl * 1000

    if (refreshTimer) clearTimeout(refreshTimer)
    const refreshIn = Math.max(auth.httpTokenTtl * 0.8, 30) * 1000
    refreshTimer = setTimeout(() => {
      refreshToken().catch(e =>
        log?.error?.(`[genspark-im] Token refresh failed: ${e}`)
      )
    }, refreshIn)

    log?.info?.(
      `[genspark-im] Authenticated: chat_id=${auth.chatId}, ttl=${auth.httpTokenTtl}s`
    )
    return auth
  }

  async function getToken(forceRefresh = false): Promise<string> {
    if (forceRefresh || !currentHttpToken || Date.now() > tokenExpiresAt - 60_000) {
      await refreshToken()
    }
    return currentHttpToken
  }

  // Expose auth state to outbound adapter so sendText can proactively send messages.
  // ⚠️ Known limitation: outboundState is a singleton. If multiple accounts are started,
  // each overwrites the previous one, and stopping any account nullifies state for all.
  // Currently we only run a single account ("default"), so this is fine.
  // If multi-account support is needed, change outboundState to a Map<accountId, state>.
  if (outboundState.pluginCfg !== null) {
    log?.error?.(
      `[genspark-im] outboundState already populated by another account — ` +
      `multi-account is NOT supported yet. Previous state will be overwritten.`
    )
  }
  outboundState.pluginCfg = cfg
  outboundState.getToken = getToken
  outboundState.log = log

  const { setStatus } = gatewayCtx

  // Helper to report status to SDK health monitor
  function reportConnected() {
    const now = Date.now()
    setStatus?.({ connected: true, lastEventAt: now, pluginVersion: PLUGIN_VERSION })
  }
  function reportDisconnected() {
    setStatus?.({ connected: false })
  }
  function reportEvent() {
    setStatus?.({ lastEventAt: Date.now() })
  }
  function reportInboundEvent() {
    const now = Date.now()
    setStatus?.({ lastEventAt: now, lastInboundAt: now })
  }

  let reconnectAttempt = 0

  while (!abortSignal.aborted) {
    try {
      const auth = await refreshToken()

      await new Promise<void>(resolvePromise => {
        if (abortSignal.aborted) return resolvePromise()
        let stabilityTimer: ReturnType<typeof setTimeout> | null = null

        const ws = connectWs({
          wsUrl: auth.wsUrl,
          wsToken: auth.wsToken,
          log,
          onMessage: (msg: IncomingMessage) => {
            // Dedup: skip if we already processed this message (same message_id)
            if (msg.message_id && trackProcessedMessage(msg.message_id)) {
              log?.info?.(`[genspark-im] Skipping duplicate message_id=${msg.message_id}`)
              // Still send ACK so server marks it as read
              sendAck(ws, msg)
              return
            }
            reportInboundEvent()
            dispatchToAgent(cfg, gatewayCtx, getToken, msg)
              .then(() => sendAck(ws, msg))
              .catch(e => {
                log?.error?.(`[genspark-im] Dispatch error: ${e}`)
                // Still ACK so the message isn't replayed forever
                sendAck(ws, msg)
              })
          },
          onEvent: reportEvent,  // ping/pong keepalive — updates lastEventAt only
          onOpen: () => {
            reportConnected()
            // Reset backoff after connection is stable (survived first ping cycle)
            stabilityTimer = setTimeout(() => { reconnectAttempt = 0 }, PING_INTERVAL_MS + 1000)
          },
          onClose: () => {
            if (stabilityTimer) { clearTimeout(stabilityTimer); stabilityTimer = null }
            reportDisconnected()
            resolvePromise()
          },
        })

        abortSignal.addEventListener(
          'abort',
          () => {
            ws.close()
          },
          { once: true }
        )
      })
    } catch (e) {
      log?.error?.(`[genspark-im] Monitor error: ${e}`)
      reportDisconnected()
    }

    if (!abortSignal.aborted) {
      const delay = getReconnectDelay(reconnectAttempt)
      log?.info?.(`[genspark-im] Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})...`)
      await new Promise(r => setTimeout(r, delay))
      reconnectAttempt++
    }
  }

  if (refreshTimer) clearTimeout(refreshTimer)
  outboundState.pluginCfg = null
  outboundState.getToken = null
  outboundState.log = null
  reportDisconnected()
  log?.info?.('[genspark-im] Monitor stopped')
}

// ---------------------------------------------------------------------------
// Agent dispatch — build MsgContext, call channelRuntime, deliver replies
// ---------------------------------------------------------------------------

async function dispatchToAgent(
  cfg: PluginConfig,
  gatewayCtx: GatewayCtx,
  getToken: (forceRefresh?: boolean) => Promise<string>,
  msg: IncomingMessage
): Promise<void> {
  const { log, channelRuntime } = gatewayCtx

  log?.info?.(
    `[genspark-im] Received from ${msg.sender} (${msg.chat_type}): "${msg.message}"`
  )

  if (!channelRuntime) {
    log?.warn?.(
      '[genspark-im] channelRuntime not available, falling back to echo'
    )
    try {
      await sendWithRetry(
        cfg,
        getToken,
        msg.chat_type,
        msg.chat_id,
        `Sorry, I'm unable to process your message right now (channelRuntime unavailable). Please try again later.`,
        log
      )
    } catch (e) {
      log?.error?.(`[genspark-im] channelRuntime unavailable fallback send also failed: ${e}`)
    }
    return
  }

  // Resolve agent ID for session routing
  const agentId = gatewayCtx.cfg?.agents?.list?.[0]?.id ?? 'main'

  // Build a per-user session key so genspark-im DMs don't share agent:main:main
  const isGroup = msg.chat_type === 'group'
  const sessionKey = isGroup
    ? `agent:${agentId}:genspark-im:group:${msg.chat_id}`
    : `agent:${agentId}:genspark-im:${msg.sender}`

  // Build MsgContext for the inbound message
  // Strip embedded base64 image data from message body before passing to LLM.
  // Images arrive as "[Image: data:image/jpeg;base64,<huge>]" — replace with a short
  // placeholder so we don't blow up the context window.
  let body = (msg.message || '').replace(
    /\[Image:\s*data:[^;]+;base64,[^\]]{20,}\]/gi,
    '[Image: (attachment — see MediaPath/ImageUrl)]'
  )

  if (msg.from_bot) {
    body = `[System: This message is from another AI bot in the group (${msg.sender_name || msg.sender}). ` +
      `Only respond if directly addressed or if you have something genuinely useful to add. ` +
      `Do NOT reply just to be polite or acknowledge — avoid creating an infinite conversation loop.]\n\n${body}`
  }

  // Download attachments to local disk and build media payload for the agent.
  // Only pass local file paths (MediaPath/MediaPaths) — never remote URLs.
  // Passing ImageUrl/ImageUrls (remote HTTP URLs) causes OpenClaw core to
  // fetch and base64-embed the image into the session transcript, which can
  // balloon session files by several MB per image and break subsequent LLM calls.
  // Feishu plugin uses the same pattern: download → disk → MediaPath only.
  const mediaPayload: Record<string, unknown> = {}
  if (msg.attachments && msg.attachments.length > 0) {
    const downloaded = await downloadAttachments(msg.attachments, log, channelRuntime)
    if (downloaded.mediaPaths.length > 0) {
      mediaPayload.MediaPath = downloaded.mediaPaths[0]
      mediaPayload.MediaPaths = downloaded.mediaPaths
      mediaPayload.MediaTypes = downloaded.mediaTypes
    }
  }

  const msgCtx: Record<string, any> = {
    Body: body,
    From: msg.sender,
    To: gatewayCtx.accountId,
    ChatType: isGroup ? 'group' : 'dm',
    Provider: 'genspark-im',
    OriginatingChannel: 'genspark-im',
    OriginatingTo: isGroup ? msg.chat_id : msg.sender,
    AccountId: gatewayCtx.accountId || 'default',
    SenderId: msg.sender,
    SenderName: msg.sender_name || msg.sender,
    SessionKey: sessionKey,
    ConversationLabel: isGroup ? msg.chat_id : msg.sender,
    Timestamp: Date.now(),
    CommandAuthorized: true,
    ExplicitDeliverRoute: true,
    ...mediaPayload,
  }

  // Finalize the inbound context (sets defaults, ensures CommandAuthorized is boolean)
  const finalCtx = channelRuntime.reply.finalizeInboundContext(msgCtx)

  // Record inbound session metadata for tracking
  try {
    const storePath = channelRuntime.session.resolveStorePath(
      gatewayCtx.cfg?.session?.store,
      { agentId }
    )
    await channelRuntime.session.recordInboundSession({
      storePath,
      sessionKey,
      ctx: finalCtx,
      onRecordError: (err: any) => {
        log?.warn?.(`[genspark-im] recordInboundSession meta error: ${err}`)
      },
    })
  } catch (e) {
    log?.warn?.(`[genspark-im] recordInboundSession failed: ${e}`)
  }

  // Dispatch to agent and deliver replies via Genspark Server API
  try {
    await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
      ctx: finalCtx,
      cfg: gatewayCtx.cfg,
      dispatcherOptions: {
        deliver: async (payload: any, info: any) => {
          const text = payload?.text
          if (!text) return
          log?.info?.(
            `[genspark-im] Delivering ${info?.kind || 'reply'} (${text.length} chars) to ${msg.chat_id}`
          )
          // Catch errors so a single block failure doesn't abort the entire
          // multi-block dispatch — subsequent blocks can still be delivered.
          const botMetadata = { format: 'markdown', source: 'ai' }
          try {
            await sendWithRetry(
              cfg,
              getToken,
              msg.chat_type,
              msg.chat_id,
              text,
              log,
              botMetadata
            )
          } catch (e) {
            log?.error?.(
              `[genspark-im] deliver failed for ${info?.kind || 'block'}: ${e}`
            )
          }
        },
        onError: (err: any, info: any) => {
          log?.error?.(
            `[genspark-im] Dispatch deliver error (${info?.kind}): ${err}`
          )
        },
      },
    })
    log?.info?.(`[genspark-im] Agent dispatch completed for ${msg.sender}`)
  } catch (e) {
    log?.error?.(`[genspark-im] Agent dispatch failed: ${e}`)
    // Fallback: send error message (best-effort, don't let it mask the original error)
    try {
      await sendWithRetry(
        cfg,
        getToken,
        msg.chat_type,
        msg.chat_id,
        `[error] Agent processing failed`,
        log
      )
    } catch (fallbackErr) {
      log?.error?.(`[genspark-im] Fallback error notification also failed: ${fallbackErr}`)
    }
  }
}

// ---------------------------------------------------------------------------
// Shared outbound state — populated by runMonitor, consumed by sendText
// ---------------------------------------------------------------------------

interface OutboundState {
  pluginCfg: PluginConfig | null
  getToken: ((forceRefresh?: boolean) => Promise<string>) | null
  log: any
}

const outboundState: OutboundState = {
  pluginCfg: null,
  getToken: null,
  log: null,
}

// ---------------------------------------------------------------------------
// Standalone send — used when monitor is not running (e.g. CLI mode).
// Performs its own auth, sends, and discards the token. Not efficient for
// repeated sends, but makes CLI `openclaw message send` work correctly.
// ---------------------------------------------------------------------------

async function standaloneSend(
  chatType: string,
  chatId: string,
  text: string,
  log?: any
): Promise<void> {
  const cfg = resolvePluginConfig()
  const auth = await authenticate(cfg)
  log?.info?.(`[genspark-im] standalone send: authenticated, sending to ${chatId}`)
  const result = await sendMessage(cfg.serverUrl, auth.httpToken, chatType, chatId, text)
  if (!result.success) {
    throw new Error(`[genspark-im] standalone send failed to ${chatId}`)
  }
}

// ---------------------------------------------------------------------------
// Shared outbound send helper — used by sendText
// ---------------------------------------------------------------------------

async function outboundSend(
  chatType: string,
  to: string,
  text: string,
  label: string,
  ctxLog?: any
): Promise<{ ok: boolean; channel: string; error?: string }> {
  const { pluginCfg, getToken, log } = outboundState

  // When monitor is running, use its managed token (efficient, supports refresh)
  if (pluginCfg && getToken) {
    log?.info?.(`[genspark-im] outbound.${label}: to=${to} (${text.length} chars)`)
    try {
      await sendWithRetry(pluginCfg, getToken, chatType, to, text, log)
      return { ok: true, channel: 'genspark-im' }
    } catch (e: any) {
      log?.error?.(`[genspark-im] outbound.${label} failed: ${e}`)
      return { ok: false, channel: 'genspark-im', error: String(e) }
    }
  }

  // Fallback: standalone auth + send (CLI mode, no monitor running)
  try {
    await standaloneSend(chatType, to, text, ctxLog)
    return { ok: true, channel: 'genspark-im' }
  } catch (e: any) {
    ctxLog?.error?.(`[genspark-im] outbound.${label} (standalone) failed: ${e}`)
    return { ok: false, channel: 'genspark-im', error: String(e) }
  }
}

// ---------------------------------------------------------------------------
// Shared outbound media send helper — used by sendMedia
// ---------------------------------------------------------------------------

function inferMediaType(url: string): string {
  const lower = url.toLowerCase()
  if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?|#|$)/.test(lower)) return 'image'
  if (/\.(mp4|webm|mov|avi|mkv)(\?|#|$)/.test(lower)) return 'video'
  if (/\.(mp3|wav|ogg|aac|flac|m4a)(\?|#|$)/.test(lower)) return 'audio'
  return 'file'
}

async function outboundSendMedia(
  chatType: string,
  to: string,
  mediaUrl: string,
  label: string,
  caption?: string,
  fileName?: string,
  mediaType?: string,
  ctxLog?: any
): Promise<{ ok: boolean; channel: string; error?: string }> {
  const { pluginCfg, getToken, log } = outboundState
  const resolvedType = mediaType || inferMediaType(mediaUrl)

  if (pluginCfg && getToken) {
    log?.info?.(`[genspark-im] outbound.${label}: to=${to} type=${resolvedType} url=${mediaUrl}`)
    try {
      await sendMediaWithRetry(pluginCfg, getToken, chatType, to, mediaUrl, resolvedType, log, caption, fileName)
      return { ok: true, channel: 'genspark-im' }
    } catch (e: any) {
      log?.error?.(`[genspark-im] outbound.${label} failed: ${e}`)
      return { ok: false, channel: 'genspark-im', error: String(e) }
    }
  }

  // Fallback: standalone auth + send media
  try {
    const cfg = resolvePluginConfig()
    const auth = await authenticate(cfg)
    ctxLog?.info?.(`[genspark-im] standalone sendMedia: authenticated, sending to ${to}`)
    const result = await sendMediaMessage(cfg.serverUrl, auth.httpToken, chatType, to, mediaUrl, resolvedType, caption, fileName)
    if (!result.success) {
      throw new Error(`[genspark-im] standalone sendMedia failed to ${to}`)
    }
    return { ok: true, channel: 'genspark-im' }
  } catch (e: any) {
    ctxLog?.error?.(`[genspark-im] outbound.${label} (standalone) failed: ${e}`)
    return { ok: false, channel: 'genspark-im', error: String(e) }
  }
}

// ---------------------------------------------------------------------------
// ChannelPlugin definition
// ---------------------------------------------------------------------------

const gensparkImPlugin: ChannelPlugin = {
  id: 'genspark-im',
  meta: {
    id: 'genspark-im',
    label: 'Genspark IM',
    selectionLabel: 'Genspark IM',
  },
  capabilities: {
    chatTypes: ['direct', 'group'],
  },
  messaging: {
    targetResolver: {
      hint: '<user-uuid or chat-id>',
      looksLikeId: (raw: string) => {
        const t = raw.trim()
        // UUID format (Genspark user IDs)
        if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(t)) return true
        // bot_ps_ prefixed chat IDs
        if (t.startsWith('bot_ps_')) return true
        return false
      },
    },
  },
  config: {
    listAccountIds: (cfg: any) =>
      Object.keys(cfg.channels?.['genspark-im']?.accounts ?? {}),
    resolveAccount: (cfg: any, accountId?: string | null) => {
      const acc =
        cfg.channels?.['genspark-im']?.accounts?.[accountId ?? 'default'] ?? {}
      return { accountId: accountId ?? 'default', ...acc }
    },
  },
  gateway: {
    startAccount: async (ctx: any) => {
      const pluginCfg = resolvePluginConfig()
      ctx.log?.info?.(
        `[genspark-im] Starting monitor: server=${pluginCfg.serverUrl}`
      )
      await runMonitor(pluginCfg, {
        cfg: ctx.cfg,
        accountId: ctx.accountId,
        abortSignal: ctx.abortSignal,
        log: ctx.log,
        channelRuntime: ctx.channelRuntime,
        setStatus: ctx.setStatus,
      })
    },
  },
  agentTools: () => [
    {
      name: 'genspark_im_send_media',
      label: 'Send media via Genspark IM',
      description: 'Send an image, video, audio, or file to a Genspark IM user or group chat. The media_url must be a publicly accessible HTTP(S) URL.',
      parameters: {
        type: 'object' as const,
        properties: {
          chat_id: { type: 'string', description: 'User UUID or group chat ID to send to' },
          media_url: { type: 'string', description: 'Public URL of the media to send' },
          media_type: { type: 'string', enum: ['image', 'video', 'audio', 'file'], description: 'Type of media (default: auto-detected from URL)' },
          caption: { type: 'string', description: 'Optional caption text to accompany the media' },
          file_name: { type: 'string', description: 'Optional file name for the media' },
          chat_type: { type: 'string', enum: ['user', 'group'], description: 'Chat type (default: user)' },
        },
        required: ['chat_id', 'media_url'],
      },
      execute: async (_toolCallId: string, params: any) => {
        const { chat_id, media_url, media_type, caption, file_name, chat_type } = params
        const { pluginCfg, getToken, log } = outboundState
        if (!pluginCfg || !getToken) {
          return { resultForAssistant: 'genspark-im plugin not connected', isError: true }
        }
        try {
          const resolvedType = media_type || inferMediaType(media_url)
          await sendMediaWithRetry(pluginCfg, getToken, chat_type || 'user', chat_id, media_url, resolvedType, log, caption, file_name)
          return { resultForAssistant: `Media sent successfully (${resolvedType}) to ${chat_id}` }
        } catch (e: any) {
          return { resultForAssistant: `Failed to send media: ${e.message || e}`, isError: true }
        }
      },
    },
    {
      name: 'genspark_im_update_bot_profile',
      label: 'Update Genspark IM bot profile',
      description: 'Update the bot\'s display name and/or avatar in Genspark IM.',
      parameters: {
        type: 'object' as const,
        properties: {
          name: { type: 'string', description: 'New display name for the bot' },
          avatar: { type: 'string', description: 'URL of the new avatar image' },
        },
      },
      execute: async (_toolCallId: string, params: any) => {
        const { name, avatar } = params
        if (!name && !avatar) {
          return { resultForAssistant: 'Please provide at least one of: name, avatar', isError: true }
        }
        const { pluginCfg, getToken, log } = outboundState
        if (!pluginCfg || !getToken) {
          return { resultForAssistant: 'genspark-im plugin not connected', isError: true }
        }
        try {
          await updateProfileWithRetry(pluginCfg, getToken, log, name, avatar)
          const parts: string[] = []
          if (name) parts.push(`name → "${name}"`)
          if (avatar) parts.push(`avatar updated`)
          return { resultForAssistant: `Bot profile updated: ${parts.join(', ')}` }
        } catch (e: any) {
          return { resultForAssistant: `Failed to update profile: ${e.message || e}`, isError: true }
        }
      },
    },
  ],
  outbound: {
    deliveryMode: 'direct' as const,
    sendText: async (ctx: any) => {
      const chatType = ctx.chatType || (ctx.to?.startsWith('im_group_') ? 'group' : 'direct')
      return outboundSend(chatType, ctx.to, ctx.text, 'sendText', ctx.log)
    },
    sendMedia: async (ctx: any) => {
      const { to, text, mediaUrl, mediaType, fileName } = ctx
      const chatType = ctx.chatType || (to?.startsWith('im_group_') ? 'group' : 'direct')
      if (mediaUrl) {
        return outboundSendMedia(chatType, to, mediaUrl, 'sendMedia', text, fileName, mediaType, ctx.log)
      }
      // Fallback: no mediaUrl, send as text
      return outboundSend(chatType, to, text || '[media]', 'sendMedia', ctx.log)
    },
  },
}

// ---------------------------------------------------------------------------
// Plugin entry point
// ---------------------------------------------------------------------------

const plugin = {
  id: 'genspark-im',
  name: 'Genspark IM',
  description: 'OpenClaw channel plugin for Genspark IM (CometChat)',
  configSchema: emptyPluginConfigSchema(),
  register(api: OpenClawPluginApi) {
    api.logger.info('[genspark-im] Plugin registering...')
    api.registerChannel({ plugin: gensparkImPlugin })
    api.logger.info('[genspark-im] Plugin registered')
  },
}

export default plugin




