import { memo, useMemo, useRef, useState, useEffect, useCallback } from 'react'
import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { formatApiError } from '../../lib/apiError'
import {
  useDuplicateGroupsQuery,
  DUPLICATE_GROUPS_QUERY_KEY,
} from '../../hooks/useDuplicateGroupsQuery'
import {
  photos as photosApi,
  type DuplicateGroup,
  type DuplicateGroupMember,
} from '../../services/api'
import {
  PhotoThumbnail,
  THUMB_BADGE_BASE,
  THUMB_BADGE_ICON,
  THUMB_BADGE_NEUTRAL,
  THUMB_BADGE_PICK,
} from '../timeline/PhotoThumbnail'
import { usePhotoStore } from '../../store/photoStore'
import { registerUndoable } from '../../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { toast } from '../ToastContainer'
import type { Photo } from '../../types/photo'

/**
 * Sectioned grid view of duplicate clusters. Replaces the old flat
 * "is_duplicate=true" timeline. Each section is one cluster the
 * regroup_duplicates task identified — header on top with a count and a
 * "keep best, discard rest" button, members rendered as PhotoThumbnail
 * cards below.
 *
 * Mounted from App.tsx in place of <Timeline /> when the user is in the
 * duplicates section. Touches no filter store state.
 */
