<script setup lang="ts">
usePageTitle('媒体库')

type RefContext = {
  ownerType: string
  ownerId: number
  label: string
  href: string | null
}

type MediaAssetRow = {
  id: number
  storageKey: string
  publicPath: string
  mime: string
  sizeBytes: number
  createdAt: string
  refCount: number
  userNote: string | null
  refContexts: RefContext[]
  canDelete: boolean
  deleteBlockedReason: 'referenced' | 'cooling' | null
  deleteGraceExpiresAt: string | null
}

const toast = useToast()
const runtimeConfig = useRuntimeConfig()
const { fetchData, getApiErrorMessage } = useClientApi()

/** 与 `NUXT_PUBLIC_SITE_URL` / `runtimeConfig.public.siteUrl` 一致；未配置则退回当前页 origin。 */
function resolveCopyOrigin(): string {
  const raw = String(runtimeConfig.public.siteUrl ?? '').trim()
  if (raw) {
    try {
      return new URL(raw).origin
    } catch {
      /* 使用 window */
    }
  }
  return typeof window !== 'undefined' ? window.location.origin : ''
}

const page = ref(1)
const pageSize = ref(20)
const items = ref<MediaAssetRow[]>([])
const total = ref(0)
const loading = ref(true)
/** 非首屏刷新（翻页、搜索）：列表区轻量遮罩，避免旧数据与新结果切换生硬 */
const listBusy = ref(false)
/** 输入框内容；`appliedSearch` 防抖后参与请求 */
const searchInput = ref('')
const appliedSearch = ref('')
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
const uploading = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const reuploadInput = ref<HTMLInputElement | null>(null)
const reuploadTargetId = ref<number | null>(null)
const reuploadingId = ref<number | null>(null)
/** 预览加载失败（多为磁盘缺文件） */
const previewFailedIds = ref<Set<number>>(new Set())
const imgBust = ref<Record<number, number>>({})
const noteDraft = reactive<Record<number, string>>({})
const savingNoteId = ref<number | null>(null)
/** 正在编辑描述的资源 id；其余项用服务端文案展示 */
const editingNoteId = ref<number | null>(null)
/** 引用详情是否展开（按资源 id） */
const refDetailOpen = reactive<Record<number, boolean>>({})
const deleteConfirmOpen = ref(false)
const pendingDeleteAsset = ref<MediaAssetRow | null>(null)
const deletingId = ref<number | null>(null)

const skeletonPlaceholders = computed(() => Math.min(Math.max(pageSize.value, 1), 12))

watch(
  items,
  (list) => {
    for (const it of list) {
      if (editingNoteId.value === it.id) {
        continue
      }
      noteDraft[it.id] = it.userNote ?? ''
    }
  },
  { immediate: true },
)

const pageSizeItems = [
  { label: '每页 10', value: 10 },
  { label: '每页 20', value: 20 },
  { label: '每页 50', value: 50 },
]

function formatBytes(n: number): string {
  if (n < 1024) {
    return `${n} B`
  }
  if (n < 1024 * 1024) {
    return `${(n / 1024).toFixed(1)} KB`
  }
  return `${(n / (1024 * 1024)).toFixed(1)} MB`
}

function formatDt(iso: string | null): string {
  if (!iso) {
    return '—'
  }
  const d = new Date(iso)
  return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN')
}

function deleteDisabledHint(item: MediaAssetRow): string {
  if (item.canDelete) {
    return ''
  }
  if (item.deleteBlockedReason === 'referenced') {
    return '该文件仍被文章或资料引用，请先从正文、封面或资料中移除对应图片后再删除。'
  }
  if (item.deleteGraceExpiresAt) {
    return `宽限期未满，预计 ${formatDt(item.deleteGraceExpiresAt)} 后可删除；也可在「媒体 → 孤儿清理」中查看进度。`
  }
  return '当前不可删除，请稍后在「孤儿清理」中处理。'
}

function openDeleteConfirm(item: MediaAssetRow) {
  if (!item.canDelete) {
    return
  }
  pendingDeleteAsset.value = item
  deleteConfirmOpen.value = true
}

function closeDeleteModal() {
  deleteConfirmOpen.value = false
}

watch(deleteConfirmOpen, (open) => {
  if (!open) {
    pendingDeleteAsset.value = null
  }
})

async function executeDelete() {
  const item = pendingDeleteAsset.value
  if (!item) {
    return
  }
  deletingId.value = item.id
  try {
    await fetchData(`/api/me/media/assets/${item.id}`, {
      method: 'DELETE',
      notify: false,
    })
    toast.add({ title: '已删除', color: 'success' })
    closeDeleteModal()
    if (editingNoteId.value === item.id) {
      editingNoteId.value = null
    }
    delete refDetailOpen[item.id]
    await load()
  } catch (e: unknown) {
    toast.add({ title: getApiErrorMessage(e), color: 'error' })
  } finally {
    deletingId.value = null
  }
}

