import type { TrackRef, RadioResponse } from '$lib/api/types';
import { untrack } from 'svelte';
import { api } from '$lib/api/client';
import { user } from '$lib/auth/user.svelte';
import { sessionEnd } from '$lib/auth/sessionEnd.svelte';
import * as storage from '$lib/util/safeLocalStorage';
import { readPersistedQueue, writePersistedQueue, clearPersistedQueue } from './persisted';

export type PlayerState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
export type RepeatMode = 'off' | 'all' | 'one';

const VOLUME_KEY = 'minstrel.volume';
const CROSSFADE_KEY = 'minstrel.crossfade';

const CROSSFADE_MIN = 0;
const CROSSFADE_MAX = 12;

export function readStoredVolume(): number {
  const raw = storage.read(VOLUME_KEY);
  if (raw == null) return 1;
  const n = Number(raw);
  if (Number.isFinite(n) && n >= 0 && n <= 1) return n;
  return 1;
}

export function readStoredCrossfade(): number {
  const raw = storage.read(CROSSFADE_KEY);
  if (raw == null) return 0;
  const n = Number(raw);
  if (Number.isFinite(n) && n >= CROSSFADE_MIN && n <= CROSSFADE_MAX) {
    return Math.round(n);
  }
  return 0;
}

// Pure-function: maps (position, duration, crossfadeSec) → volume scalar
// in [0,1]. Outside the crossfade windows it's 1. In the leading X
// seconds: position/X (fade-in). In the trailing X seconds:
// (duration-position)/X (fade-out). Tracks shorter than 2X don't fade.
export function deriveFadeScalar(
  position: number,
  duration: number,
  crossfadeSec: number
): number {
  if (crossfadeSec <= 0 || duration <= 0) return 1;
  if (duration < crossfadeSec * 2) return 1;
  if (position < crossfadeSec) {
    return Math.max(0, Math.min(1, position / crossfadeSec));
  }
  if (duration - position < crossfadeSec) {
    return Math.max(0, Math.min(1, (duration - position) / crossfadeSec));
  }
  return 1;
}

let _queue    = $state<TrackRef[]>([]);
let _index    = $state(0);
let _state    = $state<PlayerState>('idle');
let _position = $state(0);
let _duration = $state(0);
let _volume   = $state(readStoredVolume());
let _crossfadeSec = $state(readStoredCrossfade());
let _shuffle  = $state(false);
let _repeat   = $state<RepeatMode>('off');
let _error    = $state<string | null>(null);
let _queueDrawerOpen = $state(false);

// C1: one-shot restore position — set by restoreQueue, consumed once by
// the layout's loadedmetadata handler so the audio element seeks to the
// saved position after metadata loads.
let _pendingRestorePosition: number | null = null;

// M4c: track when the queue was seeded by a radio call so we can
// auto-refresh at 80% consumption. Cleared when the user enqueues
// from a non-radio source.
let _radioSeedId          = $state<string | null>(null);
let _radioRefreshInFlight = false;

// #415: which system playlist (if any) this queue was seeded from.
// 'for_you' | 'discover' | null. Read by the events dispatcher so
// play_started carries `source` and the play counts against that
// playlist's rotation. Re-set on every playQueue (a fresh non-system
// queue clears it).
let _queueSource = $state<string | null>(null);

// Track B (#968): consecutive load failures since the last successful
// 'playing'. A bad track auto-advances instead of dead-ending; the streak
// (capped against queue length) stops a fully-unplayable queue from
// looping forever. Reset on a successful play and on a fresh playQueue.
let _failureStreak = 0;

// Track B (#968): when a queue is seeded from a refreshable source (a system
// playlist or radio), the seeder hands us a closure that re-pulls the fresh
// snapshot. On total failure (whole queue unplayable — likely a stale tab
// left open across the daily rebuild) we call it once to self-heal before
// surfacing the dead-end. Kept opaque so the store stays decoupled from the
// playlist API and the per-artist (songs_like_artist) identity problem.
let _queueRefetch: (() => Promise<TrackRef[]>) | null = null;
let _selfHealInFlight = false;
let _selfHealAttempts = 0;
const MAX_SELF_HEAL_ATTEMPTS = 1;

let _audioEl: HTMLAudioElement | null = null;

