<script setup lang="ts">
import { sharedState, useNav } from '@slidev/client'
import { onBeforeUnmount, onMounted, watch } from 'vue'

type ScalableKind = 'table' | 'code'

interface ScaleMessage {
  key: string
  kind: ScalableKind
  scale: number
}

interface TableBaseStyle {
  tableFontSize: number
  tableInlineFontSize: string
  cells: Array<{
    element: HTMLElement
    inlinePaddingBottom: string
    inlinePaddingLeft: string
    inlinePaddingRight: string
    inlinePaddingTop: string
    paddingBottom: number
    paddingLeft: number
    paddingRight: number
    paddingTop: number
  }>
}

interface CodeBaseStyle {
  codeFontSize: number
  createdHost: boolean
}

interface ImageInteraction {
  ariaLabel: string | null
  role: string | null
  tabIndex: string | null
  click: (event: MouseEvent) => void
  keydown: (event: KeyboardEvent) => void
}

interface ImagePreviewRequest {
  alt: string
  id: string
  sentAt: number
  src: string
}

interface ImagePreviewAcknowledgement {
  requestId: string
  sentAt: number
  viewerId: string
}

interface ImagePreviewView {
  centerX: number
  centerY: number
  scale: number
}

interface ImagePreviewCommandBase {
  id: string
  requestId: string
  sentAt: number
  viewerId: string
}

interface ImagePreviewCloseCommand extends ImagePreviewCommandBase {
  type: 'close'
}

interface ImagePreviewViewCommand extends ImagePreviewCommandBase {
  type: 'view'
  view: ImagePreviewView
}

type ImagePreviewCommand = ImagePreviewCloseCommand | ImagePreviewViewCommand

interface EasySlidesSharedState {
  easySlidesImagePreview?: {
    acknowledgement?: ImagePreviewAcknowledgement
    command?: ImagePreviewCommand
    request?: ImagePreviewRequest
  }
}

interface PendingImagePreview {
  cleanupTimer?: ReturnType<typeof setTimeout>
  fallbackOpened: boolean
  fallbackTimer: ReturnType<typeof setTimeout>
  image: HTMLImageElement
  requestId: string
}

interface ImagePointer {
  x: number
  y: number
}

const IMAGE_MIN_SCALE = 0.1
const IMAGE_MAX_SCALE = 4
const IMAGE_SCALE_STEP = 0.1

const nav = useNav()
const easySharedState = sharedState as typeof sharedState & EasySlidesSharedState
const clientId = createClientId()
const mountedAt = Date.now()
const scales = new Map<string, number>()
const tableStyles = new WeakMap<HTMLTableElement, TableBaseStyle>()
const codeStyles = new WeakMap<HTMLPreElement, CodeBaseStyle>()
const imageInteractions = new Map<HTMLImageElement, ImageInteraction>()
let observer: MutationObserver | undefined
let resizeObserver: ResizeObserver | undefined
let channel: BroadcastChannel | undefined
let imageChannel: BroadcastChannel | undefined
let lightbox: HTMLElement | undefined
let lightboxViewport: HTMLElement | undefined
let lightboxImage: HTMLImageElement | undefined
let lightboxClose: HTMLButtonElement | undefined
let lightboxDecrease: HTMLButtonElement | undefined
let lightboxIncrease: HTMLButtonElement | undefined
let lightboxScaleOutput: HTMLOutputElement | undefined
let activeImage: HTMLImageElement | undefined
let activePreviewRequestId: string | undefined
let pendingImagePreview: PendingImagePreview | undefined
let stopAcknowledgementWatch: (() => void) | undefined
let stopCommandWatch: (() => void) | undefined
let stopRequestWatch: (() => void) | undefined
let fitFrame: number | undefined
let imageViewFrame: number | undefined
let pendingSharedView: ImagePreviewView | undefined
let pendingRemoteView: ImagePreviewView | undefined
let lastHandledRequestId = easySharedState.easySlidesImagePreview?.request?.id ?? ''
let lastHandledCommandId = easySharedState.easySlidesImagePreview?.command?.id ?? ''
let bodyOverflow = ''
let imageScale = 1
let imageCenterX = 0.5
let imageCenterY = 0.5
let imageViewTouched = false
let lightboxPointerMoved = false
let pinchDistance = 0
let pinchMidpoint: ImagePointer | undefined
const imagePointers = new Map<number, ImagePointer>()

function createClientId() {
  if (typeof crypto.randomUUID === 'function')
    return crypto.randomUUID()
  return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}

function isInteractiveRoute() {
  if (/(?:^|\/)(?:overview|export|editor|notes|notes-edit|print)(?:\/|$)/.test(window.location.pathname))
    return false
  return nav.isPresenter.value || nav.hasPrimarySlide.value
}

function isAuditRoute() {
  return new URLSearchParams(window.location.search).has('easy-slides-audit')
}

function isProjectionRoute() {
  return !isAuditRoute() && isInteractiveRoute() && !nav.isPresenter.value && nav.hasPrimarySlide.value
}

function clampScale(scale: number) {
  return Math.min(1.6, Math.max(0.6, Math.round(scale * 10) / 10))
}

function findSlideNumber(element: Element) {
  const page = element.closest<HTMLElement>('[class*="slidev-page-"]')
  const pageClass = page && Array.from(page.classList).find(className => /^slidev-page-\d+$/.test(className))
  return pageClass?.replace('slidev-page-', '') ?? 'unknown'
}

