import type {
  Message,
  ReadPreview,
  ToolCall,
} from '#cli/core/model'
import type { ThemeId } from '#cli/core/theme'
import { Buffer } from 'node:buffer'
import { debugLog } from '#cli/core/debug-log'
import {
  createEnvReader,
  readProcessEnv,
} from '#cli/core/env'
import { isReadLikeToolName } from '#cli/core/tool-metadata'
import { resolveShikiTheme } from '#cli/core/theme'
import { computeReadPreviewWithShiki } from '../../ui/chat/read-highlight'
import { stripReadLineNumberPadding } from '../../ui/chat/read-utils'
import { createLruCache } from './highlight-cache'

type AssistantMessage = Extract<Message, { role: 'assistant' }>
type ToolCallPart = Extract<AssistantMessage['parts'][number], { type: 'tool_call' }>
type ToolResultPart = Extract<AssistantMessage['parts'][number], { type: 'tool_result' }>
type ReadHighlightState = Readonly<{
  messages: Message[]
  selectedToolCallId: string | null
  expandedBashOutputs: ReadonlySet<string>
  ui?: {
    theme?: {
      mode?: ThemeId
    }
  }
}>

function isPlainObject(v: unknown): v is Record<string, unknown> {
  return v != null && typeof v === 'object' && !Array.isArray(v)
}

function readString(record: Record<string, unknown>, key: string): string | null {
  return typeof record[key] === 'string' ? String(record[key]) : null
}

function getTextHash(text: string): string {
  let hash = 0
  const str = String(text ?? '')
  for (let i = 0; i < str.length; i++) {
    const ch = str.charCodeAt(i)
    hash = (hash << 5) - hash + ch
    hash = hash & hash
  }
  return hash.toString(36)
}

function extractTextLinesFromJsonish(seg: string): string[] | null {
  const raw = String(seg ?? '').trim()
  if (!raw) return null
  try {
    const parsed = JSON.parse(raw)
    const items = Array.isArray(parsed) ? parsed : [parsed]
    const out: string[] = []
    for (const item of items) {
      if (!isPlainObject(item) || item.type !== 'text') continue
      const text = String(item.text ?? '').replace(/\r/g, '')
      if (text) out.push(...text.split('\n'))
    }
    return out.length > 0 ? out : null
  } catch {
    return null
  }
}

function extractFilePathFromToolCallArgs(
  args: Record<string, unknown> | undefined,
): string | null {
  const a = args ?? {}
  return (
    readString(a, 'file_path')?.trim()
    || readString(a, 'filePath')?.trim()
    || readString(a, 'path')?.trim()
    || null
  )
}

function getNormalizedReadOutput(output: string): {
  normalized: string
  totalLines: number
  bytes: number
  hash: string
} {
  const jsonLines = extractTextLinesFromJsonish(output)
  const lines =
    jsonLines ??
    String(output ?? '')
      .replace(/\r/g, '')
      .split('\n')
  const normalizedLines = lines.map((l) => stripReadLineNumberPadding(l).text)
  const normalized = normalizedLines.join('\n')
  const bytes = Buffer.byteLength(normalized, 'utf8')
  const hash = getTextHash(normalized)
  return { normalized, totalLines: normalizedLines.length, bytes, hash }
}

function findToolCallPart(
  message: AssistantMessage,
  callId: string,
): ToolCallPart | null {
  return (
    message.parts.find(
      (part): part is ToolCallPart =>
        part.type === 'tool_call' && part.call.id === callId,
    ) ?? null
  )
}

function findAssistantContainingToolCall(
  messages: readonly Message[],
  callId: string,
): { assistantId: string; call: ToolCall } | null {
  for (const message of messages) {
    if (message.role !== 'assistant') continue
    const part = findToolCallPart(message, callId)
    if (part) return { assistantId: message.id, call: part.call }
  }
  return null
}

function findToolResultOutput(
  messages: readonly Message[],
  assistantId: string,
  callId: string,
): string | null {
  const message = messages.find(mm => mm.id === assistantId)
  if (!message || message.role !== 'assistant') return null
  const part = message.parts.find(
    (entry): entry is ToolResultPart =>
      entry.type === 'tool_result' && entry.result.toolCallId === callId,
  )
  return typeof part?.result.output === 'string' ? part.result.output : null
}

