import { scrobbleSong } from '../api/subsonicScrobble';
import type { Track } from './playerStoreTypes';
import {
  playbackReportPlaying,
  playbackReportStart,
  playbackReportStopped,
} from './playbackReportSession';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { invoke } from '@tauri-apps/api/core';
import { getMusicNetworkRuntimeOrNull } from '../music-network';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import { notifyLibraryPlaybackHint } from './libraryPlaybackHint';
import {
  playListenSessionFinalize,
  playListenSessionOnProgress,
  playListenSessionOnTrackSwitched,
  playListenSessionOpen,
} from './playListenSession';
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
import {
  getPlaybackCacheServerKey,
  getPlaybackIndexKey,
  playbackCacheKeyForRef,
  playbackProfileIdForRef,
  playbackProfileIdForTrack,
} from '../utils/playback/playbackServer';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
import { showToast } from '../utils/ui/toast';
import { useAuthStore } from './authStore';
import { getPlayGeneration, setIsAudioPaused } from './engineState';
import {
  clearPreloadingIds,
  getBytePreloadingId,
  getGaplessPreloadingId,
  getLastGaplessSwitchTime,
  markGaplessSwitch,
  setBytePreloadingId,
  setGaplessPreloadingId,
} from './gaplessPreloadState';
import { touchHotCacheOnPlayback } from './hotCacheTouch';
import {
  isReplayGainActive,
  loudnessGainDbForEngineBind,
} from './loudnessGainCache';
import { refreshLoudnessForTrack } from './loudnessRefresh';
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
import { emitNormalizationDebug } from './normalizationDebug';
import {
  emitPlaybackProgress,
  getPlaybackProgressSnapshot,
} from './playbackProgress';
import {
  LIVE_PROGRESS_EMIT_MIN_DELTA_SEC,
  LIVE_PROGRESS_EMIT_MIN_MS,
  STORE_PROGRESS_COMMIT_MIN_DELTA_SEC,
  STORE_PROGRESS_COMMIT_MIN_MS,
  getLastLiveProgressEmitAt,
  getLastStoreProgressCommitAt,
  markLiveProgressEmit,
  markStoreProgressCommit,
  resetProgressEmitThrottles,
} from './playbackThrottles';
import {
  playbackSourceHintForResolvedUrl,
} from './playbackUrlRouting';
import { usePlayerStore } from './playerStore';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import {
  flushQueueSyncToServer,
  getLastQueueHeartbeatAt,
  syncQueueToServer,
} from './queueSync';
import { isSeekDebouncePending } from './seekDebounce';
import {
  SEEK_FALLBACK_VISUAL_GUARD_MS,
  getSeekFallbackVisualTarget,
  setSeekFallbackVisualTarget,
} from './seekFallbackState';
import {
  SEEK_TARGET_GUARD_TIMEOUT_MS,
  clearSeekTarget,
  getSeekTarget,
  getSeekTargetSetAt,
} from './seekTargetState';
import { refreshWaveformForTrack } from './waveformRefresh';
import { analyzeBoundary, computeWaveformSilence } from '../utils/waveform/waveformSilence';
import {
  autodjJsTriggerAtSec,
  clampCrossfadeSecs,
  computeAutodjJsOverlap,
  shouldJsDriveAutodjTransition,
} from '../utils/playback/autodjAutoAdvance';
import { isInterruptHandoffPending } from '../utils/playback/autodjInterruptPrep';
import { isCrossfadeNextReady, maybeCrossfadeBytePreload } from './crossfadePreload';
import { armCrossfadeDynamicOverlap, getCrossfadeTransition } from './crossfadeTrimCache';
import { armAutodjMixing } from './autodjTransitionUi';

// Silence-aware crossfade (A-tail): guards the early advance to once per play
// generation so a single playback instance triggers at most one trim-advance
// (re-arms automatically on the next play / repeat-all loop, never loops on a
// backward seek within the same playback).
let crossfadeTrimAdvanceGen = -1;
let autodjEngineMixArmGen = -1;