function contentKey(element: Element, kind: ScalableKind) {
  const layout = element.closest('.slidev-layout')
  const selector = kind === 'table' ? 'table' : 'pre'
  const elements = layout ? Array.from(layout.querySelectorAll(selector)) : [element]
  const index = Math.max(0, elements.indexOf(element))
  return `slide:${findSlideNumber(element)}:${kind}:${index}`
}

function createButton(label: string, text: string, onClick: () => void) {
  const button = document.createElement('button')
  button.type = 'button'
  button.className = 'easy-content-scale-button'
  button.setAttribute('aria-label', label)
  button.title = label
  button.textContent = text
  button.addEventListener('pointerdown', event => event.stopPropagation())
  button.addEventListener('click', (event) => {
    event.preventDefault()
    event.stopPropagation()
    onClick()
  })
  return button
}

function kindLabel(kind: ScalableKind) {
  return kind === 'table' ? '表格' : '代码'
}

function createToolbar(key: string, kind: ScalableKind) {
  const label = kindLabel(kind)
  const toolbar = document.createElement('nav')
  toolbar.className = 'easy-content-scale-toolbar'
  toolbar.setAttribute('aria-label', `${label}缩放`)
  toolbar.dataset.easyScaleKey = key
  toolbar.dataset.easyScaleKind = kind

  const decrease = createButton(`缩小${label}`, '−', () => updateScale(key, kind, currentScale(key) - 0.1))
  const reset = createButton(`重置${label}缩放`, '100%', () => updateScale(key, kind, 1))
  reset.classList.add('easy-content-scale-reset')
  const increase = createButton(`放大${label}`, '+', () => updateScale(key, kind, currentScale(key) + 0.1))
  toolbar.append(decrease, reset, increase)
  return toolbar
}

function currentScale(key: string) {
  return scales.get(key) ?? 1
}

function updateScale(key: string, kind: ScalableKind, requestedScale: number, shouldBroadcast = true) {
  const scale = clampScale(requestedScale)
  scales.set(key, scale)
  applyScale(key, kind, scale)
  if (shouldBroadcast)
    channel?.postMessage({ key, kind, scale } satisfies ScaleMessage)
}

function updateResetButton(host: HTMLElement, scale: number) {
  const reset = host.querySelector<HTMLButtonElement>('.easy-content-scale-reset')
  if (!reset)
    return
  const percentage = `${Math.round(scale * 100)}%`
  reset.textContent = percentage
  reset.title = `${reset.getAttribute('aria-label')}（当前 ${percentage}）`
}

function applyScale(key: string, kind: ScalableKind, scale: number) {
  const hostSelector = kind === 'table' ? '.easy-content-table-host' : '.easy-content-code-host'
  document.querySelectorAll<HTMLElement>(hostSelector).forEach((host) => {
    if (host.dataset.easyScaleKey !== key)
      return
    updateResetButton(host, scale)

    if (kind === 'code') {
      const code = host.querySelector<HTMLPreElement>('pre')
      const base = code && codeStyles.get(code)
      if (!code || !base)
        return
      if (scale === 1)
        host.style.removeProperty('--easy-code-scaled-font-size')
      else
        host.style.setProperty('--easy-code-scaled-font-size', `${base.codeFontSize * scale}px`)
      return
    }

    const table = host.querySelector<HTMLTableElement>('table')
    const base = table && tableStyles.get(table)
    if (!table || !base)
      return
    if (scale === 1) {
      table.style.fontSize = base.tableInlineFontSize
      base.cells.forEach((cell) => {
        cell.element.style.paddingTop = cell.inlinePaddingTop
        cell.element.style.paddingRight = cell.inlinePaddingRight
        cell.element.style.paddingBottom = cell.inlinePaddingBottom
        cell.element.style.paddingLeft = cell.inlinePaddingLeft
      })
      return
    }
    table.style.fontSize = `${base.tableFontSize * scale}px`
    base.cells.forEach((cell) => {
      cell.element.style.paddingTop = `${cell.paddingTop * scale}px`
      cell.element.style.paddingRight = `${cell.paddingRight * scale}px`
      cell.element.style.paddingBottom = `${cell.paddingBottom * scale}px`
      cell.element.style.paddingLeft = `${cell.paddingLeft * scale}px`
    })
  })
}

function clampImageScale(scale: number) {
  return Math.min(IMAGE_MAX_SCALE, Math.max(IMAGE_MIN_SCALE, Math.round(scale * 10_000) / 10_000))
}

function imageGeometry(scale = imageScale) {
  if (!lightboxViewport || !lightboxImage || !lightboxImage.naturalWidth || !lightboxImage.naturalHeight)
    return undefined
  return {
    imageHeight: lightboxImage.naturalHeight * scale,
    imageWidth: lightboxImage.naturalWidth * scale,
    viewportHeight: lightboxViewport.clientHeight,
    viewportWidth: lightboxViewport.clientWidth,
  }
}

function clampImageCenter(center: number, imageSize: number, viewportSize: number) {
  if (imageSize <= viewportSize || imageSize <= 0)
    return 0.5
  const edge = viewportSize / (2 * imageSize)
  return Math.min(1 - edge, Math.max(edge, center))
}

function currentImageView(): ImagePreviewView {
  return {
    centerX: imageCenterX,
    centerY: imageCenterY,
    scale: imageScale,
  }
}

