<script setup>
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { resolveActiveBlockContext } from '../../lib/markdown-block-context.js'
import {
  getImageDefaultAltLabel,
  isImageUrl,
  parseImageMarkdownLine,
  serializeImageMarkdown
} from '../../lib/markdown-image.js'
import { convertHtmlToMarkdown } from '../../lib/markdown-inline.js'
import {
  filterSlashCommands,
  getSlashCommandFocusTarget,
  parseSlashInput,
  resolveSlashCommand
} from '../../lib/markdown-slash-commands.js'
import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js'
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
import {
  buildDefaultUploadSizeLimits,
  formatUploadSizeLimit,
  getMaxUploadBytesForKind,
  getUploadKind
} from '../../lib/upload-size-limit.js'
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'

const { toast, showToast } = useAdminToast()
const runtimeConfig = useRuntimeConfig()
const uploadSizeLimits = computed(() => buildDefaultUploadSizeLimits({
  image: Number(runtimeConfig.public.maxFileSize || 10485760),
  video: Number(runtimeConfig.public.maxVideoFileSize || 209715200),
  audio: Number(runtimeConfig.public.maxAudioFileSize || 52428800),
  document: Number(runtimeConfig.public.maxDocumentFileSize || 52428800)
}))

const props = defineProps({
  modelValue: {
    type: [String, Array, Object],
    default: ''
  },
  /** 작성/미리보기 토글 버튼 Teleport 대상(AdminPostForm 헤더) */
  modeToggleTeleportTo: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['update:modelValue', 'block-panel'])
const activeMode = defineModel('editorMode', {
  type: String,
  default: 'write'
})

const editorRootRef = ref(null)
const textareaRef = ref(null)
const previewRef = ref(null)
const previewRendererRef = ref(null)
const gutterRef = ref(null)
/** 커서가 있는 논리 줄(0-based, `\\n` 기준) */
const activeLogicalLineIndex = ref(0)
/** textarea 자동 줄바꿈을 반영한 논리 줄별 표시 높이(px) */
const gutterLineHeights = ref([])
/** 라이브 모드에서 소스 모드로 돌아올 때 복원할 줄(0-based) */
const pendingWriteFocusLine = ref(null)
/** 소스 모드에서 라이브 모드로 넘어갈 때 복원할 줄·오프셋 */
const pendingPreviewFocus = ref(null)
const mediaItems = ref([])
const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false)
const isUploading = ref(false)
const mediaPickerTarget = ref('image')
const activeMediaPickerTab = ref('library')
const mediaSearchQuery = ref('')
const selectedMediaUrls = ref([])
const lastSelectionState = ref({
  start: 0,
  end: 0
})
/** @type {import('vue').Ref<number|null>} 슬래시 명령이 입력된 줄 */
const liveSlashSourceLine = ref(null)
/** @type {import('vue').Ref<string>} 슬래시 검색어 */
const liveSlashQuery = ref('')
/** @type {import('vue').Ref<number>} 슬래시 메뉴 하이라이트 인덱스 */
const liveSlashHighlightedIndex = ref(0)
/** @type {import('vue').Ref<{ top: number, left: number }>} 슬래시 메뉴 위치 */
const liveSlashMenuPosition = ref({ top: 0, left: 0 })
/** @type {import('vue').Ref<HTMLElement|null>} 슬래시 메뉴 DOM */
const liveSlashMenuRef = ref(null)

/** 슬래시 메뉴 예상 높이(px) — 렌더 전 위치 계산용 */
const LIVE_SLASH_MENU_ESTIMATED_HEIGHT = 280
/** @type {import('vue').Ref<number|null>} 슬래시 후 미디어 삽입 줄 */
const liveSlashInsertLine = ref(null)
/** @type {import('vue').Ref<Set<number>>} ESC로 슬래시 메뉴만 닫은 줄(0-based) */
const liveSlashSuppressedLines = ref(new Set())
/** 키보드로 슬래시 메뉴를 탐색 중일 때 mouseenter 하이라이트 무시 */
const liveSlashKeyboardNav = ref(false)
let liveSlashKeyboardNavTimer = null

const liveSlashVisible = computed(() => liveSlashSourceLine.value !== null)

const liveSlashSuppressedLineList = computed(() => [...liveSlashSuppressedLines.value])

const visibleLiveSlashCommands = computed(() => filterSlashCommands(liveSlashQuery.value))
const mediaPickerAccept = computed(() => {
  if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
    return 'image/*'
  }

  if (mediaPickerTarget.value === 'video') {
    return 'video/*'
  }

  if (mediaPickerTarget.value === 'audio') {
    return 'audio/*'
  }

  return '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx'
})

/** 작성 textarea 최소 높이(px) */
const MIN_TEXTAREA_HEIGHT_PX = 620

const markdownValue = computed({
  get: () => normalizeMarkdownContent(props.modelValue),
  set: (value) => emit('update:modelValue', value)
})

/** textarea 포커스·블록 패널 상호작용 */
const isTextareaFocused = ref(false)
const isBlockPanelEngaged = ref(false)
/** 한글 등 IME 조합 입력 중 여부 */
const isTextComposing = ref(false)
/** 조합 입력 중 패널 닫힘을 방지하기 위한 마지막 블록 컨텍스트 */
const lastStableBlockContext = ref(null)
let blockPanelFocusTimer = null

/**
 * 현재 포커스가 블록 설정 패널 안에 있는지 확인한다.
 * @returns {boolean}
 */
const isFocusInBlockPanel = () => Boolean(
  typeof document !== 'undefined'
  && document.activeElement?.closest?.('.admin-editor-block-panel')
)

/**
 * 현재 포커스가 미디어 선택 모달 안에 있는지 확인한다.
 * @returns {boolean}
 */
const isFocusInMediaPicker = () => Boolean(
  typeof document !== 'undefined'
  && document.activeElement?.closest?.('.admin-markdown-editor__media-modal')
)

/**
 * 블록 패널 편집 중 상태를 유지한다.
 * @returns {void}
 */
const ensureBlockPanelEngaged = () => {
  window.clearTimeout(blockPanelFocusTimer)
  isBlockPanelEngaged.value = true
  syncBlockPanelState()
}

/**
 * 블록 설정 패널을 닫는다.
 * @returns {void}
 */
const closeBlockPanel = () => {
  window.clearTimeout(blockPanelFocusTimer)
  isBlockPanelEngaged.value = false
  isTextareaFocused.value = false
  syncBlockPanelState()
}

/**
 * 커서 줄 기준 활성 블록(이미지·갤러리·임베드·인용)
 * @returns {Object|null}
 */
const activeBlockContext = computed(() => resolveActiveBlockContext(
  markdownValue.value,
  activeLogicalLineIndex.value
))

/** @deprecated 내부 호환 alias */
const activeMediaBlock = activeBlockContext

/**
 * IME 조합 중에는 일시적으로 블록 판별이 비어도 직전 패널을 유지한다.
 * @returns {Object|null} 패널 표시용 블록 컨텍스트
 */
const visibleBlockContext = computed(() => {
  if (activeBlockContext.value) {
    return activeBlockContext.value
  }

  return isTextComposing.value ? lastStableBlockContext.value : null
})

/**
 * 블록 설정 패널 표시 여부
 * @returns {boolean}
 */
const isBlockPanelVisible = computed(() => (activeMode.value === 'write' || isBlockPanelEngaged.value)
  && Boolean(visibleBlockContext.value)
  && (isTextareaFocused.value || isBlockPanelEngaged.value))

/**
 * 부모(글 설정 사이드바 오버레이)에 패널 상태를 전달한다.
 * @returns {void}
 */
const syncBlockPanelState = () => {
  emit('block-panel', {
    open: isBlockPanelVisible.value,
    panel: visibleBlockContext.value
  })
}

/**
 * 라이브 모드 포커스 줄을 기준으로 블록 패널을 동기화한다.
 * @param {number} sourceLine - 원본 마크다운 줄(0-based)
 * @returns {void}
 */
const handleLiveLineFocus = async (sourceLine) => {
  const nextLine = Number(sourceLine)

  if (!Number.isInteger(nextLine) || nextLine < 0) {
    return
  }

  activeLogicalLineIndex.value = nextLine
  isTextareaFocused.value = false
  isTextComposing.value = false

  await nextTick()

  if (activeBlockContext.value) {
    lastStableBlockContext.value = activeBlockContext.value
    isBlockPanelEngaged.value = true
  } else {
    isBlockPanelEngaged.value = false
  }

  syncBlockPanelState()
}

/**
 * 라이브 모드 포커스가 블록·패널 밖으로 나가면 패널을 닫는다.
 * @returns {void}
 */
const handleLiveLineBlur = () => {
  blockPanelFocusTimer = window.setTimeout(() => {
    if (isMediaPickerOpen.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
      return
    }

    isBlockPanelEngaged.value = false
    syncBlockPanelState()
  }, 0)
}

watch(activeBlockContext, (context) => {
  if (context) {
    lastStableBlockContext.value = context
  }
}, { deep: true, immediate: true })

watch([isBlockPanelVisible, visibleBlockContext], syncBlockPanelState, { deep: true })

/**
 * 본문의 논리 줄(`\\n` 기준) 개수. 빈 본문은 1줄로 본다.
 * @returns {number}
 */
const gutterLineCount = computed(() => {
  const raw = markdownValue.value ?? ''
  const n = raw.split('\n').length

  return Math.max(1, n)
})

/**
 * CSS px 값을 숫자로 변환한다.
 * @param {string} value - CSS 값
 * @param {number} fallback - 변환 실패 시 기본값
 * @returns {number} px 숫자
 */
const parseCssPixelValue = (value, fallback) => {
  const parsed = Number.parseFloat(value)
  return Number.isFinite(parsed) ? parsed : fallback
}

/**
 * 소스 textarea의 줄 시작 오프셋을 반환한다.
 * @param {number} lineIndex - 줄 번호(0-based)
 * @returns {number} 문자 오프셋
 */
const getLineStartOffset = (lineIndex) => {
  const lines = (markdownValue.value ?? '').split('\n')
  const safeLineIndex = Math.min(Math.max(0, lineIndex), Math.max(0, lines.length - 1))

  return lines.slice(0, safeLineIndex).join('\n').length + (safeLineIndex > 0 ? 1 : 0)
}

/**
 * 문자 오프셋이 속한 소스 줄 번호를 반환한다.
 * @param {number} offset - 문자 오프셋
 * @returns {number} 줄 번호(0-based)
 */
const getLineIndexAtOffset = (offset) => {
  const value = markdownValue.value ?? ''
  const safeOffset = Math.min(Math.max(0, offset), value.length)

  return Math.max(0, value.slice(0, safeOffset).split('\n').length - 1)
}

/**
 * 현재 textarea 선택 위치를 라이브 모드 포커스 대상으로 저장한다.
 * @returns {void}
 */
const rememberWritePositionForPreview = () => {
  const textarea = textareaRef.value
  const value = markdownValue.value ?? ''
  const start = textarea
    ? Math.min(textarea.selectionStart, value.length)
    : Math.min(lastSelectionState.value.start, value.length)
  const line = getLineIndexAtOffset(start)

  pendingPreviewFocus.value = {
    line,
    offset: Math.max(0, start - getLineStartOffset(line))
  }
}

/**
 * textarea와 동일한 폭·폰트로 각 논리 줄의 실제 wrap 높이를 측정한다.
 * @returns {void}
 */
