import { readPrefixedEnvFlag } from './env'
import { sanitizeMarkdownText } from './markdown/sanitize'
import {
  padEndByCells,
  sliceByCells,
  takeByCells,
  textCellWidth,
  wrapLineByCells,
} from './markdown/wrap'
import { isPerfEnabled, perfLog, withPerfSpan } from './observability/perf-log'

export type MarkdownSpan = Readonly<{
  text: string
  kind: 'text' | 'bold' | 'code'
}>

export type MarkdownLine = Readonly<{
  kind: 'heading' | 'quote' | 'code' | 'list' | 'text'
  spans: readonly MarkdownSpan[]
}>

export type FencedMarkdownBlock
  = | Readonly<{ type: 'text', text: string }>
    | Readonly<{ type: 'code', language: string, code: string, closed: boolean }>

/**
 * Split a markdown string into alternating text/code blocks for triple-backtick fences.
 * Keeps the original newline structure (excluding the fence marker lines themselves).
 *
 * Note: For streaming markdown, the last code block may be `closed: false` if the closing
 * fence hasn't arrived yet.
 */
export function splitMarkdownByFencedCodeBlocks(
  markdown: string,
): FencedMarkdownBlock[] {
  const raw = String(markdown ?? '').replace(/\r/g, '')
  if (!raw)
    return [{ type: 'text', text: '' }]

  const lines = raw.split('\n')
  const out: FencedMarkdownBlock[] = []

  let inCode = false
  let codeLang = ''
  let textBuf: string[] = []
  let codeBuf: string[] = []

  const flushText = () => {
    if (textBuf.length === 0)
      return
    out.push({ type: 'text', text: textBuf.join('\n') })
    textBuf = []
  }
  const flushCode = (closed: boolean) => {
    out.push({
      type: 'code',
      language: codeLang,
      code: codeBuf.join('\n'),
      closed,
    })
    codeBuf = []
    codeLang = ''
  }

  for (const line of lines) {
    if (line.startsWith('```')) {
      if (!inCode) {
        flushText()
        inCode = true
        codeLang = line.slice(3).trim()
        continue
      }
      inCode = false
      flushCode(true)
      continue
    }
    if (inCode) {
      codeBuf.push(line)
    }
    else {
      textBuf.push(line)
    }
  }

  if (inCode) {
    flushText()
    flushCode(false)
  }
  else {
    flushText()
  }

  return out.length > 0 ? out : [{ type: 'text', text: '' }]
}

// Cache for markdown parsing to avoid re-parsing during streaming
interface MarkdownCacheEntry {
  hash: string
  lines: MarkdownLine[]
  timestamp: number
}

const markdownCache = new Map<string, MarkdownCacheEntry>()
const MAX_CACHE_SIZE = 100
const CACHE_TTL_MS = 60000 // 1 minute
let lastSlowMarkdownLogAt = 0
let cacheCleanupInterval: ReturnType<typeof setInterval> | null = null

function getMarkdownHash(markdown: string, width: number): string {
  let hash = 0
  const str = `${markdown}|${width}`
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i)
    hash = (hash << 5) - hash + char
    hash = hash & hash
  }
  return hash.toString(36)
}

function cleanExpiredCache(): void {
  const now = Date.now()
  for (const [key, entry] of markdownCache.entries()) {
    if (now - entry.timestamp > CACHE_TTL_MS) {
      markdownCache.delete(key)
    }
  }
}

export function startMarkdownCacheCleanup(): void {
  if (cacheCleanupInterval)
    return
  if (typeof setInterval !== 'undefined') {
    cacheCleanupInterval = setInterval(cleanExpiredCache, 10000)
    ;(cacheCleanupInterval as any).unref?.()
  }
}

export function stopMarkdownCacheCleanup(): void {
  if (cacheCleanupInterval) {
    clearInterval(cacheCleanupInterval)
    cacheCleanupInterval = null
  }
}

export function clearMarkdownCache(): void {
  markdownCache.clear()
}

