<script setup lang="ts">
// Composeur de portfolio (spec §8.2, gabarit_book_v3) : pages réordonnables,
// pages projet en deux volets (couverture + grilles), curation des images,
// overrides, couleurs de bandeau — avec aperçu WYSIWYG central.
import draggable from 'vuedraggable'
import { nanoid } from 'nanoid'
import type { FreeColumn, FreePage, OutroPage, Page, Portfolio, Project, ProjectGridPage, ProjectPage, TeamPage, TocPage } from '~~/types'

const route = useRoute()
const toast = useToast()

const [{ data: portfolioData }, { data: projectsData }] = await Promise.all([
  useFetch<Portfolio>(`/api/portfolios/${route.params.id}`),
  useFetch<{ projects: Project[] }>('/api/projects'),
])

if (!portfolioData.value) {
  throw createError({ statusCode: 404, statusMessage: 'Portfolio introuvable' })
}

const portfolio = ref<Portfolio>(portfolioData.value)
const projects = computed<Record<string, Project>>(() =>
  Object.fromEntries((projectsData.value?.projects ?? []).map(p => [p.id, p])),
)

// Migration des pages libres legacy (body/media) → colonnes. Faite avant
// l'enregistrement du watch d'autosave (donc sans sauvegarde parasite) ; se
// persistera à la première vraie édition. Le rendu gère aussi le repli legacy.
for (const p of portfolio.value.pages) {
  if (p.kind === 'free' && !p.columns) {
    const cols: FreeColumn[] = []
    if (p.body?.trim()) cols.push({ id: nanoid(6), kind: 'text', body: p.body })
    if (p.media?.length) cols.push({ id: nanoid(6), kind: 'media', media: [...p.media] })
    p.columns = cols.length ? cols : [{ id: nanoid(6), kind: 'text', body: '' }]
    p.body = undefined
    p.media = undefined
  }
}

const selectedPageId = ref<string | undefined>(portfolio.value.pages[0]?.id)
const selectedIndex = computed(() => portfolio.value.pages.findIndex(p => p.id === selectedPageId.value))
const selectedPage = computed<Page | undefined>(() => portfolio.value.pages[selectedIndex.value])

// La liste des pages (colonne de gauche) suit la page active : quand la
// sélection sort de la vue (beaucoup de pages), on la ramène dans le conteneur.
watch(selectedPageId, (id) => {
  if (!id) return
  nextTick(() => {
    document
      .querySelector(`[data-page-nav-id="${CSS.escape(id)}"]`)
      ?.scrollIntoView({ block: 'nearest' })
  })
})

// ---- Sauvegarde automatique (debounce) ----
const saveState = ref<'saved' | 'saving' | 'dirty' | 'error'>('saved')
let timer: ReturnType<typeof setTimeout> | undefined

// ---- Fraîcheur du dernier PDF ----
// Un PDF « à jour » (non modifié depuis) reste téléchargeable directement.
const exportFileName = ref(portfolio.value.lastExport?.fileName)
const pdfStale = ref(!(portfolio.value.lastExport && portfolio.value.updatedAt <= portfolio.value.lastExport.at))
const pdfDownloadUrl = computed(() =>
  exportFileName.value ? `/api/exports/${encodeURIComponent(exportFileName.value)}` : undefined,
)
const pdfDownloadable = computed(() => !pdfStale.value && !!exportFileName.value)

async function save() {
  saveState.value = 'saving'
  try {
    await $fetch(`/api/portfolios/${portfolio.value.id}`, { method: 'PUT', body: portfolio.value })
    saveState.value = 'saved'
  } catch {
    saveState.value = 'error'
    toast.add({ title: 'Échec de la sauvegarde', color: 'error' })
  }
}

// Réassignation programmatique de `portfolio` (publier/couper/dégeler) : ne
// compte pas comme une édition (ni autosave, ni « modifié depuis publication »).
let programmatic = false

// Le lien public a-t-il pris du retard sur les éditions ? (édité après le gel)
const sharedStale = ref(
  Boolean(portfolio.value.publicShare)
  && !!portfolio.value.frozenContent
  && portfolio.value.updatedAt > (portfolio.value.frozenContent.generatedAt ?? ''),
)

watch(
  portfolio,
  () => {
    if (programmatic) {
      programmatic = false
      return
    }
    saveState.value = 'dirty'
    pdfStale.value = true // toute modif rend le dernier PDF périmé
    sharedStale.value = true // et décale le lien public par rapport à l'instantané
    clearTimeout(timer)
    timer = setTimeout(save, 800)
  },
  { deep: true },
)

// ---- Ajout / suppression de pages ----
const showProjectPicker = ref(false)

const previewScroll = ref<HTMLElement>()

// Défilement de l'aperçu → la page active (selectedPageId) suit la page centrée.
const pagesRef = computed(() => portfolio.value.pages)
const { onScroll: onPreviewScroll, scrollToIndex } = usePageScrollSpy({
  container: previewScroll,
  pages: pagesRef,
  active: selectedPageId,
  idPrefix: 'preview-page',
})

function selectPage(id: string, scroll = true) {
  selectedPageId.value = id
  if (!scroll) return
  const i = portfolio.value.pages.findIndex(p => p.id === id)
  if (i !== -1) scrollToIndex(i)
}

/** Clic sur une entrée du sommaire (aperçu) → défile jusqu'à la page N. */
function goToPage(pageNumber: number) {
  const page = portfolio.value.pages[pageNumber - 1]
  if (page) selectPage(page.id)
}

function projectImages(projectId: string): string[] {
  return (projects.value[projectId]?.media ?? []).filter(m => m.type === 'image').map(m => m.filename)
}

/** Images du projet pas encore posées sur une page grille. */
function unusedImages(projectId: string): string[] {
  const used = new Set(
    portfolio.value.pages
      .filter((p): p is ProjectGridPage => p.kind === 'project-grid' && p.projectId === projectId)
      .flatMap(p => p.media),
  )
  return projectImages(projectId).filter(f => !used.has(f))
}