const syncGutterLineHeights = () => {
  if (!import.meta.client) {
    return
  }

  nextTick(() => {
    const textarea = textareaRef.value

    if (!textarea) {
      gutterLineHeights.value = []
      return
    }

    const style = window.getComputedStyle(textarea)
    const fallbackLineHeight = parseCssPixelValue(style.lineHeight, 28)
    const paddingLeft = parseCssPixelValue(style.paddingLeft, 0)
    const paddingRight = parseCssPixelValue(style.paddingRight, 0)
    const contentWidth = Math.max(1, textarea.clientWidth - paddingLeft - paddingRight)
    const mirror = document.createElement('div')

    mirror.style.position = 'absolute'
    mirror.style.left = '-10000px'
    mirror.style.top = '0'
    mirror.style.visibility = 'hidden'
    mirror.style.pointerEvents = 'none'
    mirror.style.width = `${contentWidth}px`
    mirror.style.boxSizing = 'border-box'
    mirror.style.whiteSpace = 'pre-wrap'
    mirror.style.overflowWrap = style.overflowWrap || 'break-word'
    mirror.style.wordBreak = style.wordBreak || 'normal'
    mirror.style.font = style.font
    mirror.style.letterSpacing = style.letterSpacing
    mirror.style.lineHeight = style.lineHeight

    document.body.appendChild(mirror)

    const nextHeights = (markdownValue.value ?? '').split('\n').map((line) => {
      const lineEl = document.createElement('div')
      lineEl.textContent = line || ' '
      mirror.appendChild(lineEl)

      return Math.max(fallbackLineHeight, lineEl.getBoundingClientRect().height)
    })

    document.body.removeChild(mirror)
    gutterLineHeights.value = nextHeights.length ? nextHeights : [fallbackLineHeight]
  })
}

/**
 * 거터 줄 높이를 반환한다.
 * @param {number} lineIndex - 줄 번호(0-based)
 * @returns {number} 줄 높이(px)
 */
const getGutterLineHeight = (lineIndex) => gutterLineHeights.value[lineIndex] || 28

/**
 * textarea 높이를 본문 길이에 맞춘다. 내부 스크롤 없이 부모(`editor-scroll`)만 스크롤한다.
 * @returns {void}
 */
const syncTextareaHeight = () => {
  nextTick(() => {
    const textarea = textareaRef.value

    if (!textarea) {
      return
    }

    textarea.style.height = '0px'
    textarea.style.height = `${Math.max(MIN_TEXTAREA_HEIGHT_PX, textarea.scrollHeight)}px`
    syncGutterLineHeights()
  })
}

/**
 * textarea 포커스 진입
 * @returns {void}
 */
const onTextareaFocus = () => {
  window.clearTimeout(blockPanelFocusTimer)
  isTextareaFocused.value = true
  refreshCaretLogicalLine()
}

/**
 * textarea 포커스 이탈(블록 패널으로 이동 시 유지)
 * @returns {void}
 */
const onTextareaBlur = () => {
  blockPanelFocusTimer = window.setTimeout(() => {
    if (isMediaPickerOpen.value || isBlockPanelEngaged.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
      return
    }

    isTextareaFocused.value = false
    syncBlockPanelState()
  }, 0)
}

/**
 * 블록 패널 포커스 진입(AdminPostForm 오버레이에서 호출)
 * @returns {void}
 */
const handleBlockPanelFocusIn = () => {
  window.clearTimeout(blockPanelFocusTimer)
  isBlockPanelEngaged.value = true
  syncBlockPanelState()
}

/**
 * 블록 패널 포커스 이탈
 * @returns {void}
 */
const handleBlockPanelFocusOut = () => {
  blockPanelFocusTimer = window.setTimeout(() => {
    if (isMediaPickerOpen.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
      return
    }

    isBlockPanelEngaged.value = false

    if (!isTextareaFocused.value) {
      syncBlockPanelState()
    }
  }, 50)
}

/**
 * IME 조합 입력 시작 시 패널 컨텍스트를 유지한다.
 * @returns {void}
 */
const handleTextareaCompositionStart = () => {
  isTextComposing.value = true

  if (activeBlockContext.value) {
    lastStableBlockContext.value = activeBlockContext.value
  }
}

/**
 * IME 조합 입력 종료 후 실제 커서 줄 기준으로 패널을 다시 동기화한다.
 * @returns {void}
 */
const handleTextareaCompositionEnd = () => {
  isTextComposing.value = false
  refreshCaretLogicalLine()
  nextTick(syncBlockPanelState)
}

/**
 * 커서 위치 기준으로 활성 논리 줄 인덱스를 갱신하고 거터 스크롤을 맞춘다.
 * @returns {void}
 */
const refreshCaretLogicalLine = () => {
  nextTick(() => {
    const textarea = textareaRef.value

    if (!textarea) {
      return
    }

    const value = markdownValue.value ?? ''
    const pos = Math.min(textarea.selectionStart, value.length)
    const lineIndex = value.slice(0, pos).split('\n').length - 1

    lastSelectionState.value = {
      start: Math.min(textarea.selectionStart, value.length),
      end: Math.min(textarea.selectionEnd, value.length)
    }
    activeLogicalLineIndex.value = Math.max(0, lineIndex)
    syncTextareaHeight()
    syncBlockPanelState()

    if (activeMode.value === 'write' && document.activeElement === textarea) {
      syncWriteSlashState()
    }
  })
}

/**
 * 소스(작성) 모드 textarea 현재 줄의 슬래시 입력 상태를 갱신한다.
 * @returns {void}
 */
const syncWriteSlashState = () => {
  if (activeMode.value !== 'write') {
    return
  }

  const line = activeLogicalLineIndex.value

  if (liveSlashSuppressedLines.value.has(line)) {
    if (liveSlashSourceLine.value !== null) {
      liveSlashSourceLine.value = null
      liveSlashQuery.value = ''
      liveSlashHighlightedIndex.value = 0
    }

    return
  }

  const lines = (markdownValue.value ?? '').split('\n')
  const parsed = parseSlashInput(lines[line] ?? '')

  if (!parsed) {
    onLiveSlashEnd({ sourceLine: line })
    return
  }

  liveSlashSourceLine.value = line
  liveSlashQuery.value = parsed.query
  nextTick(updateLiveSlashMenuPosition)
}

/**
 * textarea의 선택 영역과 스크롤 위치를 기억한다.
 * @returns {void}
 */
const rememberTextareaSelection = () => {
  const textarea = textareaRef.value
  const value = markdownValue.value ?? ''

  if (!textarea) {
    lastSelectionState.value = {
      start: value.length,
      end: value.length
    }
    return
  }

  lastSelectionState.value = {
    start: Math.min(textarea.selectionStart, value.length),
    end: Math.min(textarea.selectionEnd, value.length)
  }
}

/**
 * 가장 가까운 스크롤 컨테이너를 찾는다.
 * @param {HTMLElement|null} element - 기준 요소
 * @returns {HTMLElement|null} 스크롤 컨테이너
 */
const getNearestScrollContainer = (element) => {
  let current = element?.parentElement || null

  while (current && current !== document.body && current !== document.documentElement) {
    const style = window.getComputedStyle(current)

    if (/(auto|scroll)/.test(`${style.overflowY} ${style.overflow}`) && current.scrollHeight > current.clientHeight) {
      return current
    }

    current = current.parentElement
  }

  return null
}

/**
 * textarea의 지정 줄이 화면 안에 보이도록 스크롤한다.
 * @param {number} lineIndex - 줄 번호(0-based)
 * @returns {void}
 */
const scrollTextareaLineIntoView = (lineIndex) => {
  const textarea = textareaRef.value

  if (!textarea || !import.meta.client) {
    return
  }

  const style = window.getComputedStyle(textarea)
  const paddingTop = parseCssPixelValue(style.paddingTop, 0)
  const lineTop = paddingTop + gutterLineHeights.value
    .slice(0, Math.max(0, lineIndex))
    .reduce((sum, height) => sum + height, 0)
  const lineHeight = getGutterLineHeight(lineIndex)
  const lineBottom = lineTop + lineHeight
  const padding = 140
  const scrollContainer = getNearestScrollContainer(editorRootRef.value)

  if (scrollContainer) {
    const containerRect = scrollContainer.getBoundingClientRect()
    const textareaRect = textarea.getBoundingClientRect()
    const absoluteLineTop = textareaRect.top - containerRect.top + scrollContainer.scrollTop + lineTop
    const absoluteLineBottom = textareaRect.top - containerRect.top + scrollContainer.scrollTop + lineBottom
    const viewTop = scrollContainer.scrollTop
    const viewBottom = viewTop + scrollContainer.clientHeight

    if (absoluteLineTop < viewTop + padding) {
      scrollContainer.scrollTop = Math.max(0, absoluteLineTop - padding)
    } else if (absoluteLineBottom > viewBottom - padding) {
      scrollContainer.scrollTop = Math.max(0, absoluteLineBottom - scrollContainer.clientHeight + padding)
    }

    return
  }

  const textareaRect = textarea.getBoundingClientRect()
  const absoluteLineTop = textareaRect.top + window.scrollY + lineTop
  const absoluteLineBottom = textareaRect.top + window.scrollY + lineBottom
  const viewTop = window.scrollY
  const viewBottom = viewTop + window.innerHeight

  if (absoluteLineTop < viewTop + padding) {
    window.scrollTo({ top: Math.max(0, absoluteLineTop - padding) })
  } else if (absoluteLineBottom > viewBottom - padding) {
    window.scrollTo({ top: Math.max(0, absoluteLineBottom - window.innerHeight + padding) })
  }
}

/**
 * 소스 textarea의 지정 줄로 포커스와 커서를 이동한다.
 * @param {number} lineIndex - 줄 번호(0-based)
 * @returns {void}
 */
const focusTextareaAtLine = (lineIndex) => {
  nextTick(() => {
    const textarea = textareaRef.value
    const value = markdownValue.value ?? ''

    if (!textarea) {
      return
    }

    const offset = Math.min(getLineStartOffset(lineIndex), value.length)
    textarea.focus()
    textarea.setSelectionRange(offset, offset)
    syncTextareaHeight()
    nextTick(() => scrollTextareaLineIntoView(lineIndex))
    refreshCaretLogicalLine()
  })
}

/**
 * 기억한 선택 영역과 스크롤 위치로 작성 textarea 포커스를 복원한다.
 * @returns {void}
 */
const restoreTextareaFocus = () => {
  nextTick(() => {
    const textarea = textareaRef.value
    const value = markdownValue.value ?? ''

    if (!textarea) {
      return
    }

    const start = Math.min(lastSelectionState.value.start, value.length)
    const end = Math.min(lastSelectionState.value.end, value.length)
    const lineIndex = value.slice(0, start).split('\n').length - 1

    textarea.focus()
    textarea.setSelectionRange(start, end)
    syncTextareaHeight()
    nextTick(() => scrollTextareaLineIntoView(lineIndex))
    refreshCaretLogicalLine()
  })
}

watch(() => props.modelValue, () => {
  if (isBlockPanelEngaged.value) {
    syncBlockPanelState()
    return
  }

  refreshCaretLogicalLine()
})

watch(activeMode, (mode) => {
  if (mode === 'write') {
    nextTick(() => {
      syncTextareaHeight()
      const focusLine = pendingWriteFocusLine.value
      pendingWriteFocusLine.value = null

      if (typeof focusLine === 'number') {
        focusTextareaAtLine(focusLine)
      } else {
        restoreTextareaFocus()
      }
    })
    return
  }

  nextTick(() => {
    const previewFocus = pendingPreviewFocus.value
    pendingPreviewFocus.value = null

    if (!previewFocus) {
      scrollPreviewToTop()
      return
    }

    nextTick(() => {
      previewRendererRef.value?.focusEditableAtLine(previewFocus.line, 0, 'auto', previewFocus.offset, 'center')
    })
  })
})

/**
 * 라이브 미리보기 스크롤을 맨 위로 맞춘다.
 * @returns {void}
 */
const scrollPreviewToTop = () => {
  if (previewRef.value) {
    previewRef.value.scrollTop = 0
  }
}

/**
 * 라이브 미리보기에서 현재 포커스 또는 화면 상단에 가까운 원본 줄을 찾는다.
 * @returns {number|null} 원본 줄 번호
 */
const getCurrentPreviewSourceLine = () => {
  if (!import.meta.client || !previewRef.value) {
    return null
  }

  const activeElement = document.activeElement
  const focusedLineElement = previewRef.value.contains(activeElement)
    ? activeElement?.closest?.('[data-source-line]')
    : null
  const focusedLine = Number(focusedLineElement?.getAttribute?.('data-source-line'))

  if (Number.isFinite(focusedLine)) {
    return focusedLine
  }

  const previewRect = previewRef.value.getBoundingClientRect()
  const anchorTop = Math.max(previewRect.top, 0) + 24
  const candidates = [...previewRef.value.querySelectorAll('[data-source-line]')]
    .map((element) => {
      const rect = element.getBoundingClientRect()
      const line = Number(element.getAttribute('data-source-line'))

      return { rect, line }
    })
    .filter(({ rect, line }) => Number.isFinite(line) && rect.height > 0 && rect.bottom >= anchorTop)
    .sort((a, b) => Math.abs(a.rect.top - anchorTop) - Math.abs(b.rect.top - anchorTop))

  return candidates[0]?.line ?? null
}

