import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { format } from 'date-fns'
import { cn } from '@/lib/utils'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
import { useViewSettingsStore } from '../../store/viewSettingsStore'
import { PhotoThumbnail } from './PhotoThumbnail'
import { usePhotosQuery, usePhotosLoadingMore } from '../../hooks/usePhotosQuery'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
import { Button } from '@/components/ui/button'
import { ImageOff, Loader2 } from 'lucide-react'
import type { Photo } from '../../types/photo'

// Layout constants for the grid + grouped headers. The cell size is
// user-configurable via the view-settings store; everything else is
// fixed.
const GAP = 4
const PADDING = 16
const HEADER_HEIGHT = 36

interface PhotoCell {
  photo: Photo
  globalIndex: number
}

type TimelineItem =
  | { type: 'header'; key: string; label: string; height: number }
  | { type: 'row'; key: string; cells: PhotoCell[]; height: number }

/**
 * Build the flat header|row item array the virtualizer renders.
 *
 * Two modes:
 *  - sortBy is a date field: month buckets.
 *  - otherwise: one un-headered stream.
 *
 * Tag, rating, and color grouping now live in their own dedicated views
 * (TagsView, RatedView, ColorsView) instead of being handled here.
 */
function buildItems(
  photos: Photo[],
  columns: number,
  sortBy: string,
  groupBy: string,
): TimelineItem[] {
  if (photos.length === 0) return []

  const items: TimelineItem[] = []

  // Row items carry a placeholder `height: 0` — the virtualizer reads
  // the live cellSize off a ref at render time (see `estimateSize`),
  // so resizing the main column doesn't force items to rebuild.
  const pushRowsForGroup = (groupKey: string, cells: PhotoCell[]) => {
    for (let i = 0; i < cells.length; i += columns) {
      const slice = cells.slice(i, i + columns)
      items.push({
        type: 'row',
        key: `${groupKey}::row::${i}`,
        cells: slice,
        height: 0,
      })
    }
  }

  // Date grouping only applies when groupBy is explicitly 'date' and
  // the sort field is a date column. Other sections (tags, colors,
  // rated, people) reuse Timeline for their detail views and should
  // render a flat grid without month headers.
  const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'

  if (!isDateSort || groupBy !== 'date') {
    // No grouping — one row stream.
    const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({
      photo,
      globalIndex,
    }))
    pushRowsForGroup('all', cells)
    return items
  }

  // Walk photos in order, breaking into groups whenever the month label changes.
  let currentLabel: string | null = null
  let bucket: PhotoCell[] = []
  let bucketIndex = 0

  const flushBucket = () => {
    if (bucket.length === 0 || currentLabel === null) return
    items.push({
      type: 'header',
      key: `header::${bucketIndex}::${currentLabel}`,
      label: currentLabel,
      height: HEADER_HEIGHT,
    })
    // Sort the current month's photos ascending (day 1 → day 31) so
    // each month reads chronologically start-to-end regardless of the
    // global sortOrder. Month buckets themselves stay in the order the
    // backend returned them (honours `sortOrder` for the month-level
    // axis). Stable on equal timestamps via the globalIndex tiebreak.
    bucket.sort((a, b) => {
      const aKey =
        (sortBy === 'taken_at'
          ? a.photo.taken_at
          : a.photo.added_at ?? a.photo.taken_at) ?? ''
      const bKey =
        (sortBy === 'taken_at'
          ? b.photo.taken_at
          : b.photo.added_at ?? b.photo.taken_at) ?? ''
      if (aKey === bKey) return a.globalIndex - b.globalIndex
      return aKey < bKey ? -1 : 1
    })
    pushRowsForGroup(`${bucketIndex}::${currentLabel}`, bucket)
    bucketIndex++
    bucket = []
  }

  photos.forEach((photo, globalIndex) => {
    const dateStr =
      sortBy === 'taken_at' ? photo.taken_at : photo.added_at ?? photo.taken_at
    let label: string
    if (dateStr) {
      // Same slice-first strategy the scroll-date chip uses — the
      // date portion is trusted verbatim so local timezones can't
      // drag an early/late-day photo into the wrong month bucket.
      const iso = dateStr.slice(0, 10)
      const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso)
      if (match) {
        const y = Number(match[1])
        const mth = Number(match[2])
        const day = Number(match[3])
        try {
          label = format(new Date(y, mth - 1, day), 'MMMM yyyy')
        } catch {
          label = 'Unknown date'
        }
      } else {
        label = 'Unknown date'
      }
    } else {
      label = 'Unknown date'
    }
    if (label !== currentLabel) {
      flushBucket()
      currentLabel = label
    }
    bucket.push({ photo, globalIndex })
  })
  flushBucket()

  return items
}

