// Runtime state + API client. Loaded first so every later module can rely
// on `window.Store` and `window.Api`.

const Store = (() => {
  let state = {
    players: [],
    verifiedSet: new Set(),
    submissions: [],
    me: null,
  };
  const subs = new Set();
  const get = () => state;
  const set = (patch) => {
    state = { ...state, ...patch };
    subs.forEach(fn => fn(state));
  };
  const subscribe = (fn) => { subs.add(fn); return () => subs.delete(fn); };
  return { get, set, subscribe };
})();

const Api = {
  async me() {
    const r = await fetch('/api/me', { credentials: 'include' });
    if (r.status === 401) return null;
    // Distinguish a rate-limit hiccup from a real auth failure. A 429
    // means "you talked too fast", not "your session is gone" — the
    // app.jsx error path would otherwise interpret a throw as logout
    // and dump the user on the login screen mid-bulk-upload. Surface
    // a marker so the caller can treat it as "transient retry me"
    // instead of "log out".
    if (r.status === 429) {
      const e = new Error('rate-limited');
      e.rateLimited = true;
      throw e;
    }
    if (!r.ok) throw new Error('me failed');
    return r.json();
  },
  async players() {
    const r = await fetch('/api/players', { credentials: 'include' });
    if (!r.ok) throw new Error('players failed');
    return r.json();
  },
  async submissions() {
    const r = await fetch('/api/submissions', { credentials: 'include' });
    if (!r.ok) throw new Error('submissions failed');
    return r.json();
  },
  // Paginated catalog browse — server-side filter/sort/page. Used by
  // HomeScreen and the Verlauf sub-tab. Returns `{ items, total }`.
  // `params` is an object of query params (status/q/tag/sort/limit/offset/identifier);
  // undefined keys are stripped so the server gets a clean URL.
  async catalog(params = {}) {
    const qs = new URLSearchParams();
    for (const [k, v] of Object.entries(params)) {
      if (v != null && v !== '') qs.set(k, String(v));
    }
    const r = await fetch(`/api/submissions/catalog?${qs}`, { credentials: 'include' });
    if (!r.ok) throw new Error('catalog failed');
    return r.json();
  },
  // Single-track lookup for the share-link boot path. Returns the
  // full track DTO if the id refers to an accepted track, null
  // otherwise (404 on the server). Errors are swallowed so a bad
  // hash in the URL just falls back to the default landing page.
  async track(id) {
    try {
      const r = await fetch(`/api/submissions/track/${encodeURIComponent(id)}`,
        { credentials: 'include' });
      if (!r.ok) return null;
      return r.json();
    } catch { return null; }
  },
  // Distinct tag list across accepted catalog. Drives the filter chips.
  async availableTags() {
    const r = await fetch('/api/submissions/tags', { credentials: 'include' });
    if (!r.ok) throw new Error('tags failed');
    return r.json();
  },
  // Fetch the lyrics row for a track. Returns null if there isn't
  // one yet (still in the transcription queue, or the worker hasn't
  // produced anything). The big-player view consumes this; failures
  // are not toast-worthy since "no lyrics yet" is a normal state.
  async lyrics(id) {
    try {
      const r = await fetch(`/api/lyrics/track/${encodeURIComponent(id)}`, {
        credentials: 'include',
      });
      if (!r.ok) return null;
      return r.json();
    } catch { return null; }
  },
  // Per-user playlists — same tables the ingame app writes to, so
  // changes show up in both surfaces. Every mutation is owner-
  // scoped on the server; the client just sends ids.
  playlists: {
    async list() {
      const r = await fetch('/api/playlists', { credentials: 'include' });
      if (!r.ok) throw new Error('playlists/list failed');
      return r.json();
    },
    async get(id) {
      const r = await fetch(`/api/playlists/${encodeURIComponent(id)}`,
        { credentials: 'include' });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'playlists/get failed');
      return r.json();
    },
    async create(name) {
      const r = await fetch('/api/playlists', {
        method: 'POST', credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name }),
      });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'playlists/create failed');
      return r.json();
    },
    async rename(id, name) {
      const r = await fetch(`/api/playlists/${encodeURIComponent(id)}`, {
        method: 'PATCH', credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name }),
      });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'playlists/rename failed');
      return r.json();
    },
    async remove(id) {
      const r = await fetch(`/api/playlists/${encodeURIComponent(id)}`, {
        method: 'DELETE', credentials: 'include',
      });
      if (!r.ok) throw new Error('playlists/remove failed');
      return r.json();
    },
    async addTrack(id, trackId) {
      const r = await fetch(`/api/playlists/${encodeURIComponent(id)}/tracks`, {
        method: 'POST', credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ trackId }),
      });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'playlists/addTrack failed');
      return r.json();
    },
    async removeTrack(id, trackId) {
      const r = await fetch(
        `/api/playlists/${encodeURIComponent(id)}/tracks/${encodeURIComponent(trackId)}`,
        { method: 'DELETE', credentials: 'include' }
      );
      if (!r.ok) throw new Error('playlists/removeTrack failed');
      return r.json();
    },
  },
  // Toggle a like on a track. Returns the new state so the client
  // can update its heart icon. Mirrored with the ingame app — same
  // st_music_likes row.
  async toggleLike(id) {
    const r = await fetch(`/api/submissions/${id}/like`, {
      method: 'POST', credentials: 'include',
    });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'like failed');
    return r.json();
  },
  // Fetch the current user's "Zuletzt gehört" + "Meine Likes" lists
  // in one round-trip. Used by the Profile screen.
  async library() {
    const r = await fetch('/api/me/library', { credentials: 'include' });
    if (!r.ok) throw new Error('library failed');
    return r.json();
  },
  // Just the set of liked track IDs — for rendering heart-icon
  // state on the Big Player + Library row without pulling the full
  // track data.
  async likedIds() {
    const r = await fetch('/api/me/likes', { credentials: 'include' });
    if (!r.ok) return { ids: [] };
    return r.json();
  },
  // Webpanel stream-count bump. Fires from audio-service once a
  // track has been audibly playing for 20+ seconds — mirrors the
  // ingame definition of "stream". The server applies cooldowns and
  // skips artist-self listens; this client side just fires and
  // forgets. Errors are swallowed because a missed bump isn't
  // worth interrupting playback for.
  async stream(id) {
    try {
      await fetch(`/api/submissions/${id}/stream`, {
        method: 'POST', credentials: 'include',
      });
    } catch { /* ignore */ }
  },
  // Admin-only cover-edit. Body is a FormData with field `cover`.
  async editCover(id, file) {
    const form = new FormData();
    form.append('cover', file);
    const r = await fetch(`/api/submissions/${id}/cover`, {
      method: 'POST', credentials: 'include', body: form,
    });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'cover update failed');
    return r.json();
  },
  async submit(form) {
    const r = await fetch('/api/submissions', { method: 'POST', credentials: 'include', body: form });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'submit failed');
    return r.json();
  },
  async vote(id, vote) {
    const r = await fetch(`/api/submissions/${id}/vote`, {
      method: 'POST', credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ vote }),
    });
    if (!r.ok) throw new Error('vote failed');
    return r.json();
  },
  async forceAccept(id) {
    const r = await fetch(`/api/submissions/${id}/force-accept`, { method: 'POST', credentials: 'include' });
    if (!r.ok) throw new Error('force-accept failed');
    return r.json();
  },
  async forceReject(id) {
    const r = await fetch(`/api/submissions/${id}/force-reject`, { method: 'POST', credentials: 'include' });
    if (!r.ok) throw new Error('force-reject failed');
    return r.json();
  },
  async copyrightSkip(id) {
    const r = await fetch(`/api/submissions/${id}/copyright-skip`, { method: 'POST', credentials: 'include' });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'copyright-skip failed');
    return r.json();
  },
  async blockUser(submissionId, reason) {
    const r = await fetch(`/api/submissions/${submissionId}/block-user`, {
      method: 'POST', credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ reason: reason || null }),
    });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'block-user failed');
    return r.json();
  },
  async deleteSubmission(id) {
    const r = await fetch(`/api/submissions/${id}`, { method: 'DELETE', credentials: 'include' });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'delete failed');
    return r.json();
  },
  async overrideStatus(id, status) {
    const r = await fetch(`/api/submissions/${id}/status`, {
      method: 'POST', credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ status }),
    });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'override failed');
    return r.json();
  },
  async editSubmission(id, fields) {
    const r = await fetch(`/api/submissions/${id}`, {
      method: 'PATCH', credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(fields),
    });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'edit failed');
    return r.json();
  },
  async regenerateLyrics(id) {
    const r = await fetch(`/api/submissions/${id}/regenerate-lyrics`, {
      method: 'POST', credentials: 'include',
    });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'lyrics regen failed');
    return r.json();
  },
  async regenerateWaveform(id) {
    const r = await fetch(`/api/submissions/${id}/regenerate-waveform`, {
      method: 'POST', credentials: 'include',
    });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'waveform regen failed');
    return r.json();
  },
  artist: {
    async getMe() {
      const r = await fetch('/api/artists/me', { credentials: 'include' });
      if (!r.ok) throw new Error('artist/me failed');
      return r.json();
    },
    // form is a FormData with fields { name, avatar? }.
    async putMe(form) {
      const r = await fetch('/api/artists/me', { method: 'PUT', credentials: 'include', body: form });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'profile save failed');
      return r.json();
    },
    // Admin Künstler page — paginated directory of every uploader /
    // profile / blocked player. Returns { items, total }.
    async list(params = {}) {
      const qs = new URLSearchParams();
      for (const [k, v] of Object.entries(params)) {
        if (v != null && v !== '') qs.set(k, String(v));
      }
      const r = await fetch(`/api/artists?${qs}`, { credentials: 'include' });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'artists list failed');
      return r.json();
    },
    // Deep info for a single artist: profile + every track + block.
    async detail(identifier) {
      const r = await fetch(`/api/artists/${encodeURIComponent(identifier)}/detail`,
        { credentials: 'include' });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'artist detail failed');
      return r.json();
    },
  },
  audit: {
    async list({ limit = 200, action, target, since } = {}) {
      const params = new URLSearchParams();
      if (limit)  params.set('limit', limit);
      if (action) params.set('action', action);
      if (target) params.set('target', target);
      if (since)  params.set('since', since);
      const r = await fetch(`/api/audit?${params}`, { credentials: 'include' });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'audit failed');
      return r.json();
    },
  },
  reports: {
    async list() {
      const r = await fetch('/api/reports', { credentials: 'include' });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'reports failed');
      return r.json();
    },
    async dismiss(id) {
      const r = await fetch(`/api/reports/${id}/dismiss`, { method: 'POST', credentials: 'include' });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'dismiss failed');
      return r.json();
    },
    async forceDelete(id) {
      const r = await fetch(`/api/reports/${id}/delete`, { method: 'POST', credentials: 'include' });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'delete failed');
      return r.json();
    },
  },
  async stats() {
    const r = await fetch('/api/stats', { credentials: 'include' });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'stats failed');
    return r.json();
  },
  blocks: {
    async list() {
      const r = await fetch('/api/blocks', { credentials: 'include' });
      if (!r.ok) throw new Error('blocks list failed');
      return r.json();
    },
    async add(identifier, reason, playerName) {
      const r = await fetch('/api/blocks', {
        method: 'POST', credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ identifier, reason: reason || null, playerName: playerName || null }),
      });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'add block failed');
      return r.json();
    },
    async remove(identifier) {
      const r = await fetch(`/api/blocks/${encodeURIComponent(identifier)}`, {
        method: 'DELETE', credentials: 'include',
      });
      if (!r.ok) throw new Error('remove block failed');
      return r.json();
    },
  },
  async logout() {
    await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
  },
};