/**
 * 라이브 모드의 현재 위치를 소스 모드 복원 대상으로 저장한다.
 * @returns {void}
 */
const rememberPreviewPosition = () => {
  pendingWriteFocusLine.value = getCurrentPreviewSourceLine() ?? activeLogicalLineIndex.value
}

/**
 * 본문 작성 시작 시 포커스할 첫 줄 인덱스를 반환한다.
 * @returns {number} 줄 번호(0-based)
 */
const getInitialContentFocusLineIndex = () => {
  const lines = (markdownValue.value ?? '').split('\n')

  if (!lines.length) {
    return 0
  }

  const firstLine = lines[0]?.trim() ?? ''

  if (/^#{1,6}\s+/.test(firstLine)) {
    return 1
  }

  return 0
}

/**
 * 본문 작성 시작 줄이 없으면 빈 줄을 추가한다.
 * @param {number} lineIndex - 포커스할 줄
 * @returns {number} 보정된 줄 번호
 */
const ensureContentFocusLineExists = (lineIndex) => {
  const lines = (markdownValue.value ?? '').split('\n')

  if (lineIndex < lines.length) {
    return lineIndex
  }

  markdownValue.value = lines.length ? `${markdownValue.value}\n` : ''
  return lines.length
}

/**
 * 작성 모드와 미리보기 모드를 전환한다.
 * @returns {void}
 */
const toggleEditorMode = () => {
  if (activeMode.value === 'write') {
    rememberTextareaSelection()
    rememberWritePositionForPreview()
  } else {
    rememberPreviewPosition()
  }

  activeMode.value = activeMode.value === 'write' ? 'preview' : 'write'
}

/**
 * 작성/미리보기 모드를 지정한다.
 * @param {'write'|'preview'} mode - 전환할 모드
 * @returns {void}
 */
const setEditorMode = (mode) => {
  if (activeMode.value === mode) {
    return
  }

  if (activeMode.value === 'write') {
    rememberTextareaSelection()
    rememberWritePositionForPreview()
  } else {
    rememberPreviewPosition()
  }

  activeMode.value = mode
}

onMounted(() => {
  /**
   * document selectionchange에서 작성 textarea가 포커스일 때만 활성 줄을 갱신한다.
   * @returns {void}
   */
  const onSelectionChange = () => {
    if (activeMode.value !== 'write') {
      return
    }

    const textarea = textareaRef.value

    if (!textarea || document.activeElement !== textarea) {
      return
    }

    refreshCaretLogicalLine()
  }

  /**
   * 에디터 안에서 Cmd/Ctrl+E를 누르면 작성/미리보기 모드를 전환한다.
   * @param {KeyboardEvent} event - 키보드 이벤트
   * @returns {void}
   */
  const onDocumentKeydown = (event) => {
    if (event.key === 'Escape' && isMediaPickerOpen.value) {
      event.preventDefault()
      closeMediaPicker()
      return
    }

    const root = editorRootRef.value

    if (!root || !root.contains(document.activeElement)) {
      return
    }

    if (liveSlashVisible.value) {
      if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
        event.preventDefault()
        const count = visibleLiveSlashCommands.value.length

        if (count) {
          liveSlashKeyboardNav.value = true
          window.clearTimeout(liveSlashKeyboardNavTimer)
          liveSlashKeyboardNavTimer = window.setTimeout(() => {
            liveSlashKeyboardNav.value = false
          }, 200)

          if (event.key === 'ArrowDown') {
            liveSlashHighlightedIndex.value = (liveSlashHighlightedIndex.value + 1) % count
          } else {
            liveSlashHighlightedIndex.value = (liveSlashHighlightedIndex.value - 1 + count) % count
          }
        }

        return
      }

      if (event.key === 'Enter') {
        event.preventDefault()
        const command = visibleLiveSlashCommands.value[liveSlashHighlightedIndex.value]

        if (command) {
          applyLiveSlashCommand(command)
        }

        return
      }

      if (event.key === 'Escape') {
        event.preventDefault()
        cancelLiveSlashCommand()
        return
      }
    }

    if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'e') {
      return
    }

    event.preventDefault()
    toggleEditorMode()
  }

  /**
   * 블록 패널 바깥 클릭 시 패널을 닫는다.
   * @param {PointerEvent} event - 포인터 이벤트
   * @returns {void}
   */
  const onDocumentPointerDown = (event) => {
    if (!isBlockPanelVisible.value || isMediaPickerOpen.value) {
      return
    }

    const target = event.target

    if (!(target instanceof Element)) {
      return
    }

    if (target.closest('.admin-editor-block-panel')
      || target.closest('.admin-markdown-editor')
      || target.closest('.admin-markdown-editor__media-modal')) {
      return
    }

    closeBlockPanel()
  }

  document.addEventListener('selectionchange', onSelectionChange)
  document.addEventListener('keydown', onDocumentKeydown, true)
  document.addEventListener('pointerdown', onDocumentPointerDown, true)
  window.addEventListener('resize', syncGutterLineHeights)

  onBeforeUnmount(() => {
    window.clearTimeout(blockPanelFocusTimer)
    window.clearTimeout(liveSlashKeyboardNavTimer)
    document.removeEventListener('selectionchange', onSelectionChange)
    document.removeEventListener('keydown', onDocumentKeydown, true)
    document.removeEventListener('pointerdown', onDocumentPointerDown, true)
    window.removeEventListener('resize', syncGutterLineHeights)
  })

  refreshCaretLogicalLine()
  syncGutterLineHeights()
  syncBlockPanelState()
})

/**
 * 본문 에디터에 포커스한다. 현재 모드(write/preview)를 유지한다.
 * @returns {void}
 */
const focusFirstBlock = () => {
  const focusLine = ensureContentFocusLineExists(getInitialContentFocusLineIndex())

  if (activeMode.value === 'preview') {
    scrollPreviewToTop()

    nextTick(() => {
      nextTick(() => {
        previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'start')
      })
    })

    return
  }

  nextTick(() => {
    const textarea = textareaRef.value
    const lineStart = getLineStartOffset(focusLine)

    if (textarea) {
      textarea.focus()
      textarea.setSelectionRange(lineStart, lineStart)
      textarea.scrollTop = 0
    }

    activeLogicalLineIndex.value = focusLine
    refreshCaretLogicalLine()
  })
}

/**
 * textarea 선택 영역 정보를 반환한다.
 * @returns {{ start: number, end: number, value: string }} 선택 정보
 */
const getSelectionState = () => {
  const textarea = textareaRef.value
  const value = markdownValue.value

  if (!textarea) {
    return {
      start: value.length,
      end: value.length,
      value
    }
  }

  return {
    start: textarea.selectionStart,
    end: textarea.selectionEnd,
    value
  }
}

/**
 * textarea 커서 위치를 갱신한다.
 * @param {number} start - 시작 위치
 * @param {number} end - 끝 위치
 * @returns {void}
 */
const setTextareaSelection = (start, end = start) => {
  nextTick(() => {
    const textarea = textareaRef.value
    if (!textarea) {
      return
    }

    textarea.focus()
    textarea.setSelectionRange(start, end)
    refreshCaretLogicalLine()
  })
}

/**
 * 선택 영역을 지정 문자열로 교체한다.
 * @param {string} replacement - 대체 문자열
 * @param {number} cursorOffset - 교체 문자열 안 커서 위치
 * @param {number|null} selectionLength - 선택 길이
 * @returns {void}
 */
const replaceSelection = (replacement, cursorOffset = replacement.length, selectionLength = null) => {
  const { start, end, value } = getSelectionState()
  markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
  const nextStart = start + cursorOffset
  setTextareaSelection(nextStart, nextStart + (selectionLength ?? 0))
}

/**
 * 블록형 마크다운 조각을 커서 위치에 삽입한다.
 * @param {string} snippet - 삽입할 마크다운
 * @returns {void}
 */
const insertBlockSnippet = (snippet) => {
  const { start, end, value } = getSelectionState()
  const needsBeforeLine = start > 0 && value[start - 1] !== '\n'
  const needsAfterLine = end < value.length && value[end] !== '\n'
  const replacement = `${needsBeforeLine ? '\n\n' : ''}${snippet}${needsAfterLine ? '\n\n' : ''}`
  replaceSelection(replacement)
}

/**
 * 선택 텍스트를 인라인 마크다운으로 감싼다.
 * @param {string} prefix - 앞 표식
 * @param {string} suffix - 뒤 표식
 * @param {string} placeholder - 선택이 없을 때 들어갈 텍스트
 * @returns {void}
 */
const wrapInline = (prefix, suffix, placeholder) => {
  const { start, end, value } = getSelectionState()
  const selected = value.slice(start, end)
  const inner = selected || placeholder
  const replacement = `${prefix}${inner}${suffix}`
  markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
  const selectionStart = start + prefix.length
  setTextareaSelection(selectionStart, selectionStart + inner.length)
}

/**
 * 선택된 줄을 변환한다.
 * @param {(line: string) => string} transformLine - 줄 변환 함수
 * @returns {void}
 */
const transformSelectedLines = (transformLine) => {
  const { start, end, value } = getSelectionState()
  const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1
  const nextLineBreak = value.indexOf('\n', end)
  const lineEnd = nextLineBreak === -1 ? value.length : nextLineBreak
  const selectedBlock = value.slice(lineStart, lineEnd)
  const replacement = selectedBlock.split('\n').map(transformLine).join('\n')

  markdownValue.value = `${value.slice(0, lineStart)}${replacement}${value.slice(lineEnd)}`
  setTextareaSelection(lineStart, lineStart + replacement.length)
}

/**
 * 소스 모드에서 현재 선택 범위가 걸친 줄을 삭제한다.
 * @returns {void}
 */
const deleteSelectedSourceLines = () => {
  const { start, end, value } = getSelectionState()
  const safeStart = Math.min(start, end)
  const safeEnd = Math.max(start, end)
  const lineStart = value.lastIndexOf('\n', Math.max(0, safeStart - 1)) + 1
  const nextLineBreak = value.indexOf('\n', safeEnd)
  const lineEnd = nextLineBreak === -1 ? value.length : nextLineBreak + 1
  const previousLineBreak = value.lastIndexOf('\n', Math.max(0, lineStart - 2))
  const nextValue = `${value.slice(0, lineStart)}${value.slice(lineEnd)}`
  const nextSelection = lineStart > 0
    ? previousLineBreak + 1
    : 0

  markdownValue.value = nextValue
  setTextareaSelection(Math.min(nextSelection, nextValue.length))
}

/**
 * 제목 마크다운을 적용한다.
 * @param {number} level - 제목 레벨
 * @returns {void}
 */
