import { useEffect, useMemo, useState } from 'react'
import {
  X,
  Star,
  MapPin,
  Camera,
  Aperture,
  ChevronDown,
  ChevronRight,
  ShoppingBasket,
  Trash2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import {
  useQuery,
  useMutation,
  useQueryClient,
  keepPreviousData,
} from '@tanstack/react-query'
import {
  photos as photosApi,
  heaps as heapsApi,
  tags as tagsApi,
} from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { formatApiError } from '../../lib/apiError'
import { toast } from '../ToastContainer'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
  COLOR_LABEL_OPTIONS,
  type ColorLabel,
} from '../../constants/colorLabels'
import { toDatetimeLocalValue } from '../../lib/guessDateFromPath'
import { TagsEditor } from './TagsEditor'
import { TakenAtEditor } from './TakenAtEditor'

export interface PhotoTagSummary {
  id: string
  name: string
  color: string | null
}

export interface PhotoDetails {
  id: string
  filename: string
  filepath: string
  width: number | null
  height: number | null
  file_size: number | null
  taken_at: string | null
  taken_at_source: string | null
  rating: number
  is_discarded: boolean
  user_title: string | null
  user_notes: string | null
  color_label: string | null
  exif_json: string | null
  latitude?: number | null
  longitude?: number | null
  tags?: PhotoTagSummary[]
}

/** Format a signed decimal degree value with the hemisphere letter, e.g.
 *  ``48.12777° N``. Keeps the panel readable without dragging in a heavy
 *  formatting lib. */
function formatLatLon(value: number, axis: 'lat' | 'lon'): string {
  const abs = Math.abs(value).toFixed(5)
  const ref = axis === 'lat' ? (value >= 0 ? 'N' : 'S') : (value >= 0 ? 'E' : 'W')
  return `${abs}° ${ref}`
}

interface ExifData {
  Make?: string
  Model?: string
  LensModel?: string
  Lens?: string
  ISO?: number | string
  FNumber?: number | string
  ApertureValue?: number | string
  ExposureTime?: string
  ShutterSpeedValue?: string
  FocalLength?: string
  FocalLengthIn35mmFormat?: string
  GPSLatitude?: number | string
  GPSLongitude?: number | string
  [key: string]: unknown
}

function formatFileSize(bytes: number | null): string {
  if (bytes == null) return '—'
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}

function formatExifValue(v: unknown): string {
  if (v == null || v === '') return '—'
  return String(v)
}

function pickFirst(exif: ExifData, ...keys: string[]): string {
  for (const k of keys) {
    const v = exif[k]
    if (v != null && v !== '') return String(v)
  }
  return '—'
}

function parseExif(json: string | null): ExifData {
  if (!json) return {}
  try {
    const parsed = JSON.parse(json)
    return typeof parsed === 'object' && parsed !== null ? (parsed as ExifData) : {}
  } catch {
    return {}
  }
}

interface PhotoInfoPanelProps {
  /** The photo to show metadata for. Drives an on-demand detail fetch. */
  photoId: string
  /** When true, the editable text fields (filename, title, notes) render
   *  with a darker theme to read against a black preview backdrop. */
  darkTheme?: boolean
}

/**
 * Reusable single-photo metadata + edit panel. Used by both the grid
 * RightSidebar (when one photo is selected) and the PreviewView's optional
 * info overlay. Self-contained — owns its own queries and mutations.
 */