window.Store = Store;
window.Api = Api;

// Likes — a tiny standalone store so the heart-icon state stays in
// sync across every screen without prop-drilling. Maintains a Set
// of liked track IDs; subscribers re-render when the set changes.
//
// Two flows feed this:
//   1. Boot — useLikes() lazy-loads from /api/me/likes the first
//      time a component subscribes (cached for the session).
//   2. Mutations — Likes.toggle(id) hits the API + optimistically
//      flips the local set so the heart UI feels instant.
const Likes = (() => {
  let ids = null;            // Set | null (null = not loaded)
  let loading = null;        // Promise | null
  const subs = new Set();
  const emit = () => subs.forEach(fn => fn(ids));

  async function ensureLoaded() {
    if (ids) return ids;
    if (loading) return loading;
    loading = (async () => {
      try {
        const { ids: arr } = await Api.likedIds();
        ids = new Set(arr || []);
      } catch {
        ids = new Set();   // fail open — empty likes is fine
      }
      loading = null;
      emit();
      return ids;
    })();
    return loading;
  }

  async function toggle(trackId) {
    await ensureLoaded();
    // Optimistic flip first so the heart animates instantly.
    const had = ids.has(trackId);
    if (had) ids.delete(trackId); else ids.add(trackId);
    emit();
    try {
      const { liked } = await Api.toggleLike(trackId);
      // Server is the source of truth — reconcile if mismatched
      // (network glitch, race, etc.).
      if (liked && !ids.has(trackId)) { ids.add(trackId);    emit(); }
      if (!liked && ids.has(trackId)) { ids.delete(trackId); emit(); }
    } catch (e) {
      // Roll back on error.
      if (had) ids.add(trackId); else ids.delete(trackId);
      emit();
      toast.error('Like fehlgeschlagen: ' + e.message);
    }
  }

  function has(trackId) {
    return ids ? ids.has(trackId) : false;
  }

  function subscribe(fn) { subs.add(fn); return () => subs.delete(fn); }
  function reset() { ids = null; loading = null; emit(); }

  return { ensureLoaded, toggle, has, subscribe, reset };
})();

