<script setup lang="ts">
import {
  GlobalWorkerOptions,
  TextLayer,
  getDocument,
  type PDFDocumentLoadingTask,
  type PDFDocumentProxy,
  type RenderTask,
} from 'pdfjs-dist'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import {
  getDataProcessPdfPages,
  getDataProcessSourceRawUrl,
  type DataProcessPdfPageRange,
} from '@/api/modules/dataProcess'
import type { PreviewItem } from './types'

GlobalWorkerOptions.workerSrc = pdfWorkerUrl

const props = defineProps<{
  taskId: string | number | null
  sourceFileId: string | number | null
  fileName: string
  selectedItem: PreviewItem | null
}>()

const scrollRef = ref<HTMLElement | null>(null)
const pageRef = ref<HTMLElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const textLayerRef = ref<HTMLElement | null>(null)
const documentRef = shallowRef<PDFDocumentProxy | null>(null)
const pageRanges = ref<DataProcessPdfPageRange[]>([])
const currentPage = ref(1)
const pageCount = ref(0)
const zoom = ref(1)
const renderedScale = ref(1)
const loading = ref(false)
const rendering = ref(false)
const errorMessage = ref('')
const highlightState = ref<'idle' | 'highlighted' | 'unmatched' | 'manual'>('idle')

let loadingTask: PDFDocumentLoadingTask | null = null
let renderTask: RenderTask | null = null
let textLayer: TextLayer | null = null
let loadSequence = 0
let renderSequence = 0
let renderedPageNumber = 0
let resizeFrame = 0

const sourceUrl = computed(() => (
  props.taskId != null && props.sourceFileId != null
    ? getDataProcessSourceRawUrl(props.taskId, props.sourceFileId)
    : ''
))

const pageStyle = computed(() => ({
  '--total-scale-factor': String(renderedScale.value),
}))

const locationText = computed(() => {
  if (highlightState.value === 'highlighted') return `切片原文已在第 ${currentPage.value} 页高亮`
  if (highlightState.value === 'unmatched') return `已定位第 ${currentPage.value} 页，未匹配到可高亮文字`
  if (highlightState.value === 'manual') return '手动新增切片没有原文位置'
  return pageCount.value ? `第 ${currentPage.value} / ${pageCount.value} 页` : '正在读取 PDF'
})

function pageForItem(item: PreviewItem | null) {
  if (!item) return null
  if (item.sourceStart == null || item.sourceEnd == null) {
    const pageNumber = item.sourcePages?.[0]
    return pageNumber == null
      ? null
      : pageRanges.value.find((page) => page.page_number === pageNumber) ?? null
  }
  return pageRanges.value.find((page) => (
    item.sourceStart! >= page.source_start && item.sourceStart! < page.source_end
  )) ?? pageRanges.value.find((page) => (
    item.sourceStart! < page.source_end && item.sourceEnd! > page.source_start
  )) ?? null
}

function selectedTextForPage(page: DataProcessPdfPageRange) {
  const item = props.selectedItem
  if (!item) return ''
  if (item.sourceStart == null || item.sourceEnd == null) {
    return item.sourcePages?.includes(page.page_number) ? item.originalContent : ''
  }
  const intersectionStart = Math.max(item.sourceStart, page.source_start)
  const intersectionEnd = Math.min(item.sourceEnd, page.source_end)
  if (intersectionEnd <= intersectionStart) return ''
  const relativeStart = Math.max(0, intersectionStart - item.sourceStart)
  const relativeEnd = Math.max(relativeStart, intersectionEnd - item.sourceStart)
  return item.originalContent.slice(relativeStart, relativeEnd)
}

function normalizeLocatorText(value: string) {
  return value.normalize('NFKC').replace(/[^\p{L}\p{N}]+/gu, '').toLocaleLowerCase()
}

function nearestOccurrence(haystack: string, needle: string, expectedIndex: number) {
  let nearest = -1
  let nearestDistance = Number.POSITIVE_INFINITY
  let cursor = haystack.indexOf(needle)
  while (cursor >= 0) {
    const distance = Math.abs(cursor - expectedIndex)
    if (distance < nearestDistance) {
      nearest = cursor
      nearestDistance = distance
    }
    cursor = haystack.indexOf(needle, cursor + 1)
  }
  return nearest
}

