/* ═══════════════════════════════════════════════════════════
   Lightify — Frontend Application
   ═══════════════════════════════════════════════════════════ */

const { invoke } = window.__TAURI__.core;
const { listen }  = window.__TAURI__.event;
const { getCurrentWindow } = window.__TAURI__.window;
const appWindow = getCurrentWindow();

// ── DOM refs ─────────────────────────────────────────────────

const $ = id => document.getElementById(id);

const dom = {
  // pages
  authPage:     $('auth-page'),
  playerPage:   $('player-page'),
  // auth
  clientId:     $('client-id'),
  btnAuth:      $('btn-auth'),
  btnAuthRetry: $('btn-auth-retry'),
  btnOpenSpotifyDashboard: $('btn-open-spotify-dashboard'),
  authStatus:   $('auth-status'),
  // titlebar
  btnMin:       $('btn-min'),
  btnMax:       $('btn-max'),
  btnTray:      $('btn-tray'),
  btnClose:     $('btn-close'),
  btnMiniPlayer:$('btn-miniplayer'),
  // tabs
  tabLibrary:   $('tab-library'),
  tabSearch:    $('tab-search'),
  tabBeatport:  $('tab-beatport'),
  leftPanel:    $('left-panel'),
  btnLeftPanelToggle: $('btn-left-panel-toggle'),
  libraryContent:  $('library-content'),
  searchContent:   $('search-content'),
  beatportContent: $('beatport-content'),
  // library
  playlistList: $('playlist-list'),
  trackList:    $('track-list'),
  btnBack:      $('btn-back'),
  libraryToolbar: $('library-toolbar'),
  btnLibrarySortRecent: $('btn-library-sort-recent'),
  btnLibrarySortAlpha: $('btn-library-sort-alpha'),
  // search
  searchInput:  $('search-input'),
  searchFilters:$('search-filters'),
  searchResults:$('search-results'),
  searchDrill:  $('search-drill'),
  btnSearchBack:$('btn-search-back'),
  // beatport
  bpGenre:      $('bp-genre'),
  bpGenreTrigger: $('bp-genre-trigger'),
  bpGenreLabel: $('bp-genre-label'),
  bpGenreMenu:  $('bp-genre-menu'),
  btnBpModeTracks: $('btn-bp-mode-tracks'),
  btnBpModeHype: $('btn-bp-mode-hype'),
  btnBpModeReleases: $('btn-bp-mode-releases'),
  btnBpVerify:  $('btn-bp-verify'),
  bpList:       $('bp-list'),
  btnBpSelectAll:$('btn-bp-select-all'),
  btnBpCreate:  $('btn-bp-create'),
  bpStatus:     $('bp-status'),
  // now playing
  nowPlaying:   $('now-playing'),
  albumArtContainer:$('album-art-container'),
  trackInfo:    $('track-info'),
  trackName:    $('track-name'),
  trackArtist:  $('track-artist'),
  elapsed:      $('elapsed'),
  duration:     $('duration'),
  progressBar:  $('progress-bar'),
  progressFill: $('progress-fill'),
  miniModes:    $('mini-modes'),
  miniEdge:     $('mini-edge'),
  // controls
  btnShuffle:   $('btn-shuffle'),
  btnPrev:      $('btn-prev'),
  btnPlay:      $('btn-play'),
  btnNext:      $('btn-next'),
  btnRepeat:    $('btn-repeat'),
  iconPlay:     $('icon-play'),
  iconPause:    $('icon-pause'),
  volumeSlider: $('volume-slider'),
  btnLike:      $('btn-like'),
  btnStation:   $('btn-station'),
  btnAddQueue:  $('btn-add-queue'),
  // sidebar
  btnShowQueue:   $('btn-show-queue'),
  btnShowRecent:  $('btn-show-recent'),
  btnShowDownloads: $('btn-show-downloads'),
  sidebar:        $('sidebar'),
  sidebarTitle:   $('sidebar-title'),
  btnSidebarClear:$('btn-sidebar-clear'),
  btnCloseSidebar:$('btn-close-sidebar'),
  sidebarList:    $('sidebar-list'),
  sidebarActions: $('sidebar-actions'),
  btnSidebarCreatePl: $('btn-sidebar-create-pl'),
  albumArt:     $('album-art'),
  noArt:        $('no-art'),
  // status bar
  statusText:   $('status-text'),
  btnLogout:    $('btn-logout'),
  btnSettings:  $('btn-settings'),
  // settings modal
  commandPalette:             $('command-palette'),
  cmdkInput:                  $('cmdk-input'),
  cmdkList:                   $('cmdk-list'),
  cmdkEmpty:                  $('cmdk-empty'),
  settingsOverlay:            $('settings-overlay'),
  btnSettingsClose:           $('btn-settings-close'),
  settingsDevicesList:        $('settings-devices-list'),
  btnSettingsRefreshDevices:  $('btn-settings-refresh-devices'),
  settingsNativeStatus:       $('settings-native-status'),
  btnSettingsRestartAudio:    $('btn-settings-restart-audio'),
  btnSettingsRefreshWatchdog: $('btn-settings-refresh-watchdog'),
  settingsWatchdogReport:     $('settings-watchdog-report'),
  settingsAudioOutput:        $('settings-audio-output'),
  btnSettingsRefreshOutputs:  $('btn-settings-refresh-outputs'),
  btnSettingsApplyOutput:     $('btn-settings-apply-output'),
  btnSettingsExportDebug:     $('btn-settings-export-debug'),
  settingsDebugStatus:        $('settings-debug-status'),
  settingsDownloadPath:       $('settings-download-path'),
  btnSettingsApplyDownloadPath: $('btn-settings-apply-download-path'),
  settingsDownloaderStatus:   $('settings-downloader-status'),
  btnSettingsStartDownloaderLogin: $('btn-settings-start-downloader-login'),
  confirmOverlay: $('confirm-overlay'),
  confirmMessage: $('confirm-message'),
  btnConfirmCancel: $('btn-confirm-cancel'),
  btnConfirmAccept: $('btn-confirm-accept'),
};

// ── State ────────────────────────────────────────────────────

const state = {
  currentTrack: null,     // Track object
  miniMode: 'off',        // off | square | bar | nano  (compact player layout)
  isPlaying: false,
  shuffleOn: false,
  repeatMode: 'off',      // off, context, track
  isLiked: false,
  pollTimer: null,
  progressTickTimer: null,
  playbackSnapshot: null,
  audioOutputWatchTimer: null,
  playlists: [],
  libraryListSortMode: 'recent',
  libraryListAlphaDirection: 'asc',
  trackListSortMode: 'recent',
  trackListAlphaDirection: 'asc',
  currentPlaylistId: null,
  currentPlaylistUri: null,
  activeLibraryPlaybackSourceId: null,
  activeLibraryPlaybackContextUri: null,
  playlistLoadToken: 0,
  activeTrackPager: null,
  activeSearchPager: null,
  activeSearchResultsPager: null,
  activeSearchExpansion: null,
  activeSearchDrillPager: null,
  searchDrillSource: null,
  searchFilter: 'all',
  searchQuery: '',
  searchLoadToken: 0,
  searchResultScope: null,
  lastSearchResults: null,   // SearchAllResult
  drillType: null,           // 'playlist' | 'album' | 'artist'
  drillId: null,
  drillUri: null,
  searchDrillLoadToken: 0,
  beatportTracks: [],
  beatportGenres: [],
  beatportGenreValue: '',
  beatportChartKind: 'tracks',
  beatportFetchLoading: false,
  beatportSelected: new Set(),
  beatportMatchCache: new Map(),
  beatportLastMatchRequestAt: 0,
  beatportAutoplay: null,
  spotifySelectionAutoplay: null,
  stationAutoplay: null,
  nativeStation: false,     // true while a native Spotify-radio station is active (autoplay on)
  queueSource: null,        // null | 'spotify' | 'lightify' | 'context'
  lightifyQueue: [],       // Track-like objects managed by Lightify
  lightifyQueueCurrent: null,
  lightifyQueueContextUri: null,
  lightifyQueueAdvancePending: false,
  navHistory: [],           // chronological played tracks for Prev/Next navigation
  navIndex: -1,             // cursor into navHistory
  navExpectedKey: null,     // identity we're navigating to (guards transient polls)
  sidebarMode: null,         // 'queue' | 'recent' | 'downloads'
  sessionHistory: [],        // Recent entries added this app session
  persistedRecentHistory: [], // Canonical recently-played entries loaded from Spotify
  recentHistoryClearedAtMs: 0,
  recentHistoryLoaded: false,
  recentHistoryLoading: false,
  pollIntervalMs: 3000,
  searchDebounce: null,
  pendingVolumePercent: null,
  pendingVolumeAt: 0,
  pendingVolumeConfirmedAt: 0,
  volumeInteractionActive: false,
  volumeDebounce: null,
  likeTogglePending: false,
  likeToggleTrackId: null,
  defaultAudioOutput: null,
  audioOutputWatchBusy: false,
  downloadsPollTimer: null,
  downloadsLoading: false,
  confirmDialog: null,
  authFlowPending: false,
};

const PLAYLIST_PAGE_LIMIT = 100;
const SEARCH_PLAYLIST_PAGE_LIMIT = 10; // Spotify rejects limit > 10 for this client id (400 Invalid limit)
const ARTIST_PAGE_LIMIT = 50;
const LIKED_PAGE_LIMIT = 50;
const TRACK_PAGE_SCROLL_THRESHOLD = 320;
const SEARCH_EXPANSION_SCROLL_THRESHOLD = 120;
const SEARCH_RESULTS_SCROLL_THRESHOLD = 220;
const SEARCH_INPUT_DEBOUNCE_MS = 800;
const SEARCH_RESULT_CACHE_STORAGE_KEY = 'lightify.searchResults.v1';
const SEARCH_RESULT_CACHE_LIMIT = 40;
const LONG_PRESS_SELECT_MS = 200;
const PROGRESS_TICK_MS = 1000;
const VOLUME_INPUT_DEBOUNCE_MS = 50;
const VOLUME_PENDING_GRACE_MS = 1800;
const RECENT_HISTORY_LIMIT = 200;
const RECENT_HISTORY_DEDUPE_WINDOW_MS = 5000;
const BEATPORT_INITIAL_MATCH_COUNT = 2;
const BEATPORT_INITIAL_QUEUE_COUNT = 4;
const BEATPORT_MAX_QUEUE_BATCH_SIZE = 32;
const BEATPORT_AUTOPLAY_PREFILL_TARGET = 6;
const BEATPORT_MATCH_REQUEST_DELAY_MS = 650;
const BEATPORT_MATCH_CACHE_STORAGE_KEY = 'lightify.beatportMatches.v1';
const BEATPORT_VERIFIED_SESSION_STORAGE_KEY = 'lightify.beatportVerifiedSession.v1';
const BEATPORT_MATCH_CACHE_LIMIT = 400;
const SPOTIFY_SELECTION_INITIAL_QUEUE_COUNT = 4;
const SPOTIFY_SELECTION_MAX_QUEUE_BATCH_SIZE = 16;
const SPOTIFY_SELECTION_PREFILL_TARGET = 8;

const selectionState = {
  activeScope: null,
  activeContainer: null,
  selectedKeys: new Set(),
  registry: new Map(),
  pressTimer: null,
  pointerDown: false,
  pointerId: null,
  dragActive: false,
  gestureScope: null,
  gestureContainer: null,
  dragVisitedKeys: new Set(),
  pointerClientX: null,
  pointerClientY: null,
};

function nextFrame() {
  return new Promise(resolve => requestAnimationFrame(resolve));
}

function selectionRegistryKey(scope, key) {
  return `${scope}::${key}`;
}

function clearSelectionGesture() {
  if (selectionState.pressTimer) {
    clearTimeout(selectionState.pressTimer);
    selectionState.pressTimer = null;
  }
  selectionState.pointerDown = false;
  selectionState.pointerId = null;
  selectionState.dragActive = false;
  selectionState.gestureScope = null;
  selectionState.gestureContainer = null;
  selectionState.dragVisitedKeys.clear();
  selectionState.pointerClientX = null;
  selectionState.pointerClientY = null;
}

function noteSelectionPointer(e) {
  if (!e) return;
  if (typeof e.clientX === 'number') selectionState.pointerClientX = e.clientX;
  if (typeof e.clientY === 'number') selectionState.pointerClientY = e.clientY;
}

function selectionRowFromTarget(target) {
  if (!(target instanceof Element)) return null;
  return target.closest('.list-item[data-selection-registry-key]');
}

function selectDraggedRow(registryKey) {
  if (!registryKey || selectionState.dragVisitedKeys.has(registryKey)) return;
  const meta = selectionState.registry.get(registryKey);
  if (!meta) return;
  if (meta.scope !== selectionState.gestureScope || meta.container !== selectionState.gestureContainer) return;
  selectionState.dragVisitedKeys.add(registryKey);
  if (meta.row) meta.row.__skipClick = true;
  setSelectionEntrySelected(registryKey, true);
}

function maybeSelectDraggedRow(row) {
  if (!row) return;
  const registryKey = row.dataset.selectionRegistryKey;
  if (!registryKey) return;
  const meta = selectionState.registry.get(registryKey);
  if (!meta) return;
  if (meta.scope !== selectionState.gestureScope || meta.container !== selectionState.gestureContainer) return;
  row.__skipClick = true;
  selectDraggedRow(registryKey);
}

function selectDraggedRowAtPointer() {
  if (selectionState.pointerClientX == null || selectionState.pointerClientY == null) return;
  maybeSelectDraggedRow(selectionRowFromTarget(document.elementFromPoint(selectionState.pointerClientX, selectionState.pointerClientY)));
}

function handleSelectionPointerMove(e) {
  if (!selectionState.pointerDown || selectionState.pointerId !== e.pointerId) return;
  noteSelectionPointer(e);
  if (!selectionState.dragActive) return;
  selectDraggedRowAtPointer();
}

function detachActiveSearchDrillPager() {
  if (state.activeSearchDrillPager && state.activeSearchDrillPager.onScroll) {
    dom.searchDrill.removeEventListener('scroll', state.activeSearchDrillPager.onScroll);
  }
  state.activeSearchDrillPager = null;
}

function clearSelectionForContainer(container) {
  if (!container) return;
  if (selectionState.activeContainer === container) {
    clearActiveSelection();
  }
  Array.from(selectionState.registry.entries()).forEach(([registryKey, meta]) => {
    if (meta.container === container) {
      selectionState.registry.delete(registryKey);
    }
  });
}

function selectionMatches(scope, container) {
  return selectionState.activeScope === scope && selectionState.activeContainer === container;
}

function setSelectionEntrySelected(registryKey, selected) {
  const meta = selectionState.registry.get(registryKey);
  if (!meta) return;

  if (selected) {
    if (!selectionMatches(meta.scope, meta.container)) {
      clearActiveSelection();
    }
    selectionState.activeScope = meta.scope;
    selectionState.activeContainer = meta.container;
    if (!selectionState.selectedKeys.has(registryKey)) {
      selectionState.selectedKeys.add(registryKey);
      if (meta.row?.isConnected) meta.row.classList.add('selected');
      if (typeof meta.onSelectedChange === 'function') meta.onSelectedChange(true);
    }
    return;
  }

  if (selectionState.selectedKeys.delete(registryKey)) {
    if (meta.row?.isConnected) meta.row.classList.remove('selected');
    if (typeof meta.onSelectedChange === 'function') meta.onSelectedChange(false);
  }

  if (selectionState.selectedKeys.size === 0) {
    selectionState.activeScope = null;
    selectionState.activeContainer = null;
  }
}

function clearActiveSelection() {
  const keys = Array.from(selectionState.selectedKeys);
  keys.forEach(registryKey => setSelectionEntrySelected(registryKey, false));
  clearSelectionGesture();
}

function hasActiveSelection(scope, container) {
  return selectionMatches(scope, container) && selectionState.selectedKeys.size > 0;
}

function getSelectedEntries(scope, container) {
  if (!selectionMatches(scope, container)) return [];
  return Array.from(selectionState.selectedKeys)
    .map(registryKey => selectionState.registry.get(registryKey))
    .filter(meta => meta && meta.scope === scope && meta.container === container);
}

function getSelectionTabRoot(container) {
  return container?.closest('.tab-content') || container?.closest('#sidebar') || container || null;
}

function trackSelectionScope(container) {
  if (container === dom.trackList) {
    return `track-list:${state.currentPlaylistId || 'library'}`;
  }
  if (container === dom.searchDrill) {
    return `search-drill:${state.drillType || 'unknown'}:${state.drillId || 'none'}`;
  }
  if (container === dom.searchResults) {
    return `search-results:${state.searchFilter || 'all'}`;
  }
  if (container === dom.sidebarList) {
    return `sidebar:${state.sidebarMode || 'unknown'}`;
  }
  return `track-container:${container?.id || 'unknown'}`;
}