export const player = {
  get queue()     { return _queue; },
  get index()     { return _index; },
  get current()   { return _queue[_index] as TrackRef | undefined; },
  get state()     { return _state; },
  get isPlaying() { return _state === 'playing'; },
  get position()  { return _position; },
  get duration()  { return _duration; },
  get volume()    { return _volume; },
  get crossfadeSec() { return _crossfadeSec; },
  get shuffle()   { return _shuffle; },
  get repeat()    { return _repeat; },
  get error()     { return _error; },
  get queueSource()       { return _queueSource; },
  get queueDrawerOpen()   { return _queueDrawerOpen; }
};

export function registerAudioEl(el: HTMLAudioElement | null): void {
  _audioEl = el;
}

export function playQueue(
  tracks: TrackRef[],
  startIndex = 0,
  opts: {
    shuffle?: boolean;
    source?: string | null;
    refetch?: () => Promise<TrackRef[]>;
  } = {},
): void {
  _radioSeedId = null;  // M4c: non-radio enqueue clears the radio refresh state
  // #415: a fresh queue resets the system-playlist source. Set only
  // when seeded from a system playlist (server already returned the
  // rotation-aware order, so no client shuffle in that path).
  _queueSource = opts.source ?? null;
  // #968: a fresh play replaces the self-heal closure + resets its budget.
  _queueRefetch = opts.refetch ?? null;
  _selfHealAttempts = 0;
  if (opts.shuffle && tracks.length > 1) {
    // Fisher-Yates over the whole list. startIndex is ignored — the
    // caller is asking for "random play from this pool," so the first
    // track should also be random, not the one at startIndex.
    const arr = tracks.slice();
    for (let j = arr.length - 1; j > 0; j--) {
      const r = Math.floor(_rng() * (j + 1));
      [arr[j], arr[r]] = [arr[r], arr[j]];
    }
    _queue = arr;
    _index = 0;
    _shuffle = true;
    _state = 'loading';
  } else {
    _queue = tracks;
    if (tracks.length === 0) {
      _index = 0;
      _state = 'idle';
    } else {
      _index = Math.max(0, Math.min(startIndex, tracks.length - 1));
      _state = 'loading';
    }
  }
  _position = 0;
  _duration = 0;
  _error = null;
  _failureStreak = 0;
}

export function togglePlay(): void {
  if (_queue.length === 0) return;
  // "Intent to play" (loading + playing) → paused. Anything else → loading (user
  // wants to play; audio will catch up).
  if (_state === 'playing' || _state === 'loading') {
    _state = 'paused';
  } else {
    _state = 'loading';
  }
}

export function skipNext(): void {
  if (_queue.length === 0) return;
  if (_index + 1 < _queue.length) {
    _index++;
    _position = 0;
    _duration = 0;
    _state = 'loading';
    return;
  }
  // At end of queue.
  if (_repeat === 'all') {
    _index = 0;
    _position = 0;
    _duration = 0;
    _state = 'loading';
  } else {
    // 'off' or 'one' both pause at end on EXPLICIT skip.
    _state = 'paused';
    _position = _duration;
  }
}

export function skipPrev(): void {
  if (_queue.length === 0) return;
  if (_position < 3 && _index > 0) {
    _index--;
    _position = 0;
    _duration = 0;
    _state = 'loading';
  } else {
    // Seek to 0 of the current track.
    _position = 0;
    if (_audioEl) _audioEl.currentTime = 0;
  }
}

export function seekTo(sec: number): void {
  _position = sec;
  if (_audioEl) _audioEl.currentTime = sec;
}

export function setVolume(v: number): void {
  const clamped = Math.max(0, Math.min(1, v));
  _volume = clamped;
  storage.write(VOLUME_KEY, String(clamped));
}

export function setCrossfade(sec: number): void {
  const clamped = Math.max(CROSSFADE_MIN, Math.min(CROSSFADE_MAX, Math.round(sec)));
  _crossfadeSec = clamped;
  storage.write(CROSSFADE_KEY, String(clamped));
}

// Last non-zero volume captured so the M shortcut can toggle to silent
// then restore. Set lazily on the first toggle and on any setVolume
// that's non-zero. Default 0.5 keeps unmute usable even if the user
// somehow lands at volume=0 with no prior non-zero value.
let _volumeBeforeMute = 0.5;