function findHighlight(
  itemStrings: string[],
  selectedText: string,
  page: DataProcessPdfPageRange,
) {
  const itemRanges: Array<{ start: number; end: number }> = []
  let pageText = ''
  for (const item of itemStrings) {
    const start = pageText.length
    pageText += normalizeLocatorText(item)
    itemRanges.push({ start, end: pageText.length })
  }

  const target = normalizeLocatorText(selectedText)
  if (!pageText || !target) return []
  const itemStart = props.selectedItem?.sourceStart ?? page.source_start
  const sourceLength = Math.max(1, page.source_end - page.source_start)
  const expectedRatio = Math.min(1, Math.max(0, (itemStart - page.source_start) / sourceLength))
  const expectedIndex = Math.round(pageText.length * expectedRatio)
  const anchorLengths = [target.length, 120, 80, 48, 24, 12, 8]
    .map((length) => Math.min(length, target.length))
    .filter((length, index, values) => length >= 4 && values.indexOf(length) === index)

  for (const anchorLength of anchorLengths) {
    const anchor = target.slice(0, anchorLength)
    const matchStart = nearestOccurrence(pageText, anchor, expectedIndex)
    if (matchStart < 0) continue
    const matchEnd = matchStart + anchor.length
    return itemRanges.reduce<number[]>((matches, range, index) => {
      if (range.end > matchStart && range.start < matchEnd) matches.push(index)
      return matches
    }, [])
  }
  return []
}

function applySelectedHighlight(page: DataProcessPdfPageRange | null) {
  const item = props.selectedItem
  for (const element of textLayer?.textDivs ?? []) {
    element.classList.remove('is-slice-highlighted')
  }
  if (!item || (
    (item.sourceStart == null || item.sourceEnd == null)
    && !item.sourcePages?.length
  )) {
    highlightState.value = item ? 'manual' : 'idle'
    return
  }
  if (!page || page.page_number !== currentPage.value || !textLayer) {
    highlightState.value = 'idle'
    return
  }
  const matches = findHighlight(
    textLayer.textContentItemsStr,
    selectedTextForPage(page),
    page,
  )
  for (const index of matches) {
    textLayer.textDivs[index]?.classList.add('is-slice-highlighted')
  }
  highlightState.value = matches.length ? 'highlighted' : 'unmatched'
}

