<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 EasySlidesSharedState {
  easySlidesImagePreview?: {
    acknowledgement?: ImagePreviewAcknowledgement
    request?: ImagePreviewRequest
  }
}

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

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 lightbox: HTMLElement | undefined
let lightboxImage: HTMLImageElement | undefined
let lightboxClose: HTMLButtonElement | undefined
let activeImage: HTMLImageElement | undefined
let pendingImagePreview: PendingImagePreview | undefined
let stopAcknowledgementWatch: (() => void) | undefined
let stopRequestWatch: (() => void) | undefined
let fitFrame: number | undefined
let lastHandledRequestId = easySharedState.easySlidesImagePreview?.request?.id ?? ''
let bodyOverflow = ''

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 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 preview = document.createElement('img')
  preview.className = 'easy-image-lightbox-preview'
  preview.alt = ''

  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('click', (event) => {
    event.preventDefault()
    event.stopPropagation()
    closeLightbox()
  })

  overlay.addEventListener('pointerdown', event => event.stopPropagation())
  overlay.addEventListener('click', (event) => {
    event.preventDefault()
    event.stopPropagation()
    if (event.target === overlay)
      closeLightbox()
  })
  overlay.append(preview, close)
  document.body.append(overlay)
  lightbox = overlay
  lightboxImage = preview
  lightboxClose = close
}

function showLightbox(src: string, alt: string, image?: HTMLImageElement) {
  if (!lightbox || !lightboxImage || !lightboxClose)
    return
  const wasHidden = lightbox.hidden
  activeImage = image
  lightboxImage.src = src
  lightboxImage.alt = alt
  lightbox.hidden = false
  if (wasHidden)
    bodyOverflow = document.body.style.overflow
  document.body.style.overflow = 'hidden'
  document.addEventListener('keydown', handleLightboxKeydown, true)
  lightboxClose.focus()
}

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

function closeLightbox(restoreFocus = true) {
  if (!lightbox || lightbox.hidden)
    return
  document.removeEventListener('keydown', handleLightboxKeydown, true)
  lightbox.hidden = true
  if (lightboxImage)
    lightboxImage.removeAttribute('src')
  document.body.style.overflow = bodyOverflow
  if (restoreFocus)
    activeImage?.focus()
  activeImage = undefined
}

function handleLightboxKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape') {
    event.preventDefault()
    event.stopPropagation()
    closeLightbox()
  }
  else if (event.key === 'Tab') {
    event.preventDefault()
    lightboxClose?.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 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)
  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')
  if (!layout || 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
  return Math.max(0, Math.floor((Math.min(regionBottom, layoutBottom) - codeRect.top) / scale))
}

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-height')
      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) => {
    const single = codes.length === 1
    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-height', single ? available : undefined)
      setHostLength(host, '--easy-code-auto-max-height', available)
    })
  })
}

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

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-height')
    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()
  imageInteractions.forEach((interaction, image) => cleanupImage(image, interaction))
  imageInteractions.clear()
  lightbox?.remove()
  lightbox = undefined
  lightboxImage = undefined
  lightboxClose = 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)
    })
  }
  stopAcknowledgementWatch = watch(
    () => easySharedState.easySlidesImagePreview?.acknowledgement,
    handleImagePreviewAcknowledgement,
  )
  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', scheduleCodeHeightFit)
  void document.fonts?.ready.then(scheduleCodeHeightFit)
  scheduleCodeHeightFit()
})

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

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