import { useEffect, useMemo, useState } from 'react'
import {
  Caption1,
  Text,
  Input,
  Button,
  Checkbox,
  Switch,
  Spinner,
  makeStyles,
  mergeClasses,
  tokens,
  shorthands
} from '@fluentui/react-components'
import {
  AddRegular,
  ArrowSyncRegular,
  DeleteRegular,
  ArrowDownloadRegular,
  ChevronDownRegular,
  ChevronRightRegular,
  AppsListRegular,
  VideoClipMultipleRegular,
  AlertRegular,
  LibraryRegular,
  DismissRegular
} from '@fluentui/react-icons'
import type { MediaItem, Source, MediaKind } from '@shared/ipc'
import { useSources } from '../store/sources'
import { useSettings } from '../store/settings'
import { useProfiles } from '../store/profiles'
import { Select } from '../components/Select'
import { useNav } from '../store/nav'
import { useClipboardLink, looksLikeSingleVideo } from '../lib/useClipboardLink'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { selectUndownloaded } from '../store/librarySelect'
import { thumbUrl } from '../lib/thumb'
import { relTime } from '../lib/datetime'
import { MediaThumb } from '../components/MediaThumb'
import { VirtualList } from '../components/VirtualList'
import { Hint } from '../components/Hint'
import { ScreenHeader, useScreenStyles } from '../components/ui/Screen'
import { StatusChip } from '../components/ui/StatusChip'
import { SegmentedControl } from '../components/ui/SegmentedControl'
import { EmptyState } from '../components/ui/EmptyState'
import { LinkSuggestion } from '../components/ui/LinkSuggestion'
import { SourceRetention } from '../components/SourceRetention'
import { useFocusStyles } from '../components/ui/focusRing'
import { useTextStyles } from '../components/ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from '../components/ui/tokens'
import { THUMB_XS } from '../lib/thumbSizes'

/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
type ItemStatus = DownloadStatus | 'pending'

/**
 * A flattened row for the virtualized item list: either a playlist header or a
 * single video. Flattening the groups into one list lets a 1000s-video source
 * window cleanly (one virtualizer over a flat array, headers included).
 */
type LibRow =
  { kind: 'header'; title: string; items: MediaItem[] } | { kind: 'item'; item: MediaItem }

/** Above this many flattened rows, the item list switches to a virtualized panel. */
const VIRTUALIZE_AT = 100

// When a URL appears more than once in the queue ("Download anyway" duplicates),
// the library row should reflect the most meaningful state: a finished copy wins,
// then anything in flight, then terminal failures (M17).
const STATUS_PRIORITY: Record<DownloadStatus, number> = {
  completed: 6,
  downloading: 5,
  queued: 4,
  saved: 3,
  paused: 2,
  error: 1,
  canceled: 0
}