window.Likes = Likes;

// Playlists store — light-weight cache of the user's playlists,
// invalidated whenever a mutation goes through. Backed by
// Api.playlists.* — same shape the ingame app reads. Two flows:
//   • Loaders (PlaylistsScreen, BigPlayer "add to" picker) read
//     via useMyPlaylists()
//   • Mutations call MyPlaylists.<verb>(…) which fires the API call
//     AND refreshes the cache so all listeners re-render.
const MyPlaylists = (() => {
  let list = null;        // Array | null
  let loading = null;
  const subs = new Set();
  const emit = () => subs.forEach(fn => fn(list));

  async function refresh() {
    list = await Api.playlists.list();
    emit();
    return list;
  }
  async function ensureLoaded() {
    if (list) return list;
    if (loading) return loading;
    loading = refresh().finally(() => { loading = null; });
    return loading;
  }

  // CRUD wrappers — fire-and-refresh.
  async function create(name) { await Api.playlists.create(name); return refresh(); }
  async function rename(id, name) { await Api.playlists.rename(id, name); return refresh(); }
  async function remove(id) { await Api.playlists.remove(id); return refresh(); }
  async function addTrack(id, trackId) {
    await Api.playlists.addTrack(id, trackId);
    // Updating the list bumps `updated_at` and `trackCount` — refresh
    // so the picker shows the latest counts.
    return refresh();
  }
  async function removeTrack(id, trackId) {
    await Api.playlists.removeTrack(id, trackId);
    return refresh();
  }

  function subscribe(fn) { subs.add(fn); return () => subs.delete(fn); }
  function reset() { list = null; loading = null; emit(); }
  function getList() { return list || []; }

  return {
    ensureLoaded, refresh, getList,
    create, rename, remove, addTrack, removeTrack,
    subscribe, reset,
  };
})();

window.MyPlaylists = MyPlaylists;

// React hook — returns the current playlist array (empty while
// the initial fetch is in flight) and re-renders subscribers when
// any mutation refreshes the cache.
function useMyPlaylists() {
  const [, force] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    MyPlaylists.ensureLoaded();
    return MyPlaylists.subscribe(force);
  }, []);
  return MyPlaylists.getList();
}
window.useMyPlaylists = useMyPlaylists;

// React hook — returns { isLiked, toggle, ready }. The first
// subscriber triggers the lazy load.
function useLikes() {
  const [, force] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    Likes.ensureLoaded();
    return Likes.subscribe(force);
  }, []);
  return {
    isLiked: (id) => Likes.has(id),
    toggle:  Likes.toggle,
  };
}
window.useLikes = useLikes;