/** Orientation dominante d'un lot d'images du projet (grille par défaut). */
function orientationOf(projectId: string, filenames: string[]) {
  const byName = new Map((projects.value[projectId]?.media ?? []).map(m => [m.filename, m]))
  return dominantOrientation(
    filenames.map((f) => {
      const a = byName.get(f)
      return a?.width && a?.height ? a.width / a.height : 0
    }),
  )
}

/** Insère une page grille après la dernière page du même projet. */
function addGridPage(projectId: string) {
  const remaining = unusedImages(projectId)
  const images = remaining.length ? remaining : projectImages(projectId)
  const familyId = defaultGridFor(images.length, orientationOf(projectId, images))
  const page: ProjectGridPage = {
    kind: 'project-grid',
    id: nanoid(8),
    projectId,
    grid: familyId,
    media: images.slice(0, gridFamily(familyId).max),
  }
  const lastOfProject = portfolio.value.pages.reduce(
    (acc, p, i) => ((p.kind === 'project' || p.kind === 'project-grid') && p.projectId === projectId ? i : acc),
    -1,
  )
  portfolio.value.pages.splice(lastOfProject === -1 ? portfolio.value.pages.length : lastOfProject + 1, 0, page)
  selectPage(page.id)
}

/** Un projet = une page couverture + une première page grille (gabarit). */
function addProjectPage(project: Project) {
  const page: ProjectPage = {
    kind: 'project',
    id: nanoid(8),
    projectId: project.id,
  }
  portfolio.value.pages.push(page)
  const cover = project.coverImage
  const others = projectImages(project.id).filter(f => f !== cover)
  if (others.length) {
    const familyId = defaultGridFor(others.length, orientationOf(project.id, others))
    portfolio.value.pages.push({
      kind: 'project-grid',
      id: nanoid(8),
      projectId: project.id,
      grid: familyId,
      media: others.slice(0, gridFamily(familyId).max),
    })
  }
  selectPage(page.id)
}

function addFreePage() {
  const page: FreePage = {
    kind: 'free',
    id: nanoid(8),
    template: 'free-text',
    title: 'Page libre',
    columns: [{ id: nanoid(6), kind: 'text', body: '' }],
    background: { mode: 'white' },
  }
  portfolio.value.pages.push(page)
  selectPage(page.id)
}

// ---- Colonnes des pages libres (1 à 3, texte ou images) ----
const MAX_FREE_COLUMNS = 3

function addFreeColumn() {
  if (selectedPage.value?.kind !== 'free') return
  const cols = selectedPage.value.columns ?? (selectedPage.value.columns = [])
  if (cols.length >= MAX_FREE_COLUMNS) return
  cols.push({ id: nanoid(6), kind: 'text', body: '' })
}

function removeFreeColumn(id: string) {
  if (selectedPage.value?.kind !== 'free' || !selectedPage.value.columns) return
  if (selectedPage.value.columns.length <= 1) return // au moins une colonne
  selectedPage.value.columns = selectedPage.value.columns.filter(c => c.id !== id)
}

function setFreeColumnKind(col: FreeColumn, kind: FreeColumn['kind']) {
  if (col.kind === kind) return
  col.kind = kind
  // On conserve les deux contenus : repasser sur l'autre type retrouve ce qui
  // avait été saisi. Seul le champ actif est initialisé s'il est vide.
  if (kind === 'text') col.body ??= ''
  else col.media ??= []
}

const freeColumnKinds = [
  { value: 'text', label: 'Texte', icon: 'i-lucide-text' },
  { value: 'media', label: 'Images', icon: 'i-lucide-image' },
] as const

function addTocPage() {
  const page: TocPage = { kind: 'toc', id: nanoid(8), title: 'Sommaire', background: { mode: 'white' } }
  // Le sommaire s'insère après la couverture par convention
  const at = portfolio.value.pages.findIndex(p => p.kind !== 'cover')
  portfolio.value.pages.splice(at === -1 ? portfolio.value.pages.length : at, 0, page)
  selectPage(page.id)
}

function addCoverPage() {
  const page: Page = { kind: 'cover', id: nanoid(8), background: { mode: 'white' } }
  portfolio.value.pages.unshift(page)
  selectPage(page.id)
}

function addOutroPage() {
  // Page de conclusion : à la fin par convention.
  const page: OutroPage = { kind: 'outro', id: nanoid(8), title: 'Merci !', background: { mode: 'white' } }
  portfolio.value.pages.push(page)
  selectPage(page.id)
}

function addTeamPage() {
  // Membres seedés depuis le roster : les défauts sont cochés, chaque membre
  // porte son rôle et sa présentation (éditables ensuite).
  const page: TeamPage = {
    kind: 'team',
    id: nanoid(8),
    title: 'L’équipe',
    members: TEAM_ROSTER.map(m => ({ id: m.id, enabled: !!m.defaultEnabled, role: m.role, bio: m.bio })),
    background: { mode: 'white' },
  }
  portfolio.value.pages.push(page)
  selectPage(page.id)
}

const rosterById = (id: string) => TEAM_ROSTER.find(m => m.id === id)
const enabledMemberCount = computed(() =>
  selectedPage.value?.kind === 'team' ? selectedPage.value.members.filter(m => m.enabled).length : 0,
)
// Sections de personnalisation (rôle + présentation) repliées par défaut ;
// dépliées membre par membre via le chevron.
const expandedMembers = ref<Record<string, boolean>>({})

function removePage(id: string) {
  const i = portfolio.value.pages.findIndex(p => p.id === id)
  if (i === -1) return
  portfolio.value.pages.splice(i, 1)
  if (selectedPageId.value === id) {
    selectedPageId.value = portfolio.value.pages[Math.max(0, i - 1)]?.id
  }
}

const hasCover = computed(() => portfolio.value.pages.some(p => p.kind === 'cover'))
const hasOutro = computed(() => portfolio.value.pages.some(p => p.kind === 'outro'))
const hasTeam = computed(() => portfolio.value.pages.some(p => p.kind === 'team'))