function parseInline(text: string): MarkdownSpan[] {
  const out: MarkdownSpan[] = []
  const src = String(text ?? '')
  if (!src)
    return out

  let i = 0
  let textStart = 0

  const flushText = (end: number) => {
    if (end > textStart)
      out.push({ kind: 'text', text: src.slice(textStart, end) })
  }

  let iterations = 0
  const MAX_ITERATIONS = src.length * 10 + 1000

  while (i < src.length) {
    iterations++
    if (iterations > MAX_ITERATIONS)
      break

    const ch = src[i]!
    if (ch === '`') {
      const end = src.indexOf('`', i + 1)
      if (end > i + 1) {
        flushText(i)
        out.push({ kind: 'code', text: src.slice(i + 1, end) })
        i = end + 1
        textStart = i
        continue
      }
    }
    if (ch === '*' && src[i + 1] === '*') {
      const end = src.indexOf('**', i + 2)
      if (end > i + 2) {
        flushText(i)
        out.push({ kind: 'bold', text: src.slice(i + 2, end) })
        i = end + 2
        textStart = i
        continue
      }
    }

    i++
  }

  flushText(src.length)
  return out
}

function wrapSpans(
  spans: readonly MarkdownSpan[],
  width: number,
): MarkdownSpan[][] {
  if (width <= 0)
    return [[{ kind: 'text', text: '' }]]

  function tokenizeSpan(span: MarkdownSpan): MarkdownSpan[] {
    const out: MarkdownSpan[] = []
    const re = /\s+/g
    let last = 0
    for (let m = re.exec(span.text); m; m = re.exec(span.text)) {
      if (m.index > last)
        out.push({ kind: span.kind, text: span.text.slice(last, m.index) })
      out.push({ kind: span.kind, text: m[0] })
      last = m.index + m[0].length
    }
    if (last < span.text.length)
      out.push({ kind: span.kind, text: span.text.slice(last) })
    return out
  }

  const out: MarkdownSpan[][] = []
  let line: MarkdownSpan[] = []
  let col = 0

  function spanCells(span: MarkdownSpan): number {
    if (!span.text)
      return 0
    if (/^\s+$/.test(span.text))
      return span.text.length
    return textCellWidth(span.text)
  }

  function pushSpan(span: MarkdownSpan): void {
    const prev = line[line.length - 1]
    if (prev && prev.kind === span.kind) {
      ;(prev as any).text += span.text
      return
    }
    line.push({ ...span })
  }

  function pushLine(): void {
    out.push(line)
    line = []
    col = 0
  }

  for (const span of spans) {
    const tokens = tokenizeSpan(span)
    for (const token of tokens) {
      if (!token.text)
        continue
      if (/^\s+$/.test(token.text) && col === 0)
        continue

      const tokenW = spanCells(token)
      if (tokenW <= width - col) {
        pushSpan(token)
        col += tokenW
        continue
      }

      if (!/^\s+$/.test(token.text) && col > 0 && tokenW <= width) {
        pushLine()
        pushSpan(token)
        col += tokenW
        continue
      }

      let remaining = token.text
      let chunkIterations = 0
      const MAX_CHUNK_ITERATIONS = token.text.length * 10 + 100

      while (remaining) {
        chunkIterations++
        if (chunkIterations > MAX_CHUNK_ITERATIONS)
          break

        const available = width - col
        if (available <= 0) {
          pushLine()
          col = 0
          if (available <= 0)
            continue
        }
        if (/^\s+$/.test(remaining)) {
          pushLine()
          remaining = ''
          continue
        }
        const taken = takeByCells(remaining, available)
        const chunk = taken.chunk
        remaining = taken.rest
        pushSpan({ kind: token.kind, text: chunk })
        col += textCellWidth(chunk)
        if (col >= width)
          pushLine()
      }
    }
  }

  if (line.length === 0)
    return out.length ? out : [[{ kind: 'text', text: '' }]]
  out.push(line)

  return out
}

