import { useMutation, useQueryClient } from '@tanstack/react-query'
import { photos as photosApi } from '../services/api'
import { toast } from '../components/ToastContainer'
import { formatApiError } from '../lib/apiError'
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
import type { Photo } from '../types/photo'
import type { MemoriesResponse, MemoryPhoto } from '../services/api'

/**
 * Centralised bulk-mutation hook used by both the RightSidebar multi-
 * select panel and the keyboard shortcut layer. Before this existed the
 * two paths each declared their own `bulkRatingMutation` /
 * `bulkColorMutation` pair — identical signatures, slightly different
 * optimistic behaviour, easy to drift.
 *
 * Each mutation:
 *  - Applies an optimistic patch to every cached photo list AND the
 *    per-photo cache so rating stars / color swatches flip instantly.
 *  - Rolls back the patch on error and surfaces a toast.
 *  - Invalidates the photo/library queries on success so server-side
 *    derived fields (needs_review, date_warning, etc.) reconcile.
 *
 * Discard lives elsewhere — its two call sites have deliberately
 * different semantics (keep-in-place for the X hotkey; strip-from-
 * timeline for the sidebar bulk button) so they don't belong here.
 */
export function useBulkPhotoMutations() {
  const queryClient = useQueryClient()

  const invalidatePhotoQueries = () => {
    queryClient.invalidateQueries({ queryKey: ['photo'] })
    queryClient.invalidateQueries({ queryKey: ['photos'] })
    queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
  }

  // Snapshot enough of the current cache to roll back a failed mutation.
  // We only track the photos actually being patched so the snapshot
  // stays O(selection), not O(library).
  const snapshotPhotos = (ids: string[]): Map<string, Partial<Photo>> => {
    const snap = new Map<string, Partial<Photo>>()
    const want = new Set(ids)
    const lists = queryClient.getQueriesData<Photo[]>({ queryKey: ['photos'] })
    for (const [, list] of lists) {
      if (!list) continue
      for (const p of list) if (want.has(p.id)) snap.set(p.id, { ...p })
    }
    for (const id of ids) {
      if (snap.has(id)) continue
      const single = queryClient.getQueryData<Photo>(['photo', id])
      if (single) snap.set(id, { ...single })
    }
    // Fall back to memories cache for photos only surfaced via
    // "On this day" — otherwise a rollback on error can't restore them.
    const mem = queryClient.getQueryData<MemoriesResponse>(['memories'])
    if (mem) {
      for (const g of mem.memories) {
        for (const p of g.photos) {
          if (want.has(p.id) && !snap.has(p.id)) {
            snap.set(p.id, {
              rating: p.rating,
              color_label: p.color_label,
              is_discarded: p.is_discarded,
            } as Partial<Photo>)
          }
        }
      }
    }
    return snap
  }

  // Apply a partial patch to every matching photo in every cached list,
  // plus the per-photo cache. Used as the optimistic-update primitive.
  const patchPhotos = (ids: string[], patch: Partial<Photo>) => {
    const want = new Set(ids)
    queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) =>
      prev ? prev.map((p) => (want.has(p.id) ? { ...p, ...patch } : p)) : prev,
    )
    for (const id of ids) {
      const cur = queryClient.getQueryData<Photo>(['photo', id])
      if (cur) queryClient.setQueryData<Photo>(['photo', id], { ...cur, ...patch })
    }
    // Memories cache has a nested {memories:[{photos:[...]}]} shape —
    // only the fields MemoryPhoto carries (rating/color_label/is_discarded)
    // can be patched in-place, which covers every culling shortcut.
    const memPatch: Partial<MemoryPhoto> = {}
    if (patch.rating !== undefined) memPatch.rating = patch.rating
    if (patch.color_label !== undefined) memPatch.color_label = patch.color_label
    if (patch.is_discarded !== undefined) memPatch.is_discarded = patch.is_discarded
    if (Object.keys(memPatch).length === 0) return
    queryClient.setQueryData<MemoriesResponse>(['memories'], (prev) =>
      prev
        ? {
            ...prev,
            memories: prev.memories.map((g) => ({
              ...g,
              photos: g.photos.map((p) =>
                want.has(p.id) ? { ...p, ...memPatch } : p,
              ),
            })),
          }
        : prev,
    )
  }

  const restoreFromSnapshot = (snap: Map<string, Partial<Photo>>) => {
    const ids = Array.from(snap.keys())
    const want = new Set(ids)
    queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) =>
      prev
        ? prev.map((p) =>
            want.has(p.id) ? ({ ...p, ...snap.get(p.id) } as Photo) : p,
          )
        : prev,
    )
    for (const id of ids) {
      const cur = queryClient.getQueryData<Photo>(['photo', id])
      const orig = snap.get(id)
      if (cur && orig) {
        queryClient.setQueryData<Photo>(['photo', id], { ...cur, ...orig })
      }
    }
    queryClient.setQueryData<MemoriesResponse>(['memories'], (prev) =>
      prev
        ? {
            ...prev,
            memories: prev.memories.map((g) => ({
              ...g,
              photos: g.photos.map((p) => {
                const orig = snap.get(p.id)
                if (!orig) return p
                const out: MemoryPhoto = { ...p }
                if (orig.rating !== undefined) out.rating = orig.rating
                if (orig.color_label !== undefined)
                  out.color_label = orig.color_label ?? null
                if (orig.is_discarded !== undefined)
                  out.is_discarded = orig.is_discarded
                return out
              }),
            })),
          }
        : prev,
    )
  }

  const bulkRating = useMutation({
    mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
      photosApi.bulkSetRating(ids, rating),
    onMutate: ({ ids, rating }) => {
      const snapshot = snapshotPhotos(ids)
      patchPhotos(ids, { rating })
      return { snapshot }
    },
    onError: (e, _vars, ctx) => {
      if (ctx?.snapshot) restoreFromSnapshot(ctx.snapshot)
      toast.error('Rating failed', formatApiError(e))
    },
    onSuccess: invalidatePhotoQueries,
  })

  const bulkColor = useMutation({
    mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
      photosApi.bulkSetColor(ids, color),
    onMutate: ({ ids, color }) => {
      const snapshot = snapshotPhotos(ids)
      patchPhotos(ids, { color_label: color })
      return { snapshot }
    },
    onError: (e, _vars, ctx) => {
      if (ctx?.snapshot) restoreFromSnapshot(ctx.snapshot)
      toast.error('Color label failed', formatApiError(e))
    },
    onSuccess: invalidatePhotoQueries,
  })

  const bulkNotes = useMutation({
    mutationFn: ({ ids, notes }: { ids: string[]; notes: string }) =>
      photosApi.bulkSetNotes(ids, notes),
    onMutate: ({ ids, notes }) => {
      const snapshot = snapshotPhotos(ids)
      // Mirror the server's empty-string -> null collapse so the
      // optimistic cache matches what the API returns.
      patchPhotos(ids, { user_notes: notes || null })
      return { snapshot }
    },
    onError: (e, _vars, ctx) => {
      if (ctx?.snapshot) restoreFromSnapshot(ctx.snapshot)
      toast.error('Notes update failed', formatApiError(e))
    },
    onSuccess: invalidatePhotoQueries,
  })

  // Bulk taken-at: same iso applied to every selected id. Mirror the
  // server's manual-source flip so the badge updates without waiting on
  // the reconcile fetch. has_date_warning isn't patched optimistically —
  // it's recomputed server-side and the post-success invalidate carries
  // it back. Per-photo errors (unsupported EXIF formats) come through
  // the response shape, not as a thrown error, so onError isn't the
  // place to roll back; instead we rely on the invalidate to bring back
  // any rows the server skipped.
  const bulkTakenAt = useMutation({
    mutationFn: ({ ids, iso }: { ids: string[]; iso: string }) =>
      photosApi.bulkSetTakenAt(ids, iso),
    onMutate: ({ ids, iso }) => {
      const snapshot = snapshotPhotos(ids)
      patchPhotos(ids, { taken_at: iso, taken_at_source: 'manual' })
      return { snapshot }
    },
    onError: (e, _vars, ctx) => {
      if (ctx?.snapshot) restoreFromSnapshot(ctx.snapshot)
      toast.error('Date update failed', formatApiError(e))
    },
    onSuccess: invalidatePhotoQueries,
  })

  // Bulk taken-at with a per-photo iso map. Same shape as bulkTakenAt
  // otherwise — patch each row to its own iso, snapshot the lot for
  // rollback, reconcile via invalidate.
  const bulkTakenAtMap = useMutation({
    mutationFn: (map: Record<string, string>) =>
      photosApi.bulkSetTakenAtMap(map),
    onMutate: (map) => {
      const ids = Object.keys(map)
      const snapshot = snapshotPhotos(ids)
      const want = new Set(ids)
      // Per-id patch: can't reuse patchPhotos (single-patch primitive),
      // so walk the same caches inline.
      queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) =>
        prev
          ? prev.map((p) =>
              want.has(p.id)
                ? { ...p, taken_at: map[p.id], taken_at_source: 'manual' }
                : p,
            )
          : prev,
      )
      for (const id of ids) {
        const cur = queryClient.getQueryData<Photo>(['photo', id])
        if (cur) {
          queryClient.setQueryData<Photo>(['photo', id], {
            ...cur,
            taken_at: map[id],
            taken_at_source: 'manual',
          })
        }
      }
      return { snapshot }
    },
    onError: (e, _vars, ctx) => {
      if (ctx?.snapshot) restoreFromSnapshot(ctx.snapshot)
      toast.error('Date update failed', formatApiError(e))
    },
    onSuccess: invalidatePhotoQueries,
  })

  return {
    bulkRating,
    bulkColor,
    bulkNotes,
    bulkTakenAt,
    bulkTakenAtMap,
    invalidatePhotoQueries,
  }
}