export function toggleMute(): void {
  if (_volume > 0) {
    _volumeBeforeMute = _volume;
    setVolume(0);
  } else {
    setVolume(_volumeBeforeMute > 0 ? _volumeBeforeMute : 0.5);
  }
}

export function reportTimeUpdate(sec: number): void {
  _position = sec;
}

export function reportDuration(sec: number): void {
  _duration = Number.isFinite(sec) ? sec : 0;
}

// --- Shuffle (with injectable RNG for tests) ---

let _rng: () => number = Math.random;
export function __setShuffleRng(fn: () => number): void { _rng = fn; }

export function toggleShuffle(): void {
  _shuffle = !_shuffle;
  if (_shuffle && _index + 1 < _queue.length) {
    // Fisher-Yates shuffle over _queue[_index+1 .. end].
    const arr = _queue.slice();
    for (let j = arr.length - 1; j > _index; j--) {
      const r = Math.floor(_rng() * (j - _index)) + _index + 1;
      [arr[j], arr[r]] = [arr[r], arr[j]];
    }
    _queue = arr;
  }
}

// --- Repeat ---

export function cycleRepeat(): void {
  _repeat = _repeat === 'off' ? 'all' : _repeat === 'all' ? 'one' : 'off';
}

export function reportStateFromAudio(
  event: 'playing' | 'paused' | 'waiting' | 'ended' | 'error',
  detail?: string
): void {
  switch (event) {
    case 'playing':
      _state = 'playing';
      _error = null;
      _failureStreak = 0;
      _selfHealAttempts = 0;
      return;
    case 'paused':
      _state = 'paused';
      return;
    case 'waiting':
      _state = 'loading';
      return;
    case 'error':
      handleLoadFailure(detail);
      return;
    case 'ended':
      if (_repeat === 'one') {
        _position = 0;
        _state = 'loading';
        if (_audioEl) _audioEl.currentTime = 0;
        return;
      }
      if (_index + 1 < _queue.length) {
        _index++;
        _position = 0;
        _duration = 0;
        _state = 'loading';
        return;
      }
      if (_repeat === 'all') {
        _index = 0;
        _position = 0;
        _duration = 0;
        _state = 'loading';
      } else {
        _state = 'paused';
        _position = _duration;
      }
      return;
  }
}

// Track B (#968): a single failed track must not strand the player on the
// "Try again" dead-end. Advance past it; only surface the error once the
// whole queue has proven unplayable — every track failed, or we reached the
// end with nothing playable. The streak cap (vs queue length) stops a
// fully-broken queue from cycling forever instead of settling.
function handleLoadFailure(detail?: string): void {
  _failureStreak++;
  if (_failureStreak < _queue.length && _index + 1 < _queue.length) {
    _index++;
    _position = 0;
    _duration = 0;
    _state = 'loading';
    _error = null;
    return;
  }
  // Whole queue is unplayable. If it came from a refreshable source, the
  // snapshot is probably stale — re-pull it and resume before giving up.
  if (trySelfHeal()) return;
  _state = 'error';
  _error = detail ?? 'Playback failed.';
}

// #968: re-pull a stale refreshable queue. Returns true if a self-heal was
// started (caller must not dead-end). Bounded to one attempt per exhaustion
// (reset on the next successful play) so a still-broken refresh can't loop.
function trySelfHeal(): boolean {
  if (_selfHealInFlight) return true;
  if (_queueRefetch === null) return false;
  if (_selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) return false;
  _selfHealAttempts++;
  _selfHealInFlight = true;
  _state = 'loading';
  _error = null;
  const refetch = _queueRefetch;
  void refetch()
    .then((refs) => {
      if (refs.length > 0) {
        // Re-seed in place — keep _queueSource / _queueRefetch so the new
        // snapshot can itself self-heal, and don't reset _selfHealAttempts
        // (only a successful 'playing' clears the budget).
        _queue = refs;
        _index = 0;
        _position = 0;
        _duration = 0;
        _failureStreak = 0;
        _state = 'loading';
        _error = null;
      } else {
        _state = 'error';
        _error = 'Nothing playable in this mix right now.';
      }
    })
    .catch(() => {
      _state = 'error';
      _error = 'Couldn’t refresh this mix. Try again.';
    })
    .finally(() => {
      _selfHealInFlight = false;
    });
  return true;
}

export function enqueueTrack(t: TrackRef): void {
  _radioSeedId = null;  // M4c
  _queue = [..._queue, t];
  if (_state === 'idle') _index = 0;
}