function previewSatisfiesView(
  preview: ReadPreview | undefined,
  mode: 'collapsed' | 'expanded',
  hash: string,
  totalLines: number,
  theme: string,
): boolean {
  if (!preview) return false
  if (preview.hash !== hash || preview.totalLines !== totalLines) return false
  if (preview.kind === 'shiki' && String(preview.theme ?? '').trim() !== theme)
    return false
  if (preview.mode === 'expanded' && mode === 'collapsed') return true
  if (preview.mode !== mode) return false
  if (preview.kind === 'shiki') {
    if (mode === 'expanded') return preview.lines.length === totalLines
    return preview.lines.length >= Math.min(totalLines, 10)
  }
  return true
}

const MAX_BYTES_FOR_EXPANDED = 200_000
const MAX_LINES_FOR_EXPANDED = 2000

const env = createEnvReader(readProcessEnv())
const inFlight = new Set<string>()
const readCache = createLruCache<string, ReadPreview>(
  (() => {
    const raw = env.prefixedStr('READ_HIGHLIGHT_CACHE')
    const n = Number(raw)
    if (Number.isFinite(n) && n > 0)
      return Math.min(10_000, Math.max(50, Math.floor(n)))
    return 500
  })(),
)

function yieldToMacrotask(): Promise<void> {
  return new Promise((resolve) => {
    const si = (
      globalThis as typeof globalThis & {
        setImmediate?: (fn: () => void) => void
      }
    ).setImmediate
    if (typeof si === 'function') si(resolve)
    else setTimeout(resolve, 0)
  })
}

function resolveReadTheme(state: ReadHighlightState): string {
  return resolveShikiTheme(state.ui?.theme?.mode)
}

export function clearReadHighlightInFlight(): void {
  inFlight.clear()
  readCache.clear()
}