const useStyles = makeStyles({
  root: { display: 'flex', flexDirection: 'column', gap: SPACE.section },
  addRow: { display: 'flex', gap: '8px' },
  addInput: { flexGrow: 1 },
  toolbar: {
    display: 'flex',
    alignItems: 'center',
    gap: '14px',
    flexWrap: 'wrap',
    paddingTop: '2px'
  },
  toolbarSpacer: { flexGrow: 1 },
  switchRow: {
    display: 'flex',
    alignItems: 'center',
    gap: '2px',
    color: tokens.colorNeutralForeground2
  },
  progress: {
    display: 'flex',
    alignItems: 'center',
    gap: '8px',
    color: tokens.colorNeutralForeground2,
    fontSize: tokens.fontSizeBase200
  },
  error: { color: tokens.colorPaletteRedForeground1 },
  list: { display: 'flex', flexDirection: 'column', gap: '10px' },
  card: {
    border: `1px solid ${tokens.colorNeutralStroke2}`,
    ...shorthands.borderRadius(tokens.borderRadiusLarge),
    backgroundColor: tokens.colorNeutralBackground1,
    overflow: 'hidden'
  },
  // Row wrapping the expand button + an optional trailing action (e.g. "Download
  // new"). The action is a sibling of the button, not nested inside it (nested
  // <button>s are invalid), so both stay independently focusable.
  cardHeadRow: {
    display: 'flex',
    alignItems: 'center'
  },
  cardHeadAction: {
    flexShrink: 0,
    paddingRight: SPACE.cozy
  },
  cardHead: {
    display: 'flex',
    alignItems: 'center',
    gap: '12px',
    // List-row padding tier (UI3): cozy on both axes (was 12/14).
    padding: SPACE.cozy,
    // Fill the row, but shrink to make room for the trailing action (minWidth:0
    // lets the title ellipsize instead of pushing the action off the edge).
    flexGrow: 1,
    minWidth: 0,
    border: 'none',
    backgroundColor: 'transparent',
    // A native <button> doesn't inherit color; set it so the chevron (currentColor)
    // matches the page foreground in dark mode instead of UA ButtonText.
    color: tokens.colorNeutralForeground1,
    fontFamily: tokens.fontFamilyBase,
    textAlign: 'left',
    cursor: 'pointer',
    ':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
  },
  srcIcon: {
    width: '34px',
    height: '34px',
    flexShrink: 0,
    borderRadius: tokens.borderRadiusMedium,
    backgroundColor: tokens.colorBrandBackground2,
    color: tokens.colorBrandForeground2,
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    // Source tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
    fontSize: `${ICON.control}px`
  },
  srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
  srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
  srcTitle: { fontWeight: tokens.fontWeightSemibold, color: tokens.colorNeutralForeground1 },
  watchBadge: {
    flexShrink: 0,
    fontSize: tokens.fontSizeBase100,
    padding: '0 7px',
    ...shorthands.borderRadius(tokens.borderRadiusCircular),
    backgroundColor: tokens.colorBrandBackground2,
    color: tokens.colorBrandForeground2
  },
  detail: {
    borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
    // List-row padding tier (UI3): cozy on both axes (was 12/14).
    padding: SPACE.cozy,
    display: 'flex',
    flexDirection: 'column',
    gap: '12px'
  },
  actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
  actionSpacer: { flexGrow: 1 },
  groupHead: {
    display: 'flex',
    alignItems: 'center',
    gap: '8px',
    padding: '2px 4px'
  },
  groupToggle: {
    display: 'flex',
    alignItems: 'center',
    gap: '8px',
    flexGrow: 1,
    minWidth: 0,
    padding: '4px 4px',
    border: 'none',
    backgroundColor: 'transparent',
    color: tokens.colorNeutralForeground2,
    cursor: 'pointer',
    fontFamily: tokens.fontFamilyBase,
    fontSize: tokens.fontSizeBase300,
    textAlign: 'left',
    ...shorthands.borderRadius(tokens.borderRadiusMedium),
    ':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
  },
  groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
  row: {
    display: 'flex',
    alignItems: 'center',
    gap: '10px',
    padding: '5px 4px 5px 18px'
  },
  plainList: { display: 'flex', flexDirection: 'column' },
  rowThumb: {
    width: `${THUMB_XS.w}px`,
    height: `${THUMB_XS.h}px`,
    // One thumbnail radius app-wide (UI6): control tier / Medium.
    ...shorthands.borderRadius(RADIUS.control)
  },
  rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }
})

/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[] }[] {
  const map = new Map<string, MediaItem[]>()
  for (const it of items) {
    const arr = map.get(it.playlistTitle) ?? []
    arr.push(it)
    map.set(it.playlistTitle, arr)
  }
  const groups = [...map.entries()].map(([title, its]) => ({
    title,
    items: [...its].sort((a, b) => a.playlistIndex - b.playlistIndex)
  }))
  groups.sort(
    (a, b) =>
      (a.title === 'Uploads' ? 1 : 0) - (b.title === 'Uploads' ? 1 : 0) ||
      a.title.localeCompare(b.title)
  )
  return groups
}

