import {
  getQueue,
  setQueue,
  addToQueueFront,
  addToQueueNext,
  removeFromQueueById,
  addHistoryEntry,
  getPlayHistory,
} from '../shared/playlist-store.js';

const CTRL_TYPE = 'xyz.playback.control';
const STATE_TYPE = 'xyz.playback.state';
const LOG_PREFIX = '[核桃FM][playback]';
const BADGE_PLAYING_TEXT = '🔊';
const BADGE_PLAYING_BG_COLOR = [0, 0, 0, 0]; // transparent

// In-memory playback state (source of truth exposed to UI)
const state = {
  currentEpisodeId: null,
  playing: false,
  positionSec: 0,
  durationSec: 0,
  playbackRate: 1,
  queue: [], // array of episode objects (minimal render fields)
};

const STATE_STORAGE_KEY = 'playbackStateSnapshot';
const STATE_PERSIST_DEBOUNCE_MS = 500;
const PROGRESS_PERSIST_INTERVAL_MS = 5000;

// Internal cache for quick lookups
let currentEpisode = null;
let currentAudioSources = [];
let currentAudioSourceIndex = 0;
let fallbackInProgress = false;
let offscreenReady = false;
const readyWaiters = [];
let statePersistTimer = null;
let bootstrapPromise = null;
let lastProgressPersistAt = 0;
let lastProgressEpisodeId = null;
let lastOffscreenSrc = '';

export function installPlaybackHandlers() {
  console.log(LOG_PREFIX, 'install handlers start');
  chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    if (!message || message.type !== CTRL_TYPE) return false;
    console.log(LOG_PREFIX, 'control message', message.payload?.action, message.payload);
    try {
      const p = handleControl(message.payload);
      Promise.resolve(p)
        .then(result => sendResponse({ ok: true, state: snapshotState(), result }))
        .catch(err => sendResponse({ ok: false, error: err?.message || String(err) }));
    } catch (err) {
      try { sendResponse({ ok: false, error: err?.message || String(err) }); } catch (_) {}
    }
    return true;
  });

  // Listen to offscreen player updates
  chrome.runtime.onMessage.addListener((message) => {
    if (!message || typeof message.type !== 'string') return;
    if (message.type === 'offscreen.playback.update') {
      // frequent — keep logs light
      const s = message.state || {};
      state.positionSec = Number(s.positionSec) || 0;
      state.durationSec = Number(s.durationSec) || state.durationSec || 0;
      state.playing = Boolean(s.playing);
      state.playbackRate = Number(s.playbackRate) || state.playbackRate || 1;
      lastOffscreenSrc = ensureString(s.src);
      maybePersistPlaybackProgress({ force: !state.playing });
      scheduleStatePersist();
      broadcastState();
    } else if (message.type === 'offscreen.playback.ended') {
      console.log(LOG_PREFIX, 'offscreen ended');
      // Auto-complete and advance
      const finishedId = state.currentEpisodeId;
      if (finishedId) {
        addHistoryEntry(finishedId, { positionSec: state.durationSec || state.positionSec || 0 }).catch(() => {});
        // Remove current from queue and advance
        state.queue = state.queue.filter(e => e.id !== finishedId);
        setQueue(state.queue).catch(() => {});
      }
      const next = ensureCurrentEpisodeFromQueue();
      state.positionSec = 0;
      if (next) {
        startPlaybackFromCurrent({ resumePosition: 0 }).catch(err => {
          console.warn(LOG_PREFIX, 'auto advance failed', err);
          state.playing = false;
          broadcastState();
        });
      } else {
        state.playing = false;
      }
      scheduleStatePersist();
      broadcastState();
    } else if (message.type === 'offscreen.playback.error') {
      console.warn(LOG_PREFIX, 'offscreen playback error', message.error);
      handleOffscreenPlaybackError(message);
      // forward error to any UI listeners for diagnostics
      try {
        const maybe = chrome.runtime.sendMessage({ type: 'offscreen.playback.error', error: message.error, state: message.state });
        if (maybe && typeof maybe.then === 'function') maybe.catch(() => {});
      } catch (_) {}
    } else if (message.type === 'offscreen.playback.timing') {
      // Do not re-broadcast to avoid duplicates; UI receives directly from offscreen
    } else if (message.type === 'offscreen.ready') {
      console.log(LOG_PREFIX, 'offscreen ready signal');
      offscreenReady = true;
      // resolve any waiters
      while (readyWaiters.length) {
        const fn = readyWaiters.shift();
        try { fn(); } catch (_) {}
      }
    }
  });

  // Initialize queue snapshot from storage
  bootstrapPromise = bootstrapFromStorage();

  if (chrome.runtime?.onSuspend) {
    chrome.runtime.onSuspend.addListener(() => {
      if (statePersistTimer) {
        clearTimeout(statePersistTimer);
        statePersistTimer = null;
      }
      persistStateSnapshot().catch(err => console.warn(LOG_PREFIX, 'persist on suspend failed', err));
    });
  }
}