// AutoDJ: mirror of the engine's `autodj_suppress_autocrossfade` flag so we only
// invoke the setter on change. When a content fade is pending for the upcoming
// transition we suppress the engine's autonomous crossfade timer and let the JS
// A-tail advance drive it (gated on the next track being playable) — otherwise
// the engine would start a still-buffering next track and fade over it (a jump).
let autodjSuppressSent: boolean | null = null;
function syncAutodjSuppress(want: boolean): void {
  if (autodjSuppressSent === want) return;
  autodjSuppressSent = want;
  invoke('audio_set_autodj_suppress', { enabled: want }).catch(() => {});
}

/** Rust-side `audio:normalization-state` event payload. */
export type NormalizationStatePayload = {
  engine: 'off' | 'replaygain' | 'loudness' | string;
  currentGainDb: number | null;
  targetLufs: number;
};

export function handleAudioPlaying(duration: number): void {
  setDeferHotCachePrefetch(false);
  resetProgressEmitThrottles();
  usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
  notifyLibraryPlaybackHint('playing');
  const { currentTrack: track, queueItems, queueIndex } = usePlayerStore.getState();
  if (track) {
    const ref = queueItems[queueIndex];
    void playListenSessionOpen(track, playbackProfileIdForTrack(track, ref), duration);
    // Engine-confirmed play (initial start + resume) — keep live now-playing in
    // the `playing` state for servers with the playbackReport extension.
    playbackReportPlaying();
  }
}

