import type {
  ApprovalRequest,
  GoatChainContext,
  Message,
  RunConfig,
  ToolApprovalsMode,
  ToolCall,
  ToolResult,
} from '#cli/core/model'
import type {
  AgentRoundResult,
  ToolExecutionContextInput,
} from './stream-runner'
import type { PausedState } from './types'
import { normalizeText } from '#cli/core/utils'
import { t } from '#cli/i18n'
import { shouldAutoApproveRequest } from './approvals'
import { formatAgentErrorForUi, formatAgentErrorHintForUi } from './errors'

type ToolApprovalResult = Readonly<{ approved: boolean, reason?: string }>

function isClosedDatabaseRoundError(error: unknown): boolean {
  const text = error instanceof Error ? `${error.name} ${error.message}` : String(error ?? '')
  return /\bdatabase is not open\b/i.test(text)
}

interface SendLoopState {
  config: RunConfig
  context: GoatChainContext
  sessionId?: string
  messages: Message[]
  runningAgentRound: boolean
  runningToolCallId: string | null
  paused: PausedState | null
  approval: {
    active: ApprovalRequest | null
    pendingAutoRun: null | { assistantId: string, toolCallId: string }
    queue: Array<{ request: ApprovalRequest, assistantId: string }>
    decisions: Record<string, ToolApprovalResult>
    mode: ToolApprovalsMode
  }
}

export type SendLoopControls = Readonly<{
  send: (
    input:
      | string
      | { content: string, focusFiles?: readonly string[], steer?: boolean },
  ) => Promise<void>
  isInSendQueueRun: () => boolean
}>

