import { useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../store/photoStore'
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer'
import { registerUndoable, useUndoStore } from '../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
import { useBulkPhotoMutations } from './useBulkPhotoMutations'
import { formatApiError } from '../lib/apiError'
import type { Photo } from '../types/photo'

interface KeyboardShortcutsProps {
  onToggleLeftSidebar: () => void
  onToggleRightSidebar: () => void
  /** Returns the first photo id in the current timeline, or null if empty. */
  getFirstPhotoId?: () => string | null
}

interface PhotoUpdate {
  rating?: number
  is_discarded?: boolean
  color_label?: string | null
}

// Spec §6.4 number-key color labels.
const COLOR_LABELS: Record<string, string> = {
  '6': 'red',
  '7': 'orange',
  '8': 'yellow',
  '9': 'green',
}

// Default options shared by every shortcut: preventDefault stops the browser
// from claiming the event (Firefox quick-find on letter keys, Cmd+F search,
// `/` quick-find, Tab focus traversal). enableOnFormTags is left default-off
// so typing in inputs doesn't fire culling shortcuts.
const HK_OPTS = { preventDefault: true } as const

export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
  const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props

  const viewMode = usePhotoStore((s) => s.viewMode)
  const openPreview = usePhotoStore((s) => s.openPreview)
  const closePreview = usePhotoStore((s) => s.closePreview)

  const isPreview = viewMode === 'preview'

  // Photo mutation shared by every culling shortcut. Reads the active photo
  // id from the store at fire time so the closure stays fresh without forcing
  // hotkey re-binding on every selection change.
  const queryClient = useQueryClient()
  const updateMutation = useMutation({
    mutationFn: ({ id, data }: { id: string; data: PhotoUpdate }) =>
      photosApi.update(id, data),
    onSuccess: (_data, vars) => {
      queryClient.invalidateQueries({ queryKey: ['photo', vars.id] })
      queryClient.invalidateQueries({ queryKey: ['photos'] })
    },
  })

  const {
    bulkRating: bulkRatingMutation,
    bulkColor: bulkColorMutation,
  } = useBulkPhotoMutations()

  /** Flip the cached photos to is_discarded=value in every list query
   *  without removing them. Lets the grid grey them in place instead of
   *  reflowing — they stay until a hard reload, which gives the user
   *  visual context for the undo. */
  const markCachedDiscarded = (ids: string[], discarded: boolean) => {
    const set = new Set(ids)
    queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) =>
      prev
        ? prev.map((p) => (set.has(p.id) ? { ...p, is_discarded: discarded } : p))
        : prev
    )
  }

  /** Look up a photo's CURRENT cached state (is_discarded etc) without
   *  triggering a refetch. Walks every ['photos', _] entry first
   *  (timeline lists), then falls back to the per-photo cache. */
  const findCachedPhoto = (id: string): Photo | undefined => {
    const entries = queryClient.getQueriesData<Photo[]>({ queryKey: ['photos'] })
    for (const [, list] of entries) {
      if (!list) continue
      const p = list.find((x) => x.id === id)
      if (p) return p
    }
    return queryClient.getQueryData<Photo>(['photo', id])
  }

  // Discard / restore mutation — the only path that doesn't auto-
  // invalidate ['photos']. Invalidating would refetch with the active
  // filter (which excludes discarded photos in every section except
  // Discarded, and vice-versa) and yank the just-changed photos off
  // the screen. We want the opposite: the photos stay where they are,
  // re-tinted via PhotoThumbnail's `is_discarded` styling, until the
  // user reloads. Same goes for the preview filmstrip.
  const discardMutation = useMutation({
    mutationFn: ({ ids, discarded }: { ids: string[]; discarded: boolean }) =>
      ids.length === 1
        ? photosApi.update(ids[0], { is_discarded: discarded })
        : discarded
          ? photosApi.bulkDiscard(ids)
          : photosApi.bulkRestore(ids),
    onMutate: ({ ids, discarded }) => markCachedDiscarded(ids, discarded),
    onError: (e: any, { ids, discarded }) => {
      // Roll back the optimistic flip.
      markCachedDiscarded(ids, !discarded)
      toast.error(
        discarded ? 'Discard failed' : 'Restore failed',
        formatApiError(e)
      )
    },
    onSuccess: (_data, { ids }) => {
      queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
      ids.forEach((id) =>
        queryClient.invalidateQueries({ queryKey: ['photo', id] })
      )
    },
  })

  // Coalesced undo for rapid X (or U) presses. The mutation itself still
  // fires per-press so the grayscale flip is instant; only the undo
  // registration waits out COALESCE_MS so we emit one toast + one undo
  // entry for a burst rather than N stacked ones.
  const discardBatchRef = useRef<{
    discarded: boolean
    ids: Set<string>
    timer: number | null
  } | null>(null)
  const DISCARD_COALESCE_MS = 1200

  const flushDiscardBatch = () => {
    const batch = discardBatchRef.current
    if (!batch) return
    discardBatchRef.current = null
    const list = Array.from(batch.ids)
    const discarded = batch.discarded
    const verb = discarded ? 'Discarded' : 'Restored'
    registerUndoable(
      `${verb} ${list.length} photo${list.length === 1 ? '' : 's'}`,
      async () => {
        markCachedDiscarded(list, !discarded)
        await (discarded
          ? photosApi.bulkRestore(list)
          : photosApi.bulkDiscard(list))
        queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
        list.forEach((id) =>
          queryClient.invalidateQueries({ queryKey: ['photo', id] })
        )
      }
    )
  }

  const queueDiscardUndo = (ids: string[], discarded: boolean) => {
    const batch = discardBatchRef.current
    if (batch && batch.discarded === discarded) {
      if (batch.timer !== null) window.clearTimeout(batch.timer)
      ids.forEach((id) => batch.ids.add(id))
      batch.timer = window.setTimeout(flushDiscardBatch, DISCARD_COALESCE_MS)
      return
    }
    // Flush any in-flight batch of the opposite direction first so the
    // two actions stay independently undoable.
    if (batch) {
      if (batch.timer !== null) window.clearTimeout(batch.timer)
      flushDiscardBatch()
    }
    discardBatchRef.current = {
      discarded,
      ids: new Set(ids),
      timer: window.setTimeout(flushDiscardBatch, DISCARD_COALESCE_MS),
    }
  }

  /** The set of photo ids the next culling action should apply to.
   *  - Multi-selection → all selected photos
   *  - Single selection → that one photo
   *  - No selection but an activePhotoId set (last clicked) → that one
   *  - Otherwise → empty
   */
  const cullTargets = (): string[] => {
    const state = usePhotoStore.getState()
    if (state.selectedPhotos.length > 0) return state.selectedPhotos
    if (state.activePhotoId) return [state.activePhotoId]
    return []
  }

  /** Apply a partial PhotoUpdate to the cull targets. Picks the right
   *  bulk endpoint when there are 2+ photos so a single API call covers
   *  the whole selection. */
  const updateActive = (data: PhotoUpdate) => {
    const ids = cullTargets()
    if (ids.length === 0) return

    // Discard / restore: leave the photos in place and just flip the
    // tint via the dedicated mutation (no cache strip, no timeline
    // removal). They disappear on hard reload because the section
    // filter excludes them in the wrong direction.
    //
    // Rapid X presses coalesce into a single undo entry + one toast
    // (see queueDiscardUndo below) so hammering the key doesn't stack
    // five toasts and force five Cmd+Z to back out.
    if (data.is_discarded === true || data.is_discarded === false) {
      const discarded = data.is_discarded
      discardMutation.mutate(
        { ids, discarded },
        { onSuccess: () => queueDiscardUndo(ids, discarded) }
      )
      return
    }

    if (ids.length === 1) {
      updateMutation.mutate({ id: ids[0], data })
      return
    }

    // Multi-selection — fan out to the right bulk endpoint per field.
    if (data.rating !== undefined) {
      bulkRatingMutation.mutate({ ids, rating: data.rating })
    }
    if (data.color_label !== undefined) {
      bulkColorMutation.mutate({ ids, color: data.color_label })
    }
  }

  /** X toggles the discard flag based on the FIRST target's current
   *  state — so pressing X on a photo that's already discarded restores
   *  it. Mirrors the way Lightroom's flag-toggle works for selections. */
  const toggleDiscardOnTargets = () => {
    const ids = cullTargets()
    if (ids.length === 0) return
    const first = findCachedPhoto(ids[0])
    const willDiscard = !(first?.is_discarded ?? false)
    updateActive({ is_discarded: willDiscard })
  }

  // S key (Select): toggle the current selection's membership in the active
  // heap. If every selected photo is already a member, remove them; otherwise
  // add the missing ones. No active heap → toast hint.
  const heapMutation = useMutation({
    mutationFn: ({
      heapId,
      photoIds,
      remove,
    }: {
      heapId: string
      photoIds: string[]
      remove: boolean
    }) =>
      remove
        ? heapsApi.removePhotos(heapId, photoIds)
        : heapsApi.addPhotos(heapId, photoIds),
    // Optimistically flip the membership cache so the basket affordance
    // updates instantly and a quick second P press reads the new state
    // (otherwise invalidate-then-refetch leaves a brief stale window).
    onMutate: ({ heapId, photoIds, remove }) => {
      const key = ['heap-photo-ids', heapId] as const
      const previous = queryClient.getQueryData<string[]>(key)
      const set = new Set(previous ?? [])
      if (remove) photoIds.forEach((id) => set.delete(id))
      else photoIds.forEach((id) => set.add(id))
      queryClient.setQueryData<string[]>(key, Array.from(set))
      return { previous }
    },
    onError: (e: any, _vars, ctx) => {
      // Roll back the optimistic update on failure.
      if (ctx?.previous) {
        queryClient.setQueryData(['heap-photo-ids', _vars.heapId], ctx.previous)
      }
      toast.error('Heap update failed', formatApiError(e))
    },
    onSuccess: (data, vars) => {
      const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
        (h) => h.id === vars.heapId
      )
      const heapName = heap?.name ?? 'heap'
      if (vars.remove) {
        const removed = data?.removed ?? 0
        toast.success(`Removed from ${heapName}`, `${removed} photo${removed === 1 ? '' : 's'}`)
      } else {
        const added = data?.added ?? 0
        const already = data?.already_present ?? 0
        if (added > 0) {
          toast.success(
            `Added to ${heapName}`,
            `${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
          )
        }
      }
    },
    onSettled: (_data, _err, vars) => {
      // Re-sync with server truth (heap counts in particular need this).
      queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
      queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
    },
  })

  const togglePickOnSelection = () => {
    const state = usePhotoStore.getState()
    const ids =
      state.selectedPhotos.length > 0
        ? state.selectedPhotos
        : state.activePhotoId
          ? [state.activePhotoId]
          : []
    if (ids.length === 0) {
      toast.info('Nothing selected', 'Select photos first, then press S')
      return
    }
    const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
    const active = heapsList.find((h) => h.is_active)
    if (!active) {
      toast.info('No active heap', 'Set an active heap (target icon next to a heap)')
      return
    }
    // Determine direction: if every selected photo is already a member, this
    // press REMOVES them; otherwise it ADDS the missing ones. Mirrors how
    // Lightroom's flag-toggle works.
    const memberIds =
      queryClient.getQueryData<string[]>(['heap-photo-ids', active.id]) ?? []
    const memberSet = new Set(memberIds)
    const allMembers = ids.every((id) => memberSet.has(id))
    heapMutation.mutate({
      heapId: active.id,
      photoIds: ids,
      remove: allMembers,
    })
  }

  // Toggle sidebars. The right sidebar `i` shortcut is grid-only — in
  // preview mode the PreviewView mounts its own `i` handler for the
  // overlay info panel, and we don't want both to fire.
  useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
  useHotkeys('i', onToggleRightSidebar, { ...HK_OPTS, enabled: !isPreview })

  // Cmd/Ctrl+Z → pop the most recent undoable action and reverse it.
  // Bound at the global level so it works in both grid and preview modes.
  useHotkeys(
    'mod+z',
    async () => {
      const entry = useUndoStore.getState().pop()
      if (!entry) {
        toast.info('Nothing to undo')
        return
      }
      try {
        await entry.undo()
      } catch (e: any) {
        useUndoStore.getState().push({ label: entry.label, undo: entry.undo })
        toast.error('Undo failed', formatApiError(e))
      }
    },
    HK_OPTS
  )

  // Search focus (/ or Cmd/Ctrl+F).
  const focusSearch = () => {
    const el = document.getElementById('topbar-search') as HTMLInputElement | null
    el?.focus()
    el?.select()
  }
  useHotkeys('/', focusSearch, HK_OPTS)
  useHotkeys('mod+f', focusSearch, HK_OPTS)

  // Space toggles the preview view (open from grid, close from preview).
  // Double-click on a thumbnail does the same.
  const openPreviewFromGrid = () => {
    const state = usePhotoStore.getState()
    const id = state.activePhotoId ?? getFirstPhotoId?.() ?? null
    if (!id) return
    // Pass the current visible sequence explicitly so preview navigation
    // walks the order the user actually sees, even if the active photo
    // was selected before the publisher caught up.
    openPreview(id, state.visiblePhotoIds)
  }

  const togglePreview = () => {
    if (isPreview) closePreview()
    else openPreviewFromGrid()
  }

  useHotkeys('space', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])

  // ── Culling shortcuts (work in both grid and preview) ────────────────────

  // Star rating: 1-5 set, 0 clears.
  useHotkeys(
    '1,2,3,4,5',
    (_e, handler) => {
      const rating = parseInt(handler.keys![0])
      if (Number.isFinite(rating)) updateActive({ rating })
    },
    HK_OPTS
  )

  useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)

  // S (Select) is unified with "add to active heap" — Select a photo and
  // you're adding it to the heap you set as active. Toggling on already-
  // selected photos removes them from the heap.
  useHotkeys('s', togglePickOnSelection, HK_OPTS)

  useHotkeys('x', toggleDiscardOnTargets, HK_OPTS)

  useHotkeys('u', () => updateActive({ is_discarded: false }), HK_OPTS)

  // Color labels 6-9 (red/orange/yellow/green per spec §6.4).
  useHotkeys(
    '6,7,8,9',
    (_e, handler) => {
      const label = COLOR_LABELS[handler.keys![0]]
      if (label) updateActive({ color_label: label })
    },
    HK_OPTS
  )

}