function renderImageView() {
  if (!lightboxImage || !lightboxViewport)
    return
  const geometry = imageGeometry()
  if (!geometry)
    return
  imageCenterX = clampImageCenter(imageCenterX, geometry.imageWidth, geometry.viewportWidth)
  imageCenterY = clampImageCenter(imageCenterY, geometry.imageHeight, geometry.viewportHeight)
  lightboxImage.style.width = `${geometry.imageWidth}px`
  lightboxImage.style.height = `${geometry.imageHeight}px`
  lightboxImage.style.transform = `translate(${-imageCenterX * 100}%, ${-imageCenterY * 100}%)`
  lightboxImage.dataset.easyImageScale = String(imageScale)
  lightboxImage.dataset.easyImageCenterX = String(imageCenterX)
  lightboxImage.dataset.easyImageCenterY = String(imageCenterY)
  lightboxViewport.classList.toggle(
    'easy-image-lightbox-pannable',
    geometry.imageWidth > geometry.viewportWidth + 1 || geometry.imageHeight > geometry.viewportHeight + 1,
  )
  const percentage = `${Math.round(imageScale * 100)}%`
  if (lightboxScaleOutput) {
    lightboxScaleOutput.value = percentage
    lightboxScaleOutput.textContent = percentage
    lightboxScaleOutput.title = `当前图片缩放 ${percentage}`
  }
  if (lightboxDecrease)
    lightboxDecrease.disabled = imageScale <= IMAGE_MIN_SCALE
  if (lightboxIncrease)
    lightboxIncrease.disabled = imageScale >= IMAGE_MAX_SCALE
}

function broadcastImageView() {
  if (!activePreviewRequestId)
    return
  pendingSharedView = currentImageView()
  if (imageViewFrame !== undefined)
    return
  imageViewFrame = requestAnimationFrame(() => {
    imageViewFrame = undefined
    const view = pendingSharedView
    pendingSharedView = undefined
    if (!activePreviewRequestId || !view)
      return
    const command: ImagePreviewViewCommand = {
      id: createClientId(),
      requestId: activePreviewRequestId,
      sentAt: Date.now(),
      type: 'view',
      view,
      viewerId: clientId,
    }
    lastHandledCommandId = command.id
    imageChannel?.postMessage(command)
    updateImagePreviewState({ command })
  })
}

function applyImageView(view: ImagePreviewView, shouldBroadcast = true, touched = true) {
  if (!Number.isFinite(view.scale) || !Number.isFinite(view.centerX) || !Number.isFinite(view.centerY))
    return
  imageScale = clampImageScale(view.scale)
  imageCenterX = Math.min(1, Math.max(0, view.centerX))
  imageCenterY = Math.min(1, Math.max(0, view.centerY))
  imageViewTouched ||= touched
  renderImageView()
  if (shouldBroadcast)
    broadcastImageView()
}

function resetImageView() {
  const geometry = imageGeometry(1)
  if (!geometry)
    return
  const scale = clampImageScale(Math.min(
    1,
    geometry.viewportWidth / geometry.imageWidth,
    geometry.viewportHeight / geometry.imageHeight,
  ))
  imageViewTouched = false
  applyImageView({ centerX: 0.5, centerY: 0.5, scale }, false, false)
}

function zoomImageAt(requestedScale: number, clientX?: number, clientY?: number, shouldBroadcast = true) {
  const oldGeometry = imageGeometry()
  if (!oldGeometry || !lightboxViewport)
    return
  const scale = clampImageScale(requestedScale)
  let centerX = imageCenterX
  let centerY = imageCenterY
  if (clientX !== undefined && clientY !== undefined && scale !== imageScale) {
    const viewportRect = lightboxViewport.getBoundingClientRect()
    const pointerX = clientX - viewportRect.left
    const pointerY = clientY - viewportRect.top
    const imageLeft = oldGeometry.viewportWidth / 2 - imageCenterX * oldGeometry.imageWidth
    const imageTop = oldGeometry.viewportHeight / 2 - imageCenterY * oldGeometry.imageHeight
    const anchorX = (pointerX - imageLeft) / oldGeometry.imageWidth
    const anchorY = (pointerY - imageTop) / oldGeometry.imageHeight
    const nextWidth = lightboxImage!.naturalWidth * scale
    const nextHeight = lightboxImage!.naturalHeight * scale
    centerX = (oldGeometry.viewportWidth / 2 - pointerX + anchorX * nextWidth) / nextWidth
    centerY = (oldGeometry.viewportHeight / 2 - pointerY + anchorY * nextHeight) / nextHeight
  }
  applyImageView({ centerX, centerY, scale }, shouldBroadcast)
}

function panImageBy(deltaX: number, deltaY: number, shouldBroadcast = true) {
  const geometry = imageGeometry()
  if (!geometry)
    return
  applyImageView({
    centerX: imageCenterX - deltaX / geometry.imageWidth,
    centerY: imageCenterY - deltaY / geometry.imageHeight,
    scale: imageScale,
  }, shouldBroadcast)
}

function pinchMetrics() {
  const pointers = Array.from(imagePointers.values()).slice(0, 2)
  if (pointers.length < 2)
    return undefined
  return {
    distance: Math.hypot(pointers[1].x - pointers[0].x, pointers[1].y - pointers[0].y),
    midpoint: {
      x: (pointers[0].x + pointers[1].x) / 2,
      y: (pointers[0].y + pointers[1].y) / 2,
    },
  }
}