export function DuplicatesView() {
  const { data, isLoading, isError, error } = useDuplicateGroupsQuery()
  const queryClient = useQueryClient()
  const openPreview = usePhotoStore((s) => s.openPreview)
  const selectPhoto = usePhotoStore((s) => s.selectPhoto)
  const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
  const activePhotoId = usePhotoStore((s) => s.activePhotoId)

  // Bulk discard with the same undoable wrapper the timeline uses, so
  // Cmd+Z restores the discarded copies. invalidate ['library', 'duplicates']
  // so the group disappears from the view immediately.
  const discardMutation = useMutation({
    mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
    onSuccess: (_, ids) => {
      registerUndoable(
        `Discarded ${ids.length} duplicate${ids.length === 1 ? '' : 's'}`,
        async () => {
          await photosApi.bulkRestore(ids)
          queryClient.invalidateQueries({ queryKey: ['photos'] })
          queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_KEY })
          queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
        }
      )
      queryClient.invalidateQueries({ queryKey: ['photos'] })
      queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_KEY })
      queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
    },
    onError: (e: any) =>
      toast.error('Discard failed', formatApiError(e)),
  })

  // Hooks below this point must run on every render — rules of hooks
  // forbid early returns above any useState/useEffect/useMemo. The early
  // loading/error/empty branches sit AFTER the hook block.
  const groups = data?.groups ?? []

  // Flat sequence of member ids in visual order. Drives both preview
  // navigation and the in-grid keyboard walker. useMemo so the keyboard
  // effect doesn't tear down on every render.
  const allMemberIds = useMemo(
    () => groups.flatMap((g) => g.members.map((m) => m.id)),
    [groups]
  )
  // Ref mirror so the stable keyboard handler can reach into the latest
  // groups without re-binding the listener on every render.
  const groupsRef = useRef(groups)
  groupsRef.current = groups

  // Track the rendered column count of the duplicates grid so ↑/↓ can
  // skip a row instead of jumping a single cell. The grid uses
  // `repeat(auto-fill, minmax(160px, 1fr))` so columns = floor(width/160).
  // We measure the FIRST section's grid container — every section uses
  // the same auto-fill rule so any one is representative.
  const [columns, setColumns] = useState(4)
  // With virtualisation the first section can unmount on scroll and
  // re-mount on scroll back — track the observer so we can disconnect
  // cleanly every time the ref detaches (previously this leaked).
  const sampleObserverRef = useRef<ResizeObserver | null>(null)
  const sampleGridRef = useCallback((el: HTMLDivElement | null) => {
    sampleObserverRef.current?.disconnect()
    sampleObserverRef.current = null
    if (!el) return
    const measure = () => {
      const cols = Math.max(1, Math.floor(el.clientWidth / 160))
      setColumns(cols)
    }
    measure()
    const ro = new ResizeObserver(measure)
    ro.observe(el)
    sampleObserverRef.current = ro
  }, [])

  // Virtualise the group list. Each group has a variable height
  // (header + dynamic row count), so we let TanStack measure rendered
  // DOM via `measureElement` and use a conservative estimate for
  // unmeasured rows. ~360px handles a 2-row, 180px-cell section + header
  // + group padding without underestimating in most cases.
  const scrollRef = useRef<HTMLDivElement | null>(null)
  const virtualizer = useVirtualizer({
    count: groups.length,
    getScrollElement: () => scrollRef.current,
    estimateSize: () => 360,
    overscan: 2,
    measureElement: (el) => el.getBoundingClientRect().height,
    getItemKey: (idx) => groups[idx].group_id,
  })

  // Window-level keyboard nav. Mirrors Timeline's handler but walks
  // `allMemberIds` directly — duplicate groups don't have a uniform row
  // grid so we approximate ↑/↓ via the measured `columns` count and
  // wrap ←/→ across group boundaries.
  useEffect(() => {
    if (allMemberIds.length === 0) return

    const onKeyDown = (e: KeyboardEvent) => {
      const target = e.target as HTMLElement | null
      if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
        return
      }
      const key = e.key
      if (
        key !== 'ArrowLeft' &&
        key !== 'ArrowRight' &&
        key !== 'ArrowUp' &&
        key !== 'ArrowDown'
      ) {
        return
      }
      e.preventDefault()
      const currentIdx = activePhotoId ? allMemberIds.indexOf(activePhotoId) : -1
      const startIdx = currentIdx >= 0 ? currentIdx : 0
      let nextIdx = startIdx
      if (key === 'ArrowLeft') nextIdx = startIdx - 1
      else if (key === 'ArrowRight') nextIdx = startIdx + 1
      else if (key === 'ArrowUp') nextIdx = startIdx - columns
      else if (key === 'ArrowDown') nextIdx = startIdx + columns
      // Clamp to bounds — we don't wrap on out-of-range vertical moves
      // since the grid is partitioned into groups and a "wrap" would
      // skip across visually unrelated content.
      nextIdx = Math.max(0, Math.min(allMemberIds.length - 1, nextIdx))
      const nextId = allMemberIds[nextIdx]
      if (!nextId) return
      selectPhoto(nextId)
      // With the group list virtualised, the destination cell might not
      // be mounted yet. Scroll its owning group into view first (cheap
      // virtualizer op), then scrollIntoView on the cell once it mounts.
      const currentGroups = groupsRef.current
      const ownerIdx = currentGroups.findIndex((g) =>
        g.members.some((m) => m.id === nextId)
      )
      if (ownerIdx >= 0) virtualizer.scrollToIndex(ownerIdx, { align: 'auto' })
      requestAnimationFrame(() => {
        const el = document.querySelector<HTMLElement>(
          `[data-dup-id="${nextId}"]`
        )
        el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
      })
    }

    window.addEventListener('keydown', onKeyDown)
    return () => window.removeEventListener('keydown', onKeyDown)
  }, [allMemberIds, activePhotoId, columns, selectPhoto, virtualizer])

  // Stable handlers — passed through memoised section + thumbnail so
  // React.memo is actually effective. The `discardMutation.mutate` and
  // store actions have stable identity already; we wrap them once so the
  // closure identity doesn't change per render. Must sit above the early
  // returns below — rules of hooks forbid conditional hook calls.
  const allMemberIdsRef = useRef(allMemberIds)
  allMemberIdsRef.current = allMemberIds
  const handleKeepBest = useCallback(
    (discardIds: string[]) => discardMutation.mutate(discardIds),
    [discardMutation]
  )
  const handlePreviewMember = useCallback(
    (memberId: string) => openPreview(memberId, allMemberIdsRef.current),
    [openPreview]
  )
  const handleSelectMember = useCallback(
    (memberId: string) => selectPhoto(memberId),
    [selectPhoto]
  )

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

  if (isError) {
    return (
      <div className="flex h-full items-center justify-center text-text-muted">
        Could not load duplicate groups: {(error as any)?.message ?? 'unknown error'}
      </div>
    )
  }

  if (groups.length === 0) {
    return (
      <div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-text-muted">
        <Sparkles className="h-8 w-8" />
        <div className="text-sm font-medium text-text">No duplicates found</div>
        <p className="max-w-sm text-xs">
          Nothing in your library matches another photo at the perceptual-hash
          level. If you've just added new photos, give the worker a minute and
          re-run "Re-detect duplicates" from Settings.
        </p>
      </div>
    )
  }

  return (
    <div ref={scrollRef} className="h-full overflow-auto bg-bg p-4 pb-20">
      <div className="mb-4 flex items-center gap-2 text-xs text-text-muted">
        <Info className="h-3.5 w-3.5" />
        <span>
          {data?.total_groups} group{data?.total_groups === 1 ? '' : 's'} ·{' '}
          {data?.total_members} photo{data?.total_members === 1 ? '' : 's'}.
          Click "Keep best" to auto-discard all but the highest-resolution
          copy of each group. Cmd+Z to undo.
        </span>
      </div>

      <div
        style={{
          height: virtualizer.getTotalSize(),
          position: 'relative',
          width: '100%',
        }}
      >
        {virtualizer.getVirtualItems().map((vItem) => {
          const group = groups[vItem.index]
          return (
            <div
              key={group.group_id}
              data-index={vItem.index}
              ref={virtualizer.measureElement}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                transform: `translateY(${vItem.start}px)`,
                paddingBottom: 24,
              }}
            >
              <DuplicateGroupSection
                group={group}
                onKeepBest={handleKeepBest}
                onPreviewMember={handlePreviewMember}
                onSelectMember={handleSelectMember}
                selectedPhotos={selectedPhotos}
                isPending={discardMutation.isPending}
                // First section feeds the column-count sample ref —
                // every section uses the same auto-fill rule.
                gridRef={vItem.index === 0 ? sampleGridRef : undefined}
              />
            </div>
          )
        })}
      </div>
    </div>
  )
}