/**
 * M7 #372: insert a track immediately after the currently-playing one
 * so it plays next. If the queue is empty, behaves like enqueueTrack
 * and starts the queue with this track at index 0.
 */
export function playNext(t: TrackRef): void {
  _radioSeedId = null; // M4c: any user-driven enqueue clears the radio refresh state
  if (_queue.length === 0) {
    _queue = [t];
    _index = 0;
    return;
  }
  const next = _index + 1;
  _queue = [..._queue.slice(0, next), t, ..._queue.slice(next)];
}

export function enqueueTracks(ts: TrackRef[]): void {
  _radioSeedId = null;  // M4c
  if (ts.length === 0) return;
  _queue = [..._queue, ...ts];
  if (_state === 'idle') _index = 0;
}

// Bulk variant of playNext: inserts every track immediately after the
// currently-playing one, preserving order. Empty queue seeds the queue
// with the batch at index 0 (mirrors playNext's single-track shape).
export function playNextMany(ts: TrackRef[]): void {
  _radioSeedId = null;
  if (ts.length === 0) return;
  if (_queue.length === 0) {
    _queue = [...ts];
    _index = 0;
    return;
  }
  const next = _index + 1;
  _queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)];
}

async function fetchRadioTracks(seedTrackId: string): Promise<TrackRef[]> {
  const resp = await api.get<RadioResponse>(
    `/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
  );
  return resp.tracks;
}

export async function playRadio(seedTrackId: string): Promise<void> {
  const tracks = await fetchRadioTracks(seedTrackId);
  if (tracks.length === 0) return;
  // #968: hand the player a self-heal closure so a fully-stale radio queue
  // re-seeds from the same track instead of dead-ending.
  playQueue(tracks, 0, { refetch: () => fetchRadioTracks(seedTrackId) });
  _radioSeedId = seedTrackId;  // M4c: set AFTER playQueue (which clears it)
}

export function moveQueueItem(from: number, to: number): void {
  if (from === to) return;
  if (from < 0 || from >= _queue.length) return;
  if (to < 0 || to >= _queue.length) return;
  const playingId = _queue[_index]?.id;
  const next = _queue.slice();
  const [moved] = next.splice(from, 1);
  next.splice(to, 0, moved);
  _queue = next;
  if (playingId) {
    const found = next.findIndex((x) => x.id === playingId);
    if (found >= 0) _index = found;
  }
}

export function removeFromQueue(idx: number): void {
  if (idx < 0 || idx >= _queue.length) return;
  const next = _queue.slice();
  next.splice(idx, 1);
  if (idx < _index) {
    _queue = next;
    _index = _index - 1;
    return;
  }
  if (idx > _index) {
    _queue = next;
    return;
  }
  // idx === _index — removing the currently-playing track.
  _queue = next;
  if (next.length === 0) {
    _index = 0;
    _state = 'idle';
    _position = 0;
    _duration = 0;
    _error = null;
    return;
  }
  if (_index >= next.length) {
    // Removed the last track. Mirror skipNext end-of-queue behaviour:
    // pause on the now-last remaining track at position 0 — don't
    // auto-play a track the user already heard.
    _index = next.length - 1;
    _state = 'paused';
    _position = 0;
    _duration = 0;
    _error = null;
    return;
  }
  // _index stays — now points at what was the next track. Advance into it.
  _position = 0;
  _duration = 0;
  _state = 'loading';
  _error = null;
}

// Clear the whole queue and stop playback — mirrors removeFromQueue's
// empty-queue branch. Also drops the radio/system source + self-heal closure
// so the emptied player doesn't try to refill from a now-irrelevant source.
export function clearQueue(): void {
  _queue = [];
  _index = 0;
  _state = 'idle';
  _position = 0;
  _duration = 0;
  _error = null;
  _radioSeedId = null;
  _queueSource = null;
  _queueRefetch = null;
}

export function playFromQueueIndex(idx: number): void {
  if (idx < 0 || idx >= _queue.length) return;
  _radioSeedId = null;
  _index = idx;
  _position = 0;
  _duration = 0;
  _state = 'loading';
  _error = null;
}

export function toggleQueueDrawer(): void {
  _queueDrawerOpen = !_queueDrawerOpen;
}

export function closeQueueDrawer(): void {
  _queueDrawerOpen = false;
}

export function restoreQueue(userId: string): void {
  if (!userId) return;
  const persisted = readPersistedQueue(userId);
  if (!persisted) return;
  if (persisted.queue.length === 0) return;
  _queue = persisted.queue;
  _index = Math.max(0, Math.min(persisted.index, persisted.queue.length - 1));
  _position = persisted.position;
  _pendingRestorePosition = persisted.position;
  _duration = 0;
  _state = 'paused';
  _error = null;
  _radioSeedId = null;
}

export function consumePendingRestorePosition(): number | null {
  const p = _pendingRestorePosition;
  _pendingRestorePosition = null;
  return p;
}

// M4c: auto-refresh radio queue when 80% consumed.
// Fires whenever the consumption ratio crosses 80% AND the queue was
// seeded by playRadio() AND no refresh is currently in-flight.
$effect.root(() => {
  $effect(() => {
    if (_radioSeedId === null) return;
    if (_radioRefreshInFlight) return;
    if (_queue.length === 0) return;
    const consumedRatio = (_index + 1) / _queue.length;
    if (consumedRatio < 0.8) return;

    _radioRefreshInFlight = true;
    const seed = _radioSeedId;
    // Drift #554: cap the exclude list so a multi-hour radio session
    // doesn't grow the query string past common 8KB limits and start
    // 414-ing /api/radio. The .catch() below would silently swallow
    // that failure and the player would stop topping up — a dead
    // radio. The server's RecentlyPlayedHours filter already handles
    // broader history dedup, so the request-side exclude only needs
    // to cover the visible queue's recent tail.
    const RADIO_EXCLUDE_CAP = 100;
    const exclude = _queue
      .slice(-RADIO_EXCLUDE_CAP)
      .map((t) => t.id)
      .join(',');
    api
      .get<RadioResponse>(
        `/api/radio?seed_track=${encodeURIComponent(seed)}&exclude=${exclude}`
      )
      .then((resp) => {
        // Strip the seed at index 0 (already in our queue) and any other
        // tracks that happen to match the original seed id.
        const newTracks = resp.tracks.slice(1).filter((t) => t.id !== seed);
        if (newTracks.length > 0) {
          // Append directly without calling enqueueTracks — that would
          // clear _radioSeedId and prevent further refreshes.
          _queue = [..._queue, ...newTracks];
        }
      })
      .catch(() => {
        // Swallow; next track-advance can retry.
      })
      .finally(() => {
        _radioRefreshInFlight = false;
      });
  });
});

// Session-end teardown (#375): auth signals via sessionEnd; player
// owns the player-specific cleanup (clear persisted queue for the
// outgoing user, reset in-memory queue, close drawer) without auth
// having to import any player APIs.
$effect.root(() => {
  let lastTick = sessionEnd.tick;
  $effect(() => {
    if (sessionEnd.tick === lastTick) return;
    lastTick = sessionEnd.tick;
    const prevId = sessionEnd.userId;
    if (prevId) clearPersistedQueue(prevId);
    playQueue([]);
    closeQueueDrawer();
  });
});

// Persistence (M7 #364): split-effect shape. Queue/index changes write
// immediately (rare, user-driven); position changes throttle to once
// per 5s during steady playback. See spec section "Persistence write
// effect" for the alternative debounce shape.
$effect.root(() => {
  let lastPositionWriteAt = 0;

  // Immediate write on queue or index changes. Position is read via
  // untrack so this effect doesn't fire on every position tick — only
  // queue/index changes drive it. The throttled-write effect below
  // covers position drift.
  $effect(() => {
    const userId = user.value?.id;
    if (!userId) return;
    // Reading these registers the reactive dependency; the values
    // themselves are unused — writePersistedQueue re-reads live state.
    _queue;
    _index;
    writePersistedQueue(userId, {
      queue: _queue,
      index: _index,
      position: untrack(() => _position)
    });
    lastPositionWriteAt = Date.now();
  });

  // Throttled write on position changes.
  $effect(() => {
    const userId = user.value?.id;
    if (!userId) return;
    const _p = _position;
    void _p;
    const now = Date.now();
    if (now - lastPositionWriteAt < 5000) return;
    writePersistedQueue(userId, {
      queue: untrack(() => _queue),
      index: untrack(() => _index),
      position: _position
    });
    lastPositionWriteAt = now;
  });
});