export function LibraryView(): React.JSX.Element {
  const styles = useStyles()
  const screen = useScreenStyles()
  const focus = useFocusStyles()
  const text = useTextStyles()
  const sources = useSources((s) => s.sources)
  const itemsBySource = useSources((s) => s.itemsBySource)
  const selectedSourceId = useSources((s) => s.selectedSourceId)
  const indexing = useSources((s) => s.indexing)
  const selectSource = useSources((s) => s.selectSource)
  const indexSource = useSources((s) => s.indexSource)
  const reindexSource = useSources((s) => s.reindexSource)
  const cancelIndexing = useSources((s) => s.cancelIndexing)
  const removeSource = useSources((s) => s.removeSource)
  const enqueueItems = useSources((s) => s.enqueueItems)
  const ensureSourceItems = useSources((s) => s.ensureSourceItems)
  const setWatched = useSources((s) => s.setWatched)
  const setProfile = useSources((s) => s.setProfile)
  const profiles = useProfiles((s) => s.profiles)
  const syncWatched = useSources((s) => s.syncWatched)
  const syncing = useSources((s) => s.syncing)
  const scheduled = useSources((s) => s.scheduledSyncEnabled)
  const loadScheduledSync = useSources((s) => s.loadScheduledSync)
  const setScheduledSync = useSources((s) => s.setScheduledSync)
  const commitSyncTime = useSources((s) => s.commitSyncTime)
  const downloadItems = useDownloads((s) => s.items)
  const autoDownloadNew = useSettings((s) => s.autoDownloadNew)
  const syncTime = useSettings((s) => s.syncTime)
  const updateSettings = useSettings((s) => s.update)

  const [url, setUrl] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null)

  // A channel/playlist URL handed over from the Downloads bar (UX3): pre-fill the
  // add field with it once, so the user lands here ready to add it.
  const pendingLibraryUrl = useNav((s) => s.pendingLibraryUrl)
  const consumeLibraryUrl = useNav((s) => s.consumeLibraryUrl)
  useEffect(() => {
    if (pendingLibraryUrl === null) return
    const handed = consumeLibraryUrl()
    if (handed) setUrl(handed)
  }, [pendingLibraryUrl, consumeLibraryUrl])

  // Reset any pending Remove confirmation when the expanded source changes so a
  // stale confirm can't reappear after navigating between sources.
  useEffect(() => {
    setConfirmRemoveId(null)
  }, [selectedSourceId])
  // Offer a freshly-copied link the way the Downloads tab does, but skip single
  // videos -- a library source is a channel/playlist to sync, not a one-off.
  const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u))
  const [selected, setSelected] = useState<Set<string>>(new Set())
  // Per-batch video/audio choice for queueing items, seeded from the global
  // default so behavior is unchanged until the user flips it (M27).
  const [enqueueKind, setEnqueueKind] = useState<MediaKind>(
    () => useSettings.getState().defaultKind
  )
  const [batchNote, setBatchNote] = useState<string | null>(null)
  const [syncNote, setSyncNote] = useState<string | null>(null)
  // The source whose per-row "Download new" is currently loading items + queuing,
  // so its button shows a busy state (the action is async: it may fetch a
  // collapsed source's items before enqueuing).
  const [busySourceId, setBusySourceId] = useState<string | null>(null)
  // Which playlist groups are expanded. Empty = all collapsed (the default), so
  // an expanded source shows just its group headers until one is opened.
  const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())

  const watchedCount = sources.filter((s) => s.watched).length

  // Load the current scheduled-sync (Task Scheduler) state once.
  useEffect(() => {
    loadScheduledSync()
  }, [loadScheduledSync])

  async function onCheckNew(): Promise<void> {
    setSyncNote(null)
    const n = await syncWatched()
    setSyncNote(n > 0 ? `Found ${n} new video${n === 1 ? '' : 's'}.` : 'No new videos.')
  }

  async function toggleScheduled(next: boolean): Promise<void> {
    const err = await setScheduledSync(next)
    if (err) setError(err)
  }

  // The native time input emits a complete 'HH:MM' or '' (cleared/partial) — only
  // persist real times, so the picker can never store a malformed value (L51).
  function onSyncTimeChange(value: string): void {
    if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return
    updateSettings({ syncTime: value })
  }

  // Re-register the task with the new time once editing settles (blur), so typing
  // through the segments doesn't spawn schtasks per keystroke (L51).
  async function onSyncTimeCommit(): Promise<void> {
    const err = await commitSyncTime()
    if (err) setError(err)
  }

  // Reset the selection (and any batch note) whenever the expanded source changes.
  useEffect(() => {
    setSelected(new Set())
    setBatchNote(null)
    setExpandedGroups(new Set())
  }, [selectedSourceId])

  // Live per-URL queue status so a video row reflects its real download state.
  const statusByUrl = useMemo(() => {
    const m = new Map<string, DownloadStatus>()
    for (const d of downloadItems) {
      const prev = m.get(d.url)
      // Keep the highest-priority status when a URL has duplicate queue entries.
      if (prev === undefined || STATUS_PRIORITY[d.status] > STATUS_PRIORITY[prev]) {
        m.set(d.url, d.status)
      }
    }
    return m
  }, [downloadItems])

  const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : []
  const groups = useMemo(() => groupByPlaylist(items), [items])
  // A group is shown when toggled open, or auto-expanded when it's the only group
  // (a single "Uploads" channel shouldn't need a second click to reach its videos).
  const isGroupOpen = (title: string): boolean => groups.length === 1 || expandedGroups.has(title)
  // Flatten groups → [header, ...its items, header, ...] for the virtualized list.
  const flatRows = useMemo<LibRow[]>(() => {
    const rows: LibRow[] = []
    for (const g of groups) {
      rows.push({ kind: 'header', title: g.title, items: g.items })
      if (groups.length === 1 || expandedGroups.has(g.title)) {
        for (const it of g.items) rows.push({ kind: 'item', item: it })
      }
    }
    return rows
  }, [groups, expandedGroups])

  const effStatus = (it: MediaItem): ItemStatus =>
    statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')

  // Every URL that already has a queue entry (any status), so a source's
  // not-downloaded-and-not-queued items can be picked with the shared selector —
  // used both here for the expanded panel and by the per-row "Download new".
  const queuedUrls = useMemo(() => new Set(statusByUrl.keys()), [statusByUrl])
  const pendingItems = selectUndownloaded(items, queuedUrls)

  // An item is worth (re)queuing when it isn't already downloaded or in flight:
  // pending, or a previous failure/cancel to retry. Skipping completed/queued/
  // downloading items keeps "Select all → Download" from enqueuing duplicates,
  // since addFromUrl doesn't dedupe by URL.
  const actionable = (it: MediaItem): boolean => {
    const st = effStatus(it)
    return st === 'pending' || st === 'error' || st === 'canceled'
  }
  const actionableItems = items.filter(actionable)
  const selectedActionable = actionableItems.filter((it) => selected.has(it.id))
  const allActionableSelected =
    actionableItems.length > 0 && actionableItems.every((it) => selected.has(it.id))

  async function onIndex(): Promise<void> {
    setError(null)
    const u = url.trim()
    if (!u || indexing.active) return
    const res = await indexSource(u)
    if (res.ok) setUrl('')
    else if (!res.canceled) setError(res.error ?? 'Could not add that link.')
  }

  function toggle(id: string, on: boolean): void {
    setSelected((prev) => {
      const next = new Set(prev)
      if (on) next.add(id)
      else next.delete(id)
      return next
    })
  }

  function toggleGroupExpand(title: string): void {
    setExpandedGroups((prev) => {
      const next = new Set(prev)
      if (next.has(title)) next.delete(title)
      else next.add(title)
      return next
    })
  }

  function toggleGroup(groupItems: MediaItem[], on: boolean): void {
    setSelected((prev) => {
      const next = new Set(prev)
      for (const it of groupItems) {
        // Only actionable rows are selectable, so the selection count can never
        // exceed what "Download N selected" will actually queue (M36).
        if (!actionable(it)) continue
        if (on) next.add(it.id)
        else next.delete(it.id)
      }
      return next
    })
  }

  // Select / clear every downloadable item across all groups in this source.
  function toggleAll(on: boolean): void {
    setSelected(on ? new Set(actionableItems.map((it) => it.id)) : new Set())
  }

  // One click queues every chosen item -- maxConcurrent gates how many actually
  // run, the rest wait in the queue. Selection clears since nothing is held back.
  function downloadSelected(): void {
    if (!selectedSourceId || selectedActionable.length === 0) return
    const n = enqueueItems(selectedSourceId, selectedActionable, enqueueKind)
    setSelected(new Set())
    setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
  }

  function downloadPending(): void {
    if (!selectedSourceId || pendingItems.length === 0) return
    const n = enqueueItems(selectedSourceId, pendingItems, enqueueKind)
    setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
  }

  // Per source-row action: queue every not-yet-downloaded, not-yet-queued video
  // of a (possibly collapsed) watched source in one click — loading its items
  // first if they aren't cached. Uses the source's own resolved kind (no
  // kindOverride), and reports the outcome in the shared sync note.
  async function downloadNew(src: Source): Promise<void> {
    if (busySourceId) return
    setBusySourceId(src.id)
    setSyncNote(null)
    try {
      const its = await ensureSourceItems(src.id)
      const queueable = selectUndownloaded(its, queuedUrls)
      if (queueable.length === 0) {
        setSyncNote(`Nothing new to download for ${src.title}.`)
        return
      }
      const n = enqueueItems(src.id, queueable)
      setSyncNote(`Queued ${n} from ${src.title}.`)
    } catch {
      setSyncNote(`Couldn't load videos for ${src.title}.`)
    } finally {
      setBusySourceId(null)
    }
  }

  // Queue every actionable (pending/failed/canceled) video in one playlist group,
  // so "download this whole playlist" is a single click.
  function downloadGroup(groupItems: MediaItem[]): void {
    if (!selectedSourceId) return
    const toQueue = groupItems.filter(actionable)
    if (toQueue.length === 0) return
    const n = enqueueItems(selectedSourceId, toQueue, enqueueKind)
    setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
  }

  // One row of the item list -- a playlist header or a video -- shared by the
  // inline (small source) and virtualized (large source) render paths.
  function rowKey(row: LibRow): string {
    return row.kind === 'header' ? `h:${row.title}` : row.item.id
  }

  function renderRow(row: LibRow): React.JSX.Element {
    if (row.kind === 'header') {
      const open = isGroupOpen(row.title)
      // "All on" is judged over the ACTIONABLE rows only -- downloaded rows aren't
      // selectable, so they must not keep the group from reading as fully selected (M36).
      const groupActionableItems = row.items.filter(actionable)
      const groupActionable = groupActionableItems.length
      const allOn = groupActionable > 0 && groupActionableItems.every((it) => selected.has(it.id))
      return (
        <div className={styles.groupHead}>
          <button
            type="button"
            className={mergeClasses(styles.groupToggle, focus.focusRing)}
            onClick={() => toggleGroupExpand(row.title)}
            aria-expanded={open}
            aria-label={`${row.title}, ${row.items.length} items`}
          >
            {open ? <ChevronDownRegular /> : <ChevronRightRegular />}
            <AppsListRegular />
            <Text className={styles.groupTitle}>{row.title}</Text>
            <Caption1 className={text.muted}>{row.items.length}</Caption1>
          </button>
          <Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
            {allOn ? 'None' : 'All'}
          </Button>
          <Button
            size="small"
            appearance="subtle"
            icon={<ArrowDownloadRegular />}
            disabled={groupActionable === 0}
            onClick={() => downloadGroup(row.items)}
          >
            Download{groupActionable > 0 ? ` ${groupActionable}` : ''}
          </Button>
        </div>
      )
    }
    const it = row.item
    const status = effStatus(it)
    return (
      <div className={styles.row}>
        <Checkbox
          checked={selected.has(it.id)}
          disabled={!actionable(it)}
          onChange={(_, d) => toggle(it.id, !!d.checked)}
          aria-label={`Select ${it.title}`}
        />
        <MediaThumb
          className={styles.rowThumb}
          src={thumbUrl({ url: it.url, videoId: it.videoId })}
          kind="video"
          iconSize={16}
        />
        <div className={styles.rowMain}>
          <Text className={text.truncate}>
            {it.playlistIndex}. {it.title}
          </Text>
          {it.durationLabel && <Caption1 className={text.muted}>{it.durationLabel}</Caption1>}
        </div>
        <StatusChip status={status} />
      </div>
    )
  }

  return (
    <div className={screen.page}>
      <div className={mergeClasses(styles.root, screen.width)}>
        <ScreenHeader
          title="Library"
          description="Add a channel or playlist once, then download its videos into organized folders."
        />

        <div className={styles.addRow}>
          <Input
            className={styles.addInput}
            input={{ 'aria-label': 'Channel or playlist URL' }}
            value={url}
            onChange={(_, d) => setUrl(d.value)}
            onKeyDown={(e) => e.key === 'Enter' && onIndex()}
            placeholder="Paste a channel or playlist URL to add it…"
            size="large"
            contentBefore={<LibraryRegular />}
            disabled={indexing.active}
          />
          <Button
            size="large"
            appearance="primary"
            icon={indexing.active ? <Spinner size="tiny" /> : <AddRegular />}
            onClick={onIndex}
            disabled={!url.trim() || indexing.active}
          >
            Add
          </Button>
        </div>

        {clip.suggestion && (
          <LinkSuggestion
            prefix="Use copied link? "
            link={clip.suggestion}
            onAccept={() => {
              const link = clip.accept()
              if (link) setUrl(link)
            }}
            onDismiss={clip.dismiss}
            dismissLabel="Dismiss suggested link"
          />
        )}

        <div className={styles.toolbar}>
          <Hint
            label={
              watchedCount === 0
                ? 'Turn on "Watch" for a channel or playlist below first, then this checks them for new uploads.'
                : 'Check your watched sources for new uploads'
            }
            placement="top"
            align="start"
          >
            <Button
              size="small"
              appearance="subtle"
              icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
              onClick={onCheckNew}
              // disabledFocusable (not disabled) when nothing is watched, so the
              // button still shows its explanatory tooltip on hover/focus (UX24).
              disabled={syncing}
              disabledFocusable={watchedCount === 0}
            >
              Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
            </Button>
          </Hint>
          {syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
          <div className={styles.toolbarSpacer} />
          <span className={styles.switchRow}>
            <Caption1>Auto-download new</Caption1>
            <Switch
              checked={autoDownloadNew}
              onChange={(_, d) => updateSettings({ autoDownloadNew: d.checked })}
              aria-label="Auto-download new uploads"
            />
          </span>
          <span className={styles.switchRow}>
            <Caption1>Daily sync</Caption1>
            <Switch
              checked={scheduled}
              onChange={(_, d) => toggleScheduled(d.checked)}
              aria-label="Daily background sync"
            />
            {scheduled && (
              <Input
                type="time"
                size="small"
                value={syncTime}
                onChange={(_, d) => onSyncTimeChange(d.value)}
                onBlur={onSyncTimeCommit}
                aria-label="Daily sync time"
              />
            )}
          </span>
        </div>

        {indexing.active && (
          <div className={styles.progress}>
            <Spinner size="tiny" />
            <Text>
              {indexing.message ?? 'Adding…'}
              {indexing.current && indexing.total
                ? `  (${indexing.current}/${indexing.total})`
                : ''}
            </Text>
            {/* B2: a big channel's index walk can take minutes — let the user abort it. */}
            <Button
              size="small"
              appearance="subtle"
              icon={<DismissRegular />}
              onClick={cancelIndexing}
            >
              Cancel
            </Button>
          </div>
        )}
        {error && <Caption1 className={styles.error}>{error}</Caption1>}

        {sources.length === 0 && !indexing.active ? (
          <EmptyState
            icon={<LibraryRegular fontSize={ICON.hero} />}
            message="No channels or playlists yet. Paste one above to add it."
          />
        ) : (
          <div className={styles.list}>
            {sources.map((src) => (
              <SourceCard
                key={src.id}
                styles={styles}
                source={src}
                expanded={selectedSourceId === src.id}
                onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
                rowAction={
                  src.watched ? (
                    <Button
                      size="small"
                      appearance="subtle"
                      icon={busySourceId === src.id ? <Spinner size="tiny" /> : <ArrowDownloadRegular />}
                      onClick={() => downloadNew(src)}
                      disabled={busySourceId !== null}
                    >
                      Download new
                    </Button>
                  ) : undefined
                }
              >
                <div className={styles.detail}>
                  <div className={styles.actionRow}>
                    <Checkbox
                      checked={allActionableSelected ? true : selected.size > 0 ? 'mixed' : false}
                      onChange={(_, d) => toggleAll(!!d.checked)}
                      label="Select all"
                      disabled={actionableItems.length === 0}
                    />
                    <Caption1 className={text.muted}>
                      {items.length} videos{META_SEP}
                      {pendingItems.length} pending{META_SEP}indexed {relTime(src.lastIndexedAt)}
                    </Caption1>
                    <div className={styles.actionSpacer} />
                    <SegmentedControl<MediaKind>
                      value={enqueueKind}
                      options={[
                        { value: 'video', label: 'Video' },
                        { value: 'audio', label: 'Audio' }
                      ]}
                      onChange={setEnqueueKind}
                      ariaLabel="Download as video or audio"
                    />
                    {selected.size > 0 ? (
                      <>
                        <Button
                          size="small"
                          appearance="subtle"
                          onClick={() => setSelected(new Set())}
                        >
                          Clear
                        </Button>
                        <Button
                          size="small"
                          appearance="primary"
                          icon={<ArrowDownloadRegular />}
                          onClick={downloadSelected}
                          disabled={selectedActionable.length === 0}
                        >
                          Download {selectedActionable.length} selected
                        </Button>
                      </>
                    ) : (
                      <Button
                        size="small"
                        appearance="primary"
                        icon={<ArrowDownloadRegular />}
                        onClick={downloadPending}
                        disabled={pendingItems.length === 0}
                      >
                        Download {pendingItems.length} pending
                      </Button>
                    )}
                    {profiles.length > 0 && (
                      <span className={styles.switchRow}>
                        <Caption1>Profile</Caption1>
                        <Select
                          value={src.profileId ?? 'none'}
                          options={[
                            { value: 'none', label: 'Default settings' },
                            ...profiles.map((p) => ({ value: p.id, label: p.name }))
                          ]}
                          onChange={(v) => setProfile(src.id, v === 'none' ? null : v)}
                          aria-label={`Download profile for ${src.title}`}
                        />
                      </span>
                    )}
                    <span className={styles.switchRow}>
                      <Caption1>Watch</Caption1>
                      <Switch
                        checked={!!src.watched}
                        onChange={(_, d) => setWatched(src.id, !!d.checked)}
                        aria-label={`Watch ${src.title} for new uploads`}
                      />
                    </span>
                    <Button
                      size="small"
                      appearance="subtle"
                      icon={<ArrowSyncRegular />}
                      onClick={() => reindexSource(src.id)}
                      disabled={indexing.active}
                    >
                      Refresh
                    </Button>
                    {confirmRemoveId === src.id ? (
                      <>
                        <Caption1 className={text.muted}>Remove {src.title}?</Caption1>
                        <Button
                          size="small"
                          appearance="primary"
                          icon={<DeleteRegular />}
                          onClick={() => {
                            removeSource(src.id)
                            setConfirmRemoveId(null)
                          }}
                        >
                          Remove
                        </Button>
                        <Button
                          size="small"
                          appearance="subtle"
                          onClick={() => setConfirmRemoveId(null)}
                        >
                          Cancel
                        </Button>
                      </>
                    ) : (
                      <Button
                        size="small"
                        appearance="subtle"
                        icon={<DeleteRegular />}
                        onClick={() => setConfirmRemoveId(src.id)}
                      >
                        Remove
                      </Button>
                    )}
                  </div>

                  {batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}

                  <SourceRetention sourceId={src.id} />

                  {flatRows.length > VIRTUALIZE_AT ? (
                    // Big source: a fixed-height, internally-scrolling virtualized
                    // panel so 1000s of rows stay light. A definite height (not
                    // max-height) gives the virtualizer a viewport to measure.
                    <VirtualList
                      items={flatRows}
                      style={{ height: '58vh' }}
                      overscan={10}
                      estimateSize={(i) => (flatRows[i]?.kind === 'header' ? 40 : 46)}
                      getKey={(row) => rowKey(row)}
                      renderItem={(row) => renderRow(row)}
                    />
                  ) : (
                    // Small source: render inline so the card grows naturally.
                    <div className={styles.plainList}>
                      {flatRows.map((row) => (
                        <div key={rowKey(row)}>{renderRow(row)}</div>
                      ))}
                    </div>
                  )}
                </div>
              </SourceCard>
            ))}
          </div>
        )}
      </div>
    </div>
  )
}