export function handleAudioProgress(
  current_time: number,
  duration: number,
  buffering = false,
): void {
  bumpPerfCounter('audioProgressEvents');
  const perfFlags = getPerfProbeFlags();
  const progressUiDisabled = perfFlags.disablePlayerProgressUi;
  // While a seek is pending, the store already holds the optimistic target
  // position.  Accepting stale progress from the Rust engine would briefly
  // snap the waveform back to the old position before the seek completes.
  if (isSeekDebouncePending()) return;
  // After the debounce fires, Rust may still emit 1–2 ticks with the old
  // position before the seek takes effect.  Block until current_time is
  // within 2 s of the requested target, then clear the guard.
  const activeSeekTarget = getSeekTarget();
  if (activeSeekTarget !== null) {
    if (Math.abs(current_time - activeSeekTarget) > 2.0) {
      // If a seek command hangs while streaming is stalled, do not freeze UI.
      if (Date.now() - getSeekTargetSetAt() <= SEEK_TARGET_GUARD_TIMEOUT_MS) return;
      clearSeekTarget();
    } else {
      clearSeekTarget();
    }
  }

  const store = usePlayerStore.getState();
  const track = store.currentTrack;
  if (!track) return;
  if (!store.currentRadio && store.isPlaybackBuffering !== buffering) {
    usePlayerStore.setState({ isPlaybackBuffering: buffering });
  }
  // Some backends can emit stale progress ticks shortly after pause/stop.
  // Ignoring them avoids reactivating UI redraw loops while transport is idle.
  const transportActive = store.isPlaying || store.currentRadio != null;
  let visualTarget = getSeekFallbackVisualTarget();
  if (!transportActive && !visualTarget) return;
  if (visualTarget && visualTarget.trackId !== track.id) {
    setSeekFallbackVisualTarget(null);
    visualTarget = null;
  }
  let displayTime = buffering ? 0 : current_time;
  if (visualTarget && visualTarget.trackId === track.id) {
    const nearTarget = Math.abs(current_time - visualTarget.seconds) <= 2.0;
    if (nearTarget) {
      setSeekFallbackVisualTarget(null);
      visualTarget = null;
    } else if (Date.now() - visualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) {
      // Keep UI at the requested position while backend catches up.
      displayTime = visualTarget.seconds;
    } else {
      setSeekFallbackVisualTarget(null);
      visualTarget = null;
    }
  }
  const dur = duration > 0 ? duration : track.duration;
  if (dur <= 0) return;
  const progress = displayTime / dur;
  playListenSessionOnProgress(current_time, buffering, dur).catch(() => {});
  if (!progressUiDisabled) {
    const nowLive = Date.now();
    const live = getPlaybackProgressSnapshot();
    const liveTimeDelta = Math.abs(live.currentTime - displayTime);
    if (
      nowLive - getLastLiveProgressEmitAt() >= LIVE_PROGRESS_EMIT_MIN_MS ||
      liveTimeDelta >= LIVE_PROGRESS_EMIT_MIN_DELTA_SEC ||
      visualTarget != null
    ) {
      emitPlaybackProgress({
        currentTime: displayTime,
        progress: buffering ? 0 : progress,
        buffered: 0,
        buffering,
      });
      markLiveProgressEmit(nowLive);
    }
  }
  // Heartbeat: push current position to the server every 15 s while playing so
  // cross-device resume works even on a hard close — pause() and the close
  // handler flush on top of this for clean shutdowns.
  if (store.isPlaying && !store.currentRadio) {
    const now = Date.now();
    if (now - getLastQueueHeartbeatAt() >= 15_000) {
      void flushQueueSyncToServer(store.queueItems, track, displayTime);
      // Same 15 s cadence keeps the server's now-playing position fresh so it
      // can extrapolate accurately between reports (playbackReport extension).
      playbackReportPlaying(displayTime);
    }
  }

  // Scrobble at 50%: Music Network + Navidrome (updates play_date / recently played)
  if (progress >= 0.5 && !store.scrobbled) {
    usePlayerStore.setState({ scrobbled: true });
    scrobbleSong(
      track.id,
      Date.now(),
      playbackProfileIdForTrack(track, store.queueItems[store.queueIndex]),
    );
    void getMusicNetworkRuntimeOrNull()?.dispatchScrobble({
      title: track.title,
      artist: track.artist,
      album: track.album,
      duration: track.duration,
      timestamp: Date.now(),
    });
  }
  if (progressUiDisabled) return;
  // Critical architectural guard: avoid high-frequency writes to the persisted
  // Zustand store (each write serializes queue state). Keep only coarse commits.
  const nowCommit = Date.now();
  const commitDelta = Math.abs(store.currentTime - displayTime);
  const shouldCommitStore =
    visualTarget != null ||
    nowCommit - getLastStoreProgressCommitAt() >= STORE_PROGRESS_COMMIT_MIN_MS ||
    commitDelta >= STORE_PROGRESS_COMMIT_MIN_DELTA_SEC;
  if (shouldCommitStore) {
    usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
    markStoreProgressCommit(nowCommit);
  }

  // Pre-buffer / pre-chain next track for gapless and crossfade.
  const {
    gaplessEnabled,
    crossfadeEnabled,
    crossfadeSecs,
    crossfadeTrimSilence,
  } = useAuthStore.getState();
  const remaining = dur - current_time;

  // Silence-aware crossfade — current track's trailing silence, derived once
  // from its cached waveform. Drives both the early A-tail advance AND a wider
  // pre-buffer window (the early advance must not outrun the next track's
  // download, or its stream starts late and the fade has nothing to overlap).
  const trimActive =
    crossfadeEnabled && crossfadeTrimSilence && !gaplessEnabled && !store.currentRadio;
  const curTrailSilenceSec = trimActive
    ? computeWaveformSilence(store.waveformBins, dur).trailSilenceSec
    : 0;

  // A-tail: start the next track early so the fade overlaps *audible* tail/head.
  // Overlap is content-driven ("by fact"); loud→loud always uses the standard
  // ~2 s JS blend (not the engine crossfadeSecs slider). When JS drives we
  // suppress the engine's autonomous crossfade timer so B is readiness-gated.
  let autodjSuppressWant = false;
  if (trimActive && store.isPlaying && store.repeatMode !== 'one') {
    const nextIdx = store.queueIndex + 1;
    const nextRef = nextIdx < store.queueItems.length
      ? store.queueItems[nextIdx]
      : (store.repeatMode === 'all' && store.queueItems.length > 0 ? store.queueItems[0] : null);
    const nextTrackId = nextRef ? resolveQueueTrack(nextRef)?.id : undefined;
    if (nextTrackId) {
      const cf = clampCrossfadeSecs(crossfadeSecs);
      const plan = getCrossfadeTransition(nextTrackId);
      let contentOverlap: number;
      // Scenario A: does A carry its own recorded fade-out? If so we let it ride
      // at full engine gain (no double fade) and bring B up underneath.
      let aRidesOwnFade: boolean;
      if (plan && plan.overlapSec > 0) {
        contentOverlap = plan.overlapSec;
        aRidesOwnFade = plan.outgoingFadeSec <= 0.001;
      } else {
        // No next-track envelope (cold plan) → judge A from its own waveform.
        const aShape = analyzeBoundary(store.waveformBins, dur);
        contentOverlap = aShape.outroFadeSec;
        aRidesOwnFade = aShape.outroFadeSec >= 1.0;
      }
      if (shouldJsDriveAutodjTransition(curTrailSilenceSec, contentOverlap, cf, aRidesOwnFade)) {
        autodjSuppressWant = true;
        const { overlapSec, outgoingFadeSec } = computeAutodjJsOverlap(contentOverlap, aRidesOwnFade);
        const triggerAt = autodjJsTriggerAtSec(dur, curTrailSilenceSec, overlapSec);
        const gen = getPlayGeneration();
        // Readiness gate: only advance when B's audio is actually available (RAM
        // preload slot or local on disk). A cold stream can't sustain a stable
        // fade, so we leave the gen guard unset and re-check on later ticks — if
        // B readies before A ends we fade then; if never, A plays out (engine
        // timer suppressed) and the source-exhaustion end gives a clean cut.
        if (
          current_time >= triggerAt
          && crossfadeTrimAdvanceGen !== gen
          && isCrossfadeNextReady(
            nextTrackId,
            playbackProfileIdForRef(nextRef),
            playbackCacheKeyForRef(nextRef),
          )
        ) {
          crossfadeTrimAdvanceGen = gen;
          armCrossfadeDynamicOverlap(nextTrackId, overlapSec, outgoingFadeSec);
          armAutodjMixing(overlapSec);
          store.next(false);
          return;
        }
      }
    }
  }
  syncAutodjSuppress(autodjSuppressWant);

  if (trimActive && store.isPlaying && !autodjSuppressWant) {
    const cf = clampCrossfadeSecs(crossfadeSecs);
    if (remaining > 0 && remaining <= cf) {
      const gen = getPlayGeneration();
      if (autodjEngineMixArmGen !== gen) {
        autodjEngineMixArmGen = gen;
        armAutodjMixing(cf);
      }
    }
  }

  // Crossfade pre-buffer (next-track byte download + leading-silence probe).
  // Self-gating; also invoked right after a seek into the window (see seekAction).
  maybeCrossfadeBytePreload(current_time, dur);

  const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;

  if (gaplessEnabled) {
    const { queueItems, queueIndex, repeatMode } = store;
    const nextIdx = queueIndex + 1;
    // Next track for preload/chain. The resolver bridge keeps the window around
    // queueIndex warm, so the next ref is cache-hot; resolveQueueTrack falls
    // back to a placeholder (correct trackId, so URL building still works) on a
    // cold miss. current track = `track` (full) — never resolved.
    const nextRef = repeatMode === 'one'
      ? null
      : (nextIdx < queueItems.length ? queueItems[nextIdx] : (repeatMode === 'all' ? queueItems[0] : null));
    const nextTrack = repeatMode === 'one'
      ? track
      : (nextRef ? resolveQueueTrack(nextRef) : null);
    if (!nextTrack || nextTrack.id === track.id) return;

    // Gapless backup: keep next-track bytes ready even if chain/decode misses
    // the boundary. Start earlier for larger files / slower conservative link.
    const estBytes = (() => {
      if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
        return nextTrack.size;
      }
      const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
        ? nextTrack.bitRate
        : 320;
      return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
    })();
    const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
    const estDownloadSecs = estBytes / conservativeBytesPerSec;
    const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
    const shouldBytePreloadForGaplessBackup =
      gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;

    const serverId = nextRef ? playbackCacheKeyForRef(nextRef) : getPlaybackCacheServerKey();
    const analysisServerId = nextRef
      ? playbackCacheKeyForRef(nextRef)
      : getPlaybackIndexKey();
    const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);

    // Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
    if (
      shouldBytePreloadForGaplessBackup
      && nextTrack.id !== getBytePreloadingId()
    ) {
      setBytePreloadingId(nextTrack.id);
      // Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
      // waveformBins and would replace the current track's seekbar while still playing it.
      void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
      if (import.meta.env.DEV) {
        console.info('[psysonic][preload-request]', {
          nextTrackId: nextTrack.id,
          nextUrl,
          shouldBytePreloadForGaplessBackup,
          remaining,
          gaplessEnabled,
        });
      }
      invoke('audio_preload', {
        url: nextUrl,
        durationHint: nextTrack.duration,
        analysisTrackId: nextTrack.id,
        serverId: analysisServerId || null,
      }).catch(() => {});
    }

    // Gapless chain — decode + chain into Sink 30s before track boundary.
    if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) {
      setGaplessPreloadingId(nextTrack.id);
      // Ensure loudness gain is already cached for the chained request payload.
      void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
      const authState = useAuthStore.getState();
      // Auto-mode neighbours for the *next* track: current track on its left,
      // queueItems[nextIdx+1] on its right (resolved; placeholder on a cold miss
      // — only its replaygain tags matter, which a placeholder lacks → fallback).
      const nextNeighbourRef = nextIdx + 1 < queueItems.length
        ? queueItems[nextIdx + 1]
        : (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
      const nextNeighbour = nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null;
      const replayGainDb = resolveReplayGainDb(
        nextTrack, track, nextNeighbour,
        isReplayGainActive(), authState.replayGainMode,
      );
      const replayGainPeak = isReplayGainActive()
        ? (nextTrack.replayGainPeak ?? null)
        : null;
      invoke('audio_chain_preload', {
        url: nextUrl,
        volume: store.volume,
        durationHint: nextTrack.duration,
        replayGainDb,
        replayGainPeak,
        loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
        preGainDb: authState.replayGainPreGainDb,
        fallbackDb: authState.replayGainFallbackDb,
        hiResEnabled: authState.enableHiRes,
        analysisTrackId: nextTrack.id,
        serverId: analysisServerId || null,
      }).catch(() => {});
    }
  }
}

