/**
 * DingTalk AI Card streaming module (Layer 1.5).
 *
 * Ported from the official dingtalk-openclaw-connector's card.ts,
 * adapted for Forge's fetch-based HTTP style.
 *
 * Lifecycle:
 *   createAICardForTarget → (PROCESSING)
 *       ↓
 *   streamAICard (first call) → INPUTING status switch (once)
 *       ↓
 *   streamAICard (subsequent) → PUT /v1.0/card/streaming (typewriter)
 *       ↓
 *   finishAICard → streamAICard(isFinalize=true) + PUT FINISHED status
 *
 * All markdown content is normalized via normalizeForCard() before sending,
 * which fixes DingTalk Card rendering quirks (newlines, tables, quotes).
 */

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema'

const CARD_API_MAX_QPS = 20
const QPS_BACKOFF_DURATION_MS = 2_000

const AICardStatus = {
  PROCESSING: '1',
  INPUTING: '2',
  FINISHED: '3',
  EXECUTING: '4',
  FAILED: '5',
} as const

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

export interface AICardInstance {
  cardInstanceId: string
  accessToken: string
  tokenExpireTime: number
  inputingStarted: boolean
}

export type AICardTarget =
  | { type: 'user'; userId: string }
  | { type: 'group'; openConversationId: string }

// ---------------------------------------------------------------------------
// Global token bucket rate limiter
// ---------------------------------------------------------------------------