const applyHeading = (level) => {
  transformSelectedLines((line) => {
    const text = line.replace(/^#{1,6}\s+/, '').trimStart()
    return `${'#'.repeat(level)} ${text || '제목'}`
  })
}

/**
 * 인용 마크다운을 적용한다.
 * @returns {void}
 */
const applyQuote = () => {
  transformSelectedLines((line) => line.startsWith('> ') ? line : `> ${line}`)
}

/**
 * 목록 마크다운을 적용한다.
 * @returns {void}
 */
const applyList = () => {
  transformSelectedLines((line) => line.startsWith('- ') ? line : `- ${line}`)
}

/**
 * 코드 블록을 삽입한다.
 * @returns {void}
 */
const insertCodeBlock = () => {
  const { start, end, value } = getSelectionState()
  const selected = value.slice(start, end) || 'code'
  const snippet = `\`\`\`\n${selected}\n\`\`\``
  replaceSelection(snippet, 4, selected.length)
}

/**
 * 이미지 마크다운 문자열을 생성한다.
 * @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
 * @returns {string} 이미지 마크다운
 */
const createImageMarkdown = (image) => serializeImageMarkdown(image)

/**
 * 마크다운 이미지 줄과 단독 이미지 URL 줄을 같은 이미지 데이터로 해석한다.
 * @param {string} line - 원본 마크다운 줄
 * @returns {{ url: string, width: string, caption: string, useAlt: boolean }|null} 이미지 데이터
 */
const parseEditorImageLine = (line) => {
  const image = parseImageMarkdownLine(line)

  if (image) {
    return image
  }

  const trimmed = String(line || '').trim()

  if (!isImageUrl(trimmed)) {
    return null
  }

  return {
    url: trimmed,
    width: 'regular',
    caption: '',
    useAlt: false
  }
}

/**
 * 파일 크기를 표시용 문자열로 변환한다.
 * @param {number} size - 바이트 크기
 * @returns {string} 표시용 크기
 */
const formatMediaFileSize = (size) => {
  const value = Number(size || 0)

  if (!Number.isFinite(value) || value <= 0) {
    return ''
  }

  if (value < 1024) {
    return `${value} B`
  }

  if (value < 1024 * 1024) {
    return `${Math.round(value / 1024)} KB`
  }

  return `${(value / 1024 / 1024).toFixed(1).replace(/\.0$/, '')} MB`
}

/**
 * 미디어 항목 종류를 반환한다.
 * @param {Object} item - 미디어 항목
 * @returns {'image'|'video'|'audio'|'file'} 미디어 종류
 */
const getMediaItemKind = (item) => {
  if (item?.kind) {
    return item.kind
  }

  const source = `${item?.extension || ''} ${item?.url || ''}`.toLowerCase()

  if (/\.(jpe?g|png|webp|gif)(\?|$)/i.test(source)) {
    return 'image'
  }

  if (/\.(mp4|webm|mov)(\?|$)/i.test(source)) {
    return 'video'
  }

  if (/\.(mp3|wav|ogg|m4a)(\?|$)/i.test(source)) {
    return 'audio'
  }

  return 'file'
}

/**
 * 미디어 항목이 현재 선택 대상과 맞는지 확인한다.
 * @param {Object} item - 미디어 항목
 * @returns {boolean} 선택 가능 여부
 */
const isMediaItemSelectableForTarget = (item) => {
  const kind = getMediaItemKind(item)

  if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
    return kind === 'image'
  }

  return kind === mediaPickerTarget.value
}

/**
 * 드롭된 파일이 현재 미디어 선택 대상에 맞는지 확인한다.
 * @param {File} file - 브라우저 파일
 * @returns {boolean} 허용 여부
 */
const isUploadFileAllowedForPicker = (file) => {
  if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
    return file.type.startsWith('image/')
  }

  if (mediaPickerTarget.value === 'video') {
    return file.type.startsWith('video/')
  }

  if (mediaPickerTarget.value === 'audio') {
    return file.type.startsWith('audio/')
  }

  return !file.type.startsWith('image/') && !file.type.startsWith('video/') && !file.type.startsWith('audio/')
}

/**
 * 미디어 항목으로 Prose 미디어 블록을 만든다.
 * @param {'video'|'audio'|'file'} blockType - 블록 유형
 * @param {Object} item - 미디어 항목
 * @returns {string[]} 마크다운 줄
 */
const createMediaBlockMarkdown = (blockType, item) => {
  const title = item.title || item.name || ''

  if (blockType === 'video') {
    return [':::video', `url=${item.url}`, `title=${title}`, 'caption=', ':::']
  }

  if (blockType === 'audio') {
    return [':::audio', `url=${item.url}`, `title=${title}`, 'description=', ':::']
  }

  return [
    ':::file',
    `url=${item.url}`,
    `title=${item.name || title}`,
    'description=',
    `name=${item.name || title}`,
    `size=${formatMediaFileSize(item.size)}`,
    ':::'
  ]
}

/**
 * 지정 줄 범위를 새 줄 목록으로 교체한다.
 * @param {number} startLine - 시작 줄
 * @param {number} endLine - 끝 줄
 * @param {string[]} replacementLines - 대체 줄 목록
 * @param {boolean} focusEditor - 교체 후 textarea에 포커스를 돌릴지 여부
 * @returns {void}
 */
const replaceLineRange = (startLine, endLine, replacementLines, focusEditor = true) => {
  const lines = (markdownValue.value || '').split('\n')
  const nextLines = [
    ...lines.slice(0, startLine),
    ...replacementLines,
    ...lines.slice(endLine + 1)
  ]

  markdownValue.value = nextLines.join('\n')

  if (focusEditor) {
    setTextareaSelection(nextLines.slice(0, startLine + replacementLines.length).join('\n').length)
  }
}

/**
 * 현재 미디어 블록을 이미지 목록 기준으로 다시 작성한다.
 * @param {Array<{ alt: string, url: string, width?: string }>} images - 이미지 목록
 * @returns {void}
 */
const replaceActiveMediaImages = (images) => {
  const block = activeMediaBlock.value

  if (!block) {
    return
  }

  if (block.kind === 'image') {
    if (!images[0]) {
      replaceLineRange(block.startLine, block.endLine, [], false)
      return
    }

    replaceLineRange(block.startLine, block.endLine, [createImageMarkdown(images[0])], false)
    return
  }

  if (!images.length) {
    replaceLineRange(block.startLine, block.endLine, [], false)
    return
  }

  replaceLineRange(block.startLine, block.endLine, [
    ':::gallery',
    ...images.map(createImageMarkdown),
    ':::'
  ], false)
}

/**
 * 현재 미디어 블록의 특정 이미지를 수정한다.
 * @param {number} imageIndex - 이미지 인덱스
 * @param {Partial<{ alt: string, url: string, width: string }>} patch - 변경 값
 * @returns {void}
 */
const updateActiveMediaImage = (imageIndex, patch) => {
  const block = activeMediaBlock.value

  if (!block) {
    return
  }

  ensureBlockPanelEngaged()
  const images = block.images.map((image, index) => index === imageIndex ? { ...image, ...patch } : image)
  replaceActiveMediaImages(images)
}

/**
 * 현재 미디어 이미지의 파일명 캡션 사용 여부를 바꾼다.
 * @param {number} imageIndex - 이미지 인덱스
 * @param {boolean} enabled - 파일명 캡션 사용 여부
 * @returns {void}
 */
const setActiveMediaUseAlt = (imageIndex, enabled) => {
  const image = activeMediaBlock.value?.images[imageIndex]

  if (!image) {
    return
  }

  const filename = getImageDefaultAltLabel(image.url)
  const patch = { useAlt: enabled }

  if (enabled) {
    if (!String(image.caption || '').trim()) {
      const legacy = String(image.legacyBracketLabel || '').trim()

      patch.caption = legacy && legacy !== filename ? legacy : filename
    }
  } else if (String(image.caption || '').trim() === filename) {
    patch.caption = ''
  }

  updateActiveMediaImage(imageIndex, patch)
}

/**
 * 미리보기에서 갤러리 순서 변경
 * @param {{ startLine: number, endLine: number, images: Array<Object> }} payload - 갤러리 줄 범위·이미지 목록
 * @returns {void}
 */
const onPreviewGalleryReorder = ({ startLine, endLine, images }) => {
  if (typeof startLine !== 'number' || typeof endLine !== 'number' || !Array.isArray(images)) {
    return
  }

  replaceLineRange(startLine, endLine, [
    ':::gallery',
    ...images.map(createImageMarkdown),
    ':::'
  ], false)
}

/**
 * 라이브 모드에서 두 이미지 블록을 갤러리 fenced block으로 병합한다.
 * @param {{ sourceLine: number, targetLine: number }} payload - 원본·대상 줄(0-based)
 * @returns {void}
 */
const onPreviewMergeImagesToGallery = ({ sourceLine, targetLine }) => {
  if (typeof sourceLine !== 'number' || typeof targetLine !== 'number' || sourceLine === targetLine) {
    return
  }

  const lines = (markdownValue.value || '').split('\n')
  const firstLine = Math.min(sourceLine, targetLine)
  const secondLine = Math.max(sourceLine, targetLine)
  const firstImage = parseEditorImageLine(lines[firstLine] || '')
  const secondImage = parseEditorImageLine(lines[secondLine] || '')

  if (!firstImage || !secondImage) {
    return
  }

  const orderedImages = sourceLine < targetLine
    ? [firstImage, secondImage]
    : [secondImage, firstImage]

  const nextLines = [
    ...lines.slice(0, firstLine),
    ':::gallery',
    ...orderedImages.map(createImageMarkdown),
    ':::',
    ...lines.slice(secondLine + 1)
  ]

  markdownValue.value = nextLines.join('\n')
}

/**
 * 라이브 모드에서 단일 이미지 블록을 기존 갤러리에 추가한다.
 * @param {{ sourceLine: number, startLine: number, endLine: number, targetIndex: number, image?: Object }} payload - 이미지 줄·갤러리 범위·삽입 위치
 * @returns {void}
 */
const onPreviewInsertImageToGallery = ({
  sourceLine,
  startLine,
  endLine,
  targetIndex,
  image
}) => {
  if (typeof sourceLine !== 'number'
    || typeof startLine !== 'number'
    || typeof endLine !== 'number'
    || typeof targetIndex !== 'number'
    || sourceLine >= startLine && sourceLine <= endLine) {
    return
  }

  const lines = (markdownValue.value || '').split('\n')
  const sourceImage = image && image.url ? image : parseEditorImageLine(lines[sourceLine] || '')
  const galleryImages = lines
    .slice(startLine + 1, endLine)
    .map((line) => parseEditorImageLine(line))
    .filter(Boolean)

  if (!sourceImage || !galleryImages.length) {
    return
  }

  const nextGalleryImages = [...galleryImages]
  const insertIndex = Math.max(0, Math.min(targetIndex, nextGalleryImages.length))
  nextGalleryImages.splice(insertIndex, 0, sourceImage)

  const withoutSource = [
    ...lines.slice(0, sourceLine),
    ...lines.slice(sourceLine + 1)
  ]
  const adjustedStartLine = startLine - (sourceLine < startLine ? 1 : 0)
  const adjustedEndLine = endLine - (sourceLine < endLine ? 1 : 0)

  markdownValue.value = [
    ...withoutSource.slice(0, adjustedStartLine),
    ':::gallery',
    ...nextGalleryImages.map(createImageMarkdown),
    ':::',
    ...withoutSource.slice(adjustedEndLine + 1)
  ].join('\n')
}

/**
 * 라이브 모드 갤러리에서 특정 이미지를 삭제한다.
 * @param {{ startLine: number, endLine: number, imageIndex: number }} payload - 갤러리 범위·이미지 인덱스
 * @returns {void}
 */
const onPreviewRemoveGalleryImage = ({ startLine, endLine, imageIndex }) => {
  if (typeof startLine !== 'number' || typeof endLine !== 'number' || typeof imageIndex !== 'number') {
    return
  }

  const lines = (markdownValue.value || '').split('\n')
  const galleryImages = lines
    .slice(startLine + 1, endLine)
    .map((line) => parseEditorImageLine(line))
    .filter(Boolean)

  if (imageIndex < 0 || imageIndex >= galleryImages.length) {
    return
  }

  const remaining = galleryImages.filter((_, index) => index !== imageIndex)
  const replacementLines = remaining.length === 0
    ? []
    : remaining.length === 1
      ? [createImageMarkdown(remaining[0])]
      : [
        ':::gallery',
        ...remaining.map(createImageMarkdown),
        ':::'
      ]

  replaceLineRange(startLine, endLine, replacementLines, false)
  activeLogicalLineIndex.value = Math.max(0, Math.min(startLine + imageIndex, startLine + replacementLines.length - 1))
}

/**
 * 라이브 모드에서 갤러리 이미지를 블록 사이에 단일 이미지 줄로 분리한다.
 * @param {{ startLine: number, endLine: number, imageIndex: number, insertBeforeLine: number, image?: Object }} payload - 갤러리 범위·삽입 위치
 * @returns {void}
 */