function buildTimestampedPlaylistName(prefix) {
  const now = new Date();
  return `${prefix} ${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
}

function spotifyTrackUri(track) {
  if (!track || typeof track !== 'object') return '';
  if (typeof track.uri === 'string' && track.uri.trim()) return track.uri.trim();
  const id = typeof track.id === 'string' ? track.id.trim() : '';
  if (!id) return '';
  // The native audio engine reports ids as full "spotify:track:X" URIs while the
  // Web API reports bare base-62 ids — accept either without double-prefixing.
  return id.startsWith('spotify:') ? id : `spotify:track:${id}`;
}

function spotifyTrackId(track) {
  if (!track || typeof track !== 'object') return '';
  const id = typeof track.id === 'string' ? track.id.trim() : '';
  if (id && !id.startsWith('spotify:')) return id;
  const uri = spotifyTrackUri(track);
  return uri.startsWith('spotify:track:') ? uri.slice('spotify:track:'.length).trim() : '';
}

function uniqueTrackUris(tracks) {
  const seen = new Set();
  return tracks
    .map(track => spotifyTrackUri(track))
    .filter(uri => typeof uri === 'string' && uri && !seen.has(uri) && seen.add(uri));
}

function queueTrackIdentity(track) {
  if (!track || typeof track !== 'object') return '';
  const uri = spotifyTrackUri(track) || (typeof track.uri === 'string' ? track.uri.trim() : '');
  if (uri) return uri;
  if (typeof track.id === 'string' && track.id.trim()) return `spotify:track:${track.id.trim()}`;
  const name = typeof track.name === 'string' ? track.name.trim() : '';
  const artists = typeof track.artists === 'string' ? track.artists.trim() : '';
  return `${name}::${artists}`;
}

function resolvedSpotifyTrackId(track) {
  return spotifyTrackId(track);
}

function findRegisteredPlaybackTrack(track) {
  if (!track || typeof track !== 'object') return null;
  let match = null;
  selectionState.registry.forEach(meta => {
    if (match) return;
    const candidate = playbackTrackFromRowData(meta?.data);
    if (candidate && spotifyTrackId(candidate) && samePlaybackTrack(track, candidate)) {
      match = candidate;
    }
  });
  return match;
}

function stationTrackFromPlaybackTrack(track) {
  if (!track || typeof track !== 'object') return null;
  const trackId = spotifyTrackId(track);
  if (trackId) return { ...track, id: trackId };

  const registeredTrack = findRegisteredPlaybackTrack(track);
  if (!registeredTrack) return track;
  const registeredTrackId = spotifyTrackId(registeredTrack);
  return { ...registeredTrack, id: registeredTrackId || registeredTrack.id || '' };
}

// Native audio events can describe the same song before Spotify polling fills in URI and art.
function samePlaybackTrack(left, right) {
  if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false;

  const leftUri = typeof left.uri === 'string' ? left.uri.trim() : '';
  const rightUri = typeof right.uri === 'string' ? right.uri.trim() : '';
  if (leftUri && rightUri && leftUri === rightUri) return true;

  const leftId = typeof left.id === 'string' ? left.id.trim() : '';
  const rightId = typeof right.id === 'string' ? right.id.trim() : '';
  if (leftId && rightId && leftId === rightId) return true;

  if (leftUri && rightUri && leftUri !== rightUri) return false;
  if (leftId && rightId && leftId !== rightId) return false;

  const leftName = typeof left.name === 'string' ? left.name.trim().toLowerCase() : '';
  const rightName = typeof right.name === 'string' ? right.name.trim().toLowerCase() : '';
  const leftArtists = typeof left.artists === 'string' ? left.artists.trim().toLowerCase() : '';
  const rightArtists = typeof right.artists === 'string' ? right.artists.trim().toLowerCase() : '';
  const leftDuration = Number.isFinite(left.duration_ms) ? left.duration_ms : 0;
  const rightDuration = Number.isFinite(right.duration_ms) ? right.duration_ms : 0;
  if (leftName && leftArtists && leftName === rightName && leftArtists === rightArtists) {
    const allowLooseMetadataMatch = !leftId || !rightId || !leftUri || !rightUri;
    if (allowLooseMetadataMatch && (!leftDuration || !rightDuration || Math.abs(leftDuration - rightDuration) <= 1500)) {
      return true;
    }
  }

  return false;
}

function mergePlaybackTrack(current, incoming) {
  if (!incoming || typeof incoming !== 'object') return current || null;

  const previous = current && typeof current === 'object' ? current : {};
  const next = incoming;
  const sameTrack = samePlaybackTrack(previous, next);
  const previousId = typeof previous.id === 'string' ? previous.id.trim() : '';
  const nextId = typeof next.id === 'string' ? next.id.trim() : '';
  const previousUri = typeof previous.uri === 'string' ? previous.uri.trim() : '';
  const nextUri = typeof next.uri === 'string' ? next.uri.trim() : '';

  return {
    ...(sameTrack ? previous : {}),
    ...next,
    id: sameTrack ? (nextId || previousId) : nextId,
    uri: sameTrack ? (nextUri || previousUri) : nextUri,
    name: (typeof next.name === 'string' && next.name.trim())
      || (sameTrack ? ((typeof previous.name === 'string' && previous.name.trim()) || '') : '')
      || '—',
    artists: (typeof next.artists === 'string' ? next.artists.trim() : '')
      || (sameTrack ? (typeof previous.artists === 'string' ? previous.artists.trim() : '') : ''),
    album_art: sameTrack
      ? ((typeof next.album_art === 'string' ? next.album_art : '') || (typeof previous.album_art === 'string' ? previous.album_art : ''))
      : (typeof next.album_art === 'string' ? next.album_art : ''),
    duration_ms: Number.isFinite(next.duration_ms)
      ? next.duration_ms
      : (sameTrack && Number.isFinite(previous.duration_ms) ? previous.duration_ms : 0),
  };
}

function playbackTrackFromRowData(data) {
  const track = data?.track;
  return track && typeof track === 'object' ? track : null;
}

function applyTrackPlaybackPresence(row, track) {
  if (!(row instanceof HTMLElement)) return;

  const isCurrentTrack = Boolean(track && state.currentTrack && samePlaybackTrack(state.currentTrack, track));
  const isPlayingTrack = isCurrentTrack && state.isPlaying;
  const isPausedTrack = isCurrentTrack && !state.isPlaying;

  row.classList.toggle('playing', isCurrentTrack);
  row.classList.toggle('playing-active', isPlayingTrack);
  row.classList.toggle('playing-paused', isPausedTrack);

  if (isCurrentTrack) {
    row.dataset.playbackState = isPlayingTrack ? 'playing' : 'paused';
    row.setAttribute('aria-current', 'true');
  } else {
    delete row.dataset.playbackState;
    row.removeAttribute('aria-current');
  }
}

function libraryPlaybackSourceMatches(sourceId = null, contextUri = null) {
  if (sourceId && state.activeLibraryPlaybackSourceId && sourceId === state.activeLibraryPlaybackSourceId) {
    return true;
  }
  if (contextUri && state.activeLibraryPlaybackContextUri && contextUri === state.activeLibraryPlaybackContextUri) {
    return true;
  }
  return false;
}

function applyLibraryPlaybackPresence(row, sourceId = null, contextUri = null) {
  if (!(row instanceof HTMLElement)) return;

  const isActiveSource = libraryPlaybackSourceMatches(sourceId, contextUri);
  const isPlayingSource = isActiveSource && state.isPlaying;
  const isPausedSource = isActiveSource && !state.isPlaying;

  row.classList.toggle('playing', isActiveSource);
  row.classList.toggle('playing-active', isPlayingSource);
  row.classList.toggle('playing-paused', isPausedSource);

  if (isActiveSource) {
    row.setAttribute('aria-current', 'true');
    row.dataset.playbackState = isPlayingSource ? 'playing' : 'paused';
  } else {
    row.removeAttribute('aria-current');
    delete row.dataset.playbackState;
  }
}

function refreshLibraryPlaybackPresence() {
  if (!(dom.playlistList instanceof HTMLElement)) return;
  dom.playlistList.querySelectorAll('.list-item[data-library-source-id]').forEach(row => {
    const sourceId = row.getAttribute('data-library-source-id') || null;
    const contextUri = row.getAttribute('data-library-context-uri') || null;
    applyLibraryPlaybackPresence(row, sourceId, contextUri);
  });
}

function rememberActiveLibraryPlaybackSource(sourceId = null, contextUri = null) {
  state.activeLibraryPlaybackSourceId = sourceId || null;
  state.activeLibraryPlaybackContextUri = contextUri || null;
  refreshLibraryPlaybackPresence();
}

function refreshTrackPlaybackPresence() {
  selectionState.registry.forEach(meta => {
    applyTrackPlaybackPresence(meta?.row, playbackTrackFromRowData(meta?.data));
  });
}

function scheduleFastPlaybackPoll(delay = 220) {
  setTimeout(pollNow, delay);
}

function normalizeQueueTrack(track) {
  if (!track || typeof track !== 'object') return null;
  const uri = spotifyTrackUri(track) || (typeof track.uri === 'string' ? track.uri.trim() : '');
  const id = (typeof track.id === 'string' && track.id.trim())
    ? track.id.trim()
    : beatportTrackIdFromUri(uri);
  const name = typeof track.name === 'string' && track.name.trim() ? track.name.trim() : 'Unknown track';
  const artists = typeof track.artists === 'string' ? track.artists.trim() : '';
  return {
    ...track,
    id,
    uri,
    name,
    artists,
    album_art: typeof track.album_art === 'string' ? track.album_art : '',
    duration_ms: typeof track.duration_ms === 'number' ? track.duration_ms : 0,
  };
}

// Drop region-locked / unavailable tracks before building a play or queue batch.
// The backend sets is_playable === false when market relinking found no playable
// version for the user's region; skipping them here stops a dead track from
// stalling the local queue (2.0.5 already skips them backend-side in cmd_play_uris).
// Tolerant by design: any track missing the field (embed/SSR fallbacks, beatport
// rows resolved later, older payloads) is treated as playable and kept.
function playableTracks(tracks) {
  if (!Array.isArray(tracks)) return [];
  return tracks.filter(track => track && track.is_playable !== false);
}

function uniqueQueueTracks(tracks) {
  const seen = new Set();
  return tracks
    .map(track => normalizeQueueTrack(track))
    .filter(track => {
      if (!track) return false;
      const key = queueTrackIdentity(track);
      if (!key || seen.has(key)) return false;
      seen.add(key);
      return true;
    });
}

function refreshVisibleQueueSidebar() {
  if (state.sidebarMode === 'queue' && !dom.sidebar.classList.contains('hidden')) {
    loadSidebarQueue().catch(() => {});
  }
  updateSidebarClearButton();
  schedulePersistState();
  pushExpectedTracksToEngine();
}

// Blip gate (#1): tell the native engine which track ids are sanctioned so it can
// mute stale Spotify user-queue interlopers that clip in between tracks. Only the
// arbitrary local ('lightify') queue is gated — there the mirror is the complete
// source of truth. In 'context' mode a real Spotify context advances through tracks
// the paged mirror may not hold yet, so gating would mute valid audio → fail open
// (empty set). Debounced because mirror mutations arrive in bursts.
let _expectedTracksTimer = null;
function pushExpectedTracksToEngine() {
  if (_expectedTracksTimer) return;
  _expectedTracksTimer = setTimeout(() => {
    _expectedTracksTimer = null;
    let ids = [];
    if (state.queueSource === 'lightify') {
      const set = new Set();
      const add = t => { const id = spotifyTrackId(t); if (id) set.add(id); };
      add(state.lightifyQueueCurrent);
      add(state.currentTrack);
      for (const t of state.lightifyQueue) add(t);
      // A few recently-played ids too, so a lagging poll re-report of a just-finished
      // track isn't mistaken for an interloper and muted.
      for (const t of (state.sessionHistory || []).slice(-4)) add(t);
      ids = [...set];
    }
    invoke('cmd_set_expected_tracks', { ids }).catch(() => {});
  }, 120);
}

function usesLocalQueueMirror() {
  return state.queueSource === 'lightify' || state.queueSource === 'context';
}

function setShuffleUiState(enabled) {
  state.shuffleOn = Boolean(enabled);
  dom.btnShuffle.classList.toggle('active', state.shuffleOn);
}

function shuffleQueueTracks(tracks, { preserveFirst = false } = {}) {
  const queueTracks = uniqueQueueTracks(tracks)
    .map(track => normalizeQueueTrack(track))
    .filter(Boolean);
  if (queueTracks.length <= 1) return queueTracks;

  const startIndex = preserveFirst ? 1 : 0;
  const shuffled = queueTracks.slice();
  for (let index = shuffled.length - 1; index > startIndex; index -= 1) {
    const swapIndex = startIndex + Math.floor(Math.random() * (index - startIndex + 1));
    const nextValue = shuffled[index];
    shuffled[index] = shuffled[swapIndex];
    shuffled[swapIndex] = nextValue;
  }
  return shuffled;
}

function reshuffleActiveQueue() {
  if (!usesLocalQueueMirror() || state.lightifyQueue.length <= 1) return 0;
  state.lightifyQueue = shuffleQueueTracks(state.lightifyQueue);
  refreshVisibleQueueSidebar();
  return state.lightifyQueue.length;
}

async function ensureBackendShuffleDisabled() {
  try {
    await invoke('cmd_shuffle', { enabled: false });
  } catch (err) {
    console.warn('failed to disable backend shuffle', err);
  }
}

function setLightifyQueue(tracks = [], currentTrack = null) {
  state.queueSource = 'lightify';
  state.lightifyQueue = uniqueQueueTracks(tracks);
  state.lightifyQueueCurrent = normalizeQueueTrack(currentTrack);
  state.lightifyQueueContextUri = null;
  clearStationAutoplay();
  clearSpotifySelectionAutoplay();
  clearBeatportAutoplay();
  refreshVisibleQueueSidebar();
}

function appendLightifyQueueTracks(tracks = []) {
  const additions = tracks.map(track => normalizeQueueTrack(track)).filter(Boolean);

  // In Spotify-queue mode the real Spotify queue owns ordering and the sidebar
  // mirrors it via cmd_queue. Seizing it into the local mirror here would
  // collapse the visible queue to only the freshly added tracks.
  if (state.queueSource === 'spotify') {
    refreshVisibleQueueSidebar();
    return;
  }

  // In context mode the local mirror drives playback, so additions must land in
  // the mirror (preserving the context) to actually play.
  if (state.queueSource === 'context') {
    if (additions.length > 0) state.lightifyQueue.push(...additions);
    refreshVisibleQueueSidebar();
    return;
  }

  // null / 'lightify': Lightify owns the queue locally.
  if (state.queueSource !== 'lightify') {
    clearStationAutoplay();
    clearSpotifySelectionAutoplay();
    clearBeatportAutoplay();
    state.queueSource = 'lightify';
    state.lightifyQueue = [];
    state.lightifyQueueCurrent = null;
    state.lightifyQueueContextUri = null;
  }
  if (additions.length > 0) {
    state.lightifyQueue.push(...additions);
  }
  refreshVisibleQueueSidebar();
}

function setContextQueue(tracks = [], currentTrack = null, contextUri = null) {
  state.queueSource = 'context';
  state.lightifyQueue = uniqueQueueTracks(tracks);
  state.lightifyQueueCurrent = normalizeQueueTrack(currentTrack);
  state.lightifyQueueContextUri = contextUri || null;
  clearStationAutoplay();
  clearSpotifySelectionAutoplay();
  clearBeatportAutoplay();
  refreshVisibleQueueSidebar();
}

function clearLightifyQueue({ stopBeatportAutoplay = true } = {}) {
  state.queueSource = 'lightify';
  state.lightifyQueue = [];
  state.lightifyQueueCurrent = null;
  state.lightifyQueueContextUri = null;
  clearStationAutoplay();
  clearSpotifySelectionAutoplay();
  if (stopBeatportAutoplay) clearBeatportAutoplay();
  refreshVisibleQueueSidebar();
}

function switchToSpotifyQueueSource() {
  state.queueSource = 'spotify';
  state.lightifyQueue = [];
  state.lightifyQueueCurrent = null;
  state.lightifyQueueContextUri = null;
  clearStationAutoplay();
  clearSpotifySelectionAutoplay();
  clearBeatportAutoplay();
  refreshVisibleQueueSidebar();
}

// Pure array move — returns a new array with the item relocated. Testable.
function moveQueueItem(list, from, to) {
  const arr = Array.isArray(list) ? list.slice() : [];
  if (!Number.isInteger(from) || from < 0 || from >= arr.length) return arr;
  const target = Math.max(0, Math.min(to, arr.length - 1));
  const [item] = arr.splice(from, 1);
  arr.splice(target, 0, item);
  return arr;
}

// Direct-manipulation queue edits (drag-reorder / remove). Only valid on a local
// mirror — the real Spotify queue can't be reordered client-side.
function reorderLightifyQueue(from, to) {
  if (!usesLocalQueueMirror()) return;
  const next = moveQueueItem(state.lightifyQueue, from, to);
  if (next.length !== state.lightifyQueue.length) return;
  state.lightifyQueue = next;
  refreshVisibleQueueSidebar();
}

function removeLightifyQueueItem(index) {
  if (!usesLocalQueueMirror()) return;
  if (!Number.isInteger(index) || index < 0 || index >= state.lightifyQueue.length) return;
  state.lightifyQueue.splice(index, 1);
  refreshVisibleQueueSidebar();
}

function consumeLightifyQueueTrack(track) {
  if (!usesLocalQueueMirror() || state.lightifyQueue.length === 0 || !track) return;
  const currentKey = queueTrackIdentity(track);
  const index = state.lightifyQueue.findIndex(entry => {
    if (currentKey && queueTrackIdentity(entry) === currentKey) return true;
    return entry.id && track.id && entry.id === track.id;
  });
  if (index < 0) return;
  // Removing a match from the middle of the mirror leaves the entries in front of
  // it stranded: the head still points at a track playback has already gone past,
  // so the next advance looks unexpected and the recovery path force-plays it —
  // the track that was quietly removed never gets heard. Align the mirror to where
  // playback actually is instead, and record the jump so the diagnostics report
  // shows whether something is still advancing behind the queue's back.
  if (index > 0) {
    logDiag('warn', 'queue', 'queue mirror jumped ' + index + ' track(s) to ' + (track.name || 'unknown'));
  }
  state.lightifyQueue.splice(0, index + 1);
  state.lightifyQueueCurrent = normalizeQueueTrack(track);
  refreshVisibleQueueSidebar();
}

function noteSpotifySelectionPlaybackBatch(tracks) {
  const sequence = state.spotifySelectionAutoplay;
  if (!sequence || !Array.isArray(sequence.sourceTracks) || !Array.isArray(sequence.loadedIds)) return;
  const batchKeys = new Set(tracks.map(track => queueTrackIdentity(track)).filter(Boolean));
  while (sequence.nextSourceIndex < sequence.sourceTracks.length) {
    const sourceTrack = normalizeQueueTrack(sequence.sourceTracks[sequence.nextSourceIndex]);
    const sourceKey = queueTrackIdentity(sourceTrack);
    if (!sourceKey || !batchKeys.has(sourceKey)) break;
    const trackId = resolvedSpotifyTrackId(sourceTrack) || sourceTrack.id;
    if (trackId && !sequence.loadedIds.includes(trackId)) sequence.loadedIds.push(trackId);
    sequence.nextSourceIndex += 1;
  }
  sequence.completed = sequence.nextSourceIndex >= sequence.sourceTracks.length;
}

async function playNextLightifyQueueTrack(options = {}) {
  if (state.lightifyQueueAdvancePending) return;

  const queueTracks = uniqueQueueTracks(state.lightifyQueue).filter(track => track.uri);
  if (queueTracks.length !== state.lightifyQueue.length) {
    state.lightifyQueue = queueTracks;
    refreshVisibleQueueSidebar();
  }

  if (queueTracks.length === 0) {
    setStatus('Queue is empty');
    return;
  }

  const playbackBatch = queueTracks.slice(0, SPOTIFY_SELECTION_INITIAL_QUEUE_COUNT);
  const nextTrack = playbackBatch[0];
  const trackUris = playbackBatch.map(track => track.uri);
  let played = false;
  const play = async () => {
    await invoke('cmd_play_uris', { trackUris });
    played = true;
  };

  state.lightifyQueueAdvancePending = true;
  try {
    await play();
  } catch (err) {
    await handlePlayError(String(err), play);
  } finally {
    state.lightifyQueueAdvancePending = false;
    const pollDelay = Number(options.pollDelay || 0);
    if (pollDelay > 0) setTimeout(pollNow, pollDelay);
  }

  if (!played) return;
  state.lightifyQueueCurrent = nextTrack;
  consumeLightifyQueueTrack(nextTrack);
  noteSpotifySelectionPlaybackBatch(playbackBatch);
}

async function playNextContextQueueTrack(options = {}) {
  if (state.lightifyQueueAdvancePending) return;

  const contextUri = state.lightifyQueueContextUri;
  const queueTracks = uniqueQueueTracks(state.lightifyQueue).filter(track => track.uri);
  if (queueTracks.length !== state.lightifyQueue.length) {
    state.lightifyQueue = queueTracks;
    refreshVisibleQueueSidebar();
  }

  if (!contextUri || queueTracks.length === 0) {
    const pollDelay = Number(options.pollDelay || 0);
    const fn_ = () => invoke('cmd_next');
    return fn_()
      .catch(err => handlePlayError(String(err), fn_))
      .finally(() => {
        if (pollDelay > 0) setTimeout(pollNow, pollDelay);
      });
  }

  const nextTrack = queueTracks[0];
  let played = false;
  const play = async () => {
    await invoke('cmd_play_track', { trackUri: nextTrack.uri, contextUri });
    played = true;
  };

  state.lightifyQueueAdvancePending = true;
  try {
    await play();
  } catch (err) {
    await handlePlayError(String(err), play);
  } finally {
    state.lightifyQueueAdvancePending = false;
    const pollDelay = Number(options.pollDelay || 0);
    if (pollDelay > 0) setTimeout(pollNow, pollDelay);
  }

  if (!played) return;
  state.lightifyQueueCurrent = nextTrack;
  consumeLightifyQueueTrack(nextTrack);
}

// ── Playback history navigation (Prev / Next) ────────────────────────────────
// A stable chronological timeline of played tracks with a cursor, distinct from
// sessionHistory (the move-to-front Recent list). Prev/Next only move the cursor,
// so playback can walk arbitrarily far back and forward without looping.
const NAV_HISTORY_LIMIT = 300;

function recordNavPlay(track) {
  const key = queueTrackIdentity(track);
  if (!key) return;
  // While a back/forward navigation is settling, ignore transient polls until the
  // exact navigated-to track is actually observed (otherwise the cursor corrupts).
  if (state.navExpectedKey) {
    if (key === state.navExpectedKey) state.navExpectedKey = null;
    return;
  }
  // Natural advance into the next history entry — just move the cursor forward.
  const nextEntry = state.navHistory[state.navIndex + 1];
  if (nextEntry && queueTrackIdentity(nextEntry) === key) {
    state.navIndex += 1;
    return;
  }
  // Re-detection / metadata refresh of the current entry — no change.
  const curEntry = state.navHistory[state.navIndex];
  if (curEntry && queueTrackIdentity(curEntry) === key) return;
  // A genuinely new play: drop any forward history, append, point the cursor at it.
  state.navHistory = state.navHistory.slice(0, state.navIndex + 1);
  state.navHistory.push(normalizeQueueTrack(track));
  while (state.navHistory.length > NAV_HISTORY_LIMIT) state.navHistory.shift();
  state.navIndex = state.navHistory.length - 1;
}

// Move the cursor by delta (-1 back, +1 forward); returns the target track or null
// at an edge. Sets navExpectedKey so the settling poll doesn't move the cursor.
function navigateHistory(delta) {
  const target = state.navIndex + delta;
  if (target < 0 || target >= state.navHistory.length) return null;
  state.navIndex = target;
  const track = normalizeQueueTrack(state.navHistory[target]);
  state.navExpectedKey = track ? queueTrackIdentity(track) : null;
  return track;
}

async function restartCurrentTrack(options = {}) {
  const pollDelay = Number(options.pollDelay || 0);
  if (spotifyTrackUri(state.currentTrack)) {
    try {
      await (stationOwnsPlayback() ? invoke('cmd_engine_seek', { positionMs: 0 }) : invoke('cmd_seek', { positionMs: 0 }));
      const dur = Number(state.currentTrack?.duration_ms || state.playbackSnapshot?.durationMs || 0);
      setPlaybackSnapshot(0, dur, state.isPlaying);
    } catch (err) {
      setStatus(String(err));
    }
  }
  if (pollDelay > 0) setTimeout(pollNow, pollDelay);
}

// Play a specific track for back/forward navigation (context-aware), without
// disturbing the upcoming-queue mirror.
async function playNavTrack(track, options = {}) {
  if (!track || !track.uri) return;
  const contextUri = state.queueSource === 'context' ? state.lightifyQueueContextUri : null;
  const play = contextUri
    ? () => invoke('cmd_play_track', { trackUri: track.uri, contextUri })
    : () => invoke('cmd_play_uris', { trackUris: [track.uri] });
  state.lightifyQueueAdvancePending = true;
  try {
    await play();
  } catch (err) {
    await handlePlayError(String(err), play);
  } finally {
    state.lightifyQueueAdvancePending = false;
    const pollDelay = Number(options.pollDelay || 0);
    if (pollDelay > 0) setTimeout(pollNow, pollDelay);
  }
  state.lightifyQueueCurrent = track;
  refreshVisibleQueueSidebar();
}

// "Previous" for a Lightify-managed queue: restart the current track past a few
// seconds, otherwise step back through the navigation history.
async function playPreviousLocalQueueTrack(options = {}) {
  if (state.lightifyQueueAdvancePending) return;
  if (currentPlaybackProgressMs() > 3000) return restartCurrentTrack(options);
  const prev = navigateHistory(-1);
  if (!prev || !prev.uri) return restartCurrentTrack(options);
  return playNavTrack(prev, options);
}

// "Next" for a Lightify-managed queue: if we've stepped back and there's forward
// history, walk it; otherwise advance the upcoming queue.
async function playNextLocalQueueTrack(options = {}) {
  const forward = navigateHistory(1);
  if (forward && forward.uri) return playNavTrack(forward, options);
  return state.queueSource === 'context'
    ? playNextContextQueueTrack(options)
    : playNextLightifyQueueTrack(options);
}

async function enqueueTrackBatch(tracks) {
  const queueTracks = uniqueQueueTracks(playableTracks(tracks));
  if (queueTracks.length === 0) return [];
  for (const track of queueTracks) {
    await invoke('cmd_add_to_queue', { uri: track.uri });
  }
  appendLightifyQueueTracks(queueTracks);
  return queueTracks;
}

async function playSpotifyTrackSelection(tracks) {
  let queueTracks = uniqueQueueTracks(playableTracks(tracks));
  if (state.shuffleOn) {
    queueTracks = shuffleQueueTracks(queueTracks, { preserveFirst: true });
  }
  const trackUris = queueTracks.map(track => track.uri);
  if (trackUris.length === 0) {
    setStatus('Selection has no playable tracks');
    return;
  }

  clearSpotifySelectionAutoplay();
  await ensureBackendShuffleDisabled();
  const initialQueueCount = Math.min(SPOTIFY_SELECTION_INITIAL_QUEUE_COUNT, trackUris.length);
  const initialTrackUris = trackUris.slice(0, initialQueueCount);
  const fn_ = () => invoke('cmd_play_uris', { trackUris: initialTrackUris });
  await fn_().catch(err => handlePlayError(String(err), fn_));
  clearBeatportAutoplay();
  setLightifyQueue(queueTracks.slice(1), queueTracks[0]);

  if (queueTracks.length > initialQueueCount) {
    state.spotifySelectionAutoplay = {
      sourceTracks: queueTracks,
      nextSourceIndex: initialQueueCount,
      nextBatchSize: SPOTIFY_SELECTION_INITIAL_QUEUE_COUNT,
      loadedIds: queueTracks
        .slice(0, initialQueueCount)
        .map(track => resolvedSpotifyTrackId(track) || track.id)
        .filter(Boolean),
      loading: false,
      completed: false,
    };
    prefillSpotifySelectionAutoplay(state.spotifySelectionAutoplay)
      .catch(err => console.warn('spotify selection prefill failed', err));
  }

  const bufferedCount = Math.max(0, initialQueueCount - 1);
  const deferredCount = Math.max(0, trackUris.length - initialQueueCount);
  setStatus(
    deferredCount > 0
      ? `Playing ${trackUris.length} selected tracks (${bufferedCount} buffered, ${deferredCount} deferred)`
      : `Playing ${trackUris.length} selected tracks`
  );
  scheduleFastPlaybackPoll();
}

async function playSpotifyPlaylistSelection(tracks) {
  clearLightifyQueue();
  return playSpotifyTrackSelection(tracks);
}

async function playSpotifyContextSelection(contextUri, tracks) {
  const queueTracks = uniqueQueueTracks(playableTracks(tracks));
  const leadTrack = queueTracks[0];
  if (!contextUri || !leadTrack?.uri) {
    return playSpotifyPlaylistSelection(queueTracks);
  }

  // Residue from a previous play is a reason to *clear* the local queue, not to
  // abandon the context path. Bailing out here meant only the first play of a
  // session got a real Spotify context; every later one fell through to the
  // uris-based selection path, where Spotify's user queue (which plays ahead of
  // any context) and the local mirror advance in different orders — the source of
  // the mid-playlist wrong-track bursts and skipped tracks.
  const hasLocalQueueResidue = state.queueSource === 'lightify'
    || !!state.beatportAutoplay
    || !!state.spotifySelectionAutoplay
    || !!state.stationAutoplay
    || !!state.lightifyQueueCurrent
    || state.lightifyQueue.length > 0;

  if (hasLocalQueueResidue) {
    clearLightifyQueue();
  }

  if (state.shuffleOn) {
    return playSpotifyTrackSelection(queueTracks);
  }

  await ensureBackendShuffleDisabled();
  const fn_ = () => invoke('cmd_play_track', { trackUri: leadTrack.uri, contextUri });
  await fn_().catch(err => handlePlayError(String(err), fn_));
  setContextQueue(queueTracks.slice(1), leadTrack, contextUri);
  setStatus(`Playing ${queueTracks.length} playlist tracks`);
  scheduleFastPlaybackPoll();
}

async function addSpotifyTrackSelectionToQueue(tracks) {
  const queuedTracks = await enqueueTrackBatch(tracks);
  if (queuedTracks.length === 0) {
    setStatus('Selection has no queueable tracks');
    return;
  }
  setStatus(`Added ${queuedTracks.length} selected tracks to queue`);
}

async function createSpotifyPlaylistFromSelection(tracks) {
  const trackUris = uniqueTrackUris(tracks);
  if (trackUris.length === 0) {
    setStatus('Selection has no playlist tracks');
    return;
  }
  const playlist = await invoke('cmd_create_playlist', {
    name: buildTimestampedPlaylistName('Selection'),
    trackUris,
  });
  setStatus('Created: ' + playlist.name);
  loadPlaylists();
}

function buildSpotifyTrackMenu(track, playAction) {
  return buildSpotifyTrackMenuWithOptions(track, playAction);
}

function buildSpotifyTrackMenuWithOptions(track, playAction, options = {}) {
  const { showLike = false } = options;
  const items = [
    { label: 'Play', action: playAction },
  ];
  const trackUri = spotifyTrackUri(track);
  if (trackUri) {
    items.push({
      label: 'Add to queue',
      action: () => enqueueTrackBatch([track])
        .then(queuedTracks => {
          if (queuedTracks.length > 0) setStatus('Added to queue');
        })
        .catch(err => setStatus(String(err))),
    });
  }
  if (track && track.id) {
    if (showLike) {
      items.push({ label: 'Like', action: () => likeTrack(track, false).catch(err => setStatus(String(err))) });
    }
    items.push({ label: 'Share', action: () => shareSpotifyTrack(track).catch(err => setStatus(String(err))) });
    items.push({ label: 'Start station', action: () => startStationFromTrack(track) });
    if (track.artist_ids && track.artist_ids.length > 0) {
      items.push({ label: 'Follow artist', action: () => invoke('cmd_follow_artist', { artistId: track.artist_ids[0] }).then(() => setStatus('Artist followed')).catch(err => setStatus(String(err))) });
    }
    items.push({ label: 'Download', action: () => enqueueTrackDownload(track) });
  }
  return items;
}

async function buildSearchTrackMenu(track, playAction) {
  let showLike = false;
  if (track && track.id) {
    try {
      showLike = !(await invoke('cmd_check_liked', { trackId: track.id }));
    } catch (_) {
      showLike = false;
    }
  }
  return buildSpotifyTrackMenuWithOptions(track, playAction, { showLike });
}

function isSearchTrackContainer(container) {
  return container === dom.searchResults
    || container === dom.searchDrill
    || (container instanceof Element && container.classList.contains('search-expansion-list'));
}

function buildSpotifySelectionMenu(entries) {
  const tracks = entries.map(entry => entry.data.track).filter(Boolean);
  const trackCount = uniqueTrackUris(tracks).length;
  if (trackCount === 0) return [];
  return [
    { label: `Play ${trackCount} selected`, action: () => playSpotifyTrackSelection(tracks).catch(err => setStatus(String(err))) },
    { label: `Add ${trackCount} to queue`, action: () => addSpotifyTrackSelectionToQueue(tracks).catch(err => setStatus(String(err))) },
    { label: 'Create playlist from selection', action: () => createSpotifyPlaylistFromSelection(tracks).catch(err => setStatus(String(err))) },
  ];
}

function registerSelectableRow({ scope, key, container, row, data, onActivate, getMenuItems, getSelectionMenuItems, onSelectedChange, onDoubleClick }) {
  const registryKey = selectionRegistryKey(scope, key);
  selectionState.registry.set(registryKey, {
    scope,
    container,
    row,
    data,
    onSelectedChange,
  });
  row.dataset.selectionRegistryKey = registryKey;

  if (selectionState.selectedKeys.has(registryKey)) {
    row.classList.add('selected');
    if (typeof onSelectedChange === 'function') onSelectedChange(true);
  } else {
    row.classList.remove('selected');
  }

  applyTrackPlaybackPresence(row, playbackTrackFromRowData(data));

  row.addEventListener('pointerdown', e => {
    if (e.button !== 0) return;
    clearSelectionGesture();
    selectionState.pointerDown = true;
    selectionState.pointerId = e.pointerId;
    selectionState.gestureScope = scope;
    selectionState.gestureContainer = container;
    noteSelectionPointer(e);
    selectionState.pressTimer = setTimeout(() => {
      row.__skipClick = true;
      selectionState.dragActive = true;
      selectionState.dragVisitedKeys.clear();
      selectDraggedRow(registryKey);
      selectDraggedRowAtPointer();
    }, LONG_PRESS_SELECT_MS);
  });

  row.addEventListener('pointerenter', e => {
    if (!selectionState.dragActive || !selectionState.pointerDown || selectionState.pointerId !== e.pointerId) return;
    noteSelectionPointer(e);
    maybeSelectDraggedRow(row);
  });

  row.addEventListener('click', e => {
    if (row.__skipClick) {
      row.__skipClick = false;
      e.preventDefault();
      e.stopPropagation();
      return;
    }
    if (typeof onActivate === 'function') onActivate(e);
  });

  row.addEventListener('dblclick', e => {
    if (typeof onDoubleClick === 'function') onDoubleClick(e);
  });

  row.addEventListener('contextmenu', async e => {
    e.preventDefault();
    e.stopPropagation();
    const selectedItems = getSelectedEntries(scope, container);
    const showGroupMenu = selectedItems.length > 0 && selectionState.selectedKeys.has(registryKey);
    const items = showGroupMenu
      ? await Promise.resolve(getSelectionMenuItems?.(selectedItems))
      : await Promise.resolve(getMenuItems?.(data));
    showContextMenu(e, items || []);
  });

  return registryKey;
}

function openConfirmDialog({ title = 'CONFIRM', message = 'Are you sure?', acceptLabel = 'Yes', cancelLabel = 'No', danger = false, onAccept }) {
  closeContextMenu();
  closeConfirmDialog();

  const titleEl = $('confirm-title');
  if (titleEl) titleEl.textContent = title;
  if (dom.confirmMessage) dom.confirmMessage.textContent = message;
  if (dom.btnConfirmAccept) {
    dom.btnConfirmAccept.textContent = acceptLabel;
    dom.btnConfirmAccept.classList.toggle('confirm-danger', danger);
  }
  if (dom.btnConfirmCancel) dom.btnConfirmCancel.textContent = cancelLabel;

  state.confirmDialog = { onAccept };
  dom.confirmOverlay.classList.remove('hidden');
  dom.btnConfirmAccept?.focus();
}

function closeConfirmDialog() {
  state.confirmDialog = null;
  dom.confirmOverlay?.classList.add('hidden');
}

async function acceptConfirmDialog() {
  const dialog = state.confirmDialog;
  closeConfirmDialog();
  if (!dialog || typeof dialog.onAccept !== 'function') return;
  await dialog.onAccept();
}

function confirmDeleteLibraryPlaylist(pl) {
  openConfirmDialog({
    title: 'DELETE PLAYLIST',
    message: `Delete "${pl.name}" from your Spotify library?`,
    acceptLabel: 'Yes',
    cancelLabel: 'No',
    danger: true,
    onAccept: async () => {
      await invoke('cmd_unfollow_playlist', { playlistId: pl.id });
      await loadPlaylists();
      setStatus('Deleted playlist: ' + pl.name);
    },
  });
}

document.addEventListener('pointerup', clearSelectionGesture, true);
document.addEventListener('pointercancel', clearSelectionGesture, true);
document.addEventListener('pointermove', handleSelectionPointerMove, true);
document.addEventListener('keydown', e => {
  if (e.key === 'Escape' && state.confirmDialog) {
    e.preventDefault();
    closeConfirmDialog();
  }
});

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

function formatMs(ms) {
  const s = Math.floor(ms / 1000);
  const m = Math.floor(s / 60);
  return m + ':' + String(s % 60).padStart(2, '0');
}

function setStatus(msg) {
  dom.statusText.textContent = msg;
}

function showAuth(msg, isError) {
  dom.authStatus.textContent = msg;
  dom.authStatus.className = 'status' + (isError ? ' error' : ' ok');
}

function hide(el) { el.classList.add('hidden'); }
function show(el) { el.classList.remove('hidden'); }

function compareDisplayText(left, right) {
  return String(left || '').localeCompare(String(right || ''), undefined, {
    sensitivity: 'base',
    numeric: true,
  });
}

function isTrackListVisible() {
  return !dom.trackList.classList.contains('hidden');
}

function getLibrarySortStateKeys() {
  return isTrackListVisible()
    ? { modeKey: 'trackListSortMode', directionKey: 'trackListAlphaDirection' }
    : { modeKey: 'libraryListSortMode', directionKey: 'libraryListAlphaDirection' };
}

function findCurrentTrackRowInContainer(container) {
  if (!container || !state.currentTrack) return null;

  for (const meta of selectionState.registry.values()) {
    if (meta?.container !== container) continue;
    const row = meta?.row;
    const track = playbackTrackFromRowData(meta?.data);
    if (!(row instanceof HTMLElement) || !row.isConnected || !track) continue;
    if (samePlaybackTrack(state.currentTrack, track)) return row;
  }

  return null;
}

function updateLibrarySortControls() {
  const { modeKey, directionKey } = getLibrarySortStateKeys();
  dom.btnLibrarySortRecent.classList.toggle('active', state[modeKey] === 'recent');
  dom.btnLibrarySortAlpha.classList.toggle('active', state[modeKey] === 'alpha');
  dom.btnLibrarySortAlpha.textContent = state[directionKey] === 'asc' ? 'A → Z' : 'Z → A';
}

function sortPlaylistsForDisplay(playlists) {
  const items = Array.isArray(playlists) ? playlists.slice() : [];
  if (state.libraryListSortMode !== 'alpha') return items;
  const direction = state.libraryListAlphaDirection === 'asc' ? 1 : -1;
  return items.sort((left, right) => direction * compareDisplayText(left?.name, right?.name));
}

function trackAddedAtMs(track) {
  const value = typeof track?.added_at === 'string' ? track.added_at : '';
  const ms = Date.parse(value);
  return Number.isFinite(ms) ? ms : 0;
}

function recentHistoryTimestampMs(track) {
  const addedAtMs = trackAddedAtMs(track);
  if (addedAtMs > 0) return addedAtMs;
  const timestamp = Number.isFinite(track?.timestamp) ? track.timestamp : 0;
  return timestamp > 0 ? timestamp : 0;
}

function sortTracksForDisplay(tracks) {
  const items = Array.isArray(tracks) ? tracks : [];
  const direction = state.trackListAlphaDirection === 'asc' ? 1 : -1;
  return items
    .map((track, index) => ({ track, index }))
    .sort((left, right) => {
      if (state.trackListSortMode === 'alpha') {
        const byName = compareDisplayText(left.track?.name, right.track?.name);
        if (byName !== 0) return direction * byName;
        const byArtist = compareDisplayText(left.track?.artists, right.track?.artists);
        if (byArtist !== 0) return direction * byArtist;
      } else {
        const byAddedAt = trackAddedAtMs(right.track) - trackAddedAtMs(left.track);
        if (byAddedAt !== 0) return byAddedAt;
      }
      return left.index - right.index;
    })
    .map(entry => entry.track);
}

function renderCurrentTrackPager(pager = state.activeTrackPager) {
  if (!pager) return;
  const previousScrollTop = dom.trackList.scrollTop;
  renderTrackList(dom.trackList, sortTracksForDisplay(pager.tracks), pager.contextUri);
  dom.trackList.scrollTop = previousScrollTop;
  updateLibrarySortControls();
}

async function prefetchRemainingTrackPages(pager) {
  if (!pager || pager.prefetchingAll || pager.done) return;
  pager.prefetchingAll = true;
  try {
    while (pagerIsActive(pager) && !pager.done) {
      await loadNextTrackPage(pager);
    }
    if (pagerIsActive(pager) && pager.loaded > 0) {
      renderCurrentTrackPager(pager);
    }
  } finally {
    pager.prefetchingAll = false;
  }
}

function rerenderVisibleLibrarySurface() {
  if (isTrackListVisible()) {
    renderCurrentTrackPager();
  } else {
    renderPlaylistList();
  }
  updateLibrarySortControls();
}

// ── Window controls ──────────────────────────────────────────

dom.btnMin.addEventListener('click', () => appWindow.minimize());
dom.btnMax.addEventListener('click', () => appWindow.toggleMaximize());
dom.btnTray.addEventListener('click', () => invoke('cmd_hide_to_tray').catch(err => setStatus(String(err))));
dom.btnClose.addEventListener('click', () => appWindow.close());

// ── Mini player ──────────────────────────────────────────────
// Compact, always-on-top background mode with three layouts:
//   square — the original cover-forward column (360×420)
//   bar    — a short wide strip: cover + text + progress + transport (460×112)
//   nano   — cover-only tile; controls overlay on hover (200×200)
// Plus idle-fade chrome, an edge progress line, an album-tinted frame, and a
// snap control to switch layouts. The last layout is remembered across sessions.
const MINI_MODES = ['square', 'bar', 'nano'];
// Window targets (logical px). Heights account for the 30px titlebar; the
// statusbar is hidden in mini modes. These sit below the app's normal min-size,
// so setMiniMode lowers the min-size before resizing (see below).
const MINI_MODE_SIZES = {
  square: { w: 360, h: 440 },
  bar: { w: 480, h: 136 },
  nano: { w: 240, h: 270 },
};
const MINI_DEFAULT_MIN = { w: 320, h: 260 }; // mirrors tauri.conf.json minWidth/minHeight
const MINI_MODE_STORAGE_KEY = 'lightify.miniMode.v1';
const MINI_IDLE_MS = 2500;
let miniPlayerPrevSize = null;
let miniIdleTimer = null;

function lastMiniMode() {
  const stored = readStoredObject(MINI_MODE_STORAGE_KEY).mode;
  return MINI_MODES.includes(stored) ? stored : 'square';
}

async function setMiniMode(mode) {
  const nextMode = mode === 'off' || MINI_MODES.includes(mode) ? mode : 'square';
  // In Tauri v2, LogicalSize lives under `dpi`; older builds expose it on `window`.
  const LogicalSize = window.__TAURI__?.dpi?.LogicalSize || window.__TAURI__?.window?.LogicalSize;
  const size = (w, h) => (LogicalSize ? new LogicalSize(w, h) : { type: 'Logical', width: w, height: h });
  const wasOff = state.miniMode === 'off';
  const isMini = nextMode !== 'off';

  // Capture the pre-mini size once (only when entering from the full window).
  if (isMini && wasOff) {
    try {
      const inner = await appWindow.innerSize();
      const factor = await appWindow.scaleFactor();
      miniPlayerPrevSize = { w: Math.round(inner.width / factor), h: Math.round(inner.height / factor) };
    } catch (_) { miniPlayerPrevSize = null; }
  }

  state.miniMode = nextMode;
  const body = document.body;
  body.classList.toggle('mini-player', isMini);
  MINI_MODES.forEach(m => body.classList.toggle('mini-' + m, nextMode === m));
  if (!isMini) body.classList.remove('mini-idle');

  if (isMini) {
    const dims = MINI_MODE_SIZES[nextMode] || MINI_MODE_SIZES.square;
    // A maximized window silently ignores setSize, and the mode targets are
    // smaller than the configured min-size — so un-maximize and lower the
    // min-size first, or the window stays full-screen (the reported bug).
    try { await appWindow.unmaximize(); } catch (_) {}
    try { await appWindow.setMinSize(size(dims.w, dims.h)); } catch (_) {}
    try { await appWindow.setSize(size(dims.w, dims.h)); } catch (_) {}
    try { await appWindow.setAlwaysOnTop(true); } catch (_) {}
    writeStoredObject(MINI_MODE_STORAGE_KEY, { mode: nextMode });
    startMiniIdleWatch();
  } else {
    const restore = miniPlayerPrevSize || { w: 1000, h: 620 };
    // Restore the normal min-size before growing back, else setSize is clamped.
    try { await appWindow.setMinSize(size(MINI_DEFAULT_MIN.w, MINI_DEFAULT_MIN.h)); } catch (_) {}
    try { await appWindow.setSize(size(restore.w, restore.h)); } catch (_) {}
    try { await appWindow.setAlwaysOnTop(false); } catch (_) {}
    stopMiniIdleWatch();
  }

  updateMiniModeUi();
  updateMiniEdgeProgress();
}

function updateMiniModeUi() {
  const on = state.miniMode !== 'off';
  if (dom.btnMiniPlayer) {
    dom.btnMiniPlayer.classList.toggle('active', on);
    dom.btnMiniPlayer.title = on ? 'Exit mini player' : 'Mini player';
    dom.btnMiniPlayer.setAttribute('aria-pressed', String(on));
  }
  if (dom.miniModes) {
    dom.miniModes.querySelectorAll('.mini-mode-btn').forEach(btn => {
      btn.classList.toggle('active', btn.dataset.miniMode === state.miniMode);
    });
  }
}

// Mirror the playback progress onto the thin edge line pinned to the mini window.
function updateMiniEdgeProgress() {
  if (!dom.miniEdge) return;
  dom.miniEdge.style.width = state.miniMode === 'off' ? '0%' : (dom.progressFill.style.width || '0%');
}

// Idle-fade: after MINI_IDLE_MS without pointer movement, dim the chrome (title,
// progress, controls, snap) while the cover stays. Any movement brings it back.
function startMiniIdleWatch() {
  document.addEventListener('mousemove', handleMiniPointerActivity, true);
  scheduleMiniIdle();
}
function stopMiniIdleWatch() {
  document.removeEventListener('mousemove', handleMiniPointerActivity, true);
  clearTimeout(miniIdleTimer);
  document.body.classList.remove('mini-idle');
}
function scheduleMiniIdle() {
  clearTimeout(miniIdleTimer);
  miniIdleTimer = setTimeout(() => {
    if (state.miniMode !== 'off') document.body.classList.add('mini-idle');
  }, MINI_IDLE_MS);
}
function handleMiniPointerActivity() {
  if (state.miniMode === 'off') return;
  document.body.classList.remove('mini-idle');
  scheduleMiniIdle();
}

function toggleMiniPlayer() {
  const next = state.miniMode === 'off' ? lastMiniMode() : 'off';
  setMiniMode(next).catch(err => console.warn('mini-player toggle failed', err));
}

if (dom.btnMiniPlayer) {
  dom.btnMiniPlayer.addEventListener('click', toggleMiniPlayer);
}
if (dom.miniModes) {
  dom.miniModes.querySelectorAll('.mini-mode-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      setMiniMode(btn.dataset.miniMode).catch(err => console.warn('mini-mode switch failed', err));
    });
  });
}
if (dom.nowPlaying) {
  dom.nowPlaying.addEventListener('click', event => {
    // Nano mini-mode: click the cover (anywhere but a control/snap button) to expand.
    if (state.miniMode === 'nano') {
      if (event.target.closest('#controls') || event.target.closest('#mini-modes')) return;
      setMiniMode('square').catch(err => console.warn('mini-mode expand failed', err));
      return;
    }
    // Only the full (non-mini) player has the dual-sidebar layout.
    if (state.miniMode !== 'off') return;
    // Clicking the center (artwork / background), but NOT an actual control, closes an
    // open sidebar. When both are open, close the RIGHT sidebar first; a second click
    // then closes the LEFT — always right-before-left.
    if (event.target.closest('#controls, #progress-row, #volume-row, #extras-row, #sidebar, button, input, a')) return;
    if (!dom.sidebar.classList.contains('hidden')) {
      closeSidebar();
      return;
    }
    if (!state.leftPanelCollapsed) {
      setLeftPanelCollapsed(true);
    }
  });
}
dom.btnLeftPanelToggle.addEventListener('click', () => {
  setLeftPanelCollapsed(!state.leftPanelCollapsed);
});
dom.btnLogout.addEventListener('click', () => logout());

// ── Auth ─────────────────────────────────────────────────────

async function tryRestore() {
  try {
    const cfg = await invoke('get_config');
    if (cfg.client_id) dom.clientId.value = cfg.client_id;

    const displayName = await invoke('try_cached_auth');
    await enterPlayer(displayName);
  } catch (e) {
    console.error('Restore failed:', e);
    // No usable cached session → reveal the onboarding/auth page (hidden by default
    // so it never flashes under the splash while cached auth is resolving).
    show(dom.authPage);
  }
}

async function launchAuthFlow({ retry = false } = {}) {
  const cid = dom.clientId.value.trim();
  if (cid.length < 10) { showAuth('Invalid Client ID.', true); return; }

  state.authFlowPending = true;
  dom.btnAuth.disabled = true;
  showAuth(retry ? 'Retrying browser — complete the login…' : 'Opening browser — complete the login…', false);

  try {
    const displayName = await invoke('start_auth', { clientId: cid });
    await enterPlayer(displayName);
  } catch (e) {
    showAuth(String(e), true);
  } finally {
    state.authFlowPending = false;
    dom.btnAuth.disabled = false;
  }
}

async function retryAuthFlow() {
  if (state.authFlowPending) {
    showAuth('Re-opening browser — complete the login…', false);
    try {
      await invoke('cmd_retry_auth_browser');
    } catch (e) {
      showAuth(String(e), true);
    }
    return;
  }

  await launchAuthFlow({ retry: true });
}

dom.btnAuth.addEventListener('click', () => {
  launchAuthFlow().catch(err => showAuth(String(err), true));
});

dom.btnAuthRetry.addEventListener('click', () => {
  retryAuthFlow().catch(err => showAuth(String(err), true));
});

dom.btnOpenSpotifyDashboard.addEventListener('click', () => {
  invoke('cmd_open_spotify_dashboard').catch(err => showAuth(String(err), true));
});

async function enterPlayer(name) {
  hide(dom.authPage);
  show(dom.playerPage);
  show(dom.btnLogout);
  show(dom.btnSettings);
  setStatus('Logged in as ' + (name || 'Unknown'));

  // Rehydrate the queue mirror + Recent list from the previous session before
  // polling/rendering kicks in. Never auto-resumes playback.
  restorePersistedState();

  try {
    await invoke('cmd_restart_native_audio');
  } catch (e) {
    console.warn('Native audio backend unavailable:', e);
  }

  loadPlaylists();
  refreshPersistedRecentHistory({ silent: true }).catch(() => {});
  startPolling();
  startProgressTicker();
  startAudioOutputWatcher();
  ensureDevice({ timeoutMs: 6000, reportFailure: false }).catch(() => {});
}

// ── Tab switching ────────────────────────────────────────────

const tabs = [
  { btn: dom.tabLibrary,  content: dom.libraryContent },
  { btn: dom.tabSearch,   content: dom.searchContent },
  { btn: dom.tabBeatport, content: dom.beatportContent },
];

tabs.forEach(({ btn, content }) => {
  btn.addEventListener('click', () => {
    tabs.forEach(t => {
      t.btn.classList.remove('active');
      hide(t.content);
    });
    btn.classList.add('active');
    show(content);

    // Load Beatport genres on first open
    if (btn === dom.tabBeatport && state.beatportGenres.length === 0) {
      loadBeatportGenres();
    } else if (btn === dom.tabBeatport && state.beatportTracks.length === 0 && !state.beatportFetchLoading) {
      requestBeatportChartFetch().catch(err => {
        dom.bpStatus.textContent = 'Fetch failed: ' + err;
      });
    }
  });
});

// ── Library: playlists + tracks ──────────────────────────────

async function logout() {
  try { await invoke('cmd_logout'); } catch (_) {}
  show(dom.authPage);
  hide(dom.playerPage);
  hide(dom.btnLogout);
  hide(dom.btnSettings);
  if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
  if (state.progressTickTimer) { clearInterval(state.progressTickTimer); state.progressTickTimer = null; }
  state.isPlaying = false;
  state.playbackSnapshot = null;
  state.currentTrack = null;
  state.sessionHistory = [];
  state.persistedRecentHistory = [];
  state.recentHistoryClearedAtMs = 0;
  state.recentHistoryLoaded = false;
  state.recentHistoryLoading = false;
  state.activeLibraryPlaybackSourceId = null;
  state.activeLibraryPlaybackContextUri = null;
  refreshTrackPlaybackPresence();
  refreshLibraryPlaybackPresence();
  setStatus('');
}

async function forceReauth(message) {
  await logout();
  showAuth(message, true);
  setStatus(message);
}

async function loadPlaylists() {
  try {
    state.playlists = await invoke('cmd_playlists');
    renderPlaylistList();
  } catch (e) {
    setStatus('Failed to load playlists: ' + String(e));
  }
}

async function playPlaylistFromStart(pl) {
  rememberActiveLibraryPlaybackSource(pl?.id || null, pl?.uri || null);
  if (!pl?.id || !pl?.uri) {
    switchToSpotifyQueueSource();
    return playContext(pl?.uri || null);
  }

  try {
    let page = await invoke('cmd_playlist_tracks_page', {
      playlistId: pl.id,
      offset: 0,
      limit: PLAYLIST_PAGE_LIMIT,
      refresh: false,
    });
    if (page?.cached) {
      page = await invoke('cmd_playlist_tracks_page', {
        playlistId: pl.id,
        offset: 0,
        limit: PLAYLIST_PAGE_LIMIT,
        refresh: true,
      });
    }
    const tracks = Array.isArray(page?.tracks) ? page.tracks : [];
    if (tracks.length === 0) {
      switchToSpotifyQueueSource();
      return playContext(pl.uri);
    }
    return playSpotifyContextSelection(pl.uri, tracks);
  } catch (_) {
    switchToSpotifyQueueSource();
    return playContext(pl.uri);
  }
}

function renderPlaylistList() {
  state.playlistLoadToken++;
  detachActiveTrackPager();
  dom.playlistList.innerHTML = '';
  hide(dom.trackList);
  hide(dom.btnBack);
  show(dom.playlistList);
  state.currentPlaylistId = null;
  state.currentPlaylistUri = null;

  const liked = makeListItem('Liked Songs', 'Your saved Spotify tracks', 'liked');
  liked.setAttribute('data-library-source-id', 'liked');
  liked.setAttribute('data-library-context-uri', '');
  applyLibraryPlaybackPresence(liked, 'liked', null);
  liked.addEventListener('click', openLikedSongs);
  liked.addEventListener('contextmenu', e => {
    e.preventDefault();
    e.stopPropagation();
    showContextMenu(e, [
      { label: 'Open', action: openLikedSongs },
      { label: 'Download liked songs', action: () => enqueueLikedSongsDownload() },
    ]);
  });
  dom.playlistList.appendChild(liked);

  sortPlaylistsForDisplay(state.playlists).forEach(pl => {
    const div = makeListItem(
      pl.name,
      pl.tracks + ' tracks • ' + pl.owner,
      'playlist'
    );
    div.setAttribute('data-library-source-id', pl.id);
    div.setAttribute('data-library-context-uri', pl.uri || '');
    applyLibraryPlaybackPresence(div, pl.id, pl.uri || null);
    div.addEventListener('click', () => openPlaylist(pl));
    // Double-click plays the whole playlist as a context — same path as
    // right-click → Play. Single click stays as "expand to track list".
    div.addEventListener('dblclick', e => {
      e.preventDefault();
      e.stopPropagation();
      playPlaylistFromStart(pl).catch(err => setStatus(String(err)));
    });
    div.addEventListener('contextmenu', e => {
      e.preventDefault();
      e.stopPropagation();
      showContextMenu(e, [
        { label: 'Play', action: () => playPlaylistFromStart(pl).catch(err => setStatus(String(err))) },
        { label: 'Save to library', action: () => invoke('cmd_follow_playlist', { playlistId: pl.id }).then(() => setStatus('Playlist saved to library')).catch(err => setStatus(String(err))) },
        { label: 'Download', action: () => enqueuePlaylistDownload(pl) },
        { label: 'Delete', danger: true, action: () => confirmDeleteLibraryPlaylist(pl) },
      ]);
    });
    dom.playlistList.appendChild(div);
  });

  updateLibrarySortControls();
}

function detachActiveTrackPager() {
  if (state.activeTrackPager && state.activeTrackPager.onScroll) {
    dom.trackList.removeEventListener('scroll', state.activeTrackPager.onScroll);
  }
  state.activeTrackPager = null;
}

function nearTrackListBottom() {
  return dom.trackList.scrollTop + dom.trackList.clientHeight >= dom.trackList.scrollHeight - TRACK_PAGE_SCROLL_THRESHOLD;
}

async function startPagedTrackList(options) {
  detachActiveTrackPager();
  const loadToken = ++state.playlistLoadToken;
  state.currentPlaylistId = options.sourceId;
  state.currentPlaylistUri = options.contextUri || null;
  hide(dom.playlistList);
  show(dom.trackList);
  show(dom.btnBack);
  dom.trackList.scrollTop = 0;
  updateLibrarySortControls();
  renderListSkeleton(dom.trackList);
  setStatus('Loading "' + options.name + '"...');

  const pager = {
    loadToken,
    sourceId: options.sourceId,
    name: options.name,
    contextUri: options.contextUri || null,
    offset: 0,
    total: 0,
    loaded: 0,
    done: false,
    loading: false,
    limit: options.limit,
    tracks: [],
    loadPage: options.loadPage,
    onScroll: null,
  };
  pager.onScroll = () => {
    if (nearTrackListBottom()) loadNextTrackPage(pager);
  };
  state.activeTrackPager = pager;
  dom.trackList.addEventListener('scroll', pager.onScroll);
  await loadNextTrackPage(pager);
}

function cleanSpotifyError(e) {
  return String(e).replace(/^SPOTIFY_FORBIDDEN:\s*/, '403 Forbidden: ')
                  .replace(/^SPOTIFY_NO_DEVICE:\s*/, '404 Not Found: ')
                  .replace(/^SPOTIFY_AUTH_EXPIRED:\s*/, '401 Unauthorized: ')
                  .replace(/^SPOTIFY_RATE_LIMITED:\s*/, '');
}

function isSpotifyRateLimitedError(e) {
  const message = String(e);
  return message.startsWith('SPOTIFY_RATE_LIMITED:') || message.startsWith('Spotify 429:');
}

function playlistSearchResultFromPage(page) {
  return {
    tracks: [],
    playlists: Array.isArray(page?.playlists) ? page.playlists : [],
    playlist_total: Number(page?.total || 0),
    artists: [],
    albums: [],
  };
}

function readStoredObject(key) {
  try {
    const raw = localStorage.getItem(key);
    if (!raw) return {};
    const parsed = JSON.parse(raw);
    return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
  } catch (_) {
    return {};
  }
}

function writeStoredObject(key, value) {
  try {
    localStorage.setItem(key, JSON.stringify(value));
  } catch (_) {}
}

function boundedStoreSet(storageKey, entryKey, value, limit) {
  const store = readStoredObject(storageKey);
  store[entryKey] = { value, updatedAt: Date.now() };
  const entries = Object.entries(store);
  if (entries.length > limit) {
    entries
      .sort((left, right) => Number(left[1]?.updatedAt || 0) - Number(right[1]?.updatedAt || 0))
      .slice(0, entries.length - limit)
      .forEach(([key]) => delete store[key]);
  }
  writeStoredObject(storageKey, store);
}

// ── Session persistence ──────────────────────────────────────
// Persist the local queue mirror + Recent history so a restart resumes where
// the user left off. Playback is never auto-resumed — only the data is
// rehydrated; the user presses play/next to continue.
const PERSISTED_STATE_STORAGE_KEY = 'lightify.session.v1';
const PERSISTED_QUEUE_MAX_AGE_MS = 12 * 60 * 60 * 1000; // ignore queues older than 12h
let persistStateTimer = null;

function persistStateNow() {
  persistStateTimer = null;
  try {
    writeStoredObject(PERSISTED_STATE_STORAGE_KEY, {
      v: 1,
      savedAt: Date.now(),
      queueSource: state.queueSource,
      lightifyQueue: state.lightifyQueue.slice(0, RECENT_HISTORY_LIMIT),
      lightifyQueueCurrent: state.lightifyQueueCurrent,
      lightifyQueueContextUri: state.lightifyQueueContextUri,
      sessionHistory: state.sessionHistory.slice(0, RECENT_HISTORY_LIMIT),
      recentHistoryClearedAtMs: state.recentHistoryClearedAtMs || 0,
    });
  } catch (_) {}
}

function schedulePersistState() {
  if (persistStateTimer) return;
  persistStateTimer = setTimeout(persistStateNow, 600);
}

function restorePersistedState() {
  const saved = readStoredObject(PERSISTED_STATE_STORAGE_KEY);
  if (!saved || saved.v !== 1) return;

  if (Number.isFinite(saved.recentHistoryClearedAtMs)) {
    state.recentHistoryClearedAtMs = saved.recentHistoryClearedAtMs;
  }

  // Recent history — always safe to rehydrate.
  if (Array.isArray(saved.sessionHistory)) {
    state.sessionHistory = saved.sessionHistory
      .map(entry => normalizeRecentHistoryEntry(entry))
      .filter(Boolean)
      .slice(0, RECENT_HISTORY_LIMIT);
  }

  // Local queue mirror — restore data only (never auto-play), and only while
  // it's recent enough that resuming it still makes sense.
  const fresh = Number(saved.savedAt || 0) > (Date.now() - PERSISTED_QUEUE_MAX_AGE_MS);
  const src = saved.queueSource;
  if (fresh && (src === 'lightify' || src === 'context') && Array.isArray(saved.lightifyQueue)) {
    state.queueSource = src;
    state.lightifyQueue = uniqueQueueTracks(saved.lightifyQueue);
    state.lightifyQueueCurrent = saved.lightifyQueueCurrent
      ? normalizeQueueTrack(saved.lightifyQueueCurrent)
      : null;
    state.lightifyQueueContextUri = src === 'context' ? (saved.lightifyQueueContextUri || null) : null;
    state.lightifyQueueAdvancePending = false;
  }
}

function normalizedCacheText(value) {
  return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ');
}

function searchResultCacheKey(query, scope) {
  return `${scope || 'all'}::${normalizedCacheText(query)}`;
}

function beatportMatchCacheKey(track) {
  const name = normalizedCacheText(track?.name);
  if (!name) return '';
  return `${name}::${normalizedCacheText(track?.artists)}`;
}

function loadCachedSearchResult(query, scope) {
  const entry = readStoredObject(SEARCH_RESULT_CACHE_STORAGE_KEY)[searchResultCacheKey(query, scope)];
  const value = entry?.value;
  return value && typeof value === 'object' ? value : null;
}

function saveCachedSearchResult(query, scope, results) {
  if (!results || typeof results !== 'object') return;
  boundedStoreSet(SEARCH_RESULT_CACHE_STORAGE_KEY, searchResultCacheKey(query, scope), results, SEARCH_RESULT_CACHE_LIMIT);
}

function loadCachedBeatportMatch(track) {
  const cacheKey = beatportMatchCacheKey(track);
  if (!cacheKey) return null;
  const value = readStoredObject(BEATPORT_MATCH_CACHE_STORAGE_KEY)[cacheKey]?.value;
  return value && typeof value === 'object' ? normalizeQueueTrack(value) : null;
}

function saveCachedBeatportMatch(track, spotifyTrack) {
  const cacheKey = beatportMatchCacheKey(track);
  if (!cacheKey || !spotifyTrack || typeof spotifyTrack !== 'object') return;
  boundedStoreSet(BEATPORT_MATCH_CACHE_STORAGE_KEY, cacheKey, spotifyTrack, BEATPORT_MATCH_CACHE_LIMIT);
}

function showCachedSearchResult(query, scope, message = '') {
  const cached = loadCachedSearchResult(query, scope);
  if (!cached) return false;
  state.lastSearchResults = cached;
  state.searchResultScope = scope;
  hide(dom.searchDrill);
  hide(dom.btnSearchBack);
  show(dom.searchResults);
  collapseSearchExpansion();
  renderSearchResults();
  setStatus(message || 'Showing cached search results');
  return true;
}

function syncLikeButtonState() {
  if (!(dom.btnLike instanceof HTMLButtonElement)) return;
  const currentTrackId = state.currentTrack?.id || null;
  dom.btnLike.disabled = Boolean(
    state.likeTogglePending
      && state.likeToggleTrackId
      && currentTrackId
      && state.likeToggleTrackId === currentTrackId
  );
}

function pagerIsActive(pager) {
  return state.activeTrackPager === pager && pager.loadToken === state.playlistLoadToken;
}

async function loadNextTrackPage(pager) {
  if (!pager || pager.loading || pager.done) return;
  pager.loading = true;
  const pageOffset = pager.offset;

  try {
    let page = await pager.loadPage(pageOffset, false);
    if (!pagerIsActive(pager)) return;

    if (page.cached && pageOffset === 0) {
      try {
        const refreshedPage = await pager.loadPage(pageOffset, true);
        if (!pagerIsActive(pager)) return;
        page = refreshedPage;
      } catch (err) {
        console.warn('track cache refresh failed', pager.name, err);
      }
    }

    const pageTracks = Array.isArray(page.tracks) ? page.tracks : [];
    pager.total = Number(page.total || pager.total || 0);

    if (pageTracks.length > 0) {
      pager.tracks.push(...pageTracks);
      pager.loaded = pager.tracks.length;

      const shouldRenderSorted = state.trackListSortMode === 'alpha' || pager.done;
      if (shouldRenderSorted) {
        renderCurrentTrackPager(pager);
      } else {
        if (pager.loaded === pageTracks.length) dom.trackList.innerHTML = '';
        appendTrackList(dom.trackList, pageTracks, pager.contextUri);
      }
    } else if (pager.loaded === 0) {
      renderListMessage(dom.trackList, 'No playable tracks');
    }

    pager.done = Boolean(page.done);
    pager.offset = Number(page.next_offset || (pageOffset + Number(page.limit || pager.limit)));

    const totalText = pager.total ? ' / ' + pager.total : '';
    const cachePrefix = page.cached ? 'Cached ' : 'Loaded ';
    setStatus(cachePrefix + pager.loaded + totalText + ' tracks from "' + pager.name + '"');

    if (page.cached && pageOffset !== 0) {
      pager.loadPage(pageOffset, true).catch(err => console.warn('track cache refresh failed', pager.name, err));
    }

    if (pager.done && pager.loaded > 0) {
      setStatus('Loaded ' + pager.loaded + ' tracks from "' + pager.name + '"' + (pager.total && pager.total !== pager.loaded ? ' (' + pager.total + ' items)' : ''));
    } else if (!pager.done && nearTrackListBottom()) {
      requestAnimationFrame(() => loadNextTrackPage(pager));
    }
  } catch (e) {
    if (!pagerIsActive(pager)) return;
    const clean = cleanSpotifyError(e);
    if (pager.loaded > 0) {
      pager.done = true;
      setStatus('"' + pager.name + '" stopped after ' + pager.loaded + ' tracks: ' + clean);
    } else {
      if (/403.*\bForbidden\b/.test(clean) && !/scope|expired|insufficient/i.test(clean)) {
        setStatus('"' + pager.name + '" \u2014 403 Forbidden. Add this account to your Spotify app\'s User Management list (developer.spotify.com).');
      } else {
        setStatus('"' + pager.name + '" failed: ' + clean);
      }
      renderListMessage(dom.trackList, 'Failed to load tracks');
    }
    console.error('loadNextTrackPage error', pager.name, e);
  } finally {
    pager.loading = false;
  }
}

function openPlaylist(pl) {
  return startPagedTrackList({
    sourceId: pl.id,
    name: pl.name,
    contextUri: pl.uri,
    limit: PLAYLIST_PAGE_LIMIT,
    loadPage: (offset, refresh) => invoke('cmd_playlist_tracks_page', {
      playlistId: pl.id,
      offset,
      limit: PLAYLIST_PAGE_LIMIT,
      refresh,
    }),
  });
}

function openLikedSongs() {
  return startPagedTrackList({
    sourceId: 'liked',
    name: 'Liked Songs',
    contextUri: null,
    limit: LIKED_PAGE_LIMIT,
    loadPage: (offset, refresh) => invoke('cmd_liked_tracks_page', {
      offset,
      limit: LIKED_PAGE_LIMIT,
      refresh,
    }),
  });
}

dom.btnBack.addEventListener('click', () => {
  state.playlistLoadToken++;
  detachActiveTrackPager();
  renderPlaylistList();
});

dom.btnLibrarySortRecent.addEventListener('click', () => {
  const { modeKey } = getLibrarySortStateKeys();
  state[modeKey] = 'recent';
  rerenderVisibleLibrarySurface();
});

dom.btnLibrarySortAlpha.addEventListener('click', () => {
  const { modeKey, directionKey } = getLibrarySortStateKeys();
  if (state[modeKey] === 'alpha') {
    state[directionKey] = state[directionKey] === 'asc' ? 'desc' : 'asc';
  } else {
    state[modeKey] = 'alpha';
    state[directionKey] = 'asc';
  }
  rerenderVisibleLibrarySurface();
  if (state.activeTrackPager && state[modeKey] === 'alpha') {
    prefetchRemainingTrackPages(state.activeTrackPager).catch(err => console.warn('track prefetch failed', err));
  }
});

// ── Shared track list renderer ───────────────────────────────

function renderTrackList(container, tracks, contextUri) {
  clearSelectionForContainer(container);
  container.innerHTML = '';
  if (!tracks || tracks.length === 0) {
    renderListMessage(container, 'No playable tracks');
    return;
  }
  appendTrackList(container, tracks, contextUri);
}

function playTrackPagerFromSelection(track) {
  const pager = state.activeTrackPager;
  if (!pager || !Array.isArray(pager.tracks)) {
    return playTrack(spotifyTrackUri(track), null);
  }

  rememberActiveLibraryPlaybackSource(pager.sourceId || state.currentPlaylistId || null, pager.contextUri || null);

  const startIndex = pager.tracks.findIndex(candidate => candidate && candidate.id === track.id);
  if (startIndex < 0) {
    return playTrack(track.uri, pager.contextUri || null);
  }

  if (pager.contextUri) {
    return playSpotifyContextSelection(pager.contextUri, pager.tracks.slice(startIndex));
  }

  return playSpotifyTrackSelection(pager.tracks.slice(startIndex));
}

function activeSearchTrackSource(container) {
  if (container === dom.searchDrill) {
    if (state.activeSearchDrillPager && Array.isArray(state.activeSearchDrillPager.tracks) && state.activeSearchDrillPager.tracks.length > 0) {
      return {
        tracks: state.activeSearchDrillPager.tracks,
        contextUri: state.activeSearchDrillPager.contextUri || null,
      };
    }

    if (state.searchDrillSource && Array.isArray(state.searchDrillSource.tracks) && state.searchDrillSource.tracks.length > 0) {
      return state.searchDrillSource;
    }

    return null;
  }

  if (state.activeSearchPager && state.activeSearchPager.list === container && Array.isArray(state.activeSearchPager.tracks) && state.activeSearchPager.tracks.length > 0) {
    return {
      tracks: state.activeSearchPager.tracks,
      contextUri: state.activeSearchPager.contextUri || null,
    };
  }

  return null;
}

function playSearchTrackSourceSelection(track, source) {
  rememberActiveLibraryPlaybackSource(null, null);
  const trackUri = spotifyTrackUri(track);
  if (!trackUri || !source || !Array.isArray(source.tracks)) {
    return playTrack(trackUri, source?.contextUri || null);
  }

  const normalizedTrack = normalizeQueueTrack({ ...track, uri: trackUri });
  const sourceTracks = source.tracks
    .map(entry => normalizeQueueTrack(entry))
    .filter(Boolean);
  const startIndex = sourceTracks.findIndex(candidate => samePlaybackTrack(candidate, normalizedTrack));
  if (startIndex < 0) {
    return playTrack(trackUri, source.contextUri || null);
  }

  if (source.contextUri) {
    return playSpotifyContextSelection(source.contextUri, sourceTracks.slice(startIndex));
  }

  return playSpotifyPlaylistSelection(sourceTracks.slice(startIndex));
}

function playTrackItem(track, contextUri, container) {
  const trackUri = spotifyTrackUri(track);
  if (!trackUri) return Promise.resolve();
  if (container === dom.trackList && state.activeTrackPager && Array.isArray(state.activeTrackPager.tracks)) {
    return playTrackPagerFromSelection({ ...track, uri: trackUri });
  }

  const searchSource = activeSearchTrackSource(container);
  if (searchSource) {
    return playSearchTrackSourceSelection({ ...track, uri: trackUri }, searchSource);
  }

  return playTrack(trackUri, contextUri || null);
}

function appendTrackList(container, tracks, contextUri) {
  if (!tracks || tracks.length === 0) return;
  const fragment = document.createDocumentFragment();
  const scope = trackSelectionScope(container);
  const baseIndex = container.querySelectorAll('.list-item').length;
  tracks.forEach((t, index) => {
    const div = makeListItem(t.name, t.artists, 'track');
    if (t && t.is_playable === false) {
      div.classList.add('track-unavailable');
      div.title = 'Unavailable in your region';
    }
    const playAction = () => playTrackItem(t, contextUri, container);
    registerSelectableRow({
      scope,
      key: `${baseIndex + index}:${t.id || t.uri || t.name}`,
      container,
      row: div,
      data: { track: t },
      onActivate: playAction,
      getMenuItems: () => isSearchTrackContainer(container)
        ? buildSearchTrackMenu(t, playAction)
        : buildSpotifyTrackMenu(t, playAction),
      getSelectionMenuItems: buildSpotifySelectionMenu,
    });
    fragment.appendChild(div);
  });
  container.appendChild(fragment);
}

function renderListMessage(container, message) {
  container.innerHTML = '';
  const div = document.createElement('div');
  div.className = 'list-item list-empty';
  const body = document.createElement('div');
  body.className = 'item-body';
  const nameEl = document.createElement('div');
  nameEl.className = 'item-name';
  nameEl.style.color = 'var(--text-dim)';
  nameEl.textContent = message;
  body.appendChild(nameEl);
  div.appendChild(body);
  container.appendChild(div);
}

// Shimmer placeholder rows shown while a list loads, in place of "Loading…" text.
function renderListSkeleton(container, count = 6) {
  container.innerHTML = '';
  const frag = document.createDocumentFragment();
  for (let i = 0; i < count; i += 1) {
    const div = document.createElement('div');
    div.className = 'list-item skeleton-row';
    div.setAttribute('aria-hidden', 'true');

    const icon = document.createElement('div');
    icon.className = 'skeleton-block skeleton-icon';

    const body = document.createElement('div');
    body.className = 'item-body';
    const line1 = document.createElement('div');
    line1.className = 'skeleton-block skeleton-line';
    line1.style.width = (55 + ((i * 7) % 35)) + '%';
    const line2 = document.createElement('div');
    line2.className = 'skeleton-block skeleton-line skeleton-line-sub';
    line2.style.width = (30 + ((i * 5) % 25)) + '%';
    body.appendChild(line1);
    body.appendChild(line2);

    div.appendChild(icon);
    div.appendChild(body);
    frag.appendChild(div);
  }
  container.appendChild(frag);
}

// Reset the dynamic accent back to the default teal.
function resetDynamicAccent() {
  const root = document.documentElement;
  root.style.removeProperty('--accent');
  root.style.removeProperty('--accent-dim');
  root.style.removeProperty('--accent-rgb');
  root.style.removeProperty('--accent-dynamic');
  root.style.removeProperty('--accent-dynamic-soft');
}

// Sample the cover's dominant (saturation-weighted) color and expose it as CSS
// vars so the progress bar / volume / EQ / art glow tint to the track. Uses a
// separate CORS image so a failed sample (tainted canvas) just falls back to the
// default accent without affecting the displayed artwork.
function applyDynamicAccent(url) {
  if (!url) { resetDynamicAccent(); return; }
  const img = new Image();
  img.crossOrigin = 'anonymous';
  img.onload = () => {
    try {
      const size = 16;
      const canvas = document.createElement('canvas');
      canvas.width = size;
      canvas.height = size;
      const ctx = canvas.getContext('2d', { willReadFrequently: true });
      if (!ctx) return;
      ctx.drawImage(img, 0, 0, size, size);
      const data = ctx.getImageData(0, 0, size, size).data;
      let r = 0, g = 0, b = 0, total = 0;
      for (let i = 0; i < data.length; i += 4) {
        if (data[i + 3] < 200) continue;
        const cr = data[i], cg = data[i + 1], cb = data[i + 2];
        const mx = Math.max(cr, cg, cb), mn = Math.min(cr, cg, cb);
        const sat = mx === 0 ? 0 : (mx - mn) / mx;
        const weight = 0.25 + sat; // vivid pixels dominate the average
        r += cr * weight; g += cg * weight; b += cb * weight; total += weight;
      }
      if (total <= 0) return;
      r = Math.round(r / total); g = Math.round(g / total); b = Math.round(b / total);
      // Lift very dark averages so the accent stays visible on the dark UI.
      const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
      if (lum < 90) {
        const boost = 90 / Math.max(lum, 1);
        r = Math.min(255, Math.round(r * boost));
        g = Math.min(255, Math.round(g * boost));
        b = Math.min(255, Math.round(b * boost));
      }
      const root = document.documentElement;
      const rgb = `rgb(${r}, ${g}, ${b})`;
      const dim = `rgb(${Math.round(r * 0.72)}, ${Math.round(g * 0.72)}, ${Math.round(b * 0.72)})`;
      // Drive the whole accent theme from the cover so every accented element —
      // tabs, playing rows, active controls, progress, EQ, glow — tints to the song.
      root.style.setProperty('--accent', rgb);
      root.style.setProperty('--accent-dim', dim);
      root.style.setProperty('--accent-rgb', `${r}, ${g}, ${b}`);
      root.style.setProperty('--accent-dynamic', rgb);
      root.style.setProperty('--accent-dynamic-soft', `rgba(${r}, ${g}, ${b}, 0.3)`);
    } catch (_) {
      resetDynamicAccent(); // tainted canvas (no CORS) — keep the default accent
    }
  };
  img.onerror = () => resetDynamicAccent();
  img.src = url;
}

function renderSyntheticCover(track) {
  const albumArt = (track && typeof track.album_art === 'string') ? track.album_art.trim() : '';
  if (!albumArt) {
    dom.albumArt.removeAttribute('src');
    dom.albumArt.classList.add('hidden');
    dom.noArt.textContent = 'No artwork';
    dom.noArt.classList.remove('hidden');
    resetDynamicAccent();
    return;
  }

  const changed = dom.albumArt.getAttribute('src') !== albumArt;

  dom.albumArt.onerror = () => {
    dom.albumArt.removeAttribute('src');
    dom.albumArt.classList.add('hidden');
    dom.noArt.textContent = 'No artwork';
    dom.noArt.classList.remove('hidden');
    resetDynamicAccent();
  };
  dom.albumArt.src = albumArt;
  dom.albumArt.classList.remove('hidden');
  dom.noArt.classList.add('hidden');

  if (changed) {
    // Crossfade the new cover in and retint the accent to it.
    dom.albumArt.classList.remove('art-swap');
    void dom.albumArt.offsetWidth; // reflow so the animation restarts
    dom.albumArt.classList.add('art-swap');
    applyDynamicAccent(albumArt);
  }
}

// ── List item builder with type icons ────────────────────────

function makeListItem(name, sub, type) {
  const div = document.createElement('div');
  div.className = 'list-item';

  const iconWrap = document.createElement('div');
  iconWrap.className = 'item-icon';
  iconWrap.innerHTML = getTypeIcon(type);
  div.appendChild(iconWrap);

  const body = document.createElement('div');
  body.className = 'item-body';

  const nameEl = document.createElement('div');
  nameEl.className = 'item-name';
  nameEl.textContent = name;
  body.appendChild(nameEl);

  if (sub) {
    const subEl = document.createElement('div');
    subEl.className = 'item-sub';
    subEl.textContent = sub;
    body.appendChild(subEl);
  }

  div.appendChild(body);
  return div;
}

function getTypeIcon(type) {
  switch (type) {
    case 'track':
      return '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>';
    case 'playlist':
      return '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h11v2H3v-2z"/></svg>';
    case 'album':
      return '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z"/></svg>';
    case 'artist':
      return '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>';
    case 'liked':
      return '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>';
    default:
      return '';
  }
}

// ── Search ───────────────────────────────────────────────────

dom.searchInput.addEventListener('input', () => {
  clearTimeout(state.searchDebounce);
  state.searchDebounce = setTimeout(doSearch, SEARCH_INPUT_DEBOUNCE_MS);
});

dom.searchInput.addEventListener('keydown', e => {
  if (e.key === 'Enter') { clearTimeout(state.searchDebounce); doSearch(); }
});

// Search filter buttons
dom.searchFilters.querySelectorAll('.filter-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    dom.searchFilters.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    state.searchFilter = btn.dataset.filter;
    // Every tab renders from the same cmd_search_all payload, so switching tabs is a
    // pure client-side re-filter — no refetch, no request the user can fail into.
    renderSearchResults();
  });
});

async function doSearch() {
  const q = dom.searchInput.value.trim();
  const filter = state.searchFilter;
  const loadToken = ++state.searchLoadToken;
  // One request backs every tab. The per-category tabs used to issue their own
  // request (playlists had a dedicated endpoint that intermittently 404s), which is
  // why searching inside a tab could fail while ALL worked. Now the tabs are just a
  // filter over this payload — same results, no extra request, nothing to 404.
  const resultScope = 'all';
  state.searchQuery = q;
  if (!q) {
    detachActiveSearchResultsPager();
    state.searchDrillSource = null;
    dom.searchResults.innerHTML = '';
    state.lastSearchResults = null;
    state.searchResultScope = null;
    return;
  }

  try {
    detachActiveSearchDrillPager();
    detachActiveSearchResultsPager();
    state.searchDrillSource = null;
    const results = await invoke('cmd_search_all', { query: q });
    if (loadToken !== state.searchLoadToken || q !== dom.searchInput.value.trim() || filter !== state.searchFilter) return;
    state.lastSearchResults = results;
    state.searchResultScope = resultScope;
    saveCachedSearchResult(q, resultScope, results);
    // Return to results view if we were in drill-down
    hide(dom.searchDrill);
    hide(dom.btnSearchBack);
    show(dom.searchResults);
    collapseSearchExpansion();
    renderSearchResults();
  } catch (e) {
    if (loadToken !== state.searchLoadToken) return;
    if (isSpotifyRateLimitedError(e)) {
      if (showCachedSearchResult(q, resultScope, 'Showing cached search results')) return;
      setStatus('Spotify search is temporarily rate limited');
      return;
    }
    setStatus('Search failed: ' + cleanSpotifyError(e));
  }
}

function renderSearchResults() {
  detachActiveSearchResultsPager();
  collapseSearchExpansion();
  clearSelectionForContainer(dom.searchResults);
  dom.searchResults.innerHTML = '';
  const r = state.lastSearchResults;
  if (!r) return;

  const filter = state.searchFilter;
  const tracks = Array.isArray(r.tracks) ? r.tracks : [];
  const playlists = Array.isArray(r.playlists) ? r.playlists : [];
  const playlistTotal = Number(r.playlist_total || playlists.length || 0);
  const artists = Array.isArray(r.artists) ? r.artists : [];
  const albums = Array.isArray(r.albums) ? r.albums : [];

  // Tracks
  if (filter === 'all' || filter === 'tracks') {
    const scope = trackSelectionScope(dom.searchResults);
    tracks.forEach((t, index) => {
      const div = makeListItem(t.name, t.artists, 'track');
      const playAction = () => playTrack(t.uri, null);
      registerSelectableRow({
        scope,
        key: `${index}:${t.id || t.uri || t.name}`,
        container: dom.searchResults,
        row: div,
        data: { track: t },
        onActivate: playAction,
        getMenuItems: () => buildSearchTrackMenu(t, playAction),
        getSelectionMenuItems: buildSpotifySelectionMenu,
      });
      dom.searchResults.appendChild(div);
    });
  }

  // Playlists
  if (filter === 'all' || filter === 'playlists') {
    playlists.forEach(pl => {
      appendSearchPlaylistResult(pl);
    });

    if (filter === 'playlists' && state.searchQuery && playlistTotal > playlists.length) {
      startSearchResultsPlaylistPager({
        query: state.searchQuery,
        initialOffset: playlists.length,
        total: playlistTotal,
      });
    }
  }

  // Artists
  if (filter === 'all' || filter === 'artists') {
    artists.forEach(artist => {
      const followers = Number(artist.followers || 0);
      const sub = followers > 0 ? followers.toLocaleString() + ' followers' : 'Artist';
      const div = makeListItem(artist.name, sub, 'artist');
      div.addEventListener('click', () => {
        drillInto('artist', artist.id, null, artist.name);
      });
      div.addEventListener('contextmenu', e => {
        e.preventDefault();
        e.stopPropagation();
        showContextMenu(e, [
          { label: 'Show tracks', action: () => drillInto('artist', artist.id, null, artist.name) },
        ]);
      });
      dom.searchResults.appendChild(div);
    });
  }

  if (filter === 'all' || filter === 'albums') {
    albums.forEach(album => {
      const sub = [album.artists, album.tracks ? album.tracks + ' tracks' : 'Album'].filter(Boolean).join(' • ');
      const div = makeListItem(album.name, sub, 'album');
      div.addEventListener('click', () => {
        drillInto('album', album.id, album.uri || null, album.name);
      });
      div.addEventListener('contextmenu', e => {
        e.preventDefault();
        e.stopPropagation();
        showContextMenu(e, [
          { label: 'Play', action: () => playContext(album.uri) },
          { label: 'Show tracks', action: () => drillInto('album', album.id, album.uri || null, album.name) },
          { label: 'Save album', action: () => invoke('cmd_save_album', { albumId: album.id }).then(() => setStatus('Album saved')).catch(err => setStatus(String(err))) },
        ]);
      });
      dom.searchResults.appendChild(div);
    });
  }
}

function appendSearchPlaylistResult(pl) {
  const div = makeListItem(pl.name, pl.tracks + ' tracks • ' + pl.owner, 'playlist');
  div.addEventListener('click', () => {
    drillInto('playlist', pl.id, pl.uri || null, pl.name);
  });
  div.addEventListener('contextmenu', e => {
    e.preventDefault();
    e.stopPropagation();
    showContextMenu(e, [
      { label: 'Play', action: () => playPlaylistFromStart(pl).catch(err => setStatus(String(err))) },
      { label: 'Show tracks', action: () => drillInto('playlist', pl.id, pl.uri || null, pl.name) },
      { label: 'Save playlist', action: () => invoke('cmd_follow_playlist', { playlistId: pl.id }).then(() => setStatus('Playlist saved')).catch(err => setStatus(String(err))) },
      { label: 'Download', action: () => enqueuePlaylistDownload(pl) },
    ]);
  });
  dom.searchResults.appendChild(div);
}

function detachActiveSearchResultsPager() {
  if (state.activeSearchResultsPager?.onScroll) {
    dom.searchResults.removeEventListener('scroll', state.activeSearchResultsPager.onScroll);
  }
  state.activeSearchResultsPager = null;
}

function nearSearchResultsBottom() {
  return dom.searchResults.scrollTop + dom.searchResults.clientHeight >= dom.searchResults.scrollHeight - SEARCH_RESULTS_SCROLL_THRESHOLD;
}

function searchResultsPagerIsActive(pager) {
  return state.activeSearchResultsPager === pager
    && state.searchFilter === 'playlists'
    && state.searchQuery === pager.query;
}

function startSearchResultsPlaylistPager({ query, initialOffset, total }) {
  const pager = {
    query,
    offset: initialOffset,
    total,
    done: initialOffset >= total,
    loading: false,
    limit: SEARCH_PLAYLIST_PAGE_LIMIT,
    onScroll: null,
  };

  pager.onScroll = () => {
    if (nearSearchResultsBottom()) loadNextSearchResultsPlaylistPage(pager);
  };

  state.activeSearchResultsPager = pager;
  dom.searchResults.addEventListener('scroll', pager.onScroll);
}

async function loadNextSearchResultsPlaylistPage(pager) {
  if (!pager || pager.loading || pager.done) return;
  pager.loading = true;
  const pageOffset = pager.offset;

  try {
    const page = await invoke('cmd_search_playlists_page', {
      query: pager.query,
      offset: pageOffset,
      limit: pager.limit,
    });
    if (!searchResultsPagerIsActive(pager)) return;

    const playlists = Array.isArray(page.playlists) ? page.playlists : [];
    pager.total = Number(page.total || pager.total || 0);
    playlists.forEach(pl => appendSearchPlaylistResult(pl));
    pager.done = Boolean(page.done);
    pager.offset = Number(page.next_offset || (pageOffset + Number(page.limit || pager.limit)));

    if (!pager.done && nearSearchResultsBottom()) {
      requestAnimationFrame(() => loadNextSearchResultsPlaylistPage(pager));
    }
  } catch (e) {
    if (!searchResultsPagerIsActive(pager)) return;
    pager.done = true;
    if (isSpotifyRateLimitedError(e)) {
      setStatus('Spotify search is temporarily rate limited');
    } else {
      setStatus('Search failed: ' + cleanSpotifyError(e));
    }
  } finally {
    pager.loading = false;
  }
}

// ── Search inline expansion (playlists/artists) ──────────────

function searchExpansionKey(type, id) {
  return type + ':' + id;
}

function detachActiveSearchPager() {
  if (state.activeSearchPager && state.activeSearchPager.onScroll && state.activeSearchPager.list) {
    state.activeSearchPager.list.removeEventListener('scroll', state.activeSearchPager.onScroll);
  }
  state.activeSearchPager = null;
}

function collapseSearchExpansion() {
  detachActiveSearchPager();
  if (state.activeSearchExpansion) {
    if (state.activeSearchExpansion.row) state.activeSearchExpansion.row.classList.remove('expanded');
    if (state.activeSearchExpansion.node) state.activeSearchExpansion.node.remove();
  }
  state.activeSearchExpansion = null;
}

function nearSearchExpansionBottom(list) {
  return list.scrollTop + list.clientHeight >= list.scrollHeight - SEARCH_EXPANSION_SCROLL_THRESHOLD;
}

function searchPagerIsActive(pager) {
  return state.activeSearchPager === pager
    && state.activeSearchExpansion
    && state.activeSearchExpansion.key === pager.key;
}

function toggleSearchExpansion(options) {
  if (!options || !options.id || !options.row) return;

  const key = searchExpansionKey(options.type, options.id);
  if (state.activeSearchExpansion && state.activeSearchExpansion.key === key) {
    collapseSearchExpansion();
    return;
  }

  collapseSearchExpansion();

  const node = document.createElement('div');
  node.className = 'search-expansion';

  const status = document.createElement('div');
  status.className = 'search-expansion-status';
  status.textContent = 'Loading...';
  node.appendChild(status);

  const list = document.createElement('div');
  list.className = 'search-expansion-list';
  node.appendChild(list);

  options.row.classList.add('expanded');
  options.row.insertAdjacentElement('afterend', node);
  state.activeSearchExpansion = { key, node, row: options.row };

  const isArtist = options.type === 'artist';
  const limit = isArtist ? ARTIST_PAGE_LIMIT : PLAYLIST_PAGE_LIMIT;
  const loadPage = isArtist
    ? (offset, refresh) => invoke('cmd_artist_tracks_page', {
        artistId: options.id,
        artistName: options.name,
        offset,
        limit,
        refresh,
      }).catch(err => loadSearchExpansionFallback(options, offset, limit, err))
    : (offset, refresh) => invoke('cmd_playlist_tracks_page', {
        playlistId: options.id,
        offset,
        limit,
        refresh,
      });

  startSearchExpansionPager({
    key,
    name: options.name,
    type: options.type,
    contextUri: options.contextUri || null,
    limit,
    list,
    status,
    loadPage,
  });
}

async function loadSearchExpansionFallback(options, offset, limit, originalError) {
  if (offset !== 0) throw originalError;

  const tracks = options.type === 'artist'
    ? await invoke('cmd_artist_tracks', { artistId: options.id })
    : await invoke('cmd_playlist_tracks', { playlistId: options.id });

  const safeTracks = Array.isArray(tracks) ? tracks : [];
  const pageTracks = safeTracks.slice(0, limit);
  const nextOffset = pageTracks.length < safeTracks.length ? pageTracks.length : null;
  return {
    tracks: pageTracks,
    total: safeTracks.length,
    offset: 0,
    limit,
    next_offset: nextOffset,
    done: nextOffset === null,
    cached: false,
  };
}

async function startSearchExpansionPager(options) {
  detachActiveSearchPager();
  renderListSkeleton(options.list);

  const pager = {
    key: options.key,
    type: options.type,
    name: options.name,
    contextUri: options.contextUri || null,
    offset: 0,
    total: 0,
    loaded: 0,
    done: false,
    loading: false,
    limit: options.limit,
    tracks: [],
    list: options.list,
    status: options.status,
    loadPage: options.loadPage,
    onScroll: null,
  };

  pager.onScroll = () => {
    if (nearSearchExpansionBottom(pager.list)) loadNextSearchExpansionPage(pager);
  };
  state.activeSearchPager = pager;
  pager.list.addEventListener('scroll', pager.onScroll);
  await loadNextSearchExpansionPage(pager);
}

async function loadNextSearchExpansionPage(pager) {
  if (!pager || pager.loading || pager.done) return;
  pager.loading = true;
  const pageOffset = pager.offset;

  try {
    let page = await pager.loadPage(pageOffset, false);
    if (!searchPagerIsActive(pager)) return;

    if (page.cached && pageOffset === 0) {
      try {
        const refreshedPage = await pager.loadPage(pageOffset, true);
        if (!searchPagerIsActive(pager)) return;
        page = refreshedPage;
      } catch (err) {
        console.warn('search expansion refresh failed', pager.name, err);
      }
    }

    const pageTracks = Array.isArray(page.tracks) ? page.tracks : [];
    pager.total = Number(page.total || pager.total || 0);

    if (pageTracks.length > 0) {
      pager.tracks.push(...pageTracks);
      if (pager.loaded === 0) pager.list.innerHTML = '';
      appendTrackList(pager.list, pageTracks, pager.contextUri);
      pager.loaded += pageTracks.length;
    } else if (pager.loaded === 0) {
      renderListMessage(pager.list, 'No playable tracks');
    }

    pager.done = Boolean(page.done);
    pager.offset = Number(page.next_offset || (pageOffset + Number(page.limit || pager.limit)));

    const totalText = pager.total ? ' / ' + pager.total : '';
    pager.status.textContent = (page.cached ? 'Cached ' : 'Loaded ') + pager.loaded + totalText + ' tracks';

    if (page.cached && pageOffset !== 0) {
      pager.loadPage(pageOffset, true).catch(err => console.warn('search expansion refresh failed', pager.name, err));
    }

    if (!pager.done && nearSearchExpansionBottom(pager.list)) {
      requestAnimationFrame(() => loadNextSearchExpansionPage(pager));
    }
  } catch (e) {
    if (!searchPagerIsActive(pager)) return;
    const clean = cleanSpotifyError(e);
    pager.done = true;
    pager.status.textContent = pager.loaded > 0
      ? 'Stopped after ' + pager.loaded + ' tracks: ' + clean
      : 'Could not load tracks: ' + clean;
    if (pager.loaded === 0) {
      renderListMessage(pager.list, clean.includes('403 Forbidden') ? 'Track listing unavailable' : 'Failed to load tracks');
    }
    setStatus('Failed to load ' + pager.type + ': ' + clean);
    console.error('search expansion error', pager.name, e);
  } finally {
    pager.loading = false;
  }
}

// ── Legacy search drill-down path ────────────────────────────

async function drillInto(type, id, uri, name) {
  state.drillType = type;
  state.drillId = id;
  state.drillUri = uri;
  state.searchDrillSource = null;
  detachActiveSearchDrillPager();
  collapseSearchExpansion();

  try {
    hide(dom.searchResults);
    show(dom.searchDrill);
    show(dom.btnSearchBack);

    if (type === 'artist') {
      await startSearchDrillPager({ type, id, uri, name });
    } else {
      let tracks;
      if (type === 'playlist') {
        tracks = await invoke('cmd_playlist_tracks', { playlistId: id });
      } else {
        tracks = await invoke('cmd_album_tracks', { albumId: id });
      }
      state.searchDrillSource = {
        tracks: Array.isArray(tracks) ? tracks.slice() : [],
        contextUri: uri || null,
      };
      renderTrackList(dom.searchDrill, tracks, uri);
    }
  } catch (e) {
    setStatus('Failed to load ' + type + ': ' + String(e));
  }
}

function nearSearchDrillBottom() {
  return dom.searchDrill.scrollTop + dom.searchDrill.clientHeight >= dom.searchDrill.scrollHeight - TRACK_PAGE_SCROLL_THRESHOLD;
}

function searchDrillPagerIsActive(pager) {
  return state.activeSearchDrillPager === pager && pager.loadToken === state.searchDrillLoadToken;
}

async function startSearchDrillPager({ type, id, uri, name }) {
  detachActiveSearchDrillPager();
  const loadToken = ++state.searchDrillLoadToken;
  dom.searchDrill.scrollTop = 0;
  renderListSkeleton(dom.searchDrill);

  const isArtist = type === 'artist';
  const limit = isArtist ? ARTIST_PAGE_LIMIT : PLAYLIST_PAGE_LIMIT;
  const pager = {
    loadToken,
    type,
    id,
    name,
    contextUri: uri || null,
    offset: 0,
    total: 0,
    loaded: 0,
    done: false,
    loading: false,
    limit,
    tracks: [],
    onScroll: null,
    loadPage: isArtist
      ? (offset, refresh) => invoke('cmd_artist_tracks_page', {
          artistId: id,
          artistName: name,
          offset,
          limit,
          refresh,
        }).catch(err => loadSearchExpansionFallback({ type, id }, offset, limit, err))
      : (offset, refresh) => invoke('cmd_playlist_tracks_page', {
          playlistId: id,
          offset,
          limit,
          refresh,
        }),
  };

  pager.onScroll = () => {
    if (nearSearchDrillBottom()) loadNextSearchDrillPage(pager);
  };

  state.activeSearchDrillPager = pager;
  dom.searchDrill.addEventListener('scroll', pager.onScroll);
  await loadNextSearchDrillPage(pager);
}

async function loadNextSearchDrillPage(pager) {
  if (!pager || pager.loading || pager.done) return;
  pager.loading = true;
  const pageOffset = pager.offset;

  try {
    let page = await pager.loadPage(pageOffset, false);
    if (!searchDrillPagerIsActive(pager)) return;

    if (page.cached && pageOffset === 0) {
      try {
        const refreshedPage = await pager.loadPage(pageOffset, true);
        if (!searchDrillPagerIsActive(pager)) return;
        page = refreshedPage;
      } catch (err) {
        console.warn('search drill refresh failed', pager.name, err);
      }
    }

    const pageTracks = Array.isArray(page.tracks) ? page.tracks : [];
    pager.total = Number(page.total || pager.total || 0);

    if (pageTracks.length > 0) {
      pager.tracks.push(...pageTracks);
      if (pager.loaded === 0) dom.searchDrill.innerHTML = '';
      appendTrackList(dom.searchDrill, pageTracks, pager.contextUri);
      pager.loaded += pageTracks.length;
    } else if (pager.loaded === 0) {
      renderListMessage(dom.searchDrill, 'No playable tracks');
    }

    pager.done = Boolean(page.done);
    pager.offset = Number(page.next_offset || (pageOffset + Number(page.limit || pager.limit)));
    const totalText = pager.total ? ' / ' + pager.total : '';
    setStatus((page.cached ? 'Cached ' : 'Loaded ') + pager.loaded + totalText + ' tracks from "' + pager.name + '"');

    if (!pager.done && nearSearchDrillBottom()) {
      requestAnimationFrame(() => loadNextSearchDrillPage(pager));
    }
  } catch (e) {
    if (!searchDrillPagerIsActive(pager)) return;
    const clean = cleanSpotifyError(e);
    pager.done = true;
    if (pager.loaded === 0) {
      renderListMessage(dom.searchDrill, clean.includes('403 Forbidden') ? 'Track listing unavailable' : 'Failed to load tracks');
    }
    setStatus('Failed to load ' + pager.type + ': ' + clean);
  } finally {
    pager.loading = false;
  }
}

dom.btnSearchBack.addEventListener('click', () => {
  detachActiveSearchDrillPager();
  state.searchDrillSource = null;
  hide(dom.searchDrill);
  hide(dom.btnSearchBack);
  show(dom.searchResults);
  state.drillType = null;
  state.drillId = null;
  state.drillUri = null;
});

// ── Beatport ─────────────────────────────────────────────────

const BEATPORT_CHARTS = {
  tracks: { label: 'Top 100 Tracks', entryLabel: 'tracks' },
  hype: { label: 'Hype 100', entryLabel: 'tracks' },
  releases: { label: 'Top 100 Releases', entryLabel: 'releases' },
};

function selectedBeatportChartKind() {
  const kind = String(state.beatportChartKind || 'tracks');
  return Object.prototype.hasOwnProperty.call(BEATPORT_CHARTS, kind) ? kind : 'tracks';
}

function selectedBeatportChartConfig() {
  return BEATPORT_CHARTS[selectedBeatportChartKind()];
}

function selectedBeatportChartLabel() {
  return selectedBeatportChartConfig().label;
}

function selectedBeatportEntryLabel() {
  return selectedBeatportChartConfig().entryLabel;
}

function beatportSelectionScope() {
  return `beatport:${selectedBeatportChartKind()}:${selectedBeatportGenreValue() || 'overall'}`;
}

function beatportVerifiedSessionStored() {
  try {
    return JSON.parse(localStorage.getItem(BEATPORT_VERIFIED_SESSION_STORAGE_KEY) || 'false') === true;
  } catch (_) {
    return false;
  }
}

function setBeatportVerifiedSessionStored(value) {
  try {
    if (value) localStorage.setItem(BEATPORT_VERIFIED_SESSION_STORAGE_KEY, 'true');
    else localStorage.removeItem(BEATPORT_VERIFIED_SESSION_STORAGE_KEY);
  } catch (_) {}
}

function beatportModeButtons() {
  return [dom.btnBpModeTracks, dom.btnBpModeHype, dom.btnBpModeReleases].filter(Boolean);
}

function updateBeatportModeButtons() {
  const active = selectedBeatportChartKind();
  dom.btnBpModeTracks.classList.toggle('active', active === 'tracks');
  dom.btnBpModeHype.classList.toggle('active', active === 'hype');
  dom.btnBpModeReleases.classList.toggle('active', active === 'releases');
}

function setBeatportFetchLoading(isLoading) {
  state.beatportFetchLoading = Boolean(isLoading);
  dom.bpGenreTrigger.disabled = state.beatportFetchLoading;
  beatportModeButtons().forEach(button => {
    button.disabled = state.beatportFetchLoading;
  });
  if (!dom.btnBpVerify.classList.contains('hidden')) {
    dom.btnBpVerify.disabled = state.beatportFetchLoading;
  }
}

function clearBeatportChartView() {
  clearSelectionForContainer(dom.bpList);
  dom.bpList.innerHTML = '';
  state.beatportTracks = [];
  state.beatportSelected.clear();
  state.beatportMatchCache.clear();
  dom.btnBpCreate.disabled = true;
  updateBeatportSelectionControls();
}

function beatportChallengeRequired(message) {
  return String(message || '').includes('Beatport is blocking automated chart requests');
}

function showBeatportVerifyPrompt(message = '') {
  setBeatportVerifiedSessionStored(false);
  setBeatportFetchLoading(false);
  show(dom.btnBpVerify);
  dom.btnBpVerify.disabled = false;
  dom.bpStatus.textContent = message || `Beatport verification required for ${selectedBeatportChartLabel()}. Click Verify to refresh the saved session.`;
}

async function requestBeatportChartFetch() {
  if (state.beatportGenres.length === 0) return;

  clearBeatportAutoplay();
  clearBeatportChartView();
  setBeatportFetchLoading(true);
  hide(dom.btnBpVerify);
  const hasVerifiedSession = beatportVerifiedSessionStored();
  dom.bpStatus.textContent = hasVerifiedSession
    ? `Loading ${selectedBeatportChartLabel()}…`
    : `Fetching ${selectedBeatportChartLabel()}…`;

  try {
    if (hasVerifiedSession) {
      await invoke('cmd_beatport_session_fetch', {
        genreSlug: selectedBeatportGenreValue(),
        chartKind: selectedBeatportChartKind(),
      });
      return;
    }

    const tracks = await invoke('cmd_beatport_fetch', {
      genreSlug: selectedBeatportGenreValue(),
      chartKind: selectedBeatportChartKind(),
    });
    state.beatportTracks = Array.isArray(tracks) ? tracks : [];
    state.beatportSelected.clear();
    state.beatportMatchCache.clear();
    dom.btnBpCreate.disabled = true;
    renderBeatportList();
    dom.bpStatus.textContent = `${state.beatportTracks.length} ${selectedBeatportEntryLabel()} loaded from ${selectedBeatportChartLabel()}`;
    hide(dom.btnBpVerify);
    dom.btnBpVerify.disabled = false;
    setBeatportFetchLoading(false);
  } catch (e) {
    const message = String(e);
    setBeatportFetchLoading(false);
    if (beatportChallengeRequired(message)) {
      showBeatportVerifyPrompt(`Beatport verification required for ${selectedBeatportChartLabel()}. Click Verify to continue.`);
      return;
    }
    dom.bpStatus.textContent = 'Fetch failed: ' + message;
  }
}

function setBeatportChartKind(chartKind) {
  if (!Object.prototype.hasOwnProperty.call(BEATPORT_CHARTS, chartKind)) return;
  if (state.beatportChartKind === chartKind) return;
  state.beatportChartKind = chartKind;
  updateBeatportModeButtons();
  if (!dom.beatportContent.classList.contains('hidden')) {
    requestBeatportChartFetch().catch(err => {
      dom.bpStatus.textContent = 'Fetch failed: ' + err;
    });
  }
}

async function loadBeatportGenres() {
  try {
    const genres = await invoke('cmd_beatport_genres');
    state.beatportGenres = genres.map(g => ({ label: g[0], value: g[1] }));
    if (!state.beatportGenres.some(genre => genre.value === state.beatportGenreValue)) {
      state.beatportGenreValue = state.beatportGenres[0]?.value || '';
    }
    renderBeatportGenreMenu();
    syncBeatportGenreLabel();
    updateBeatportModeButtons();
    if (!dom.beatportContent.classList.contains('hidden')) {
      requestBeatportChartFetch().catch(err => {
        dom.bpStatus.textContent = 'Fetch failed: ' + err;
      });
    }
  } catch (e) {
    dom.bpStatus.textContent = 'Failed to load genres: ' + e;
  }
}

function selectedBeatportGenreValue() {
  return typeof state.beatportGenreValue === 'string' ? state.beatportGenreValue : '';
}

function selectedBeatportGenreLabel() {
  return state.beatportGenres.find(genre => genre.value === selectedBeatportGenreValue())?.label || 'Select genre';
}

function syncBeatportGenreLabel() {
  dom.bpGenreLabel.textContent = selectedBeatportGenreLabel();
}

function closeBeatportGenreMenu() {
  hide(dom.bpGenreMenu);
  dom.bpGenre.classList.remove('open');
  dom.bpGenreTrigger.setAttribute('aria-expanded', 'false');
}

function toggleBeatportGenreMenu() {
  if (dom.bpGenreMenu.classList.contains('hidden')) {
    show(dom.bpGenreMenu);
    dom.bpGenre.classList.add('open');
    dom.bpGenreTrigger.setAttribute('aria-expanded', 'true');
    return;
  }
  closeBeatportGenreMenu();
}

function renderBeatportGenreMenu() {
  dom.bpGenreMenu.innerHTML = '';
  const selectedValue = selectedBeatportGenreValue();
  state.beatportGenres.forEach(genre => {
    const button = document.createElement('button');
    button.type = 'button';
    button.className = 'custom-select-option' + (genre.value === selectedValue ? ' active' : '');
    button.textContent = genre.label;
    button.addEventListener('click', () => {
      state.beatportGenreValue = genre.value;
      syncBeatportGenreLabel();
      renderBeatportGenreMenu();
      closeBeatportGenreMenu();
      requestBeatportChartFetch().catch(err => {
        dom.bpStatus.textContent = 'Fetch failed: ' + err;
      });
    });
    dom.bpGenreMenu.appendChild(button);
  });
}

dom.bpGenreTrigger.addEventListener('click', () => {
  if (state.beatportGenres.length === 0) return;
  toggleBeatportGenreMenu();
});

document.addEventListener('pointerdown', e => {
  if (dom.bpGenreMenu.classList.contains('hidden')) return;
  if (dom.bpGenre.contains(e.target)) return;
  closeBeatportGenreMenu();
});

dom.btnBpModeTracks.addEventListener('click', () => setBeatportChartKind('tracks'));
dom.btnBpModeHype.addEventListener('click', () => setBeatportChartKind('hype'));
dom.btnBpModeReleases.addEventListener('click', () => setBeatportChartKind('releases'));

dom.btnBpVerify.addEventListener('click', () => {
  setBeatportFetchLoading(true);
  dom.bpStatus.textContent = 'Opening verification window\u2026';
  invoke('cmd_beatport_challenge', {
    genreSlug: selectedBeatportGenreValue(),
    chartKind: selectedBeatportChartKind(),
  })
    .then(() => {
      setBeatportFetchLoading(false);
      show(dom.btnBpVerify);
      dom.btnBpVerify.disabled = false;
      dom.bpStatus.textContent = 'Verification window open \u2014 complete the Beatport check, then the chart will import automatically.';
    })
    .catch(err => {
      setBeatportFetchLoading(false);
      show(dom.btnBpVerify);
      dom.bpStatus.textContent = 'Failed to open verification: ' + err;
      dom.btnBpVerify.disabled = false;
    });
});

function renderBeatportList() {
  clearSelectionForContainer(dom.bpList);
  dom.bpList.innerHTML = '';
  const scope = beatportSelectionScope();
  state.beatportTracks.forEach((t, i) => {
    const div = document.createElement('div');
    div.className = 'list-item';

    const body = document.createElement('div');
    body.className = 'item-body';

    const nameEl = document.createElement('div');
    nameEl.className = 'item-name';
    nameEl.textContent = (t.rank || (i + 1)) + '. ' + t.name;
    body.appendChild(nameEl);

    const subEl = document.createElement('div');
    subEl.className = 'item-sub';
    subEl.textContent = t.artists + (t.label ? ' • ' + t.label : '');
    body.appendChild(subEl);

    div.appendChild(body);

    // Click body to play from this row through the remaining chart.
    registerSelectableRow({
      scope,
      key: String(i),
      container: dom.bpList,
      row: div,
      data: { beatportTrack: t, index: i },
      onActivate: () => {
        playBeatportSequenceFromIndex(i).catch(err => handlePlayError(String(err)));
      },
      getMenuItems: () => [
        { label: 'Play', action: () => playBeatportSequenceFromIndex(i).catch(err => setStatus(String(err))) },
        { label: 'Add to queue', action: () => enqueueBeatportSelection([{ beatportTrack: t, index: i }]).catch(err => setStatus(String(err))) },
        { label: 'Download', action: () => downloadBeatportTrack({ beatportTrack: t, index: i }).catch(err => setStatus(String(err))) },
      ],
      getSelectionMenuItems: entries => buildBeatportSelectionMenu(entries),
      onSelectedChange: selected => {
        if (selected) state.beatportSelected.add(i);
        else state.beatportSelected.delete(i);
        updateBeatportSelectionControls();
      },
    });

    dom.bpList.appendChild(div);
  });
  updateBeatportSelectionControls();
}

async function resolveBeatportUrisFromIndex(startIndex) {
  const uris = [];
  for (let i = startIndex; i < state.beatportTracks.length; i++) {
    const track = state.beatportTracks[i];
    if (!track) continue;
    const uri = await invoke('cmd_beatport_search_track', {
      name: track.name,
      artists: track.artists,
    });
    if (uri && !uris.includes(uri)) {
      uris.push(uri);
    }
  }
  return uris;
}

function clearBeatportAutoplay() {
  state.beatportAutoplay = null;
}

function clearSpotifySelectionAutoplay() {
  state.spotifySelectionAutoplay = null;
}

function clearStationAutoplay() {
  state.stationAutoplay = null;
  // Any non-station playback setup funnels through here, so it's the single place to
  // guarantee spirc autoplay is turned back OFF when a native-radio station ends —
  // otherwise a playlist/selection started after a station would keep radio-ing.
  if (state.nativeStation) {
    state.nativeStation = false;
    invoke('cmd_set_autoplay', { enabled: false }).catch(() => {});
  }
}

function nextSpotifySelectionQueueBatchSize(size) {
  return Math.min(SPOTIFY_SELECTION_MAX_QUEUE_BATCH_SIZE, Math.max(SPOTIFY_SELECTION_INITIAL_QUEUE_COUNT, size * 2));
}

function spotifySelectionRefillThreshold(sequence) {
  return Math.max(1, Math.floor(sequence.nextBatchSize / 2));
}

function stationRefillThreshold(sequence) {
  return Math.max(1, Math.floor(sequence.nextBatchSize / 2));
}

async function prefillSpotifySelectionAutoplay(sequence) {
  while (
    state.spotifySelectionAutoplay === sequence
    && state.queueSource === 'lightify'
    && !sequence.completed
    && sequence.loadedIds.length < SPOTIFY_SELECTION_PREFILL_TARGET
  ) {
    await pumpSpotifySelectionAutoplay(true);
    if (
      state.spotifySelectionAutoplay !== sequence
      || sequence.completed
      || sequence.loadedIds.length >= SPOTIFY_SELECTION_PREFILL_TARGET
    ) break;
    await nextFrame();
  }
}

async function prefillBeatportAutoplay(sequence) {
  while (
    state.beatportAutoplay === sequence
    && state.queueSource === 'lightify'
    && !sequence.completed
    && sequence.loadedIds.length < BEATPORT_AUTOPLAY_PREFILL_TARGET
  ) {
    await pumpBeatportAutoplay(true);
    if (
      state.beatportAutoplay !== sequence
      || sequence.completed
      || sequence.loadedIds.length >= BEATPORT_AUTOPLAY_PREFILL_TARGET
    ) break;
    await nextFrame();
  }
}

function nextBeatportQueueBatchSize(size) {
  return Math.min(BEATPORT_MAX_QUEUE_BATCH_SIZE, Math.max(BEATPORT_INITIAL_QUEUE_COUNT, size * 2));
}

function beatportRefillThreshold(sequence) {
  return Math.max(1, Math.floor(sequence.nextBatchSize / 2));
}

function beatportTrackIdFromUri(uri) {
  return typeof uri === 'string' && uri.startsWith('spotify:track:') ? uri.slice('spotify:track:'.length) : '';
}

async function waitForBeatportMatchSlot() {
  const elapsedMs = Date.now() - state.beatportLastMatchRequestAt;
  if (elapsedMs < BEATPORT_MATCH_REQUEST_DELAY_MS) {
    await sleep(BEATPORT_MATCH_REQUEST_DELAY_MS - elapsedMs);
  }
  state.beatportLastMatchRequestAt = Date.now();
}

async function resolveBeatportTrackMatch(track, index) {
  if (!track || !Number.isInteger(index)) return null;
  const cacheKey = beatportMatchCacheKey(track) || String(index);
  if (state.beatportMatchCache.has(cacheKey)) {
    return state.beatportMatchCache.get(cacheKey);
  }

  const cached = loadCachedBeatportMatch(track);
  if (cached) {
    state.beatportMatchCache.set(cacheKey, cached);
    return cached;
  }

  await waitForBeatportMatchSlot();
  const spotifyTrack = await invoke('cmd_beatport_match_track', {
    name: track.name,
    artists: track.artists,
  });
  const normalized = spotifyTrack ? normalizeQueueTrack(spotifyTrack) : null;
  state.beatportMatchCache.set(cacheKey, normalized);
  if (normalized) saveCachedBeatportMatch(track, normalized);
  return normalized;
}

async function resolveBeatportUriBatch(startIndex, batchSize, seenUris, sourceTracks = state.beatportTracks) {
  const chartTracks = Array.isArray(sourceTracks) ? sourceTracks : [];
  const matches = [];
  let nextIndex = startIndex;
  while (nextIndex < chartTracks.length && matches.length < batchSize) {
    const entryIndex = nextIndex;
    const track = chartTracks[nextIndex];
    nextIndex += 1;
    if (!track) continue;
    const spotifyTrack = await resolveBeatportTrackMatch(track, entryIndex);
    if (spotifyTrack?.uri && !seenUris.has(spotifyTrack.uri)) {
      seenUris.add(spotifyTrack.uri);
      matches.push({ uri: spotifyTrack.uri, beatportTrack: track, spotifyTrack, index: entryIndex });
    }
  }
  return {
    matches,
    uris: matches.map(match => match.uri),
    nextIndex,
  };
}

function beatportQueueTrackFromMatch(match) {
  const spotifyTrack = normalizeQueueTrack(match?.spotifyTrack);
  if (spotifyTrack) return spotifyTrack;

  const uri = match?.uri || '';
  const beatportTrack = match?.beatportTrack || {};
  return normalizeQueueTrack({
    id: beatportTrackIdFromUri(uri),
    uri,
    name: beatportTrack.name || 'Matched Beatport track',
    artists: beatportTrack.artists || '',
    album_art: '',
    duration_ms: 0,
  });
}

async function pumpSpotifySelectionAutoplay(force = false) {
  const sequence = state.spotifySelectionAutoplay;
  if (!sequence || sequence.loading || sequence.completed || state.queueSource !== 'lightify') return;

  const currentTrackId = resolvedSpotifyTrackId(state.currentTrack);
  if (!force) {
    if (!currentTrackId) return;
    const currentIndex = sequence.loadedIds.indexOf(currentTrackId);
    if (currentIndex < 0) {
      // A manual skip can jump past the loaded window. Re-sync to the source
      // list and keep refilling instead of abandoning autoplay — clearing it
      // here would strand the rest of the playlist with nothing queued behind.
      const sourceIndex = sequence.sourceTracks.findIndex(track => {
        const candidate = normalizeQueueTrack(track);
        const id = resolvedSpotifyTrackId(candidate) || candidate.id;
        return id && id === currentTrackId;
      });
      if (sourceIndex < 0) {
        clearSpotifySelectionAutoplay();
        return;
      }
      sequence.nextSourceIndex = Math.max(sequence.nextSourceIndex, sourceIndex + 1);
      sequence.loadedIds = [currentTrackId];
      // fall through to refill from the re-synced position
    } else {
      const remainingLoaded = sequence.loadedIds.length - currentIndex - 1;
      if (remainingLoaded > spotifySelectionRefillThreshold(sequence)) return;
    }
  }

  sequence.loading = true;
  try {
    const batch = [];
    while (sequence.nextSourceIndex < sequence.sourceTracks.length && batch.length < sequence.nextBatchSize) {
      if (state.spotifySelectionAutoplay !== sequence || state.queueSource !== 'lightify') return;
      const track = normalizeQueueTrack(sequence.sourceTracks[sequence.nextSourceIndex]);
      sequence.nextSourceIndex += 1;
      if (!track?.uri) continue;
      batch.push(track);
    }

    if (state.spotifySelectionAutoplay !== sequence || state.queueSource !== 'lightify') return;

    if (batch.length === 0) {
      sequence.completed = true;
      return;
    }

    for (const track of batch) {
      if (state.spotifySelectionAutoplay !== sequence || state.queueSource !== 'lightify') return;
      await invoke('cmd_add_to_queue', { uri: track.uri });
      if (state.spotifySelectionAutoplay !== sequence || state.queueSource !== 'lightify') return;
      const trackId = resolvedSpotifyTrackId(track) || track.id;
      if (trackId) sequence.loadedIds.push(trackId);
    }

    if (state.spotifySelectionAutoplay !== sequence || state.queueSource !== 'lightify') return;

    sequence.nextBatchSize = nextSpotifySelectionQueueBatchSize(sequence.nextBatchSize);
    sequence.completed = sequence.nextSourceIndex >= sequence.sourceTracks.length;
  } catch (err) {
    console.warn('spotify selection queue refill failed', err);
    setStatus('Playlist queue refill failed: ' + String(err));
    clearSpotifySelectionAutoplay();
  } finally {
    if (state.spotifySelectionAutoplay === sequence) {
      sequence.loading = false;
    }
  }
}

async function pumpStationAutoplay(force = false) {
  const sequence = state.stationAutoplay;
  if (!sequence || sequence.loading || state.queueSource !== 'lightify') return;

  const currentTrackId = resolvedSpotifyTrackId(state.currentTrack);
  if (!currentTrackId) return;

  if (!force && state.lightifyQueue.length > stationRefillThreshold(sequence)) {
    return;
  }

  if (sequence.lastSeedTrackId === currentTrackId) {
    return;
  }

  sequence.loading = true;
  try {
    const stationTracks = await invoke('cmd_start_station', { trackId: currentTrackId });
    if (!Array.isArray(stationTracks) || stationTracks.length <= 1) {
      sequence.lastSeedTrackId = currentTrackId;
      return;
    }

    let batch = stationTracks
      .slice(1)
      .map(track => normalizeQueueTrack(track))
      .filter(track => {
        if (!track?.uri) return false;
        const key = queueTrackIdentity(track);
        return !!key && !sequence.seenKeys.has(key);
      })
      .slice(0, sequence.nextBatchSize);

    if (batch.length === 0) {
      sequence.lastSeedTrackId = currentTrackId;
      return;
    }

    if (state.shuffleOn) {
      batch = shuffleQueueTracks(batch);
    }

    const queuedTracks = await enqueueTrackBatch(batch);
    queuedTracks.forEach(track => {
      const key = queueTrackIdentity(track);
      if (key) sequence.seenKeys.add(key);
    });
    sequence.lastSeedTrackId = currentTrackId;
    sequence.nextBatchSize = nextSpotifySelectionQueueBatchSize(sequence.nextBatchSize);
  } catch (err) {
    console.warn('station queue refill failed', err);
    setStatus('Station queue refill failed: ' + String(err));
  } finally {
    if (state.stationAutoplay === sequence) {
      sequence.loading = false;
    }
  }
}

async function pumpBeatportAutoplay(force = false) {
  const sequence = state.beatportAutoplay;
  if (!sequence || sequence.loading || sequence.completed) return;
  const sourceTracks = Array.isArray(sequence.sourceTracks) ? sequence.sourceTracks : [];

  const batchSize = sequence.nextBatchSize;

  const currentTrackId = resolvedSpotifyTrackId(state.currentTrack);
  if (!force) {
    if (!currentTrackId) return;
    const currentIndex = sequence.loadedIds.indexOf(currentTrackId);
    if (currentIndex < 0) {
      clearBeatportAutoplay();
      return;
    }
    const remainingLoaded = sequence.loadedIds.length - currentIndex - 1;
    if (remainingLoaded > beatportRefillThreshold(sequence)) return;
  }

  sequence.loading = true;
  try {
    const { matches, nextIndex } = await resolveBeatportUriBatch(
      sequence.nextSourceIndex,
      batchSize,
      sequence.seenUris,
      sourceTracks,
    );
    if (state.beatportAutoplay !== sequence) return;
    sequence.nextSourceIndex = nextIndex;

    if (matches.length === 0) {
      sequence.completed = true;
      if (!force) {
        dom.bpStatus.textContent = 'Beatport autoplay fully queued';
      }
      return;
    }

    for (const match of matches) {
      if (state.beatportAutoplay !== sequence) return;
      await invoke('cmd_add_to_queue', { uri: match.uri });
      const trackId = match.spotifyTrack?.id || beatportTrackIdFromUri(match.uri);
      if (trackId) sequence.loadedIds.push(trackId);
    }
    if (state.beatportAutoplay !== sequence) return;
    appendLightifyQueueTracks(matches.map(beatportQueueTrackFromMatch));

    sequence.nextBatchSize = nextBeatportQueueBatchSize(sequence.nextBatchSize);

    sequence.completed = sequence.nextSourceIndex >= sourceTracks.length;
    dom.bpStatus.textContent = sequence.completed
      ? `Beatport autoplay queued ${sequence.loadedIds.length} matched tracks`
      : `Beatport autoplay buffered ${sequence.loadedIds.length} matched tracks`;
  } catch (err) {
    console.warn('beatport autoplay refill failed', err);
    const clean = cleanSpotifyError(err);
    setStatus('Beatport queue refill failed: ' + clean);
    dom.bpStatus.textContent = isSpotifyRateLimitedError(err)
      ? clean
      : 'Beatport queue refill failed: ' + clean;
    clearBeatportAutoplay();
  } finally {
    if (state.beatportAutoplay === sequence) {
      sequence.loading = false;
    }
  }
}

async function playBeatportSequenceFromIndex(startIndex) {
  const sourceTracks = Array.isArray(state.beatportTracks) ? state.beatportTracks.slice() : [];
  const chartIndex = Number(startIndex);
  if (!Number.isInteger(chartIndex) || chartIndex < 0 || chartIndex >= sourceTracks.length) {
    return;
  }

  const chartTrack = sourceTracks[chartIndex];
  if (!chartTrack) return;

  dom.bpStatus.textContent = `Matching ${selectedBeatportChartLabel()} from #${chartTrack.rank || chartIndex + 1}...`;
  clearBeatportAutoplay();
  clearSpotifySelectionAutoplay();
  const autoplay = {
    startIndex: chartIndex,
    sourceTracks,
    nextSourceIndex: chartIndex,
    nextBatchSize: BEATPORT_INITIAL_QUEUE_COUNT,
    seenUris: new Set(),
    loadedIds: [],
    loading: false,
    completed: false,
  };
  let resolvedBatch;
  try {
    resolvedBatch = await resolveBeatportUriBatch(chartIndex, BEATPORT_INITIAL_MATCH_COUNT, autoplay.seenUris, sourceTracks);
  } catch (err) {
    clearBeatportAutoplay();
    const clean = cleanSpotifyError(err);
    dom.bpStatus.textContent = isSpotifyRateLimitedError(err)
      ? clean
      : 'Beatport matching failed: ' + clean;
    throw err;
  }
  const { matches, uris: trackUris, nextIndex } = resolvedBatch;
  if (trackUris.length === 0) {
    clearBeatportAutoplay();
    setStatus('No Spotify matches found from: ' + chartTrack.name);
    dom.bpStatus.textContent = 'No Spotify matches found';
    return;
  }

  const immediateQueueMatches = matches.slice(1);
  const fn_ = () => invoke('cmd_play_uris', { trackUris });
  await fn_().catch(err => handlePlayError(String(err), fn_));

  setLightifyQueue(immediateQueueMatches.map(beatportQueueTrackFromMatch), beatportQueueTrackFromMatch(matches[0]));

  autoplay.nextSourceIndex = nextIndex;
  autoplay.loadedIds = matches.map(match => match.spotifyTrack?.id || beatportTrackIdFromUri(match.uri)).filter(Boolean);
  autoplay.completed = nextIndex >= sourceTracks.length;
  state.beatportAutoplay = autoplay;

  if (!autoplay.completed) {
    await pumpBeatportAutoplay(true);
    if (state.beatportAutoplay === autoplay && !autoplay.completed) {
      prefillBeatportAutoplay(autoplay).catch(err => {
        console.warn('beatport autoplay prefill failed', err);
        const clean = cleanSpotifyError(err);
        setStatus('Beatport queue refill failed: ' + clean);
        dom.bpStatus.textContent = isSpotifyRateLimitedError(err)
          ? clean
          : 'Beatport queue refill failed: ' + clean;
        clearBeatportAutoplay();
      });
    }
  }

  const remainingSource = Math.max(0, sourceTracks.length - autoplay.nextSourceIndex);
  const matchedLoaded = state.beatportAutoplay ? state.beatportAutoplay.loadedIds.length : autoplay.loadedIds.length;
  const prefix = remainingSource > 0
    ? `Playing immediately (${matchedLoaded} matched, ${remainingSource} left to match)`
    : `Playing immediately (${matchedLoaded} matched)`;
  dom.bpStatus.textContent = prefix;
  setStatus(prefix + ' from ' + selectedBeatportChartLabel());
}