async function renderCurrentPage(force = false) {
  const document = documentRef.value
  const pageContainer = pageRef.value
  const canvas = canvasRef.value
  const layerContainer = textLayerRef.value
  const scroller = scrollRef.value
  if (!document || !pageContainer || !canvas || !layerContainer || !scroller) return

  const selectedPage = pageForItem(props.selectedItem)
  if (!force && renderedPageNumber === currentPage.value && textLayer) {
    applySelectedHighlight(selectedPage)
    return
  }

  const sequence = ++renderSequence
  renderTask?.cancel()
  textLayer?.cancel()
  renderTask = null
  textLayer = null
  rendering.value = true
  errorMessage.value = ''

  try {
    const page = await document.getPage(currentPage.value)
    if (sequence !== renderSequence) return
    const baseViewport = page.getViewport({ scale: 1 })
    const availableWidth = Math.max(280, scroller.clientWidth - 36)
    const scale = (availableWidth / baseViewport.width) * zoom.value
    const viewport = page.getViewport({ scale })
    const outputScale = Math.max(1, window.devicePixelRatio || 1)
    const context = canvas.getContext('2d')
    if (!context) throw new Error('浏览器无法创建 PDF 画布')

    renderedScale.value = scale
    pageContainer.style.width = `${viewport.width}px`
    pageContainer.style.height = `${viewport.height}px`
    canvas.width = Math.floor(viewport.width * outputScale)
    canvas.height = Math.floor(viewport.height * outputScale)
    canvas.style.width = `${viewport.width}px`
    canvas.style.height = `${viewport.height}px`
    layerContainer.replaceChildren()

    const activeRenderTask = page.render({
      canvas,
      viewport,
      transform: outputScale === 1 ? undefined : [outputScale, 0, 0, outputScale, 0, 0],
    })
    renderTask = activeRenderTask
    // 立即挂接取消处理，避免 ResizeObserver 触发重绘时产生未处理的取消异常。
    const canvasRenderPromise = activeRenderTask.promise.catch((error: unknown) => {
      if (
        sequence !== renderSequence
        || (error instanceof Error && error.name === 'RenderingCancelledException')
      ) return
      throw error
    })
    // WebKit 兼容：PDF.js 的 getTextContent() 依赖 ReadableStream 异步迭代，
    // 部分 Safari/WKWebView 未实现该接口。TextLayer 可直接通过 getReader() 消费文本流。
    const textContentSource = page.streamTextContent()
    if (sequence !== renderSequence) return
    const activeTextLayer = new TextLayer({
      textContentSource,
      container: layerContainer,
      viewport,
    })
    textLayer = activeTextLayer
    await Promise.all([canvasRenderPromise, activeTextLayer.render()])
    if (sequence !== renderSequence) return
    renderedPageNumber = currentPage.value
    applySelectedHighlight(selectedPage)
    await nextTick()
    pageContainer.querySelector<HTMLElement>('.is-slice-highlighted')
      ?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' })
  } catch (error) {
    if (sequence !== renderSequence) return
    errorMessage.value = error instanceof Error ? error.message : 'PDF 页面渲染失败'
  } finally {
    if (sequence === renderSequence) rendering.value = false
  }
}

async function locateSelectedItem() {
  if (!documentRef.value) return
  const item = props.selectedItem
  if (!item || (
    (item.sourceStart == null || item.sourceEnd == null)
    && !item.sourcePages?.length
  )) {
    applySelectedHighlight(null)
    return
  }
  const page = pageForItem(item)
  if (!page) {
    highlightState.value = 'unmatched'
    return
  }
  if (currentPage.value !== page.page_number) {
    currentPage.value = page.page_number
    await renderCurrentPage(true)
    return
  }
  await renderCurrentPage(false)
}

async function loadPdf() {
  const sequence = ++loadSequence
  ++renderSequence
  renderedPageNumber = 0
  renderTask?.cancel()
  textLayer?.cancel()
  await loadingTask?.destroy()
  loadingTask = null
  documentRef.value = null
  pageRanges.value = []
  pageCount.value = 0
  currentPage.value = 1
  errorMessage.value = ''
  highlightState.value = 'idle'
  if (!sourceUrl.value || props.taskId == null || props.sourceFileId == null) return

  loading.value = true
  try {
    const task = getDocument({ url: sourceUrl.value })
    loadingTask = task
    const [mapping, document] = await Promise.all([
      getDataProcessPdfPages(props.taskId, props.sourceFileId),
      task.promise,
    ])
    if (sequence !== loadSequence) {
      await task.destroy()
      return
    }
    documentRef.value = document
    pageRanges.value = mapping.pages
    pageCount.value = document.numPages
    await nextTick()
    await locateSelectedItem()
    if (!props.selectedItem) await renderCurrentPage(true)
  } catch (error) {
    if (sequence !== loadSequence) return
    errorMessage.value = error instanceof Error ? error.message : 'PDF 原文件加载失败'
  } finally {
    if (sequence === loadSequence) loading.value = false
  }
}

async function changePage(delta: number) {
  const nextPage = Math.min(pageCount.value, Math.max(1, currentPage.value + delta))
  if (nextPage === currentPage.value) return
  currentPage.value = nextPage
  await renderCurrentPage(true)
}

async function changeZoom(delta: number) {
  const nextZoom = Math.min(2, Math.max(0.6, Number((zoom.value + delta).toFixed(1))))
  if (nextZoom === zoom.value) return
  zoom.value = nextZoom
  await renderCurrentPage(true)
}

function scheduleResizeRender() {
  window.cancelAnimationFrame(resizeFrame)
  resizeFrame = window.requestAnimationFrame(() => {
    if (documentRef.value) void renderCurrentPage(true)
  })
}