export function markdownToLines(
  markdown: string,
  width: number,
): MarkdownLine[] {
  return withPerfSpan(
    'markdownToLines',
    `chars=${String(markdown ?? '').length} width=${Math.max(0, Math.floor(Number(width) || 0))}`,
    () => {
      const perfEnabled = isPerfEnabled()
      const startTime = Date.now()

      const cacheKey = getMarkdownHash(markdown, width)
      const cached = markdownCache.get(cacheKey)
      if (cached) {
        cached.timestamp = Date.now()
        return cached.lines
      }

      const lines = sanitizeMarkdownText(markdown).split('\n')

      const out: MarkdownLine[] = []

      let inCode = false
      let codeLang = ''
      let codeLineNo = 0
      let codeLineNoWidth = 2
      let codeWrapLines = true
      let codeShowLineNumbers = true

      const boxDrawingRe = /[┌┐└┘│─├┤┬┴┼╭╮╯╰]/

      function clampWidth(n: number): number {
        return Math.max(0, Math.floor(n))
      }

      function padLine(text: string, w: number): string {
        if (w <= 0)
          return ''
        return padEndByCells(text, w)
      }

      function codeTop(label: string, w: number): string {
        w = clampWidth(w)
        if (w <= 0)
          return ''
        if (w === 1)
          return '┌'
        const innerW = Math.max(0, w - 2)
        const txt = (label || 'code').trim()
        const labelTxt = ` ${txt} `
        if (innerW === 0)
          return '┌┐'
        const clipped = sliceByCells(labelTxt, innerW)
        const pad = '─'.repeat(Math.max(0, innerW - textCellWidth(clipped)))
        return `┌${clipped}${pad}┐`.slice(0, w)
      }

      function codeBottom(w: number): string {
        w = clampWidth(w)
        if (w <= 0)
          return ''
        if (w === 1)
          return '└'
        return `└${'─'.repeat(Math.max(0, w - 2))}┘`.slice(0, w)
      }

      function codeContentLine(
        content: string,
        lineNo: string,
        w: number,
      ): string {
        w = clampWidth(w)
        if (w <= 0)
          return ''
        if (w < 4)
          return sliceByCells(content, w)

        const innerW = Math.max(0, w - 2)
        const noPrefix = innerW < 6 || !lineNo
        const prefix = noPrefix ? '' : `${lineNo} │ `
        const inside = padLine(`${prefix}${content}`, innerW)
        return `│${inside}│`.slice(0, w)
      }

      let lineIdx = 0
      for (const raw of lines) {
        const line = raw.replace(/\r/g, '')
        if (line.startsWith('```')) {
          if (!inCode) {
            inCode = true
            codeLang = line.slice(3).trim()
            codeLineNo = 0
            codeLineNoWidth = 2
            codeWrapLines = true
            codeShowLineNumbers = true

            let codeLinesCount = 0
            let containsBoxDrawing = false
            for (let j = lineIdx + 1; j < lines.length; j++) {
              const next = lines[j]!.replace(/\r/g, '')
              if (next.startsWith('```'))
                break
              codeLinesCount++
              if (!containsBoxDrawing && boxDrawingRe.test(next))
                containsBoxDrawing = true
            }
            codeLineNoWidth = Math.max(
              2,
              String(Math.max(0, codeLinesCount)).length,
            )
            if (containsBoxDrawing) {
              codeWrapLines = false
              codeShowLineNumbers = false
            }
            out.push({
              kind: 'code',
              spans: [
                { kind: 'text', text: codeTop(codeLang || 'code', width) },
              ],
            })
          }
          else {
            inCode = false
            out.push({
              kind: 'code',
              spans: [{ kind: 'text', text: codeBottom(width) }],
            })
          }
          lineIdx++
          continue
        }

        if (inCode) {
          codeLineNo += 1
          const n = String(codeLineNo).padStart(codeLineNoWidth, ' ')
          const innerW = Math.max(0, clampWidth(width) - 2)
          const noPrefix = !codeShowLineNumbers || innerW < 6
          const prefixLen = noPrefix ? 0 : codeLineNoWidth + 3
          const contentW = Math.max(1, innerW - prefixLen)
          const segs = codeWrapLines
            ? wrapLineByCells(line, contentW)
            : [sliceByCells(line, contentW)]
          for (const [i, seg] of segs.entries()) {
            const ln = !codeShowLineNumbers
              ? ''
              : i === 0
                ? n
                : ' '.repeat(codeLineNoWidth)
            out.push({
              kind: 'code',
              spans: [{ kind: 'text', text: codeContentLine(seg, ln, width) }],
            })
          }
          lineIdx++
          continue
        }

        if (/^#{1,6}\s+/.test(line)) {
          const title = line.replace(/^#{1,6}\s+/, '')
          const spans = parseInline(title)
          for (const wrapped of wrapSpans(spans, width))
            out.push({ kind: 'heading', spans: wrapped })
          lineIdx++
          continue
        }

        if (line.startsWith('> ')) {
          const q = line.slice(2)
          const spans = parseInline(q)
          for (const wrapped of wrapSpans(spans, Math.max(1, width - 2))) {
            out.push({
              kind: 'quote',
              spans: [{ kind: 'text', text: '│ ' }, ...wrapped],
            })
          }
          lineIdx++
          continue
        }

        let liI = 0
        while (liI < line.length && line[liI] === ' ') liI++

        const hasWs = (idx: number) => line[idx] === ' ' || line[idx] === '\t'
        const skipWs = (idx: number) => {
          let j = idx
          while (j < line.length && hasWs(j)) j++
          return j
        }

        let isList = false
        let marker = ''
        let item = ''
        let indentLevel = 0

        const ulMarker = line[liI]
        if (ulMarker === '-' || ulMarker === '*' || ulMarker === '+') {
          const afterMarker = liI + 1
          if (hasWs(afterMarker)) {
            const start = skipWs(afterMarker)
            if (start < line.length) {
              isList = true
              indentLevel = Math.floor(liI / 2)
              marker = '•'
              item = line.slice(start)
            }
          }
        }

        if (!isList) {
          let j = liI
          while (j < line.length) {
            const c = line.charCodeAt(j)
            if (c < 48 || c > 57)
              break
            j++
          }
          if (j > liI && line[j] === '.' && hasWs(j + 1)) {
            const start = skipWs(j + 1)
            if (start < line.length) {
              isList = true
              indentLevel = Math.floor(liI / 2)
              marker = `${line.slice(liI, j)}.`
              item = line.slice(start)
            }
          }
        }

        if (isList) {
          const indent = ' '.repeat(indentLevel * 2)

          const prefixFirst = `${indent}${marker} `
          const prefixCells = textCellWidth(prefixFirst)
          const prefixNext = ' '.repeat(prefixCells)

          const spans = parseInline(item)
          const wrappedLines = wrapSpans(
            spans,
            Math.max(1, width - prefixCells),
          )
          for (const [i, wrapped] of wrappedLines.entries()) {
            out.push({
              kind: 'list',
              spans: [
                { kind: 'text', text: i === 0 ? prefixFirst : prefixNext },
                ...wrapped,
              ],
            })
          }
          lineIdx++
          continue
        }

        if (!line.trim()) {
          out.push({ kind: 'text', spans: [{ kind: 'text', text: '' }] })
          lineIdx++
          continue
        }

        const spans = parseInline(line)
        for (const wrapped of wrapSpans(spans, width))
          out.push({ kind: 'text', spans: wrapped })
        lineIdx++
      }

      if (markdownCache.size >= MAX_CACHE_SIZE) {
        let oldestKey: string | null = null
        let oldestTime = Date.now()
        for (const [key, entry] of markdownCache.entries()) {
          if (entry.timestamp < oldestTime) {
            oldestTime = entry.timestamp
            oldestKey = key
          }
        }
        if (oldestKey) {
          markdownCache.delete(oldestKey)
        }
      }

      markdownCache.set(cacheKey, {
        hash: cacheKey,
        lines: out,
        timestamp: Date.now(),
      })

      const elapsed = Date.now() - startTime

      const profile1Shot
        = readPrefixedEnvFlag(undefined, 'PROFILE_1SHOT') === '1'
      if (perfEnabled && elapsed >= (profile1Shot ? 5 : 30)) {
        const now = Date.now()
        if (profile1Shot || now - lastSlowMarkdownLogAt >= 800) {
          lastSlowMarkdownLogAt = now
          perfLog(
            `[MARKDOWN] ${elapsed}ms width=${width} chars=${String(markdown ?? '').length} lines=${out.length}`,
          )
        }
      }

      return out
    },
  )
}