interface DuplicateGroupSectionProps {
  group: DuplicateGroup
  onKeepBest: (discardIds: string[]) => void
  onPreviewMember: (memberId: string) => void
  onSelectMember: (memberId: string) => void
  selectedPhotos: string[]
  isPending: boolean
  /** Optional callback ref attached to this section's grid container.
   *  Used by DuplicatesView to measure the rendered column count for
   *  ↑/↓ keyboard navigation. Only the first section gets one. */
  gridRef?: (el: HTMLDivElement | null) => void
}

const DuplicateGroupSection = memo(function DuplicateGroupSection({
  group,
  onKeepBest,
  onPreviewMember,
  onSelectMember,
  selectedPhotos,
  isPending,
  gridRef,
}: DuplicateGroupSectionProps) {
  // Auto-pick "best" copy: highest pixel count, ties broken by file_size,
  // then earliest taken_at, then id for determinism. This is just the
  // default — the user can override it by clicking the crown button on
  // any other thumbnail (see `manualBestId`).
  const autoBest = useMemo(() => pickBestMember(group.members), [group.members])
  // When the user clicks "make this the best" on a non-default thumb,
  // we override the auto-pick. Local to the section so different groups
  // remember independent overrides; resets if the group itself changes.
  const [manualBestId, setManualBestId] = useState<string | null>(null)
  const bestId =
    manualBestId && group.members.some((m) => m.id === manualBestId)
      ? manualBestId
      : autoBest.id
  const best = group.members.find((m) => m.id === bestId) ?? autoBest
  const discardCount = group.member_count - 1
  const isExact = group.reason === 'exact'

  // Adapt members to Photo once per group (group.members reference is
  // stable across renders as long as the query data doesn't change),
  // so PhotoThumbnail's memo doesn't re-render on parent tick.
  const photoByMemberId = useMemo(() => {
    const map = new Map<string, Photo>()
    for (const m of group.members) map.set(m.id, memberToPhoto(m))
    return map
  }, [group.members])

  const handleClick = useCallback(
    (p: Photo) => onSelectMember(p.id),
    [onSelectMember]
  )
  const handleDoubleClick = useCallback(
    (p: Photo) => onPreviewMember(p.id),
    [onPreviewMember]
  )
  const selectedSet = useMemo(() => new Set(selectedPhotos), [selectedPhotos])

  return (
    <section className="rounded-lg border border-border bg-surface">
      <header className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
        <div className="flex items-center gap-2 text-sm">
          {isExact ? (
            <Copy className="h-4 w-4 text-text-muted" />
          ) : (
            <Layers className="h-4 w-4 text-text-muted" />
          )}
          <span className="font-medium text-text">
            {group.member_count} {isExact ? 'exact' : 'similar'} photos
          </span>
          <span className="text-xs text-text-faint">
            keeping: {formatDimensions(best)}
            {best.file_size != null && ` · ${formatBytes(best.file_size)}`}
            {manualBestId && manualBestId !== autoBest.id && (
              <span className="ml-1 text-text-muted">(manual)</span>
            )}
          </span>
        </div>
        <Button
          variant="outline"
          size="sm"
          onClick={() => {
            const discardIds = group.members
              .filter((m) => m.id !== best.id)
              .map((m) => m.id)
            if (discardIds.length === 0) return
            onKeepBest(discardIds)
          }}
          disabled={isPending}
          className="hover:border-reject/50 hover:bg-reject/10 hover:text-reject"
          title="Keep the highest-resolution copy and discard the rest"
        >
          <Trash2 className="mr-1.5 h-3 w-3" />
          Keep best, discard {discardCount}
        </Button>
      </header>

      <div
        ref={gridRef}
        className="grid gap-1 p-2"
        style={{
          // Fix both axes so each cell reserves a 160×160 box before
          // its thumbnail finishes loading — otherwise rows collapse to
          // the height of an empty <img> and jump when images arrive,
          // which also throws off the virtualizer's row-height measure.
          gridTemplateColumns: 'repeat(auto-fill, 160px)',
          gridAutoRows: '160px',
        }}
      >
        {group.members.map((member) => {
          const isBest = member.id === best.id
          return (
            <div
              key={member.id}
              data-dup-id={member.id}
              className="group/dup relative"
            >
              <PhotoThumbnail
                photo={photoByMemberId.get(member.id)!}
                size={160}
                fill
                isSelected={selectedSet.has(member.id)}
                onClick={handleClick}
                onDoubleClick={handleDoubleClick}
              />
              {/* BEST pill — top-right, pick-coloured. Composes the same
               *  THUMB_BADGE_* family used by PhotoThumbnail so the full
               *  set of ornaments (selection, rating, flags, BEST) reads
               *  as one consistent chip system. Inset 1.5 (6px) rather
               *  than 1 (4px) because these are SIBLINGS of the thumbnail,
               *  not inside its overflow-hidden box, so they need
               *  clearance from PhotoThumbnail's outer selection ring. */}
              {isBest && (
                <span
                  className={cn(
                    'pointer-events-none absolute right-1.5 top-1.5 z-10 uppercase',
                    THUMB_BADGE_BASE,
                    THUMB_BADGE_PICK
                  )}
                >
                  <Crown className={THUMB_BADGE_ICON} />
                  Best
                </span>
              )}
              {/* "Keep this" — shown on hover for non-best thumbnails.
               *  Mirrors BEST's placement so the eye doesn't retarget
               *  while scanning. Starts neutral and shifts to pick on
               *  hover as a preview of the state it'll set. */}
              {!isBest && (
                <button
                  onClick={(e) => {
                    e.stopPropagation()
                    setManualBestId(member.id)
                  }}
                  className={cn(
                    'absolute right-1.5 top-1.5 z-10 hidden uppercase transition hover:bg-pick group-hover/dup:inline-flex',
                    THUMB_BADGE_BASE,
                    THUMB_BADGE_NEUTRAL
                  )}
                  title="Keep this one instead"
                >
                  <Crown className={THUMB_BADGE_ICON} />
                  Keep this
                </button>
              )}
              {/* Dimensions chip — top-LEFT (Best / Keep this lives
               *  top-right; bottom is reserved for the path strip). */}
              <div
                className={cn(
                  'pointer-events-none absolute left-1.5 top-1.5 z-10 font-mono',
                  THUMB_BADGE_BASE,
                  THUMB_BADGE_NEUTRAL
                )}
              >
                {formatDimensions(member)}
              </div>
              {/* Path strip — full-width across the bottom. Shows the
               *  parent folder name (the actual discriminator when two
               *  copies share the same filename), truncating from the
               *  right if necessary. Full filepath surfaces via the
               *  native tooltip on hover. */}
              <div
                className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 truncate bg-black/65 px-1.5 py-0.5 text-[10px] text-white"
                title={photoByMemberId.get(member.id)!.filepath}
              >
                {duplicatePathLabel(photoByMemberId.get(member.id)!.filepath)}
              </div>
            </div>
          )
        })}
      </div>
    </section>
  )
})