export function createSendLoop(
  opts: Readonly<{
    state: SendLoopState
    externalAgentProvided: boolean
    id: (prefix: string) => string
    pushMessage: (m: Message) => void
    replaceMessage: (messageId: string, next: Message) => void
    setAssistantStatus: (messageId: string, text: string) => void
    setFocusFiles: (files: readonly string[] | undefined) => void
    runAgentRound: (opts?: {
      toolContext?: ToolExecutionContextInput
      baseMessages?: Message[]
      steer?: boolean
    }) => Promise<AgentRoundResult>
    abortActiveAgentRound: () => void
    cancelActiveAgentStream: () => void
    cancelActiveAgentStreamAndWaitWithTimeout: (
      timeoutMs?: number,
    ) => Promise<void>
    ensureApproved: (assistantId: string, call: ToolCall) => 'ok' | 'pending'
    runToolCall: (callId: string) => Promise<ToolResult | null>
    resolveActiveApproval: (decision: 'approved' | 'denied') => Promise<void>
    sleep: (ms: number) => Promise<void>
    yieldToEventLoop: () => Promise<void>
    enqueue: (fn: () => Promise<void>) => Promise<void>
    setInSendQueueRun: (value: boolean) => void
    interruptAndWait: (opts?: {
      streamCleanupTimeoutMs?: number
    }) => Promise<void>
    getInterruptEpoch: () => number
    rewriteUserInput?: (
      opts: Readonly<{ content: string, sessionId?: string }>,
    ) => Promise<string>
  }>,
): SendLoopControls {
  let inSendQueueRun = false
  let sendLoopRunning = false
  let pendingSend: null | {
    content: string
    focusFiles?: readonly string[]
    interruptEpoch: number
    steer?: boolean
  } = null
  let pendingUserMessageId: string | null = null
  let sendCoalesceUntilMs = 0
  const SEND_COALESCE_MS = 60

  const setSendQueueRun = (value: boolean): void => {
    inSendQueueRun = value
    opts.setInSendQueueRun(value)
  }

  function isBusyForSend(): boolean {
    if (opts.state.runningAgentRound)
      return true
    if (opts.state.paused)
      return true
    return Boolean(
      opts.state.approval.active
      && opts.state.approval.active.status === 'pending',
    )
  }

  function upsertPendingUserMessage(
    content: string,
    focusFiles?: readonly string[],
  ): void {
    const nextContent = String(content ?? '')
    const nextFocusFiles = focusFiles?.length ? [...focusFiles] : undefined

    if (pendingUserMessageId) {
      const idx = opts.state.messages.findIndex(
        m => m.role === 'user' && m.id === pendingUserMessageId,
      )
      if (idx >= 0) {
        const prev = opts.state.messages[idx] as Extract<
          Message,
          { role: 'user' }
        >
        opts.replaceMessage(pendingUserMessageId, {
          ...prev,
          content: nextContent,
          focusFiles: nextFocusFiles,
        } as any)
        return
      }
    }

    const messageId = opts.id('m')
    pendingUserMessageId = messageId
    const modelId
      = String((opts.state as any)?.config?.model ?? '').trim() || undefined
    opts.pushMessage({
      id: messageId,
      role: 'user',
      content: nextContent,
      focusFiles: nextFocusFiles,
      createdAtMs: Date.now(),
      ...(modelId ? { modelId } : {}),
    })
  }

  async function send(
    input:
      | string
      | { content: string, focusFiles?: readonly string[], steer?: boolean },
  ): Promise<void> {
    const raw = typeof input === 'string' ? input : input.content
    const focusFiles = typeof input === 'string' ? undefined : input.focusFiles
    const steer = typeof input === 'string' ? false : input.steer === true
    const requestedEpoch = opts.getInterruptEpoch()
    const normalized = normalizeText(raw)
    const ensuredSessionId = (() => {
      const current = String(opts.state.sessionId ?? '').trim()
      if (current)
        return current
      const generated = opts.id('s')
      opts.state.sessionId = generated
      return generated
    })()
    const content = opts.rewriteUserInput
      ? normalizeText(
          await opts.rewriteUserInput({
            content: normalized,
            sessionId: ensuredSessionId || undefined,
          }),
        )
      : normalized
    if (!content && !focusFiles?.length)
      return

    if (focusFiles?.length)
      opts.setFocusFiles(focusFiles)

    // Keep only the latest pending send while busy. Update the same "pending" user message
    // so spamming Enter doesn't flood transcript with messages that won't be sent.
    pendingSend = {
      content,
      ...(focusFiles?.length ? { focusFiles } : {}),
      ...(steer ? { steer: true } : {}),
      interruptEpoch: requestedEpoch,
    }
    upsertPendingUserMessage(content, focusFiles)

    if (isBusyForSend()) {
      sendCoalesceUntilMs = Date.now() + SEND_COALESCE_MS
      await opts.interruptAndWait({ streamCleanupTimeoutMs: 1000 })
      opts.state.paused = null
      opts.state.approval.active = null
      opts.state.approval.pendingAutoRun = null
      opts.state.approval.queue = []
      opts.state.approval.decisions = {}
      pendingSend = {
        content,
        ...(focusFiles?.length ? { focusFiles } : {}),
        ...(steer ? { steer: true } : {}),
        interruptEpoch: opts.getInterruptEpoch(),
      }
    }

    const runSendLoop = async (): Promise<void> => {
      if (sendLoopRunning)
        return
      sendLoopRunning = true
      try {
        while (pendingSend) {
          const now = Date.now()
          if (sendCoalesceUntilMs > now) {
            await opts.sleep(sendCoalesceUntilMs - now)
            continue
          }
          // Snapshot pending send metadata at execution time (not at keypress time), so
          // we always send the latest user input after any interrupts settle.
          const queued = pendingSend
          if (!queued) {
            pendingUserMessageId = null
            continue
          }
          pendingSend = null
          pendingUserMessageId = null

          setSendQueueRun(true)
          const baseMessages = opts.state.messages.slice()
          const startEpoch = queued.interruptEpoch
          // Find the pending user message so we can detect if a concurrent session
          // hydration erases it while we yield below.
          const pendingRoundUserMsg = (() => {
            for (let i = baseMessages.length - 1; i >= 0; i--) {
              if (baseMessages[i]?.role === 'user')
                return baseMessages[i]
            }
            return null
          })()
          try {
            // Yield so the UI can render the "sending/loading" state immediately before
            // potentially expensive stream setup runs.
            await opts.yieldToEventLoop()
            // Guard: session polling may call hydrateStoreFromSnapshot during the yield,
            // which replaces state.messages from the backend snapshot. If the pending user
            // message was wiped (it has not yet been persisted to the snapshot), re-push it
            // so this round remains visible in the UI transcript.
            if (
              pendingRoundUserMsg
              && !opts.state.messages.some(m => m.id === pendingRoundUserMsg.id)
            ) {
              opts.pushMessage(pendingRoundUserMsg)
            }
            const abortImmediately = opts.getInterruptEpoch() !== startEpoch
            const round = opts.runAgentRound({
              baseMessages,
              ...(queued.steer ? { steer: true } : {}),
            })
            if (abortImmediately) {
              opts.abortActiveAgentRound()
              await opts.cancelActiveAgentStreamAndWaitWithTimeout(200)
            }
            const { assistantId, toolCalls, success, error } = await round

            // Handle concurrent execution errors gracefully
            if (!success && error) {
              if (!isClosedDatabaseRoundError(error))
                console.warn('Agent round failed:', error.message)
              // Clear running state to allow retry
              opts.state.runningAgentRound = false
              setSendQueueRun(false)
              continue
            }

            if (opts.state.paused) {
              // Optional auto-approve mode (either fully `all`, or `auto` for low-risk permissions).
              if (opts.state.paused.kind === 'tool_approval') {
                // A single user send may trigger multiple approval rounds (resume → next approval → resume...).
                // Keep auto-approving until the agent no longer requires approval.
                for (let i = 0; i < 16; i++) {
                  const active = opts.state.approval.active
                  if (!active || active.status !== 'pending')
                    break
                  if (
                    !shouldAutoApproveRequest(opts.state.approval.mode, active)
                  )
                    break
                  await opts.resolveActiveApproval('approved')
                  if (
                    !opts.state.paused
                    || opts.state.paused.kind !== 'tool_approval'
                  ) {
                    break
                  }
                }
              }
              continue
            }

            // When an external agent is provided, it is responsible for tool execution and
            // final responses. The extra "mock flow" below is only for the built-in demo agent.
            if (opts.externalAgentProvided) {
              void assistantId
              void toolCalls
              continue
            }

            // Mock "thinking" → tool execution → final summary.
            await opts.sleep(500)
            opts.setAssistantStatus(
              assistantId,
              toolCalls.length
                ? t('chatStatus.planning')
                : t('chatStatus.drafting'),
            )
            await opts.sleep(500)

            if (toolCalls.length > 0) {
              const pendingToolCalls = toolCalls.filter(
                tc =>
                  !opts.state.messages.some(
                    m => m.role === 'tool' && m.result.toolCallId === tc.id,
                  ),
              )
              if (pendingToolCalls.length === 0) {
                opts.setAssistantStatus(assistantId, t('chatStatus.done'))
                continue
              }

              const approvalResults = pendingToolCalls.map(tc => ({
                tc,
                status: opts.ensureApproved(assistantId, tc),
              }))
              const needsApproval = approvalResults.some(
                r => r.status === 'pending',
              )
              if (needsApproval)
                continue

              opts.setAssistantStatus(
                assistantId,
                t('chatStatus.runningTools', {
                  count: pendingToolCalls.length,
                }),
              )
              await opts.sleep(500)

              const toolResults = await Promise.all(
                pendingToolCalls.map(async (tc) => {
                  const result = await opts.runToolCall(tc.id)
                  return { tc, result }
                }),
              )

              await opts.sleep(300)
              opts.setAssistantStatus(assistantId, t('chatStatus.done'))

              const summary = [
                '# Summary',
                '',
                'Here is a mocked end-to-end flow:',
                '',
                ...toolResults
                  .filter(r => r.result)
                  .map(
                    r => `- Ran \`${r.tc.name}\` and captured a tool result`,
                  ),
                '- Rendered markdown + code fences',
                '',
                '## Tool output (excerpt)',
                '',
                ...toolResults
                  .filter(r => r.result)
                  .map(r =>
                    [
                      '',
                      `\`${r.tc.name}\`:`,
                      '```text',
                      normalizeText(r.result!.output)
                        .split('\n')
                        .slice(0, 8)
                        .join('\n'),
                      '```',
                    ].join('\n'),
                  ),
                '',
                '## Next',
                '',
                '- You can select a `tool_call` on the left and re-run it',
                '- (Coming next) approval-gated tools + context token breakdown',
              ].join('\n')
              const modelId
                = String((opts.state as any)?.config?.model ?? '').trim()
                  || undefined
              opts.pushMessage({
                id: opts.id('m'),
                role: 'assistant',
                parts: [{ type: 'markdown', markdown: summary }],
                createdAtMs: Date.now(),
                ...(modelId ? { modelId } : {}),
              })
            }
            else {
              opts.setAssistantStatus(assistantId, t('chatStatus.done'))
            }
          }
          catch (e) {
            opts.abortActiveAgentRound()
            opts.cancelActiveAgentStream()
            opts.state.runningAgentRound = false
            opts.state.runningToolCallId = null
            opts.state.paused = null
            opts.state.approval.active = null
            opts.state.approval.pendingAutoRun = null
            opts.state.approval.queue = []
            opts.state.approval.decisions = {}

            const env = opts.state.context?.env
            const errText = formatAgentErrorForUi(e)
            const hint = formatAgentErrorHintForUi(errText, env)
            const markdown = [
              '# Error',
              '',
              '```text',
              errText,
              '```',
              ...(hint ? ['', hint] : []),
            ].join('\n')

            const modelId
              = String((opts.state as any)?.config?.model ?? '').trim()
                || undefined
            opts.pushMessage({
              id: opts.id('m'),
              role: 'assistant',
              parts: [
                { type: 'status', text: t('chatStatus.error') },
                { type: 'markdown', markdown },
              ],
              createdAtMs: Date.now(),
              ...(modelId ? { modelId } : {}),
            })
          }
          finally {
            setSendQueueRun(false)
          }
        }
      }
      finally {
        sendLoopRunning = false
      }
    }

    await opts.enqueue(runSendLoop)
  }

  return {
    send,
    isInSendQueueRun: () => inSendQueueRun,
  }
}