async function bootstrapFromStorage() {
  try {
    const [queueSnapshot, stateSnapshot] = await Promise.all([
      getQueue(),
      loadStateSnapshot(),
    ]);
    state.queue = Array.isArray(queueSnapshot) ? queueSnapshot : [];
    if (stateSnapshot) {
      state.currentEpisodeId = ensureString(stateSnapshot.currentEpisodeId) || null;
      state.positionSec = Number(stateSnapshot.positionSec) || 0;
      state.durationSec = Number(stateSnapshot.durationSec) || 0;
      const rate = Number(stateSnapshot.playbackRate);
      if (Number.isFinite(rate) && rate > 0) {
        state.playbackRate = rate;
      }
      if (typeof stateSnapshot.playing === 'boolean') {
        state.playing = stateSnapshot.playing;
      }
      const storedSourceIndex = Number(stateSnapshot.sourceIndex);
      if (Number.isFinite(storedSourceIndex) && storedSourceIndex >= 0) {
        currentAudioSourceIndex = storedSourceIndex;
      }
    }
    const current = ensureCurrentEpisodeFromQueue(state.currentEpisodeId);
    if (current) {
      const sources = ensureCurrentAudioSources();
      if (sources.length) {
        currentAudioSourceIndex = Math.max(0, Math.min(currentAudioSourceIndex, sources.length - 1));
      } else {
        currentAudioSourceIndex = 0;
      }
      // Try to eagerly load current episode into offscreen at last known position (without playing)
      let resumePos = Number(state.positionSec) || 0;
      if (!resumePos && state.currentEpisodeId) {
        try {
          const history = await getPlayHistory();
          const entry = Array.isArray(history) ? history.find(h => h.episodeId === state.currentEpisodeId) : null;
          if (entry && Number(entry.lastPosition) > 0) {
            resumePos = Number(entry.lastPosition) || 0;
            state.positionSec = resumePos;
          }
        } catch (_) { /* ignore */ }
      }
      try {
        await loadCurrentEpisode({ positionSec: resumePos, sourceIndex: currentAudioSourceIndex });
        // Do not auto-play; keep state.playing as snapshot value
      } catch (err) {
        console.warn(LOG_PREFIX, 'load on bootstrap failed', err);
      }
    } else {
      state.currentEpisodeId = null;
    }
    broadcastState();
    // Proactively preconnect first upcoming audio source to warm up DNS/TLS
    try { maybePreconnectUpcoming(); } catch (_) {}
  } catch (e) {
    // ignore
  }
}