const addItems = computed(() => [[
  { label: 'Projet (couverture + grille)…', icon: 'i-lucide-image', onSelect: () => (showProjectPicker.value = true) },
  { label: 'Page libre', icon: 'i-lucide-text', onSelect: addFreePage },
  { label: 'Sommaire', icon: 'i-lucide-list', onSelect: addTocPage },
  ...(hasTeam.value ? [] : [{ label: 'Équipe', icon: 'i-lucide-users', onSelect: addTeamPage }]),
  ...(hasCover.value ? [] : [{ label: 'Couverture', icon: 'i-lucide-bookmark', onSelect: addCoverPage }]),
  ...(hasOutro.value ? [] : [{ label: 'Conclusion', icon: 'i-lucide-heart', onSelect: addOutroPage }]),
]])

// ---- Libellés de la liste de pages ----
function pageLabel(page: Page): string {
  switch (page.kind) {
    case 'cover': return portfolio.value.cover.title || 'Couverture'
    case 'toc': return page.title || 'Sommaire'
    case 'free': return page.title || 'Page libre'
    case 'project': return projects.value[page.projectId]?.title ?? page.projectId
    case 'project-grid': return projects.value[page.projectId]?.title ?? page.projectId
    case 'outro': return page.title || 'Conclusion'
    case 'team': return page.title || 'Équipe'
  }
}

const KIND_LABELS: Record<Page['kind'], string> = {
  'cover': 'Couverture',
  'toc': 'Sommaire',
  'free': 'Page libre',
  'project': 'Projet — couverture',
  'project-grid': 'Projet — grille',
  'outro': 'Conclusion',
  'team': 'Équipe',
}

// ---- Encre du bandeau (pages projet) ----
const inkItems = [
  { value: 'auto', label: 'Auto' },
  { value: 'black', label: 'Noir' },
  { value: 'white', label: 'Blanc' },
] as const

function setInk(value: (typeof inkItems)[number]['value']) {
  if (selectedPage.value?.kind === 'project') selectedPage.value.ink = value
}

// Disposition de la page couverture de projet
const coverLayoutItems = [
  { value: 'band', label: 'Bandeau' },
  { value: 'split', label: 'Colonne' },
] as const

function setCoverLayout(value: (typeof coverLayoutItems)[number]['value']) {
  if (selectedPage.value?.kind === 'project') selectedPage.value.layout = value === 'band' ? undefined : value
}

// ---- Calage de l'image de couverture (pages projet) ----
const coverFitItems = [
  { value: 'width', label: 'Largeur' },
  { value: 'height', label: 'Hauteur' },
] as const

function setCoverFit(value: (typeof coverFitItems)[number]['value']) {
  if (selectedPage.value?.kind === 'project') selectedPage.value.coverFit = value
}

// Fit hauteur : calage horizontal de l'image (marge gauche / centre / marge droite)
const coverAlignItems = [
  { value: 'left', icon: 'i-lucide-align-start-vertical', title: 'Calée sur la marge gauche' },
  { value: 'center', icon: 'i-lucide-align-center-vertical', title: 'Centrée' },
  { value: 'right', icon: 'i-lucide-align-end-vertical', title: 'Calée sur la marge droite' },
] as const

function setCoverAlign(value: (typeof coverAlignItems)[number]['value']) {
  if (selectedPage.value?.kind === 'project') {
    selectedPage.value.coverAlign = value === 'center' ? undefined : value
  }
}

// Sliders des pages couverture : taille du titre (%) et hauteur du bandeau
// (mm = calage haut de l'image). Undefined = valeurs du gabarit.
const titleScale = computed({
  get: () => (selectedPage.value?.kind === 'project' ? selectedPage.value.titleScale ?? 100 : 100),
  set: (v: number) => {
    if (selectedPage.value?.kind === 'project') selectedPage.value.titleScale = v === 100 ? undefined : v
  },
})

const bandHeight = computed({
  get: () => (selectedPage.value?.kind === 'project' ? selectedPage.value.bandHeight ?? 55 : 55),
  set: (v: number) => {
    if (selectedPage.value?.kind === 'project') selectedPage.value.bandHeight = v === 55 ? undefined : v
  },
})


// Taille du texte de labeur des pages libres (%). Undefined = 100.
const freeTextScale = computed({
  get: () => (selectedPage.value?.kind === 'free' ? selectedPage.value.textScale ?? 100 : 100),
  set: (v: number) => {
    if (selectedPage.value?.kind === 'free') selectedPage.value.textScale = v === 100 ? undefined : v
  },
})

// Année par défaut (placeholder) déduite de la date Grav du projet sélectionné.
const projectYearPlaceholder = computed(() => {
  if (selectedPage.value?.kind !== 'project') return 'Année'
  const iso = projects.value[selectedPage.value.projectId]?.date
  const y = iso ? new Date(iso).getFullYear() : NaN
  return Number.isNaN(y) ? 'Année' : String(y)
})

// Texte de présentation du projet : le champ est prérempli avec le texte Grav
// pour édition rapide. `textOverride` reste `undefined` tant que le texte n'a pas
// été modifié → la page suit alors automatiquement Grav (donc se met à jour lors
// d'un rafraîchissement manuel). Dès qu'il diffère de Grav, il est « figé » et
// n'est plus resynchronisé ; le bouton « Réinitialiser » le rattache à Grav.
const presentationText = computed({
  get: () => {
    if (selectedPage.value?.kind !== 'project') return ''
    const grav = projects.value[selectedPage.value.projectId]?.bodyMarkdown ?? ''
    return selectedPage.value.textOverride ?? grav
  },
  set: (v: string) => {
    if (selectedPage.value?.kind !== 'project') return
    const grav = projects.value[selectedPage.value.projectId]?.bodyMarkdown ?? ''
    // Ré-édité à l'identique de Grav → on relâche l'override (re-suit Grav).
    selectedPage.value.textOverride = v === grav ? undefined : v
  },
})