// ── Helpers ──────────────────────────────────────────────────────────────

/** Score a member by (pixels, file_size, -taken_at) and return the winner.
 *  Larger pixel count wins; ties broken by file_size; final tie by earliest
 *  taken_at (more likely the original capture). */
function pickBestMember(members: DuplicateGroupMember[]): DuplicateGroupMember {
  return members.reduce((best, m) => {
    const bestPixels = (best.width ?? 0) * (best.height ?? 0)
    const mPixels = (m.width ?? 0) * (m.height ?? 0)
    if (mPixels !== bestPixels) return mPixels > bestPixels ? m : best
    const bestSize = best.file_size ?? 0
    const mSize = m.file_size ?? 0
    if (mSize !== bestSize) return mSize > bestSize ? m : best
    // Earliest taken_at wins (treat null as far-future).
    const bestTaken = best.taken_at ?? '9999'
    const mTaken = m.taken_at ?? '9999'
    if (mTaken !== bestTaken) return mTaken < bestTaken ? m : best
    return best
  })
}

function formatDimensions(m: DuplicateGroupMember): string {
  if (!m.width || !m.height) return '?'
  const mp = (m.width * m.height) / 1_000_000
  if (mp >= 1) return `${mp.toFixed(1)}MP`
  return `${m.width}×${m.height}`
}