const onPreviewExtractGalleryImage = ({
  startLine,
  endLine,
  imageIndex,
  insertBeforeLine,
  image
}) => {
  if (typeof startLine !== 'number'
    || typeof endLine !== 'number'
    || typeof imageIndex !== 'number'
    || typeof insertBeforeLine !== 'number') {
    return
  }

  const lines = (markdownValue.value || '').split('\n')
  const galleryImages = lines
    .slice(startLine + 1, endLine)
    .map((line) => parseEditorImageLine(line))
    .filter(Boolean)

  if (imageIndex < 0 || imageIndex >= galleryImages.length) {
    return
  }

  const extracted = image && image.url ? image : galleryImages[imageIndex]
  const remaining = galleryImages.filter((_, index) => index !== imageIndex)
  const galleryReplacement = remaining.length === 0
    ? []
    : remaining.length === 1
      ? [createImageMarkdown(remaining[0])]
      : [
        ':::gallery',
        ...remaining.map(createImageMarkdown),
        ':::'
      ]

  const withoutGallery = [
    ...lines.slice(0, startLine),
    ...lines.slice(endLine + 1)
  ]

  const removedSpan = endLine - startLine + 1
  const addedSpan = galleryReplacement.length
  let insertAt = insertBeforeLine

  if (insertBeforeLine > endLine) {
    insertAt -= removedSpan - addedSpan
  } else if (insertBeforeLine > startLine) {
    insertAt = startLine + addedSpan
  }

  insertAt = Math.max(0, Math.min(insertAt, withoutGallery.length))
  withoutGallery.splice(insertAt, 0, createImageMarkdown(extracted))

  let galleryAt = startLine

  if (startLine > insertAt) {
    galleryAt += 1
  }

  withoutGallery.splice(galleryAt, 0, ...galleryReplacement)
  markdownValue.value = withoutGallery.join('\n')
}

/**
 * 미리보기 인라인 편집 결과를 마크다운 본문에 반영한다.
 * @param {{ startLine: number, endLine: number, replacementLines: string[] }} payload - 줄 범위·대체 줄
 * @returns {void}
 */
const onPreviewBlockContentChange = ({ startLine, endLine, replacementLines }) => {
  if (typeof startLine !== 'number' || typeof endLine !== 'number' || !Array.isArray(replacementLines)) {
    return
  }

  replaceLineRange(startLine, endLine, replacementLines, false)
}

/**
 * 라이브 모드 하단에서 새 문단 줄을 추가한다.
 * @returns {void}
 */
const onPreviewAppendParagraph = () => {
  const value = markdownValue.value ?? ''
  const trimmed = value.replace(/\n+$/, '')
  markdownValue.value = trimmed ? `${trimmed}\n\n` : ''
}

/**
 * 라이브 모드에서 지정 줄 아래에 마크다운 줄을 삽입한다.
 * @param {{ afterLine: number, lines: string[] }} payload - 삽입 위치·줄
 * @returns {void}
 */
const onPreviewInsertAfterLine = ({ afterLine, lines }) => {
  if (typeof afterLine !== 'number' || !Array.isArray(lines)) {
    return
  }

  const sourceLines = (markdownValue.value ?? '').split('\n')
  markdownValue.value = [
    ...sourceLines.slice(0, afterLine + 1),
    ...lines,
    ...sourceLines.slice(afterLine + 1)
  ].join('\n')
}

/**
 * 라이브 모드에서 현재 줄을 삭제한다.
 * @param {number} lineIndex - 줄 번호(0-based)
 * @returns {void}
 */
const onPreviewDeleteLine = (lineIndex) => {
  if (typeof lineIndex !== 'number' || lineIndex < 0) {
    return
  }

  const sourceLines = (markdownValue.value ?? '').split('\n')

  if (lineIndex >= sourceLines.length) {
    return
  }

  const nextLines = [
    ...sourceLines.slice(0, lineIndex),
    ...sourceLines.slice(lineIndex + 1)
  ]

  markdownValue.value = nextLines.length ? nextLines.join('\n') : ''
}

/**
 * 라이브 모드 미리보기 루트 단축키를 처리한다.
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @returns {void}
 */
const handlePreviewKeydownCapture = (event) => {
  const isDeleteShortcut = (event.metaKey || event.ctrlKey)
    && event.shiftKey
    && event.key.toLowerCase() === 'k'

  if (!isDeleteShortcut) {
    return
  }

  const target = event.target

  if (target instanceof HTMLElement && target.closest('[contenteditable="true"]')) {
    return
  }

  const lineIndex = getCurrentPreviewSourceLine()

  if (typeof lineIndex !== 'number' || lineIndex < 0) {
    return
  }

  event.preventDefault()
  event.stopPropagation()
  onPreviewDeleteLine(lineIndex)
}

/**
 * 라이브 모드 이미지 설정 패널을 연다.
 * @param {number} lineIndex - 이미지 원본 줄 번호(0-based)
 * @returns {void}
 */
const onPreviewEditImage = (lineIndex) => {
  if (typeof lineIndex !== 'number' || lineIndex < 0) {
    return
  }

  activeLogicalLineIndex.value = lineIndex
  ensureBlockPanelEngaged()
}

/**
 * 라이브 모드에서 현재 줄을 이전 줄 끝에 병합한다.
 * @param {{ lineIndex: number, mergedLine: string }} payload - 병합 위치·결과 줄
 * @returns {void}
 */
/**
 * 슬래시 메뉴 좌표를 뷰포트 안으로 맞춘다.
 * @param {number} top - 후보 top(px)
 * @param {number} left - 후보 left(px)
 * @param {number} menuHeight - 메뉴 높이
 * @param {number} menuWidth - 메뉴 너비
 * @param {number} anchorTop - 기준 요소 top(위로 넘칠 때)
 * @returns {{ top: number, left: number }}
 */
const clampSlashMenuPosition = (top, left, menuHeight, menuWidth, anchorTop) => {
  const gap = 4
  const padding = 8
  const viewportBottom = window.innerHeight - padding
  const viewportRight = window.innerWidth - padding
  let nextTop = top

  if (nextTop + menuHeight > viewportBottom) {
    const aboveTop = anchorTop - menuHeight - gap

    if (aboveTop >= padding) {
      nextTop = aboveTop
    } else {
      nextTop = Math.max(padding, viewportBottom - menuHeight)
    }
  }

  let nextLeft = left

  if (nextLeft + menuWidth > viewportRight) {
    nextLeft = viewportRight - menuWidth
  }

  return {
    top: nextTop,
    left: Math.max(padding, nextLeft)
  }
}

/**
 * 미리보기 모드 슬래시 메뉴 위치를 갱신한다.
 * @returns {void}
 */
const updatePreviewSlashMenuPosition = () => {
  const element = previewRef.value?.querySelector(`[data-source-line="${liveSlashSourceLine.value}"]`)

  if (!element) {
    return
  }

  const rect = element.getBoundingClientRect()
  const menuEl = liveSlashMenuRef.value
  const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
  const menuWidth = menuEl?.offsetWidth || 288
  const gap = 4

  liveSlashMenuPosition.value = clampSlashMenuPosition(
    rect.bottom + gap,
    rect.left,
    menuHeight,
    menuWidth,
    rect.top
  )
}

/**
 * 소스 모드 textarea 슬래시 메뉴 위치를 갱신한다.
 * @returns {void}
 */
const updateWriteSlashMenuPosition = () => {
  const textarea = textareaRef.value
  const line = liveSlashSourceLine.value

  if (!textarea || line === null) {
    return
  }

  const value = markdownValue.value ?? ''
  const lineStart = value.split('\n').slice(0, line).join('\n').length + (line > 0 ? 1 : 0)
  const coords = getTextareaCaretCoordinates(textarea, lineStart)
  const rect = textarea.getBoundingClientRect()
  const menuEl = liveSlashMenuRef.value
  const menuHeight = menuEl?.offsetHeight || LIVE_SLASH_MENU_ESTIMATED_HEIGHT
  const menuWidth = menuEl?.offsetWidth || 288
  const gap = 4
  const anchorTop = rect.top + coords.top - textarea.scrollTop

  liveSlashMenuPosition.value = clampSlashMenuPosition(
    anchorTop + coords.height + gap,
    rect.left + coords.left - textarea.scrollLeft,
    menuHeight,
    menuWidth,
    anchorTop
  )
}

/**
 * 슬래시 메뉴 위치를 갱신한다(뷰포트 아래로 넘치면 위쪽·좌우 클램프).
 * @returns {void}
 */
const updateLiveSlashMenuPosition = () => {
  if (!import.meta.client || liveSlashSourceLine.value === null) {
    return
  }

  nextTick(() => {
    nextTick(() => {
      if (activeMode.value === 'write') {
        updateWriteSlashMenuPosition()
        return
      }

      updatePreviewSlashMenuPosition()
    })
  })
}

/**
 * 키보드로 선택한 슬래시 메뉴 항목이 보이도록 스크롤한다.
 * @returns {void}
 */
const scrollLiveSlashHighlightedIntoView = () => {
  if (!import.meta.client || !liveSlashVisible.value) {
    return
  }

  nextTick(() => {
    const menu = liveSlashMenuRef.value

    if (!menu) {
      return
    }

    const highlightedItem = menu.querySelector('.admin-markdown-editor__slash-item--active')

    if (!highlightedItem) {
      return
    }

    const itemTop = highlightedItem.offsetTop
    const itemBottom = itemTop + highlightedItem.offsetHeight
    const viewTop = menu.scrollTop
    const viewBottom = viewTop + menu.clientHeight

    if (itemTop < viewTop) {
      menu.scrollTop = itemTop
    } else if (itemBottom > viewBottom) {
      menu.scrollTop = itemBottom - menu.clientHeight
    }
  })
}

/**
 * 슬래시 메뉴 항목 mouseenter 시 하이라이트(키보드 탐색 중에는 무시).
 * @param {number} commandIndex - 명령 인덱스
 * @returns {void}
 */
const onSlashMenuItemMouseEnter = (commandIndex) => {
  if (liveSlashKeyboardNav.value) {
    return
  }

  liveSlashHighlightedIndex.value = commandIndex
}

/**
 * 슬래시 명령 입력을 갱신한다.
 * @param {{ sourceLine: number, query: string }} payload - 줄·검색어
 * @returns {void}
 */
const onLiveSlashUpdate = (payload) => {
  if (typeof payload?.sourceLine !== 'number') {
    return
  }

  if (liveSlashSuppressedLines.value.has(payload.sourceLine)) {
    if (liveSlashSourceLine.value !== null) {
      liveSlashSourceLine.value = null
      liveSlashQuery.value = ''
      liveSlashHighlightedIndex.value = 0
    }

    return
  }

  liveSlashSourceLine.value = payload.sourceLine
  liveSlashQuery.value = String(payload.query ?? '')
  nextTick(updateLiveSlashMenuPosition)
}

watch(liveSlashQuery, () => {
  liveSlashHighlightedIndex.value = 0
})

watch(visibleLiveSlashCommands, (commands) => {
  if (liveSlashHighlightedIndex.value >= commands.length) {
    liveSlashHighlightedIndex.value = Math.max(0, commands.length - 1)
  }

  updateLiveSlashMenuPosition()
})

watch(liveSlashHighlightedIndex, () => {
  scrollLiveSlashHighlightedIntoView()
})

watch(liveSlashVisible, (visible) => {
  if (!import.meta.client) {
    return
  }

  const onReposition = () => updateLiveSlashMenuPosition()

  if (visible) {
    window.addEventListener('scroll', onReposition, true)
    window.addEventListener('resize', onReposition)
    previewRef.value?.addEventListener('scroll', onReposition)
    textareaRef.value?.addEventListener('scroll', onReposition)
    updateLiveSlashMenuPosition()
    scrollLiveSlashHighlightedIntoView()
    return
  }

  window.removeEventListener('scroll', onReposition, true)
  window.removeEventListener('resize', onReposition)
  previewRef.value?.removeEventListener('scroll', onReposition)
  textareaRef.value?.removeEventListener('scroll', onReposition)
})

/**
 * 슬래시 명령 입력을 종료한다.
 * @returns {void}
 */