// Le texte de présentation a-t-il été modifié (ne suit plus Grav) ?
const presentationEdited = computed(() =>
  selectedPage.value?.kind === 'project' && selectedPage.value.textOverride !== undefined,
)

function resetPresentation() {
  if (selectedPage.value?.kind === 'project') selectedPage.value.textOverride = undefined
}

// ---- Image de couverture / médias de page libre ----
const showMediaPicker = ref(false)
type MediaPickerTarget = { type: 'cover' } | { type: 'free-column'; columnId: string }
const mediaPickerTarget = ref<MediaPickerTarget>({ type: 'cover' })

function openMediaPicker(target: MediaPickerTarget) {
  mediaPickerTarget.value = target
  showMediaPicker.value = true
}

function onMediaPicked(ref_: string) {
  const target = mediaPickerTarget.value
  if (target.type === 'cover') {
    portfolio.value.cover.backgroundImage = ref_
  } else if (selectedPage.value?.kind === 'free') {
    const col = selectedPage.value.columns?.find(c => c.id === target.columnId)
    if (col?.kind === 'media') col.media = [...(col.media ?? []), ref_]
  }
}

const saveLabels: Record<typeof saveState.value, string> = {
  saved: 'Enregistré',
  saving: 'Enregistrement…',
  dirty: 'Modifications…',
  error: 'Erreur de sauvegarde',
}

// ---- Génération PDF (phase 2) ----
const showGenerate = ref(false)
const generating = ref(false)
const pdfFileName = ref('')
const frozenAt = ref(portfolio.value.frozenContent?.generatedAt)
const lastGeneratedAt = ref(portfolio.value.lastExport?.at)
const lastResult = ref<{ fileName: string; downloadUrl: string; sizeBytes: number; pageCount: number } | null>(null)

function openGenerate() {
  pdfFileName.value = portfolio.value.frozenContent?.fileName?.replace(/\.pdf$/, '')
    ?? portfolio.value.name.toLowerCase().replace(/\s+/g, '-')
  showGenerate.value = true
}

async function generate() {
  generating.value = true
  lastResult.value = null
  try {
    lastResult.value = await $fetch(`/api/portfolios/${portfolio.value.id}/generate`, {
      method: 'POST',
      body: { fileName: pdfFileName.value },
      timeout: 300_000,
    })
    frozenAt.value = frozenAt.value ?? new Date().toISOString()
    lastGeneratedAt.value = new Date().toISOString()
    exportFileName.value = lastResult.value!.fileName
    pdfStale.value = false // le PDF reflète désormais l'état courant
    toast.add({ title: 'PDF généré', description: lastResult.value!.fileName, color: 'success' })
  } catch (err: any) {
    // Détail de l'erreur pour diagnostic (message serveur, timeout, réseau…).
    const detail = err?.data?.statusMessage || err?.statusMessage || err?.message || String(err)
    toast.add({ title: 'Échec de la génération', description: detail, color: 'error' })
    console.error('[generate] échec:', err)
  } finally {
    generating.value = false
  }
}

async function unfreeze() {
  await $fetch(`/api/portfolios/${portfolio.value.id}/unfreeze`, { method: 'POST' })
  frozenAt.value = undefined
  toast.add({ title: 'Contenu réactualisé', description: 'La prochaine génération utilisera le contenu Grav à jour.', color: 'success' })
}

