import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type PointerEvent as ReactPointerEvent,
} from 'react'
import { listPhotos, uploadPhoto, deletePhoto, fetchPhotoObjectUrl, type UploadError } from '../api/photos'

/* ════════════════════════════════════════════════════════════════
   照片墙 Photo Wall —— 互动式墙面照片展示。
   纯前端：照片/便签/装饰皆可拖拽、编辑，状态存 localStorage，可导出 PNG。
   设计文档见 docs（产品设计 v1.0）。本组件是其 React 重写版。
   ──────────────────────────────────────────────────────────────
   性能取舍：拖拽时直接改 DOM 的 transform（GPU 合成），不触发 React
   重渲染；松手后再一次性 commit 进状态。故拖拽过程中不得 setState。
   ════════════════════════════════════════════════════════════════ */

interface PhotoWallPageProps {
  /** 退出照片墙（清登录态 + 抹掉地址栏 #photo，回到导航页） */
  onExit: () => void
  /** 当前会话 JWT，供后续服务端图片接口使用（Task 6 正式消费） */
  token: string
}

type ElType = 'photo' | 'note' | 'tape' | 'sticker' | 'stamp' | 'doodle'

interface El {
  id: number
  type: ElType
  x: number
  y: number
  rot: number
  scale: number
  z: number
  // photo
  src?: string
  w?: number
  h?: number
  caption?: string
  fix?: 'tape' | 'pin' | 'tape2'
  fixColor?: string
  fixRot?: number
  // note
  text?: string
  color?: string
  /** 服务器图片 id（仅 type==='photo'），也是 localStorage 排版的 key */
  photoId?: string
  // sticker
  bg?: string
  // doodle
  path?: string
}

const STORAGE_KEY = 'photo-wall-state'
const HISTORY_LIMIT = 30
const H2C_SRC = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'

const BACKGROUNDS = [
  { cls: 'pw-bg-cork', name: '软木板' },
  { cls: 'pw-bg-blackboard', name: '黑板' },
  { cls: 'pw-bg-wall', name: '白墙' },
  { cls: 'pw-bg-kraft', name: '牛皮纸' },
]
const PIN_COLORS = ['#E74C3C', '#3498DB', '#2ECC71', '#F1C40F', '#9B59B6', '#E67E22', '#1ABC9C', '#FF6FAE']
const NOTE_COLORS = ['#FFF9C4', '#C8E6C9', '#BBDEFB', '#F8BBD9', '#FFE0B2']
const TAPE_COLORS = [
  'rgba(255,224,102,0.55)', 'rgba(120,190,255,0.55)', 'rgba(255,120,120,0.55)',
  'rgba(130,220,150,0.55)', 'rgba(255,160,205,0.55)', 'rgba(255,190,120,0.55)',
]
const STICKERS = [
  '✨ 美好时光', '📸 拍立得', '🌸 春天', '☀️ 夏日', '🍂 秋天', '❄️ 冬日',
  '🎉 庆祝', '💕 最爱', '🌙 夜晚', '🏖️ 旅行', '👨‍👩‍👧 家人', '🐾 宠物',
]
const STAMP_TEXTS = ['MEMORIES', 'VINTAGE', '2024', 'PHOTO', 'LOVE', 'TRAVEL']

/* ── 小工具 ─────────────────────────────────────────────────── */
const rand = (a: number, b: number) => Math.random() * (b - a) + a
const randInt = (a: number, b: number) => Math.floor(rand(a, b + 1))
const pick = <T,>(arr: T[]): T => arr[randInt(0, arr.length - 1)]
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))

// #rrggbb 调亮/调暗（便签折角取底色更深 15%）。
function shade(hex: string, amt: number) {
  if (hex[0] !== '#' || hex.length < 7) return hex
  const ch = (i: number) =>
    clamp(Math.round(parseInt(hex.slice(i, i + 2), 16) + 255 * amt), 0, 255)
      .toString(16)
      .padStart(2, '0')
  return '#' + ch(1) + ch(3) + ch(5)
}

function transformOf(el: El, extraScale = 1) {
  return `translate(${el.x}px, ${el.y}px) rotate(${el.rot}deg) scale(${el.scale * extraScale})`
}

/** 每张照片的本地排版覆盖（按服务器图片 id 存浏览器） */
interface PhotoLayout {
  x: number
  y: number
  rot: number
  scale: number
  z: number
  w: number
  caption: string
}

interface SavedState {
  bgIndex: number
  decorations: El[]
  photoLayout: Record<string, PhotoLayout>
}