function updateBeatportSelectionControls() {
  const total = state.beatportTracks.length;
  const selected = state.beatportSelected.size;
  dom.btnBpCreate.disabled = selected === 0;
  dom.btnBpSelectAll.textContent = total > 0 && selected === total ? 'Clear selection' : 'Select all';
}

async function resolveBeatportMatches(entries) {
  const matches = [];
  const seenUris = new Set();
  for (const entry of entries) {
    const track = entry?.beatportTrack;
    if (!track) continue;
    const spotifyTrack = await resolveBeatportTrackMatch(track, entry?.index);
    if (spotifyTrack?.uri && !seenUris.has(spotifyTrack.uri)) {
      seenUris.add(spotifyTrack.uri);
      matches.push({ uri: spotifyTrack.uri, beatportTrack: track, spotifyTrack, index: entry?.index });
    }
  }
  return matches;
}

async function downloadBeatportTrack(entry) {
  const spotifyTrack = await resolveBeatportTrackMatch(entry?.beatportTrack, entry?.index);
  if (!spotifyTrack?.id) {
    setStatus('No Spotify match found to download');
    return;
  }
  enqueueTrackDownload(spotifyTrack);
}

async function enqueueBeatportSelection(entries) {
  const matches = await resolveBeatportMatches(entries);
  if (matches.length === 0) {
    setStatus('No Spotify matches found for selected Beatport tracks');
    return;
  }
  for (const match of matches) {
    await invoke('cmd_add_to_queue', { uri: match.uri });
  }
  appendLightifyQueueTracks(matches.map(beatportQueueTrackFromMatch));
  setStatus(`Added ${matches.length} Beatport tracks to queue`);
}