async function handleControl(payload) {
  const action = (payload?.action || '').toLowerCase();
  if (bootstrapPromise) {
    try {
      await bootstrapPromise;
    } catch (err) {
      console.warn(LOG_PREFIX, 'bootstrap promise rejected', err);
    } finally {
      bootstrapPromise = null;
    }
  }
  switch (action) {
    case 'getstate':
      return snapshotState();
    case 'playnow': {
      const episode = normalizeEpisode(payload.episode);
      if (!episode) throw new Error('Invalid episode');
      console.log(LOG_PREFIX, 'playNow', episode.id);
      // Persist progress for the previous episode if switching
      try {
        const previousId = ensureString(state.currentEpisodeId);
        if (previousId && previousId !== episode.id) {
          const pos = Number(state.positionSec) || 0;
          await addHistoryEntry(previousId, { positionSec: pos });
        }
      } catch (_) {}
      await addToQueueFront(episode);
      state.queue = await getQueue();
      const current = ensureCurrentEpisodeFromQueue(episode.id);
      if (!current) throw new Error('Invalid episode after queue update');
      // Try resume position from history if available
      let resumePosition = 0;
      try {
        const history = await getPlayHistory();
        const entry = Array.isArray(history) ? history.find(h => h.episodeId === episode.id) : null;
        if (entry && Number(entry.lastPosition) > 0) {
          resumePosition = Number(entry.lastPosition) || 0;
        }
      } catch (_) {}
      state.positionSec = resumePosition || 0;
      state.durationSec = 0;
      try {
        await startPlaybackFromCurrent({ resumePosition: state.positionSec || 0 });
      } catch (err) {
        state.playing = false;
        throw err;
      }
      broadcastState();
      try { maybePreconnectUpcoming(); } catch (_) {}
      return true;
    }
    case 'enqueuenext': {
      const episode = normalizeEpisode(payload.episode);
      if (!episode) throw new Error('Invalid episode');
      console.log(LOG_PREFIX, 'enqueueNext', episode.id);
      await addToQueueNext(episode, { afterCurrent: true });
      state.queue = await getQueue();
      ensureCurrentEpisodeFromQueue(state.currentEpisodeId);
      broadcastState();
      try { maybePreconnectUpcoming(); } catch (_) {}
      return true;
    }
    case 'removefromqueue': {
      const id = ensureString(payload.episodeId);
      console.log(LOG_PREFIX, 'removeFromQueue', id);
      await removeFromQueueById(id);
      state.queue = await getQueue();
      const removedCurrent = state.currentEpisodeId === id;
      const next = ensureCurrentEpisodeFromQueue(state.currentEpisodeId);
      if (removedCurrent) {
        state.positionSec = 0;
        state.durationSec = 0;
        if (next) {
          try {
            await loadCurrentEpisode({ positionSec: 0, sourceIndex: 0 });
          } catch (err) {
            console.warn(LOG_PREFIX, 'load after remove failed', err);
          }
        } else {
          await postToOffscreen({ type: 'stop' }).catch(() => {});
          state.playing = false;
        }
      }
      broadcastState();
      try { maybePreconnectUpcoming(); } catch (_) {}
      return true;
    }
    case 'complete': {
      const id = ensureString(payload.episodeId || state.currentEpisodeId);
      console.log(LOG_PREFIX, 'complete', id);
      if (!id) return false;
      await addHistoryEntry(id, { positionSec: state.durationSec || state.positionSec || 0 });
      await removeFromQueueById(id);
      state.queue = await getQueue();
      const removedCurrent = state.currentEpisodeId === id;
      const next = ensureCurrentEpisodeFromQueue(state.currentEpisodeId);
      if (removedCurrent) {
        state.positionSec = 0;
        state.durationSec = 0;
        if (next) {
          try {
            await loadCurrentEpisode({ positionSec: 0, sourceIndex: 0 });
          } catch (err) {
            console.warn(LOG_PREFIX, 'load after complete failed', err);
          }
        } else {
          await postToOffscreen({ type: 'stop' }).catch(() => {});
          state.playing = false;
        }
      }
      broadcastState();
      try { maybePreconnectUpcoming(); } catch (_) {}
      return true;
    }
    case 'skipnext': {
      console.log(LOG_PREFIX, 'skipNext');
      const current = ensureCurrentEpisodeFromQueue(state.currentEpisodeId);
      const currentId = current?.id || ensureString(state.currentEpisodeId);
      if (!currentId) {
        throw new Error('NO_EPISODE_AVAILABLE');
      }
      const wasPlaying = state.playing === true;
      await addHistoryEntry(currentId, { positionSec: state.positionSec || 0 });
      await removeFromQueueById(currentId);
      state.queue = await getQueue();
      const next = ensureCurrentEpisodeFromQueue();
      state.positionSec = 0;
      state.durationSec = 0;
      let transitionError = null;
      if (next) {
        try {
          if (wasPlaying) {
            await startPlaybackFromCurrent({ resumePosition: 0 });
          } else {
            await loadCurrentEpisode({ positionSec: 0, sourceIndex: 0 });
            state.playing = false;
          }
        } catch (err) {
          console.warn(LOG_PREFIX, 'skipNext transition failed', err);
          transitionError = err;
          state.playing = false;
        }
      } else {
        try {
          await postToOffscreen({ type: 'stop' });
        } catch (_) {
          // ignore
        }
        state.playing = false;
        assignCurrentEpisode(null);
        state.currentEpisodeId = null;
      }
      scheduleStatePersist();
      broadcastState();
      try { maybePreconnectUpcoming(); } catch (_) {}
      if (transitionError) {
        throw transitionError;
      }
      return true;
    }
    case 'toggle': {
      console.log(LOG_PREFIX, 'toggle');
      if (state.playing) {
        await ensureOffscreen();
        try {
          await postToOffscreen({ type: 'pause' });
        } catch (err) {
          console.warn(LOG_PREFIX, 'pause failed, retrying once', err);
          await ensureOffscreen();
          await postToOffscreen({ type: 'pause' });
        }
        state.playing = false;
      } else {
        let current = ensureCurrentEpisodeFromQueue(state.currentEpisodeId);
        if (!current) {
          const q = await getQueue();
          state.queue = Array.isArray(q) ? q : [];
          current = ensureCurrentEpisodeFromQueue();
        }
        if (!current) {
          throw new Error('NO_EPISODE_AVAILABLE');
        }
        try {
          await startPlaybackFromCurrent({ resumePosition: state.positionSec || 0 });
        } catch (err) {
          state.playing = false;
          throw err;
        }
      }
      broadcastState();
      return true;
    }
    case 'pause':
      console.log(LOG_PREFIX, 'pause');
      await ensureOffscreen();
      await postToOffscreen({ type: 'pause' });
      state.playing = false;
      broadcastState();
      return true;
    case 'resume':
      console.log(LOG_PREFIX, 'resume');
      await ensureOffscreen();
      try {
        await postToOffscreen({ type: 'play' });
      } catch (err) {
        console.warn(LOG_PREFIX, 'resume failed, first play rejected', err);
        const msg = (err && (err.message || String(err))) || '';
        if (/interrupted by a new load request/i.test(msg)) {
          // Likely race with a recent load; wait and retry without reload
          await new Promise(r => setTimeout(r, 150));
          await postToOffscreen({ type: 'play' });
        } else {
          await ensureOffscreen();
          if (currentEpisode) {
            try { await postToOffscreen({ type: 'load', episode: currentEpisode }); } catch (_) {}
          }
          await postToOffscreen({ type: 'play' });
        }
      }
      state.playing = true;
      broadcastState();
      return true;
    case 'seekby': {
      const seconds = Number(payload.seconds) || 0;
      console.log(LOG_PREFIX, 'seekBy', seconds);
  await ensureOffscreen(500);
  try {
    await postToOffscreen({ type: 'seekBy', seconds });
  } catch (err) {
    console.warn(LOG_PREFIX, 'seek failed, retry once', err);
    await ensureOffscreen(400);
    await postToOffscreen({ type: 'seekBy', seconds });
  }
      return true;
    }
    case 'seekto': {
      const raw = payload.positionSec ?? payload.position ?? payload.seconds;
      const position = Number(raw);
      const duration = Number(state.durationSec) || 0;
      const target = Number.isFinite(position) ? Math.max(0, position) : 0;
      const clamped = duration > 0 ? Math.min(duration, target) : target;
      console.log(LOG_PREFIX, 'seekTo', clamped);
  await ensureOffscreen(500);
  try {
    await postToOffscreen({ type: 'seekTo', positionSec: clamped });
  } catch (err) {
    console.warn(LOG_PREFIX, 'seekTo failed, retry once', err);
    await ensureOffscreen(400);
    await postToOffscreen({ type: 'seekTo', positionSec: clamped });
  }
      state.positionSec = clamped;
      broadcastState();
      return true;
    }
    case 'setrate': {
      const rate = Number(payload.rate) || 1;
      console.log(LOG_PREFIX, 'setRate', rate);
  await ensureOffscreen(500);
  await postToOffscreen({ type: 'setRate', rate });
      state.playbackRate = rate;
      broadcastState();
      return true;
    }
    case 'reorderqueue': {
      const ids = Array.isArray(payload.ids) ? payload.ids.map(String) : [];
      console.log(LOG_PREFIX, 'reorderQueue', ids);
      if (!ids.length) return snapshotState();
      const byId = new Map(state.queue.map(e => [e.id, e]));
      const next = [];
      ids.forEach(id => { const item = byId.get(id); if (item) next.push(item); byId.delete(id); });
      // append any remaining not in ids at the end (to avoid loss)
      byId.forEach(item => next.push(item));
      state.queue = next;
      await setQueue(state.queue).catch(() => {});
      const previousId = state.currentEpisodeId;
      const current = ensureCurrentEpisodeFromQueue(previousId);
      if (previousId && previousId !== state.currentEpisodeId) {
        state.positionSec = 0;
        state.durationSec = 0;
        if (current) {
          try {
            await loadCurrentEpisode({ positionSec: 0, sourceIndex: 0 });
          } catch (err) {
            console.warn(LOG_PREFIX, 'load after reorder failed', err);
          }
        } else {
          await postToOffscreen({ type: 'stop' }).catch(() => {});
          state.playing = false;
        }
      }
      broadcastState();
      try { maybePreconnectUpcoming(); } catch (_) {}
      return snapshotState();
    }
    default:
      throw new Error(`Unsupported action: ${action}`);
  }
}