async function load() {
  const searchTrim = appliedSearch.value.trim()
  const blocking = items.value.length === 0 && !searchTrim
  if (blocking) {
    loading.value = true
  } else {
    listBusy.value = true
  }
  try {
    const q = new URLSearchParams({
      page: String(page.value),
      pageSize: String(pageSize.value),
    })
    if (searchTrim) {
      q.set('search', searchTrim)
    }
    const body = await fetchData<{ items: MediaAssetRow[]; total: number }>(
      `/api/me/media/assets?${q.toString()}`,
      { notify: false },
    )
    items.value = body.items
    total.value = body.total
    const ids = new Set(body.items.map((i) => i.id))
    previewFailedIds.value = new Set([...previewFailedIds.value].filter((id) => ids.has(id)))
  } catch (e: unknown) {
    toast.add({ title: getApiErrorMessage(e), color: 'error' })
  } finally {
    if (blocking) {
      loading.value = false
    } else {
      listBusy.value = false
    }
  }
}

watch(pageSize, () => {
  page.value = 1
})

watch(searchInput, () => {
  if (searchDebounceTimer) {
    clearTimeout(searchDebounceTimer)
  }
  searchDebounceTimer = setTimeout(() => {
    searchDebounceTimer = null
    const next = searchInput.value.trim()
    if (next === appliedSearch.value) {
      return
    }
    if (page.value !== 1) {
      page.value = 1
    }
    appliedSearch.value = next
  }, 350)
})

watch([page, pageSize, appliedSearch], () => {
  void load()
})

onMounted(() => {
  void load()
})

onBeforeUnmount(() => {
  if (searchDebounceTimer) {
    clearTimeout(searchDebounceTimer)
    searchDebounceTimer = null
  }
})

function openFilePicker() {
  fileInput.value?.click()
}

async function onFileChange(ev: Event) {
  const input = ev.target as HTMLInputElement
  const files = input.files ? Array.from(input.files) : []
  input.value = ''
  if (!files.length) {
    return
  }

  uploading.value = true
  try {
    const form = new FormData()
    for (const f of files) {
      form.append('file', f)
    }
    await fetchData<{ files: { url: string }[] }>('/api/file/upload', {
      method: 'POST',
      body: form,
      notify: false,
    })
    toast.add({
      title: files.length === 1 ? '上传成功' : `已上传 ${files.length} 个文件`,
      color: 'success',
    })
    await load()
  } catch (e: unknown) {
    toast.add({ title: getApiErrorMessage(e), color: 'error' })
  } finally {
    uploading.value = false
  }
}

async function copyUrl(item: MediaAssetRow) {
  if (!import.meta.client) {
    return
  }
  const absoluteUrl = `${resolveCopyOrigin()}${item.publicPath}`
  try {
    await navigator.clipboard.writeText(absoluteUrl)
    toast.add({ title: '已复制 URL', color: 'success' })
  } catch (e: unknown) {
    toast.add({ title: getApiErrorMessage(e), color: 'error' })
  }
}

function imageSrc(item: MediaAssetRow): string {
  const b = imgBust.value[item.id]
  return b ? `${item.publicPath}?v=${b}` : item.publicPath
}

function onImgError(id: number) {
  previewFailedIds.value = new Set([...previewFailedIds.value, id])
}

function openReuploadPicker(assetId: number) {
  reuploadTargetId.value = assetId
  nextTick(() => reuploadInput.value?.click())
}

function startEditNote(item: MediaAssetRow) {
  editingNoteId.value = item.id
  noteDraft[item.id] = item.userNote ?? ''
}

function cancelEditNote(item: MediaAssetRow) {
  editingNoteId.value = null
  noteDraft[item.id] = item.userNote ?? ''
}

async function saveMyNote(assetId: number) {
  savingNoteId.value = assetId
  try {
    await fetchData(`/api/me/media/assets/${assetId}/note`, {
      method: 'PATCH',
      body: { userNote: noteDraft[assetId] ?? '' },
      notify: false,
    })
    toast.add({ title: '描述已保存', color: 'success' })
    editingNoteId.value = null
    await load()
  } catch (e: unknown) {
    toast.add({ title: getApiErrorMessage(e), color: 'error' })
  } finally {
    savingNoteId.value = null
  }
}

function toggleRefDetail(id: number) {
  refDetailOpen[id] = !refDetailOpen[id]
}