export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelProps) {
  const queryClient = useQueryClient()

  const [expandedSections, setExpandedSections] = useState<Set<string>>(
    new Set(['metadata', 'camera'])
  )
  const toggleSection = (section: string) => {
    const next = new Set(expandedSections)
    if (next.has(section)) next.delete(section)
    else next.add(section)
    setExpandedSections(next)
  }

  // Fetch the photo's full record (with EXIF) on demand. `keepPreviousData`
  // holds the last photo on screen while the next one loads, so arrow-nav
  // through the preview doesn't flash the "Loading…" placeholder between
  // every neighbour — the panel swaps in place once the new record arrives.
  const { data: photo, isPlaceholderData } = useQuery<PhotoDetails>({
    queryKey: ['photo', photoId],
    queryFn: () => photosApi.get(photoId),
    enabled: !!photoId,
    staleTime: 60_000,
    placeholderData: keepPreviousData,
  })

  // Mutation for any patchable field. The PATCH response is the updated
  // row, which we merge straight into the per-photo cache so the panel
  // paints synchronously — no follow-up GET round-trip before the new
  // value appears. Merge (not replace) because the GET endpoint adds a
  // `tags` field that PATCH doesn't return; replacing would clobber it.
  // Lists and stats are still invalidated so sort order and aggregates
  // reconcile with the server.
  const updateMutation = useMutation({
    mutationFn: (data: {
      filename?: string
      rating?: number
      is_discarded?: boolean
      user_title?: string | null
      user_notes?: string | null
      color_label?: string | null
      taken_at?: string
    }) => photosApi.update(photoId, data),
    onSuccess: (
      updated: Partial<PhotoDetails> & { id: string },
      variables,
    ) => {
      queryClient.setQueryData<PhotoDetails>(['photo', photoId], (prev) =>
        prev ? { ...prev, ...updated } : (updated as PhotoDetails),
      )
      queryClient.setQueriesData<PhotoDetails[]>(
        { queryKey: ['photos'] },
        (prev) =>
          prev
            ? prev.map((p) => (p.id === updated.id ? { ...p, ...updated } : p))
            : prev,
      )
      queryClient.invalidateQueries({ queryKey: ['photos'] })
      queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })

      // Confirmation toast. Cache updates are synchronous now, so without
      // a toast a successful save is invisible (the field shows the new
      // value but it's the same one the user just typed). Pick a label
      // from the changed field; fall back to a count for multi-field
      // patches and a generic message if neither applies.
      const fieldLabels: Record<string, string> = {
        filename: 'Filename updated',
        rating: 'Rating updated',
        color_label: 'Color updated',
        user_title: 'Title updated',
        user_notes: 'Notes updated',
        taken_at: 'Date updated',
      }
      const fields = Object.keys(variables)
      let detail: string
      if (fields.length === 1) {
        const k = fields[0]
        if (k === 'is_discarded') {
          detail = variables.is_discarded ? 'Discarded' : 'Restored'
        } else {
          detail = fieldLabels[k] ?? 'Updated'
        }
      } else if (fields.length > 1) {
        detail = `${fields.length} fields updated`
      } else {
        detail = 'Updated'
      }
      toast.success('Saved', detail)
    },
  })

  // Active heap membership for the Pick toggle button.
  const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
  const isInActiveHeap = activeHeapMembers.has(photoId)

  const heapMutation = useMutation({
    mutationFn: ({ remove }: { remove: boolean }) => {
      if (!activeHeap) return Promise.resolve(null)
      return remove
        ? heapsApi.removePhotos(activeHeap.id, [photoId])
        : heapsApi.addPhotos(activeHeap.id, [photoId])
    },
    onMutate: ({ remove }) => {
      if (!activeHeap) return { previous: undefined }
      const key = ['heap-photo-ids', activeHeap.id] as const
      const previous = queryClient.getQueryData<string[]>(key)
      const set = new Set(previous ?? [])
      if (remove) set.delete(photoId)
      else set.add(photoId)
      queryClient.setQueryData<string[]>(key, Array.from(set))
      return { previous }
    },
    onError: (_e, _vars, ctx) => {
      if (activeHeap && ctx?.previous) {
        queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
      }
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
      if (activeHeap) {
        queryClient.invalidateQueries({
          queryKey: ['heap-photo-ids', activeHeap.id],
        })
      }
    },
  })

  // ── Tags state + mutations ──────────────────────────────────────────
  const { data: allTags = [] } = useTagsQuery()
  const [tagInput, setTagInput] = useState('')

  const invalidateTagsAndPhoto = () => {
    queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
    queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
    queryClient.invalidateQueries({ queryKey: ['photos'] })
    queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
  }

  const addTagMutation = useMutation({
    mutationFn: async (name: string) => {
      const created = await tagsApi.create(name)
      await tagsApi.addToPhoto(photoId, [created.id])
      return created
    },
    onSuccess: () => invalidateTagsAndPhoto(),
    onError: (e: any) =>
      toast.error('Add tag failed', formatApiError(e)),
  })

  const attachExistingTagMutation = useMutation({
    mutationFn: (tagId: string) => tagsApi.addToPhoto(photoId, [tagId]),
    onSuccess: () => invalidateTagsAndPhoto(),
    onError: (e: any) =>
      toast.error('Add tag failed', formatApiError(e)),
  })

  const removeTagMutation = useMutation({
    mutationFn: (tagId: string) => tagsApi.removeFromPhoto(photoId, tagId),
    onSuccess: () => invalidateTagsAndPhoto(),
    onError: (e: any) =>
      toast.error('Remove tag failed', formatApiError(e)),
  })

  // Local drafts for the text fields. Mirror the server value but stay
  // independent while typing so we don't fight focus or clobber edits.
  const [filenameDraft, setFilenameDraft] = useState('')
  const [notesDraft, setNotesDraft] = useState('')
  const [takenAtDraft, setTakenAtDraft] = useState('')

  useEffect(() => {
    setFilenameDraft(photo?.filename ?? '')
    setNotesDraft(photo?.user_notes ?? '')
    setTakenAtDraft(
      photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
    )
  }, [
    photo?.id,
    photo?.filename,
    photo?.user_notes,
    photo?.taken_at,
  ])

  const commitFilename = () => {
    const next = filenameDraft.trim()
    const current = photo?.filename ?? ''
    if (!next || next === current) {
      setFilenameDraft(current)
      return
    }
    if (next.includes('/') || next.includes('\\') || next === '.' || next === '..') {
      toast.error('Invalid filename', 'No path separators allowed')
      setFilenameDraft(current)
      return
    }
    updateMutation.mutate(
      { filename: next },
      {
        onError: (e: any) => {
          toast.error(
            'Rename failed',
            formatApiError(e)
          )
          setFilenameDraft(current)
        },
      }
    )
  }

  const commitNotes = () => {
    const next = notesDraft
    const current = photo?.user_notes ?? ''
    if (next === current) return
    updateMutation.mutate({ user_notes: next || null })
  }

  /** Commit a datetime-local draft back to the server. The backend also
   *  rewrites EXIF on disk, so a failure here rolls the draft back to the
   *  server value — we never want the UI to silently disagree with the
   *  file. An empty string is a no-op because the input's `required` is
   *  off and we don't yet have a "clear date" affordance. */
  const commitTakenAt = (rawValue?: string) => {
    const source = rawValue ?? takenAtDraft
    if (!source) return
    const parsed = new Date(source)
    if (Number.isNaN(parsed.getTime())) {
      toast.error('Invalid date', 'Could not parse the value')
      setTakenAtDraft(
        photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
      )
      return
    }
    const iso = parsed.toISOString()
    if (photo?.taken_at && new Date(photo.taken_at).toISOString() === iso) {
      return
    }
    updateMutation.mutate(
      { taken_at: iso },
      {
        onError: (e: any) => {
          toast.error(
            'Date update failed',
            formatApiError(e)
          )
          setTakenAtDraft(
            photo?.taken_at
              ? toDatetimeLocalValue(new Date(photo.taken_at))
              : ''
          )
        },
      }
    )
  }

  const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])

  if (!photo) {
    return <div className="p-4 text-xs text-text-muted">Loading…</div>
  }

  const rating = photo.rating ?? 0
  const isDiscarded = photo.is_discarded ?? false
  const colorLabel = (photo.color_label ?? null) as ColorLabel | null

  // Single themable input class so the same component reads against either
  // the surface (grid sidebar) or a darker preview overlay.
  const inputClass = cn(
    'w-full rounded border px-2 py-1 text-sm focus:outline-none',
    darkTheme
      ? 'border-white/15 bg-black/40 text-white placeholder-white/40 focus:border-primary'
      : 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
  )
  const monoInputClass = cn(
    'w-full rounded border px-2 py-1 font-mono text-xs focus:outline-none',
    darkTheme
      ? 'border-white/15 bg-black/40 text-white placeholder-white/40 focus:border-primary'
      : 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
  )

  // Parent (RightSidebar) owns the scroll container; this panel is a
  // pair of stacked collapsibles. Metadata holds readonly file/photo
  // facts plus the editable form (separated by a thin <hr>). Camera
  // is isolated at the bottom so a long EXIF block doesn't push the
  // primary form off-screen.
  const hasGps = photo.latitude != null && photo.longitude != null
  return (
    <div
      className={cn(
        'flex flex-col transition-opacity duration-150',
        isPlaceholderData && 'opacity-70'
      )}
    >
      <Collapsible
        open={expandedSections.has('metadata')}
        onOpenChange={() => toggleSection('metadata')}
        className="border-b border-border"
      >
        <CollapsibleTrigger className="flex w-full items-center justify-between border-b border-border bg-surface-2/40 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
          <span>Metadata</span>
          {expandedSections.has('metadata') ? (
            <ChevronDown className="h-3 w-3" />
          ) : (
            <ChevronRight className="h-3 w-3" />
          )}
        </CollapsibleTrigger>
        <CollapsibleContent className="space-y-2 px-3 py-2">
          {/* Readonly: size / dims / path / gps. No sub-headers. */}
          <div className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs">
            <Field label="Size" value={formatFileSize(photo.file_size)} />
            <Field
              label="Dimensions"
              value={
                photo.width && photo.height
                  ? `${photo.width} × ${photo.height}`
                  : '—'
              }
            />
          </div>
          {/* Filepath: full-width mono so it wraps cleanly instead of
           *  stretching the two-column grid. */}
          <div className="text-xs">
            <span className="text-text-muted">Path</span>
            <p
              className="mt-0.5 break-all font-mono text-[11px] text-text"
              title={photo.filepath}
            >
              {photo.filepath
                ? photo.filepath.replace(
                    /^\/?nextcloud-users\/[^/]+\/files\//,
                    '…/',
                  )
                : '—'}
            </p>
          </div>
          {hasGps && (
            <a
              href={`https://www.openstreetmap.org/?mlat=${photo.latitude}&mlon=${photo.longitude}#map=15/${photo.latitude}/${photo.longitude}`}
              target="_blank"
              rel="noreferrer"
              className="flex items-center gap-1.5 text-xs hover:underline"
              title="Open in OpenStreetMap"
            >
              <MapPin className="h-3 w-3 text-text-muted" />
              <span className="font-mono text-text">
                {formatLatLon(photo.latitude!, 'lat')},{' '}
                {formatLatLon(photo.longitude!, 'lon')}
              </span>
            </a>
          )}

          {/* Visual separator between readonly facts and the editable
           *  form — no second collapsible needed. */}
          <hr className="my-1 border-border/60" />

          {/* Editable form. Tighter space-y than before; rating + color
           *  share one row to save vertical space. */}
          <div className="space-y-2">
            <div>
              <label className="mb-1 block text-xs text-text-muted">Filename</label>
              <Input
                type="text"
                value={filenameDraft}
                onChange={(e) => setFilenameDraft(e.target.value)}
                onBlur={commitFilename}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') {
                    e.currentTarget.blur()
                  } else if (e.key === 'Escape') {
                    setFilenameDraft(photo.filename ?? '')
                    e.currentTarget.blur()
                  }
                }}
                className={monoInputClass}
              />
            </div>

            <TakenAtEditor
              photo={photo}
              draft={takenAtDraft}
              onDraftChange={setTakenAtDraft}
              onCommit={commitTakenAt}
              darkTheme={darkTheme}
            />

            <div>
              <label className="mb-1 block text-xs text-text-muted">Notes</label>
              <Textarea
                value={notesDraft}
                onChange={(e) => setNotesDraft(e.target.value)}
                onBlur={commitNotes}
                placeholder="Add notes…"
                rows={2}
                className={cn(inputClass, 'resize-none')}
              />
            </div>

            <div>
              <label className="mb-1 block text-xs text-text-muted">Tags</label>
              <TagsEditor
                photoTags={photo.tags ?? []}
                allTags={allTags}
                tagInput={tagInput}
                onTagInputChange={setTagInput}
                onAttachExisting={(id) => attachExistingTagMutation.mutate(id)}
                onCreateAndAttach={(name) => {
                  addTagMutation.mutate(name)
                  setTagInput('')
                }}
                onRemove={(id) => removeTagMutation.mutate(id)}
              />
            </div>

            {/* Rating + Color share a row. Both are compact controls
             *  (5 stars / 4 swatches), so the panel doesn't feel cramped
             *  and we recover a row of vertical space. */}
            <div className="flex items-start gap-4">
              <div>
                <label className="mb-1 block text-xs text-text-muted">Rating</label>
                <div className="flex gap-0.5">
                  {[1, 2, 3, 4, 5].map((value) => (
                    <button
                      key={value}
                      onClick={() =>
                        updateMutation.mutate({ rating: rating === value ? 0 : value })
                      }
                      className="p-0.5"
                      title={`Set rating to ${value}`}
                    >
                      <Star
                        className={cn(
                          'h-4 w-4 transition-colors',
                          value <= rating
                            ? 'fill-star text-star'
                            : 'text-text-muted hover:text-star'
                        )}
                      />
                    </button>
                  ))}
                </div>
              </div>
              <div>
                <label className="mb-1 block text-xs text-text-muted">Color</label>
                <div className="flex items-center gap-1.5">
                  {COLOR_LABEL_OPTIONS.map(({ value, className }) => {
                    const active = colorLabel === value
                    return (
                      <button
                        key={value}
                        onClick={() =>
                          updateMutation.mutate({ color_label: active ? null : value })
                        }
                        className={cn(
                          'h-4 w-4 rounded-full ring-offset-2 ring-offset-surface transition-all',
                          className,
                          active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
                        )}
                        title={value}
                      />
                    )
                  })}
                  {colorLabel && (
                    <button
                      onClick={() => updateMutation.mutate({ color_label: null })}
                      className="ml-0.5 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
                      title="Clear color label"
                    >
                      <X className="h-3 w-3" />
                    </button>
                  )}
                </div>
              </div>
            </div>

            <div>
              <label className="mb-1 block text-xs text-text-muted">Flag</label>
              <div className="flex gap-1.5">
                <button
                  onClick={() => {
                    if (!activeHeap) return
                    heapMutation.mutate({ remove: isInActiveHeap })
                  }}
                  disabled={!activeHeap || heapMutation.isPending}
                  className={cn(
                    'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50',
                    isInActiveHeap
                      ? 'bg-pick/20 text-pick'
                      : 'bg-surface-2 text-text-muted hover:bg-surface-offset'
                  )}
                  title={
                    activeHeap
                      ? isInActiveHeap
                        ? `Remove from "${activeHeap.name}"`
                        : `Add to "${activeHeap.name}"`
                      : 'Set an active heap first'
                  }
                >
                  <ShoppingBasket className="h-3 w-3" />
                  {isInActiveHeap ? 'Selected' : 'Select'}
                </button>
                <button
                  onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
                  className={cn(
                    'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
                    isDiscarded
                      ? 'bg-reject/20 text-reject'
                      : 'bg-surface-2 text-text-muted hover:bg-surface-offset'
                  )}
                >
                  <Trash2 className="h-3 w-3" />
                  Discard
                </button>
              </div>
            </div>
          </div>
        </CollapsibleContent>
      </Collapsible>

      {/* Camera — its own collapsible at the bottom so the EXIF block
       *  doesn't crowd the primary metadata + edit form. */}
      <Collapsible
        open={expandedSections.has('camera')}
        onOpenChange={() => toggleSection('camera')}
        className="border-b border-border"
      >
        <CollapsibleTrigger className="flex w-full items-center justify-between border-b border-border bg-surface-2/40 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
          <span>Camera</span>
          {expandedSections.has('camera') ? (
            <ChevronDown className="h-3 w-3" />
          ) : (
            <ChevronRight className="h-3 w-3" />
          )}
        </CollapsibleTrigger>
        <CollapsibleContent className="space-y-1.5 px-3 py-2 text-xs">
          <div className="flex items-center gap-2">
            <Camera className="h-3 w-3 text-text-muted" />
            <span className="text-text">
              {pickFirst(exif, 'Make', 'Model') === '—'
                ? '—'
                : `${formatExifValue(exif.Make)} ${formatExifValue(exif.Model)}`.trim()}
            </span>
          </div>
          <div className="flex items-center gap-2">
            <Aperture className="h-3 w-3 text-text-muted" />
            <span className="text-text">
              {pickFirst(exif, 'LensModel', 'Lens')}
            </span>
          </div>
          <div className="grid grid-cols-2 gap-x-2 gap-y-1">
            <Field label="ISO" value={formatExifValue(exif.ISO)} />
            <Field
              label="Aperture"
              value={
                exif.FNumber
                  ? `f/${exif.FNumber}`
                  : pickFirst(exif, 'ApertureValue')
              }
            />
            <Field
              label="Shutter"
              value={pickFirst(exif, 'ExposureTime', 'ShutterSpeedValue')}
            />
            <Field
              label="Focal"
              value={pickFirst(exif, 'FocalLength', 'FocalLengthIn35mmFormat')}
            />
          </div>
        </CollapsibleContent>
      </Collapsible>
    </div>
  )
}

function Field({ label, value }: { label: string; value: string }) {
  return (
    <div>
      <span className="text-text-muted">{label}:</span>
      <p className="break-words text-text">{value}</p>
    </div>
  )
}