async function playBeatportSelection(entries) {
  const matches = await resolveBeatportMatches(entries);
  const uris = matches.map(match => match.uri);
  if (uris.length === 0) {
    setStatus('No Spotify matches found for selected Beatport tracks');
    return;
  }
  const fn_ = () => invoke('cmd_play_uris', { trackUris: uris });
  await fn_().catch(err => handlePlayError(String(err), fn_));
  setLightifyQueue(matches.slice(1).map(beatportQueueTrackFromMatch), beatportQueueTrackFromMatch(matches[0]));
  const queuedTail = Math.max(0, uris.length - 50);
  setStatus(queuedTail > 0 ? `Playing ${uris.length} selected Beatport tracks (${queuedTail} queued)` : `Playing ${uris.length} selected Beatport tracks`);
}

async function createBeatportPlaylistFromSelection(entries) {
  const selected = entries
    .map(entry => entry?.beatportTrack)
    .filter(Boolean)
    .map(track => ({ name: track.name, artists: track.artists }));
  if (selected.length === 0) {
    setStatus('No Beatport tracks selected');
    return;
  }
  const result = await invoke('cmd_beatport_create_playlist', {
    playlistName: buildTimestampedPlaylistName('BP Selection'),
    tracks: selected,
  });
  setStatus('Created: ' + result.name);
  loadPlaylists();
}