export async function ensureOffscreen(timeoutMs = 700) {
  if (offscreenReady) return true;
  console.log(LOG_PREFIX, 'ensureOffscreen start', { supported: !!chrome.offscreen });
  if (chrome.offscreen && chrome.offscreen.hasDocument) {
    const has = await chrome.offscreen.hasDocument();
    console.log(LOG_PREFIX, 'hasDocument?', has);
    if (!has) {
      console.log(LOG_PREFIX, 'create offscreen document');
      await chrome.offscreen.createDocument({
        url: chrome.runtime.getURL('offscreen/player.html'),
        reasons: ['AUDIO_PLAYBACK'],
        justification: 'Background audio playback for 核桃FM',
      });
    } else {
      offscreenReady = true;
      return true;
    }
  }
  // wait for ready signal
  if (offscreenReady) return true;
  await new Promise((resolve) => {
    const t = setTimeout(() => {
      console.warn(LOG_PREFIX, 'offscreen ready timeout');
      resolve();
    }, Math.max(0, Number(timeoutMs) || 0));
    readyWaiters.push(() => {
      try { clearTimeout(t); } catch (_) {}
      resolve();
    });
  });
  return true;
}

function postToOffscreen(message) {
  return new Promise((resolve, reject) => {
    if (!chrome.offscreen) {
      const errMsg = 'offscreen API not available';
      console.warn(LOG_PREFIX, errMsg, message?.type);
      reject(new Error(errMsg));
      return;
    }
    const envelope = { type: 'offscreen.playback.control', payload: message };
    const attempt = (triesLeft) => {
      chrome.runtime.sendMessage(envelope, response => {
        const err = chrome.runtime.lastError;
        if (err) {
          console.warn(LOG_PREFIX, 'post offscreen failed', message?.type, err);
          if (triesLeft > 0) {
            setTimeout(() => attempt(triesLeft - 1), 60);
            return;
          }
          reject(new Error(err.message || 'offscreen post failed'));
          return;
        }
        if (!response || response.ok !== true) {
          console.warn(LOG_PREFIX, 'post offscreen rejected', message?.type, response);
          if (triesLeft > 0) {
            setTimeout(() => attempt(triesLeft - 1), 60);
            return;
          }
          reject(new Error(response?.error || 'offscreen rejected'));
          return;
        }
        resolve(response.result || true);
      });
    };
    attempt(1);
  });
}

