import { createApp, h, reactive } from 'vue'
import { serializerCtx, type Ctx } from '@milkdown/kit/core'
import { $ctx, $node, $prose, $remark, $view } from '@milkdown/kit/utils'
import { Plugin, PluginKey, Selection } from '@milkdown/prose/state'
import { type Node as ProseNode, type Schema } from '@milkdown/prose/model'
import type { EditorView, NodeView } from '@milkdown/prose/view'
import WebSearchBlockCrepe from '../components/WebSearchBlockCrepe.vue'
import { extractDocBlockContextFromMarkdown } from '../utils/docBlock.js'
import { buildOcrContextForDoc } from '../utils/ocrCache'
import {
  WEB_SEARCH_CONTEXT_LIMIT,
  WEB_SEARCH_NODE_TYPE,
  WEB_SEARCH_RESULT_FENCE_LANG,
  WEB_SEARCH_TRIGGER_TEXT,
  buildWebSearchResultMarkdown,
  extractWebSearchContextFromMarkdown,
  parseWebSearchResultMarkdown,
  parseWebSearchTriggerSyntax,
} from '../utils/webSearch.js'

const WEB_SEARCH_BLOCK_INPUT_PLUGIN_KEY = new PluginKey('milkdown-web-search-block-input')
const FALLBACK_BLOCK_SEPARATOR = '\n\n'
const FALLBACK_LEAF_TEXT = '\n'
const CONTEXT_SEPARATOR = '\n\n'
const MAX_SUFFIX_RATIO = 0.35

interface WebSearchBlockConfig {
  fetchWebSearchStream: (payload: {
    prefix: string
    suffix: string
    languageId: string
    signal?: AbortSignal
    onEvent?: (event: string, data?: Record<string, any>) => void
    onDelta?: (data: { text: string }) => void
  }) => Promise<{ content: string; createdAt: string }>
  t: (key: string) => string
  showError: (message: string) => void
}