function buildBeatportSelectionMenu(entries) {
  const ordered = entries
    .map(entry => entry.data)
    .filter(Boolean)
    .sort((left, right) => left.index - right.index);
  if (ordered.length === 0) return [];
  return [
    { label: `Play ${ordered.length} selected`, action: () => playBeatportSelection(ordered).catch(err => setStatus(String(err))) },
    { label: `Add ${ordered.length} to queue`, action: () => enqueueBeatportSelection(ordered).catch(err => setStatus(String(err))) },
    { label: 'Create playlist from selection', action: () => createBeatportPlaylistFromSelection(ordered).catch(err => setStatus(String(err))) },
  ];
}

dom.btnBpSelectAll.addEventListener('click', () => {
  const allSelected = state.beatportSelected.size === state.beatportTracks.length;
  const scope = beatportSelectionScope();
  state.beatportTracks.forEach((_, i) => {
    setSelectionEntrySelected(selectionRegistryKey(scope, String(i)), !allSelected);
  });
  updateBeatportSelectionControls();
});

dom.btnBpCreate.addEventListener('click', async () => {
  if (state.beatportSelected.size === 0) return;

  const genreName = selectedBeatportGenreLabel() || 'Beatport';
  const now = new Date();
  const dateStr = now.getFullYear() + '-' +
    String(now.getMonth() + 1).padStart(2, '0') + '-' +
    String(now.getDate()).padStart(2, '0');
  const playlistName = 'BP ' + genreName + ' ' + dateStr;

  const selected = [];
  state.beatportSelected.forEach(i => {
    const t = state.beatportTracks[i];
    if (t) selected.push({ name: t.name, artists: t.artists });
  });

  dom.btnBpCreate.disabled = true;
  dom.bpStatus.textContent = 'Creating playlist — searching ' + selected.length + ' tracks…';

  try {
    const result = await invoke('cmd_beatport_create_playlist', {
      playlistName: playlistName,
      tracks: selected,
    });
    dom.bpStatus.textContent =
      'Created "' + result.name + '" — ' + result.tracks_found + '/' + result.tracks_total + ' tracks matched';
    // Refresh playlists so the new one shows up
    loadPlaylists();
  } catch (e) {
    dom.bpStatus.textContent = 'Failed: ' + e;
  } finally {
    dom.btnBpCreate.disabled = state.beatportSelected.size === 0;
  }
});