const onLiveSlashEnd = (payload) => {
  if (typeof payload?.sourceLine === 'number') {
    const next = new Set(liveSlashSuppressedLines.value)
    next.delete(payload.sourceLine)
    liveSlashSuppressedLines.value = next
  }

  liveSlashSourceLine.value = null
  liveSlashQuery.value = ''
  liveSlashHighlightedIndex.value = 0
}

/**
 * 슬래시 명령 입력을 취소한다(줄 내용은 유지, 메뉴만 닫음).
 * @returns {void}
 */
const cancelLiveSlashCommand = () => {
  const line = liveSlashSourceLine.value

  if (line !== null) {
    liveSlashSuppressedLines.value = new Set([...liveSlashSuppressedLines.value, line])
  }

  liveSlashSourceLine.value = null
  liveSlashQuery.value = ''
  liveSlashHighlightedIndex.value = 0
}

/**
 * 슬래시 명령 적용 후 미리보기 인라인 편집에 포커스를 복원한다.
 * @param {number} line - 줄 번호(0-based)
 * @param {string[]} replacementLines - 삽입된 줄
 * @returns {void}
 */
const focusPreviewAfterSlashCommand = (line, replacementLines) => {
  if (!import.meta.client) {
    return
  }

  const { line: focusLine, offset } = getSlashCommandFocusTarget(line, replacementLines)

  nextTick(() => {
    nextTick(() => {
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'end', offset)
        })
      })
    })
  })
}

/**
 * 소스 모드 textarea에 슬래시 명령 적용 후 커서를 복원한다.
 * @param {number} line - 줄 번호(0-based)
 * @param {string[]} replacementLines - 삽입된 줄
 * @returns {void}
 */
const focusTextareaAfterSlashCommand = (line, replacementLines) => {
  const value = markdownValue.value ?? ''
  const lineStart = value.split('\n').slice(0, line).join('\n').length + (line > 0 ? 1 : 0)
  const { offset } = getSlashCommandFocusTarget(line, replacementLines)
  setTextareaSelection(lineStart + offset, lineStart + offset)
}

/**
 * 슬래시 명령을 적용한다.
 * @param {import('../../lib/markdown-slash-commands.js').MarkdownSlashCommand} command - 명령
 * @returns {void}
 */
const applyLiveSlashCommand = (command) => {
  if (!command || liveSlashSourceLine.value === null) {
    return
  }

  const line = liveSlashSourceLine.value

  if (['media-image', 'media-gallery', 'media-video', 'media-audio', 'media-file'].includes(command.action)) {
    liveSlashInsertLine.value = line
    replaceLineRange(line, line, [], false)
    onLiveSlashEnd()
    openMediaPicker(command.action.replace('media-', ''))
    return
  }

  if (command.action === 'lines' && Array.isArray(command.lines)) {
    const replacementLines = command.lines
    const nextSuppressed = new Set(liveSlashSuppressedLines.value)
    nextSuppressed.delete(line)
    liveSlashSuppressedLines.value = nextSuppressed
    replaceLineRange(line, line, replacementLines, false)
    liveSlashSourceLine.value = null
    liveSlashQuery.value = ''
    liveSlashHighlightedIndex.value = 0

    if (activeMode.value === 'write') {
      focusTextareaAfterSlashCommand(line, replacementLines)
    } else {
      focusPreviewAfterSlashCommand(line, replacementLines)
    }
  }
}

/**
 * 슬래시 명령 Enter 적용
 * @param {{ sourceLine: number, value: string }} payload - 줄·입력 값
 * @returns {void}
 */
const onLiveSlashApply = (payload) => {
  const parsed = parseSlashInput(payload?.value)
  const command = resolveSlashCommand(parsed?.query ?? liveSlashQuery.value)

  if (!command) {
    return
  }

  applyLiveSlashCommand(command)
}

/**
 * 지정 줄에 마크다운 줄을 삽입한다.
 * @param {number} lineIndex - 삽입 위치(0-based)
 * @param {string[]} insertLines - 삽입 줄
 * @returns {void}
 */
const insertMarkdownAtLine = (lineIndex, insertLines) => {
  const sourceLines = (markdownValue.value ?? '').split('\n')

  markdownValue.value = [
    ...sourceLines.slice(0, lineIndex),
    ...insertLines,
    ...sourceLines.slice(lineIndex)
  ].join('\n')
}

const onPreviewMergeWithPreviousLine = ({ lineIndex, mergedLine }) => {
  if (typeof lineIndex !== 'number' || lineIndex <= 0) {
    return
  }

  const sourceLines = (markdownValue.value ?? '').split('\n')

  if (lineIndex >= sourceLines.length) {
    return
  }

  markdownValue.value = [
    ...sourceLines.slice(0, lineIndex - 1),
    String(mergedLine ?? ''),
    ...sourceLines.slice(lineIndex + 1)
  ].join('\n')
}

/**
 * 현재 갤러리 이미지 순서를 바꾼다.
 * @param {number} imageIndex - 이동할 이미지 인덱스
 * @param {-1|1} direction - 이동 방향
 * @returns {void}
 */
const moveActiveGalleryImage = (imageIndex, direction) => {
  const block = activeMediaBlock.value

  if (!block || block.kind !== 'gallery') {
    return
  }

  const nextIndex = imageIndex + direction

  if (nextIndex < 0 || nextIndex >= block.images.length) {
    return
  }

  const images = [...block.images]
  const [target] = images.splice(imageIndex, 1)
  images.splice(nextIndex, 0, target)
  replaceActiveMediaImages(images)
}

/**
 * 현재 미디어 블록에서 이미지를 삭제한다.
 * @param {number} imageIndex - 삭제할 이미지 인덱스
 * @returns {void}
 */
const removeActiveMediaImage = (imageIndex) => {
  const block = activeMediaBlock.value

  if (!block) {
    return
  }

  replaceActiveMediaImages(block.images.filter((_, index) => index !== imageIndex))
}

/**
 * 현재 갤러리에 이미지를 추가한다.
 * @param {Array<{ url: string, alt?: string, width?: string }>} images - 추가 이미지 목록
 * @returns {void}
 */
const appendImagesToActiveGallery = (images) => {
  const block = activeMediaBlock.value

  if (!block || block.kind !== 'gallery') {
    insertGallery(images)
    return
  }

  replaceActiveMediaImages([...block.images, ...images])
}

/**
 * 현재 임베드 URL을 수정한다.
 * @param {string} url - 임베드 URL
 * @returns {void}
 */
const updateActiveEmbedUrl = (url) => {
  const block = activeBlockContext.value

  if (!block || block.kind !== 'embed') {
    return
  }

  ensureBlockPanelEngaged()
  const trimmed = String(url || '').trim()

  if (!trimmed) {
    replaceLineRange(block.startLine, block.endLine, [], false)
    return
  }

  replaceLineRange(block.startLine, block.endLine, [trimmed], false)
}

/**
 * 현재 인용 블록 배경색을 수정한다.
 * @param {string} background - 배경색 옵션
 * @returns {void}
 */
const updateActiveQuoteBackground = (background) => {
  const block = activeBlockContext.value

  if (!block || block.kind !== 'quote') {
    return
  }

  ensureBlockPanelEngaged()
  const value = String(background || '').trim()

  if (!QUOTE_BACKGROUND_OPTIONS.includes(value)) {
    return
  }

  const optionLine = `> [!bg=${value}]`
  const lines = (markdownValue.value || '').split('\n')
  const nextLines = [...lines]

  if (block.hasQuoteOptions) {
    nextLines.splice(block.startLine, 1, optionLine)
  } else {
    nextLines.splice(block.startLine, 0, optionLine)
  }

  markdownValue.value = nextLines.join('\n')
}

/**
 * 현재 콜아웃 블록 옵션을 수정한다.
 * @param {Partial<{ calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string, title: string }>} patch - 변경 옵션
 * @returns {void}
 */
const updateActiveCalloutOptions = (patch = {}) => {
  const block = activeBlockContext.value

  if (!block || block.kind !== 'callout') {
    return
  }

  ensureBlockPanelEngaged()
  const nextBackground = CALLOUT_BACKGROUND_OPTIONS.includes(patch.calloutBackground)
    ? patch.calloutBackground
    : block.calloutBackground
  const nextLine = buildCalloutOpenerLine({
    calloutEmojiEnabled: patch.calloutEmojiEnabled ?? block.calloutEmojiEnabled,
    calloutEmoji: patch.calloutEmoji ?? block.calloutEmoji,
    calloutBackground: nextBackground,
    title: patch.title ?? block.title
  })
  const lines = (markdownValue.value || '').split('\n')
  const nextLines = [...lines]

  nextLines.splice(block.startLine, 1, nextLine)
  markdownValue.value = nextLines.join('\n')
}

/**
 * 현재 코드 블록 옵션을 수정한다.
 * @param {Partial<{ language: string, showLineNumbers: boolean }>} patch - 변경 옵션
 * @returns {void}
 */
const updateActiveCodeOptions = (patch = {}) => {
  const block = activeBlockContext.value

  if (!block || block.kind !== 'code') {
    return
  }

  ensureBlockPanelEngaged()
  const nextLine = buildCodeFenceOpener({
    language: patch.language ?? block.language,
    showLineNumbers: patch.showLineNumbers ?? block.showLineNumbers
  })
  const lines = (markdownValue.value || '').split('\n')
  const nextLines = [...lines]

  nextLines.splice(block.startLine, 1, nextLine)
  markdownValue.value = nextLines.join('\n')
}

/**
 * 현재 토글 블록 옵션을 수정한다.
 * @param {Partial<{ defaultOpen: boolean }>} patch - 변경 옵션
 * @returns {void}
 */
const updateActiveToggleOptions = (patch = {}) => {
  const block = activeBlockContext.value

  if (!block || block.kind !== 'toggle') {
    return
  }

  ensureBlockPanelEngaged()
  const nextLine = buildToggleOpenerLine({
    title: block.title,
    defaultOpen: patch.defaultOpen ?? block.defaultOpen
  })
  const lines = (markdownValue.value || '').split('\n')
  const nextLines = [...lines]

  nextLines.splice(block.startLine, 1, nextLine)
  markdownValue.value = nextLines.join('\n')
}

/**
 * 이미지 마크다운을 삽입한다.
 * @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
 * @returns {void}
 */
const insertImage = (image) => {
  insertBlockSnippet(createImageMarkdown(image))
}

/**
 * 갤러리 마크다운을 삽입한다.
 * @param {Array<{ url: string, alt?: string, width?: string }>} images - 이미지 목록
 * @returns {void}
 */
const insertGallery = (images) => {
  if (!images.length) {
    return
  }

  insertBlockSnippet([':::gallery', ...images.map(createImageMarkdown), ':::'].join('\n'))
}

/**
 * 미디어 목록을 불러온다.
 * @returns {Promise<void>}
 */
const fetchMediaItems = async () => {
  isLoadingMedia.value = true

  try {
    mediaItems.value = await $fetch('/admin/api/media')
  } finally {
    isLoadingMedia.value = false
  }
}

/**
 * 미디어 선택 창을 연다.
 * @param {'image'|'gallery'|'active-gallery'} target - 삽입 대상
 * @returns {Promise<void>}
 */
const openMediaPicker = async (target) => {
  if (target === 'active-gallery') {
    ensureBlockPanelEngaged()
  }

  mediaPickerTarget.value = target
  activeMediaPickerTab.value = 'library'
  mediaSearchQuery.value = ''
  selectedMediaUrls.value = []
  isMediaPickerOpen.value = true
  await fetchMediaItems()
}

defineExpose({
  focusFirstBlock,
  toggleEditorMode,
  activeMode,
  handleBlockPanelFocusIn,
  handleBlockPanelFocusOut,
  updateActiveMediaImage,
  setActiveMediaUseAlt,
  moveActiveGalleryImage,
  removeActiveMediaImage,
  appendImagesToActiveGallery,
  updateActiveEmbedUrl,
  updateActiveQuoteBackground,
  updateActiveCalloutOptions,
  updateActiveCodeOptions,
  updateActiveToggleOptions,
  openMediaPicker
})

/**
 * 미디어 선택 창을 닫는다.
 * @returns {void}
 */
