import {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
  type DragEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react'
import { clampScale, type Point } from '@/lib/geometry'
import { monogramInk } from '@/lib/monogram'
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
import { FALLBACK_PLANT_COLOR, type Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import { formatSize } from '@/lib/units'
import { kindDef, objectStyle, rectRadius } from './kinds'
import {
  DIM_OBJECT,
  DIM_PLOP,
  MAX_SCALE,
  MIN_OBJECT_CM,
  MIN_SCALE,
  SNAP_CM,
  clampPlopLocal,
  defaultPlopRadius,
  objectAt,
  objectTransform,
  rotatedHalfHeight,
  snapPlopLocal,
  toLocal,
  toWorld,
} from './shared'
import { useEditorStore, type Viewport } from './store'
import type { EditorGarden, EditorObject } from './types'

const WHEEL_SENSITIVITY = 0.0016
const ANIM_MS = 520
const REFIT_THRESHOLD_PX = 60
const FT = 30.48

export interface CanvasHandle {
  zoomFit: () => void
  zoomIn: () => void
  zoomOut: () => void
  /** Frame a bed and enter focus mode (the page handles mode + URL). */
  focusObject: (o: EditorObject) => void
}

type Drag =
  | { t: 'pan'; sx: number; sy: number; tx: number; ty: number; moved: boolean; th: number }
  | { t: 'obj'; base: EditorObject; sx: number; sy: number; moved: boolean; th: number }
  | { t: 'plop'; base: EditorPlanting; obj: EditorObject; sx: number; sy: number; moved: boolean; th: number }
  | { t: 'resize'; base: EditorObject; cx: number; cy: number; opp: Point; moved: boolean }

interface Pinch {
  d0: number
  cx0: number
  cy0: number
  s0: number
  tx0: number
  ty0: number
}

/**
 * The garden canvas: one SVG, world = garden cm under a single translate/scale
 * group. Everything the prototype does — wheel zoom to the cursor, two-finger
 * pinch about the centroid, one-finger pan, drag-to-move with a 3″ snap, tap to
 * select, semantic zoom on the plant markers, a ghost while a kind is armed,
 * drag-and-drop from the toolkit — lives here, with native SVG hit-testing
 * doing the picking. Drags commit ONE change on release (a PATCH with the row's
 * version); placement creates. Corner handles on the selected object resize it.
 */
export const Canvas = forwardRef<
  CanvasHandle,
  {
    garden: EditorGarden
    objects: EditorObject[]
    plantings: EditorPlanting[]
    plantsById: Map<number, Plant>
    letters: Map<number, string>
    canEdit: boolean
    isMobile: boolean
  }
>(function Canvas({ garden, objects, plantings, plantsById, letters, canEdit, isMobile }, ref) {
  const svgRef = useRef<SVGSVGElement>(null)
  const hostRef = useRef<HTMLDivElement>(null)
  const [size, setSize] = useState({ w: 0, h: 0 })
  const fitSize = useRef({ w: 0, h: 0 })
  const fittedGarden = useRef<number | null>(null)
  const wasMobile = useRef(isMobile)
  const animTimer = useRef<number | null>(null)
  const pts = useRef(new Map<number, Point>())
  const drag = useRef<Drag | null>(null)
  const pinch = useRef<Pinch | null>(null)
  const dropPlant = useRef<{ plant: Plant; lotId: number | null } | null>(null)

  const vp = useEditorStore((s) => s.vp)
  const anim = useEditorStore((s) => s.anim)
  const sel = useEditorStore((s) => s.sel)
  const focusId = useEditorStore((s) => s.focusId)
  const armedKind = useEditorStore((s) => s.armedKind)
  const armedPlant = useEditorStore((s) => s.armedPlant)
  const ghost = useEditorStore((s) => s.ghost)
  const liveObject = useEditorStore((s) => s.liveObject)
  const livePlanting = useEditorStore((s) => s.livePlanting)

  const createObject = useCreateObject(garden.id)
  const createPlanting = useCreatePlanting(garden.id)
  const updateObject = useUpdateObject(garden.id)
  const updatePlanting = useUpdatePlanting(garden.id)

  const GW = garden.widthCm
  const GH = garden.heightCm
  const s = vp.s
  const snapStep = garden.snapToGrid && garden.gridSizeCm > 0 ? garden.gridSizeCm : SNAP_CM

  // Objects in draw order, with any in-flight drag geometry merged in.
  const rendered = useMemo(() => {
    const sorted = [...objects].sort((a, b) => a.zIndex - b.zIndex || a.id - b.id)
    return liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted
  }, [objects, liveObject])
  const renderedPlops = useMemo(
    () => (livePlanting ? plantings.map((p) => (p.id === livePlanting.id ? livePlanting : p)) : plantings),
    [plantings, livePlanting],
  )
  const byId = useMemo(() => new Map(rendered.map((o) => [o.id, o])), [rendered])
  // Latest lists for the pointer handlers, which must not close over a render.
  const latest = useRef({ rendered, renderedPlops, byId, canEdit, isMobile, garden, snapStep })
  latest.current = { rendered, renderedPlops, byId, canEdit, isMobile, garden, snapStep }

  // ── camera ──────────────────────────────────────────────────────────────
  const camera = useCallback((next: Viewport, animate: boolean) => {
    useEditorStore.getState().setVp(next, animate)
    if (animTimer.current != null) window.clearTimeout(animTimer.current)
    if (animate) {
      animTimer.current = window.setTimeout(() => {
        animTimer.current = null
        useEditorStore.getState().setAnim(false)
      }, ANIM_MS)
    }
  }, [])

  const zoomFit = useCallback(() => {
    const el = svgRef.current
    if (!el) return
    const w = el.clientWidth
    const h = el.clientHeight
    const pad = latest.current.isMobile ? 20 : 40
    const { widthCm, heightCm } = latest.current.garden
    const ns = clampScale(Math.min((w - pad * 2) / widthCm, (h - pad * 2) / heightCm), MIN_SCALE, MAX_SCALE)
    if (!Number.isFinite(ns) || ns <= 0) return
    fitSize.current = { w, h }
    camera({ s: ns, tx: (w - widthCm * ns) / 2, ty: (h - heightCm * ns) / 2 }, true)
  }, [camera])

  const zoomBy = useCallback(
    (f: number) => {
      const el = svgRef.current
      if (!el) return
      const { tx, ty, s: cur } = useEditorStore.getState().vp
      const ns = clampScale(cur * f, MIN_SCALE, MAX_SCALE)
      const px = el.clientWidth / 2
      const py = el.clientHeight / 2
      camera({ s: ns, tx: px - ((px - tx) / cur) * ns, ty: py - ((py - ty) / cur) * ns }, true)
    },
    [camera],
  )

  const focusObject = useCallback(
    (o: EditorObject) => {
      const el = svgRef.current
      const st = useEditorStore.getState()
      const m = latest.current.isMobile
      st.setFocus(o.id)
      st.setSel(m ? null : { type: 'object', id: o.id })
      st.setArmedKind(null)
      st.setGhost(null)
      st.setTab('plot')
      if (!el) return
      const bw = o.rotationDeg % 180 ? o.heightCm : o.widthCm
      const bh = o.rotationDeg % 180 ? o.widthCm : o.heightCm
      const ns = Math.min(
        MAX_SCALE * 0.75,
        Math.min(el.clientWidth / (bw * (m ? 1.35 : 2.1)), el.clientHeight / (bh * (m ? 1.45 : 1.7))),
      )
      camera({ s: ns, tx: el.clientWidth / 2 - o.xCm * ns, ty: el.clientHeight / 2 - o.yCm * ns + (m ? 0 : 10) }, true)
    },
    [camera],
  )

  useImperativeHandle(ref, () => ({ zoomFit, zoomIn: () => zoomBy(1.45), zoomOut: () => zoomBy(1 / 1.45), focusObject }), [
    zoomFit,
    zoomBy,
    focusObject,
  ])

  // Measure; refit on a real size change (>60px) or when the chrome flips.
  useEffect(() => {
    const el = hostRef.current
    if (!el) return
    const ro = new ResizeObserver(([entry]) => setSize({ w: entry.contentRect.width, h: entry.contentRect.height }))
    ro.observe(el)
    return () => ro.disconnect()
  }, [])

  useEffect(() => {
    if (size.w === 0 || size.h === 0 || GW === 0 || GH === 0) return
    const first = fittedGarden.current !== garden.id
    const flipped = wasMobile.current !== isMobile
    const big =
      Math.abs(size.w - fitSize.current.w) > REFIT_THRESHOLD_PX || Math.abs(size.h - fitSize.current.h) > REFIT_THRESHOLD_PX
    wasMobile.current = isMobile
    if (!first && !flipped && !big) return
    fittedGarden.current = garden.id
    // A focused bed (a ?focus= deep link, or a chrome flip mid-planting) frames
    // its bed rather than the whole garden.
    const f = useEditorStore.getState().focusId
    const target = f != null ? latest.current.byId.get(f) : undefined
    if (target) {
      fitSize.current = { w: size.w, h: size.h }
      focusObject(target)
    } else zoomFit()
  }, [size, garden.id, GW, GH, isMobile, zoomFit, focusObject])

  // Wheel zooms to the cursor. Non-passive so the page doesn't scroll.
  useEffect(() => {
    const el = svgRef.current
    if (!el) return
    const onWheel = (e: WheelEvent) => {
      e.preventDefault()
      if (!Number.isFinite(e.deltaY)) return
      const r = el.getBoundingClientRect()
      const { tx, ty, s: cur } = useEditorStore.getState().vp
      const ns = clampScale(cur * Math.exp(-e.deltaY * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE)
      const px = e.clientX - r.left
      const py = e.clientY - r.top
      camera({ s: ns, tx: px - ((px - tx) / cur) * ns, ty: py - ((py - ty) / cur) * ns }, false)
    }
    el.addEventListener('wheel', onWheel, { passive: false })
    return () => el.removeEventListener('wheel', onWheel)
  }, [camera])

  useEffect(
    () => () => {
      if (animTimer.current != null) window.clearTimeout(animTimer.current)
    },
    [],
  )

  // ── pointer math ────────────────────────────────────────────────────────
  const world = (e: { clientX: number; clientY: number }): Point => {
    const r = svgRef.current!.getBoundingClientRect()
    const { tx, ty, s: cur } = useEditorStore.getState().vp
    return { x: (e.clientX - r.left - tx) / cur, y: (e.clientY - r.top - ty) / cur }
  }
  const snap = (v: number) => Math.round(v / latest.current.snapStep) * latest.current.snapStep
  const thresh = (e: { pointerType?: string }) => (e.pointerType === 'touch' ? 7 : 3)
  const track = (e: ReactPointerEvent) => {
    const r = svgRef.current!.getBoundingClientRect()
    pts.current.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top })
    try {
      svgRef.current!.setPointerCapture(e.pointerId)
    } catch {
      /* not all pointers capture */
    }
  }
  const pinchStart = () => {
    const [a, b] = [...pts.current.values()]
    drag.current = null
    const { s: cur, tx, ty } = useEditorStore.getState().vp
    pinch.current = { d0: Math.hypot(a.x - b.x, a.y - b.y), cx0: (a.x + b.x) / 2, cy0: (a.y + b.y) / 2, s0: cur, tx0: tx, ty0: ty }
  }

  // ── placement ───────────────────────────────────────────────────────────
  const placeObject = (kind: string, w: Point) => {
    const def = kindDef(kind)
    const st = useEditorStore.getState()
    st.setArmedKind(null)
    st.setGhost(null)
    if (!def || !latest.current.canEdit) return
    const n = latest.current.rendered.filter((o) => o.kind === def.kind).length + 1
    createObject.mutate(
      {
        kind: def.kind,
        shape: def.shape,
        name: `${def.label} ${n}`,
        xCm: snap(w.x),
        yCm: snap(w.y),
        widthCm: def.widthCm,
        heightCm: def.heightCm,
        zIndex: def.defaultZ,
        plantable: def.plantable,
      },
      { onSuccess: (o) => useEditorStore.getState().setSel({ type: 'object', id: o.id }) },
    )
  }

  const placePlop = (o: EditorObject, local: Point, plant: Plant, lotId: number | null) => {
    if (!latest.current.canEdit || !o.plantable) return
    const r = defaultPlopRadius(plant)
    const c = clampPlopLocal(o, snapPlopLocal(o, local), r)
    createPlanting.mutate(
      { objectId: o.id, plantId: plant.id, xCm: c.x, yCm: c.y, radiusCm: r, seedLotId: lotId ?? undefined },
      {
        onSuccess: (p) => {
          // Desktop selects what it just placed; the phone keeps the strip's plant
          // armed and the peek closed so the next tap plants again.
          if (!latest.current.isMobile) useEditorStore.getState().setSel({ type: 'plop', id: p.id })
        },
      },
    )
  }

  // ── pointer handlers ────────────────────────────────────────────────────
  const onCanvasDown = (e: ReactPointerEvent) => {
    track(e)
    if (pts.current.size === 2) return pinchStart()
    const st = useEditorStore.getState()
    const w = world(e)
    if (st.armedKind) return placeObject(st.armedKind, w)
    if (st.armedPlant) {
      const o = st.focusId != null ? latest.current.byId.get(st.focusId) : objectAt(latest.current.rendered, w)
      if (o?.plantable) placePlop(o, toLocal(o, w), st.armedPlant, st.armedLotId)
      return
    }
    if (!drag.current) drag.current = { t: 'pan', sx: e.clientX, sy: e.clientY, tx: st.vp.tx, ty: st.vp.ty, moved: false, th: thresh(e) }
  }

  const objDown = (o: EditorObject) => (e: ReactPointerEvent) => {
    e.stopPropagation()
    track(e)
    if (pts.current.size === 2) return pinchStart()
    const st = useEditorStore.getState()
    const w = world(e)
    if (st.armedKind) return placeObject(st.armedKind, w)
    if (st.armedPlant) {
      if (o.plantable) placePlop(o, toLocal(o, w), st.armedPlant, st.armedLotId)
      return
    }
    // Dimmed siblings stay inert inside a focused bed.
    if (st.focusId != null && o.id !== st.focusId) return
    if (!latest.current.canEdit) {
      st.setSel({ type: 'object', id: o.id })
      st.setTab('plot')
      return
    }
    drag.current = { t: 'obj', base: o, sx: w.x, sy: w.y, moved: false, th: thresh(e) }
  }

  const plopDown = (p: EditorPlanting) => (e: ReactPointerEvent) => {
    const st = useEditorStore.getState()
    if (st.armedKind || st.armedPlant) return // bubbles to the bed / canvas, which places
    e.stopPropagation()
    const o = latest.current.byId.get(p.objectId)
    if (!o) return
    // Outside focus a plop is just part of its bed: grabbing it grabs the bed.
    if (st.focusId !== p.objectId) return objDown(o)(e)
    track(e)
    if (pts.current.size === 2) return pinchStart()
    if (!latest.current.canEdit) {
      st.setSel({ type: 'plop', id: p.id })
      st.setTab('plot')
      return
    }
    const l = toLocal(o, world(e))
    drag.current = { t: 'plop', base: p, obj: o, sx: l.x, sy: l.y, moved: false, th: thresh(e) }
  }

  const handleDown = (o: EditorObject, cx: number, cy: number) => (e: ReactPointerEvent) => {
    e.stopPropagation()
    track(e)
    if (!latest.current.canEdit) return
    // The corner opposite the dragged one stays put, in the object's own frame.
    drag.current = { t: 'resize', base: o, cx, cy, opp: { x: -cx * (o.widthCm / 2), y: -cy * (o.heightCm / 2) }, moved: false }
  }

  const onCanvasMove = (e: ReactPointerEvent) => {
    const r = svgRef.current!.getBoundingClientRect()
    if (pts.current.has(e.pointerId)) pts.current.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top })
    const st = useEditorStore.getState()
    if (pinch.current && pts.current.size >= 2) {
      const [a, b] = [...pts.current.values()]
      const p = pinch.current
      const d1 = Math.hypot(a.x - b.x, a.y - b.y)
      const cx = (a.x + b.x) / 2
      const cy = (a.y + b.y) / 2
      const ns = clampScale(p.s0 * (d1 / Math.max(1, p.d0)), MIN_SCALE, MAX_SCALE)
      const wx = (p.cx0 - p.tx0) / p.s0
      const wy = (p.cy0 - p.ty0) / p.s0
      camera({ s: ns, tx: cx - wx * ns, ty: cy - wy * ns }, false)
      return
    }
    const d = drag.current
    if (!d) {
      if (st.armedKind && e.pointerType !== 'touch') {
        const w = world(e)
        st.setGhost({ x: snap(w.x), y: snap(w.y) })
      }
      return
    }
    if (d.t === 'pan') {
      if (Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > d.th) d.moved = true
      camera({ ...st.vp, tx: d.tx + e.clientX - d.sx, ty: d.ty + e.clientY - d.sy }, false)
    } else if (d.t === 'obj') {
      const w = world(e)
      if (Math.hypot(w.x - d.sx, w.y - d.sy) > d.th / st.vp.s) d.moved = true
      if (!d.moved) return
      st.setLiveObject({ ...d.base, xCm: snap(d.base.xCm + w.x - d.sx), yCm: snap(d.base.yCm + w.y - d.sy) })
    } else if (d.t === 'plop') {
      const l = toLocal(d.obj, world(e))
      if (Math.hypot(l.x - d.sx, l.y - d.sy) > d.th / st.vp.s) d.moved = true
      if (!d.moved) return
      const next = clampPlopLocal(
        d.obj,
        snapPlopLocal(d.obj, { x: d.base.xCm + l.x - d.sx, y: d.base.yCm + l.y - d.sy }),
        d.base.radiusCm,
      )
      st.setLivePlanting({ ...d.base, xCm: next.x, yCm: next.y })
    } else if (d.t === 'resize') {
      const p = toLocal(d.base, world(e))
      d.moved = true
      let newW = Math.max(MIN_OBJECT_CM, Math.abs(p.x - d.opp.x))
      let newH = Math.max(MIN_OBJECT_CM, Math.abs(p.y - d.opp.y))
      if (latest.current.garden.snapToGrid) {
        const g = latest.current.snapStep
        newW = Math.max(g, Math.round(newW / g) * g)
        newH = Math.max(g, Math.round(newH / g) * g)
      }
      const dragged = { x: d.opp.x + d.cx * newW, y: d.opp.y + d.cy * newH }
      const c = toWorld(d.base, { x: (d.opp.x + dragged.x) / 2, y: (d.opp.y + dragged.y) / 2 })
      st.setLiveObject({ ...d.base, xCm: c.x, yCm: c.y, widthCm: newW, heightCm: newH })
    }
  }

  const onCanvasUp = (e: ReactPointerEvent) => {
    pts.current.delete(e.pointerId)
    if (pinch.current) {
      if (pts.current.size < 2) pinch.current = null
      return
    }
    const d = drag.current
    drag.current = null
    if (!d) return
    const st = useEditorStore.getState()
    if (d.t === 'pan') {
      if (!d.moved) st.setSel(null)
      return
    }
    if (d.t === 'obj') {
      if (!d.moved) {
        st.setSel({ type: 'object', id: d.base.id })
        st.setTab('plot')
        return
      }
      const final = st.liveObject
      st.setLiveObject(null)
      if (final && (final.xCm !== d.base.xCm || final.yCm !== d.base.yCm))
        updateObject.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm })
    } else if (d.t === 'plop') {
      if (!d.moved) {
        st.setSel({ type: 'plop', id: d.base.id })
        st.setTab('plot')
        return
      }
      const final = st.livePlanting
      st.setLivePlanting(null)
      if (final) updatePlanting.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm })
    } else if (d.t === 'resize') {
      const final = st.liveObject
      st.setLiveObject(null)
      if (final && d.moved)
        updateObject.mutate({
          id: final.id,
          version: final.version,
          xCm: final.xCm,
          yCm: final.yCm,
          widthCm: final.widthCm,
          heightCm: final.heightCm,
        })
    }
  }

  // HTML5 drag-and-drop from the toolkit (desktop).
  const onDragOver = (e: DragEvent) => e.preventDefault()
  const onDrop = (e: DragEvent) => {
    e.preventDefault()
    const data = e.dataTransfer.getData('text/plain')
    if (!data) return
    const w = world(e)
    const [t, id] = data.split(':')
    if (t === 'kind') placeObject(id, w)
    if (t === 'plant' && dropPlant.current) {
      const o = objectAt(latest.current.rendered, w)
      if (o?.plantable) placePlop(o, toLocal(o, w), dropPlant.current.plant, dropPlant.current.lotId)
    }
  }
  // The plant being dragged from the toolkit rides along in the store (the
  // dataTransfer only carries its id); the armed plant is what's being dragged.
  dropPlant.current = armedPlant ? { plant: armedPlant, lotId: useEditorStore.getState().armedLotId } : null

  // ── derived drawing data ───────────────────────────────────────────────
  const grid = useMemo(() => {
    const imperial = garden.unitPref === 'imperial'
    let step = imperial ? FT : 25
    const majorEvery = imperial ? 5 : 4
    while (Math.max(GW, GH) / step > 400) step *= majorEvery // a 100 m field still draws a sane number of lines
    const minor: string[] = []
    const major: string[] = []
    for (let i = 1; i * step < GW; i++) (i % majorEvery ? minor : major).push(`M${(i * step).toFixed(2)} 0V${GH}`)
    for (let i = 1; i * step < GH; i++) (i % majorEvery ? minor : major).push(`M0 ${(i * step).toFixed(2)}H${GW}`)
    return { minor: minor.join(''), major: major.join('') }
  }, [GW, GH, garden.unitPref])

  const selectedObject = sel?.type === 'object' ? (byId.get(sel.id) ?? null) : null
  const showLabels = s > 0.28
  const handlePx = isMobile ? 14 : 9
  const cursor = armedKind || armedPlant ? 'crosshair' : 'default'

  return (
    <div ref={hostRef} className="absolute inset-0">
      <svg
        ref={svgRef}
        width="100%"
        height="100%"
        style={{ display: 'block', touchAction: 'none', cursor, userSelect: 'none' }}
        onPointerDown={onCanvasDown}
        onPointerMove={onCanvasMove}
        onPointerUp={onCanvasUp}
        onPointerCancel={onCanvasUp}
        onDragOver={onDragOver}
        onDrop={onDrop}
        role="application"
        aria-label={`${garden.name} — the plan. Tap a bed to select it; double-click to plant it.`}
      >
        <title>{garden.name} plan</title>
        <g
          className={anim ? 'camera-anim' : undefined}
          style={{ transform: `translate(${vp.tx}px, ${vp.ty}px) scale(${s})`, transformOrigin: '0 0' }}
        >
          <rect x={0} y={0} width={GW} height={GH} rx={18} fill="var(--p-field)" stroke="var(--color-accent-2-500)" strokeWidth={3 / s} />
          <path d={grid.minor} stroke="var(--p-grid-ink)" strokeWidth={1 / s} opacity={0.06} fill="none" />
          <path d={grid.major} stroke="var(--p-grid-ink)" strokeWidth={1 / s} opacity={0.12} fill="none" />

          {rendered.map((o) => {
            const st = objectStyle(o)
            const isSel = sel?.type === 'object' && sel.id === o.id
            const dim = focusId != null && o.id !== focusId
            const common = {
              fill: st.fill,
              stroke: isSel ? 'var(--color-accent)' : st.stroke,
              strokeWidth: (isSel ? 3.5 : 2.5) / s,
              strokeDasharray: st.dash,
            }
            return (
              <g
                key={o.id}
                data-object-id={o.id}
                transform={objectTransform(o)}
                opacity={dim ? DIM_OBJECT : 1}
                style={{ cursor: dim ? 'default' : canEdit ? 'grab' : 'pointer' }}
                onPointerDown={objDown(o)}
                onDoubleClick={o.plantable && !dim ? () => focusObject(o) : undefined}
              >
                {o.shape === 'circle' ? (
                  <ellipse rx={o.widthCm / 2} ry={o.heightCm / 2} {...common} />
                ) : (
                  <rect x={-o.widthCm / 2} y={-o.heightCm / 2} width={o.widthCm} height={o.heightCm} rx={rectRadius(o.widthCm)} {...common} />
                )}
              </g>
            )
          })}

          {renderedPlops.map((p) => {
            const o = byId.get(p.objectId)
            if (!o) return null
            const plant = plantsById.get(p.plantId)
            const w = toWorld(o, { x: p.xCm, y: p.yCm })
            const isSel = sel?.type === 'plop' && sel.id === p.id
            const inFocus = focusId === p.objectId
            return (
              <g
                key={p.id}
                data-plop-id={p.id}
                transform={`translate(${w.x} ${w.y})`}
                opacity={focusId != null && !inFocus ? DIM_PLOP : 0.94}
                style={{ cursor: inFocus ? (canEdit ? 'grab' : 'pointer') : 'inherit' }}
                onPointerDown={plopDown(p)}
              >
                {/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */}
                <circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" />
                <circle r={p.radiusCm} fill={plant?.color ?? FALLBACK_PLANT_COLOR} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} />
              </g>
            )
          })}

          {/* Labels — semantic zoom. pointer-events none so they never catch a tap. */}
          <g style={{ pointerEvents: 'none' }}>
            {showLabels &&
              rendered.map((o) => {
                if (!o.name || Math.max(o.widthCm, o.heightCm) * s <= 54 || (focusId != null && focusId !== o.id)) return null
                const bh = rotatedHalfHeight(o)
                return o.plantable ? (
                  <text
                    key={`ol${o.id}`}
                    x={o.xCm}
                    y={o.yCm - bh - 9 / s}
                    textAnchor="middle"
                    fontSize={13 / s}
                    fill="var(--p-ink-soft)"
                    style={{ fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.02em' }}
                  >
                    {o.name}
                  </text>
                ) : (
                  <text
                    key={`ol${o.id}`}
                    x={o.xCm}
                    y={o.yCm}
                    textAnchor="middle"
                    dominantBaseline="central"
                    fontSize={13 / s}
                    fill="var(--p-ink-mute)"
                    style={{ fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.06em' }}
                  >
                    {o.name}
                  </text>
                )
              })}
            {renderedPlops.map((p) => {
              const o = byId.get(p.objectId)
              if (!o || (focusId != null && p.objectId !== focusId)) return null
              const plant = plantsById.get(p.plantId)
              const r = p.radiusCm
              if (r * s < 9) return null
              const w = toWorld(o, { x: p.xCm, y: p.yCm })
              return (
                <g key={`pl${p.id}`}>
                  <text
                    x={w.x}
                    y={w.y}
                    textAnchor="middle"
                    dominantBaseline="central"
                    fontSize={r * 1.05}
                    fill={monogramInk(plant?.color ?? FALLBACK_PLANT_COLOR)}
                    style={{ fontFamily: 'var(--font-heading)' }}
                  >
                    {letters.get(p.plantId) ?? '?'}
                  </text>
                  {r * s >= 34 && plant && (
                    <text
                      x={w.x}
                      y={w.y + r + 13 / s}
                      textAnchor="middle"
                      fontSize={11 / s}
                      fill="var(--p-ink-strong)"
                      style={{ fontFamily: 'var(--font-body)', fontWeight: 600 }}
                    >
                      {plant.name}
                    </text>
                  )}
                </g>
              )
            })}
            {selectedObject && (
              <text
                x={selectedObject.xCm}
                y={selectedObject.yCm + rotatedHalfHeight(selectedObject) + 22 / s}
                textAnchor="middle"
                fontSize={12.5 / s}
                fill="var(--color-accent-700)"
                style={{ fontFamily: 'var(--font-body)', fontWeight: 700 }}
              >
                {formatSize(selectedObject.widthCm, selectedObject.heightCm, garden.unitPref)}
              </text>
            )}
          </g>

          {/* Selection: a dashed accent outline offset from the object, and —
              for editors — corner handles to resize it. */}
          {selectedObject && (
            <g transform={objectTransform(selectedObject)}>
              {selectedObject.shape === 'circle' ? (
                <ellipse
                  rx={selectedObject.widthCm / 2 + 7 / s}
                  ry={selectedObject.heightCm / 2 + 7 / s}
                  fill="none"
                  stroke="var(--color-accent)"
                  strokeWidth={1.8 / s}
                  strokeDasharray={`${8 / s} ${6 / s}`}
                  style={{ pointerEvents: 'none' }}
                />
              ) : (
                <rect
                  x={-selectedObject.widthCm / 2 - 7 / s}
                  y={-selectedObject.heightCm / 2 - 7 / s}
                  width={selectedObject.widthCm + 14 / s}
                  height={selectedObject.heightCm + 14 / s}
                  rx={16 / s}
                  fill="none"
                  stroke="var(--color-accent)"
                  strokeWidth={1.8 / s}
                  strokeDasharray={`${8 / s} ${6 / s}`}
                  style={{ pointerEvents: 'none' }}
                />
              )}
              {canEdit &&
                !liveObject &&
                (
                  [
                    [-1, -1],
                    [1, -1],
                    [1, 1],
                    [-1, 1],
                  ] as const
                ).map(([cx, cy]) => (
                  <rect
                    key={`${cx},${cy}`}
                    x={cx * (selectedObject.widthCm / 2 + 7 / s) - handlePx / 2 / s}
                    y={cy * (selectedObject.heightCm / 2 + 7 / s) - handlePx / 2 / s}
                    width={handlePx / s}
                    height={handlePx / s}
                    rx={2 / s}
                    fill="var(--color-neutral-100)"
                    stroke="var(--color-accent)"
                    strokeWidth={1.5 / s}
                    style={{ cursor: cx * cy > 0 ? 'nwse-resize' : 'nesw-resize' }}
                    onPointerDown={handleDown(selectedObject, cx, cy)}
                  />
                ))}
            </g>
          )}

          {ghost && armedKind && (() => {
            const def = kindDef(armedKind)
            if (!def) return null
            const st = objectStyle({ kind: def.kind })
            return (
              <g transform={`translate(${ghost.x} ${ghost.y})`} opacity={0.55} style={{ pointerEvents: 'none' }}>
                {def.shape === 'circle' ? (
                  <circle r={def.widthCm / 2} fill={st.fill} stroke="var(--color-accent)" strokeWidth={2 / s} strokeDasharray={`${7 / s} ${5 / s}`} />
                ) : (
                  <rect
                    x={-def.widthCm / 2}
                    y={-def.heightCm / 2}
                    width={def.widthCm}
                    height={def.heightCm}
                    rx={12}
                    fill={st.fill}
                    stroke="var(--color-accent)"
                    strokeWidth={2 / s}
                    strokeDasharray={`${7 / s} ${5 / s}`}
                  />
                )}
              </g>
            )
          })()}
        </g>
      </svg>
    </div>
  )
})