export function handleAudioEnded(): void {
  notifyLibraryPlaybackHint('idle');

  if (Date.now() - getLastGaplessSwitchTime() < 600) {
    return;
  }

  if (isInterruptHandoffPending()) {
    return;
  }

  void playListenSessionFinalize('ended');
  // Track finished — clear live now-playing. A follow-on track (next / repeat)
  // opens a fresh session via playbackReportStart.
  void playbackReportStopped();

  // Radio stream disconnected — just stop; don't advance queue.
  if (usePlayerStore.getState().currentRadio) {
    setIsAudioPaused(false);
    usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
    return;
  }

  const { repeatMode, currentTrack, queueIndex } = usePlayerStore.getState();
  setIsAudioPaused(false);
  usePlayerStore.setState({
    isPlaying: false,
    isPlaybackBuffering: false,
    progress: 0,
    currentTime: 0,
    buffered: 0,
  });
  setTimeout(() => {
    void (async () => {
      if (repeatMode === 'one' && currentTrack) {
        const authState = useAuthStore.getState();
        const repeatPromoteSid = getPlaybackCacheServerKey();
        if (authState.hotCacheEnabled && repeatPromoteSid) {
          // Same-track repeat never hit `playTrack`'s prev→promote path; flush
          // Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local.
          await promoteCompletedStreamToHotCache(
            currentTrack,
            repeatPromoteSid,
            authState.hotCacheDownloadDir || null,
          );
        }
        // Pin to the current slot — the track may appear elsewhere in the queue.
        // No-arg queue: playTrack keeps the canonical refs and just re-binds.
        usePlayerStore.getState().playTrack(currentTrack, undefined, false, false, queueIndex);
      } else {
        usePlayerStore.getState().next(false);
      }
    })();
  }, 150);
}