function handleImagePointerDown(event: PointerEvent) {
  if (!lightboxViewport || (event.pointerType === 'mouse' && event.button !== 0))
    return
  event.preventDefault()
  event.stopPropagation()
  if (imagePointers.size === 0)
    lightboxPointerMoved = false
  imagePointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
  try {
    lightboxViewport.setPointerCapture(event.pointerId)
  }
  catch {
    // Synthetic pointer events used by tests do not participate in native pointer capture.
  }
  lightboxViewport.classList.add('easy-image-lightbox-dragging')
  const metrics = pinchMetrics()
  pinchDistance = metrics?.distance ?? 0
  pinchMidpoint = metrics?.midpoint
}

function handleImagePointerMove(event: PointerEvent) {
  const previous = imagePointers.get(event.pointerId)
  if (!previous)
    return
  event.preventDefault()
  event.stopPropagation()
  const current = { x: event.clientX, y: event.clientY }
  imagePointers.set(event.pointerId, current)
  if (Math.hypot(current.x - previous.x, current.y - previous.y) > 1)
    lightboxPointerMoved = true

  if (imagePointers.size === 1) {
    panImageBy(current.x - previous.x, current.y - previous.y)
    return
  }

  const metrics = pinchMetrics()
  if (!metrics)
    return
  if (pinchDistance > 0)
    zoomImageAt(imageScale * metrics.distance / pinchDistance, metrics.midpoint.x, metrics.midpoint.y, false)
  if (pinchMidpoint)
    panImageBy(metrics.midpoint.x - pinchMidpoint.x, metrics.midpoint.y - pinchMidpoint.y)
  pinchDistance = metrics.distance
  pinchMidpoint = metrics.midpoint
}

function handleImagePointerEnd(event: PointerEvent) {
  if (!imagePointers.has(event.pointerId))
    return
  event.preventDefault()
  event.stopPropagation()
  imagePointers.delete(event.pointerId)
  if (lightboxViewport?.hasPointerCapture(event.pointerId))
    lightboxViewport.releasePointerCapture(event.pointerId)
  const metrics = pinchMetrics()
  pinchDistance = metrics?.distance ?? 0
  pinchMidpoint = metrics?.midpoint
  if (imagePointers.size === 0)
    lightboxViewport?.classList.remove('easy-image-lightbox-dragging')
}

function handleImageWheel(event: WheelEvent) {
  event.preventDefault()
  event.stopPropagation()
  const sensitivity = event.deltaMode === WheelEvent.DOM_DELTA_PIXEL ? 0.002 : 0.05
  zoomImageAt(imageScale * Math.exp(-event.deltaY * sensitivity), event.clientX, event.clientY)
}

function handleLightboxImageLoad() {
  if (!lightbox || lightbox.hidden)
    return
  resetImageView()
  if (pendingRemoteView) {
    const view = pendingRemoteView
    pendingRemoteView = undefined
    applyImageView(view, false)
  }
}

function createImageScaleButton(label: string, text: string, onClick: () => void) {
  const button = document.createElement('button')
  button.type = 'button'
  button.className = 'easy-image-scale-button'
  button.setAttribute('aria-label', label)
  button.title = label
  button.textContent = text
  button.addEventListener('pointerdown', event => event.stopPropagation())
  button.addEventListener('click', (event) => {
    event.preventDefault()
    event.stopPropagation()
    onClick()
  })
  return button
}

function createLightbox() {
  const overlay = document.createElement('div')
  overlay.className = 'easy-image-lightbox'
  overlay.hidden = true
  overlay.setAttribute('role', 'dialog')
  overlay.setAttribute('aria-modal', 'true')
  overlay.setAttribute('aria-label', '图片全屏预览')

  const viewport = document.createElement('div')
  viewport.className = 'easy-image-lightbox-viewport'

  const preview = document.createElement('img')
  preview.className = 'easy-image-lightbox-preview'
  preview.alt = ''
  preview.draggable = false
  preview.addEventListener('load', handleLightboxImageLoad)
  viewport.addEventListener('pointerdown', handleImagePointerDown)
  viewport.addEventListener('pointermove', handleImagePointerMove)
  viewport.addEventListener('pointerup', handleImagePointerEnd)
  viewport.addEventListener('pointercancel', handleImagePointerEnd)
  viewport.addEventListener('wheel', handleImageWheel, { passive: false })
  viewport.append(preview)

  const close = document.createElement('button')
  close.type = 'button'
  close.className = 'easy-image-lightbox-close'
  close.setAttribute('aria-label', '关闭图片全屏预览')
  close.title = '关闭（Esc）'
  close.textContent = '×'
  close.addEventListener('pointerdown', event => event.stopPropagation())
  close.addEventListener('click', (event) => {
    event.preventDefault()
    event.stopPropagation()
    closeLightbox()
  })

  const toolbar = document.createElement('nav')
  toolbar.className = 'easy-image-scale-toolbar'
  toolbar.setAttribute('aria-label', '图片缩放')
  toolbar.addEventListener('pointerdown', event => event.stopPropagation())
  const decrease = createImageScaleButton('缩小图片', '−', () => zoomImageAt(imageScale - IMAGE_SCALE_STEP))
  const scaleOutput = document.createElement('output')
  scaleOutput.className = 'easy-image-scale-value'
  scaleOutput.setAttribute('aria-live', 'polite')
  scaleOutput.value = '100%'
  scaleOutput.textContent = '100%'
  const increase = createImageScaleButton('放大图片', '+', () => zoomImageAt(imageScale + IMAGE_SCALE_STEP))
  toolbar.append(decrease, scaleOutput, increase)

  overlay.addEventListener('pointerdown', event => event.stopPropagation())
  overlay.addEventListener('click', (event) => {
    event.preventDefault()
    event.stopPropagation()
    const clickedBackdrop = event.target === overlay || event.target === viewport
    if (clickedBackdrop && !lightboxPointerMoved)
      closeLightbox()
    lightboxPointerMoved = false
  })
  overlay.append(viewport, close, toolbar)
  document.body.append(overlay)
  lightbox = overlay
  lightboxViewport = viewport
  lightboxImage = preview
  lightboxClose = close
  lightboxDecrease = decrease
  lightboxIncrease = increase
  lightboxScaleOutput = scaleOutput
}