function isFatalMediaError(error) {
  try {
    const code = Number(error?.code);
    const msg = ensureString(error?.message).toLowerCase();
    // MediaError codes: 1 ABORTED, 2 NETWORK, 3 DECODE, 4 SRC_NOT_SUPPORTED
    if (code === 2 || code === 3 || code === 4) return true;
    if (!msg) return false;
    // Treat decode/network/unsupported family as fatal
    if (/(network|decode|decoding|not\s*supported|src\s*not\s*supported|no\s*supported\s*source|unsupported)/i.test(msg)) {
      return true;
    }
    // Explicitly non-fatal transient play/load races
    if (/interrupted by a new load request|the play\(\) request was interrupted|aborterror|notallowederror/i.test(msg)) {
      return false;
    }
    return false;
  } catch (_) {
    return false;
  }
}

function broadcastState() {
  scheduleStatePersist();
  updateBadge();
  const payload = { type: STATE_TYPE, state: snapshotState() };
  try {
    const maybe = chrome.runtime.sendMessage(payload);
    if (maybe && typeof maybe.then === 'function') {
      maybe.catch(() => {});
    }
  } catch (e) {
    // ignore
  }
}

function updateBadge() {
  if (!chrome?.action || typeof chrome.action.setBadgeText !== 'function') {
    return;
  }
  const text = state.playing ? BADGE_PLAYING_TEXT : '';
  chrome.action.setBadgeText({ text });
  if (state.playing && typeof chrome.action.setBadgeBackgroundColor === 'function') {
    chrome.action.setBadgeBackgroundColor({ color: BADGE_PLAYING_BG_COLOR });
  }
}