// ── Playback polling ─────────────────────────────────────────

function startPolling() {
  if (state.pollTimer) return;
  pollNow();
  state.pollTimer = setInterval(pollNow, state.pollIntervalMs);
}

function renderPlaybackProgress(progressMs, durationMs) {
  const safeDurationMs = Math.max(0, Number(durationMs || 0));
  const safeProgressMs = Math.max(0, Number(progressMs || 0));
  const clampedProgressMs = safeDurationMs > 0 ? Math.min(safeProgressMs, safeDurationMs) : safeProgressMs;
  dom.elapsed.textContent = formatMs(clampedProgressMs);
  dom.duration.textContent = formatMs(safeDurationMs);
  const pct = safeDurationMs > 0 ? (clampedProgressMs / safeDurationMs) * 100 : 0;
  dom.progressFill.style.width = pct + '%';
  if (dom.miniEdge && state.miniMode !== 'off') {
    dom.miniEdge.style.width = pct + '%';
  }
}

function setPlaybackSnapshot(progressMs, durationMs, isPlaying) {
  state.playbackSnapshot = {
    progressMs: Math.max(0, Number(progressMs || 0)),
    durationMs: Math.max(0, Number(durationMs || 0)),
    isPlaying: Boolean(isPlaying),
    updatedAt: Date.now(),
  };
  renderPlaybackProgress(state.playbackSnapshot.progressMs, state.playbackSnapshot.durationMs);
}

function tickPlaybackProgress() {
  if (!state.playbackSnapshot) return;
  const elapsedMs = state.playbackSnapshot.isPlaying ? (Date.now() - state.playbackSnapshot.updatedAt) : 0;
  renderPlaybackProgress(state.playbackSnapshot.progressMs + elapsedMs, state.playbackSnapshot.durationMs);
}

function expectedLocalQueueTrack() {
  if (!usesLocalQueueMirror() || state.lightifyQueueAdvancePending) return null;
  return normalizeQueueTrack(state.lightifyQueue[0]);
}

function shouldRecoverUnexpectedLocalQueueAdvance(previousTrack, nextTrack, previousSnapshot) {
  // Don't fight an in-progress back/forward navigation — the "unexpected" track is
  // the one the user deliberately navigated to.
  if (state.navExpectedKey) return false;
  // With repeat engaged, the native device (spirc) intentionally loops the track or
  // context; its wrap-around reads as an "unexpected advance" but force-correcting it
  // would fight the loop. Normal consume + autoplay-pump still run (this only skips
  // the force-play), so arbitrary queues keep flowing while repeat owns the looping.
  if (state.repeatMode && state.repeatMode !== 'off') return false;
  // The mirror already accepted this track as the current one — an engine event
  // consumed it moments ago and moved the head past it. The 3s Spotify poll lags
  // a track change by a beat, so it can re-report this same track (or the one it
  // replaced) after the fact; treating that as an advance would force-play the
  // new head and skip the track that is legitimately playing.
  if (samePlaybackTrack(nextTrack, state.lightifyQueueCurrent)) return false;
  const expectedNext = expectedLocalQueueTrack();
  if (!expectedNext || !previousTrack || !nextTrack) return false;
  if (samePlaybackTrack(nextTrack, expectedNext)) return false;
  if (samePlaybackTrack(previousTrack, nextTrack)) return false;

  const previousDurationMs = Math.max(
    0,
    Number(previousSnapshot?.durationMs || previousTrack?.duration_ms || 0),
  );
  const previousElapsedMs = previousSnapshot?.isPlaying
    ? Math.max(0, Date.now() - Number(previousSnapshot?.updatedAt || 0))
    : 0;
  const previousProgressMs = Math.max(
    0,
    Number(previousSnapshot?.progressMs || 0) + previousElapsedMs,
  );
  if (previousDurationMs <= 0) return false;

  return previousProgressMs >= Math.max(1000, previousDurationMs - 4000);
}

function recoverUnexpectedLocalQueueAdvance() {
  if (usesLocalQueueMirror()) {
    logDiag('warn', 'queue', 'recovering from unexpected Spotify advance (' + state.queueSource + ')');
  }
  if (state.queueSource === 'lightify') {
    playNextLightifyQueueTrack({ pollDelay: 500 }).catch(err => setStatus(String(err)));
    return true;
  }
  if (state.queueSource === 'context') {
    playNextContextQueueTrack({ pollDelay: 500 }).catch(err => setStatus(String(err)));
    return true;
  }
  return false;
}

function startProgressTicker() {
  if (state.progressTickTimer) return;
  tickPlaybackProgress();
  state.progressTickTimer = setInterval(tickPlaybackProgress, PROGRESS_TICK_MS);
}

async function pollNow() {
  try {
    const pb = await invoke('poll_playback');
    if (!pb) return;
    updatePlaybackUI(pb);
  } catch (_) { /* ignore transient */ }
}

function updatePlaybackUI(pb) {
  const previousSnapshot = state.playbackSnapshot
    ? { ...state.playbackSnapshot }
    : null;
  // Play / pause
  state.isPlaying = pb.is_playing;
  if (pb.is_playing) {
    hide(dom.iconPlay);
    show(dom.iconPause);
  } else {
    show(dom.iconPlay);
    hide(dom.iconPause);
  }

  // Shuffle
  if (!usesLocalQueueMirror()) {
    setShuffleUiState(pb.shuffle_state);
  } else {
    dom.btnShuffle.classList.toggle('active', state.shuffleOn);
  }

  // Repeat — while Lightify's local mirror owns advancement, honor the user's
  // intent (state.repeatMode) rather than the device's echoed flag. The native
  // Lightify device does not reliably report repeat back through `me/player`, so
  // trusting the poll here snapped the button (and mode) back to 'off' seconds
  // after the user set it — the "repeat does nothing" symptom. Same guard as shuffle.
  if (!usesLocalQueueMirror()) {
    state.repeatMode = pb.repeat_state || 'off';
  }
  dom.btnRepeat.classList.toggle('active', state.repeatMode !== 'off');

  // Progress
  setPlaybackSnapshot(pb.progress_ms, pb.duration_ms, pb.is_playing);

  // Volume
  syncVolumeSliderFromBackend(pb.volume_percent);

  // Track info
  if (pb.track) {
    const t = pb.track;
    const previousTrack = state.currentTrack;
    const nextTrack = mergePlaybackTrack(previousTrack, t);
    const changed = !samePlaybackTrack(previousTrack, t);
    const gainedResolvedSpotify = !resolvedSpotifyTrackId(previousTrack) && !!resolvedSpotifyTrackId(nextTrack);

    dom.trackName.textContent = nextTrack.name || '—';
    dom.trackArtist.textContent = nextTrack.artists || '';
    renderSyntheticCover(nextTrack);

    state.currentTrack = nextTrack;
    syncLikeButtonState();

    if (changed || gainedResolvedSpotify) {
      if (shouldRecoverUnexpectedLocalQueueAdvance(previousTrack, nextTrack, previousSnapshot)) {
        if (recoverUnexpectedLocalQueueAdvance()) return;
      }
      recordNavPlay(nextTrack);
      consumeLightifyQueueTrack(nextTrack);
      addToSessionHistory(nextTrack);
      if (resolvedSpotifyTrackId(nextTrack) && nextTrack.id) {
        checkLiked(nextTrack.id);
        pumpSpotifySelectionAutoplay().catch(err => console.warn('spotify selection autoplay pump failed', err));
        pumpBeatportAutoplay().catch(err => console.warn('beatport autoplay pump failed', err));
        pumpStationAutoplay().catch(err => console.warn('station autoplay pump failed', err));
      }
    }
  }

  refreshTrackPlaybackPresence();
  // Keep the now-playing list highlight in sync when a change is detected by the
  // 3s poll (e.g. remote Spotify Connect control) rather than an audio event.
  refreshLibraryPlaybackPresence();

  updateMediaSession();

  // Device
  if (pb.device_name) {
    setStatus(pb.device_name + (pb.is_playing ? ' ▶' : ' ⏸'));
  }
}

// ── Playback controls ────────────────────────────────────────

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// Try to auto-activate a device without blocking normal playback/UI startup.
async function ensureDevice(options = {}) {
  const timeoutMs = Number(options.timeoutMs || 10000);
  const reportFailure = options.reportFailure !== false;
  const reportConnected = options.reportConnected !== false;
  try {
    // Check if the native audio binary exists before waiting for it
    const status = await invoke('cmd_native_audio_status').catch(() => ({ available: false, running: false }));

    if (!status.available) {
      // No native binary — fall back immediately to whatever Spotify device is already active
      const devices = await invoke('cmd_get_devices').catch(() => []);
      const active = devices && (devices.find(d => d.is_active) || devices[0]);
      if (active) {
        try { await invoke('cmd_set_device', { deviceId: active.id }); } catch (_) {}
        if (reportConnected) setStatus('Device: ' + active.name + ' (build lightify-audio for native playback)');
      } else if (reportFailure) {
        setStatus('⚠ No playback device. Open Spotify on a device first.');
      }
      return !!active;
    }

    // Binary exists — start it and wait briefly for the Lightify device to register.
    const attempts = Math.max(1, Math.ceil(timeoutMs / 500));
    for (let i = 0; i < attempts; i++) {
      if (i === 0 || i === 4) {
        try { await invoke('cmd_start_native_audio'); } catch (_) {}
      }

      const devices = await invoke('cmd_get_devices').catch(() => []);
      if (devices && devices.length > 0) {
        const lightify = devices.find(d => (d.name || '').toLowerCase().includes('lightify'));
        if (lightify) {
          try {
            await invoke('cmd_set_device', { deviceId: lightify.id });
            if (reportConnected) setStatus('Connected: ' + lightify.name);
          } catch (_) {}
          return true;
        }
      }

      await sleep(500);
    }

    if (reportFailure) setStatus('Lightify audio device is still connecting');
    return false;
  } catch (e) {
    console.error('ensureDevice failed:', e);
    return false;
  }
}

function isNoActiveDeviceError(errMsg) {
  return errMsg.includes('NO_ACTIVE_DEVICE')
    || errMsg.includes('SPOTIFY_NO_DEVICE')
    || errMsg.includes('Spotify 404')
    || errMsg.includes('404 Not Found');
}

// Record a structured event in the backend diagnostics ring buffer (surfaced in
// Settings → debug report). Fire-and-forget; never blocks playback.
function logDiag(level, category, message) {
  try {
    invoke('cmd_log_event', { level, category, message: String(message) }).catch(() => {});
  } catch (_) {}
}

async function handlePlayError(errMsg, retry = null) {
  logDiag('error', 'playback', errMsg);
  if (isNoActiveDeviceError(errMsg)) {
    const ok = await ensureDevice({ timeoutMs: 3500, reportFailure: false });
    if (ok && retry) {
      try { await retry(); } catch (e2) { setStatus(String(e2)); }
    } else if (ok) {
      setStatus('Device activated. Try playing again.');
    } else {
      setStatus('Playback device is still connecting. Try again in a moment.');
    }
  } else if (errMsg.includes('SPOTIFY_FORBIDDEN') || errMsg.includes('403') || errMsg.includes('Forbidden')) {
    setStatus('Playback unavailable — check Spotify Premium or account/device permissions.');
  } else {
    setStatus(cleanSpotifyError(errMsg));
  }
}

// ── Settings ─────────────────────────────────────────────────

function openSettings() {
  show(dom.settingsOverlay);
  refreshSettingsDevices();
  refreshSettingsNativeStatus();
  refreshSettingsWatchdogReport();
  refreshSettingsAudioOutputs();
  refreshSettingsDownloadPath();
  refreshSettingsDownloaderStatus();
  // sync poll-rate radio to current interval
  document.querySelectorAll('input[name="poll-rate"]').forEach(r => {
    r.checked = parseInt(r.value, 10) === state.pollIntervalMs;
  });
}

function closeSettings() {
  hide(dom.settingsOverlay);
}

// ── Command palette (Ctrl/Cmd+K) ─────────────────────────────
// A keyboard-first launcher: jump to a playlist, run a search, or fire an
// action. The matcher (`filterCommands`) is a pure function so it can be tested.

// Score how well `q` matches a command. Higher = better; 0 = no match.
// Ranking: exact title > title prefix > title substring > keyword substring >
// subsequence (fuzzy) over title+keywords.
function commandMatchScore(q, title, hay) {
  if (!q) return 1;
  if (title === q) return 1000;
  if (title.startsWith(q)) return 800 - title.length;
  const titleIdx = title.indexOf(q);
  if (titleIdx >= 0) return 600 - titleIdx;
  const hayIdx = hay.indexOf(q);
  if (hayIdx >= 0) return 400 - hayIdx;
  return isSubsequenceMatch(q, hay) ? 200 : 0;
}

// True if every char of `q` appears in `hay` in order (classic fuzzy match).
function isSubsequenceMatch(q, hay) {
  let i = 0;
  for (let j = 0; j < hay.length && i < q.length; j += 1) {
    if (hay[j] === q[i]) i += 1;
  }
  return i === q.length;
}

// Pure: filter + rank a command list against a query. Empty query keeps order.
// Each command is `{ title, keywords?, ... }`; extra fields pass through.
function filterCommands(query, commands) {
  const list = Array.isArray(commands) ? commands : [];
  const q = String(query || '').trim().toLowerCase();
  if (!q) return list.slice();
  const scored = [];
  for (const cmd of list) {
    if (!cmd) continue;
    const title = String(cmd.title || '').toLowerCase();
    const hay = (title + ' ' + String(cmd.keywords || '')).toLowerCase();
    const score = commandMatchScore(q, title, hay);
    if (score > 0) scored.push({ cmd, score });
  }
  // Array.sort is stable, so equal scores keep their source order.
  scored.sort((a, b) => b.score - a.score);
  return scored.map(entry => entry.cmd);
}

let commandPaletteBase = [];
let commandPaletteResults = [];
let commandPaletteActive = 0;

// Build the static + playlist commands (everything except the query-dependent
// "Search for …" row, which is prepended at render time).
function buildCommandPaletteBase() {
  const cmds = [
    { id: 'play-pause', title: state.isPlaying ? 'Pause' : 'Play', keywords: 'toggle playback resume', hint: 'Playback', run: () => togglePlayback() },
    { id: 'next', title: 'Next track', keywords: 'skip forward', hint: 'Playback', run: () => runTransportCommand('cmd_next', { pollDelay: 500 }) },
    { id: 'prev', title: 'Previous track', keywords: 'back rewind', hint: 'Playback', run: () => runTransportCommand('cmd_prev', { pollDelay: 500 }) },
  ];
  if (state.currentTrack) {
    cmds.push({ id: 'station', title: 'Make it a mix (start station)', keywords: 'radio autoplay similar', hint: 'Playback', run: () => startStationFromTrack(state.currentTrack) });
  }
  cmds.push(
    { id: 'mini-square', title: 'Mini player: square', keywords: 'compact cover window', hint: 'Window', run: () => setMiniMode('square') },
    { id: 'mini-bar', title: 'Mini player: bar', keywords: 'compact strip window', hint: 'Window', run: () => setMiniMode('bar') },
    { id: 'mini-nano', title: 'Mini player: nano (cover only)', keywords: 'compact tile window', hint: 'Window', run: () => setMiniMode('nano') },
    { id: 'mini-off', title: 'Exit mini player', keywords: 'full window restore', hint: 'Window', run: () => setMiniMode('off') },
    { id: 'settings', title: 'Open settings', keywords: 'preferences config devices audio', hint: 'App', run: () => openSettings() },
    { id: 'liked', title: 'Liked Songs', keywords: 'favorites hearts library', hint: 'Library', run: () => { dom.tabLibrary.click(); openLikedSongs(); } },
  );
  (state.playlists || []).forEach(pl => {
    if (!pl || !pl.id) return;
    cmds.push({ id: 'pl-' + pl.id, title: pl.name || 'Untitled', keywords: 'playlist ' + (pl.owner || ''), hint: 'Playlist', run: () => { dom.tabLibrary.click(); openPlaylist(pl); } });
  });
  return cmds;
}

function makeSearchCommand(query) {
  return {
    id: 'search',
    title: `Search for “${query}”`,
    keywords: 'find spotify ' + query,
    hint: 'Search',
    run: () => { dom.tabSearch.click(); dom.searchInput.value = query; doSearch(); },
  };
}

function openCommandPalette() {
  commandPaletteBase = buildCommandPaletteBase();
  dom.cmdkInput.value = '';
  show(dom.commandPalette);
  renderCommandPalette();
  dom.cmdkInput.focus();
}

function closeCommandPalette() {
  hide(dom.commandPalette);
  dom.cmdkInput.value = '';
  commandPaletteResults = [];
  commandPaletteActive = 0;
}

function isCommandPaletteOpen() {
  return dom.commandPalette && !dom.commandPalette.classList.contains('hidden');
}

function renderCommandPalette() {
  const q = dom.cmdkInput.value.trim();
  const source = q ? [makeSearchCommand(q), ...commandPaletteBase] : commandPaletteBase;
  commandPaletteResults = filterCommands(q, source);
  commandPaletteActive = 0;

  dom.cmdkList.innerHTML = '';
  dom.cmdkList.classList.toggle('hidden', commandPaletteResults.length === 0);
  dom.cmdkEmpty.classList.toggle('hidden', commandPaletteResults.length !== 0);

  const frag = document.createDocumentFragment();
  commandPaletteResults.forEach((cmd, index) => {
    const row = document.createElement('div');
    row.className = 'cmdk-item' + (index === 0 ? ' active' : '');
    row.setAttribute('role', 'option');
    const title = document.createElement('span');
    title.className = 'cmdk-item-title';
    title.textContent = cmd.title;
    row.appendChild(title);
    if (cmd.hint) {
      const hint = document.createElement('span');
      hint.className = 'cmdk-item-hint';
      hint.textContent = cmd.hint;
      row.appendChild(hint);
    }
    row.addEventListener('mousemove', () => setCommandPaletteActive(index));
    row.addEventListener('click', () => runCommandPaletteCommand(cmd));
    frag.appendChild(row);
  });
  dom.cmdkList.appendChild(frag);
}

function setCommandPaletteActive(index) {
  if (index < 0 || index >= commandPaletteResults.length || index === commandPaletteActive) return;
  const rows = dom.cmdkList.children;
  if (rows[commandPaletteActive]) rows[commandPaletteActive].classList.remove('active');
  commandPaletteActive = index;
  if (rows[commandPaletteActive]) rows[commandPaletteActive].classList.add('active');
}

function moveCommandPaletteActive(delta) {
  if (commandPaletteResults.length === 0) return;
  const next = Math.min(Math.max(commandPaletteActive + delta, 0), commandPaletteResults.length - 1);
  setCommandPaletteActive(next);
  const activeRow = dom.cmdkList.children[commandPaletteActive];
  if (activeRow) activeRow.scrollIntoView({ block: 'nearest' });
}

function runCommandPaletteCommand(cmd) {
  closeCommandPalette();
  if (cmd && typeof cmd.run === 'function') {
    try {
      const result = cmd.run();
      if (result && typeof result.catch === 'function') result.catch(err => setStatus(String(err)));
    } catch (err) {
      setStatus(String(err));
    }
  }
}

function toggleCommandPalette() {
  if (isCommandPaletteOpen()) closeCommandPalette();
  else openCommandPalette();
}

if (dom.commandPalette) {
  dom.commandPalette.addEventListener('click', e => {
    if (e.target === dom.commandPalette) closeCommandPalette();
  });
  dom.cmdkInput.addEventListener('input', renderCommandPalette);
  dom.cmdkInput.addEventListener('keydown', e => {
    if (e.key === 'ArrowDown') { e.preventDefault(); moveCommandPaletteActive(1); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); moveCommandPaletteActive(-1); }
    else if (e.key === 'Enter') { e.preventDefault(); runCommandPaletteCommand(commandPaletteResults[commandPaletteActive]); }
    else if (e.key === 'Escape') { e.preventDefault(); closeCommandPalette(); }
  });
}

// Global Ctrl/Cmd+K toggles the palette from anywhere (it's a chord, so it
// doesn't interfere with normal typing).
document.addEventListener('keydown', e => {
  if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === 'k' || e.key === 'K')) {
    e.preventDefault();
    toggleCommandPalette();
  }
});

async function refreshSettingsDevices() {
  dom.settingsDevicesList.innerHTML = '<span class="settings-hint">Loading\u2026</span>';
  try {
    const devices = await invoke('cmd_get_devices');
    if (!devices || devices.length === 0) {
      dom.settingsDevicesList.innerHTML =
        '<span class="settings-hint">No devices found. Open Spotify on a device first.</span>';
      return;
    }
    dom.settingsDevicesList.innerHTML = '';
    devices.forEach(d => {
      const row = document.createElement('div');
      row.className = 'settings-device-row';
      const isLightify = (d.name || '').toLowerCase().includes('lightify');
      const nameSpan = document.createElement('span');
      nameSpan.className = 'settings-device-name' + (isLightify ? ' settings-device-active' : '');
      nameSpan.textContent = (d.name || 'Unknown') + (isLightify ? ' \u2713' : '');
      const btn = document.createElement('button');
      btn.className = 'btn-primary btn-xs';
      btn.textContent = 'Select';
      btn.dataset.id = d.id;
      btn.dataset.name = d.name || '';
      btn.addEventListener('click', async () => {
        try {
          await invoke('cmd_set_device', { deviceId: btn.dataset.id });
          setStatus('Device: ' + btn.dataset.name);
          closeSettings();
        } catch (e) {
          setStatus('Failed to select device: ' + e);
        }
      });
      row.appendChild(nameSpan);
      row.appendChild(btn);
      dom.settingsDevicesList.appendChild(row);
    });
  } catch (e) {
    dom.settingsDevicesList.innerHTML = '<span class="settings-hint">Error: ' + e + '</span>';
  }
}

async function refreshSettingsNativeStatus() {
  try {
    const status = await invoke('cmd_native_audio_status');
    dom.settingsNativeStatus.textContent = status.available
      ? (status.running ? 'Running \u2014 ' + status.path : 'Available \u2014 not running')
      : 'Not found (build lightify-audio for native playback)';
  } catch (e) {
    dom.settingsNativeStatus.textContent = 'Error: ' + e;
  }
}

async function refreshSettingsWatchdogReport() {
  if (!dom.settingsWatchdogReport) return;
  const btn = dom.btnSettingsRefreshWatchdog;
  if (btn) btn.disabled = true;
  dom.settingsWatchdogReport.textContent = 'Loading…';
  try {
    dom.settingsWatchdogReport.textContent = await invoke('cmd_debug_report');
  } catch (e) {
    dom.settingsWatchdogReport.textContent = 'Failed to load report: ' + String(e);
  } finally {
    if (btn) btn.disabled = false;
  }
}

dom.btnSettings.addEventListener('click', openSettings);
dom.btnSettingsClose.addEventListener('click', closeSettings);
dom.settingsOverlay.addEventListener('click', e => {
  if (e.target === dom.settingsOverlay) closeSettings();
});

dom.btnSettingsRefreshDevices.addEventListener('click', refreshSettingsDevices);
if (dom.btnSettingsRefreshWatchdog) {
  dom.btnSettingsRefreshWatchdog.addEventListener('click', refreshSettingsWatchdogReport);
}

function startAudioOutputWatcher() {
  if (state.audioOutputWatchTimer) return;
  checkDefaultAudioOutputChange().catch(() => {});
  state.audioOutputWatchTimer = setInterval(() => {
    checkDefaultAudioOutputChange().catch(() => {});
  }, 5000);
}