function showLightbox(src: string, alt: string, image?: HTMLImageElement, requestId?: string) {
  if (!lightbox || !lightboxImage || !lightboxClose)
    return
  const wasHidden = lightbox.hidden
  activeImage = image
  activePreviewRequestId = requestId
  pendingRemoteView = undefined
  imageViewTouched = false
  imageScale = 1
  imageCenterX = 0.5
  imageCenterY = 0.5
  lightboxImage.alt = alt
  lightbox.hidden = false
  lightboxImage.src = src
  if (wasHidden)
    bodyOverflow = document.body.style.overflow
  document.body.style.overflow = 'hidden'
  document.addEventListener('keydown', handleLightboxKeydown, true)
  if (lightboxImage.complete && lightboxImage.naturalWidth)
    requestAnimationFrame(handleLightboxImageLoad)
  lightboxClose.focus()
}

function openLightbox(image: HTMLImageElement) {
  showLightbox(image.currentSrc || image.src, image.alt, image)
}

function broadcastImageClose() {
  if (!activePreviewRequestId)
    return
  const command: ImagePreviewCloseCommand = {
    id: createClientId(),
    requestId: activePreviewRequestId,
    sentAt: Date.now(),
    type: 'close',
    viewerId: clientId,
  }
  lastHandledCommandId = command.id
  imageChannel?.postMessage(command)
  updateImagePreviewState({ command })
}

function closeLightbox(restoreFocus = true, shouldBroadcast = true) {
  if (!lightbox || lightbox.hidden)
    return
  if (shouldBroadcast)
    broadcastImageClose()
  document.removeEventListener('keydown', handleLightboxKeydown, true)
  lightbox.hidden = true
  if (imageViewFrame !== undefined) {
    cancelAnimationFrame(imageViewFrame)
    imageViewFrame = undefined
  }
  pendingSharedView = undefined
  pendingRemoteView = undefined
  imagePointers.clear()
  lightboxViewport?.classList.remove('easy-image-lightbox-dragging', 'easy-image-lightbox-pannable')
  if (lightboxImage) {
    lightboxImage.removeAttribute('src')
    lightboxImage.style.removeProperty('width')
    lightboxImage.style.removeProperty('height')
    lightboxImage.style.removeProperty('transform')
    delete lightboxImage.dataset.easyImageScale
    delete lightboxImage.dataset.easyImageCenterX
    delete lightboxImage.dataset.easyImageCenterY
  }
  document.body.style.overflow = bodyOverflow
  if (restoreFocus)
    activeImage?.focus()
  activeImage = undefined
  activePreviewRequestId = undefined
  imageScale = 1
  imageCenterX = 0.5
  imageCenterY = 0.5
  imageViewTouched = false
}

function handleLightboxKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape') {
    event.preventDefault()
    event.stopPropagation()
    closeLightbox()
  }
  else if (event.key === 'Tab') {
    const focusable = [lightboxClose, lightboxDecrease, lightboxIncrease].filter(
      (element): element is HTMLButtonElement => Boolean(element && !element.disabled),
    )
    if (!focusable.length)
      return
    event.preventDefault()
    const currentIndex = focusable.indexOf(document.activeElement as HTMLButtonElement)
    const direction = event.shiftKey ? -1 : 1
    const nextIndex = currentIndex < 0
      ? 0
      : (currentIndex + direction + focusable.length) % focusable.length
    focusable[nextIndex].focus()
  }
}

function updateImagePreviewState(patch: NonNullable<EasySlidesSharedState['easySlidesImagePreview']>) {
  easySharedState.easySlidesImagePreview = {
    ...easySharedState.easySlidesImagePreview,
    ...patch,
  }
}

function clearPendingImagePreview() {
  if (!pendingImagePreview)
    return
  clearTimeout(pendingImagePreview.fallbackTimer)
  if (pendingImagePreview.cleanupTimer)
    clearTimeout(pendingImagePreview.cleanupTimer)
  pendingImagePreview = undefined
}

function requestProjectionLightbox(image: HTMLImageElement) {
  clearPendingImagePreview()
  const request: ImagePreviewRequest = {
    alt: image.alt,
    id: createClientId(),
    sentAt: Date.now(),
    src: image.currentSrc || image.src,
  }
  const pending: PendingImagePreview = {
    fallbackOpened: false,
    fallbackTimer: setTimeout(() => {
      if (pendingImagePreview?.requestId !== request.id)
        return
      pendingImagePreview.fallbackOpened = true
      openLightbox(image)
      pendingImagePreview.cleanupTimer = setTimeout(() => {
        if (pendingImagePreview?.requestId === request.id)
          pendingImagePreview = undefined
      }, 10_000)
    }, 500),
    image,
    requestId: request.id,
  }
  pendingImagePreview = pending
  updateImagePreviewState({ request })
}