function assignCurrentEpisode(episode) {
  const prevEpisodeId = currentEpisode?.id || null;
  const prevSourceIndex = currentAudioSourceIndex;
  currentEpisode = episode || null;
  currentAudioSources = buildAudioSources(currentEpisode);
  if (prevEpisodeId && currentEpisode && prevEpisodeId === currentEpisode.id && currentAudioSources.length) {
    currentAudioSourceIndex = Math.max(0, Math.min(prevSourceIndex, currentAudioSources.length - 1));
  } else {
    currentAudioSourceIndex = 0;
  }
}

function ensureCurrentEpisodeFromQueue(preferredId) {
  const targetId = ensureString(preferredId);
  let candidate = null;
  if (targetId) {
    candidate = state.queue.find(e => e.id === targetId) || null;
  }
  if (!candidate && state.queue.length) {
    candidate = state.queue[0];
  }
  assignCurrentEpisode(candidate);
  state.currentEpisodeId = candidate ? candidate.id : null;
  if (lastProgressEpisodeId !== state.currentEpisodeId) {
    lastProgressEpisodeId = state.currentEpisodeId;
    lastProgressPersistAt = 0;
  }
  return currentEpisode;
}

function ensureCurrentAudioSources() {
  if (!currentEpisode) {
    currentAudioSources = [];
    currentAudioSourceIndex = 0;
    return currentAudioSources;
  }
  if (!Array.isArray(currentAudioSources) || currentAudioSources.length === 0) {
    currentAudioSources = buildAudioSources(currentEpisode);
  }
  return currentAudioSources;
}

function buildAudioSources(episode) {
  const urls = [];
  const seen = new Set();
  const push = value => {
    const url = normalizeHttpUrl(value);
    if (!url || seen.has(url)) return;
    seen.add(url);
    urls.push(url);
  };
  if (episode) {
    push(episode.audioUrl);
    if (Array.isArray(episode.backupAudioUrls)) {
      episode.backupAudioUrls.forEach(push);
    }
  }
  return urls;
}

function maybePreconnectUpcoming() {
  try {
    const candidate = Array.isArray(state.queue) && state.queue.length ? state.queue[0] : null;
    if (!candidate) return;
    const sources = buildAudioSources(candidate);
    const url = ensureString(Array.isArray(sources) && sources.length ? sources[0] : candidate.audioUrl);
    if (!url) return;
    // If already loaded same src in offscreen, skip
    if (lastOffscreenSrc && lastOffscreenSrc === url) return;
    ensureOffscreen(300)
      .then(() => postToOffscreen({ type: 'preconnect', url }).catch(() => {}))
      .catch(() => {});
  } catch (_) {
    // ignore
  }
}

async function loadCurrentEpisode({ positionSec = 0, sourceIndex = null } = {}) {
  if (!currentEpisode) throw new Error('NO_EPISODE');
  const sources = ensureCurrentAudioSources();
  if (!sources.length) throw new Error('NO_SOURCE');
  const previousIndex = currentAudioSourceIndex;
  const maxIndex = sources.length - 1;
  const resolvedIndex = sourceIndex != null ? Math.max(0, Math.min(maxIndex, Number(sourceIndex))) : previousIndex;
  const url = ensureString(sources[resolvedIndex]);
  if (!url) throw new Error('NO_SOURCE');
  const payload = { ...currentEpisode, audioUrl: url };
  await ensureOffscreen();
  try {
    await postToOffscreen({ type: 'load', episode: payload });
  } catch (err) {
    currentAudioSourceIndex = previousIndex;
    throw err;
  }
  currentAudioSourceIndex = resolvedIndex;
  if (positionSec > 0) {
    try {
      await postToOffscreen({ type: 'seekTo', positionSec });
      state.positionSec = positionSec;
    } catch (err) {
      console.warn(LOG_PREFIX, 'seek after load failed', err);
    }
  }
  if (Number(state.playbackRate) && state.playbackRate !== 1) {
    try {
      await postToOffscreen({ type: 'setRate', rate: state.playbackRate });
    } catch (err) {
      console.warn(LOG_PREFIX, 'apply rate after load failed', err);
    }
  }
  scheduleStatePersist();
  return true;
}

