import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { DayPicker } from 'react-day-picker'
import { useVirtualizer } from '@tanstack/react-virtual'
import { cn } from '@/lib/utils'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { usePhotoStore } from '../../store/photoStore'
import { buttonVariants } from '@/components/ui/button'

// Fixed month height so snap points are uniform regardless of whether
// a given month lays out as 5 or 6 weeks. Sized to fit the tallest
// (6-week) case: caption ~20px + weekday head ~14px + 6 rows × 24px
// + 5 × 2px row gap = 154px. Plus a few px slack so the last row
// never gets clipped on months that span 6 weeks (e.g. Nov 2025).
const MONTH_HEIGHT = 200

/**
 * Continuous vertical calendar. Renders one DayPicker per month from
 * the earliest booked year up to next month, stacked in a scrollable
 * column. As the user scrolls the Timeline, the calendar
 * programmatically scrolls the matching month into view — so the date
 * picker is always showing "what the user is looking at."
 *
 * Clicking days does nothing here (no range selection); range setting
 * lives in the popover reachable via the Filter button.
 */
export function DateRangePicker({
  from,
  to,
  onFromChange,
  onToChange,
}: {
  from: string | null
  to: string | null
  onFromChange: (v: string | null) => void
  onToChange: (v: string | null) => void
}) {
  const { data: photos = [] } = usePhotosQuery()

  // Photo count per yyyy-mm-dd — drives a GitHub-contribution-style
  // intensity heatmap on the calendar. Slicing the ISO string verbatim
  // avoids timezone drift (Date round-trips would shift photos near
  // midnight into the adjacent day).
  const countByDay = useMemo(() => {
    const m = new Map<string, number>()
    for (const p of photos) {
      if (!p.taken_at) continue
      const key = p.taken_at.slice(0, 10)
      if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) continue
      m.set(key, (m.get(key) ?? 0) + 1)
    }
    return m
  }, [photos])

  // Thresholds picked from the count distribution so the heatmap adapts
  // to the library: a 100k-photo archive and a 200-photo album both get
  // meaningful contrast. Quartiles of non-zero day counts, with a
  // floor so a very flat distribution still buckets into 4 steps.
  const levelThresholds = useMemo(() => {
    const counts = Array.from(countByDay.values()).sort((a, b) => a - b)
    if (counts.length === 0) return [1, 2, 4, 8]
    const q = (p: number) =>
      counts[Math.min(counts.length - 1, Math.floor(counts.length * p))]
    // 4 buckets: [≥1, ≥q50, ≥q75, ≥q95]
    const t1 = 1
    const t2 = Math.max(2, q(0.5))
    const t3 = Math.max(t2 + 1, q(0.75))
    const t4 = Math.max(t3 + 1, q(0.95))
    return [t1, t2, t3, t4]
  }, [countByDay])

  // Oldest year in the cache — drives the top of the month list.
  const currentDate = new Date()
  const earliestYear = useMemo(() => {
    let min = currentDate.getFullYear()
    for (const k of countByDay.keys()) {
      const y = parseInt(k.slice(0, 4), 10)
      if (!Number.isNaN(y) && y < min) min = y
    }
    return min
  }, [countByDay, currentDate])

  // Flat list of yyyy-mm keys from earliest year's January up through
  // (and including) next month, newest first — matches the default
  // timeline sort order (desc), so scroll direction feels consistent.
  const monthKeys = useMemo(() => {
    const end = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
    const start = new Date(earliestYear, 0, 1)
    const out: string[] = []
    const cursor = new Date(end)
    while (cursor >= start) {
      const y = cursor.getFullYear()
      const m = String(cursor.getMonth() + 1).padStart(2, '0')
      out.push(`${y}-${m}`)
      cursor.setMonth(cursor.getMonth() - 1)
    }
    return out
  }, [earliestYear, currentDate])

  // O(1) id→taken_at map so activePhotoId lookups don't linear-scan
  // the whole library on every selection change.
  const takenAtById = useMemo(() => {
    const m = new Map<string, string | null | undefined>()
    for (const p of photos) m.set(p.id, p.taken_at)
    return m
  }, [photos])

  // Month the calendar should currently centre on. Priority:
  //   1. The currently focused photo's taken_at (follows click/arrow
  //      navigation — lets the calendar land on the right month even
  //      before any scroll has happened).
  //   2. The Timeline's published anchor date (live scroll tracking).
  //   3. A picked from/to bound (so opening the picker with a saved
  //      filter doesn't snap back to today).
  //   4. Current month, as a final fallback.
  const visibleAnchorDate = usePhotoStore((s) => s.visibleAnchorDate)
  const activePhotoId = usePhotoStore((s) => s.activePhotoId)
  const activePhotoDate = useMemo(() => {
    if (!activePhotoId) return null
    const iso = takenAtById.get(activePhotoId)?.slice(0, 10)
    return iso && /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : null
  }, [activePhotoId, takenAtById])
  const anchorMonthKey = useMemo(() => {
    const src = activePhotoDate ?? visibleAnchorDate ?? from ?? to
    if (src && /^\d{4}-\d{2}/.test(src)) return src.slice(0, 7)
    return `${currentDate.getFullYear()}-${String(
      currentDate.getMonth() + 1,
    ).padStart(2, '0')}`
  }, [activePhotoDate, visibleAnchorDate, from, to, currentDate])

  // Index-in-monthKeys of the anchor month — needed both to drive the
  // virtualizer's scrollToIndex and as the O(1) lookup for the month
  // list below (avoids a linear indexOf per render).
  const monthIndex = useMemo(() => {
    const m = new Map<string, number>()
    for (let i = 0; i < monthKeys.length; i++) m.set(monthKeys[i], i)
    return m
  }, [monthKeys])

  // Virtualise the month list. Mounting 100+ DayPickers up-front was
  // the dominant cost of opening the calendar; with the viewport fixed
  // to MONTH_HEIGHT we only keep ~1 mounted at any moment (+overscan).
  const scrollRef = useRef<HTMLDivElement | null>(null)
  const virtualizer = useVirtualizer({
    count: monthKeys.length,
    getScrollElement: () => scrollRef.current,
    estimateSize: () => MONTH_HEIGHT,
    overscan: 1,
    getItemKey: (idx) => monthKeys[idx],
  })

  // Jump instantly to the anchor on first mount; smooth-scroll for
  // subsequent moves so a click-through-sidebar feels natural while
  // the initial paint lands on the right month without animation.
  //
  // Only depend on `anchorMonthKey` — `virtualizer` gets a new object
  // identity on most renders, so including it would refire this effect
  // constantly and fight the user's own wheel/drag scroll in the
  // calendar viewport. We read the latest virtualizer via a ref.
  const virtualizerRef = useRef(virtualizer)
  virtualizerRef.current = virtualizer
  const didInitialScrollRef = useRef(false)
  useEffect(() => {
    const idx = monthIndex.get(anchorMonthKey)
    if (idx === undefined) return
    virtualizerRef.current.scrollToIndex(idx, {
      align: 'start',
      behavior: didInitialScrollRef.current ? 'smooth' : 'auto',
    })
    didInitialScrollRef.current = true
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [anchorMonthKey, monthIndex])

  const hasSelection = !!(from || to)

  // Precomputed (time, id) array sorted ascending, so "closest photo
  // to a clicked date" is an O(log n) binary search instead of a
  // linear scan that allocates a Date per photo on every click.
  const photosByDate = useMemo(() => {
    const out: { t: number; id: string }[] = []
    for (const p of photos) {
      if (!p.taken_at) continue
      const d = parseIsoDate(p.taken_at.slice(0, 10)).getTime()
      if (!Number.isFinite(d)) continue
      out.push({ t: d, id: p.id })
    }
    out.sort((a, b) => a.t - b.t)
    return out
  }, [photos])

  // Click a day → find the photo whose taken_at is closest and jump
  // the timeline to it. Empty days still jump to the nearest neighbour
  // so a click never no-ops.
  const jumpToPhoto = usePhotoStore((s) => s.jumpToPhoto)
  // Stable identity so MonthGrid's memo actually skips unchanged months.
  const onDayClick = useCallback(
    (iso: string) => {
      if (photosByDate.length === 0) return
      const target = parseIsoDate(iso).getTime()
      // Binary search for the insertion point.
      let lo = 0
      let hi = photosByDate.length
      while (lo < hi) {
        const mid = (lo + hi) >> 1
        if (photosByDate[mid].t < target) lo = mid + 1
        else hi = mid
      }
      // Compare the two neighbours and pick whichever is closer.
      const prev = photosByDate[lo - 1]
      const next = photosByDate[lo]
      const best =
        !prev ? next
          : !next ? prev
            : target - prev.t <= next.t - target ? prev
              : next
      if (best) jumpToPhoto(best.id)
    },
    [photosByDate, jumpToPhoto]
  )

  return (
    <div className="space-y-1.5">
      {hasSelection && (
        <div className="flex items-center justify-end">
          <button
            onClick={() => {
              onFromChange(null)
              onToChange(null)
            }}
            className="shrink-0 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-text-muted hover:bg-surface-2 hover:text-text"
          >
            Clear
          </button>
        </div>
      )}
      {/* Single-month viewport — container height matches exactly one
       *  month so the previous / next months stay off-screen. Uses
       *  scroll-snap so a wheel tick or drag settles on a whole month
       *  rather than an awkward halfway position. Each month wrapper
       *  is fixed-height (padded to fit the 6-row calendar layout)
       *  so the snap points are evenly spaced. */}
      {/* Virtualised single-month viewport. `overflow: hidden` keeps
       *  the user from scrolling the calendar manually — month-changes
       *  are always driven from outside (timeline scroll / active
       *  photo / filter bounds). Programmatic `scrollToIndex` still
       *  sets scrollTop even when overflow is hidden. */}
      <div
        ref={scrollRef}
        className="overflow-hidden"
        style={{ height: MONTH_HEIGHT }}
      >
        <div
          style={{
            height: virtualizer.getTotalSize(),
            width: '100%',
            position: 'relative',
          }}
        >
          {virtualizer.getVirtualItems().map((vItem) => {
            const k = monthKeys[vItem.index]
            const [y, m] = k.split('-').map(Number)
            return (
              <div
                key={k}
                className="flex items-start justify-center"
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  width: '100%',
                  height: MONTH_HEIGHT,
                  transform: `translateY(${vItem.start}px)`,
                }}
              >
                <MonthGrid
                  year={y}
                  month={m - 1}
                  countByDay={countByDay}
                  levels={levelThresholds}
                  focusedIso={activePhotoDate}
                  fromIso={from}
                  toIso={to}
                  onDayClick={onDayClick}
                />
              </div>
            )
          })}
        </div>
      </div>
    </div>
  )
}