function activateImage(image: HTMLImageElement) {
  if (nav.isPresenter.value)
    requestProjectionLightbox(image)
  else
    openLightbox(image)
}

function handleImagePreviewAcknowledgement(acknowledgement: ImagePreviewAcknowledgement | undefined) {
  if (!acknowledgement || !pendingImagePreview || acknowledgement.requestId !== pendingImagePreview.requestId)
    return
  const pending = pendingImagePreview
  clearPendingImagePreview()
  if (pending.fallbackOpened && activeImage === pending.image)
    closeLightbox(false)
}

function isImagePreviewCommand(command: ImagePreviewCommand | undefined): command is ImagePreviewCommand {
  if (!command || !command.id || !command.requestId || !command.viewerId || !Number.isFinite(command.sentAt))
    return false
  if (command.type === 'close')
    return true
  return command.type === 'view'
    && Number.isFinite(command.view?.scale)
    && Number.isFinite(command.view?.centerX)
    && Number.isFinite(command.view?.centerY)
}

function handleImagePreviewCommand(command: ImagePreviewCommand | undefined) {
  if (!isImagePreviewCommand(command) || !isProjectionRoute() || command.id === lastHandledCommandId)
    return
  if (command.requestId !== activePreviewRequestId)
    return
  if (command.sentAt < mountedAt - 100 || Date.now() - command.sentAt > 10_000)
    return
  lastHandledCommandId = command.id
  if (command.type === 'close') {
    closeLightbox(false, false)
    return
  }
  if (!lightboxImage?.naturalWidth) {
    pendingRemoteView = command.view
    return
  }
  applyImageView(command.view, false)
}

function handleImagePreviewRequest(request: ImagePreviewRequest | undefined) {
  if (!request || !isProjectionRoute() || request.id === lastHandledRequestId)
    return
  if (request.sentAt < mountedAt - 100 || Date.now() - request.sentAt > 10_000)
    return
  lastHandledRequestId = request.id
  showLightbox(request.src, request.alt, undefined, request.id)
  updateImagePreviewState({
    acknowledgement: {
      requestId: request.id,
      sentAt: Date.now(),
      viewerId: clientId,
    },
  })
}

function enhanceImage(image: HTMLImageElement) {
  if (imageInteractions.has(image) || image.closest('a, button') || image.closest('.easy-image-lightbox'))
    return

  const click = (event: MouseEvent) => {
    event.preventDefault()
    event.stopPropagation()
    activateImage(image)
  }
  const keydown = (event: KeyboardEvent) => {
    if (event.key !== 'Enter' && event.key !== ' ')
      return
    event.preventDefault()
    event.stopPropagation()
    activateImage(image)
  }
  imageInteractions.set(image, {
    ariaLabel: image.getAttribute('aria-label'),
    role: image.getAttribute('role'),
    tabIndex: image.getAttribute('tabindex'),
    click,
    keydown,
  })
  image.classList.add('easy-image-preview-trigger')
  image.tabIndex = 0
  image.setAttribute('role', 'button')
  if (!image.hasAttribute('aria-label'))
    image.setAttribute('aria-label', image.alt ? `全屏查看图片：${image.alt}` : '全屏查看图片')
  image.addEventListener('click', click)
  image.addEventListener('keydown', keydown)
}

function enhanceTable(table: HTMLTableElement) {
  if (table.closest('.easy-content-table-host'))
    return

  const key = contentKey(table, 'table')
  const cells = Array.from(table.querySelectorAll<HTMLElement>('th, td')).map((element) => {
    const style = getComputedStyle(element)
    return {
      element,
      inlinePaddingBottom: element.style.paddingBottom,
      inlinePaddingLeft: element.style.paddingLeft,
      inlinePaddingRight: element.style.paddingRight,
      inlinePaddingTop: element.style.paddingTop,
      paddingBottom: Number.parseFloat(style.paddingBottom),
      paddingLeft: Number.parseFloat(style.paddingLeft),
      paddingRight: Number.parseFloat(style.paddingRight),
      paddingTop: Number.parseFloat(style.paddingTop),
    }
  })
  tableStyles.set(table, {
    tableFontSize: Number.parseFloat(getComputedStyle(table).fontSize),
    tableInlineFontSize: table.style.fontSize,
    cells,
  })

  const host = document.createElement('div')
  host.className = 'easy-content-scale-host easy-content-table-host'
  host.dataset.easyScaleKey = key
  host.dataset.easyScaleKind = 'table'
  table.replaceWith(host)
  host.append(table, createToolbar(key, 'table'))
  applyScale(key, 'table', currentScale(key))
}

function enhanceCode(code: HTMLPreElement) {
  if (code.closest('.easy-content-code-host'))
    return

  const key = contentKey(code, 'code')
  let host = code.closest<HTMLElement>('.slidev-code-wrapper')
  const createdHost = !host
  if (!host) {
    host = document.createElement('div')
    code.replaceWith(host)
    host.append(code)
  }
  host.classList.add('easy-content-scale-host', 'easy-content-code-host')
  host.dataset.easyScaleKey = key
  host.dataset.easyScaleKind = 'code'
  host.dataset.easyCodeHostCreated = createdHost ? 'true' : 'false'
  codeStyles.set(code, {
    codeFontSize: Number.parseFloat(getComputedStyle(code).fontSize),
    createdHost,
  })
  host.append(createToolbar(key, 'code'))
  applyScale(key, 'code', currentScale(key))
  resizeObserver?.observe(code.closest('.slidev-layout') ?? host)
  scheduleCodeHeightFit()
}