export function scheduleToolCallReadHighlightByCallId(
  opts: Readonly<{
    state: ReadHighlightState
    callId: string
    mode: 'collapsed' | 'expanded'
    updateAssistant: (
      assistantId: string,
      fn: (
        m: Extract<Message, { role: 'assistant' }>,
      ) => Extract<Message, { role: 'assistant' }>,
    ) => void
  }>,
): void {
  const callId = String(opts.callId ?? '').trim()
  if (!callId) return

  void (async () => {
    // Let the UI render plain text first; compute highlighting afterward.
    await yieldToMacrotask()

    const found = findAssistantContainingToolCall(opts.state.messages, callId)
    if (!found) return

    const toolNameLower = String(found.call.name ?? '')
      .trim()
      .toLowerCase()
    if (!isReadLikeToolName(toolNameLower)) return

    const output = findToolResultOutput(
      opts.state.messages,
      found.assistantId,
      callId,
    )
    if (output == null) return

    const { totalLines, bytes, hash } = getNormalizedReadOutput(output)
    const theme = resolveReadTheme(opts.state)
    if (
      previewSatisfiesView(
        found.call.readPreview,
        opts.mode,
        hash,
        totalLines,
        theme,
      )
    )
      return

    const filePath =
      extractFilePathFromToolCallArgs(
        isPlainObject(found.call.arguments) ? found.call.arguments : undefined,
      ) ?? undefined
    const cacheKey = `${opts.mode}:${hash}:${totalLines}:${theme}:${filePath ?? ''}`
    const cachedPreview = readCache.get(cacheKey)
    if (
      cachedPreview &&
      previewSatisfiesView(cachedPreview, opts.mode, hash, totalLines, theme)
    ) {
      opts.updateAssistant(found.assistantId, (m) => {
        const parts = m.parts.slice()
        const idx = parts.findIndex(
          (p) => p.type === 'tool_call' && p.call.id === callId,
        )
        if (idx < 0) return m
        const prev = parts[idx] as Extract<
          (typeof parts)[number],
          { type: 'tool_call' }
        >
        const prevCall = prev.call as ToolCall
        if (!isReadLikeToolName(prevCall.name))
          return m

        const nowOutput = findToolResultOutput([m], m.id, callId)
        if (nowOutput == null) return m
        const nowNorm = getNormalizedReadOutput(nowOutput)
        if (nowNorm.hash !== hash || nowNorm.totalLines !== totalLines) return m

        const prevFp =
          extractFilePathFromToolCallArgs(
            isPlainObject(prevCall.arguments) ? prevCall.arguments : undefined,
          ) ??
          undefined
        if (prevFp !== filePath) return m

        if (
          previewSatisfiesView(
            prevCall.readPreview,
            opts.mode,
            cachedPreview.hash,
            cachedPreview.totalLines,
            theme,
          )
        )
          return m

        parts[idx] = {
          type: 'tool_call',
          call: { ...prevCall, readPreview: cachedPreview },
        } as const
        return { ...m, parts }
      })
      return
    }

    const flightKey = `${callId}:${opts.mode}:${hash}:${totalLines}:${theme}`
    if (inFlight.has(flightKey)) return

    // Avoid expensive full highlighting for very large reads; remember the skip to prevent repeated work.
    if (
      opts.mode === 'expanded' &&
      (bytes > MAX_BYTES_FOR_EXPANDED || totalLines > MAX_LINES_FOR_EXPANDED)
    ) {
      const skipped: ReadPreview = {
        kind: 'skipped',
        mode: 'expanded',
        hash,
        totalLines,
        reason: 'too_large',
      }
      readCache.set(cacheKey, skipped)
      opts.updateAssistant(found.assistantId, (m) => {
        const parts = m.parts.slice()
        const idx = parts.findIndex(
          (p) => p.type === 'tool_call' && p.call.id === callId,
        )
        if (idx < 0) return m
        const prev = parts[idx] as Extract<
          (typeof parts)[number],
          { type: 'tool_call' }
        >
        const prevCall = prev.call as ToolCall
        if (!isReadLikeToolName(prevCall.name))
          return m

        const nowOutput = findToolResultOutput([m], m.id, callId)
        if (nowOutput == null) return m
        const nowNorm = getNormalizedReadOutput(nowOutput)
        if (nowNorm.hash !== hash || nowNorm.totalLines !== totalLines) return m

        if (
          prevCall.readPreview?.kind === 'skipped' &&
          prevCall.readPreview.mode === skipped.mode &&
          prevCall.readPreview.hash === skipped.hash &&
          prevCall.readPreview.totalLines === skipped.totalLines &&
          prevCall.readPreview.reason === skipped.reason
        ) {
          return m
        }

        parts[idx] = {
          type: 'tool_call',
          call: { ...prevCall, readPreview: skipped },
        } as const
        return { ...m, parts }
      })
      return
    }

    inFlight.add(flightKey)
    try {
      const preview = await computeReadPreviewWithShiki(output, {
        filePath,
        mode: opts.mode,
        maxLines: 10,
        theme,
      })
      if (!preview) return
      readCache.set(cacheKey, preview)

      opts.updateAssistant(found.assistantId, (m) => {
        const parts = m.parts.slice()
        const idx = parts.findIndex(
          (p) => p.type === 'tool_call' && p.call.id === callId,
        )
        if (idx < 0) return m

        const prev = parts[idx] as Extract<
          (typeof parts)[number],
          { type: 'tool_call' }
        >
        const prevCall = prev.call as ToolCall
        if (!isReadLikeToolName(prevCall.name))
          return m

        const nowOutput = findToolResultOutput([m], m.id, callId)
        if (nowOutput == null) return m

        const nowNorm = getNormalizedReadOutput(nowOutput)
        if (nowNorm.hash !== hash || nowNorm.totalLines !== totalLines) return m

        const prevFp =
          extractFilePathFromToolCallArgs(
            isPlainObject(prevCall.arguments) ? prevCall.arguments : undefined,
          ) ??
          undefined
        if (prevFp !== filePath) return m

        if (
          previewSatisfiesView(
            prevCall.readPreview,
            opts.mode,
            preview.hash,
            preview.totalLines,
            theme,
          )
        )
          return m

        parts[idx] = {
          type: 'tool_call',
          call: { ...prevCall, readPreview: preview },
        } as const
        return { ...m, parts }
      })
    } catch (e) {
      debugLog('read_highlight_error', {
        tool: 'read-file',
        callId,
        assistantId: found.assistantId,
        mode: opts.mode,
        filePath,
        error:
          e instanceof Error
            ? { message: e.message, stack: e.stack }
            : String(e),
      })
    } finally {
      inFlight.delete(flightKey)
    }
  })()
}