export function Timeline() {
  const parentRef = useRef<HTMLDivElement | null>(null)
  // Sentinel placed inside the inner virtualizer wrapper at the exact
  // position rows will render. We measure THIS instead of parentRef,
  // because parentRef has padding and we'd otherwise have to subtract
  // it (and account for any scrollbar) — easy to get wrong by a pixel
  // and end up with a column count off by one.
  const [containerWidth, setContainerWidth] = useState(0)

  const {
    selectedPhotos,
    activePhotoId,
    selectPhoto,
    togglePhotoSelection,
    selectRange,
    openPreview,
  } = usePhotoStore()
  // Pulled via a focused selector so the publisher subscription doesn't
  // re-render Timeline on every unrelated photo store change.
  const setVisiblePhotoIds = usePhotoStore((s) => s.setVisiblePhotoIds)

  const sortBy = useFilterStore((s) => s.sortBy)
  const groupBy = useFilterStore((s) => s.groupBy)
  const currentSection = useFilterStore((s) => s.currentSection)
  const flag = useFilterStore((s) => s.flag)
  const filterHeapId = useFilterStore((s) => s.heapId)
  const viewMode = usePhotoStore((s) => s.viewMode)
  const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize)

  // Number of columns that fit at the user-configured thumbnail size.
  // Cells don't stretch to absorb the remaining width — we prefer a
  // stable cell size across sidebar-open / sidebar-closed states over
  // a perfectly-flush right edge. A small gap may remain on the right
  // when containerWidth isn't an integer multiple of (T+G).
  const { columns, cellSize } = useMemo(() => {
    if (containerWidth === 0) {
      return { columns: 4, cellSize: thumbnailSize }
    }
    const cols = Math.max(
      1,
      Math.floor((containerWidth + GAP) / (thumbnailSize + GAP))
    )
    return { columns: cols, cellSize: thumbnailSize }
  }, [containerWidth, thumbnailSize])

  // Shared photos query — both Timeline and PreviewView use the same hook so
  // they share one cache entry, regardless of filter state.
  const { data: photos = [], isLoading } = usePhotosQuery()
  const isLoadingMore = usePhotosLoadingMore()

  // Tracks the previous viewMode so the "preview just closed" scroll
  // effect (defined further down, after photoRows) only fires on the
  // actual transition rather than every items[] recomputation.
  const prevViewModeRef = useRef(viewMode)

  // Auto-focus the first photo on initial grid load so arrow-key nav
  // works immediately without a pre-click. One-shot per section — after
  // the user explicitly clears the selection (Escape), we don't
  // re-focus within the same section, so the metadata sidebar can
  // collapse and stay collapsed. Reset by the section-change effect
  // below so switching views (e.g. All → Discarded) re-focuses.
  const didAutoFocusRef = useRef(false)
  // visibleSequence isn't in scope yet (derived below from items). Read
  // it through a ref so this effect can focus the first *visually*
  // ordered photo — matters now that months sort ascending while the
  // photos array from the backend is still globally desc, i.e. the
  // top-left cell the user actually sees isn't photos[0] anymore.
  const visibleSequenceRef = useRef<string[]>([])
  useEffect(() => {
    if (didAutoFocusRef.current) return
    if (viewMode !== 'grid') return
    if (activePhotoId) {
      didAutoFocusRef.current = true
      return
    }
    const firstVisible = visibleSequenceRef.current[0]
    if (!firstVisible) return
    didAutoFocusRef.current = true
    selectPhoto(firstVisible)
  }, [viewMode, activePhotoId, photos, selectPhoto])

  // On section switch (e.g. All Photos → Discarded) or fresh mount,
  // reset the one-shot guard and clear the stale active photo so the
  // effect above picks the first photo of the new view once its query
  // resolves. The previous section's activePhotoId almost never
  // belongs to the new section — carrying it over leaves the metadata
  // sidebar showing something that isn't even in the visible grid.
  // Ref starts null so the first mount after a Duplicates/Memories
  // detour also trips this path.
  const clearSelection = usePhotoStore((s) => s.clearSelection)
  const prevSectionRef = useRef<string | null>(null)
  useEffect(() => {
    if (prevSectionRef.current === currentSection) return
    prevSectionRef.current = currentSection
    didAutoFocusRef.current = false
    clearSelection()
    // Reset scroll to the top of the new section. Without this the
    // previous section's scroll offset (which can be tens of thousands
    // of px in a long timeline) carries over to a freshly-loaded
    // folder, leaving the user looking at empty space below the last
    // row. The auto-focus effect below selects photos[0] on next
    // render, but its "ensure visible" scroll only kicks in if the
    // cell is out of view — by then the user has already seen the
    // wrong scroll position. Doing this synchronously here keeps the
    // first paint of the new section anchored at the top.
    if (parentRef.current) {
      parentRef.current.scrollTop = 0
    }
  }, [currentSection, clearSelection])

  // Membership in the active heap (drives the green tint on each
  // thumbnail). Subscribed once at this level so we don't have hundreds
  // of thumbnails each subscribing to the same query.
  const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
  // When the view is already filtered to a specific attribute, every
  // thumbnail would carry the same indicator — redundant visual noise.
  // Pass these down so PhotoThumbnail can skip the tint in those cases.
  const hideDiscardedTint = flag === 'discarded'
  const hideActiveHeapTint =
    filterHeapId != null && activeHeap != null && filterHeapId === activeHeap.id

  // Stable cell handlers. PhotoThumbnail is wrapped in React.memo so
  // identity-stable callbacks let it skip re-render on unrelated store
  // churn (e.g. heap membership invalidation). visibleSequence is read
  // through the ref declared above at click time so scrolling doesn't
  // rebind the double-click handler.
  const handleCellClick = useCallback(
    (photo: Photo, e: React.MouseEvent) => {
      if (e.shiftKey) selectRange(photo.id)
      else if (e.ctrlKey || e.metaKey) togglePhotoSelection(photo.id)
      else selectPhoto(photo.id)
    },
    [selectRange, togglePhotoSelection, selectPhoto],
  )
  const handleCellDoubleClick = useCallback(
    (photo: Photo) => {
      openPreview(photo.id, visibleSequenceRef.current)
    },
    [openPreview],
  )

  // Build the flat virtualizer items: a mix of group headers and rows of
  // photos. Date headers appear only in the main timeline (groupBy='date').
  //
  // Notably NOT dependent on `cellSize` — row heights are read off a ref
  // at virtualizer-measurement time instead. This stops `items` (and
  // every downstream memo) from rebuilding on every sub-pixel tick of
  // the sidebar CSS transition, which used to cause visible jank in
  // timelines with thousands of photos.
  const items = useMemo(
    () => buildItems(photos, columns, sortBy, groupBy),
    [photos, columns, sortBy, groupBy]
  )

  // Live cell size, consumed by the virtualizer's estimateSize so row
  // heights stay accurate as the column fluidly resizes.
  const cellSizeRef = useRef(cellSize)
  cellSizeRef.current = cellSize

  // Row items carry a placeholder height of 0 (see buildItems). Any
  // code walking `items` for scroll offsets must resolve it against the
  // current cellSize; headers carry their fixed height verbatim.
  const effectiveHeight = (it: TimelineItem) =>
    it.type === 'header' ? it.height : cellSizeRef.current + GAP

  // Used by the sticky header, the row-date index, and the floating
  // scrollbar chip — all three only make sense in date-sorted views.
  const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'

  // Pre-computed offset of every header in the virtualizer's coordinate
  // space, used to drive the sticky-header overlay below.
  const headerOffsets = useMemo(() => {
    const result: { offset: number; label: string }[] = []
    let cumulative = 0
    // Row items carry a placeholder height of 0 (see buildItems) so
    // that the items array stays stable across cellSize changes. We
    // resolve it here against the live cellSize so header offsets line
    // up with the virtualizer's coordinate space.
    const rowH = cellSize + GAP
    for (const item of items) {
      if (item.type === 'header') {
        result.push({ offset: cumulative, label: item.label })
        cumulative += item.height
      } else {
        cumulative += rowH
      }
    }
    return result
  }, [items, cellSize])

  // Virtual scrolling setup with per-item heights.
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: (index) => {
      const it = items[index]
      if (!it) return cellSizeRef.current + GAP
      // Row items carry `height: 0` as a placeholder — always resolved
      // to the current cellSize via the ref. Headers carry their own
      // fixed height.
      return it.type === 'header' ? it.height : cellSizeRef.current + GAP
    },
    overscan: 5,
  })

  // Re-measure when items change (column count, group structure) or
  // cellSize shifts (sidebar collapse, window resize). Cheap — it just
  // walks the item list and recomputes heights.
  useEffect(() => {
    virtualizer.measure()
  }, [items, cellSize, virtualizer])

  // Track scroll position so we can (a) show the current group label as
  // a pinned overlay at the top of the scroll container and (b) drive
  // the floating "date next to the scrollbar" indicator. The virtualizer's
  // items use transform translateY (so CSS position: sticky doesn't work
  // on the inline headers); the overlays sidestep that by living outside
  // the virtualizer's positioned children.
  //
  // We capture clientHeight + scrollHeight alongside top so the floating
  // indicator can position itself proportionally to scroll progress
  // without needing a second observer to react to viewport resizes.
  // `isScrolling` is a short-lived flag reset by a debounced timer —
  // drives the fade in/out of the date chip so it only appears while
  // the user is actively dragging the scrollbar / wheel-scrolling.
  const [scrollMetrics, setScrollMetrics] = useState({
    top: 0,
    clientHeight: 0,
    scrollHeight: 0,
  })
  const [isScrolling, setIsScrolling] = useState(false)
  const scrollIdleTimerRef = useRef<number | null>(null)
  useEffect(() => {
    const el = parentRef.current
    if (!el) return
    const capture = () => {
      setScrollMetrics({
        top: el.scrollTop,
        clientHeight: el.clientHeight,
        scrollHeight: el.scrollHeight,
      })
    }
    capture()
    const onScroll = () => {
      capture()
      setIsScrolling(true)
      if (scrollIdleTimerRef.current !== null) {
        window.clearTimeout(scrollIdleTimerRef.current)
      }
      scrollIdleTimerRef.current = window.setTimeout(() => {
        setIsScrolling(false)
        scrollIdleTimerRef.current = null
      }, 700)
    }
    el.addEventListener('scroll', onScroll, { passive: true })
    return () => {
      el.removeEventListener('scroll', onScroll)
      if (scrollIdleTimerRef.current !== null) {
        window.clearTimeout(scrollIdleTimerRef.current)
        scrollIdleTimerRef.current = null
      }
    }
    // Depend on isLoading + photo presence so the effect re-attaches
    // after the early-return loading/empty JSX gives way to the real
    // scroll container — on first mount parentRef.current is null and
    // an empty-deps effect would never bind the listener.
  }, [isLoading, photos.length])

  // Alias for the sticky-header math below, which only needs the scroll
  // offset. Kept as a local so the existing logic reads the same as
  // before the metrics refactor.
  const scrollTop = scrollMetrics.top

  // Parallel index of every row's (cumulative offset, raw date string),
  // built from the same flat items array the virtualizer reads. Used by
  // the floating scrollbar-date chip to answer "what date is the user
  // currently looking at" in O(log n) without reaching into the
  // virtualizer's internals. Rebuilt whenever items or sort field change.
  const rowDateIndex = useMemo(() => {
    if (!isDateSort) return [] as { offset: number; raw: string | null }[]
    const out: { offset: number; raw: string | null }[] = []
    let cum = 0
    // Row items carry a placeholder `height: 0` (resolved at render
    // time from cellSize via effectiveHeight). Sum the live height here
    // so cumulative offsets match the virtualizer's coordinate space —
    // otherwise every row lands at offset 0 and the scroll chip /
    // sidebar calendar freeze on the last photo regardless of scroll.
    const rowH = cellSize + GAP
    for (const item of items) {
      if (item.type === 'row') {
        const first = item.cells[0]?.photo
        const raw =
          sortBy === 'added_at'
            ? first?.added_at ?? first?.taken_at ?? null
            : first?.taken_at ?? null
        out.push({ offset: cum, raw })
        cum += rowH
      } else {
        cum += item.height
      }
    }
    return out
  }, [items, sortBy, isDateSort, cellSize])

  // Date shown in the floating chip next to the scrollbar. Finds the
  // deepest row whose offset is at/above the viewport top (plus a
  // small lookahead so fast flicks feel responsive) and formats its
  // photo's capture date. Null when not date-sorted, not scrolling, or
  // when the topmost row has no date — we'd rather hide the chip than
  // show an unhelpful "Unknown".
  const scrollRaw = useMemo(() => {
    if (!isDateSort || rowDateIndex.length === 0) return null
    const target = scrollMetrics.top + 20
    let raw: string | null = null
    let lo = 0
    let hi = rowDateIndex.length - 1
    while (lo <= hi) {
      const mid = (lo + hi) >> 1
      if (rowDateIndex[mid].offset <= target) {
        raw = rowDateIndex[mid].raw
        lo = mid + 1
      } else {
        hi = mid - 1
      }
    }
    return raw
  }, [rowDateIndex, scrollMetrics.top, isDateSort])
  const scrollDateLabel = useMemo(() => {
    if (!scrollRaw) return null
    // Take the date portion straight from the ISO string and build a
    // local-midnight Date for formatting — avoids parseISO's timezone
    // conversion shifting the displayed day.
    const iso = scrollRaw.slice(0, 10)
    if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null
    const [y, m, d] = iso.split('-').map(Number)
    try {
      return format(new Date(y, m - 1, d), 'MMM d, yyyy')
    } catch {
      return null
    }
  }, [scrollRaw])

  // Publish the anchor date (yyyy-mm-dd) so the left-sidebar Date
  // picker can track which month the user is scrolled to. The leading
  // 10 chars of the ISO string are taken verbatim — no Date round-trip
  // — so timezones can't shift the visible day by one.
  const setVisibleAnchorDate = usePhotoStore((s) => s.setVisibleAnchorDate)
  useEffect(() => {
    if (!scrollRaw) {
      setVisibleAnchorDate(null)
      return
    }
    const iso = scrollRaw.slice(0, 10)
    setVisibleAnchorDate(/^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : null)
  }, [scrollRaw, setVisibleAnchorDate])

  // Vertical position of the floating chip in scroll-viewport pixels.
  // Tracks the scrollbar thumb's position by mapping scroll progress
  // linearly onto the viewport height. Clamped so the chip never
  // overflows the top/bottom padding even at scroll extremes.
  const scrollIndicatorTop = useMemo(() => {
    const { top, clientHeight, scrollHeight } = scrollMetrics
    if (scrollHeight <= clientHeight) return 8
    const ratio = top / (scrollHeight - clientHeight)
    const chipHeight = 28
    const usable = clientHeight - chipHeight - 16
    return 8 + Math.max(0, Math.min(usable, ratio * usable))
  }, [scrollMetrics])

  // Find the latest header whose BOTTOM is above the viewport top. That's
  // the group whose natural in-grid header has scrolled out of view —
  // exactly the case where we want to pin the label as a sticky overlay.
  // If the natural header is still visible (scrolled but not yet past),
  // we return null and let the in-grid label do the work, avoiding the
  // duplicate-label flash.
  const stickyLabel = useMemo(() => {
    if (headerOffsets.length === 0) return null
    let current: string | null = null
    for (const h of headerOffsets) {
      if (h.offset + HEADER_HEIGHT <= scrollTop) current = h.label
      else break
    }
    return current
  }, [headerOffsets, scrollTop])

  // Measure the scroll container directly and subtract its padding to
  // get the real grid width. Observing the scroll container itself (vs
  // a sentinel child) reliably fires on every sidebar toggle and
  // window resize — no edge cases with zero-height observers or
  // early-returned loading states detaching the sentinel.
  const roRef = useRef<ResizeObserver | null>(null)
  const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
    roRef.current?.disconnect()
    roRef.current = null
    parentRef.current = el
    if (!el) return
    const measure = () => {
      const style = window.getComputedStyle(el)
      const padX =
        parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
      setContainerWidth(Math.max(0, el.clientWidth - padX))
    }
    measure()
    const ro = new ResizeObserver(measure)
    ro.observe(el)
    roRef.current = ro
  }, [])
  useEffect(() => () => roRef.current?.disconnect(), [])

  // Photo rows in visual order — drops the header items so navigation
  // walks the grid as the user sees it. Each row has cells of length
  // [1..columns], the last row of a group can be short, and a single
  // photo with multiple tags will appear in multiple rows.
  const photoRows = useMemo(
    () => items.filter((it): it is Extract<TimelineItem, { type: 'row' }> => it.type === 'row'),
    [items]
  )

  // Parallel array: photoRows[i] corresponds to items[photoRowItemIndex[i]].
  // Lets keyboard nav jump the virtualizer to the destination row even when
  // it hasn't been rendered yet (beyond the overscan window).
  const photoRowItemIndex = useMemo(() => {
    const map: number[] = []
    items.forEach((it, idx) => {
      if (it.type === 'row') map.push(idx)
    })
    return map
  }, [items])

  // After the preview closes, scroll the photo it was originally
  // opened on back into view. The store's closePreview already
  // restored activePhotoId to that origin id; we just need to make
  // sure it's actually visible in the scroll viewport. Guarded by
  // prevViewModeRef so this only fires on the actual preview→grid
  // transition, not every time photoRows recomputes.
  useEffect(() => {
    const prev = prevViewModeRef.current
    prevViewModeRef.current = viewMode
    if (prev !== 'preview' || viewMode !== 'grid') return
    if (!activePhotoId) return
    let rowIdx = -1
    for (let r = 0; r < photoRows.length; r++) {
      if (photoRows[r].cells.some((c) => c.photo.id === activePhotoId)) {
        rowIdx = r
        break
      }
    }
    if (rowIdx < 0) return
    const itemIdx = photoRowItemIndex[rowIdx]
    const scrollEl = parentRef.current
    if (itemIdx === undefined || !scrollEl) return
    let rowTop = 0
    for (let i = 0; i < itemIdx; i++) rowTop += effectiveHeight(items[i])
    const rowHeight = effectiveHeight(items[itemIdx])
    const viewTop = scrollEl.scrollTop
    const viewBottom = viewTop + scrollEl.clientHeight
    if (rowTop >= viewTop && rowTop + rowHeight <= viewBottom) return
    // Center the row in the viewport — the user is returning to a
    // specific photo, not resuming a scroll, so context above and
    // below is what they want.
    const target = Math.max(
      0,
      rowTop - scrollEl.clientHeight / 2 + rowHeight / 2
    )
    scrollEl.scrollTo({ top: target })
  }, [viewMode, activePhotoId, photoRows, photoRowItemIndex, items])

  // Handle explicit jump requests from outside the grid (e.g. the
  // sidebar calendar clicking a date). Same centring math as the
  // preview-close path but uses smooth scroll so the jump is visually
  // connected to the click, and always fires regardless of viewport
  // state. Clears the target after scrolling so the next click on the
  // same id still fires a fresh scroll.
  const jumpTargetId = usePhotoStore((s) => s.jumpTargetId)
  const clearJumpTarget = usePhotoStore((s) => s.clearJumpTarget)
  useEffect(() => {
    if (!jumpTargetId) return
    if (viewMode !== 'grid') return
    let rowIdx = -1
    for (let r = 0; r < photoRows.length; r++) {
      if (photoRows[r].cells.some((c) => c.photo.id === jumpTargetId)) {
        rowIdx = r
        break
      }
    }
    if (rowIdx < 0) {
      clearJumpTarget()
      return
    }
    const itemIdx = photoRowItemIndex[rowIdx]
    const scrollEl = parentRef.current
    if (itemIdx === undefined || !scrollEl) {
      clearJumpTarget()
      return
    }
    let rowTop = 0
    for (let i = 0; i < itemIdx; i++) rowTop += effectiveHeight(items[i])
    const rowHeight = effectiveHeight(items[itemIdx])
    const target = Math.max(
      0,
      rowTop - scrollEl.clientHeight / 2 + rowHeight / 2,
    )
    scrollEl.scrollTo({ top: target, behavior: 'smooth' })
    clearJumpTarget()
  }, [jumpTargetId, viewMode, photoRows, photoRowItemIndex, items, clearJumpTarget])

  // Flat visible-order id sequence — exactly the order the user reads
  // off the grid (top-to-bottom, left-to-right within each row).
  // Includes duplicates from tag-grouping; landing on the same photo's
  // "second" appearance in the next tag bucket is the right behavior
  // in tag mode.
  const visibleSequence = useMemo(() => {
    const ids: string[] = []
    for (const row of photoRows) {
      for (const cell of row.cells) {
        ids.push(cell.photo.id)
      }
    }
    return ids
  }, [photoRows])

  // Mirror visibleSequence into a ref synchronously so the auto-focus
  // effect (which runs earlier and references this ref) sees the
  // current sequence on the same render the data loads. Updating the
  // ref inside an effect would lag by one render.
  visibleSequenceRef.current = visibleSequence

  // Publish to the photo store so PreviewView's arrow nav and filmstrip
  // can walk the same order even when opened from a non-click path
  // (e.g. the global Space hotkey).
  useEffect(() => {
    setVisiblePhotoIds(visibleSequence)
  }, [visibleSequence, setVisiblePhotoIds])

  // Keyboard grid nav (arrows / Ctrl+A / Escape) is shared with
  // MemoriesView via useGridKeyNav. Timeline provides its own scroll
  // callback because row geometry comes from the TanStack virtualizer's
  // item heights; MemoriesView reads DOM offsets instead.
  //
  // Inert in preview mode (PreviewView has its own arrow handlers) and
  // in the duplicates section (DuplicatesView manages its own grouped
  // nav against a different snapshot of the visible grid).
  const gridNavRows = useMemo(
    () =>
      photoRows.map((row) => ({
        cells: row.cells.map((c) => ({ id: c.photo.id })),
      })),
    [photoRows]
  )
  const scrollRowIntoView = useCallback(
    (rowIdx: number) => {
      const itemIdx = photoRowItemIndex[rowIdx]
      const scrollEl = parentRef.current
      if (itemIdx === undefined || !scrollEl) return
      // Sum item heights up to itemIdx to get this row's offset in the
      // virtualizer's coordinate space. Cheap enough at O(items) and
      // avoids reaching into virtualizer.measurementsCache internals.
      let rowTop = 0
      for (let i = 0; i < itemIdx; i++) rowTop += effectiveHeight(items[i])
      const rowHeight = effectiveHeight(items[itemIdx])
      const peek = Math.round(cellSize * 0.35)
      const viewTop = scrollEl.scrollTop
      const viewBottom = viewTop + scrollEl.clientHeight
      if (rowTop - peek < viewTop) {
        scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) })
      } else if (rowTop + rowHeight + peek > viewBottom) {
        scrollEl.scrollTo({
          top: rowTop + rowHeight + peek - scrollEl.clientHeight,
        })
      }
    },
    [photoRowItemIndex, items, cellSize]
  )
  useGridKeyNav({
    rows: gridNavRows,
    enabled: viewMode === 'grid' && currentSection !== 'duplicates',
    scrollRowIntoView,
  })

  if (isLoading) {
    return (
      <div className="flex h-full items-center justify-center gap-2 text-text-muted">
        <Loader2 className="h-4 w-4 animate-spin" />
        Loading photos…
      </div>
    )
  }

  if (photos.length === 0) {
    return <EmptyTimelineState />
  }

  return (
    <div
      className="relative h-full"
      role="grid"
      aria-label="Photos"
      aria-rowcount={photoRows.length}
    >
      {/* Sticky group-header overlay. Lives outside the virtualizer's
       *  positioned children so it isn't affected by translateY transforms.
       *  Updates as the user scrolls past month boundaries. */}
      {stickyLabel && (
        <div className="pointer-events-none absolute left-0 right-0 top-0 z-20 border-b-2 border-border bg-bg/95 px-4 py-1.5 shadow-sm backdrop-blur">
          <h3 className="text-sm font-semibold uppercase tracking-wide text-text">
            {stickyLabel}
          </h3>
        </div>
      )}

      {/* Floating scrollbar date chip. Sits just inside the right edge,
       *  vertically tracking the scrollbar thumb's position, and fades
       *  out 700ms after the user stops scrolling. Read-only/decorative,
       *  so pointer-events-none — it must never steal scrollbar clicks. */}
      {isDateSort && scrollDateLabel && (
        <div
          className={cn(
            'pointer-events-none absolute right-4 z-30 rounded-md border border-border bg-surface/95 px-2.5 py-1 text-xs font-semibold text-text shadow-lg backdrop-blur transition-opacity duration-200',
            isScrolling ? 'opacity-100' : 'opacity-0'
          )}
          style={{ top: `${scrollIndicatorTop}px` }}
        >
          {scrollDateLabel}
        </div>
      )}

      <div
        ref={scrollContainerRef}
        className="h-full overflow-auto bg-bg"
        // Extra bottom padding so the last row clears the floating
        // KeyboardHints pill (which sits at bottom-4, ~40px tall).
        style={{ padding: `${PADDING}px`, paddingBottom: `${PADDING + 64}px` }}
      >
        <div
          style={{
            height: `${virtualizer.getTotalSize()}px`,
            width: '100%',
            position: 'relative',
          }}
        >
          {/* Absolute-positioned so layout doesn't jitter when pages flip. */}
          {isLoadingMore && photos.length > 0 && (
            <div
              className="pointer-events-none absolute left-0 right-0 flex items-center justify-center gap-2 py-4 text-xs text-text-muted"
              style={{ top: `${virtualizer.getTotalSize()}px` }}
            >
              <Loader2 className="h-3.5 w-3.5 animate-spin" />
              Loading more photos…
            </div>
          )}
          {virtualizer.getVirtualItems().map((virtualItem) => {
            const item = items[virtualItem.index]
            if (!item) return null

            if (item.type === 'header') {
              return (
                <div
                  key={virtualItem.key}
                  style={{
                    position: 'absolute',
                    top: 0,
                    left: 0,
                    width: '100%',
                    height: `${virtualItem.size}px`,
                    transform: `translateY(${virtualItem.start}px)`,
                  }}
                  className="flex items-end pb-1"
                >
                  <h3 className="text-sm font-semibold uppercase tracking-wide text-text">
                    {item.label}
                  </h3>
                </div>
              )
            }

            // row
            return (
              <div
                key={virtualItem.key}
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  width: '100%',
                  height: `${virtualItem.size}px`,
                  transform: `translateY(${virtualItem.start}px)`,
                }}
              >
                <div
                  style={{
                    // Fixed-size grid: every track is exactly cellSize
                    // wide and the row is exactly cellSize tall, so
                    // cells are guaranteed square no matter what CSS
                    // the cell contents bring along. cellSize was
                    // already computed from `available / cols` so the
                    // sum cols*cellSize + (cols-1)*gap equals the
                    // container width to within sub-pixel rounding.
                    display: 'grid',
                    gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
                    gridAutoRows: `${cellSize}px`,
                    gap: `${GAP}px`,
                  }}
                >
                  {item.cells.map(({ photo }) => (
                    <PhotoThumbnail
                      key={photo.id}
                      photo={photo}
                      size={cellSize}
                      fill
                      isSelected={selectedPhotos.includes(photo.id)}
                      isInActiveHeap={activeHeapMembers.has(photo.id)}
                      hideDiscardedTint={hideDiscardedTint}
                      hideActiveHeapTint={hideActiveHeapTint}
                      onClick={handleCellClick}
                      onDoubleClick={handleCellDoubleClick}
                    />
                  ))}
                </div>
              </div>
            )
          })}
        </div>
      </div>
    </div>
  )
}