async function onReuploadFile(ev: Event) {
  const assetId = reuploadTargetId.value
  reuploadTargetId.value = null
  const input = ev.target as HTMLInputElement
  const file = input.files?.[0]
  input.value = ''
  if (!assetId || !file) {
    return
  }
  reuploadingId.value = assetId
  try {
    const form = new FormData()
    form.append('file', file)
    await fetchData<{ url: string; sizeBytes: number }>(`/api/me/media/assets/${assetId}/reupload`, {
      method: 'POST',
      body: form,
      notify: false,
    })
    previewFailedIds.value = new Set([...previewFailedIds.value].filter((id) => id !== assetId))
    imgBust.value = { ...imgBust.value, [assetId]: Date.now() }
    toast.add({ title: '已重新写入磁盘', color: 'success' })
    await load()
  } catch (e: unknown) {
    toast.add({ title: getApiErrorMessage(e), color: 'error' })
  } finally {
    reuploadingId.value = null
  }
}

async function copyMarkdown(item: MediaAssetRow) {
  if (!import.meta.client) {
    return
  }
  const absoluteUrl = `${resolveCopyOrigin()}${item.publicPath}`
  const md = `![](${absoluteUrl})`
  try {
    await navigator.clipboard.writeText(md)
    toast.add({ title: '已复制 Markdown', color: 'success' })
  } catch (e: unknown) {
    toast.add({ title: getApiErrorMessage(e), color: 'error' })
  }
}
</script>