async function checkDefaultAudioOutputChange() {
  if (state.audioOutputWatchBusy) return;
  state.audioOutputWatchBusy = true;
  try {
    const [current, outputs] = await Promise.all([
      invoke('cmd_get_audio_output'),
      invoke('cmd_list_audio_outputs'),
    ]);
    const resolvedDefault = outputs && outputs.length > 0 ? outputs[0] : '';
    if (current) {
      state.defaultAudioOutput = resolvedDefault;
      return;
    }
    if (!state.defaultAudioOutput) {
      state.defaultAudioOutput = resolvedDefault;
      return;
    }
    if (resolvedDefault && resolvedDefault !== state.defaultAudioOutput) {
      const previous = state.defaultAudioOutput;
      state.defaultAudioOutput = resolvedDefault;
      setStatus('Audio output changed: ' + resolvedDefault);
      try {
        await invoke('cmd_restart_native_audio');
        await sleep(1500);
        await ensureDevice();
        if (!dom.settingsOverlay.classList.contains('hidden')) {
          await refreshSettingsNativeStatus();
          await refreshSettingsAudioOutputs();
        }
      } catch (e) {
        state.defaultAudioOutput = previous;
        setStatus('Audio output restart failed: ' + e);
      }
    }
  } finally {
    state.audioOutputWatchBusy = false;
  }
}

dom.btnSettingsRestartAudio.addEventListener('click', async () => {
  dom.settingsNativeStatus.textContent = 'Restarting\u2026';
  try {
    await invoke('cmd_restart_native_audio');
    await sleep(1500);
    await refreshSettingsNativeStatus();
    await refreshSettingsWatchdogReport();
    ensureDevice().catch(() => {});
  } catch (e) {
    dom.settingsNativeStatus.textContent = 'Error: ' + e;
  }
});

async function refreshSettingsAudioOutputs() {
  const sel = dom.settingsAudioOutput;
  if (!sel) return;
  sel.innerHTML = '<option value="">Loading\u2026</option>';
  try {
    const [outputs, current] = await Promise.all([
      invoke('cmd_list_audio_outputs'),
      invoke('cmd_get_audio_output'),
    ]);
    sel.innerHTML = '';
    const defOpt = document.createElement('option');
    defOpt.value = '';
    defOpt.textContent = outputs.length > 0 ? 'System Default - ' + outputs[0] : 'System Default';
    sel.appendChild(defOpt);
    for (const name of outputs) {
      const opt = document.createElement('option');
      opt.value = name;
      opt.textContent = name;
      sel.appendChild(opt);
    }
    sel.value = current || '';
  } catch (e) {
    sel.innerHTML = '<option value="">Error: ' + e + '</option>';
  }
}

dom.btnSettingsRefreshOutputs.addEventListener('click', refreshSettingsAudioOutputs);

async function refreshSettingsDownloadPath() {
  if (!dom.settingsDownloadPath) return;
  try {
    const path = await invoke('cmd_get_download_path');
    dom.settingsDownloadPath.value = path || '';
  } catch (e) {
    dom.settingsDownloadPath.value = '';
  }
}

dom.btnSettingsApplyDownloadPath.addEventListener('click', async () => {
  const path = dom.settingsDownloadPath.value.trim();
  if (!path) { setStatus('Download path cannot be empty'); return; }
  try {
    await invoke('cmd_set_download_path', { path });
    setStatus('Download path saved');
  } catch (e) {
    setStatus('Failed to set download path: ' + e);
  }
});

function downloaderStatusText(status) {
  if (status.spotify_ready) return 'Spotify downloader connected';
  if (status.spotify_login_in_progress) {
    return status.spotify_login_message || 'Waiting for Spotify device selection';
  }
  if (status.spotify_login_error) return 'Error: ' + status.spotify_login_error;
  return status.spotify_configured_accounts > 0
    ? 'Spotify downloader account needs reconnecting'
    : 'Spotify downloader is not connected';
}

async function refreshSettingsDownloaderStatus() {
  if (!dom.settingsDownloaderStatus) return;
  dom.settingsDownloaderStatus.textContent = 'Checking…';
  if (dom.btnSettingsStartDownloaderLogin) dom.btnSettingsStartDownloaderLogin.disabled = true;
  try {
    const status = await invoke('cmd_downloader_status');
    dom.settingsDownloaderStatus.textContent = downloaderStatusText(status);
    if (dom.btnSettingsStartDownloaderLogin) {
      dom.btnSettingsStartDownloaderLogin.disabled = status.spotify_ready || status.spotify_login_in_progress;
      dom.btnSettingsStartDownloaderLogin.textContent = status.spotify_login_in_progress ? 'Waiting…' : 'Connect Spotify Downloader';
    }
  } catch (e) {
    dom.settingsDownloaderStatus.textContent = 'Error: ' + e;
    if (dom.btnSettingsStartDownloaderLogin) dom.btnSettingsStartDownloaderLogin.disabled = false;
  }
}

if (dom.btnSettingsStartDownloaderLogin) {
  dom.btnSettingsStartDownloaderLogin.addEventListener('click', async () => {
    dom.btnSettingsStartDownloaderLogin.disabled = true;
    dom.btnSettingsStartDownloaderLogin.textContent = 'Waiting…';
    try {
      const status = await invoke('cmd_start_downloader_login');
      const message = downloaderStatusText(status);
      dom.settingsDownloaderStatus.textContent = message;
      setStatus(message);
    } catch (e) {
      dom.settingsDownloaderStatus.textContent = 'Error: ' + e;
      setStatus('Downloader setup failed: ' + e);
    } finally {
      refreshSettingsDownloaderStatus().catch(() => {});
    }
  });
}

dom.btnSettingsApplyOutput.addEventListener('click', async () => {
  dom.settingsNativeStatus.textContent = 'Applying output\u2026';
  try {
    await invoke('cmd_set_audio_output', { name: dom.settingsAudioOutput.value || '' });
    await invoke('cmd_restart_native_audio');
    await sleep(1500);
    await refreshSettingsNativeStatus();
    await refreshSettingsWatchdogReport();
    ensureDevice().catch(() => {});
  } catch (e) {
    dom.settingsNativeStatus.textContent = 'Error: ' + e;
  }
});

dom.btnSettingsExportDebug.addEventListener('click', async () => {
  const btn = dom.btnSettingsExportDebug;
  const status = dom.settingsDebugStatus;
  btn.disabled = true;
  status.textContent = 'Exporting\u2026';
  try {
    const path = await invoke('cmd_export_debug');
    status.textContent = '\u2705 Written to: ' + path;
  } catch (e) {
    status.textContent = '\u274C ' + e;
  } finally {
    btn.disabled = false;
  }
});

document.querySelectorAll('input[name="poll-rate"]').forEach(radio => {
  radio.addEventListener('change', () => {
    const ms = parseInt(radio.value, 10);
    state.pollIntervalMs = ms;
    if (state.pollTimer) {
      clearInterval(state.pollTimer);
      state.pollTimer = setInterval(pollNow, ms);
    }
  });
});

async function playTrack(trackUri, contextUri = null) {
  const fn_ = () => invoke('cmd_play_track', { trackUri, contextUri });
  try {
    switchToSpotifyQueueSource();
    // A standalone track (no list context) is not "playing from" any library
    // list, so clear the now-playing highlight. Context plays keep it — the
    // caller sets the active source for the list the track belongs to.
    if (!contextUri) rememberActiveLibraryPlaybackSource(null, null);
    await fn_();
    scheduleFastPlaybackPoll();
  } catch (e) {
    await handlePlayError(String(e), fn_);
  }
}

async function playContext(contextUri) {
  const fn_ = () => invoke('cmd_play_context', { contextUri });
  try {
    switchToSpotifyQueueSource();
    await fn_();
    scheduleFastPlaybackPoll();
  } catch (e) {
    await handlePlayError(String(e), fn_);
  }
}

// While a native station is playing, spirc owns the queue — the Web API player
// endpoints can't drive it (they re-resolve devices and transfer, which fights the
// engine and leaves the station queue untouched). Send transport to the engine so the
// station queue is the authority for the controls, exactly as it is for autoplay.
const ENGINE_TRANSPORT_ACTIONS = {
  cmd_play: 'play',
  cmd_pause: 'pause',
  cmd_next: 'next',
  cmd_prev: 'prev',
};

function stationOwnsPlayback() {
  return Boolean(state.nativeStation);
}

function runTransportCommand(command, options = {}) {
  if (stationOwnsPlayback() && ENGINE_TRANSPORT_ACTIONS[command]) {
    const pollDelay = Number(options.pollDelay || 0);
    return invoke('cmd_engine_transport', { action: ENGINE_TRANSPORT_ACTIONS[command] })
      .catch(err => setStatus(String(err)))
      .finally(() => {
        if (pollDelay > 0) setTimeout(pollNow, pollDelay);
      });
  }

  if (command === 'cmd_next' && usesLocalQueueMirror()) {
    return playNextLocalQueueTrack(options);
  }

  if (command === 'cmd_prev' && usesLocalQueueMirror()) {
    return playPreviousLocalQueueTrack(options);
  }

  const pollDelay = Number(options.pollDelay || 0);
  const fn_ = () => invoke(command);
  return fn_()
    .catch(err => handlePlayError(String(err), fn_))
    .finally(() => {
      if (pollDelay > 0) {
        setTimeout(pollNow, pollDelay);
      }
    });
}

function togglePlayback() {
  return runTransportCommand(state.isPlaying ? 'cmd_pause' : 'cmd_play');
}

function mediaSessionAvailable() {
  return typeof navigator !== 'undefined' && 'mediaSession' in navigator;
}

function clearPendingVolumeState() {
  state.pendingVolumePercent = null;
  state.pendingVolumeAt = 0;
  state.pendingVolumeConfirmedAt = 0;
}

function pendingVolumeLocked() {
  return state.pendingVolumePercent != null
    && (Date.now() - state.pendingVolumeAt) <= VOLUME_PENDING_GRACE_MS;
}

function pendingVolumeConfirmationLocked() {
  return state.pendingVolumePercent != null
    && state.pendingVolumeConfirmedAt > 0
    && (Date.now() - state.pendingVolumeConfirmedAt) <= VOLUME_PENDING_GRACE_MS;
}

function syncVolumeSliderFromBackend(percent) {
  if (!Number.isFinite(percent)) return;

  if (state.volumeInteractionActive) {
    return;
  }

  if (state.pendingVolumePercent != null) {
    if (percent === state.pendingVolumePercent) {
      dom.volumeSlider.value = percent;
      if (!state.pendingVolumeConfirmedAt) {
        state.pendingVolumeConfirmedAt = Date.now();
      }
      return;
    }

    if (pendingVolumeLocked() || pendingVolumeConfirmationLocked()) {
      return;
    }

    clearPendingVolumeState();
    dom.volumeSlider.value = percent;
    return;
  }

  dom.volumeSlider.value = percent;
}

function updateMediaSession() {
  if (!mediaSessionAvailable()) return;

  const session = navigator.mediaSession;
  session.playbackState = state.isPlaying ? 'playing' : 'paused';

  const track = state.currentTrack;
  if (!track || !track.name || typeof MediaMetadata !== 'function') {
    session.metadata = null;
    return;
  }

  const metadata = {
    title: track.name || 'Lightify',
    artist: track.artists || '',
    album: track.album || '',
  };
  const albumArt = typeof track.album_art === 'string' ? track.album_art.trim() : '';
  if (albumArt) {
    metadata.artwork = [{ src: albumArt }];
  }

  session.metadata = new MediaMetadata(metadata);
}

function setMediaSessionHandler(action, handler) {
  if (!mediaSessionAvailable()) return;
  try {
    navigator.mediaSession.setActionHandler(action, handler);
  } catch (err) {
    console.warn('media session action unsupported', action, err);
  }
}

function initMediaSession() {
  if (!mediaSessionAvailable()) return;
  setMediaSessionHandler('play', () => runTransportCommand('cmd_play'));
  setMediaSessionHandler('pause', () => runTransportCommand('cmd_pause'));
  setMediaSessionHandler('previoustrack', () => runTransportCommand('cmd_prev', { pollDelay: 500 }));
  setMediaSessionHandler('nexttrack', () => runTransportCommand('cmd_next', { pollDelay: 500 }));
  setMediaSessionHandler('stop', () => runTransportCommand('cmd_pause'));
  updateMediaSession();
}

dom.btnPlay.addEventListener('click', () => {
  togglePlayback();
});

dom.btnNext.addEventListener('click', () => {
  runTransportCommand('cmd_next', { pollDelay: 500 });
});

dom.btnPrev.addEventListener('click', () => {
  runTransportCommand('cmd_prev', { pollDelay: 500 });
});

dom.btnShuffle.addEventListener('click', () => {
  const next = !state.shuffleOn;
  if (usesLocalQueueMirror()) {
    setShuffleUiState(next);
    if (next) {
      const shuffledCount = reshuffleActiveQueue();
      setStatus(shuffledCount > 0 ? `Shuffled ${shuffledCount} queued tracks` : 'Shuffle enabled');
    } else {
      setStatus('Shuffle disabled');
    }
    return;
  }

  invoke('cmd_shuffle', { enabled: next })
    .then(() => setShuffleUiState(next))
    .catch(err => setStatus(String(err)));
});

dom.btnRepeat.addEventListener('click', () => {
  const cycle = { off: 'context', context: 'track', track: 'off' };
  const next = cycle[state.repeatMode] || 'off';
  invoke('cmd_repeat', { mode: next }).catch(err => setStatus(String(err)));
  state.repeatMode = next;
  dom.btnRepeat.classList.toggle('active', next !== 'off');
});

// ── Seek ─────────────────────────────────────────────────────

dom.progressBar.addEventListener('click', e => {
  const rect = dom.progressBar.getBoundingClientRect();
  const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
  const dur = state.currentTrack?.duration_ms;
  if (!dur) return;
  const ms = Math.round(ratio * dur);
  (stationOwnsPlayback()
    ? invoke('cmd_engine_seek', { positionMs: ms })
    : invoke('cmd_seek', { positionMs: ms })
  ).catch(err => setStatus(String(err)));
  setPlaybackSnapshot(ms, dur, state.isPlaying);
});

// ── Volume ───────────────────────────────────────────────────

dom.volumeSlider.addEventListener('pointerdown', () => {
  state.volumeInteractionActive = true;
});

dom.volumeSlider.addEventListener('pointerup', () => {
  state.volumeInteractionActive = false;
});

dom.volumeSlider.addEventListener('pointercancel', () => {
  state.volumeInteractionActive = false;
});

dom.volumeSlider.addEventListener('change', () => {
  state.volumeInteractionActive = false;
});

dom.volumeSlider.addEventListener('blur', () => {
  state.volumeInteractionActive = false;
});

dom.volumeSlider.addEventListener('input', () => {
  state.volumeInteractionActive = true;
  const percent = parseInt(dom.volumeSlider.value, 10);
  state.pendingVolumePercent = percent;
  state.pendingVolumeAt = Date.now();
  state.pendingVolumeConfirmedAt = 0;
  clearTimeout(state.volumeDebounce);
  state.volumeDebounce = setTimeout(() => {
    (stationOwnsPlayback() ? invoke('cmd_engine_volume', { percent }) : invoke('cmd_volume', { percent }))
      .then(() => setTimeout(pollNow, 120))
      .catch(err => {
        clearPendingVolumeState();
        setStatus(String(err));
      });
  }, VOLUME_INPUT_DEBOUNCE_MS);
});

// ── Like ─────────────────────────────────────────────────────

async function checkLiked(trackId) {
  if (!trackId) return;
  if (state.likeTogglePending && state.likeToggleTrackId === trackId) return;
  try {
    state.isLiked = await invoke('cmd_check_liked', { trackId });
    dom.btnLike.classList.toggle('liked', state.isLiked);
    syncLikeButtonState();
  } catch (_) { /* ignore */ }
}

async function likeTrack(track, currentlyLiked) {
  if (!track || !track.id) return false;
  if (state.likeTogglePending) {
    if (state.likeToggleTrackId === track.id) {
      setStatus('Like action already in progress');
      return state.currentTrack && state.currentTrack.id === track.id ? state.isLiked : currentlyLiked;
    }
    setStatus('Wait for the current library action to finish');
    return false;
  }

  state.likeTogglePending = true;
  state.likeToggleTrackId = track.id;
  syncLikeButtonState();
  try {
    const liked = await invoke('cmd_toggle_like', {
      trackId: track.id,
      currentlyLiked,
    });
    if (state.currentTrack && state.currentTrack.id === track.id) {
      state.isLiked = liked;
      dom.btnLike.classList.toggle('liked', liked);
    }
    setStatus(liked ? 'Saved to Liked Songs' : 'Removed from Liked Songs');
    return liked;
  } catch (e) {
    throw cleanSpotifyError(e);
  } finally {
    state.likeTogglePending = false;
    state.likeToggleTrackId = null;
    syncLikeButtonState();
  }
}

dom.btnLike.addEventListener('click', async () => {
  if (!state.currentTrack) return;
  try {
    await likeTrack(state.currentTrack, state.isLiked);
  } catch (e) {
    setStatus(String(e));
  }
});

// ── Station ──────────────────────────────────────────────────

async function startStationFromTrack(track) {
  const stationTrack = stationTrackFromPlaybackTrack(track);
  const trackId = spotifyTrackId(stationTrack);
  const seedUri = spotifyTrackUri(stationTrack);
  if (!trackId || !seedUri) {
    setStatus('Start station needs a Spotify track');
    return;
  }

  // Native Spotify radio (#5). The recommendations Web API is dead, so the engine
  // asks Spotify for the *autoplay variant* of the seed context — a real station of
  // similar tracks, seeded from the song itself (song radio), not the seed's album or
  // artist catalogue. The engine also clears the stale Connect queue first, so a
  // station never inherits leftover queued tracks (the duplicate / skip-back loop).
  try {
    // Set false first so switchToSpotifyQueueSource → clearStationAutoplay doesn't
    // race an autoplay-off against the autoplay-on below.
    state.nativeStation = false;
    // Hand the queue to Spotify: the blip gate fails open and Lightify's mirror stops
    // competing with spirc, which owns advancement for a station.
    switchToSpotifyQueueSource();

    // Repeat would fight the radio (repeat-context keeps the seed context "full", so
    // Spotify never continues into the mix — the album/loop bug). The engine clears it
    // for real: handle_load calls reset_options(). We only mirror that in the UI — no
    // Web-API repeat call here, because that goes through put_player, which re-resolves
    // the device and can transfer with play:false, interrupting the station we're about
    // to start.
    if (state.repeatMode !== 'off') {
      state.repeatMode = 'off';
      dom.btnRepeat.classList.remove('active');
    }

    await invoke('cmd_set_autoplay', { enabled: true });
    state.nativeStation = true;
    await invoke('cmd_start_native_station', { contextUri: seedUri });

    rememberActiveLibraryPlaybackSource(null, null);
    setStatus('Station started — Spotify is building a mix…');
    scheduleFastPlaybackPoll();
  } catch (e) {
    // Roll back autoplay if the station failed to start.
    if (state.nativeStation) {
      state.nativeStation = false;
      invoke('cmd_set_autoplay', { enabled: false }).catch(() => {});
    }
    setStatus(String(e));
  }
}

dom.btnStation.addEventListener('click', () => {
  if (!state.currentTrack) return;
  startStationFromTrack(state.currentTrack);
});

// ── Add to queue ─────────────────────────────────────────────

dom.btnAddQueue.addEventListener('click', () => {
  if (!state.currentTrack) return;
  enqueueTrackBatch([state.currentTrack])
    .then(queuedTracks => {
      if (queuedTracks.length > 0) setStatus('Added to queue');
    })
    .catch(err => setStatus(String(err)));
});

function showCurrentTrackContextMenu(e) {
  if (!state.currentTrack || !state.currentTrack.id) return;
  e.preventDefault();
  e.stopPropagation();
  const playAction = () => playTrack(state.currentTrack.uri, null);
  showContextMenu(e, buildSpotifyTrackMenu(state.currentTrack, playAction));
}

dom.trackInfo.addEventListener('contextmenu', showCurrentTrackContextMenu);
dom.albumArtContainer.addEventListener('contextmenu', showCurrentTrackContextMenu);

function buildSpotifyDownloadUrl(kind, id) {
  if (kind === 'liked') return 'https://open.spotify.com/collection/tracks';
  if (!id) throw new Error('Missing Spotify id');
  return `https://open.spotify.com/${kind}/${id}`;
}

async function copyTextToClipboard(text) {
  const value = String(text || '').trim();
  if (!value) throw new Error('Nothing to copy');

  if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
    await navigator.clipboard.writeText(value);
    return;
  }

  const input = document.createElement('textarea');
  input.value = value;
  input.setAttribute('readonly', 'true');
  input.style.position = 'fixed';
  input.style.opacity = '0';
  input.style.pointerEvents = 'none';
  document.body.appendChild(input);
  input.select();
  const ok = document.execCommand('copy');
  document.body.removeChild(input);
  if (!ok) {
    throw new Error('Clipboard access failed');
  }
}

function spotifyTrackShareUrl(track) {
  if (!track || !track.id) throw new Error('Missing Spotify track');
  return buildSpotifyDownloadUrl('track', track.id);
}

async function shareSpotifyTrack(track) {
  await copyTextToClipboard(spotifyTrackShareUrl(track));
  setStatus('Copied Spotify link');
}

async function enqueueDownloadUrl(url, label) {
  await invoke('cmd_enqueue_download', { url });
  setStatus(`Download queued: ${label}`);
  ensureDownloadsSidebar();
}

function enqueueTrackDownload(track) {
  if (!track || !track.id) return;
  enqueueDownloadUrl(buildSpotifyDownloadUrl('track', track.id), track.name || 'track')
    .catch(err => setStatus(String(err)));
}

function enqueuePlaylistDownload(playlist) {
  if (!playlist || !playlist.id) return;
  enqueueDownloadUrl(buildSpotifyDownloadUrl('playlist', playlist.id), playlist.name || 'playlist')
    .catch(err => setStatus(String(err)));
}

function enqueueLikedSongsDownload() {
  enqueueDownloadUrl(buildSpotifyDownloadUrl('liked'), 'Liked Songs')
    .catch(err => setStatus(String(err)));
}

function updateSidebarClearButton(items = []) {
  const sidebarVisible = !dom.sidebar.classList.contains('hidden');
  const mode = sidebarVisible ? state.sidebarMode : null;
  const showClear = mode === 'downloads' || mode === 'queue' || mode === 'recent';
  dom.btnSidebarClear.classList.toggle('hidden', !showClear);
  dom.btnSidebarClear.textContent = 'Clear';

  if (!showClear) {
    dom.btnSidebarClear.disabled = true;
    dom.btnSidebarClear.title = '';
    return;
  }

  if (mode === 'downloads') {
    const hasClearableItems = Array.isArray(items)
      && items.some(item => ['Downloaded', 'Already Exists', 'Cancelled', 'Unavailable', 'Deleted'].includes(item.item_status));
    dom.btnSidebarClear.disabled = !hasClearableItems;
    dom.btnSidebarClear.title = hasClearableItems ? 'Clear finished downloads' : 'No finished downloads to clear';
    return;
  }

  if (mode === 'queue') {
    const canClearLightifyQueue = state.queueSource === 'lightify'
      && (!!spotifyTrackUri(state.currentTrack)
        || state.lightifyQueue.length > 0
        || !!state.beatportAutoplay
        || !!state.spotifySelectionAutoplay);
    dom.btnSidebarClear.disabled = !canClearLightifyQueue;
    dom.btnSidebarClear.title = canClearLightifyQueue
      ? 'Clear the current Lightify queue'
      : (state.queueSource === 'context'
          ? 'This queue mirrors playlist context playback and cannot be cleared here'
          : 'This queue view is managed by Spotify and cannot be cleared here');
    return;
  }

  const recentTracks = recentHistoryForDisplay();
  dom.btnSidebarClear.disabled = recentTracks.length === 0;
  dom.btnSidebarClear.title = recentTracks.length > 0
    ? 'Clear recent tracks'
    : 'No recent tracks to clear';
}

function currentPlaybackProgressMs() {
  if (!state.playbackSnapshot) return 0;
  const elapsedMs = state.playbackSnapshot.isPlaying ? (Date.now() - state.playbackSnapshot.updatedAt) : 0;
  return Math.max(0, Math.round(state.playbackSnapshot.progressMs + elapsedMs));
}

async function constrainPlaybackToCurrentTrack() {
  const trackUri = spotifyTrackUri(state.currentTrack);
  if (!trackUri) return;

  const durationMs = Math.max(0, Number(state.currentTrack?.duration_ms || state.playbackSnapshot?.durationMs || 0));
  const positionMs = Math.max(0, Math.min(currentPlaybackProgressMs(), durationMs > 1000 ? durationMs - 1000 : durationMs));
  const wasPlaying = state.isPlaying;
  let constrained = false;
  const play = async () => {
    await invoke('cmd_play_uris', { trackUris: [trackUri] });
    constrained = true;
  };

  await play().catch(err => handlePlayError(String(err), play));
  if (!constrained) return;

  if (positionMs > 0) {
    try {
      await (stationOwnsPlayback() ? invoke('cmd_engine_seek', { positionMs }) : invoke('cmd_seek', { positionMs }));
    } catch (_) {}
  }

  if (!wasPlaying) {
    try {
      await invoke('cmd_pause');
    } catch (_) {}
  }

  setPlaybackSnapshot(positionMs, durationMs, wasPlaying);
  scheduleFastPlaybackPoll(180);
}

dom.btnSidebarClear.addEventListener('click', async () => {
  if (state.sidebarMode === 'queue') {
    if (state.queueSource !== 'lightify') {
      setStatus(state.queueSource === 'context' ? 'Playlist context queue cannot be cleared here' : 'Spotify queue cannot be cleared here');
      return;
    }
    await constrainPlaybackToCurrentTrack();
    clearLightifyQueue();
    setStatus('Cleared queue');
    return;
  }

  if (state.sidebarMode === 'recent') {
    state.recentHistoryClearedAtMs = Date.now();
    state.sessionHistory = [];
    state.persistedRecentHistory = [];
    state.recentHistoryLoaded = true;
    schedulePersistState();
    loadSidebarRecent();
    updateSidebarClearButton();
    setStatus('Cleared recent tracks');
    return;
  }

  if (state.sidebarMode === 'downloads') {
    try {
      await invoke('cmd_clear_downloads');
      setStatus('Cleared finished downloads');
      await loadSidebarDownloads();
    } catch (e) {
      setStatus(String(e));
    }
  }
});

function stopDownloadsPolling() {
  if (state.downloadsPollTimer) {
    clearInterval(state.downloadsPollTimer);
    state.downloadsPollTimer = null;
  }
}

function startDownloadsPolling() {
  stopDownloadsPolling();
  state.downloadsPollTimer = setInterval(() => {
    if (state.sidebarMode !== 'downloads' || dom.sidebar.classList.contains('hidden')) {
      stopDownloadsPolling();
      return;
    }
    loadSidebarDownloads().catch(() => {});
  }, 3000);
}

function ensureDownloadsSidebar() {
  if (state.sidebarMode !== 'downloads' || dom.sidebar.classList.contains('hidden')) {
    toggleSidebar('downloads');
    return;
  }
  loadSidebarDownloads().catch(() => {});
}

// ── Session history ──────────────────────────────────────────

function normalizeRecentHistoryEntry(track, fallbackTimestampMs = 0) {
  if (!track || typeof track !== 'object') return null;

  const timestamp = recentHistoryTimestampMs(track)
    || (Number.isFinite(fallbackTimestampMs) ? fallbackTimestampMs : 0);
  const entry = {
    id: typeof track.id === 'string' ? track.id : '',
    name: typeof track.name === 'string' ? track.name : '',
    artists: typeof track.artists === 'string' ? track.artists : '',
    uri: typeof track.uri === 'string' ? track.uri : '',
    album_art: typeof track.album_art === 'string' ? track.album_art : '',
    duration_ms: Number.isFinite(track.duration_ms) ? track.duration_ms : 0,
    added_at: timestamp > 0
      ? new Date(timestamp).toISOString()
      : (typeof track.added_at === 'string' ? track.added_at : ''),
    timestamp,
  };
  if (!entry.id && !entry.uri && !entry.name) return null;
  return entry;
}