/**
 * Empty-state rendered when the current filter/section returns zero photos.
 * Distinguishes "library-empty" from "filters-too-strict": the former hints
 * at upload, the latter offers a one-click Clear all.
 */
function EmptyTimelineState() {
  const filterState = useFilterStore()
  const clearAll = useFilterStore((s) => s.clearAll)
  const currentSection = useFilterStore((s) => s.currentSection)
  const filtersActive = hasActiveFilters(filterState)

  const { title, hint } = sectionEmptyCopy(currentSection, filtersActive)

  return (
    <div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
      <ImageOff className="h-10 w-10 text-text-muted/40" />
      <div className="text-sm font-medium text-text">{title}</div>
      <p className="max-w-sm text-xs text-text-muted">{hint}</p>
      {filtersActive && (
        <Button variant="outline" size="sm" onClick={clearAll} className="mt-1">
          Clear all filters
        </Button>
      )}
    </div>
  )
}

function sectionEmptyCopy(
  section: string,
  filtersActive: boolean,
): { title: string; hint: string } {
  if (filtersActive) {
    return {
      title: 'No photos match',
      hint: 'Your filters are excluding everything in this section. Clear them to see the full library.',
    }
  }
  switch (section) {
    case 'discarded':
      return {
        title: 'Discard pile is empty',
        hint: 'Photos you discard (X) land here until you empty them permanently.',
      }
    case 'rated':
      return {
        title: 'No rated photos yet',
        hint: 'Rate photos 1–5 with the number keys and they will appear here.',
      }
    case 'tags':
      return {
        title: 'No tagged photos',
        hint: 'Add tags from a photo\u2019s metadata panel or via the bulk tag editor.',
      }
    default:
      return {
        title: 'Library is empty',
        hint: 'Add photos through Nextcloud, or point the PHOTO_DIRS volume at a folder with existing images.',
      }
  }
}