const closeMediaPicker = () => {
  isMediaPickerOpen.value = false
  selectedMediaUrls.value = []
  activeMediaPickerTab.value = 'library'
  mediaSearchQuery.value = ''
  liveSlashInsertLine.value = null
}

/**
 * 미디어 항목이 검색어와 일치하는지 확인한다.
 * @param {Object} item - 미디어 항목
 * @param {string} query - 소문자 검색어
 * @returns {boolean} 일치 여부
 */
const mediaItemMatchesQuery = (item, query) => {
  if (!query) {
    return true
  }

  return [item.name, item.title, item.url]
    .some((value) => String(value || '').toLowerCase().includes(query))
}

/**
 * 검색어로 필터링된 미디어 목록
 * @returns {Array<Object>} 필터링된 미디어 목록
 */
const filteredMediaItems = computed(() => {
  const query = mediaSearchQuery.value.trim().toLowerCase()
  const targetItems = mediaItems.value.filter(isMediaItemSelectableForTarget)

  if (!query) {
    return targetItems
  }

  return targetItems.filter((item) => mediaItemMatchesQuery(item, query))
})

/**
 * 미디어 선택 상태를 토글한다.
 * @param {Object} mediaItem - 미디어 항목
 * @returns {void}
 */
const toggleMediaSelection = (mediaItem) => {
  if (!isGalleryMediaPicker.value) {
    selectedMediaUrls.value = [mediaItem.url]
    return
  }

  selectedMediaUrls.value = selectedMediaUrls.value.includes(mediaItem.url)
    ? selectedMediaUrls.value.filter((url) => url !== mediaItem.url)
    : [...selectedMediaUrls.value, mediaItem.url]
}

/**
 * 미디어 항목 목록을 마크다운에 삽입한다.
 * @param {Array<Object>} selectedItems - 선택 또는 업로드된 미디어 항목
 * @returns {void}
 */
const insertSelectedMediaItems = (selectedItems) => {
  if (liveSlashInsertLine.value !== null) {
    const line = liveSlashInsertLine.value

    if (mediaPickerTarget.value === 'image') {
      const [item] = selectedItems

      if (item) {
        insertMarkdownAtLine(line, [createImageMarkdown({
          url: item.url,
          useAlt: false,
          caption: ''
        })])
      }
    } else if (mediaPickerTarget.value === 'gallery' && selectedItems.length) {
      insertMarkdownAtLine(line, [
        ':::gallery',
        ...selectedItems.map((item) => createImageMarkdown({
          url: item.url,
          useAlt: false,
          caption: ''
        })),
        ':::'
      ])
    } else if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
      const [item] = selectedItems

      if (item) {
        insertMarkdownAtLine(line, createMediaBlockMarkdown(mediaPickerTarget.value, item))
      }
    }

    liveSlashInsertLine.value = null
    return
  }

  if (mediaPickerTarget.value === 'image') {
    const [item] = selectedItems
    if (item) {
      insertImage({
        url: item.url,
        useAlt: false,
        caption: ''
      })
    }
  } else if (mediaPickerTarget.value === 'active-gallery') {
    appendImagesToActiveGallery(selectedItems.map((item) => ({
      url: item.url,
      useAlt: false,
      caption: ''
    })))
  } else if (mediaPickerTarget.value === 'gallery') {
    insertGallery(selectedItems.map((item) => ({
      url: item.url,
      useAlt: false,
      caption: ''
    })))
  } else if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
    const [item] = selectedItems

    if (item) {
      insertBlockSnippet(createMediaBlockMarkdown(mediaPickerTarget.value, item).join('\n'))
    }
  }
}

/**
 * 선택한 미디어를 마크다운에 삽입한다.
 * @returns {void}
 */
const applyMediaSelection = () => {
  const selectedItems = selectedMediaUrls.value
    .map((url) => mediaItems.value.find((item) => item.url === url))
    .filter(Boolean)

  insertSelectedMediaItems(selectedItems)
  closeMediaPicker()
}

/**
 * 업로드 API 오류 메시지를 사용자용 문장으로 변환한다.
 * @param {unknown} error - fetch 오류
 * @returns {string} 오류 메시지
 */
const resolveUploadFetchErrorMessage = (error) => {
  const responseMessage = error?.data?.message || error?.response?._data?.message

  if (typeof responseMessage === 'string' && responseMessage.trim()) {
    return responseMessage.trim()
  }

  if (error?.statusCode === 413 || error?.status === 413) {
    return '업로드 가능한 파일 크기를 초과했습니다.'
  }

  if (error instanceof Error && error.message) {
    return error.message
  }

  return '파일 업로드에 실패했습니다.'
}

/**
 * 선택 파일이 업로드 한도를 넘지 않는지 검사한다.
 * @param {FileList|Array<File>} files - 업로드 파일 목록
 * @returns {string|null} 초과 시 오류 메시지, 아니면 null
 */
const findUploadSizeLimitError = (files) => {
  for (const file of Array.from(files)) {
    const uploadKind = getUploadKind(file.type, file.name)
    const maxBytes = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits.value)

    if (file.size > maxBytes) {
      return `파일 크기가 너무 큽니다. (최대 ${formatUploadSizeLimit(maxBytes)})`
    }
  }

  return null
}

/**
 * 미디어 파일을 업로드한다.
 * @param {FileList|Array<File>} files - 업로드 파일 목록
 * @returns {Promise<Array<Object>>} 업로드된 파일 목록
 */
const uploadMediaFiles = async (files) => {
  const sizeLimitError = findUploadSizeLimitError(files)

  if (sizeLimitError) {
    throw new Error(sizeLimitError)
  }

  const formData = new FormData()
  Array.from(files).forEach((file) => {
    formData.append('files', file)
  })

  const result = await $fetch('/admin/api/uploads', {
    method: 'POST',
    body: formData
  })

  return result.files || []
}

/**
 * 업로드 파일을 현재 커서 위치에 삽입한다.
 * @param {FileList|Array<File>} files - 파일 목록
 * @param {'image'|'gallery'|'video'|'audio'|'file'} target - 삽입 대상
 * @returns {Promise<void>}
 */
const uploadAndInsert = async (files, target = 'image') => {
  if (!files?.length) {
    return
  }

  isUploading.value = true

  try {
    const uploadedFiles = await uploadMediaFiles(files)
    const imageItems = uploadedFiles.map((file) => ({
      url: file.url,
      useAlt: false,
      caption: ''
    }))

    if (target === 'gallery') {
      insertGallery(imageItems)
    } else if (target === 'image' && imageItems[0]) {
      insertImage(imageItems[0])
    } else if (['video', 'audio', 'file'].includes(target) && uploadedFiles[0]) {
      insertBlockSnippet(createMediaBlockMarkdown(target, uploadedFiles[0]).join('\n'))
    }

    mediaItems.value = await $fetch('/admin/api/media').catch(() => mediaItems.value)
  } catch (error) {
    showToast('error', resolveUploadFetchErrorMessage(error))
  } finally {
    isUploading.value = false
  }
}

/**
 * 파일 입력 변경 처리
 * @param {Event} event - 파일 입력 이벤트
 * @param {'image'|'gallery'|'video'|'audio'|'file'} target - 삽입 대상
 * @returns {Promise<void>}
 */
const handleFileInput = async (event, target) => {
  await uploadAndInsert(event.target.files, target)
  event.target.value = ''
}

/**
 * 미디어 모달 업로드 탭에서 파일을 삽입한다.
 * @param {FileList|Array<File>} files - 업로드 파일 목록
 * @returns {Promise<void>}
 */
const uploadFromMediaModal = async (files) => {
  if (!files?.length) {
    return
  }

  const target = mediaPickerTarget.value === 'gallery' || mediaPickerTarget.value === 'active-gallery'
    ? 'gallery'
    : mediaPickerTarget.value

  isUploading.value = true

  try {
    const uploadedFiles = await uploadMediaFiles(files)
    mediaItems.value = await $fetch('/admin/api/media').catch(() => [
      ...uploadedFiles,
      ...mediaItems.value
    ])
    insertSelectedMediaItems(target === 'gallery' ? uploadedFiles : uploadedFiles.slice(0, 1))
    closeMediaPicker()
  } catch (error) {
    showToast('error', resolveUploadFetchErrorMessage(error))
  } finally {
    isUploading.value = false
  }
}

/**
 * 미디어 모달 업로드 영역에 파일을 드롭한다.
 * @param {DragEvent} event - 드롭 이벤트
 * @returns {Promise<void>}
 */
const handleMediaModalDrop = async (event) => {
  const files = Array.from(event.dataTransfer?.files || []).filter(isUploadFileAllowedForPicker)

  if (!files.length) {
    return
  }

  event.preventDefault()
  await uploadFromMediaModal(files)
}

/**
 * 미디어 모달의 삽입 대상 라벨을 반환한다.
 * @returns {string} 모달 제목
 */
const mediaPickerTitle = computed(() => {
  if (mediaPickerTarget.value === 'gallery' || mediaPickerTarget.value === 'active-gallery') {
    return '갤러리 이미지 선택'
  }

  if (mediaPickerTarget.value === 'video') {
    return '비디오 선택'
  }

  if (mediaPickerTarget.value === 'audio') {
    return '오디오 선택'
  }

  if (mediaPickerTarget.value === 'file') {
    return '파일 선택'
  }

  return '이미지 선택'
})

/**
 * 미디어 모달에서 다중 선택 여부를 반환한다.
 * @returns {boolean} 다중 선택 여부
 */
const isGalleryMediaPicker = computed(() => mediaPickerTarget.value === 'gallery'
  || mediaPickerTarget.value === 'active-gallery')

/**
 * 미디어 업로드 안내 문구를 반환한다.
 * @returns {string} 안내 문구
 */
const mediaPickerUploadHint = computed(() => {
  if (isGalleryMediaPicker.value) {
    return `여러 이미지를 한 번에 선택할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.image)})`
  }

  if (mediaPickerTarget.value === 'video') {
    return `MP4, WebM, MOV 비디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.video)})`
  }

  if (mediaPickerTarget.value === 'audio') {
    return `MP3, WAV, OGG, M4A 오디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.audio)})`
  }

  if (mediaPickerTarget.value === 'file') {
    return `PDF, ZIP, 문서 파일을 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.document)})`
  }

  return `단일 이미지가 본문에 삽입됩니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.image)})`
})

/**
 * 붙여넣은 이미지 파일을 업로드한다.
 * @param {ClipboardEvent} event - 붙여넣기 이벤트
 * @returns {Promise<void>}
 */
const handlePaste = async (event) => {
  const imageFiles = Array.from(event.clipboardData?.files || []).filter((file) => file.type.startsWith('image/'))

  if (!imageFiles.length) {
    const html = event.clipboardData?.getData('text/html')

    if (html) {
      const markdown = convertHtmlToMarkdown(html)

      if (markdown) {
        event.preventDefault()
        replaceSelection(markdown)
      }
    }

    return
  }

  event.preventDefault()
  await uploadAndInsert(imageFiles, imageFiles.length > 1 ? 'gallery' : 'image')
}

/**
 * 드롭한 이미지 파일을 업로드한다.
 * @param {DragEvent} event - 드롭 이벤트
 * @returns {Promise<void>}
 */
const handleDrop = async (event) => {
  const imageFiles = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))

  if (!imageFiles.length) {
    return
  }

  event.preventDefault()
  await uploadAndInsert(imageFiles, imageFiles.length > 1 ? 'gallery' : 'image')
}

/**
 * 키보드 단축키를 처리한다.
 * @param {KeyboardEvent} event - 키보드 이벤트
 * @returns {void}
 */
const handleKeydown = (event) => {
  if (!(event.metaKey || event.ctrlKey)) {
    return
  }

  const key = event.key.toLowerCase()

  if (event.shiftKey && key === 'k') {
    event.preventDefault()
    event.stopPropagation()
    deleteSelectedSourceLines()
  } else if (key === 'b') {
    event.preventDefault()
    wrapInline('**', '**', '굵은 글씨')
  } else if (key === 'i') {
    event.preventDefault()
    wrapInline('*', '*', '기울임')
  }
}
</script>