const resizeObserver = new ResizeObserver(scheduleResizeRender)
watch(scrollRef, (element, previous) => {
  if (previous) resizeObserver.unobserve(previous)
  if (element) resizeObserver.observe(element)
})
watch(sourceUrl, () => void loadPdf(), { immediate: true })
watch(() => props.selectedItem?.id, () => void locateSelectedItem())

onBeforeUnmount(() => {
  ++loadSequence
  ++renderSequence
  window.cancelAnimationFrame(resizeFrame)
  resizeObserver.disconnect()
  renderTask?.cancel()
  textLayer?.cancel()
  void loadingTask?.destroy()
})
</script>

<template>
  <div class="pdf-source-viewer" aria-label="PDF 原文件预览">
    <div v-if="sourceUrl" class="pdf-toolbar">
      <div class="toolbar-group">
        <button type="button" aria-label="上一页" :disabled="currentPage <= 1 || loading" @click="changePage(-1)">
          <i class="fa fa-chevron-left" aria-hidden="true" />
        </button>
        <span class="page-indicator">{{ currentPage }} / {{ pageCount || '—' }}</span>
        <button type="button" aria-label="下一页" :disabled="currentPage >= pageCount || loading" @click="changePage(1)">
          <i class="fa fa-chevron-right" aria-hidden="true" />
        </button>
      </div>
      <span class="location-state" :class="`is-${highlightState}`" role="status">
        <i v-if="highlightState === 'highlighted'" class="fa fa-map-marker" aria-hidden="true" />
        {{ locationText }}
      </span>
      <div class="toolbar-group">
        <button type="button" aria-label="缩小" :disabled="zoom <= 0.6 || loading" @click="changeZoom(-0.1)">−</button>
        <span class="zoom-indicator">{{ Math.round(zoom * 100) }}%</span>
        <button type="button" aria-label="放大" :disabled="zoom >= 2 || loading" @click="changeZoom(0.1)">+</button>
      </div>
    </div>

    <div v-if="sourceUrl" ref="scrollRef" class="pdf-scroll" tabindex="0" :aria-label="`PDF 预览：${fileName}`">
      <div
        ref="pageRef"
        class="pdf-page"
        :style="pageStyle"
        :data-page-number="currentPage"
      >
        <canvas ref="canvasRef" class="pdf-canvas" />
        <div ref="textLayerRef" class="pdf-text-layer" />
      </div>
      <div v-if="loading || rendering" class="pdf-loading" role="status">
        <i class="fa fa-spinner fa-spin" aria-hidden="true" />
        {{ loading ? '正在加载 PDF…' : '正在渲染页面…' }}
      </div>
      <div v-if="errorMessage" class="pdf-error" role="alert">
        <i class="fa fa-exclamation-circle" aria-hidden="true" />
        <strong>PDF 预览失败</strong>
        <span>{{ errorMessage }}</span>
        <a :href="sourceUrl" target="_blank" rel="noopener noreferrer">在新窗口打开原件</a>
      </div>
    </div>
    <div v-else class="pdf-unavailable" role="status">
      <i class="fa fa-file-pdf-o" aria-hidden="true" />
      <strong>PDF 原文件暂不可预览</strong>
      <span>请重新上传该文件后再试。</span>
    </div>
  </div>
</template>

<style scoped lang="scss">
.pdf-source-viewer {
  display: flex;
  min-height: 0;
  flex: 1;
  flex-direction: column;
  overflow: hidden;
  background: #525659;
}

.pdf-toolbar {
  display: grid;
  min-height: 44px;
  flex: 0 0 auto;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 12px;
  padding: 0 12px;
  color: #f2f4f7;
  background: #323639;
  border-bottom: 1px solid #1f2427;
}

.toolbar-group {
  display: flex;
  align-items: center;
  gap: 5px;

  button {
    display: inline-flex;
    width: 30px;
    height: 30px;
    align-items: center;
    justify-content: center;
    color: #f2f4f7;
    font: inherit;
    font-size: 16px;
    background: transparent;
    border: 0;
    border-radius: 4px;
    cursor: pointer;

    &:hover:not(:disabled) {
      background: rgb(255 255 255 / 12%);
    }

    &:disabled {
      color: #7d8387;
      cursor: not-allowed;
    }
  }
}