function formatBytes(n: number): string {
  if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`
  if (n >= 1024) return `${(n / 1024).toFixed(0)}KB`
  return `${n}B`
}

/** Short label for the bottom path strip on a duplicate thumbnail.
 *  Goal: tell two copies-with-the-same-filename apart at a glance.
 *  Shows the last two folders ("…/<grandparent>/<parent>") so the
 *  discriminator is visible even when both copies live under the
 *  same parent name (e.g. matching `2023` subfolders under different
 *  archives). The leading "…/" is always present when there's any
 *  truncated prefix; the enclosing strip's `truncate` handles any
 *  excess width. */
function duplicatePathLabel(filepath: string): string {
  if (!filepath) return ''
  const parts = filepath.split('/').filter(Boolean)
  if (parts.length === 0) return filepath
  // Strip the filename (last segment); only the directory chain
  // discriminates between copies with matching basenames.
  const folders = parts.slice(0, -1)
  if (folders.length === 0) return parts[0]
  if (folders.length === 1) return `…/${folders[0]}`
  const parent = folders[folders.length - 1]
  const grandparent = folders[folders.length - 2]
  return `…/${grandparent}/${parent}`
}

/** Adapt a DuplicateGroupMember (the slim API shape) to a Photo, which
 *  is what PhotoThumbnail expects. We deliberately set is_duplicate=false
 *  on the synthetic Photo so the duplicate badge isn't drawn on every
 *  cell — the entire view is duplicates, the badge would be redundant. */
function memberToPhoto(m: DuplicateGroupMember): Photo {
  return {
    id: m.id,
    filepath: m.filepath,
    filename: m.filename,
    media_type: m.media_type,
    width: m.width,
    height: m.height,
    taken_at: m.taken_at,
    rating: 0,
    is_discarded: false,
    is_duplicate: false,
    file_hash: m.file_hash ?? '',
    folder_id: m.folder_id,
    added_at: null,
    thumb_small: m.thumb_small ?? undefined,
  }
}