/** One collapsible source card (header always shown; children render when expanded). */
function SourceCard({
  styles,
  source,
  expanded,
  onToggleExpand,
  rowAction,
  children
}: {
  styles: ReturnType<typeof useStyles>
  source: Source
  expanded: boolean
  onToggleExpand: () => void
  /** an optional action rendered beside the header, OUTSIDE the expand button
   *  (nesting a button inside the header button is invalid) — e.g. "Download new" */
  rowAction?: React.ReactNode
  children: React.ReactNode
}): React.JSX.Element {
  const focus = useFocusStyles()
  const text = useTextStyles()
  return (
    <div className={styles.card}>
      <div className={styles.cardHeadRow}>
        <button
          type="button"
          className={mergeClasses(styles.cardHead, focus.focusRing)}
          onClick={onToggleExpand}
          aria-expanded={expanded}
          aria-label={source.title}
        >
          {expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
          <div className={styles.srcIcon}>
            <VideoClipMultipleRegular />
          </div>
          <div className={styles.srcMeta}>
            <span className={styles.srcTitleRow}>
              <Text className={styles.srcTitle}>{source.title}</Text>
              {source.watched && (
                <span className={styles.watchBadge}>
                  <AlertRegular fontSize={11} /> Watching
                </span>
              )}
            </span>
            <Caption1 className={text.muted}>
              {source.kind === 'channel' ? 'Channel' : 'Playlist'}
              {META_SEP}
              {source.itemCount} videos
              {source.channel && source.channel !== source.title
                ? `${META_SEP}${source.channel}`
                : ''}
            </Caption1>
          </div>
        </button>
        {rowAction && <div className={styles.cardHeadAction}>{rowAction}</div>}
      </div>
      {expanded && children}
    </div>
  )
}