<template>
  <div ref="editorRootRef" class="admin-markdown-editor grid gap-3">

    <Teleport v-if="modeToggleTeleportTo" :to="modeToggleTeleportTo">
      <button
        class="admin-markdown-editor__mode-toggle inline-flex size-8 shrink-0 items-center justify-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8e9cac]"
        type="button"
        :aria-label="activeMode === 'write' ? '미리보기로 전환' : '작성 모드로 전환'"
        @click="toggleEditorMode"
      >
        <svg
          v-if="activeMode === 'write'"
          xmlns="http://www.w3.org/2000/svg"
          width="16"
          height="16"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          class="admin-markdown-editor__mode-icon lucide-book-open"
          aria-hidden="true"
        >
          <path d="M12 7v14" />
          <path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
        </svg>
        <svg
          v-else
          xmlns="http://www.w3.org/2000/svg"
          width="16"
          height="16"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          class="admin-markdown-editor__mode-icon lucide-edit-3"
          aria-hidden="true"
        >
          <path d="M13 21h8" />
          <path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
        </svg>
      </button>
    </Teleport>

    <div v-if="activeMode === 'write'" class="admin-markdown-editor__write relative">
      <div class="admin-markdown-editor__editor-surface min-h-[620px]">
        <div class="admin-markdown-editor__write-columns flex items-start">
          <div
            ref="gutterRef"
            class="admin-markdown-editor__gutter w-10 shrink-0 select-none py-5 pr-2 font-mono text-[13px] leading-7 text-[#a0a8b0]"
            aria-hidden="true"
          >
            <div
              v-for="ln in gutterLineCount"
              :key="`gutter-line-${ln}`"
              class="admin-markdown-editor__gutter-line text-right tabular-nums"
              :style="{ height: `${getGutterLineHeight(ln - 1)}px` }"
            >
              {{ ln }}
            </div>
          </div>
          <textarea
            ref="textareaRef"
            v-model="markdownValue"
            class="admin-markdown-editor__textarea min-h-[620px] min-w-0 flex-1 resize-none overflow-hidden break-words border-0 bg-transparent py-5 pl-0 pr-5 text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:ring-0"
            placeholder="마크다운으로 글을 작성하세요."
            spellcheck="false"
            @keydown="handleKeydown"
            @paste="handlePaste"
            @drop="handleDrop"
            @dragover.prevent
            @input="refreshCaretLogicalLine"
            @compositionstart="handleTextareaCompositionStart"
            @compositionend="handleTextareaCompositionEnd"
            @click="refreshCaretLogicalLine"
            @keyup="refreshCaretLogicalLine"
            @select="refreshCaretLogicalLine"
            @focus="onTextareaFocus"
            @blur="onTextareaBlur"
          />
        </div>
      </div>
      <div v-if="isUploading" class="admin-markdown-editor__uploading absolute right-3 top-3 rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white">
        업로드 중
      </div>
    </div>

    <div
      v-else
      ref="previewRef"
      class="admin-markdown-editor__preview min-h-[620px] px-0 py-5 text-[#15171a] outline-none"
      style="--site-text: #15171a; --site-muted: #6b7280; --site-panel: #f6f7f8; --site-line: #e3e6e8; --site-accent: #2eb6ea;"
      tabindex="0"
      @keydown.capture="handlePreviewKeydownCapture"
    >
      <ContentMarkdownRenderer
        ref="previewRendererRef"
        :content="markdownValue"
        interactive
        :slash-menu-active="liveSlashVisible"
        :slash-suppressed-lines="liveSlashSuppressedLineList"
        @gallery-reorder="onPreviewGalleryReorder"
        @merge-images-to-gallery="onPreviewMergeImagesToGallery"
        @insert-image-to-gallery="onPreviewInsertImageToGallery"
        @extract-gallery-image="onPreviewExtractGalleryImage"
        @remove-gallery-image="onPreviewRemoveGalleryImage"
        @block-content-change="onPreviewBlockContentChange"
        @append-paragraph="onPreviewAppendParagraph"
        @insert-after-line="onPreviewInsertAfterLine"
        @delete-line="onPreviewDeleteLine"
        @merge-with-previous-line="onPreviewMergeWithPreviousLine"
        @edit-image="onPreviewEditImage"
        @line-focus="handleLiveLineFocus"
        @line-blur="handleLiveLineBlur"
        @slash-update="onLiveSlashUpdate"
        @slash-end="onLiveSlashEnd"
        @slash-apply="onLiveSlashApply"
      />
    </div>

    <Teleport to="body">
      <div
        v-if="liveSlashVisible"
        ref="liveSlashMenuRef"
        class="admin-markdown-editor__slash-menu fixed z-[60] w-72 max-h-[min(52vh,360px)] overflow-y-auto rounded-lg border border-[#e3e6e8] bg-white p-1 shadow-lg"
        :style="{ top: `${liveSlashMenuPosition.top}px`, left: `${liveSlashMenuPosition.left}px` }"
        role="listbox"
        aria-label="슬래시 명령"
      >
        <button
          v-for="(command, commandIndex) in visibleLiveSlashCommands"
          :key="`live-slash-${command.id}`"
          class="admin-markdown-editor__slash-item flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left transition-colors"
          :class="commandIndex === liveSlashHighlightedIndex ? 'admin-markdown-editor__slash-item--active bg-[#f4f5f6]' : ''"
          type="button"
          role="option"
          :aria-selected="commandIndex === liveSlashHighlightedIndex"
          @mouseenter="onSlashMenuItemMouseEnter(commandIndex)"
          @mousedown.prevent="applyLiveSlashCommand(command)"
        >
          <span class="admin-markdown-editor__slash-leading flex min-w-0 items-center gap-2.5">
            <AdminSlashCommandIcon
              :command-id="command.id"
              class="admin-markdown-editor__slash-icon shrink-0 text-[#394047]"
            />
            <span class="admin-markdown-editor__slash-label truncate text-sm text-[#15171a]">{{ command.label }}</span>
          </span>
          <span
            class="admin-markdown-editor__slash-slug shrink-0 text-xs text-[#8b959e] transition-opacity"
            :class="commandIndex === liveSlashHighlightedIndex ? 'opacity-100' : 'opacity-0'"
          >/{{ command.id }}</span>
        </button>
        <p v-if="!visibleLiveSlashCommands.length" class="admin-markdown-editor__slash-empty px-4 py-3 text-sm text-[#6b7280]">
          일치하는 명령이 없습니다.
        </p>
      </div>
    </Teleport>

    <div v-if="isMediaPickerOpen" class="admin-markdown-editor__media-modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-4 py-6" @click.self="closeMediaPicker">
      <div class="admin-markdown-editor__media-panel flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded bg-white shadow-2xl">
        <header class="admin-markdown-editor__media-header flex items-center justify-between border-b border-[#e3e6e8] px-5 py-4">
          <div>
            <h2 class="admin-markdown-editor__media-title text-lg font-bold text-black">
              {{ mediaPickerTitle }}
            </h2>
            <p v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-count mt-1 text-sm text-[#6b7280]">
              {{ selectedMediaUrls.length }}개 선택됨
            </p>
          </div>
          <button class="admin-markdown-editor__media-close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
            닫기
          </button>
        </header>

        <div class="admin-markdown-editor__media-tabs flex items-center gap-3 border-b border-[#e3e6e8] px-5">
          <div class="flex min-w-0 flex-1 items-center">
            <button
              class="admin-markdown-editor__media-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
              :class="activeMediaPickerTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
              type="button"
              @click="activeMediaPickerTab = 'library'"
            >
              미디어 라이브러리
            </button>
            <button
              class="admin-markdown-editor__media-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
              :class="activeMediaPickerTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
              type="button"
              @click="activeMediaPickerTab = 'upload'"
            >
              업로드
            </button>
          </div>
          <label class="admin-markdown-editor__media-search mb-px flex w-full max-w-xs shrink-0 items-center py-2">
            <span class="sr-only">파일명 검색</span>
            <input
              v-model="mediaSearchQuery"
              class="admin-markdown-editor__media-search-input w-full rounded border border-[#d7dde2] bg-white px-3 py-1.5 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]/20"
              type="search"
              placeholder="파일명 검색"
              autocomplete="off"
            >
          </label>
        </div>

        <div class="admin-markdown-editor__media-body flex-1 overflow-y-auto p-5">
          <template v-if="activeMediaPickerTab === 'library'">
          <div v-if="isLoadingMedia" class="admin-markdown-editor__media-loading py-12 text-center text-sm text-[#8e9cac]">
            불러오는 중
          </div>
          <div v-else-if="filteredMediaItems.length" class="admin-markdown-editor__media-grid grid grid-cols-2 gap-3 md:grid-cols-4">
            <button
              v-for="item in filteredMediaItems"
              :key="item.url"
              class="admin-markdown-editor__media-item group overflow-hidden rounded border bg-white text-left transition"
              :class="selectedMediaUrls.includes(item.url) ? 'border-[3px] border-[#ff7a00] ring-2 ring-[#ff7a00]/40' : 'border border-[#e3e6e8] hover:border-[#8e9cac]'"
              type="button"
              @click="toggleMediaSelection(item)"
            >
              <img
                v-if="getMediaItemKind(item) === 'image'"
                class="admin-markdown-editor__media-thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover"
                :src="item.url"
                :alt="item.name || ''"
              >
              <span
                v-else
                class="admin-markdown-editor__media-thumb flex aspect-[4/3] w-full items-center justify-center bg-[#f6f7f8] text-xs font-bold uppercase tracking-[0.18em] text-[#6b7280]"
              >
                {{ getMediaItemKind(item) }}
              </span>
              <span class="admin-markdown-editor__media-name block truncate px-3 py-2 text-xs font-semibold text-[#394047]">
                {{ item.name || item.url }}
              </span>
            </button>
          </div>
            <div v-else class="admin-markdown-editor__media-empty py-12 text-center text-sm text-[#8e9cac]">
              <template v-if="mediaSearchQuery.trim()">
                「{{ mediaSearchQuery.trim() }}」에 맞는 미디어가 없습니다.
              </template>
              <template v-else>
                선택할 미디어가 없습니다. 업로드 탭에서 파일을 추가할 수 있습니다.
              </template>
            </div>
          </template>
          <div
            v-else
            class="admin-markdown-editor__media-upload-zone grid min-h-[420px] place-items-center rounded border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
            @dragover.prevent
            @drop.prevent="handleMediaModalDrop"
          >
            <div class="admin-markdown-editor__media-upload-inner grid gap-3 px-6">
              <p class="admin-markdown-editor__media-upload-title text-lg font-semibold text-[#15171a]">
                파일을 끌어 업로드
              </p>
              <p class="admin-markdown-editor__media-upload-or text-sm text-[#6b7280]">
                또는
              </p>
              <label class="admin-markdown-editor__media-upload-button mx-auto inline-flex h-10 cursor-pointer items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50">
                {{ isUploading ? '업로드 중' : '파일 선택' }}
                <input
                  class="sr-only"
                  type="file"
                  :accept="mediaPickerAccept"
                  :multiple="isGalleryMediaPicker"
                  @change="uploadFromMediaModal($event.target.files); $event.target.value = ''"
                >
              </label>
              <p class="admin-markdown-editor__media-upload-hint text-xs text-[#8e9cac]">
                {{ mediaPickerUploadHint }}
              </p>
            </div>
          </div>
        </div>

        <footer v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-footer flex items-center justify-end gap-2 border-t border-[#e3e6e8] px-5 py-4">
          <button class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
            취소
          </button>
          <button
            class="admin-markdown-editor__media-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
            type="button"
            :disabled="selectedMediaUrls.length === 0"
            @click="applyMediaSelection"
          >
            삽입
          </button>
        </footer>
      </div>
    </div>
  </div>

  <div
    v-if="toast"
    class="admin-markdown-editor__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
    :class="{
      'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
      'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
      'border-[#e3e6e8] bg-white text-[#15171a]': toast.type === 'info'
    }"
    role="status"
  >
    {{ toast.message }}
  </div>
</template>

<style scoped>
.admin-markdown-editor__gutter {
  scrollbar-width: none;
}

.admin-markdown-editor__gutter::-webkit-scrollbar {
  display: none;
}
</style>
