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

// Layout constants for the grid + grouped headers.
const THUMBNAIL_SIZE = 200
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,
  rowHeight: number,
  sortBy: string,
  groupBy: string,
): TimelineItem[] {
  if (photos.length === 0) return []

  const items: TimelineItem[] = []

  // Helper: split a flat array of cells into rows of `columns` cells.
  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: rowHeight + GAP,
      })
    }
  }

  // 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,
    })
    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) {
      try {
        label = format(parseISO(dateStr), 'MMMM yyyy')
      } catch {
        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)
  // 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,
    clearSelection,
    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 viewMode = usePhotoStore((s) => s.viewMode)

  // Calculate number of columns + actual cell size based on container
  // width. Treat THUMBNAIL_SIZE as a *minimum* and let cells grow to
  // fill the remaining space, so we never leave a horizontal gap on
  // the right side of the grid.
  //
  // Column math: with N columns there are N-1 inter-cell gaps, so the
  // width needed is N*T + (N-1)*G. Solving for the largest N that fits
  // in the available width gives N = floor((available + G) / (T + G)).
  // The previous formula floor((available) / (T + G)) was off-by-one
  // and lost a whole column whenever the remainder almost fit.
  const { columns, cellSize } = useMemo(() => {
    if (containerWidth === 0) {
      return { columns: 4, cellSize: THUMBNAIL_SIZE }
    }
    // containerWidth here is the sentinel's actual rendered width — no
    // padding subtraction needed, the sentinel already lives inside the
    // padded scroll container.
    const available = containerWidth
    const cols = Math.max(
      1,
      Math.floor((available + GAP) / (THUMBNAIL_SIZE + GAP))
    )
    // Exact float — no floor. cellSize × cols + (cols-1) × gap == available
    // by construction, so the row fills edge-to-edge without any
    // sub-pixel rounding gap.
    const cell = (available - (cols - 1) * GAP) / cols
    return { columns: cols, cellSize: cell }
  }, [containerWidth])

  // 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()

  // 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 — after the user
  // explicitly clears the selection (Escape), we don't re-focus, so
  // the metadata sidebar can collapse and stay collapsed.
  const didAutoFocusRef = useRef(false)
  useEffect(() => {
    if (didAutoFocusRef.current) return
    if (viewMode !== 'grid') return
    if (activePhotoId) {
      didAutoFocusRef.current = true
      return
    }
    if (photos.length === 0) return
    didAutoFocusRef.current = true
    selectPhoto(photos[0].id)
  }, [viewMode, activePhotoId, photos, selectPhoto])

  // 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 { memberIds: activeHeapMembers } = useActiveHeapMembers()

  // 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 a ref at click time so scrolling doesn't rebind the
  // double-click handler.
  const visibleSequenceRef = useRef<string[]>([])
  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').
  const items = useMemo(
    () => buildItems(photos, columns, cellSize, sortBy, groupBy),
    [photos, columns, cellSize, sortBy, groupBy]
  )

  // 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
    for (const item of items) {
      if (item.type === 'header') {
        result.push({ offset: cumulative, label: item.label })
      }
      cumulative += item.height
    }
    return result
  }, [items])

  // Virtual scrolling setup with per-item heights.
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: (index) => items[index]?.height ?? cellSize,
    overscan: 5,
  })

  // Re-measure when items change (column count, group structure).
  useEffect(() => {
    virtualizer.measure()
  }, [items, 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
    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 += item.height
    }
    return out
  }, [items, sortBy, isDateSort])

  // 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 scrollDateLabel = useMemo(() => {
    if (!isDateSort || rowDateIndex.length === 0) return null
    const target = scrollMetrics.top + 20
    let raw: string | null = null
    // Binary search: largest offset ≤ target.
    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
      }
    }
    if (!raw) return null
    try {
      return format(parseISO(raw), 'MMM d, yyyy')
    } catch {
      return null
    }
  }, [rowDateIndex, scrollMetrics.top, isDateSort])

  // 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 sentinel's actual rendered width on mount, window
  // resize, and any layout change driven by the sidebar collapse /
  // right panel toggle. ResizeObserver picks up everything window
  // resize misses (sidebar collapse doesn't fire window resize).
  //
  // Uses a callback ref (not useRef + useEffect) because Timeline
  // early-returns a loading/empty state before the sentinel exists,
  // so a mount-only effect would see a null ref and never install
  // the observer. The callback ref fires whenever the sentinel
  // actually attaches, which is the moment we can measure it.
  const roRef = useRef<ResizeObserver | null>(null)
  const measureElRef = useRef<HTMLDivElement | null>(null)
  const widthSentinelRef = useCallback((el: HTMLDivElement | null) => {
    roRef.current?.disconnect()
    roRef.current = null
    measureElRef.current = el
    if (!el) return
    const measure = () => setContainerWidth(el.clientWidth)
    measure()
    const ro = new ResizeObserver(measure)
    ro.observe(el)
    roRef.current = ro
  }, [])
  useEffect(() => {
    const onResize = () => {
      const el = measureElRef.current
      if (el) setContainerWidth(el.clientWidth)
    }
    window.addEventListener('resize', onResize)
    return () => {
      window.removeEventListener('resize', onResize)
      roRef.current?.disconnect()
      roRef.current = null
    }
  }, [])

  // 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 += items[i].height
    const rowHeight = items[itemIdx].height
    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])

  // 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])

  // 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)
    visibleSequenceRef.current = visibleSequence
  }, [visibleSequence, setVisiblePhotoIds])

  // Locate the active photo in the visual grid. Returns the FIRST
  // (rowIndex, colIndex) where its id appears, since a tag-grouped view
  // can repeat a photo across groups. Returns null when there's no
  // active photo or it isn't currently rendered.
  const findActiveCell = (): { row: number; col: number } | null => {
    if (!activePhotoId) return null
    for (let r = 0; r < photoRows.length; r++) {
      const row = photoRows[r]
      const c = row.cells.findIndex((cell) => cell.photo.id === activePhotoId)
      if (c >= 0) return { row: r, col: c }
    }
    return null
  }

  // Handle keyboard shortcuts for photo navigation. Operates on the
  // grouped grid the user sees, so a half-full last row of a group
  // doesn't make ArrowDown skip into the wrong place.
  //
  // Inert in preview mode — PreviewView mounts its own arrow handlers,
  // and a window-level grid handler firing alongside them used to race
  // against PreviewView's setActivePhoto, landing the user on the wrong
  // photo. The grid handler stays attached so it can re-engage the
  // moment the user closes preview.
  useEffect(() => {
    if (viewMode !== 'grid') return
    // The duplicates section mounts its own grouped view (DuplicatesView)
    // with its own keyboard nav — bail out so we don't double-handle
    // arrow keys and try to navigate against a photoRows snapshot that
    // doesn't match what the user actually sees on screen.
    if (currentSection === 'duplicates') return
    const handleKeyDown = (e: KeyboardEvent) => {
      if (photoRows.length === 0) return
      const target = e.target as HTMLElement | null
      if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
        return
      }

      const move = (dr: number, dc: number) => {
        const current = findActiveCell() ?? { row: 0, col: -1 }
        let nextRow = current.row
        let nextCol = current.col + dc

        if (dc !== 0) {
          // Wrap left/right across row boundaries.
          while (nextCol < 0 && nextRow > 0) {
            nextRow -= 1
            nextCol = photoRows[nextRow].cells.length - 1
          }
          while (
            nextRow < photoRows.length &&
            nextCol >= photoRows[nextRow].cells.length
          ) {
            if (nextRow === photoRows.length - 1) {
              nextCol = photoRows[nextRow].cells.length - 1
              break
            }
            nextRow += 1
            nextCol = 0
          }
          if (nextCol < 0) nextCol = 0
        }

        if (dr !== 0) {
          nextRow += dr
          if (nextRow < 0) nextRow = 0
          if (nextRow >= photoRows.length) nextRow = photoRows.length - 1
          // Clamp the column to the destination row's actual width so
          // moving down into a half-full row lands on its last cell
          // instead of nothing.
          const rowLen = photoRows[nextRow].cells.length
          if (nextCol >= rowLen) nextCol = rowLen - 1
          if (nextCol < 0) nextCol = 0
        }

        const dest = photoRows[nextRow]?.cells[nextCol]
        if (!dest) return
        if (e.shiftKey) {
          selectRange(dest.photo.id)
        } else {
          selectPhoto(dest.photo.id)
        }
        // Bring the destination row into view if it's off-screen, leaving
        // a "peek" margin so the next row above/below stays partly visible
        // — cues the user that there's more content in the scroll direction.
        // In-viewport moves are a no-op, so same-row arrow presses don't
        // jitter the scroll position.
        const itemIdx = photoRowItemIndex[nextRow]
        const scrollEl = parentRef.current
        if (itemIdx !== undefined && scrollEl) {
          // 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 += items[i].height
          const rowHeight = items[itemIdx].height
          const peek = Math.round(cellSize * 0.35)
          const viewTop = scrollEl.scrollTop
          const viewBottom = viewTop + scrollEl.clientHeight
          if (rowTop - peek < viewTop) {
            // Destination is above (or flush with) the viewport top. Leave
            // `peek` pixels of the previous row visible above it.
            scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) })
          } else if (rowTop + rowHeight + peek > viewBottom) {
            // Destination is below the viewport bottom. Leave `peek` pixels
            // of the next row visible below it.
            scrollEl.scrollTo({
              top: rowTop + rowHeight + peek - scrollEl.clientHeight,
            })
          }
        }
      }

      switch (e.key) {
        case 'ArrowUp':
          e.preventDefault()
          move(-1, 0)
          break
        case 'ArrowDown':
          e.preventDefault()
          move(1, 0)
          break
        case 'ArrowLeft':
          e.preventDefault()
          move(0, -1)
          break
        case 'ArrowRight':
          e.preventDefault()
          move(0, 1)
          break
        case 'a':
          if (e.ctrlKey || e.metaKey) {
            e.preventDefault()
            photos.forEach((photo) => {
              if (!selectedPhotos.includes(photo.id)) {
                togglePhotoSelection(photo.id)
              }
            })
          }
          break
        case 'Escape':
          e.preventDefault()
          clearSelection()
          break
      }
    }

    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [viewMode, photoRows, photos, selectedPhotos, activePhotoId, photoRowItemIndex, items, cellSize, currentSection])

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-full">
        <div className="text-text-muted">Loading photos...</div>
      </div>
    )
  }

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

  return (
    <div className="relative h-full">
      {/* 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={clsx(
            '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={parentRef}
        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',
          }}
        >
          {/* Width sentinel — a 1px-tall normal-flow div that takes the
           *  full width of the inner virtualizer wrapper, which is the
           *  exact width rows render at. clientWidth on this is what we
           *  base the column count on, sidestepping any padding /
           *  scrollbar mismatch the parentRef-based measurement is
           *  vulnerable to. ResizeObserver doesn't reliably fire on
           *  zero-area absolute elements, so 1px tall + relative flow. */}
          <div
            ref={widthSentinelRef}
            aria-hidden="true"
            style={{
              width: '100%',
              height: 1,
              marginBottom: -1,
              pointerEvents: 'none',
            }}
          />
          {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)}
                      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 via the upload button, or point the PHOTO_DIRS volume at a folder with existing images.',
      }
  }
}