function loadInitial() {
  let decorations: El[] = []
  let photoLayout: Record<string, PhotoLayout> = {}
  let bgIndex = 0
  try {
    const raw = localStorage.getItem(STORAGE_KEY)
    if (raw) {
      const data = JSON.parse(raw) as Partial<SavedState> & { els?: El[] }
      if (typeof data.bgIndex === 'number') bgIndex = data.bgIndex
      if (Array.isArray(data.decorations)) {
        decorations = data.decorations
      } else if (Array.isArray(data.els)) {
        // 旧结构（照片带 base64 存本地）：只保留装饰，丢弃旧照片。
        decorations = data.els.filter((e) => e.type !== 'photo')
      }
      if (data.photoLayout && typeof data.photoLayout === 'object') {
        photoLayout = data.photoLayout
      }
    }
  } catch {
    /* 损坏的存档当作空墙 */
  }
  const nextId = decorations.reduce((m, e) => Math.max(m, e.id), 0) + 1
  const maxZ = Math.max(10, ...decorations.map((e) => e.z || 10))
  return { decorations, photoLayout, bgIndex, nextId, maxZ }
}

export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
  const initial = useMemo(() => loadInitial(), [])

  const [els, setEls] = useState<El[]>(initial.decorations)
  // 每张图的本地排版（按服务器图片 id）。runtime 维护，保存时连同装饰一起落盘。
  const layoutRef = useRef<Record<string, PhotoLayout>>(initial.photoLayout)
  // 本组件创建的所有 objectURL，卸载时统一释放。
  const objUrls = useRef<string[]>([])
  const [bgIndex, setBgIndex] = useState(initial.bgIndex)
  const [menu, setMenu] = useState<{ x: number; y: number; id: number } | null>(null)
  const [editingId, setEditingId] = useState<number | null>(null)
  const [editText, setEditText] = useState('')
  const [decoOpen, setDecoOpen] = useState(false)
  const [customSticker, setCustomSticker] = useState('')
  const [doodleMode, setDoodleMode] = useState(false)
  const [dragHint, setDragHint] = useState(false)
  const [saveLabel, setSaveLabel] = useState('已保存')
  const [toast, setToast] = useState<string | null>(null)

  const wallRef = useRef<HTMLDivElement>(null)
  const nodeRefs = useRef(new Map<number, HTMLDivElement>())
  const menuRef = useRef<HTMLDivElement>(null)
  const fileRef = useRef<HTMLInputElement>(null)
  const idRef = useRef(initial.nextId)
  const zRef = useRef(initial.maxZ)
  const elsRef = useRef(els) // 给拖拽起点取快照用（避免闭包旧值）
  const undoRef = useRef<string[]>([])
  const redoRef = useRef<string[]>([])
  const doodleRef = useRef(false)
  const toastTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)

  // 拖拽起点取快照需要最新 els；ref 在 effect 里同步，避免渲染期写 ref。
  useEffect(() => {
    elsRef.current = els
  })

  const showToast = useCallback((msg: string) => {
    setToast(msg)
    clearTimeout(toastTimer.current)
    toastTimer.current = setTimeout(() => setToast(null), 1800)
  }, [])

  // 标记“有未保存改动”，由下方防抖 effect 在 1s 后落盘。
  const markDirty = useCallback(() => setSaveLabel('保存中…'), [])

  /* ── 历史栈 ──────────────────────────────────────────────── */
  const pushHistory = useCallback((prev: El[]) => {
    undoRef.current.push(JSON.stringify(prev))
    if (undoRef.current.length > HISTORY_LIMIT) undoRef.current.shift()
    redoRef.current.length = 0
  }, [])

  // 记一步历史并改状态。离散操作（增删改旋转）都走这里。
  const mutate = useCallback(
    (updater: (prev: El[]) => El[]) => {
      markDirty()
      setEls((prev) => {
        pushHistory(prev)
        return updater(prev)
      })
    },
    [pushHistory, markDirty],
  )

  const undo = useCallback(() => {
    markDirty()
    setEls((cur) => {
      if (!undoRef.current.length) return cur
      redoRef.current.push(JSON.stringify(cur))
      return JSON.parse(undoRef.current.pop() as string) as El[]
    })
  }, [markDirty])
  const redo = useCallback(() => {
    markDirty()
    setEls((cur) => {
      if (!redoRef.current.length) return cur
      undoRef.current.push(JSON.stringify(cur))
      return JSON.parse(redoRef.current.pop() as string) as El[]
    })
  }, [markDirty])

  /* ── 自动保存（防抖 1s）──────────────────────────────────────
     “保存中…”由各改动入口（mutate/undo/redo/拖拽/换背景）打标；
     本 effect 仅负责实际落盘并在完成后置“已保存”（均为异步，不级联渲染）。 */
  useEffect(() => {
    const t = setTimeout(() => {
      try {
        const decorations = els.filter((e) => e.type !== 'photo')
        // 从已知排版起步而非清空：未加载完/取图失败的照片其排版得以保留，
        // 已加载或移动过的照片在下面覆盖各自的 key（删除时 removeEl 已从 layoutRef 删键）。
        const photoLayout: Record<string, PhotoLayout> = { ...layoutRef.current }
        for (const e of els) {
          if (e.type === 'photo' && e.photoId) {
            photoLayout[e.photoId] = {
              x: e.x, y: e.y, rot: e.rot, scale: e.scale, z: e.z,
              w: e.w ?? 160, caption: e.caption ?? '',
            }
          }
        }
        layoutRef.current = photoLayout
        localStorage.setItem(STORAGE_KEY, JSON.stringify({ bgIndex, decorations, photoLayout }))
        setSaveLabel('已保存')
      } catch {
        setSaveLabel('保存失败')
        showToast('保存失败：存储空间可能已满')
      }
    }, 1000)
    return () => clearTimeout(t)
  }, [els, bgIndex, showToast])

  /* ── 懒加载 html2canvas（导出用，非阻塞）──────────────────── */
  useEffect(() => {
    if (document.querySelector(`script[src="${H2C_SRC}"]`)) return
    const s = document.createElement('script')
    s.src = H2C_SRC
    s.async = true
    document.head.appendChild(s)
  }, [])

  /* ── 元素创建 ────────────────────────────────────────────── */
  const spawnPos = useCallback((spread = 0) => {
    const r = wallRef.current?.getBoundingClientRect()
    const w = r?.width ?? window.innerWidth
    const h = r?.height ?? window.innerHeight
    return {
      x: rand(w * 0.12, w * 0.7) + rand(-spread, spread),
      y: rand(h * 0.12, h * 0.65) + rand(-spread, spread),
    }
  }, [])

  const addEl = useCallback(
    (partial: Omit<El, 'id' | 'z'>) => {
      const el: El = { ...partial, id: idRef.current++, z: ++zRef.current }
      mutate((prev) => [...prev, el])
    },
    [mutate],
  )

  // 登录后从服务器拉本用户图片：取字节为 objectURL，套用本地排版或自动散落。
  useEffect(() => {
    if (!token) return
    let alive = true
    ;(async () => {
      try {
        // 重新加载前清掉旧会话的照片，避免重复堆叠与 objectURL 泄漏
        for (const u of objUrls.current) URL.revokeObjectURL(u)
        objUrls.current = []
        setEls((prev) => prev.filter((e) => e.type !== 'photo'))
        const { items } = await listPhotos(token)
        for (const item of items) {
          let url: string
          try {
            url = await fetchPhotoObjectUrl(token, item.id)
          } catch {
            continue
          }
          if (!alive) {
            URL.revokeObjectURL(url)
            return
          }
          objUrls.current.push(url)
          const layout = layoutRef.current[item.id]
          // 取自然尺寸算宽高比；无本地排版时随机散落。
          const dims = await new Promise<{ w: number; h: number }>((resolve) => {
            const img = new Image()
            img.onload = () => resolve({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 })
            img.onerror = () => resolve({ w: 1, h: 1 })
            img.src = url
          })
          if (!alive) return
          const ratio = dims.h / dims.w
          const roll = Math.random()
          const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2'
          const pos = spawnPos(40)
          const base: El = layout
            ? {
                id: idRef.current++, type: 'photo', src: url, photoId: item.id,
                w: layout.w, h: Math.round(layout.w * ratio),
                x: layout.x, y: layout.y, rot: layout.rot, scale: layout.scale,
                z: layout.z, caption: layout.caption,
                fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
              }
            : {
                id: idRef.current++, type: 'photo', src: url, photoId: item.id,
                w: 170, h: Math.round(170 * ratio),
                x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1, z: ++zRef.current,
                caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
              }
          if (layout) zRef.current = Math.max(zRef.current, layout.z)
          setEls((prev) => [...prev, base])
        }
      } catch {
        if (alive) showToast('图片加载失败（请重新登录或检查网络）')
      }
    })()
    return () => {
      alive = false
    }
    // 仅在挂载/换 token 时跑一次
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [token])

  // 卸载时释放所有 objectURL
  useEffect(() => {
    return () => {
      for (const u of objUrls.current) URL.revokeObjectURL(u)
    }
  }, [])

  const makeNote = useCallback(() => {
    const pos = spawnPos(20)
    addEl({ type: 'note', text: '', color: pick(NOTE_COLORS), x: pos.x, y: pos.y, rot: rand(-6, 6), scale: 1 })
  }, [addEl, spawnPos])

  const makeTape = useCallback(() => {
    const pos = spawnPos(20)
    addEl({ type: 'tape', w: randInt(80, 140), color: pick(TAPE_COLORS), x: pos.x, y: pos.y, rot: rand(-20, 20), scale: 1 })
  }, [addEl, spawnPos])

  const makeSticker = useCallback(
    (text: string, bg = '') => {
      const pos = spawnPos(20)
      addEl({ type: 'sticker', text, bg, x: pos.x, y: pos.y, rot: rand(-8, 8), scale: 1 })
    },
    [addEl, spawnPos],
  )

  const makeStamp = useCallback(() => {
    const pos = spawnPos(20)
    addEl({
      type: 'stamp', text: pick(STAMP_TEXTS),
      color: Math.random() < 0.5 ? '#C0392B' : '#1A237E',
      x: pos.x, y: pos.y, rot: rand(-12, 12), scale: 1,
    })
  }, [addEl, spawnPos])

  /* ── 图片上传（按钮 / 拖入 / 粘贴）─────────────────────────── */
  const handleFiles = useCallback(
    (files: FileList | File[]) => {
      const list = Array.from(files)
      const imgs = list.filter((f) => f.type.startsWith('image/'))
      if (!imgs.length) {
        if (list.some((f) => /heic/i.test(f.type) || /\.heic$/i.test(f.name)))
          showToast('HEIC 格式需先转换为 JPG/PNG')
        return
      }
      imgs.forEach((file) => {
        if (file.size > 10 * 1024 * 1024) showToast('图片较大，建议压缩后上传')
        const reader = new FileReader()
        reader.onload = (ev) => {
          const src = ev.target?.result as string
          // 满 10 张直接拦下
          const count = elsRef.current.filter((e) => e.type === 'photo').length
          if (count >= 10) {
            showToast('每位用户最多 10 张照片')
            return
          }
          uploadPhoto(token, src)
            .then((id) => {
              const img = new Image()
              img.onload = () => {
                const w = randInt(140, 200)
                const h = Math.round(w * (img.height / img.width))
                const pos = spawnPos(40)
                const roll = Math.random()
                const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2'
                addEl({
                  type: 'photo', src, photoId: id, w, h,
                  x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1,
                  caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
                })
              }
              img.src = src
            })
            .catch((err: Error) => {
              const reason = err.message as UploadError
              showToast(
                reason === 'full' ? '每位用户最多 10 张照片'
                  : reason === 'too-large' ? '图片过大（上限 8MB）'
                    : reason === 'bad-type' ? '不支持的图片格式'
                      : '上传失败',
              )
            })
        }
        reader.readAsDataURL(file)
      })
    },
    [addEl, spawnPos, showToast, token],
  )

  useEffect(() => {
    const onPaste = (e: ClipboardEvent) => {
      const items = e.clipboardData?.items
      if (!items) return
      const files: File[] = []
      for (const it of items) if (it.type.startsWith('image/')) {
        const f = it.getAsFile()
        if (f) files.push(f)
      }
      if (files.length) handleFiles(files)
    }
    window.addEventListener('paste', onPaste)
    return () => window.removeEventListener('paste', onPaste)
  }, [handleFiles])

  /* ── 拖拽（Pointer Events，鼠标 + 触摸统一）──────────────────
     拖拽中只改 DOM，不 setState；松手后一次性 commit。 */
  const onElPointerDown = useCallback(
    (e: ReactPointerEvent, el: El) => {
      if (e.button === 2 || doodleRef.current) return
      const tgt = e.target as HTMLElement
      if (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA') return
      e.preventDefault()
      const node = nodeRefs.current.get(el.id)
      if (!node) return

      const startX = e.clientX
      const startY = e.clientY
      const origX = el.x
      const origY = el.y
      let curX = origX
      let curY = origY
      let moved = false
      const newZ = ++zRef.current
      node.style.zIndex = String(newZ)
      node.classList.add('pw-dragging')
      const snapshot = JSON.stringify(elsRef.current)

      // 触摸长按 = 右键菜单
      let longPress: ReturnType<typeof setTimeout> | undefined
      if (e.pointerType === 'touch') {
        longPress = setTimeout(() => {
          cleanup()
          node.style.transform = transformOf(el)
          setMenu({ x: startX, y: startY, id: el.id })
        }, 500)
      }

      const move = (ev: PointerEvent) => {
        const dx = ev.clientX - startX
        const dy = ev.clientY - startY
        if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
          moved = true
          clearTimeout(longPress)
        }
        curX = origX + dx
        curY = origY + dy
        node.style.transform = transformOf({ ...el, x: curX, y: curY }, 1.02)
      }
      const cleanup = () => {
        clearTimeout(longPress)
        node.classList.remove('pw-dragging')
        window.removeEventListener('pointermove', move)
        window.removeEventListener('pointerup', up)
      }
      const up = () => {
        cleanup()
        if (moved) {
          markDirty()
          undoRef.current.push(snapshot)
          if (undoRef.current.length > HISTORY_LIMIT) undoRef.current.shift()
          redoRef.current.length = 0
          setEls((prev) => prev.map((p) => (p.id === el.id ? { ...p, x: curX, y: curY, z: newZ } : p)))
        } else {
          // 纯点击：仅把层级提到最前（不记历史）
          setEls((prev) => prev.map((p) => (p.id === el.id ? { ...p, z: newZ } : p)))
        }
      }
      window.addEventListener('pointermove', move)
      window.addEventListener('pointerup', up)
    },
    [markDirty],
  )

  /* ── 右键菜单动作 ────────────────────────────────────────── */
  const rotate = (id: number, d: number) =>
    mutate((p) => p.map((e) => (e.id === id ? { ...e, rot: e.rot + d } : e)))
  const scaleEl = (id: number, d: number) =>
    mutate((p) => p.map((e) => (e.id === id ? { ...e, scale: clamp(e.scale + d, 0.4, 4) } : e)))
  const resizePhoto = (id: number, d: number) =>
    mutate((p) =>
      p.map((e) => {
        if (e.id !== id || !e.w || !e.h) return e
        const ratio = e.h / e.w
        const w = clamp(e.w + d, 80, 600)
        return { ...e, w, h: Math.round(w * ratio) }
      }),
    )
  const toFront = (id: number) =>
    mutate((p) => p.map((e) => (e.id === id ? { ...e, z: ++zRef.current } : e)))
  const toBack = (id: number) =>
    mutate((p) => {
      const minZ = Math.min(...p.map((e) => e.z))
      return p.map((e) => (e.id === id ? { ...e, z: minZ - 1 } : e))
    })
  const recolorNote = (id: number, color: string) =>
    mutate((p) => p.map((e) => (e.id === id ? { ...e, color } : e)))
  const setRotation = (id: number, deg: number) =>
    mutate((p) => p.map((e) => (e.id === id ? { ...e, rot: deg } : e)))
  const setScale = (id: number, s: number) =>
    mutate((p) => p.map((e) => (e.id === id ? { ...e, scale: s } : e)))
  const removeEl = (id: number) => {
    const target = elsRef.current.find((e) => e.id === id)
    if (target?.type === 'photo' && target.photoId) {
      deletePhoto(token, target.photoId).catch(() => {})
      delete layoutRef.current[target.photoId]
      if (target.src?.startsWith('blob:')) {
        URL.revokeObjectURL(target.src)
        objUrls.current = objUrls.current.filter((u) => u !== target.src)
      }
    }
    mutate((p) => p.filter((e) => e.id !== id))
    nodeRefs.current.delete(id)
    setMenu(null)
  }

  // 菜单贴边自动翻转，避免超出视口
  useLayoutEffect(() => {
    if (!menu || !menuRef.current) return
    const m = menuRef.current
    const mw = m.offsetWidth
    const mh = m.offsetHeight
    let px = menu.x
    let py = menu.y
    if (px + mw > window.innerWidth) px = menu.x - mw
    if (py + mh > window.innerHeight) py = menu.y - mh
    m.style.left = Math.max(4, px) + 'px'
    m.style.top = Math.max(4, py) + 'px'
  }, [menu])

  // 点空白处关闭菜单/下拉
  useEffect(() => {
    const onDown = (e: globalThis.PointerEvent) => {
      const t = e.target as HTMLElement
      if (!t.closest('.pw-menu')) setMenu(null)
      if (!t.closest('.pw-deco')) setDecoOpen(false)
    }
    window.addEventListener('pointerdown', onDown)
    return () => window.removeEventListener('pointerdown', onDown)
  }, [])

  /* ── 双击编辑（照片标注 / 便签）──────────────────────────── */
  const startEdit = (el: El) => {
    setEditText(el.type === 'photo' ? el.caption ?? '' : el.text ?? '')
    setEditingId(el.id)
  }
  const commitEdit = (id: number) => {
    const val = editText
    mutate((p) =>
      p.map((e) =>
        e.id === id ? (e.type === 'photo' ? { ...e, caption: val.trim() } : { ...e, text: val }) : e,
      ),
    )
    setEditingId(null)
  }

  /* ── 手绘箭头：进入模式后在画布上拖动绘制 ──────────────────── */
  const startDoodle = () => {
    doodleRef.current = true
    setDoodleMode(true)
    setDecoOpen(false)
    showToast('在画布上按住拖动，绘制一条箭头')
  }
  const onWallPointerDown = (e: ReactPointerEvent) => {
    if (!doodleRef.current) return
    const t = e.target as HTMLElement
    if (t !== wallRef.current && !t.classList.contains('pw-drophint')) return
    const r = wallRef.current!.getBoundingClientRect()
    const sx = e.clientX - r.left
    const sy = e.clientY - r.top
    let ex = sx
    let ey = sy
    const move = (ev: PointerEvent) => {
      ex = ev.clientX - r.left
      ey = ev.clientY - r.top
    }
    const up = () => {
      window.removeEventListener('pointermove', move)
      window.removeEventListener('pointerup', up)
      doodleRef.current = false
      setDoodleMode(false)
      if (Math.hypot(ex - sx, ey - sy) < 12) return
      const pad = 12
      const minX = Math.min(sx, ex) - pad
      const minY = Math.min(sy, ey) - pad
      const w = Math.abs(ex - sx) + pad * 2
      const h = Math.abs(ey - sy) + pad * 2
      const x1 = sx - minX
      const y1 = sy - minY
      const x2 = ex - minX
      const y2 = ey - minY
      const mx = (x1 + x2) / 2 + (y2 - y1) * 0.18
      const my = (y1 + y2) / 2 - (x2 - x1) * 0.18
      addEl({
        type: 'doodle', x: minX, y: minY, w, h,
        path: `M${x1},${y1} Q${mx},${my} ${x2},${y2}`,
        color: Math.random() < 0.5 ? '#E57373' : '#42A5F5',
        rot: 0, scale: 1,
      })
    }
    window.addEventListener('pointermove', move)
    window.addEventListener('pointerup', up)
  }

  /* ── 拖入图片 ────────────────────────────────────────────── */
  const onDragOver = (e: React.DragEvent) => {
    e.preventDefault()
    setDragHint(true)
  }
  const onDragLeave = (e: React.DragEvent) => {
    if (wallRef.current?.contains(e.relatedTarget as Node)) return
    setDragHint(false)
  }
  const onDrop = (e: React.DragEvent) => {
    e.preventDefault()
    setDragHint(false)
    if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files)
  }

  /* ── 清空 / 背景 / 导出 ──────────────────────────────────── */
  const clearWall = () => {
    if (!els.length) return
    if (confirm('确定清空整面照片墙吗？')) {
      mutate(() => [])
      nodeRefs.current.clear()
    }
  }
  const cycleBg = () => { markDirty(); setBgIndex((i) => (i + 1) % BACKGROUNDS.length) }

  const exportImage = () => {
    type H2C = (el: HTMLElement, opts: Record<string, unknown>) => Promise<HTMLCanvasElement>
    const h2c = (window as unknown as { html2canvas?: H2C }).html2canvas
    if (!h2c || !wallRef.current) {
      showToast('导出库加载中，请稍候重试')
      return
    }
    setMenu(null)
    showToast('正在生成图片…')
    h2c(wallRef.current, { backgroundColor: null, scale: window.devicePixelRatio || 1, useCORS: true })
      .then((canvas) => {
        const d = new Date()
        const p = (n: number) => String(n).padStart(2, '0')
        const ts = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
        const a = document.createElement('a')
        a.download = `photo-wall-${ts}.png`
        a.href = canvas.toDataURL('image/png')
        a.click()
        showToast('已导出 PNG')
      })
      .catch(() => showToast('导出失败'))
  }

  /* ── 键盘快捷键 ──────────────────────────────────────────── */
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const tag = (document.activeElement as HTMLElement | null)?.tagName
      if (tag === 'INPUT' || tag === 'TEXTAREA') return
      const mod = e.ctrlKey || e.metaKey
      const k = e.key.toLowerCase()
      if (mod && k === 'z' && !e.shiftKey) { e.preventDefault(); undo() }
      else if (mod && (k === 'y' || (k === 'z' && e.shiftKey))) { e.preventDefault(); redo() }
      else if (e.key === 'Escape') { setMenu(null); doodleRef.current = false; setDoodleMode(false) }
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [undo, redo])

  /* ── 渲染单个元素 ────────────────────────────────────────── */
  const setNodeRef = (id: number) => (n: HTMLDivElement | null) => {
    if (n) nodeRefs.current.set(id, n)
    else nodeRefs.current.delete(id)
  }

  function renderInner(el: El) {
    switch (el.type) {
      case 'photo':
        return (
          <>
            <div className="pw-imgwrap" style={{ height: el.h }}>
              <img src={el.src} alt="" />
            </div>
            {editingId === el.id ? (
              <input
                className="pw-caption-input"
                autoFocus
                value={editText}
                placeholder="写点什么…"
                onChange={(e) => setEditText(e.target.value)}
                onBlur={() => commitEdit(el.id)}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') { e.preventDefault(); commitEdit(el.id) }
                  e.stopPropagation()
                }}
              />
            ) : (
              <div className="pw-caption">{el.caption}</div>
            )}
            {el.fix === 'pin' && <div className="pw-fix-pin" style={{ background: el.fixColor }} />}
            {el.fix === 'tape2' && (
              <>
                <div className="pw-fix-tape pw-left" />
                <div className="pw-fix-tape pw-right" />
              </>
            )}
            {el.fix === 'tape' && (
              <div className="pw-fix-tape" style={{ transform: `translateX(-50%) rotate(${el.fixRot}deg)` }} />
            )}
          </>
        )
      case 'note':
        return (
          <>
            {editingId === el.id ? (
              <textarea
                className="pw-note-input"
                autoFocus
                value={editText}
                onChange={(e) => setEditText(e.target.value)}
                onBlur={() => commitEdit(el.id)}
                onKeyDown={(e) => e.stopPropagation()}
              />
            ) : (
              <div style={{ opacity: el.text ? 1 : 0.45 }}>{el.text || '双击编辑…'}</div>
            )}
            <div
              className="pw-note-fold"
              style={{ borderWidth: '0 0 20px 20px', borderColor: `transparent transparent ${shade(el.color || '#fff', -0.15)} transparent` }}
            />
          </>
        )
      case 'stamp':
        return (
          <svg width="110" height="110" viewBox="0 0 110 110" style={{ opacity: 0.75 }}>
            <circle cx="55" cy="55" r="50" fill="none" stroke={el.color} strokeWidth="4" />
            <circle cx="55" cy="55" r="40" fill="none" stroke={el.color} strokeWidth="1.5" />
            <text x="55" y="61" textAnchor="middle" fill={el.color} fontFamily="var(--font-sans)" fontSize="15" fontWeight="700" letterSpacing="2">
              {el.text}
            </text>
          </svg>
        )
      case 'doodle':
        return (
          <svg width={el.w} height={el.h} viewBox={`0 0 ${el.w} ${el.h}`}>
            <defs>
              <marker id={`pw-ah-${el.id}`} markerWidth="10" markerHeight="10" refX="7" refY="3" orient="auto" markerUnits="strokeWidth">
                <path d="M0,0 L8,3 L0,6 Z" fill={el.color} />
              </marker>
            </defs>
            <path d={el.path} fill="none" stroke={el.color} strokeWidth="3" strokeLinecap="round" markerEnd={`url(#pw-ah-${el.id})`} />
          </svg>
        )
      case 'sticker':
        return el.text
      case 'tape':
        return null
    }
  }

  function elClass(el: El) {
    const base = 'pw-el'
    const byType: Record<ElType, string> = {
      photo: 'pw-photo', note: 'pw-note', tape: 'pw-tape',
      sticker: 'pw-sticker', stamp: 'pw-stamp', doodle: 'pw-doodle',
    }
    return `${base} ${byType[el.type]}`
  }

  function elStyle(el: El): CSSProperties {
    const s: CSSProperties = { transform: transformOf(el), zIndex: el.z }
    if (el.type === 'photo') s.width = el.w
    if (el.type === 'tape') {
      s.width = el.w
      s.background = `repeating-linear-gradient(90deg, rgba(255,255,255,0.2) 0 2px, transparent 2px 4px), ${el.color}`
    }
    if (el.type === 'note') s.background = el.color
    if (el.type === 'sticker' && el.bg) s.background = el.bg
    if (el.type === 'doodle') { s.width = el.w; s.height = el.h }
    return s
  }

  const menuEl = menu ? els.find((e) => e.id === menu.id) : null

  return (
    <div className="pw-root">
      {/* ── 工具栏 ── */}
      <div className="pw-toolbar">
        <span className="pw-logo">📸 Photo Wall</span>
        <button className="pw-tbtn" onClick={() => fileRef.current?.click()}>上传照片</button>
        <button className="pw-tbtn" onClick={makeNote}>添加便签</button>

        <div className="pw-deco" style={{ position: 'relative' }}>
          <button
            className="pw-tbtn"
            onClick={(e) => { e.stopPropagation(); setDecoOpen((o) => !o) }}
          >
            添加装饰 ▾
          </button>
          {decoOpen && (
            <div className="pw-pop" style={{ position: 'absolute', top: 'calc(100% + 6px)', left: 0, minWidth: 200, zIndex: 31 }}>
              <div className="pw-item" onClick={() => { makeTape(); setDecoOpen(false) }}>🎀 胶带条</div>
              <div className="pw-sep" />
              <div className="pw-grid">
                {STICKERS.map((s) => (
                  <div key={s} className="pw-item" onClick={() => { makeSticker(s); setDecoOpen(false) }}>{s}</div>
                ))}
              </div>
              <div className="pw-rowin">
                <input
                  value={customSticker}
                  placeholder="自定义贴纸文字…"
                  maxLength={20}
                  onChange={(e) => setCustomSticker(e.target.value)}
                  onKeyDown={(e) => {
                    if (e.key === 'Enter' && customSticker.trim()) {
                      makeSticker(customSticker.trim()); setCustomSticker(''); setDecoOpen(false)
                    }
                  }}
                />
                <button
                  onClick={() => {
                    if (customSticker.trim()) { makeSticker(customSticker.trim()); setCustomSticker(''); setDecoOpen(false) }
                  }}
                >
                  加
                </button>
              </div>
              <div className="pw-sep" />
              <div className="pw-item" onClick={() => { makeStamp(); setDecoOpen(false) }}>🔖 印章</div>
              <div className="pw-item" onClick={startDoodle}>✏️ 手绘箭头（拖动绘制）</div>
            </div>
          )}
        </div>

        <button className="pw-tbtn pw-danger" onClick={clearWall}>清空画布</button>

        <span style={{ flex: 1 }} />

        <span className="pw-save">{saveLabel}</span>
        <button className="pw-tbtn" onClick={exportImage}>导出图片</button>
        <button className="pw-tbtn" onClick={cycleBg}>背景：{BACKGROUNDS[bgIndex].name}</button>
        <button className="pw-tbtn pw-danger" onClick={onExit}>退出</button>
      </div>

      {/* ── 画布 ── */}
      <div
        ref={wallRef}
        className={`pw-wall ${BACKGROUNDS[bgIndex].cls}`}
        style={doodleMode ? { cursor: 'crosshair' } : undefined}
        onPointerDown={onWallPointerDown}
        onDragOver={onDragOver}
        onDragEnter={onDragOver}
        onDragLeave={onDragLeave}
        onDrop={onDrop}
        onContextMenu={(e) => { if (e.target === wallRef.current) e.preventDefault() }}
      >
        {els.map((el) => (
          <div
            key={el.id}
            ref={setNodeRef(el.id)}
            className={elClass(el)}
            style={elStyle(el)}
            onPointerDown={(e) => onElPointerDown(e, el)}
            onDoubleClick={(e) => {
              if (el.type === 'photo' || el.type === 'note') { e.stopPropagation(); startEdit(el) }
            }}
            onContextMenu={(e) => {
              e.preventDefault()
              e.stopPropagation()
              setMenu({ x: e.clientX, y: e.clientY, id: el.id })
            }}
          >
            {renderInner(el)}
          </div>
        ))}
        {dragHint && <div className="pw-drophint">拖入图片即可添加到照片墙</div>}
      </div>

      {/* ── 右键菜单 ── */}
      {menu && menuEl && (
        <div ref={menuRef} className="pw-menu pw-pop" style={{ left: menu.x, top: menu.y, minWidth: 170 }}>
          {menuEl.type !== 'doodle' && (
            <>
              <div className="pw-item" onClick={() => { rotate(menu.id, 15); setMenu(null) }}>旋转 +15°</div>
              <div className="pw-item" onClick={() => { rotate(menu.id, -15); setMenu(null) }}>旋转 -15°</div>
              {menuEl.type === 'photo' ? (
                <>
                  <div className="pw-item" onClick={() => { resizePhoto(menu.id, 30); setMenu(null) }}>放大</div>
                  <div className="pw-item" onClick={() => { resizePhoto(menu.id, -30); setMenu(null) }}>缩小</div>
                </>
              ) : (
                <>
                  <div className="pw-item" onClick={() => { scaleEl(menu.id, 0.25); setMenu(null) }}>放大</div>
                  <div className="pw-item" onClick={() => { scaleEl(menu.id, -0.25); setMenu(null) }}>缩小</div>
                </>
              )}
              <div className="pw-sep" />
            </>
          )}
          <div className="pw-item" onClick={() => { toFront(menu.id); setMenu(null) }}>移到最前</div>
          <div className="pw-item" onClick={() => { toBack(menu.id); setMenu(null) }}>移到最后</div>
          {menuEl.type === 'note' && (
            <>
              <div className="pw-sep" />
              <div className="pw-colorrow">
                {NOTE_COLORS.map((c) => (
                  <div
                    key={c}
                    className="pw-sw"
                    style={{ background: c }}
                    onClick={() => { recolorNote(menu.id, c); setMenu(null) }}
                  />
                ))}
              </div>
            </>
          )}
          <div className="pw-sep" />
          <div className="pw-preset">
            <span className="pw-preset-label">旋转</span>
            {[0, 15, 30, 45, 90].map((d) => (
              <button key={d} className="pw-chip" onClick={() => { setRotation(menu.id, d); setMenu(null) }}>{d}°</button>
            ))}
          </div>
          <div className="pw-preset">
            <span className="pw-preset-label">比例</span>
            {[0.5, 0.75, 1, 1.5, 2].map((s) => (
              <button key={s} className="pw-chip" onClick={() => { setScale(menu.id, s); setMenu(null) }}>{s}×</button>
            ))}
          </div>
          <div className="pw-sep" />
          <div className="pw-item pw-danger" onClick={() => removeEl(menu.id)}>
            <span>删除</span>
            <span className="pw-kbd">Del</span>
          </div>
        </div>
      )}

      {toast && <div className="pw-toast">{toast}</div>}

      <input ref={fileRef} type="file" accept="image/*" multiple style={{ display: 'none' }}
        onChange={(e) => { if (e.target.files) handleFiles(e.target.files); e.target.value = '' }} />
    </div>
  )
}