.page-indicator,
.zoom-indicator {
  min-width: 54px;
  color: #e4e7ec;
  font-size: 12px;
  text-align: center;
}

.location-state {
  min-width: 0;
  overflow: hidden;
  color: #d0d5dd;
  font-size: 12px;
  text-align: center;
  text-overflow: ellipsis;
  white-space: nowrap;

  &.is-highlighted {
    color: #ffd666;
  }

  &.is-unmatched {
    color: #fdb022;
  }
}

.pdf-scroll {
  position: relative;
  display: flex;
  min-height: 538px;
  flex: 1;
  align-items: flex-start;
  justify-content: center;
  overflow: auto;
  padding: 18px;
  outline: none;
}

.pdf-page {
  position: relative;
  flex: 0 0 auto;
  overflow: hidden;
  background: #fff;
  box-shadow: 0 2px 12px rgb(0 0 0 / 34%);
}

.pdf-canvas {
  position: absolute;
  inset: 0;
  display: block;
}

.pdf-text-layer {
  --min-font-size: 1;
  --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
  --min-font-size-inv: calc(1 / var(--min-font-size));

  position: absolute;
  z-index: 1;
  inset: 0;
  overflow: clip;
  color-scheme: only light;
  line-height: 1;
  letter-spacing: normal;
  word-spacing: normal;
  text-align: initial;
  text-size-adjust: none;
  forced-color-adjust: none;
  transform-origin: 0 0;
  caret-color: CanvasText;
}

.pdf-text-layer :deep(span),
.pdf-text-layer :deep(br) {
  position: absolute;
  color: transparent;
  white-space: pre;
  cursor: text;
  user-select: text;
  transform-origin: 0 0;
}

.pdf-text-layer > :deep(:not(.markedContent)),
.pdf-text-layer :deep(.markedContent span:not(.markedContent)) {
  --font-height: 0;
  --scale-x: 1;
  --rotate: 0deg;

  z-index: 1;
  font-size: calc(var(--text-scale-factor) * var(--font-height));
  transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
}

.pdf-text-layer :deep(.markedContent) {
  display: contents;
}

.pdf-text-layer :deep(.is-slice-highlighted) {
  margin: -2px;
  padding: 2px;
  background: rgb(255 202 40 / 48%);
  border-radius: 3px;
  box-shadow: 0 0 0 1px rgb(245 158 11 / 38%);
}

.pdf-text-layer :deep(::selection) {
  color: transparent;
  background: rgb(37 99 235 / 30%);
}

.pdf-loading,
.pdf-error {
  position: absolute;
  z-index: 3;
  top: 50%;
  left: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  transform: translate(-50%, -50%);
}

.pdf-loading {
  gap: 8px;
  padding: 10px 14px;
  color: #f2f4f7;
  font-size: 13px;
  background: rgb(31 36 39 / 86%);
  border-radius: 6px;
}

.pdf-error {
  width: min(360px, calc(100% - 32px));
  flex-direction: column;
  gap: 8px;
  padding: 20px;
  color: #667085;
  text-align: center;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 20px rgb(0 0 0 / 22%);

  i {
    color: #d92d20;
    font-size: 28px;
  }

  strong {
    color: #344054;
  }

  span,
  a {
    font-size: 12px;
  }
}

.pdf-unavailable {
  display: flex;
  width: 100%;
  flex: 1;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  color: #667085;
  background: #f8f9fb;

  i {
    color: #d92d20;
    font-size: 36px;
  }

  strong {
    color: #344054;
    font-size: 14px;
  }

  span {
    font-size: 12px;
  }
}

@media (max-width: 900px) {
  .pdf-toolbar {
    gap: 5px;
    padding: 0 6px;
  }

  .location-state {
    font-size: 11px;
  }

  .pdf-scroll {
    min-height: 420px;
  }
}
</style>