function enhanceContent(root: ParentNode = document) {
  const images = Array.from(root.querySelectorAll<HTMLImageElement>('.slidev-layout img'))
  const tables = Array.from(root.querySelectorAll<HTMLTableElement>('.slidev-layout table'))
  const codes = Array.from(root.querySelectorAll<HTMLPreElement>('.slidev-layout pre'))
  if (root instanceof HTMLImageElement && root.closest('.slidev-layout'))
    images.unshift(root)
  if (root instanceof HTMLTableElement && root.closest('.slidev-layout'))
    tables.unshift(root)
  if (root instanceof HTMLPreElement && root.closest('.slidev-layout'))
    codes.unshift(root)
  images.forEach(enhanceImage)
  tables.forEach(enhanceTable)
  codes.forEach(enhanceCode)
}

function codeHeightRegion(code: HTMLPreElement) {
  return code.closest<HTMLElement>('.easy-two-cols-grid > div')
    ?? code.closest<HTMLElement>('.slidev-layout')
}

function hasExplicitCodeHeight(code: HTMLPreElement) {
  if (code.style.height || code.style.maxHeight)
    return true
  return Boolean(getComputedStyle(code).getPropertyValue('--easy-code-height').trim())
}

function availableCodeHeight(code: HTMLPreElement, region: HTMLElement) {
  const layout = code.closest<HTMLElement>('.slidev-layout')
  const host = code.closest<HTMLElement>('.easy-content-code-host')
  if (!layout || !host || layout.clientHeight <= 0)
    return 0
  const layoutRect = layout.getBoundingClientRect()
  const regionRect = region.getBoundingClientRect()
  const codeRect = code.getBoundingClientRect()
  if (layoutRect.height <= 0 || regionRect.height <= 0 || codeRect.width <= 0)
    return 0
  const scale = layoutRect.height / layout.clientHeight
  if (!Number.isFinite(scale) || scale <= 0)
    return 0
  const regionPaddingBottom = Number.parseFloat(getComputedStyle(region).paddingBottom) || 0
  const layoutPaddingBottom = Number.parseFloat(getComputedStyle(layout).paddingBottom) || 0
  const regionBottom = regionRect.bottom - regionPaddingBottom * scale
  const layoutBottom = layoutRect.bottom - layoutPaddingBottom * scale
  const contentBottom = Math.min(regionBottom, layoutBottom)
  let trailingBottom = codeRect.bottom

  region.querySelectorAll<HTMLElement>('*').forEach((element) => {
    if (host.contains(element) || !(host.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING))
      return
    const style = getComputedStyle(element)
    if (style.display === 'none' || style.position === 'absolute' || style.position === 'fixed')
      return
    const rect = element.getBoundingClientRect()
    if (rect.width <= 0 || rect.height <= 0)
      return
    const marginBottom = Number.parseFloat(style.marginBottom) || 0
    trailingBottom = Math.max(trailingBottom, rect.bottom + marginBottom * scale)
  })

  const trailingHeight = Math.max(0, trailingBottom - codeRect.bottom)
  const codeStyle = getComputedStyle(code)
  const minimumHeight = (
    (Number.parseFloat(codeStyle.lineHeight) || 0)
    + (Number.parseFloat(codeStyle.paddingTop) || 0)
    + (Number.parseFloat(codeStyle.paddingBottom) || 0)
    + (Number.parseFloat(codeStyle.borderTopWidth) || 0)
    + (Number.parseFloat(codeStyle.borderBottomWidth) || 0)
  )
  const available = (contentBottom - codeRect.top - trailingHeight) / scale
  return Math.max(1, Math.floor(Math.max(minimumHeight, available)))
}

function setHostLength(host: HTMLElement, property: string, value?: number) {
  const next = value && value > 0 ? `${value}px` : ''
  if (host.style.getPropertyValue(property) === next)
    return
  if (next)
    host.style.setProperty(property, next)
  else
    host.style.removeProperty(property)
}

function fitCodeHeights() {
  const groups = new Map<HTMLElement, HTMLPreElement[]>()
  document.querySelectorAll<HTMLPreElement>('.easy-content-code-host pre').forEach((code) => {
    const host = code.closest<HTMLElement>('.easy-content-code-host')
    if (!host)
      return
    if (hasExplicitCodeHeight(code)) {
      setHostLength(host, '--easy-code-auto-max-height')
      return
    }
    const region = codeHeightRegion(code)
    if (!region)
      return
    const group = groups.get(region) ?? []
    group.push(code)
    groups.set(region, group)
  })

  groups.forEach((codes, region) => {
    codes.forEach((code) => {
      const host = code.closest<HTMLElement>('.easy-content-code-host')
      if (!host)
        return
      const available = availableCodeHeight(code, region)
      if (!available)
        return
      setHostLength(host, '--easy-code-auto-max-height', available)
    })
  })
}

function scheduleCodeHeightFit() {
  if (fitFrame !== undefined)
    return
  fitFrame = requestAnimationFrame(() => {
    fitFrame = undefined
    fitCodeHeights()
  })
}