function formatBytes(n: number): string {
  return n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} Mo` : `${Math.round(n / 1024)} Ko`
}

const dateTimeFmt = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' })
const frozenDateLabel = computed(() => (frozenAt.value ? dateTimeFmt.format(new Date(frozenAt.value)) : ''))
const lastGeneratedLabel = computed(() => (lastGeneratedAt.value ? dateTimeFmt.format(new Date(lastGeneratedAt.value)) : ''))

// ---- Partage d'un lien public ----
const showShare = ref(false)
const sharing = ref(false)
const publicShare = ref(Boolean(portfolio.value.publicShare))
const shareUrl = computed(() =>
  import.meta.client ? `${window.location.origin}/view/${portfolio.value.id}` : `/view/${portfolio.value.id}`,
)

function openShare() {
  publicShare.value = Boolean(portfolio.value.publicShare)
  showShare.value = true
}

/** Active/republie le lien public : fige l'état courant et l'expose. */
async function enableShare() {
  sharing.value = true
  try {
    // La réponse est le portfolio complet resynchronisé (frozenContent frais) :
    // on remplace l'état local pour éviter tout écrasement par l'auto-save.
    const updated = await $fetch<Portfolio>(`/api/portfolios/${portfolio.value.id}/share`, { method: 'POST', body: {} })
    programmatic = true
    portfolio.value = updated
    publicShare.value = true
    frozenAt.value = updated.frozenContent?.generatedAt
    sharedStale.value = false // l'instantané reflète l'état courant
    toast.add({ title: 'Lien public activé', color: 'success' })
  } catch {
    toast.add({ title: 'Échec de l’activation du lien', color: 'error' })
  } finally {
    sharing.value = false
  }
}

/** Coupe le lien public (le PDF conserve son contenu figé). */
async function disableShare() {
  sharing.value = true
  try {
    const updated = await $fetch<Portfolio>(`/api/portfolios/${portfolio.value.id}/share`, { method: 'POST', body: { enabled: false } })
    programmatic = true
    portfolio.value = updated
    publicShare.value = false
    toast.add({ title: 'Lien public coupé', color: 'success' })
  } catch {
    toast.add({ title: 'Échec de la coupure du lien', color: 'error' })
  } finally {
    sharing.value = false
  }
}

async function copyShareUrl() {
  if (await copyToClipboard(shareUrl.value)) {
    toast.add({ title: 'Lien copié', color: 'success' })
  } else {
    toast.add({ title: 'Copie impossible', description: shareUrl.value, color: 'error' })
  }
}
</script>

<template>
  <div class="flex h-screen">
    <!-- Colonne pages -->
    <aside class="flex w-60 shrink-0 flex-col border-r border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
      <BrandBar />
      <div class="flex items-center justify-between border-b border-neutral-200 p-3 dark:border-neutral-800">
        <span class="text-sm font-semibold">Pages ({{ portfolio.pages.length }})</span>
        <UDropdownMenu :items="addItems">
          <UButton icon="i-lucide-plus" size="xs" variant="soft" label="Ajouter" />
        </UDropdownMenu>
      </div>
      <draggable
        v-model="portfolio.pages"
        item-key="id"
        handle=".drag-handle"
        :animation="150"
        class="flex-1 space-y-1.5 overflow-y-auto p-2"
      >
        <template #item="{ element, index }">
          <div
            :data-page-nav-id="element.id"
            class="group flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-colors"
            :class="element.id === selectedPageId
              ? 'border-[var(--fl-ink)] bg-neutral-50 dark:border-white dark:bg-neutral-800'
              : 'border-neutral-200 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800'"
            @click="selectPage(element.id)"
          >
            <UIcon name="i-lucide-grip-vertical" class="drag-handle size-4 shrink-0 cursor-grab text-neutral-400" />
            <div class="min-w-0 flex-1">
              <p class="truncate font-medium">{{ index + 1 }}. {{ pageLabel(element) }}</p>
              <p class="text-xs text-neutral-500">{{ KIND_LABELS[element.kind as Page['kind']] }}</p>
            </div>
            <UButton
              icon="i-lucide-x"
              size="xs"
              variant="ghost"
              color="neutral"
              class="opacity-0 group-hover:opacity-100"
              aria-label="Supprimer la page"
              @click.stop="removePage(element.id)"
            />
          </div>
        </template>
      </draggable>
    </aside>

    <!-- Aperçu central -->
    <main class="flex min-w-0 flex-1 flex-col overflow-hidden">
      <div class="flex items-center gap-3 border-b border-neutral-200 bg-white px-4 py-2 dark:border-neutral-800 dark:bg-neutral-900">
        <UInput
          v-model="portfolio.name"
          variant="none"
          class="w-72 font-semibold"
          placeholder="Nom du portfolio"
        />
        <span class="text-xs text-neutral-400">{{ saveLabels[saveState] }}</span>
        <div class="ml-auto flex items-center gap-2">
          <UButton
            v-if="pdfDownloadable"
            icon="i-lucide-download"
            label="Télécharger le PDF"
            size="sm"
            color="neutral"
            variant="soft"
            :href="pdfDownloadUrl"
            target="_blank"
            external
          />
          <UButton
            icon="i-lucide-share-2"
            label="Partager un lien public"
            size="sm"
            color="neutral"
            variant="outline"
            @click="openShare"
          />
          <RefreshButton size="sm" />
          <UButton
            icon="i-lucide-file-down"
            label="Générer le PDF"
            size="sm"
            @click="openGenerate"
          />
        </div>
      </div>
      <div ref="previewScroll" class="flex flex-1 flex-col items-center gap-6 overflow-auto scroll-smooth bg-neutral-200/70 p-8 dark:bg-neutral-950" @scroll="onPreviewScroll">
        <div
          v-for="(page, i) in portfolio.pages"
          :id="`preview-page-${i + 1}`"
          :key="page.id"
          class="w-full max-w-[1180px] scroll-mt-8 cursor-pointer rounded-sm shadow-xl outline-1 outline-offset-4 transition-[outline-color]"
          :class="page.id === selectedPageId ? 'outline-neutral-400 dark:outline-neutral-500' : 'outline-transparent'"
          @click="selectPage(page.id, false)"
        >
          <PageViewport>
            <PageRenderer
              :portfolio="portfolio"
              :page="page"
              :index="i"
              :projects="projects"
              render-mode="preview"
              @navigate="goToPage"
            />
          </PageViewport>
        </div>
        <div v-if="!portfolio.pages.length" class="p-16 text-center text-neutral-500">Ajoutez une page pour commencer.</div>
      </div>
    </main>

    <!-- Panneau d'édition -->
    <aside class="w-[340px] shrink-0 overflow-y-auto border-l border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
      <template v-if="!selectedPage">
        <p class="text-sm text-neutral-500">Sélectionnez une page.</p>
      </template>

      <!-- Couverture -->
      <template v-else-if="selectedPage.kind === 'cover'">
        <h3 class="mb-4 font-semibold">Couverture</h3>
        <div class="space-y-4">
          <UFormField label="Titre">
            <UInput v-model="portfolio.cover.title" class="w-full" />
          </UFormField>
          <UFormField label="Texte libre">
            <UTextarea v-model="portfolio.cover.body" :rows="5" class="w-full" autoresize />
          </UFormField>
          <UFormField label="Date">
            <UInput v-model="portfolio.cover.date" class="w-full" :placeholder="formatMonthYear()" />
          </UFormField>
          <UFormField label="Site">
            <UInput v-model="portfolio.cover.siteUrl" class="w-full" :placeholder="DEFAULT_SITE_URL" />
          </UFormField>
          <UFormField label="Mail">
            <UInput v-model="portfolio.cover.contactEmail" class="w-full" :placeholder="DEFAULT_CONTACT_EMAIL" />
          </UFormField>
          <UFormField label="Adresse">
            <UInput v-model="portfolio.cover.address" class="w-full" :placeholder="DEFAULT_ADDRESS" />
          </UFormField>
          <BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
          <BackgroundEditor v-model="selectedPage.background" />
          <UCheckbox
            :model-value="portfolio.showYears !== false"
            label="Afficher les années des projets"
            @update:model-value="portfolio.showYears = $event === true"
          />
        </div>
      </template>

      <!-- Sommaire -->
      <template v-else-if="selectedPage.kind === 'toc'">
        <h3 class="mb-4 font-semibold">Sommaire</h3>
        <div class="space-y-4">
          <UFormField label="Titre">
            <UInput v-model="selectedPage.title" class="w-full" />
          </UFormField>
          <BackgroundEditor v-model="selectedPage.background" />
          <p class="text-xs text-neutral-500">Entrées et fond hérités de la couverture.</p>
        </div>
      </template>

      <!-- Conclusion -->
      <template v-else-if="selectedPage.kind === 'outro'">
        <h3 class="mb-4 font-semibold">Conclusion</h3>
        <div class="space-y-4">
          <UFormField label="Texte">
            <UInput v-model="selectedPage.title" class="w-full" placeholder="Merci !" />
          </UFormField>
          <BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
          <BackgroundEditor v-model="selectedPage.background" />
        </div>
      </template>

      <!-- Équipe -->
      <template v-else-if="selectedPage.kind === 'team'">
        <h3 class="mb-4 font-semibold">Équipe</h3>
        <div class="space-y-4">
          <UFormField label="Titre">
            <UInput v-model="selectedPage.title" class="w-full" placeholder="L’équipe" />
          </UFormField>
          <div class="space-y-2">
            <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
              Membres ({{ enabledMemberCount }}/{{ selectedPage.members.length }})
            </p>
            <draggable
              v-model="selectedPage.members"
              item-key="id"
              handle=".member-drag-handle"
              :animation="150"
              class="space-y-2"
            >
              <template #item="{ element: m }">
                <div class="rounded-lg border border-neutral-200 p-3 dark:border-neutral-800">
                  <div class="flex items-center gap-2.5">
                    <UIcon name="i-lucide-grip-vertical" class="member-drag-handle size-4 shrink-0 cursor-grab text-neutral-400" />
                    <UCheckbox v-model="m.enabled" />
                    <span class="truncate text-sm font-medium">{{ rosterById(m.id)?.name }}</span>
                    <UButton
                      v-if="m.enabled"
                      :icon="expandedMembers[m.id] ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
                      size="xs"
                      variant="ghost"
                      color="neutral"
                      class="ml-auto"
                      :aria-label="expandedMembers[m.id] ? 'Replier' : 'Personnaliser'"
                      @click="expandedMembers[m.id] = !expandedMembers[m.id]"
                    />
                  </div>
                  <div v-if="m.enabled && expandedMembers[m.id]" class="mt-3 space-y-2">
                    <UFormField label="Rôle">
                      <UInput v-model="m.role" class="w-full" />
                    </UFormField>
                    <UFormField label="Présentation">
                      <UTextarea v-model="m.bio" :rows="5" class="w-full" autoresize />
                      <div v-if="m.bio !== rosterById(m.id)?.bio" class="mt-1.5 flex justify-end">
                        <UButton
                          icon="i-lucide-rotate-ccw"
                          size="xs"
                          variant="ghost"
                          color="neutral"
                          label="Réinitialiser"
                          @click="m.bio = rosterById(m.id)?.bio"
                        />
                      </div>
                    </UFormField>
                  </div>
                </div>
              </template>
            </draggable>
          </div>
          <BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
          <BackgroundEditor v-model="selectedPage.background" />
        </div>
      </template>

      <!-- Page libre -->
      <template v-else-if="selectedPage.kind === 'free'">
        <h3 class="mb-4 font-semibold">Page libre</h3>
        <div class="space-y-4">
          <UFormField label="Titre">
            <UInput v-model="selectedPage.title" class="w-full" />
          </UFormField>

          <!-- Colonnes (1 à 3, texte ou images) -->
          <div class="space-y-3">
            <div class="flex items-center justify-between">
              <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
                Colonnes ({{ selectedPage.columns?.length ?? 0 }}/{{ MAX_FREE_COLUMNS }})
              </p>
              <UButton
                v-if="(selectedPage.columns?.length ?? 0) < MAX_FREE_COLUMNS"
                icon="i-lucide-plus"
                label="Colonne"
                size="xs"
                variant="soft"
                @click="addFreeColumn"
              />
            </div>
            <draggable
              v-if="selectedPage.columns"
              v-model="selectedPage.columns"
              item-key="id"
              handle=".col-drag-handle"
              :animation="150"
              class="space-y-3"
            >
              <template #item="{ element: col, index: ci }">
                <div class="space-y-3 rounded-lg border border-neutral-200 p-3 dark:border-neutral-800">
                  <div class="flex items-center gap-2">
                    <UIcon name="i-lucide-grip-vertical" class="col-drag-handle size-4 shrink-0 cursor-grab text-neutral-400" />
                    <div class="flex gap-1">
                      <UButton
                        v-for="opt in freeColumnKinds"
                        :key="opt.value"
                        :icon="opt.icon"
                        :label="opt.label"
                        size="xs"
                        :variant="col.kind === opt.value ? 'solid' : 'outline'"
                        color="neutral"
                        @click="setFreeColumnKind(col, opt.value)"
                      />
                    </div>
                    <UButton
                      v-if="(selectedPage.columns?.length ?? 1) > 1"
                      icon="i-lucide-trash-2"
                      size="xs"
                      variant="ghost"
                      color="neutral"
                      class="ml-auto"
                      :aria-label="`Supprimer la colonne ${ci + 1}`"
                      @click="removeFreeColumn(col.id)"
                    />
                  </div>
                  <UFormField label="Sous-titre">
                    <UInput v-model="col.heading" class="w-full" />
                  </UFormField>
                  <UFormField v-if="col.kind === 'text'" label="Texte">
                    <UTextarea v-model="col.body" :rows="8" class="w-full" />
                  </UFormField>
                  <UFormField v-else :label="`Images (${col.media?.length ?? 0}/4)`">
                    <div class="flex flex-wrap gap-2">
                      <div v-for="(ref_, i) in col.media" :key="ref_" class="relative size-16 overflow-hidden rounded border">
                        <img :src="mediaUrl(ref_.slice(0, ref_.lastIndexOf('/')), ref_.slice(ref_.lastIndexOf('/') + 1), 160)" class="size-full object-cover">
                        <button
                          type="button"
                          class="absolute right-0 top-0 flex size-4 items-center justify-center rounded-bl bg-black/60 text-white"
                          @click="col.media!.splice(i, 1)"
                        >
                          <UIcon name="i-lucide-x" class="size-3" />
                        </button>
                      </div>
                      <UButton
                        v-if="(col.media?.length ?? 0) < 4"
                        icon="i-lucide-plus"
                        size="xs"
                        variant="soft"
                        class="size-16 justify-center"
                        @click="openMediaPicker({ type: 'free-column', columnId: col.id })"
                      />
                    </div>
                  </UFormField>
                </div>
              </template>
            </draggable>
          </div>

          <div class="space-y-2">
            <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
              Taille du texte — {{ freeTextScale }} %
            </p>
            <USlider v-model="freeTextScale" :min="100" :max="180" :step="10" />
          </div>
          <BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
          <BackgroundEditor v-model="selectedPage.background" />
        </div>
      </template>

      <!-- Page projet : couverture -->
      <template v-else-if="selectedPage.kind === 'project'">
        <h3 class="mb-1 font-semibold">{{ projects[selectedPage.projectId]?.title }}</h3>
        <p class="mb-4 text-xs text-neutral-500">Page couverture du projet</p>
        <div class="space-y-4">
          <div class="space-y-2">
            <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Disposition</p>
            <div class="flex gap-1">
              <UButton
                v-for="opt in coverLayoutItems"
                :key="opt.value"
                :label="opt.label"
                size="xs"
                :variant="(selectedPage.layout ?? 'band') === opt.value ? 'solid' : 'outline'"
                color="neutral"
                @click="setCoverLayout(opt.value)"
              />
            </div>
          </div>
          <div v-if="projects[selectedPage.projectId]" class="space-y-2">
            <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Image</p>
            <div class="grid grid-cols-4 gap-1.5">
              <button
                v-for="f in projectImages(selectedPage.projectId)"
                :key="f"
                type="button"
                class="relative aspect-square overflow-hidden rounded border transition-opacity"
                :class="(selectedPage.coverImage || projects[selectedPage.projectId]!.coverImage) === f
                  ? 'border-[var(--fl-ink)] dark:border-white'
                  : 'border-transparent opacity-45 hover:opacity-80'"
                @click="selectedPage.coverImage = f"
              >
                <img :src="mediaUrl(selectedPage.projectId, f, 160)" class="size-full object-cover" :alt="f">
              </button>
            </div>
          </div>
          <div v-if="(selectedPage.layout ?? 'band') === 'band'" class="space-y-2">
            <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Calage</p>
            <div class="flex gap-1">
              <UButton
                v-for="opt in coverFitItems"
                :key="opt.value"
                :label="opt.label"
                size="xs"
                :variant="(selectedPage.coverFit ?? 'width') === opt.value ? 'solid' : 'outline'"
                color="neutral"
                @click="setCoverFit(opt.value)"
              />
            </div>
            <div v-if="(selectedPage.coverFit ?? 'width') === 'height'" class="flex gap-1 pt-1">
              <UButton
                v-for="opt in coverAlignItems"
                :key="opt.value"
                :icon="opt.icon"
                size="xs"
                :variant="(selectedPage.coverAlign ?? 'center') === opt.value ? 'solid' : 'outline'"
                color="neutral"
                :title="opt.title"
                :aria-label="opt.title"
                @click="setCoverAlign(opt.value)"
              />
            </div>
          </div>
          <div class="space-y-2">
            <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
              Titre — {{ titleScale }} %
            </p>
            <USlider v-model="titleScale" :min="50" :max="150" :step="5" />
          </div>
          <div v-if="(selectedPage.layout ?? 'band') === 'band'" class="space-y-2">
            <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
              Hauteur bandeau — {{ bandHeight }} mm
            </p>
            <USlider v-model="bandHeight" :min="40" :max="160" :step="5" />
          </div>
          <BandColorPicker
            v-model="selectedPage.bandColor"
            label="Couleur du filet"
            :default-color="FL_INK"
          />
          <BackgroundEditor v-model="selectedPage.background" />
          <div v-if="selectedPage.background?.mode === 'full'" class="space-y-2">
            <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Couleur du texte</p>
            <div class="flex gap-1">
              <UButton
                v-for="opt in inkItems"
                :key="opt.value"
                :label="opt.label"
                size="xs"
                :variant="(selectedPage.ink ?? 'auto') === opt.value ? 'solid' : 'outline'"
                color="neutral"
                @click="setInk(opt.value)"
              />
            </div>
          </div>
          <UFormField label="Sous-titre">
            <UInput v-model="selectedPage.subtitle" class="w-full" placeholder="Sous le titre" />
          </UFormField>
          <div class="grid grid-cols-2 gap-2">
            <UFormField label="Type">
              <UInput
                v-model="selectedPage.subtitleOverride"
                class="w-full"
                :placeholder="projects[selectedPage.projectId]?.categories.join(', ') || 'ex. édition'"
              />
            </UFormField>
            <UFormField label="Année">
              <UInput v-model="selectedPage.year" class="w-full" :placeholder="projectYearPlaceholder" />
            </UFormField>
          </div>
          <UFormField label="Lien">
            <UInput
              v-model="selectedPage.link"
              class="w-full"
              :placeholder="projects[selectedPage.projectId]?.externalUrl || 'https://…'"
            />
          </UFormField>
          <UFormField label="Présentation">
            <UTextarea
              v-model="presentationText"
              :rows="6"
              class="w-full"
              placeholder="Aucun texte"
            />
            <div class="mt-1.5 flex items-center justify-between gap-2">
              <p v-if="presentationEdited" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
                <UIcon name="i-lucide-pencil" class="size-3.5 shrink-0" />
                Texte modifié — ne suit plus Grav
              </p>
              <p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
                <UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
                Synchronisé avec Grav
              </p>
              <UButton
                v-if="presentationEdited"
                size="xs"
                variant="ghost"
                color="neutral"
                label="Réinitialiser"
                @click="resetPresentation"
              />
            </div>
          </UFormField>
          <USeparator />
          <UButton
            icon="i-lucide-layout-grid"
            variant="soft"
            block
            :label="`Ajouter une grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
            @click="addGridPage(selectedPage.projectId)"
          />
        </div>
      </template>

      <!-- Page projet : grille -->
      <template v-else-if="selectedPage.kind === 'project-grid'">
        <h3 class="mb-1 font-semibold">{{ projects[selectedPage.projectId]?.title }}</h3>
        <p class="mb-4 text-xs text-neutral-500">Page grille d'images</p>
        <div class="space-y-4">
          <div class="space-y-2">
            <p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Grille</p>
            <div class="grid grid-cols-3 gap-1.5">
              <button
                v-for="fam in GRID_FAMILIES"
                :key="fam.id"
                type="button"
                class="flex items-center justify-center rounded border p-1.5 transition-colors"
                :class="gridFamily(selectedPage.grid).id === fam.id
                  ? 'border-[var(--fl-ink)] bg-neutral-100 text-[var(--fl-ink)] dark:border-white dark:bg-neutral-800 dark:text-white'
                  : 'border-neutral-200 text-neutral-400 hover:border-neutral-400 hover:text-neutral-600 dark:border-neutral-700'"
                :title="fam.label"
                @click="selectedPage.grid = fam.id"
              >
                <GridIcon
                  :grid="fam.id"
                  :count="Math.max(selectedPage.media.length, 1)"
                  :orientation="orientationOf(selectedPage.projectId, selectedPage.media)"
                  :size="72"
                />
              </button>
            </div>
            <p class="text-xs text-neutral-500">
              {{ gridFamily(selectedPage.grid).label }} — {{ selectedPage.media.length }} image{{ selectedPage.media.length > 1 ? 's' : '' }}
              (max {{ gridFamily(selectedPage.grid).max }})
            </p>
          </div>
          <MediaCuration
            v-if="projects[selectedPage.projectId]"
            v-model="selectedPage.media"
            :project="projects[selectedPage.projectId]!"
            :max="gridFamily(selectedPage.grid).max"
          />
          <BackgroundEditor v-model="selectedPage.background" />
          <USeparator />
          <UButton
            icon="i-lucide-layout-grid"
            variant="soft"
            block
            :label="`Ajouter une grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
            @click="addGridPage(selectedPage.projectId)"
          />
        </div>
      </template>
    </aside>

    <ProjectPickerModal v-model:open="showProjectPicker" @pick="addProjectPage" />
    <MediaPickerModal v-model:open="showMediaPicker" @pick="onMediaPicked" />

    <UModal v-model:open="showGenerate" title="Générer le PDF">
      <template #body>
        <div class="space-y-4">
          <UFormField label="Nom du fichier">
            <UInput v-model="pdfFileName" class="w-full" placeholder="portfolio-figures-libres">
              <template #trailing>
                <span class="text-xs text-neutral-400">.pdf</span>
              </template>
            </UInput>
          </UFormField>

          <div class="flex items-center justify-between gap-2">
            <p v-if="!exportFileName" class="flex items-center gap-1.5 text-xs text-neutral-500">
              <UIcon name="i-lucide-info" class="size-3.5 shrink-0" />
              Aucun PDF généré pour l'instant.
            </p>
            <p v-else-if="pdfStale" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
              <UIcon name="i-lucide-triangle-alert" class="size-3.5 shrink-0" />
              Modifié depuis la dernière génération — régénérez pour mettre à jour.
            </p>
            <p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
              <UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
              À jour · généré le {{ lastGeneratedLabel }}
            </p>
            <UButton v-if="frozenAt" size="xs" variant="soft" color="info" label="Réactualiser depuis Grav" @click="unfreeze" />
          </div>

          <div v-if="lastResult" class="rounded-md border border-green-200 bg-green-50 p-3 text-sm dark:border-green-900 dark:bg-green-950">
            <p class="font-medium">{{ lastResult.fileName }} — {{ lastResult.pageCount }} pages, {{ formatBytes(lastResult.sizeBytes) }}</p>
            <UButton
              :href="lastResult.downloadUrl"
              external
              icon="i-lucide-download"
              label="Télécharger"
              size="xs"
              class="mt-2"
            />
          </div>

          <div class="flex justify-end gap-2">
            <UButton variant="ghost" color="neutral" label="Fermer" @click="() => { showGenerate = false }" />
            <UButton
              :loading="generating"
              :label="generating ? 'Génération…' : 'Générer'"
              icon="i-lucide-file-down"
              @click="generate"
            />
          </div>
        </div>
      </template>
    </UModal>

    <UModal v-model:open="showShare" title="Partager un lien public">
      <template #body>
        <div class="space-y-4">
          <template v-if="publicShare">
            <UFormField label="Lien public">
              <UInput :model-value="shareUrl" readonly class="w-full">
                <template #trailing>
                  <UButton icon="i-lucide-copy" size="xs" variant="ghost" color="neutral" aria-label="Copier" @click="copyShareUrl" />
                </template>
              </UInput>
            </UFormField>
            <p v-if="sharedStale" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
              <UIcon name="i-lucide-triangle-alert" class="size-3.5 shrink-0" />
              Modifié depuis la dernière publication — republiez pour mettre à jour.
            </p>
            <p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
              <UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
              À jour<span v-if="frozenDateLabel"> · publié le {{ frozenDateLabel }}</span>
            </p>
            <div class="flex flex-wrap items-center justify-between gap-2">
              <UButton icon="i-lucide-external-link" :href="shareUrl" target="_blank" external variant="soft" color="neutral" size="sm" label="Ouvrir" />
              <div class="flex gap-2">
                <UButton :loading="sharing" icon="i-lucide-refresh-cw" variant="soft" size="sm" label="Republier" @click="enableShare" />
                <UButton :loading="sharing" icon="i-lucide-link-2-off" color="error" variant="soft" size="sm" label="Couper le lien" @click="disableShare" />
              </div>
            </div>
          </template>

          <template v-else>
            <UButton :loading="sharing" icon="i-lucide-share-2" block label="Activer le lien public" @click="enableShare" />
          </template>
        </div>
      </template>
    </UModal>
  </div>
</template>