function recentHistoryForDisplay() {
  const merged = state.sessionHistory.slice();
  for (const entry of state.persistedRecentHistory) {
    const entryTime = recentHistoryTimestampMs(entry);
    const hasSessionDuplicate = state.sessionHistory.some(sessionEntry => {
      if (!samePlaybackTrack(sessionEntry, entry)) return false;
      // A shared strong identity (track id or uri) means the persisted feed is
      // re-reporting a track the session already has — always a duplicate,
      // regardless of how far apart the timestamps are.
      const sessionId = resolvedSpotifyTrackId(sessionEntry);
      const entryId = resolvedSpotifyTrackId(entry);
      if (sessionId && entryId && sessionId === entryId) return true;
      const sessionUri = spotifyTrackUri(sessionEntry);
      const entryUri = spotifyTrackUri(entry);
      if (sessionUri && entryUri && sessionUri === entryUri) return true;
      if (sessionEntry.added_at && entry.added_at && sessionEntry.added_at === entry.added_at) return true;
      const sessionTime = recentHistoryTimestampMs(sessionEntry);
      return sessionTime > 0
        && entryTime > 0
        && Math.abs(sessionTime - entryTime) <= RECENT_HISTORY_DEDUPE_WINDOW_MS;
    });
    if (!hasSessionDuplicate) merged.push(entry);
  }

  const sorted = merged
    .map((entry, index) => ({ entry, index }))
    .sort((left, right) => {
      const byTime = recentHistoryTimestampMs(right.entry) - recentHistoryTimestampMs(left.entry);
      return byTime !== 0 ? byTime : (left.index - right.index);
    });

  // Collapse repeat plays down to their most-recent occurrence so the list reads
  // as distinct recent tracks. Keyed by name+artist when both are present so the
  // same song collapses even when it comes back under two different Spotify ids
  // (relinked market versions, or the same song present as separate releases in a
  // searched playlist) — different artists stay distinct. Falls back to the id/uri
  // identity when name/artist are missing. Display-only: the underlying session
  // and persisted history are left untouched.
  const recentKey = (entry) => {
    const name = typeof entry?.name === 'string' ? entry.name.trim().toLowerCase() : '';
    const artists = typeof entry?.artists === 'string' ? entry.artists.trim().toLowerCase() : '';
    if (name && artists) return `na::${name}::${artists}`;
    return queueTrackIdentity(entry);
  };
  const seen = new Set();
  const deduped = [];
  for (const { entry } of sorted) {
    const key = recentKey(entry);
    if (key && seen.has(key)) continue;
    if (key) seen.add(key);
    deduped.push(entry);
  }
  return deduped.slice(0, RECENT_HISTORY_LIMIT);
}

async function refreshPersistedRecentHistory({ silent = false } = {}) {
  if (state.recentHistoryLoading) return;
  state.recentHistoryLoading = true;
  try {
    const tracks = await invoke('cmd_recently_played');
    const clearedAtMs = state.recentHistoryClearedAtMs;
    state.persistedRecentHistory = (Array.isArray(tracks) ? tracks : [])
      .map(track => normalizeRecentHistoryEntry(track))
      .filter(Boolean)
      .filter(track => recentHistoryTimestampMs(track) > clearedAtMs)
      .sort((left, right) => recentHistoryTimestampMs(right) - recentHistoryTimestampMs(left))
      .slice(0, RECENT_HISTORY_LIMIT);
    state.recentHistoryLoaded = true;
    if (state.sidebarMode === 'recent' && !dom.sidebar.classList.contains('hidden')) {
      loadSidebarRecent();
    } else {
      updateSidebarClearButton();
    }
  } catch (e) {
    if (!silent) setStatus('Failed to load recent tracks: ' + String(e));
  } finally {
    state.recentHistoryLoading = false;
  }
}

function addToSessionHistory(track) {
  const entry = normalizeRecentHistoryEntry(track, Date.now());
  if (!entry) return;

  if (state.sessionHistory.length > 0 && samePlaybackTrack(state.sessionHistory[0], entry)) {
    state.sessionHistory[0] = {
      ...state.sessionHistory[0],
      ...entry,
      id: entry.id || state.sessionHistory[0].id,
      uri: entry.uri || state.sessionHistory[0].uri,
      album_art: entry.album_art || state.sessionHistory[0].album_art,
      duration_ms: entry.duration_ms || state.sessionHistory[0].duration_ms || 0,
      added_at: state.sessionHistory[0].added_at || entry.added_at,
      timestamp: state.sessionHistory[0].timestamp || entry.timestamp,
    };
  } else {
    // Move-to-front: drop any earlier occurrence of this track so a replay is
    // promoted to the top instead of leaving a duplicate deeper in the log.
    const key = queueTrackIdentity(entry);
    if (key) {
      state.sessionHistory = state.sessionHistory.filter(existing => queueTrackIdentity(existing) !== key);
    }
    state.sessionHistory.unshift(entry);
    if (state.sessionHistory.length > RECENT_HISTORY_LIMIT) state.sessionHistory.length = RECENT_HISTORY_LIMIT;
  }
  if (state.sidebarMode === 'recent' && !dom.sidebar.classList.contains('hidden')) {
    loadSidebarRecent();
  } else {
    updateSidebarClearButton();
  }
  schedulePersistState();
}

// ── Sidebar (queue / recent / downloads) ────────────────────

dom.btnShowQueue.addEventListener('click',   () => toggleSidebar('queue'));
dom.btnShowRecent.addEventListener('click',  () => toggleSidebar('recent'));
dom.btnShowDownloads.addEventListener('click', () => toggleSidebar('downloads'));
dom.btnCloseSidebar.addEventListener('click', () => closeSidebar());

function syncSidebarToggleButtons() {
  dom.btnShowQueue.classList.toggle('active', sidebarModeIsActive('queue'));
  dom.btnShowRecent.classList.toggle('active', sidebarModeIsActive('recent'));
  dom.btnShowDownloads.classList.toggle('active', sidebarModeIsActive('downloads'));
}

function toggleSidebar(mode) {
  if (state.sidebarMode === mode && !dom.sidebar.classList.contains('hidden')) {
    closeSidebar();
    return;
  }
  state.sidebarMode = mode;
  dom.sidebar.classList.remove('sidebar-closing'); // cancel any in-flight close animation
  show(dom.sidebar);
  dom.sidebarTitle.textContent = mode.toUpperCase();
  updateSidebarClearButton();
  syncSidebarToggleButtons();

  stopDownloadsPolling();
  if (mode === 'queue') {
    show(dom.sidebarActions);
    loadSidebarQueue();
  } else if (mode === 'downloads') {
    hide(dom.sidebarActions);
    loadSidebarDownloads();
    startDownloadsPolling();
  } else {
    hide(dom.sidebarActions);
    loadSidebarRecent();
  }
}

function closeSidebar() {
  stopDownloadsPolling();
  const el = dom.sidebar;
  // Smooth slide-out, then hide. If the sidebar is reopened mid-animation,
  // toggleSidebar clears 'sidebar-closing' so this timeout becomes a no-op.
  if (!el.classList.contains('hidden') && !el.classList.contains('sidebar-closing')) {
    el.classList.add('sidebar-closing');
    setTimeout(() => {
      if (el.classList.contains('sidebar-closing')) {
        el.classList.remove('sidebar-closing');
        hide(el);
      }
    }, 180);
  } else {
    hide(el);
  }
  state.sidebarMode = null;
  updateSidebarClearButton();
  syncSidebarToggleButtons();
}

function sidebarModeIsActive(mode) {
  return state.sidebarMode === mode && !dom.sidebar.classList.contains('hidden');
}

async function loadSidebarQueue() {
  dom.sidebarList.innerHTML = '';
  if (usesLocalQueueMirror()) {
    if (!sidebarModeIsActive('queue')) return;
    renderSidebarTracks(state.lightifyQueue);
    updateSidebarClearButton();
    return;
  }
  try {
    const tracks = await invoke('cmd_queue');
    if (!sidebarModeIsActive('queue')) return;
    renderSidebarTracks(tracks);
    updateSidebarClearButton();
  } catch (e) {
    if (!sidebarModeIsActive('queue')) return;
    if (state.lightifyQueue.length > 0) {
      renderSidebarTracks(state.lightifyQueue);
      setStatus('Loaded local queue after Spotify queue error: ' + String(e));
      return;
    }
    dom.sidebarList.innerHTML = '<div class="list-item"><div class="item-name" style="color:var(--text-dim)">Failed to load queue</div></div>';
    updateSidebarClearButton();
  }
}

function loadSidebarRecent() {
  loadSidebarSession();
  updateSidebarClearButton();
}

function loadSidebarSession() {
  dom.sidebarList.innerHTML = '';
  if (!state.recentHistoryLoaded && !state.recentHistoryLoading) {
    refreshPersistedRecentHistory({ silent: true }).catch(() => {});
  }
  const recentTracks = recentHistoryForDisplay();
  if (recentTracks.length === 0) {
    const message = state.recentHistoryLoading ? 'Loading recent tracks…' : 'No recent tracks yet';
    dom.sidebarList.innerHTML = `<div class="list-item"><div class="item-name" style="color:var(--text-dim)">${message}</div></div>`;
    return;
  }
  renderSidebarTracks(recentTracks);
}

async function loadSidebarDownloads() {
  if (state.downloadsLoading) return;
  state.downloadsLoading = true;
  try {
    const items = await invoke('cmd_downloads_snapshot');
    if (!sidebarModeIsActive('downloads')) return;
    renderSidebarDownloads(items);
  } catch (e) {
    if (!sidebarModeIsActive('downloads')) return;
    updateSidebarClearButton();
    dom.sidebarList.innerHTML = '<div class="list-item"><div class="item-name" style="color:var(--text-dim)">Failed to load downloads</div></div>';
  } finally {
    state.downloadsLoading = false;
  }
}

function buildDownloadItemMenu(item) {
  const status = String(item.item_status || 'Waiting');
  const items = [];

  if (status === 'Failed' || status === 'Cancelled') {
    items.push({
      label: 'Retry',
      action: () => invoke('cmd_retry_download_item', { localId: item.local_id })
        .then(() => {
          setStatus('Download queued for retry');
          return loadSidebarDownloads();
        })
        .catch(err => setStatus(String(err))),
    });
  } else if (!['Downloaded', 'Already Exists', 'Deleted', 'Unavailable'].includes(status)) {
    items.push({
      label: 'Cancel',
      action: () => invoke('cmd_cancel_download_item', { localId: item.local_id })
        .then(() => {
          setStatus('Download cancelled');
          return loadSidebarDownloads();
        })
        .catch(err => setStatus(String(err))),
    });
  }

  items.push({
    label: item.file_path ? 'Open folder' : 'Open download folder',
    action: () => invoke('cmd_open_download_folder', { path: item.file_path || '' }).catch(err => setStatus(String(err))),
  });

  if (item.file_path && ['Downloaded', 'Already Exists'].includes(status)) {
    items.push({
      label: 'Delete file',
      action: () => invoke('cmd_delete_download_item', { localId: item.local_id })
        .then(() => {
          setStatus('Deleted downloaded file');
          return loadSidebarDownloads();
        })
        .catch(err => setStatus(String(err))),
    });
  }

  return items;
}

function playSidebarQueueTrackSelection(track) {
  if (!usesLocalQueueMirror()) {
    return playTrack(spotifyTrackUri(track), null);
  }

  const queueTracks = state.lightifyQueue
    .map(entry => normalizeQueueTrack(entry))
    .filter(Boolean);
  const normalizedTrack = normalizeQueueTrack(track);
  const startIndex = queueTracks.findIndex(candidate => samePlaybackTrack(candidate, normalizedTrack));
  if (startIndex < 0) {
    return playTrack(spotifyTrackUri(track), state.lightifyQueueContextUri || null);
  }

  if (state.queueSource === 'context' && state.lightifyQueueContextUri) {
    return playSpotifyContextSelection(state.lightifyQueueContextUri, queueTracks.slice(startIndex));
  }

  return playSpotifyTrackSelection(queueTracks.slice(startIndex));
}

function playSidebarRecentTrack(track) {
  const trackUri = spotifyTrackUri(track);
  if (!trackUri) return Promise.resolve();
  // Reuse playTrack so a recent-track play also drops any stale local queue
  // mirror (switchToSpotifyQueueSource) and clears the library highlight —
  // otherwise Next would resume the previously-played list.
  return playTrack(trackUri, null);
}

function buildRecentSidebarSelectionMenu(entries) {
  const tracks = entries
    .map(entry => normalizeQueueTrack(entry?.data?.track))
    .filter(Boolean);
  if (tracks.length === 0) return [];
  return [
    { label: 'Play first selected', action: () => playSidebarRecentTrack(tracks[0]).catch(err => setStatus(String(err))) },
    { label: `Add ${tracks.length} to queue`, action: () => addSpotifyTrackSelectionToQueue(tracks).catch(err => setStatus(String(err))) },
  ];
}

function formatRelativeTime(ts) {
  const t = Number(ts) || 0;
  if (t <= 0) return '';
  const diffMs = Date.now() - t;
  if (diffMs < 45000) return 'just now';
  const mins = Math.floor(diffMs / 60000);
  if (mins < 60) return mins + 'm ago';
  const hours = Math.floor(mins / 60);
  if (hours < 24) return hours + 'h ago';
  const days = Math.floor(hours / 24);
  if (days < 7) return days + 'd ago';
  const weeks = Math.floor(days / 7);
  if (weeks < 5) return weeks + 'w ago';
  const months = Math.floor(days / 30);
  if (months < 12) return months + 'mo ago';
  return Math.floor(days / 365) + 'y ago';
}

function renderSidebarTracks(tracks) {
  clearSelectionForContainer(dom.sidebarList);
  dom.sidebarList.innerHTML = '';
  if (!Array.isArray(tracks) || tracks.length === 0) {
    const message = state.sidebarMode === 'recent'
      ? 'No recent tracks yet'
      : 'No queued tracks';
    dom.sidebarList.innerHTML = `<div class="list-item"><div class="item-name" style="color:var(--text-dim)">${message}</div></div>`;
    updateSidebarClearButton();
    return;
  }
  const scope = trackSelectionScope(dom.sidebarList);
  const isRecent = state.sidebarMode === 'recent';
  const canReorder = state.sidebarMode === 'queue' && usesLocalQueueMirror();
  tracks.forEach((t, index) => {
    const div = makeListItem(t.name, t.artists, 'track');
    // Animated equalizer bars for the actively-playing row (revealed via CSS).
    const icon = div.querySelector('.item-icon');
    if (icon) {
      const eq = document.createElement('span');
      eq.className = 'eq-bars';
      eq.setAttribute('aria-hidden', 'true');
      eq.innerHTML = '<i></i><i></i><i></i>';
      icon.appendChild(eq);
    }
    // Relative timestamp on Recent rows.
    if (isRecent) {
      const rel = formatRelativeTime(recentHistoryTimestampMs(t));
      if (rel) {
        const time = document.createElement('div');
        time.className = 'item-time';
        time.textContent = rel;
        div.appendChild(time);
      }
    }
    // Queue rows are directly manipulable: drag (via the grip) to reorder,
    // × to remove. Both are isolated from the row's selection/press gesture.
    if (canReorder) {
      div.dataset.queueIndex = String(index);

      const handle = document.createElement('button');
      handle.type = 'button';
      handle.className = 'queue-grip';
      handle.title = 'Drag to reorder';
      handle.setAttribute('aria-label', 'Drag to reorder');
      handle.draggable = true;
      handle.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg>';
      handle.addEventListener('pointerdown', e => e.stopPropagation());
      handle.addEventListener('click', e => e.stopPropagation());
      handle.addEventListener('dragstart', e => {
        div.classList.add('drag-source');
        e.dataTransfer.effectAllowed = 'move';
        e.dataTransfer.setData('text/plain', String(index));
      });
      handle.addEventListener('dragend', () => {
        div.classList.remove('drag-source');
        dom.sidebarList.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
      });

      div.addEventListener('dragover', e => {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'move';
        div.classList.add('drag-over');
      });
      div.addEventListener('dragleave', () => div.classList.remove('drag-over'));
      div.addEventListener('drop', e => {
        e.preventDefault();
        div.classList.remove('drag-over');
        const from = Number(e.dataTransfer.getData('text/plain'));
        if (Number.isInteger(from) && from !== index) reorderLightifyQueue(from, index);
      });

      const remove = document.createElement('button');
      remove.type = 'button';
      remove.className = 'queue-remove';
      remove.title = 'Remove from queue';
      remove.setAttribute('aria-label', 'Remove from queue');
      remove.textContent = '×';
      remove.addEventListener('pointerdown', e => e.stopPropagation());
      remove.addEventListener('click', e => {
        e.stopPropagation();
        removeLightifyQueueItem(index);
      });

      div.insertBefore(handle, div.firstChild);
      div.appendChild(remove);
    }
    const playOnce = () => (sidebarModeIsActive('queue')
      ? playSidebarQueueTrackSelection(t)
      : playSidebarRecentTrack(t));
    registerSelectableRow({
      scope,
      key: `${index}:${t.id || t.uri || t.name}`,
      container: dom.sidebarList,
      row: div,
      data: { track: t },
      onActivate: playOnce,
      getMenuItems: () => buildSpotifyTrackMenu(t, playOnce),
      getSelectionMenuItems: entries => (sidebarModeIsActive('queue')
        ? buildSpotifySelectionMenu(entries)
        : buildRecentSidebarSelectionMenu(entries)),
    });
    dom.sidebarList.appendChild(div);
  });
  updateSidebarClearButton();
}

function renderSidebarDownloads(items) {
  clearSelectionForContainer(dom.sidebarList);
  dom.sidebarList.innerHTML = '';
  updateSidebarClearButton(items);
  if (!items || items.length === 0) {
    dom.sidebarList.innerHTML = '<div class="list-item"><div class="item-name" style="color:var(--text-dim)">No downloads queued</div></div>';
    return;
  }
  items.forEach(item => {
    const summary = [
      item.item_status || 'Waiting',
      item.item_by || item.item_service || '',
      item.progress ? `${item.progress}%` : '',
    ].filter(Boolean).join(' • ');
    const div = makeListItem(item.item_name || item.item_id || 'Download item', summary, 'track');
    if (item.error) div.title = item.error;
    div.addEventListener('contextmenu', e => {
      e.preventDefault();
      e.stopPropagation();
      showContextMenu(e, buildDownloadItemMenu(item));
    });
    if (item.file_path && ['Downloaded', 'Already Exists'].includes(item.item_status)) {
      div.addEventListener('dblclick', () => {
        invoke('cmd_open_download_folder', { path: item.file_path }).catch(err => setStatus(String(err)));
      });
    }
    dom.sidebarList.appendChild(div);
  });
}

// ── Create playlist from queue ───────────────────────────────

dom.btnSidebarCreatePl.addEventListener('click', async () => {
  try {
    const tracks = usesLocalQueueMirror()
      ? state.lightifyQueue.slice()
      : await invoke('cmd_queue');
    if (!tracks || tracks.length === 0) {
      setStatus('Queue is empty');
      return;
    }
    const uris = uniqueQueueTracks(tracks).map(t => t.uri);
    const now = new Date();
    const name = 'Queue ' +
      now.getFullYear() + '-' +
      String(now.getMonth() + 1).padStart(2, '0') + '-' +
      String(now.getDate()).padStart(2, '0') + ' ' +
      String(now.getHours()).padStart(2, '0') + ':' +
      String(now.getMinutes()).padStart(2, '0');

    const pl = await invoke('cmd_create_playlist', { name, trackUris: uris });
    setStatus('Created: ' + pl.name);
    loadPlaylists();
  } catch (e) {
    setStatus('Failed: ' + e);
  }
});

// ── Audio engine events ──────────────────────────────────────

listen('audio:ready', e => {
  setStatus('Audio engine ready — device: ' + (e.payload.device_id || '?'));
  logDiag('info', 'audio', 'engine ready device=' + (e.payload.device_id || '?'));
  // The engine may have just finished the one-time streaming sign-in, after which
  // the initial ensureDevice() wait has already timed out. Adopt the freshly
  // registered Lightify device now so the user doesn't have to press Play twice.
  ensureDevice({ reportFailure: false, reportConnected: false }).catch(() => {});
});

listen('audio:state', e => {
  if (typeof e.payload.playing === 'boolean') {
    state.isPlaying = e.payload.playing;
    if (e.payload.playing) { hide(dom.iconPlay); show(dom.iconPause); }
    else { show(dom.iconPlay); hide(dom.iconPause); }
    refreshTrackPlaybackPresence();
    refreshLibraryPlaybackPresence();
    updateMediaSession();
  }
});

listen('audio:track', e => {
  const p = e.payload;
  if (p.name) {
    const previousSnapshot = state.playbackSnapshot
      ? { ...state.playbackSnapshot }
      : null;
    const previousTrack = state.currentTrack;
    const incomingTrack = {
      // The engine sends track_id as a full "spotify:track:X" URI; normalise to a
      // bare id + uri so it matches the Web API's form. Otherwise the same track
      // shows twice in Recent (once from this event, once from polling) and the
      // now-playing highlight can miss.
      id: spotifyTrackId({ id: p.id }),
      name: p.name,
      artists: p.artists,
      duration_ms: p.duration_ms,
      uri: spotifyTrackUri({ id: p.id }),
      album_art: '',
    };
    const changed = !samePlaybackTrack(previousTrack, incomingTrack);
    const nextTrack = mergePlaybackTrack(previousTrack, incomingTrack);

    if (changed && shouldRecoverUnexpectedLocalQueueAdvance(previousTrack, nextTrack, previousSnapshot)) {
      if (recoverUnexpectedLocalQueueAdvance()) return;
    }

    dom.trackName.textContent = nextTrack.name;
    dom.trackArtist.textContent = nextTrack.artists || '';
    state.currentTrack = nextTrack;
    refreshTrackPlaybackPresence();
    refreshLibraryPlaybackPresence();

    if (changed) {
      recordNavPlay(nextTrack);
      consumeLightifyQueueTrack(nextTrack);
      // Native track changes also feed Recent; the poll handler can miss tracks
      // that advance between polls. addToSessionHistory dedupes.
      addToSessionHistory(nextTrack);
      if (resolvedSpotifyTrackId(nextTrack) && nextTrack.id) {
        checkLiked(nextTrack.id);
        pumpSpotifySelectionAutoplay().catch(err => console.warn('spotify selection autoplay pump failed', err));
        pumpBeatportAutoplay().catch(err => console.warn('beatport autoplay pump failed', err));
        pumpStationAutoplay().catch(err => console.warn('station autoplay pump failed', err));
      }
    }

    if (!resolvedSpotifyTrackId(nextTrack)) {
      // Poll immediately to fill in Spotify URI/album art for the active track.
      setTimeout(pollNow, 300);
    }
    updateMediaSession();
  }
});

listen('audio:position', e => {
  if (typeof e.payload.ms === 'number') {
    setPlaybackSnapshot(
      e.payload.ms,
      state.currentTrack?.duration_ms || state.playbackSnapshot?.durationMs || 0,
      state.isPlaying,
    );
  }
});

listen('audio:trackend', () => {
  // A track finished. Poll promptly so the UI reflects the next track (or a
  // stopped queue at the end of a context) instead of waiting up to 3s. The
  // queue-advance authority stays with the poll/recovery path; this only
  // speeds up reconciliation.
  scheduleFastPlaybackPoll(250);
});

listen('audio:volume', e => {
  syncVolumeSliderFromBackend(e.payload.percent);
});

listen('audio:needauth', e => {
  // One-time streaming sign-in. The engine has opened the browser to Spotify;
  // playback stays unavailable until the user completes it. Keep the prompt
  // visible (audio:ready clears it) so it isn't mistaken for a transient status.
  const msg = e.payload?.msg || 'Complete the Spotify sign-in in your browser to enable playback.';
  setStatus('🔑 ' + msg);
  logDiag('warn', 'audio', 'streaming sign-in required: ' + msg);
});

listen('audio:error', e => {
  setStatus('Audio error: ' + (e.payload.msg || 'unknown'));
  logDiag('error', 'audio', e.payload?.msg || 'unknown');
});

listen('global-media-shortcut', e => {
  switch (e.payload?.code) {
    case 'MediaPlayPause':
      togglePlayback();
      break;
    case 'MediaTrackNext':
      runTransportCommand('cmd_next', { pollDelay: 500 });
      break;
    case 'MediaTrackPrevious':
      runTransportCommand('cmd_prev', { pollDelay: 500 });
      break;
  }
});

listen('beatport-tracks-loaded', e => {
  const tracks = e.payload;
  if (!Array.isArray(tracks) || tracks.length === 0) return;
  setBeatportVerifiedSessionStored(true);
  setBeatportFetchLoading(false);
  state.beatportTracks = tracks;
  state.beatportSelected.clear();
  state.beatportMatchCache.clear();
  dom.btnBpCreate.disabled = true;
  renderBeatportList();
  dom.bpStatus.textContent = `${tracks.length} ${selectedBeatportEntryLabel()} loaded from ${selectedBeatportChartLabel()}`;
  hide(dom.btnBpVerify);
  dom.btnBpVerify.disabled = false;
});

listen('beatport-verification-required', () => {
  showBeatportVerifyPrompt();
});

// ── Context menu ─────────────────────────────────────────────

let activeMenu = null;

function showContextMenu(e, items) {
  console.log('[ctx] open', { items: items?.map(i => i.label), x: e?.clientX, y: e?.clientY });
  // Stop the event so any ancestor handler (or WebView default) cannot
  // immediately close the menu we are about to open.
  if (e && typeof e.preventDefault === 'function') e.preventDefault();
  if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
  closeContextMenu();

  if (!items || items.length === 0) {
    console.warn('showContextMenu called with no items');
    return;
  }

  const menu = document.createElement('div');
  menu.className = 'ctx-menu';
  // Inline z-index/position as a defensive fallback in case a stylesheet
  // override hides the menu below another layer.
  menu.style.position = 'fixed';
  menu.style.zIndex = '9999';

  items.forEach(item => {
    const row = document.createElement('div');
    row.className = 'ctx-menu-item';
    if (item.danger) row.classList.add('danger');
    row.textContent = item.label;
    row.addEventListener('click', () => {
      closeContextMenu();
      item.action();
    });
    menu.appendChild(row);
  });

  // Position
  menu.style.left = e.clientX + 'px';
  menu.style.top = e.clientY + 'px';
  document.body.appendChild(menu);
  activeMenu = menu;

  // Adjust if off-screen
  const rect = menu.getBoundingClientRect();
  if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px';
  if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px';
}

function closeContextMenu() {
  if (activeMenu) { activeMenu.remove(); activeMenu = null; }
}

dom.confirmOverlay?.addEventListener('click', e => {
  if (e.target === dom.confirmOverlay) closeConfirmDialog();
});

dom.btnConfirmCancel?.addEventListener('click', closeConfirmDialog);
dom.btnConfirmAccept?.addEventListener('click', () => {
  acceptConfirmDialog().catch(err => setStatus('Failed to delete playlist: ' + String(err)));
});

function selectedRowFromEventTarget(target) {
  return target && typeof target.closest === 'function'
    ? target.closest('.list-item[data-selection-registry-key]')
    : null;
}

document.addEventListener('click', e => {
  closeContextMenu();
});

document.addEventListener('dblclick', e => {
  if (!selectionState.selectedKeys.size) return;
  clearActiveSelection();
});

// ── Keyboard shortcuts ───────────────────────────────────────

document.addEventListener('keydown', e => {
  // Don't steal input focus
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
  switch (e.key) {
    case 'Space':
      e.preventDefault();
      togglePlayback();
      break;
    case 'MediaPlayPause':
      e.preventDefault();
      togglePlayback();
      break;
    case 'MediaTrackNext':
      e.preventDefault();
      runTransportCommand('cmd_next', { pollDelay: 500 });
      break;
    case 'MediaTrackPrevious':
      e.preventDefault();
      runTransportCommand('cmd_prev', { pollDelay: 500 });
      break;
    case 'MediaStop':
      e.preventDefault();
      runTransportCommand('cmd_pause');
      break;
    case 'ArrowRight':
      if (e.ctrlKey) runTransportCommand('cmd_next', { pollDelay: 500 });
      break;
    case 'ArrowLeft':
      if (e.ctrlKey) runTransportCommand('cmd_prev', { pollDelay: 500 });
      break;
  }
});

function setLeftPanelCollapsed(collapsed) {
  state.leftPanelCollapsed = Boolean(collapsed);
  dom.leftPanel.classList.toggle('collapsed', state.leftPanelCollapsed);
  dom.btnLeftPanelToggle.classList.toggle('collapsed', state.leftPanelCollapsed);
  dom.btnLeftPanelToggle.title = state.leftPanelCollapsed ? 'Expand left panel' : 'Collapse left panel';
  dom.btnLeftPanelToggle.setAttribute('aria-label', dom.btnLeftPanelToggle.title);
  dom.btnLeftPanelToggle.setAttribute('aria-expanded', String(!state.leftPanelCollapsed));
  dom.btnLeftPanelToggle.setAttribute('aria-pressed', String(state.leftPanelCollapsed));
  try {
    localStorage.setItem(LEFT_PANEL_COLLAPSED_STORAGE_KEY, state.leftPanelCollapsed ? '1' : '0');
  } catch (_) {}
}

// ── Init ─────────────────────────────────────────────────────

function hideSplash() {
  const splash = document.getElementById('splash-screen');
  if (splash && !splash.classList.contains('splash-out')) {
    splash.classList.add('splash-out');
    setTimeout(() => splash.classList.add('hidden'), 420); // matches CSS opacity transition
  }
  // Safety net: never leave a blank screen if auth stalled past the cap — show onboarding.
  if (dom.authPage.classList.contains('hidden') && dom.playerPage.classList.contains('hidden')) {
    show(dom.authPage);
  }
}

window.addEventListener('DOMContentLoaded', () => {
  initMediaSession();
  renderSyntheticCover(null);
  // Show the splash for a short buffer (covers the auth flash for cached sessions)
  // without inflating real load time: cached auth resolves in ~70ms, well under the
  // buffer. Cap the auth wait so a stalled network can never hang the splash.
  const minSplash = new Promise(r => setTimeout(r, 1300));
  const safetyCap = new Promise(r => setTimeout(r, 6000));
  const booted = tryRestore().catch(() => {});
  Promise.all([minSplash, Promise.race([booted, safetyCap])]).then(hideSplash);
});