async function attemptNextAudioSource({ resumePosition = 0, autoplay = false } = {}) {
  if (fallbackInProgress) return false;
  if (!currentEpisode) return false;
  const sources = ensureCurrentAudioSources();
  if (!sources.length) return false;
  fallbackInProgress = true;
  try {
    const startIndex = currentAudioSourceIndex + 1;
    for (let idx = startIndex; idx < sources.length; idx += 1) {
      try {
        await loadCurrentEpisode({ positionSec: resumePosition, sourceIndex: idx });
      } catch (err) {
        console.warn(LOG_PREFIX, 'load fallback source failed', err);
        continue;
      }
      if (autoplay) {
        try {
          await postToOffscreen({ type: 'play' });
          state.playing = true;
          state.positionSec = resumePosition;
          scheduleStatePersist();
          return true;
        } catch (err) {
          console.warn(LOG_PREFIX, 'play failed on fallback source', err);
          state.playing = false;
          continue;
        }
      } else {
        scheduleStatePersist();
        return true;
      }
    }
    return false;
  } finally {
    fallbackInProgress = false;
  }
}

function handleOffscreenPlaybackError(detail) {
  if (!currentEpisode) return;
  const err = detail?.error || {};
  if (!isFatalMediaError(err)) {
    console.warn(LOG_PREFIX, 'non-fatal media error; skip fallback', err);
    return;
  }
  const resumePosition = Number(detail?.state?.positionSec ?? detail?.positionSec ?? state.positionSec) || 0;
  const shouldAutoplay = Boolean(state.playing);
  attemptNextAudioSource({ resumePosition, autoplay: shouldAutoplay })
    .then(success => {
      if (success) {
        broadcastState();
      } else {
        console.warn(LOG_PREFIX, 'no fallback sources available after fatal error');
        state.playing = false;
        broadcastState();
      }
    })
    .catch(err2 => {
      console.warn(LOG_PREFIX, 'fallback attempt failed after fatal error', err2);
      state.playing = false;
      broadcastState();
    });
}

async function startPlaybackFromCurrent({ resumePosition = 0 } = {}) {
  if (!currentEpisode) throw new Error('NO_EPISODE');
  const position = Number(resumePosition) || 0;
  try {
    await loadCurrentEpisode({ positionSec: position, sourceIndex: currentAudioSourceIndex });
  } catch (err) {
    const loadFallback = await attemptNextAudioSource({ resumePosition: position, autoplay: false });
    if (!loadFallback) {
      throw err;
    }
  }
  try {
    await postToOffscreen({ type: 'play' });
  } catch (err) {
    console.warn(LOG_PREFIX, 'play failed on current source', err);
    const msg = (err && (err.message || String(err))) || '';
    // If interrupted by a new load, retry once on the same source after a short delay
    if (/interrupted by a new load request/i.test(msg)) {
      await new Promise(r => setTimeout(r, 150));
      try {
        await postToOffscreen({ type: 'play' });
        state.playing = true;
        state.positionSec = position;
        scheduleStatePersist();
        return true;
      } catch (e2) {
        console.warn(LOG_PREFIX, 'retry play after load-interrupt failed', e2);
      }
    }
    const playFallback = await attemptNextAudioSource({ resumePosition: position, autoplay: true });
    if (!playFallback) {
      throw err;
    }
    state.positionSec = position;
    scheduleStatePersist();
    return true;
  }
  state.playing = true;
  state.positionSec = position;
  scheduleStatePersist();
  return true;
}

function maybePersistPlaybackProgress({ force = false } = {}) {
  const episodeId = state.currentEpisodeId;
  if (!episodeId) return;
  const position = Number(state.positionSec) || 0;
  const now = Date.now();

  if (lastProgressEpisodeId !== episodeId) {
    lastProgressEpisodeId = episodeId;
    lastProgressPersistAt = 0;
  }

  if (!force) {
    if (lastProgressPersistAt && now - lastProgressPersistAt < PROGRESS_PERSIST_INTERVAL_MS) {
      return;
    }
  }

  lastProgressPersistAt = now;
  addHistoryEntry(episodeId, { positionSec: position }).catch(err => {
    console.warn(LOG_PREFIX, 'persist progress failed', err);
  });
}

function scheduleStatePersist() {
  if (statePersistTimer) return;
  statePersistTimer = setTimeout(() => {
    statePersistTimer = null;
    persistStateSnapshot().catch(err => console.warn(LOG_PREFIX, 'persist state snapshot failed', err));
  }, STATE_PERSIST_DEBOUNCE_MS);
}

async function persistStateSnapshot() {
  const snapshot = {
    currentEpisodeId: state.currentEpisodeId,
    positionSec: Number(state.positionSec) || 0,
    durationSec: Number(state.durationSec) || 0,
    playbackRate: Number(state.playbackRate) || 1,
    sourceIndex: Number(currentAudioSourceIndex) || 0,
    playing: Boolean(state.playing),
    updatedAt: Date.now(),
  };
  await chrome.storage.local.set({ [STATE_STORAGE_KEY]: snapshot });
}