/** Single month calendar — read-only, no nav buttons, no selection
 *  handler. Colours each day by photo count (GitHub-contributions
 *  style) and highlights any day inside the currently-filtered range. */
const MonthGrid = memo(function MonthGrid({
  year,
  month,
  countByDay,
  levels,
  focusedIso,
  fromIso,
  toIso,
  onDayClick,
}: {
  year: number
  month: number
  countByDay: Map<string, number>
  levels: number[]
  focusedIso: string | null
  fromIso: string | null
  toIso: string | null
  onDayClick: (iso: string) => void
}) {
  const from = fromIso ? parseIsoDate(fromIso) : undefined
  const to = toIso ? parseIsoDate(toIso) : undefined
  const monthDate = new Date(year, month, 1)
  const [t1, t2, t3, t4] = levels
  const dayLevel = (d: Date): 0 | 1 | 2 | 3 | 4 => {
    const n = countByDay.get(formatIsoDate(d)) ?? 0
    if (n >= t4) return 4
    if (n >= t3) return 3
    if (n >= t2) return 2
    if (n >= t1) return 1
    return 0
  }
  return (
    <DayPicker
      mode="default"
      month={monthDate}
      disableNavigation
      showOutsideDays={false}
      className="p-0"
      classNames={{
        months: 'flex flex-col',
        month: 'flex flex-col gap-1',
        caption: 'flex items-center justify-center px-1 pt-0.5',
        caption_label: 'text-[11px] font-semibold uppercase tracking-wide text-text',
        nav: 'hidden',
        table: 'w-full border-collapse',
        head_row: 'flex',
        head_cell:
          'text-text-muted rounded w-6 font-normal text-[9px] uppercase tracking-wide',
        row: 'flex w-full mt-0.5',
        cell: 'relative p-0 text-center text-xs',
        day: cn(
          buttonVariants({ variant: 'ghost' }),
          'h-6 w-6 p-0 text-xs font-normal cursor-pointer',
        ),
        day_today: 'text-star ring-1 ring-star/60',
      }}
      modifiers={{
        level1: (d) => dayLevel(d) === 1,
        level2: (d) => dayLevel(d) === 2,
        level3: (d) => dayLevel(d) === 3,
        level4: (d) => dayLevel(d) === 4,
        inRange: (d) => isInRange(d, from, to),
        focused: (d) => !!focusedIso && formatIsoDate(d) === focusedIso,
      }}
      modifiersClassNames={{
        // GitHub-contributions-style intensity shades. Each level tints
        // the cell's background with an increasing primary opacity
        // while keeping text readable.
        level1: 'bg-primary/15 text-text',
        level2: 'bg-primary/30 text-text',
        level3: 'bg-primary/55 text-bg',
        level4: 'bg-primary/85 text-bg font-semibold',
        // Filter-range highlight composes on top with a subtle ring
        // instead of a background so the intensity heatmap stays
        // legible.
        inRange: 'ring-1 ring-primary',
        // Focused day — the taken_at of the currently active photo.
        // Stronger ring + offset so it pops above the range highlight
        // without fighting the heatmap fill underneath.
        focused:
          'ring-2 ring-star ring-offset-1 ring-offset-bg font-semibold',
      }}
      title="Daily photo count"
      onDayClick={(d) => onDayClick(formatIsoDate(d))}
    />
  )
})

function isInRange(d: Date, from?: Date, to?: Date): boolean {
  if (!from && !to) return false
  const t = d.getTime()
  if (from && t < from.getTime()) return false
  if (to && t > to.getTime()) return false
  return true
}

function parseIsoDate(s: string): Date {
  const [y, m, d] = s.split('-').map(Number)
  return new Date(y, (m ?? 1) - 1, d ?? 1)
}

function formatIsoDate(d: Date): string {
  const y = d.getFullYear()
  const m = String(d.getMonth() + 1).padStart(2, '0')
  const day = String(d.getDate()).padStart(2, '0')
  return `${y}-${m}-${day}`
}