const cardRateLimiter = {
  tokens: CARD_API_MAX_QPS,
  lastRefillTime: Date.now(),
  backoffUntil: 0,
  _queueTail: Promise.resolve() as Promise<unknown>,

  refill(): void {
    const now = Date.now()
    const elapsedSeconds = (now - this.lastRefillTime) / 1000
    if (elapsedSeconds > 0) {
      this.tokens = Math.min(CARD_API_MAX_QPS, this.tokens + elapsedSeconds * CARD_API_MAX_QPS)
      this.lastRefillTime = now
    }
  },

  async waitForToken(): Promise<number> {
    const prev = this._queueTail
    let release!: () => void
    this._queueTail = new Promise<void>((resolve) => { release = resolve })
    try { await prev } catch { /* ignore predecessor error */ }

    try {
      let totalWaitMs = 0

      const now = Date.now()
      if (now < this.backoffUntil) {
        const backoffWaitMs = this.backoffUntil - now
        await sleep(backoffWaitMs)
        totalWaitMs += backoffWaitMs
      }

      this.refill()

      if (this.tokens < 1) {
        const waitMs = Math.ceil(((1 - this.tokens) / CARD_API_MAX_QPS) * 1000)
        await sleep(waitMs)
        totalWaitMs += waitMs
        this.refill()
      }

      this.tokens -= 1
      return totalWaitMs
    } finally {
      release()
    }
  },

  triggerBackoff(): void {
    this.backoffUntil = Date.now() + QPS_BACKOFF_DURATION_MS
    this.tokens = 0
    this.lastRefillTime = this.backoffUntil
  },
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

// ---------------------------------------------------------------------------
// QPS error detection
// ---------------------------------------------------------------------------

export function isQpsLimitError(err: unknown): boolean {
  const e = err as { response?: { status?: number; data?: { code?: string } } }
  return (
    e?.response?.status === 403 &&
    typeof e?.response?.data?.code === 'string' &&
    e.response.data.code.includes('QpsLimit')
  )
}

// ---------------------------------------------------------------------------
// Markdown normalization for DingTalk Card rendering
// ---------------------------------------------------------------------------

function normalizeLineEndings(text: string): string {
  return text.replace(/\r\n?/g, '\n')
}

export function ensureTableBlankLines(text: string): string {
  const lines = normalizeLineEndings(text).split('\n')
  const result: string[] = []

  const tableDividerRegex = /^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$/
  const tableRowRegex = /^\s*\|?.*\|.*\|?\s*$/

  const isDivider = (line: string) =>
    line && typeof line === 'string' && line.includes('|') && tableDividerRegex.test(line)

  for (let i = 0; i < lines.length; i++) {
    const currentLine = lines[i]
    const nextLine = lines[i + 1] ?? ''

    if (
      tableRowRegex.test(currentLine) &&
      isDivider(nextLine) &&
      i > 0 &&
      lines[i - 1].trim() !== '' &&
      !tableRowRegex.test(lines[i - 1])
    ) {
      result.push('')
    }

    result.push(currentLine)
  }
  return result.join('\n')
}

/**
 * Fix newlines for DingTalk AI Card markdown rendering.
 *
 * DingTalk Card rendering conventions:
 * - Normal text: `<br>` for line break, `\n` does NOT create visual break
 * - Code blocks (```): `\n` for line break, `<br>` shown as literal text
 * - Lists (- / 1.), tables (|), headings (#): `\n` for syntax line separation
 * - Block quotes (>): merge consecutive lines, connect with `<br>`
 * - Paragraph spacing: `\n\n`
 */
export function fixNewlines(text: string): string {
  const normalized = normalizeLineEndings(text)
  const markdownBlockStartPattern =
    /^(\s{0,3}(?:[-*+]|\d+[.)])[ ])|(\s{0,3}\|)|(\s{0,3}#{1,6}\s)|(\s{0,3}(?:[-*_])\s*(?:[-*_])\s*(?:[-*_]))/
  const fencePattern = /^\s{0,3}```/
  const quotePattern = /^\s{0,3}>\s?/

  // Phase 1: Merge consecutive blockquote lines
  const mergedLines: string[] = []
  let pendingQuoteLines: string[] = []
  let inCodeBlock = false

  const flushPendingQuoteLines = () => {
    if (pendingQuoteLines.length > 0) {
      mergedLines.push(pendingQuoteLines.join('<br>'))
      pendingQuoteLines = []
    }
  }

  for (const line of normalized.split('\n')) {
    const isFence = fencePattern.test(line)

    if (inCodeBlock) {
      flushPendingQuoteLines()
      mergedLines.push(line)
      if (isFence) inCodeBlock = false
      continue
    }

    if (isFence) {
      flushPendingQuoteLines()
      mergedLines.push(line)
      inCodeBlock = true
      continue
    }

    if (quotePattern.test(line)) {
      if (pendingQuoteLines.length === 0) {
        pendingQuoteLines.push(line)
      } else {
        pendingQuoteLines.push(line.replace(quotePattern, ''))
      }
    } else {
      flushPendingQuoteLines()
      mergedLines.push(line)
    }
  }
  flushPendingQuoteLines()

  // Phase 2: Line-by-line processing — code blocks keep \n, block syntax keeps \n, rest → <br>
  const lines = mergedLines
  inCodeBlock = false
  const parts: string[] = []

  for (let i = 0; i < lines.length; i++) {
    const currentLine = lines[i]
    const nextInCodeBlock: boolean = fencePattern.test(currentLine) ? !inCodeBlock : inCodeBlock

    if (i < lines.length - 1) {
      const nextLine = lines[i + 1]
      const keepNewline =
        nextInCodeBlock ||
        currentLine === '' ||
        nextLine === '' ||
        fencePattern.test(nextLine) ||
        markdownBlockStartPattern.test(nextLine)
      parts.push(currentLine + (keepNewline ? '\n' : '<br>'))
    } else {
      parts.push(currentLine)
    }

    inCodeBlock = nextInCodeBlock
  }

  return parts.join('')
}

/**
 * Normalize content for DingTalk AI Card: fix table blank lines, then fix newlines.
 * Used by streamAICard and finishAICard on all content paths.
 */
export function normalizeForCard(content: string): string {
  return fixNewlines(ensureTableBlankLines(content))
}

// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------

async function dingtalkPut(url: string, body: unknown, token: string): Promise<void> {
  const res = await fetch(url, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      'x-acs-dingtalk-access-token': token,
    },
    body: JSON.stringify(body),
  })
  if (!res.ok) {
    const text = await res.text().catch(() => '')
    const error: Error & { response?: { status: number; data?: unknown } } = new Error(
      `DingTalk Card PUT failed: ${res.status} ${text.slice(0, 300)}`,
    )
    error.response = { status: res.status, data: tryParseJson(text) }
    throw error
  }
}

async function dingtalkPost(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(() => '')
    const error: Error & { response?: { status: number; data?: unknown } } = new Error(
      `DingTalk Card POST failed: ${res.status} ${text.slice(0, 300)}`,
    )
    error.response = { status: res.status, data: tryParseJson(text) }
    throw error
  }
}

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

// ---------------------------------------------------------------------------
// AI Card: Create
// ---------------------------------------------------------------------------

function buildDeliverBody(
  cardInstanceId: string,
  target: AICardTarget,
  robotCode: string,
): Record<string, unknown> {
  const base = { outTrackId: cardInstanceId, userIdType: 1 }

  if (target.type === 'group') {
    return {
      ...base,
      openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`,
      imGroupOpenDeliverModel: { robotCode },
    }
  }

  return {
    ...base,
    openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`,
    imRobotOpenDeliverModel: {
      spaceType: 'IM_ROBOT',
      robotCode,
      extension: { dynamicSummary: 'true' },
    },
  }
}

/**
 * Create an AI Card instance and deliver it to the target conversation.
 *
 * @returns The AICardInstance (for subsequent stream/finish calls), or null on failure.
 */
export async function createAICardForTarget(
  token: string,
  clientId: string,
  endpoint: string,
  target: AICardTarget,
): Promise<AICardInstance | null> {
  const targetDesc =
    target.type === 'group' ? `group ${target.openConversationId}` : `user ${target.userId}`

  try {
    const cardInstanceId = `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`

    console.log(`[DingTalk][AICard] Creating card for ${targetDesc}, outTrackId=${cardInstanceId}`)

    // Step 1: Create card instance
    await dingtalkPost(
      `${endpoint}/v1.0/card/instances`,
      {
        cardTemplateId: AI_CARD_TEMPLATE_ID,
        outTrackId: cardInstanceId,
        cardData: {
          cardParamMap: {
            config: JSON.stringify({ autoLayout: true }),
          },
        },
        callbackType: 'STREAM',
        imGroupOpenSpaceModel: { supportForward: true },
        imRobotOpenSpaceModel: { supportForward: true },
      },
      token,
    )

    // Step 2: Deliver card to conversation
    const deliverBody = buildDeliverBody(cardInstanceId, target, clientId)
    await dingtalkPost(`${endpoint}/v1.0/card/instances/deliver`, deliverBody, token)

    const tokenExpireTime = Date.now() + 2 * 60 * 60 * 1000
    console.log(`[DingTalk][AICard] Card created successfully for ${targetDesc}`)
    return { cardInstanceId, accessToken: token, tokenExpireTime, inputingStarted: false }
  } catch (err) {
    console.warn(
      `[DingTalk][AICard] Card creation failed for ${targetDesc}: ${err instanceof Error ? err.message : err}`,
    )
    return null
  }
}

// ---------------------------------------------------------------------------
// AI Card: Stream update (typewriter effect)
// ---------------------------------------------------------------------------

/**
 * Stream content update to an existing AI Card.
 *
 * On first call, automatically switches the card to INPUTING status.
 * Subsequent calls push content via PUT /v1.0/card/streaming.
 *
 * Includes global rate limiting with QPS backoff and single retry.
 */
export async function streamAICard(
  card: AICardInstance,
  content: string,
  finished: boolean,
  token: string,
  endpoint: string,
): Promise<void> {
  if (!card) {
    console.warn('[DingTalk][AICard] streamAICard received null card, skipping')
    return
  }

  // Ensure token is fresh (auto-refresh if expiring within 5 minutes)
  const effectiveToken = Date.now() > card.tokenExpireTime - 5 * 60 * 1000
    ? (card.accessToken = token, card.tokenExpireTime = Date.now() + 2 * 60 * 60 * 1000, token)
    : card.accessToken

  // First call: switch to INPUTING status
  if (!card.inputingStarted) {
    const inputingWaitMs = await cardRateLimiter.waitForToken()
    if (inputingWaitMs > 0) {
      console.log(`[DingTalk][AICard] INPUTING waited ${inputingWaitMs}ms for rate limit`)
    }

    const statusBody = {
      outTrackId: card.cardInstanceId,
      cardData: {
        cardParamMap: {
          flowStatus: AICardStatus.INPUTING,
          msgContent: normalizeForCard(content),
          staticMsgContent: '',
          sys_full_json_obj: JSON.stringify({ order: ['msgContent'] }),
          config: JSON.stringify({ autoLayout: true }),
        },
      },
    }

    const putInputing = () =>
      dingtalkPut(`${endpoint}/v1.0/card/instances`, statusBody, effectiveToken)

    try {
      await putInputing()
    } catch (err) {
      if (isQpsLimitError(err)) {
        cardRateLimiter.triggerBackoff()
        console.warn(`[DingTalk][AICard] INPUTING QPS limited, backing off ${QPS_BACKOFF_DURATION_MS}ms then retry`)
        await cardRateLimiter.waitForToken()
        try {
          await putInputing()
        } catch (retryErr) {
          console.warn(`[DingTalk][AICard] INPUTING retry failed: ${retryErr instanceof Error ? retryErr.message : retryErr}`)
          throw retryErr
        }
      } else {
        console.warn(`[DingTalk][AICard] INPUTING failed: ${err instanceof Error ? err.message : err}`)
        throw err
      }
    }
    card.inputingStarted = true
  }

  // Stream content update
  const fixedContent = normalizeForCard(content)
  const streamContent = finished ? fixedContent : fixedContent.replace(/\n+$/, '')

  const body: Record<string, unknown> = {
    outTrackId: card.cardInstanceId,
    guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
    key: 'msgContent',
    content: streamContent,
    isFull: true,
    isFinalize: finished,
    isError: false,
  }

  const streamWaitMs = await cardRateLimiter.waitForToken()
  if (streamWaitMs > 0) {
    console.log(`[DingTalk][AICard] streaming waited ${streamWaitMs}ms for rate limit`)
  }

  try {
    await dingtalkPut(`${endpoint}/v1.0/card/streaming`, body, effectiveToken)
  } catch (err) {
    if (isQpsLimitError(err)) {
      cardRateLimiter.triggerBackoff()
      console.warn(`[DingTalk][AICard] streaming QPS limited, backing off ${QPS_BACKOFF_DURATION_MS}ms then retry`)
      await cardRateLimiter.waitForToken()
      try {
        body.guid = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
        await dingtalkPut(`${endpoint}/v1.0/card/streaming`, body, effectiveToken)
        return
      } catch (retryErr) {
        console.warn(`[DingTalk][AICard] streaming retry failed: ${retryErr instanceof Error ? retryErr.message : retryErr}`)
        throw retryErr
      }
    }
    throw err
  }
}

// ---------------------------------------------------------------------------
// AI Card: Finish
// ---------------------------------------------------------------------------

/**
 * Finish an AI Card: send final content + switch to FINISHED status.
 *
 * Must call streamAICard(isFinalize=true) first, then PUT FINISHED status.
 * Skipping either step leaves the card stuck in "thinking" state.
 */
export async function finishAICard(
  card: AICardInstance,
  content: string,
  token: string,
  endpoint: string,
): Promise<void> {
  const fixedContent = normalizeForCard(content)
  console.log(`[DingTalk][AICard] Finishing card, content length=${fixedContent.length}`)

  // Step 1: Send finalize frame
  await streamAICard(card, fixedContent, true, token, endpoint)

  // Step 2: Switch to FINISHED status
  const effectiveToken = card.accessToken

  const body = {
    outTrackId: card.cardInstanceId,
    cardData: {
      cardParamMap: {
        flowStatus: AICardStatus.FINISHED,
        msgContent: fixedContent,
        staticMsgContent: '',
        sys_full_json_obj: JSON.stringify({ order: ['msgContent'] }),
        config: JSON.stringify({ autoLayout: true }),
      },
    },
    cardUpdateOptions: { updateCardDataByKey: true },
  }

  const putFinished = () =>
    dingtalkPut(`${endpoint}/v1.0/card/instances`, body, effectiveToken)

  try {
    await cardRateLimiter.waitForToken()
    await putFinished()
    console.log(`[DingTalk][AICard] FINISHED status set successfully`)
  } catch (err) {
    if (isQpsLimitError(err)) {
      cardRateLimiter.triggerBackoff()
      console.warn(`[DingTalk][AICard] FINISHED QPS limited, backing off ${QPS_BACKOFF_DURATION_MS}ms then retry`)
      try {
        await cardRateLimiter.waitForToken()
        await putFinished()
        return
      } catch (retryErr) {
        console.warn(`[DingTalk][AICard] FINISHED retry failed: ${retryErr instanceof Error ? retryErr.message : retryErr}`)
      }
    } else {
      console.warn(`[DingTalk][AICard] FINISHED failed: ${err instanceof Error ? err.message : err}`)
    }
  }
}