async function loadStateSnapshot() {
  try {
    const stored = await chrome.storage.local.get({ [STATE_STORAGE_KEY]: null });
    const snapshot = stored?.[STATE_STORAGE_KEY];
    if (!snapshot || typeof snapshot !== 'object') {
      return null;
    }
    return {
      currentEpisodeId: ensureString(snapshot.currentEpisodeId),
      positionSec: Number(snapshot.positionSec) || 0,
      durationSec: Number(snapshot.durationSec) || 0,
      playbackRate: Number(snapshot.playbackRate) || 1,
      sourceIndex: Number(snapshot.sourceIndex) || 0,
      playing: snapshot.playing === true,
    };
  } catch (err) {
    console.warn(LOG_PREFIX, 'load state snapshot failed', err);
    return null;
  }
}

function snapshotState() {
  return {
    currentEpisodeId: state.currentEpisodeId,
    playing: state.playing,
    positionSec: state.positionSec,
    durationSec: state.durationSec,
    playbackRate: state.playbackRate,
    queue: state.queue.slice(0, 50),
  };
}

function normalizeEpisode(raw) {
  if (!raw || typeof raw !== 'object') return null;
  const id = ensureString(raw.id);
  let audioUrl = ensureString(raw.audioUrl || raw.audio?.url);
  const backupAudioUrls = collectBackupAudioUrls(raw);
  if (!audioUrl && backupAudioUrls.length) {
    audioUrl = backupAudioUrls[0];
  }
  if (!id || !audioUrl) return null;
  return {
    id,
    episodeTitle: ensureString(raw.episodeTitle || raw.title || id),
    audioUrl,
    backupAudioUrls,
    cover: ensureString(raw.cover || raw.image || ''),
    publishedAt: raw.publishedAt || raw.publishAt || raw.updatedAt || raw.createdAt || null,
    duration: toNumberOrUndefined(
      raw.duration || raw.durationSec || raw.durationSeconds || raw.audio?.duration
    ),
    episodeLink: ensureString(raw.episodeLink || raw.link || ''),
    podcastTitle: ensureString(raw.podcastTitle || raw.podcast?.title || ''),
    podcastSourceRef: ensureString(raw.podcastSourceRef || raw.podcast?.sourceRef || ''),
    podcastLink: ensureString(raw.podcastLink || raw.podcast?.link || ''),
  };
}

function ensureString(v) {
  if (typeof v === 'string') return v.trim();
  if (typeof v === 'number') return String(v);
  return '';
}

function toNumberOrUndefined(value) {
  if (value == null) return undefined;
  const num = Number(value);
  return Number.isFinite(num) ? num : undefined;
}

function collectBackupAudioUrls(raw) {
  const urls = [];
  const seen = new Set();
  const push = value => {
    const normalized = normalizeHttpUrl(value);
    if (!normalized || seen.has(normalized)) return;
    seen.add(normalized);
    urls.push(normalized);
  };

  const media = raw?.media || {};
  if (media.backupSource) {
    push(media.backupSource.url || media.backupSource.src || media.backupSource);
  }
  if (Array.isArray(media.backupSources)) {
    media.backupSources.forEach(item => {
      if (!item) return;
      if (typeof item === 'string') {
        push(item);
      } else {
        push(item.url || item.src);
      }
    });
  }
  if (Array.isArray(media.sources)) {
    media.sources.forEach(item => {
      if (!item) return;
      if (typeof item === 'string') {
        push(item);
      } else {
        push(item.url || item.src);
      }
    });
  }
  if (media.backupUrl) push(media.backupUrl);
  if (media.fallbackUrl) push(media.fallbackUrl);

  const extras = [
    raw?.backupAudioUrl,
    raw?.alternativeAudioUrl,
    raw?.fallbackAudioUrl,
  ];
  extras.forEach(push);

  if (Array.isArray(raw?.backupAudioUrls)) {
    raw.backupAudioUrls.forEach(push);
  }
  if (Array.isArray(raw?.audioAlternatives)) {
    raw.audioAlternatives.forEach(push);
  }
  if (Array.isArray(raw?.alternateAudioUrls)) {
    raw.alternateAudioUrls.forEach(push);
  }

  return urls;
}

function normalizeHttpUrl(value) {
  const str = ensureString(value);
  if (!str) return '';
  if (/^https?:\/\//i.test(str)) return str;
  if (str.startsWith('//')) return `https:${str}`;
  return '';
}