function handleViewportResize() {
  scheduleCodeHeightFit()
  if (!lightbox || lightbox.hidden || !lightboxImage?.naturalWidth)
    return
  if (imageViewTouched)
    renderImageView()
  else
    resetImageView()
}

function cleanupImage(image: HTMLImageElement, interaction: ImageInteraction) {
  image.removeEventListener('click', interaction.click)
  image.removeEventListener('keydown', interaction.keydown)
  image.classList.remove('easy-image-preview-trigger')
  restoreAttribute(image, 'aria-label', interaction.ariaLabel)
  restoreAttribute(image, 'role', interaction.role)
  restoreAttribute(image, 'tabindex', interaction.tabIndex)
}

function restoreAttribute(element: Element, name: string, value: string | null) {
  if (value === null)
    element.removeAttribute(name)
  else
    element.setAttribute(name, value)
}

function cleanupTableHosts() {
  document.querySelectorAll<HTMLElement>('.easy-content-table-host').forEach((host) => {
    const table = host.querySelector<HTMLTableElement>('table')
    const base = table && tableStyles.get(table)
    if (!table)
      return
    if (base) {
      table.style.fontSize = base.tableInlineFontSize
      base.cells.forEach((cell) => {
        cell.element.style.paddingTop = cell.inlinePaddingTop
        cell.element.style.paddingRight = cell.inlinePaddingRight
        cell.element.style.paddingBottom = cell.inlinePaddingBottom
        cell.element.style.paddingLeft = cell.inlinePaddingLeft
      })
    }
    host.replaceWith(table)
  })
}

function cleanupCodeHosts() {
  document.querySelectorAll<HTMLElement>('.easy-content-code-host').forEach((host) => {
    const code = host.querySelector<HTMLPreElement>('pre')
    const base = code && codeStyles.get(code)
    host.querySelector(':scope > .easy-content-scale-toolbar')?.remove()
    host.style.removeProperty('--easy-code-scaled-font-size')
    host.style.removeProperty('--easy-code-auto-max-height')
    host.classList.remove('easy-content-scale-host', 'easy-content-code-host')
    delete host.dataset.easyScaleKey
    delete host.dataset.easyScaleKind
    delete host.dataset.easyCodeHostCreated
    if (code && base?.createdHost)
      host.replaceWith(code)
  })
}

function cleanupContent() {
  clearPendingImagePreview()
  closeLightbox(false, false)
  imageInteractions.forEach((interaction, image) => cleanupImage(image, interaction))
  imageInteractions.clear()
  lightbox?.remove()
  lightbox = undefined
  lightboxViewport = undefined
  lightboxImage = undefined
  lightboxClose = undefined
  lightboxDecrease = undefined
  lightboxIncrease = undefined
  lightboxScaleOutput = undefined
  cleanupTableHosts()
  cleanupCodeHosts()
}

function isScaleMessage(value: unknown): value is ScaleMessage {
  if (!value || typeof value !== 'object')
    return false
  const message = value as Partial<ScaleMessage>
  return typeof message.key === 'string'
    && (message.kind === 'table' || message.kind === 'code')
    && typeof message.scale === 'number'
    && Number.isFinite(message.scale)
}

onMounted(() => {
  if (!isInteractiveRoute())
    return
  createLightbox()
  if ('BroadcastChannel' in window) {
    channel = new BroadcastChannel(`easy-slides:content-scale:${import.meta.env.BASE_URL}`)
    channel.addEventListener('message', (event) => {
      if (isScaleMessage(event.data))
        updateScale(event.data.key, event.data.kind, event.data.scale, false)
    })
    imageChannel = new BroadcastChannel(`easy-slides:image-preview:${import.meta.env.BASE_URL}`)
    imageChannel.addEventListener('message', event => handleImagePreviewCommand(event.data as ImagePreviewCommand))
  }
  stopAcknowledgementWatch = watch(
    () => easySharedState.easySlidesImagePreview?.acknowledgement,
    handleImagePreviewAcknowledgement,
  )
  stopCommandWatch = watch(
    () => easySharedState.easySlidesImagePreview?.command,
    handleImagePreviewCommand,
  )
  stopRequestWatch = watch(
    () => easySharedState.easySlidesImagePreview?.request,
    handleImagePreviewRequest,
  )
  resizeObserver = new ResizeObserver(scheduleCodeHeightFit)
  enhanceContent()
  document.querySelectorAll<HTMLElement>('.slidev-layout').forEach(layout => resizeObserver?.observe(layout))
  observer = new MutationObserver((records) => {
    records.forEach(record => enhanceContent(record.target as ParentNode))
    scheduleCodeHeightFit()
  })
  observer.observe(document.body, { childList: true, subtree: true })
  window.addEventListener('resize', handleViewportResize)
  void document.fonts?.ready.then(scheduleCodeHeightFit)
  scheduleCodeHeightFit()
})

onBeforeUnmount(() => {
  observer?.disconnect()
  resizeObserver?.disconnect()
  channel?.close()
  imageChannel?.close()
  stopAcknowledgementWatch?.()
  stopCommandWatch?.()
  stopRequestWatch?.()
  window.removeEventListener('resize', handleViewportResize)
  if (fitFrame !== undefined)
    cancelAnimationFrame(fitFrame)
  cleanupContent()
})
</script>

<template>
  <span hidden aria-hidden="true" />
</template>