function serializeRangeToMarkdown(
  doc: ProseNode,
  from: number,
  to: number,
  schema: Schema,
  serializer: (content: ProseNode) => string
): string {
  if (from >= to) return ''
  const fallback = doc.textBetween(from, to, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
  if (typeof serializer !== 'function') return fallback

  const slice = doc.slice(from, to)
  if (slice.content.size <= 0) return ''

  try {
    const sliceDoc = schema.topNodeType.createAndFill(undefined, slice.content)
    return sliceDoc ? serializer(sliceDoc) : fallback
  } catch {
    return fallback
  }
}

function getJoinedLength(parts: string[]) {
  let total = 0
  let hasContent = false
  for (const part of parts) {
    if (!part) continue
    if (hasContent) total += CONTEXT_SEPARATOR.length
    total += part.length
    hasContent = true
  }
  return total
}

function takeTail(text: string, limit: number) {
  if (!text || limit <= 0) return ''
  if (text.length <= limit) return text
  return text.slice(-limit)
}

function takeHead(text: string, limit: number) {
  if (!text || limit <= 0) return ''
  if (text.length <= limit) return text
  return text.slice(0, limit)
}

function fitPrefixSections(parts: string[], limit: number) {
  if (limit <= 0) return ''

  const fitted: string[] = []
  let remaining = limit

  for (let index = parts.length - 1; index >= 0; index -= 1) {
    const part = parts[index]
    if (!part || remaining <= 0) continue

    const separatorCost = fitted.length > 0 ? CONTEXT_SEPARATOR.length : 0
    if (remaining <= separatorCost) break

    const nextPart = takeTail(part, remaining - separatorCost)
    if (!nextPart) continue

    fitted.unshift(nextPart)
    remaining -= nextPart.length + separatorCost
  }

  return fitted.join(CONTEXT_SEPARATOR)
}

function fitSuffixSections(parts: string[], limit: number) {
  if (limit <= 0) return ''

  const fitted: string[] = []
  let remaining = limit

  for (const part of parts) {
    if (!part || remaining <= 0) continue

    const separatorCost = fitted.length > 0 ? CONTEXT_SEPARATOR.length : 0
    if (remaining <= separatorCost) break

    const nextPart = takeHead(part, remaining - separatorCost)
    if (!nextPart) continue

    fitted.push(nextPart)
    remaining -= nextPart.length + separatorCost
  }

  return fitted.join(CONTEXT_SEPARATOR)
}

function transformWebSearchChildren(node: any) {
  if (!node || !Array.isArray(node.children)) return

  node.children = node.children.map((child: any) => {
    if (child?.type === 'paragraph' && Array.isArray(child.children) && child.children.length === 1) {
      const textNode = child.children[0]
      if (textNode?.type === 'text' && typeof textNode.value === 'string') {
        const parsed = parseWebSearchTriggerSyntax(textNode.value)
        if (parsed) {
          return {
            type: 'webSearchTrigger',
            ...parsed,
          }
        }
      }
    }

    if (child?.type === 'code' && child.lang === WEB_SEARCH_RESULT_FENCE_LANG) {
      return {
        type: 'webSearchResult',
        value: String(child.value || ''),
        meta: String(child.meta || ''),
      }
    }

    transformWebSearchChildren(child)
    return child
  })
}

function findWebSearchReplacements(doc: ProseNode) {
  const replacements: Array<{ from: number; to: number }> = []

  doc.descendants((node, pos) => {
    if (node.type.name !== 'paragraph' || node.childCount !== 1) return true
    const firstChild = node.firstChild
    if (!firstChild?.isText) return true
    if (!parseWebSearchTriggerSyntax(node.textContent)) return true

    replacements.push({
      from: pos,
      to: pos + node.nodeSize,
    })
    return false
  })

  return replacements
}

function replaceParagraphWithWebSearchBlock(view: EditorView, from: number, to: number) {
  const blockType = view.state.schema.nodes[WEB_SEARCH_NODE_TYPE]
  if (!blockType) return false

  const blockNode = blockType.create({
    content: '',
    createdAt: '',
    collapsed: false,
    autoStart: false,
  })
  const tr = view.state.tr.replaceWith(from, to, blockNode)
  const nextPos = Math.min(from + blockNode.nodeSize, tr.doc.content.size)
  tr.setSelection(Selection.near(tr.doc.resolve(nextPos), 1))
  view.dispatch(tr.scrollIntoView())
  view.focus()
  return true
}

function tryHandleWebSearchTriggerTextInput(view: EditorView, insertedText: string) {
  if (!insertedText || !insertedText.includes(']')) return false

  const { state } = view
  const { $from, from, to } = state.selection
  const paragraph = $from.parent
  if (!paragraph || paragraph.type.name !== 'paragraph') return false
  if (!$from.sameParent(state.selection.$to)) return false

  const paragraphDepth = $from.depth
  const paragraphStart = $from.start(paragraphDepth)
  const startOffset = from - paragraphStart
  const endOffset = to - paragraphStart
  const nextText = `${paragraph.textContent.slice(0, startOffset)}${insertedText}${paragraph.textContent.slice(endOffset)}`
  if (!parseWebSearchTriggerSyntax(nextText)) return false

  const blockFrom = $from.before(paragraphDepth)
  const blockTo = blockFrom + paragraph.nodeSize
  return replaceParagraphWithWebSearchBlock(view, blockFrom, blockTo)
}

function buildResultStatusText(stage: string, t: (key: string) => string) {
  if (stage === 'queued') return t('webSearchQueued') || '已进入搜索队列'
  if (stage === 'keywords') return t('webSearchKeywords') || '正在生成搜索关键词'
  if (stage === 'searching') return t('webSearchSearching') || '正在通过 SearXNG 搜索'
  if (stage === 'selecting_urls') return t('webSearchSelecting') || '正在筛选可信网址'
  if (stage === 'crawling') return t('webSearchCrawling') || '正在抓取网页内容'
  if (stage === 'synthesizing') return t('webSearchSynthesizing') || '正在整理搜索结果'
  if (stage === 'cancelled') return t('webSearchCancelled') || '搜索已取消'
  if (stage === 'error') return t('webSearchError') || '搜索失败'
  return ''
}

export const webSearchBlockConfigCtx = $ctx<WebSearchBlockConfig, 'webSearchBlockConfig'>({
  fetchWebSearchStream: async () => ({ content: '', createdAt: '' }),
  t: (key: string) => key,
  showError: () => {},
}, 'webSearchBlockConfig')

class WebSearchBlockNodeView implements NodeView {
  node: ProseNode
  view: EditorView
  getPos: (() => number) | boolean
  dom: HTMLElement
  app: ReturnType<typeof createApp> | null = null
  props: Record<string, any>
  ctx: Ctx
  config: WebSearchBlockConfig
  abortController: AbortController | null = null
  requestSeq = 0
  destroyed = false

  constructor(
    node: ProseNode,
    view: EditorView,
    getPos: (() => number) | boolean,
    ctx: Ctx,
    config: WebSearchBlockConfig
  ) {
    this.node = node
    this.view = view
    this.getPos = getPos
    this.ctx = ctx
    this.config = config
    this.dom = document.createElement('div')
    this.dom.className = 'web-search-block-node-view'
    this.props = reactive({
      stage: 'idle',
      progressText: '',
      errorMessage: '',
      content: node.attrs.content || '',
      createdAt: node.attrs.createdAt || '',
      collapsed: Boolean(node.attrs.collapsed),
      t: (key: string) => this.config.t(key),
      onActivate: () => {
        void this.startSearch()
      },
      onCancel: () => {
        this.cancelSearch()
      },
      onDelete: () => {
        this.deleteNode()
      },
      onUpdateContent: (content: string) => {
        this.updateAttrs({ content })
      },
      onUpdateCollapsed: (collapsed: boolean) => {
        this.updateAttrs({ collapsed })
      },
      resolveSuggestionRequest: (payload: { prefix: string; suffix: string; languageId: string }) => this.resolveSuggestionRequest(payload),
    })
    this.mount()
  }

  mount() {
    this.app = createApp({
      render: () => h(WebSearchBlockCrepe, { ...this.props }),
    })
    this.app.mount(this.dom)
  }

  getPosValue() {
    if (typeof this.getPos === 'function') {
      try {
        const pos = this.getPos()
        if (typeof pos === 'number') return pos
      } catch {
        return undefined
      }
    }
    return undefined
  }

  updateAttrs(patch: Record<string, any>) {
    const pos = this.getPosValue()
    if (pos === undefined) return
    const nextAttrs = { ...this.node.attrs, ...patch }
    this.view.dispatch(this.view.state.tr.setNodeMarkup(pos, undefined, nextAttrs))
  }

  setStage(stage: string, progressText = '') {
    this.props.stage = stage
    this.props.progressText = progressText || buildResultStatusText(stage, this.config.t)
  }

  buildRequestPayload() {
    const pos = this.getPosValue()
    if (pos === undefined) {
      return {
        prefix: '',
        suffix: '',
        languageId: 'markdown',
        blocked: true,
      }
    }

    const doc = this.view.state.doc
    const schema = this.view.state.schema
    const serializer = this.ctx.get(serializerCtx)
    const prefixMarkdown = serializeRangeToMarkdown(doc, 0, pos, schema, serializer)
      || doc.textBetween(0, pos, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
    const suffixStart = Math.min(pos + this.node.nodeSize, doc.content.size)
    const suffixMarkdown = serializeRangeToMarkdown(doc, suffixStart, doc.content.size, schema, serializer)
      || doc.textBetween(suffixStart, doc.content.size, FALLBACK_BLOCK_SEPARATOR, FALLBACK_LEAF_TEXT)
    const ocrContext = buildOcrContextForDoc(doc, 120)
    const docContext = extractDocBlockContextFromMarkdown(`${prefixMarkdown}\n\n${suffixMarkdown}`, 1600)
    const webSearchContext = extractWebSearchContextFromMarkdown(`${prefixMarkdown}\n\n${suffixMarkdown}`, 1600)
    const prefixParts = [ocrContext, docContext, webSearchContext, prefixMarkdown].filter(Boolean)
    const suffixParts = [suffixMarkdown].filter(Boolean)
    const mergedPrefix = prefixParts.join(CONTEXT_SEPARATOR)
    const mergedSuffix = suffixParts.join(CONTEXT_SEPARATOR)

    if (mergedPrefix.length + mergedSuffix.length <= WEB_SEARCH_CONTEXT_LIMIT) {
      return {
        prefix: mergedPrefix,
        suffix: mergedSuffix,
        languageId: 'markdown',
        blocked: false,
      }
    }

    const prefixCapacity = getJoinedLength(prefixParts)
    const suffixCapacity = getJoinedLength(suffixParts)
    const maxSuffixBudget = Math.min(suffixCapacity, Math.floor(WEB_SEARCH_CONTEXT_LIMIT * MAX_SUFFIX_RATIO))
    let suffixBudget = maxSuffixBudget
    let prefixBudget = WEB_SEARCH_CONTEXT_LIMIT - suffixBudget

    if (prefixCapacity < prefixBudget) {
      const transferable = prefixBudget - prefixCapacity
      suffixBudget = Math.min(suffixCapacity, suffixBudget + transferable)
      prefixBudget = WEB_SEARCH_CONTEXT_LIMIT - suffixBudget
    } else if (suffixCapacity < suffixBudget) {
      const transferable = suffixBudget - suffixCapacity
      prefixBudget = Math.min(prefixCapacity, prefixBudget + transferable)
      suffixBudget = WEB_SEARCH_CONTEXT_LIMIT - prefixBudget
    }

    return {
      prefix: fitPrefixSections(prefixParts, prefixBudget),
      suffix: fitSuffixSections(suffixParts, suffixBudget),
      languageId: 'markdown',
      blocked: false,
    }
  }

  async startSearch() {
    if (this.abortController) {
      this.abortController.abort('restart')
    }

    const payload = this.buildRequestPayload()
    if (payload.blocked) {
      this.config.showError('联网搜索上下文过长，无法继续。')
      return
    }

    const requestSeq = this.requestSeq + 1
    this.requestSeq = requestSeq
    this.abortController = new AbortController()
    this.props.errorMessage = ''
    this.setStage('queued')

    try {
      // 用于累积流式 delta 内容，实现打字机效果
      let streamedContent = ''

      const result = await this.config.fetchWebSearchStream({
        ...payload,
        signal: this.abortController.signal,
        onEvent: (event, data) => {
          if (this.destroyed || this.requestSeq !== requestSeq) return
          if (event === 'error') {
            this.props.errorMessage = String(data?.error || this.config.t('webSearchErrorActionable') || '联网搜索失败，请重试。')
            this.setStage('error')
            return
          }
          this.setStage(event, String(data?.message || ''))
        },
        onDelta: (data) => {
          if (this.destroyed || this.requestSeq !== requestSeq) return
          const text = String(data?.text || '')
          if (!text) return
          streamedContent += text
          this.updateAttrs({ content: streamedContent })
        },
      })

      if (this.destroyed || this.requestSeq !== requestSeq) return

      const content = String(result?.content || '').trim()
      if (!content) {
        this.props.errorMessage = this.config.t('webSearchEmptyResult') || '联网搜索返回空结果，请重试。'
        this.setStage('error')
        return
      }

      const createdAt = String(result?.createdAt || new Date().toISOString())
      this.props.content = content
      this.props.createdAt = createdAt
      this.props.collapsed = false
      this.setStage('done', '')
      this.updateAttrs({
        content,
        createdAt,
        collapsed: false,
        autoStart: false,
      })
    } catch (error) {
      if (this.destroyed || this.requestSeq !== requestSeq) return
      if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') {
        this.setStage('cancelled')
      } else {
        this.props.errorMessage = error instanceof Error
          ? error.message
          : (this.config.t('webSearchErrorActionable') || '联网搜索失败，请重试。')
        this.setStage('error')
      }
    } finally {
      if (this.requestSeq === requestSeq) {
        this.abortController = null
      }
    }
  }

  cancelSearch() {
    if (!this.abortController) return
    this.abortController.abort('cancelled')
    this.abortController = null
    this.setStage('cancelled')
  }

  deleteNode() {
    const pos = this.getPosValue()
    if (pos === undefined) return
    const tr = this.view.state.tr.delete(pos, pos + this.node.nodeSize).scrollIntoView()
    this.view.dispatch(tr)
    this.view.focus()
  }

  resolveSuggestionRequest(payload: { prefix: string; suffix: string; languageId: string }) {
    const pos = this.getPosValue()
    if (pos === undefined) return payload
    const doc = this.view.state.doc
    const schema = this.view.state.schema
    const serializer = this.ctx.get(serializerCtx)
    const before = serializeRangeToMarkdown(doc, 0, pos, schema, serializer)
    const after = serializeRangeToMarkdown(doc, pos + this.node.nodeSize, doc.content.size, schema, serializer)
    const docContext = extractDocBlockContextFromMarkdown(`${before}\n\n${after}`, 1600)
    const webSearchContext = extractWebSearchContextFromMarkdown(`${before}\n\n${after}`, 1600)
    const prefixParts = [docContext, webSearchContext, before, payload.prefix].filter(Boolean)
    const suffixParts = [payload.suffix, after].filter(Boolean)
    const mergedPrefix = prefixParts.join(CONTEXT_SEPARATOR)
    const mergedSuffix = suffixParts.join(CONTEXT_SEPARATOR)

    if (mergedPrefix.length + mergedSuffix.length <= WEB_SEARCH_CONTEXT_LIMIT) {
      return {
        prefix: mergedPrefix,
        suffix: mergedSuffix,
        languageId: payload.languageId,
        blocked: false,
      }
    }

    const prefixCapacity = getJoinedLength(prefixParts)
    const suffixCapacity = getJoinedLength(suffixParts)
    const maxSuffixBudget = Math.min(suffixCapacity, Math.floor(WEB_SEARCH_CONTEXT_LIMIT * MAX_SUFFIX_RATIO))
    let suffixBudget = maxSuffixBudget
    let prefixBudget = WEB_SEARCH_CONTEXT_LIMIT - suffixBudget

    if (prefixCapacity < prefixBudget) {
      const transferable = prefixBudget - prefixCapacity
      suffixBudget = Math.min(suffixCapacity, suffixBudget + transferable)
      prefixBudget = WEB_SEARCH_CONTEXT_LIMIT - suffixBudget
    } else if (suffixCapacity < suffixBudget) {
      const transferable = suffixBudget - suffixCapacity
      prefixBudget = Math.min(prefixCapacity, prefixBudget + transferable)
      suffixBudget = WEB_SEARCH_CONTEXT_LIMIT - prefixBudget
    }

    return {
      prefix: fitPrefixSections(prefixParts, prefixBudget),
      suffix: fitSuffixSections(suffixParts, suffixBudget),
      languageId: payload.languageId,
      blocked: false,
    }
  }

  update(node: ProseNode) {
    if (node.type !== this.node.type) return false
    this.node = node
    this.props.content = node.attrs.content || ''
    this.props.createdAt = node.attrs.createdAt || ''
    this.props.collapsed = Boolean(node.attrs.collapsed)
    return true
  }

  stopEvent(event: Event) {
    const target = event.target as Node | null
    return Boolean(target && this.dom.contains(target))
  }

  ignoreMutation() {
    return true
  }

  destroy() {
    this.destroyed = true
    if (this.abortController) {
      this.abortController.abort('destroy')
      this.abortController = null
    }
    this.app?.unmount()
    this.app = null
  }
}

export const webSearchBlockRemark = $remark('webSearchBlockRemark', () => () => {
  return (tree: any) => {
    transformWebSearchChildren(tree)
  }
})

export const webSearchBlockNode = $node(WEB_SEARCH_NODE_TYPE, () => ({
  group: 'block',
  atom: true,
  isolating: true,
  selectable: true,
  draggable: false,
  marks: '',
  attrs: {
    content: { default: '' },
    createdAt: { default: '' },
    collapsed: { default: false },
    autoStart: { default: false },
  },
  parseDOM: [
    {
      tag: 'div[data-web-search-block="true"]',
      getAttrs: (dom) => ({
        content: '',
        createdAt: (dom as HTMLElement).getAttribute('data-created-at') || '',
        collapsed: ((dom as HTMLElement).getAttribute('data-collapsed') || '') === 'true',
        autoStart: false,
      }),
    },
  ],
  toDOM: (node) => [
    'div',
    {
      'data-web-search-block': 'true',
      'data-created-at': node.attrs.createdAt || '',
      'data-collapsed': String(Boolean(node.attrs.collapsed)),
    },
  ],
  parseMarkdown: {
    match: (node) => node.type === 'webSearchTrigger' || node.type === 'webSearchResult',
    runner: (state, node, type) => {
      if (node.type === 'webSearchTrigger') {
        state.addNode(type, {
          content: '',
          createdAt: '',
          collapsed: false,
          autoStart: false,
        })
        return
      }

      const attrs = parseWebSearchResultMarkdown(String(node.value || ''), String(node.meta || ''))
      state.addNode(type, attrs)
    },
  },
  toMarkdown: {
    match: (node) => node.type.name === WEB_SEARCH_NODE_TYPE,
    runner: (state, node) => {
      const content = String(node.attrs.content || '').trim()
      if (!content) {
        state.addNode('paragraph', [{
          type: 'text',
          value: WEB_SEARCH_TRIGGER_TEXT,
        }])
        return
      }
      state.addNode('code', {
        lang: WEB_SEARCH_RESULT_FENCE_LANG,
        meta: `date=${node.attrs.createdAt || new Date().toISOString()}`,
        value: content,
      })
    },
  },
  leafText: (node) => {
    const content = String(node.attrs.content || '').trim()
    if (!content) return WEB_SEARCH_TRIGGER_TEXT
    return buildWebSearchResultMarkdown(node.attrs)
  },
}))

export const webSearchBlockView = $view(webSearchBlockNode, (ctx) => {
  const config = ctx.get(webSearchBlockConfigCtx.key)
  return (node, view, getPos) => new WebSearchBlockNodeView(node, view, getPos, ctx, config)
})

export const webSearchBlockInputPlugin = $prose(() => {
  return new Plugin({
    key: WEB_SEARCH_BLOCK_INPUT_PLUGIN_KEY,
    props: {
      handleTextInput: (view, _from, _to, text) => {
        return tryHandleWebSearchTriggerTextInput(view, text)
      },
    },
    appendTransaction: (transactions, _oldState, newState) => {
      if (!transactions.some((transaction) => transaction.docChanged)) return null
      const blockType = newState.schema.nodes[WEB_SEARCH_NODE_TYPE]
      if (!blockType) return null

      const replacements = findWebSearchReplacements(newState.doc)
      if (replacements.length === 0) return null

      let tr = newState.tr
      for (let index = replacements.length - 1; index >= 0; index -= 1) {
        const replacement = replacements[index]
        tr = tr.replaceWith(
          replacement.from,
          replacement.to,
          blockType.create({
            content: '',
            createdAt: '',
            collapsed: false,
            autoStart: false,
          })
        )
      }
      return tr.docChanged ? tr : null
    },
  })
})