<template>
  <div class="space-y-6 w-full max-w-[min(100%,88rem)] px-0 sm:px-0">
    <input
      ref="reuploadInput"
      type="file"
      class="sr-only"
      accept="image/png,image/jpeg,image/jpg,image/webp"
      @change="onReuploadFile"
    >
    <div>
      <h2 class="text-lg font-medium">
        资源库
      </h2>
      <p class="text-sm text-muted mt-1">
        浏览已上传图片、复制外链或 Markdown，用于文章与资料。
      </p>
    </div>

    <div class="flex flex-wrap items-end gap-x-5 gap-y-3">
      <input
        ref="fileInput"
        type="file"
        class="sr-only"
        accept="image/png,image/jpeg,image/jpg,image/webp"
        multiple
        @change="onFileChange"
      >
      <UButton
        color="primary"
        :loading="uploading"
        :disabled="uploading"
        @click="openFilePicker"
      >
        选择图片上传
      </UButton>
      <UFormField label="搜索" class="min-w-[12rem] flex-1 max-w-xl">
        <UInput
          v-model="searchInput"
          placeholder="文件名、引用（文章标题 / 用户名等）、文件描述"
          icon="i-lucide-search"
          class="w-full"
          :disabled="uploading"
        />
      </UFormField>
      <UFormField label="每页" class="w-40 shrink-0">
        <USelect v-model="pageSize" :items="pageSizeItems" value-key="value" class="w-full" />
      </UFormField>
    </div>

    <div class="min-h-[22rem] sm:min-h-[26rem]">
      <div
        v-if="loading"
        class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
        aria-busy="true"
        aria-label="加载中"
      >
        <div
          v-for="n in skeletonPlaceholders"
          :key="n"
          class="rounded-lg border border-default bg-default p-4 space-y-3 shadow-sm"
        >
          <div class="relative w-full aspect-video rounded-md bg-elevated/80 animate-pulse" />
          <div class="h-3 w-[85%] max-w-[14rem] rounded bg-elevated/80 animate-pulse" />
          <div class="flex gap-2">
            <div class="h-3 w-16 rounded bg-elevated/60 animate-pulse" />
            <div class="h-3 w-24 rounded bg-elevated/60 animate-pulse" />
          </div>
          <div class="h-3 w-20 rounded bg-elevated/50 animate-pulse" />
          <div class="h-8 w-full rounded bg-elevated/40 animate-pulse" />
        </div>
      </div>
      <UEmpty
        v-else-if="!items.length"
        :title="appliedSearch ? '无匹配项' : '暂无图片'"
        :description="
          appliedSearch
            ? '没有匹配当前关键词的文件名、引用或描述，可更换关键词或清空搜索。'
            : '上传 PNG / JPEG / WebP 后，将在此列出并可直接复制链接。'
        "
        :class="{ 'opacity-60 pointer-events-none': listBusy }"
      />
      <div
        v-else
        class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
        :class="{ 'opacity-60 pointer-events-none': listBusy }"
      >
      <UCard v-for="item in items" :key="item.id">
        <div class="space-y-3">
          <div class="relative w-full aspect-video rounded-md border border-default overflow-hidden bg-elevated/30">
            <img
              :src="imageSrc(item)"
              :alt="item.storageKey"
              class="w-full h-full object-cover"
              loading="lazy"
              @error="onImgError(item.id)"
            />
            <div
              v-if="previewFailedIds.has(item.id)"
              class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-default/80 p-2"
            >
              <p class="text-xs text-muted text-center">
                文件缺失或无法加载
              </p>
              <UButton
                size="xs"
                color="primary"
                :loading="reuploadingId === item.id"
                :disabled="reuploadingId !== null && reuploadingId !== item.id"
                @click="openReuploadPicker(item.id)"
              >
                重新上传
              </UButton>
            </div>
          </div>
          <div
            class="truncate font-mono text-xs text-default"
            :title="item.storageKey"
          >
            {{ item.storageKey }}
          </div>
          <div class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted tabular-nums">
            <span>{{ formatBytes(item.sizeBytes) }}</span>
            <span>{{ formatDt(item.createdAt) }}</span>
          </div>
          <p class="text-xs text-default">
            引用数: {{ item.refCount }}
          </p>
          <div v-if="item.refContexts.length" class="text-xs space-y-1">
            <UButton
              size="xs"
              variant="link"
              color="neutral"
              class="p-0 h-auto font-normal"
              @click="toggleRefDetail(item.id)"
            >
              {{ refDetailOpen[item.id] ? '收起引用详情' : `查看引用详情（${item.refContexts.length}）` }}
            </UButton>
            <div v-show="refDetailOpen[item.id]" class="text-muted space-y-1 border-l border-default pl-2 ml-0.5">
              <div v-for="(c, idx) in item.refContexts" :key="idx">
                <NuxtLink
                  v-if="c.href"
                  :to="c.href"
                  class="text-primary underline-offset-2 hover:underline"
                >
                  {{ c.label }}
                </NuxtLink>
                <span v-else>{{ c.label }}</span>
              </div>
            </div>
          </div>
          <div class="text-xs space-y-1">
            <div class="text-muted">
              文件描述
            </div>
            <template v-if="editingNoteId === item.id">
              <UTextarea v-model="noteDraft[item.id]" :rows="2" class="text-xs w-full" />
              <div class="flex flex-wrap gap-2">
                <UButton
                  size="xs"
                  color="primary"
                  :loading="savingNoteId === item.id"
                  :disabled="loading || listBusy"
                  @click="saveMyNote(item.id)"
                >
                  保存
                </UButton>
                <UButton
                  size="xs"
                  variant="ghost"
                  :disabled="savingNoteId === item.id"
                  @click="cancelEditNote(item)"
                >
                  取消
                </UButton>
              </div>
            </template>
            <template v-else>
              <p class="text-default whitespace-pre-wrap break-words min-h-[1.25rem]">
                {{ (item.userNote ?? '').trim() ? item.userNote : '—' }}
              </p>
              <UButton size="xs" variant="soft" @click="startEditNote(item)">
                修改描述
              </UButton>
            </template>
          </div>
          <div class="flex flex-wrap items-center gap-2">
            <UButton size="xs" variant="soft" @click="copyUrl(item)">
              复制 URL
            </UButton>
            <UButton size="xs" variant="soft" @click="copyMarkdown(item)">
              复制 Markdown
            </UButton>
            <span class="inline-flex">
              <UButton
                size="xs"
                color="error"
                variant="soft"
                :disabled="!item.canDelete || listBusy || loading || deletingId !== null"
                @click="openDeleteConfirm(item)"
              >
                删除
              </UButton>
            </span>
          </div>
          <p
            v-if="!item.canDelete && item.deleteBlockedReason"
            class="text-[11px] text-muted leading-snug"
          >
            {{ deleteDisabledHint(item) }}
          </p>
        </div>
      </UCard>
      </div>
    </div>

    <div class="min-h-11 flex justify-end items-center">
      <UPagination
        v-if="!loading && !listBusy && total > pageSize"
        v-model:page="page"
        :total="total"
        :items-per-page="pageSize"
        size="sm"
      />
    </div>

    <UModal v-model:open="deleteConfirmOpen" title="确认删除图片">
      <template #body>
        <p class="text-sm text-default">
          将永久删除文件并移除库记录，不可恢复。
        </p>
        <p
          v-if="pendingDeleteAsset"
          class="mt-2 font-mono text-xs text-muted break-all"
        >
          {{ pendingDeleteAsset.storageKey }}
        </p>
      </template>
      <template #footer>
        <div class="flex justify-end gap-2">
          <UButton
            variant="outline"
            color="neutral"
            :disabled="deletingId !== null"
            @click="closeDeleteModal"
          >
            取消
          </UButton>
          <UButton color="error" :loading="deletingId !== null" @click="executeDelete">
            删除
          </UButton>
        </div>
      </template>
    </UModal>
  </div>
</template>