/**
 * Handle gapless auto-advance: the Rust engine has already switched to the
 * next source sample-accurately. We just need to update the UI state without
 * touching the audio stream (no playTrack() call!).
 */
export function handleAudioTrackSwitched(_duration: number): void {
  markGaplessSwitch();
  clearPreloadingIds(); // allow preloading for the track after this one
  setIsAudioPaused(false);

  const store = usePlayerStore.getState();
  if (store.currentTrack?.id) {
    useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
  }
  const { queueItems, queueIndex, repeatMode } = store;
  const nextIdx = queueIndex + 1;
  let nextTrack: Track | null = null;
  let newIndex = queueIndex;

  if (repeatMode === 'one' && store.currentTrack) {
    nextTrack = store.currentTrack;
    // queueIndex stays the same
  } else if (nextIdx < queueItems.length) {
    // The Rust engine already chained this source sample-accurately, so it must
    // have been preloaded — meaning the resolver had it cached. resolveQueueTrack
    // returns the full Track from cache (placeholder only on an unexpected miss).
    nextTrack = resolveQueueTrack(queueItems[nextIdx]);
    newIndex = nextIdx;
  } else if (repeatMode === 'all' && queueItems.length > 0) {
    nextTrack = resolveQueueTrack(queueItems[0]);
    newIndex = 0;
  }

  if (!nextTrack) return;

  void playListenSessionOnTrackSwitched(nextTrack);

  const switchRef = queueItems[newIndex];
  const switchServerId = playbackCacheKeyForRef(switchRef);
  const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
  const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);

  // Neighbour window for normalization (replaygain album-mode reads prev/next).
  // current track on the left, the track after `nextTrack` on the right.
  const switchPrev = store.currentTrack;
  const switchNextNextRef = newIndex + 1 < queueItems.length ? queueItems[newIndex + 1] : null;
  const switchNeighbourWindow: Track[] = [
    switchPrev ?? nextTrack,
    nextTrack,
    ...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []),
  ];

  usePlayerStore.setState({
    currentTrack: nextTrack,
    waveformBins: null,
    ...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
    normalizationDbgSource: 'track-switched',
    normalizationDbgTrackId: nextTrack.id,
    queueIndex: newIndex,
    isPlaying: true,
    isPlaybackBuffering: switchPlaybackSource === 'stream',
    progress: 0,
    currentTime: 0,
    buffered: 0,
    scrobbled: false,
    networkLoved: false,
    currentPlaybackSource: switchPlaybackSource,
  });
  emitNormalizationDebug('track-switched', {
    trackId: nextTrack.id,
    queueIndex: newIndex,
    engineRequested: useAuthStore.getState().normalizationEngine,
  });
  void refreshWaveformForTrack(nextTrack.id);
  void refreshLoudnessForTrack(nextTrack.id);
  usePlayerStore.getState().updateReplayGainForCurrentTrack();

  // Subsonic-server now-playing follows nowPlayingEnabled; Music Network
  // now-playing follows scrobbling, as Last.fm now-playing did (the runtime gates
  // on the master toggle, per-account enable and the nowPlaying capability
  // internally). playbackReportStart opens the FSM on extension-capable servers
  // and falls back to the legacy presence call otherwise (gating is internal).
  playbackReportStart(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
  const runtime = getMusicNetworkRuntimeOrNull();
  void runtime?.dispatchNowPlaying({
    title: nextTrack.title,
    artist: nextTrack.artist,
    album: nextTrack.album,
    duration: nextTrack.duration,
    timestamp: Date.now(),
  });
  if (runtime?.getEnrichmentPrimaryId()) {
    void runtime
      .isTrackLoved({ title: nextTrack.title, artist: nextTrack.artist })
      .then(loved => {
        usePlayerStore.getState().setNetworkLoved(loved);
      });
  }
  syncQueueToServer(queueItems, nextTrack, 0);
  touchHotCacheOnPlayback(nextTrack.id, switchServerId);
}

export function handleAudioError(message: string): void {
  console.error('[psysonic] Audio error from backend:', message);
  setIsAudioPaused(false);
  void playbackReportStopped();

  const detail = message.length > 80 ? message.slice(0, 80) + '…' : message;
  showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');

  const gen = getPlayGeneration();
  usePlayerStore.setState({ isPlaying: false, isPlaybackBuffering: false });
  setTimeout(() => {
    if (getPlayGeneration() !== gen) return;
    usePlayerStore.getState().next(false);
  }, 1500);
}
