import React, {
  createContext,
  useContext,
  useState,
  useEffect,
  useMemo,
  useRef,
} from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { File, Paths } from "expo-file-system";
import { createAudioPlayer, AudioPlayer, setAudioModeAsync } from "expo-audio";
import { SERVER_ORIGIN, API_BASE_URL } from "../config";
import { authFetch } from "../lib/apiFetch";
import { useEntitlement } from "./EntitlementContext";
import { useServiceStatus } from "./ServiceStatusContext";

export interface MediaItem {
  id: string;
  title: string;
  uri: string;
  type: "video" | "audio";
  tags: string[];
  fileSize?: number;
  /** Remote YouTube thumbnail URL, if known. */
  thumbnail?: string | null;
  /** Uploader/channel name, if known. */
  channel?: string | null;
  /** Original YouTube watch URL, for "open in YouTube". */
  webpageUrl?: string | null;
  durationSeconds?: number | null;
  /** Epoch ms when this item finished downloading to the device. Absent on items downloaded before this field existed. */
  downloadedAt?: number;
}

/** Metadata captured from /api/info at download time, carried through to the finished MediaItem. */
export interface DownloadMetadata {
  title?: string;
  thumbnail?: string | null;
  channel?: string | null;
  webpageUrl?: string | null;
  durationSeconds?: number | null;
}

export interface Playlist {
  id: string;
  name: string;
  mediaIds: string[];
  /** Epoch ms when this playlist was created. Absent on playlists created before this field existed. */
  createdAt?: number;
  /** Epoch ms of the last time playMedia() was called with this playlist's id — drives the "recently played" sort. Absent if never played. */
  lastPlayedAt?: number;
}

/** A download that has been queued/started on the server but hasn't finished saving to the device yet — shown in the list immediately for instant feedback. */
export interface PendingDownload {
  jobId: string;
  title: string;
  thumbnail?: string | null;
  channel?: string | null;
  /** Original YouTube watch URL this job was started from — lets a caller (e.g. the in-app browser) match a job back to the video it's downloading. */
  webpageUrl?: string | null;
  type: "video" | "audio";
  status: "queued" | "processing";
}

export type RepeatMode = "off" | "all" | "one";

interface MediaContextType {
  downloads: MediaItem[];
  /** Downloads in flight — server job created, not yet saved to the device. */
  pendingDownloads: PendingDownload[];
  activeTrack: MediaItem | null;
  /** The playlist the current queue came from, if any — lets the Player screen's filter chip stay in sync with what's actually playing. Null for an ad-hoc queue (e.g. played straight from Downloads). */
  activePlaylistId: string | null;
  isPlaying: boolean;
  /** Audio-only playback position; null for video (Player screen tracks that itself) or when nothing audio is loaded. */
  audioProgress: { currentTime: number; duration: number } | null;
  playlists: Playlist[];
  /** The ordered list of tracks currently being navigated via next/previous. */
  queue: MediaItem[];
  /** Tracks after the current one, in play order (respects shuffle). */
  upcomingQueue: MediaItem[];
  shuffleEnabled: boolean;
  repeatMode: RepeatMode;
  searchAndDownload: (
    url: string,
    formatId: string,
    meta?: DownloadMetadata,
  ) => Promise<void>;
  /** Plays `item`. If `queue` is given, next/previous/shuffle/repeat will navigate that list. Pass `playlistId` when `queue` came from a playlist so the Player screen's filter can stay in sync; omit/pass null for an ad-hoc queue. */
  playMedia: (
    item: MediaItem,
    queue?: MediaItem[],
    playlistId?: string | null,
  ) => Promise<void>;
  togglePlayPause: () => void;
  seekAudioTo: (seconds: number) => void;
  /** True while the active video track is actually playing. Mirrors `isPlaying` (audio), but video's native player instance is owned by the Player screen, not this context — that screen reports state changes in via `reportVideoPlaying`. */
  videoPlaying: boolean;
  reportVideoPlaying: (playing: boolean) => void;
  /** Bumped whenever requestPause() is called while a video is active — the Player screen's video component (the actual player owner) watches this and pauses itself. Audio is paused directly since this context owns that player. */
  videoPauseToken: number;
  /** Same idea as videoPauseToken, for requestResume(). */
  videoResumeToken: number;
  /** Pauses whatever's currently playing (audio or video) — for screens like the in-app YouTube browser that want playback out of the way while they're open. No-op if nothing is playing. */
  requestPause: () => void;
  /** Resumes whatever requestPause() paused. Callers are expected to only call this when they know they were the one who paused it (e.g. track "was this playing when I opened" themselves) — this doesn't check. */
  requestResume: () => void;
  playNext: () => void;
  playPrevious: () => void;
  toggleShuffle: () => void;
  cycleRepeatMode: () => void;
  /** The video player (owned by the Player screen) calls this when it reaches the end of its source. */
  notifyTrackEnded: () => void;
  deleteMedia: (id: string) => Promise<void>;
  clearAllMedia: () => Promise<void>;
  createPlaylist: (name: string) => void;
  deletePlaylist: (id: string) => void;
  addToPlaylist: (playlistId: string, mediaId: string) => void;
  removeFromPlaylist: (playlistId: string, mediaId: string) => void;
}

function deleteFileIfExists(uri: string) {
  try {
    const file = new File(uri);
    if (file.exists) file.delete();
  } catch (e) {
    console.warn("Failed to delete local file", uri, e);
  }
}

/**
 * `MediaItem.uri` is stored as just a filename (not an absolute path) —
 * resolved against the CURRENT app container's Documents directory here,
 * at read time. This is what makes playback survive a container path
 * change (device restore, app reinstall, or a Simulator dev rebuild) as
 * long as the file itself is still present: an absolute `file://` URI
 * bakes in a container UUID that can change without warning, so storing
 * one directly (the old behavior) meant every stored item broke at once
 * the moment that happened, with no way to recover the file's real
 * location from the stale path alone.
 *
 * Also handles reading OLDER items that still have a full absolute URI
 * persisted from before this fix — extracts just the filename from those
 * so they keep resolving too, rather than needing a one-time migration.
 */
export function resolveMediaUri(stored: string): string {
  const fileName = stored.includes("/") ? stored.split("/").pop()! : stored;
  return new File(Paths.document, fileName).uri;
}

/** Fisher-Yates shuffle of [0..count-1], with `keepFirst` forced to the front so the currently playing track doesn't jump. */
function shuffledIndices(count: number, keepFirst: number): number[] {
  const rest = Array.from({ length: count }, (_, i) => i).filter(
    (i) => i !== keepFirst,
  );
  for (let i = rest.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [rest[i], rest[j]] = [rest[j], rest[i]];
  }
  return keepFirst >= 0 && keepFirst < count ? [keepFirst, ...rest] : rest;
}

const MediaContext = createContext<MediaContextType | null>(null);

export const MediaProvider = ({ children }: { children: React.ReactNode }) => {
  const { openBuyModal } = useEntitlement();
  const { refreshServiceStatus } = useServiceStatus();
  const [downloads, setDownloads] = useState<MediaItem[]>([]);
  const [pendingDownloads, setPendingDownloads] = useState<PendingDownload[]>(
    [],
  );
  const [playlists, setPlaylists] = useState<Playlist[]>([]);
  const [activeTrack, setActiveTrack] = useState<MediaItem | null>(null);
  const [activePlaylistId, setActivePlaylistId] = useState<string | null>(null);
  const [isPlaying, setIsPlaying] = useState(false);
  const [videoPlaying, setVideoPlaying] = useState(false);
  const [videoPauseToken, setVideoPauseToken] = useState(0);
  const [videoResumeToken, setVideoResumeToken] = useState(0);
  // Audio-only progress (seconds). Video reports its own via the Player
  // screen's local VideoContainer state, since it isn't driven from here.
  const [audioProgress, setAudioProgress] = useState<{
    currentTime: number;
    duration: number;
  } | null>(null);

  // --- Queue / shuffle / repeat state ---
  const [queue, setQueue] = useState<MediaItem[]>([]);
  const [queuePosition, setQueuePosition] = useState(-1); // index into `order`, not `queue`
  const [order, setOrder] = useState<number[]>([]); // permutation of queue indices; identity when shuffle is off
  const [shuffleEnabled, setShuffleEnabled] = useState(false);
  const [repeatMode, setRepeatMode] = useState<RepeatMode>("off");

  // The audio player's event listener is attached once per track and would
  // otherwise close over whatever these were at that moment — mirror
  // everything imperative code needs into refs so it always reads live
  // values, no matter how long the listener has been attached.
  const playerRef = useRef<AudioPlayer | null>(null);
  // Bumped every time playItemInternal swaps the active player. A player's
  // native teardown (remove()) isn't guaranteed synchronous, so its JS
  // listeners can still receive one more late event after a *newer* player
  // has already taken over — rapid next/prev taps were the easiest way to
  // hit this. Listeners capture the epoch current when THEY were created and
  // compare against this ref before acting, so a stale event from a
  // superseded player is a no-op instead of corrupting state (flipping
  // isPlaying back off, or double-advancing the queue) for whatever's
  // actually playing now.
  const playbackEpochRef = useRef(0);
  const queueRef = useRef(queue);
  const orderRef = useRef(order);
  const queuePositionRef = useRef(queuePosition);
  const repeatModeRef = useRef(repeatMode);
  useEffect(() => {
    queueRef.current = queue;
  }, [queue]);
  useEffect(() => {
    orderRef.current = order;
  }, [order]);
  useEffect(() => {
    queuePositionRef.current = queuePosition;
  }, [queuePosition]);
  useEffect(() => {
    repeatModeRef.current = repeatMode;
  }, [repeatMode]);

  const upcomingQueue = useMemo(() => {
    if (queuePosition < 0 || queuePosition >= order.length) return [];
    return order.slice(queuePosition + 1).map((idx) => queue[idx]);
  }, [queue, order, queuePosition]);

  // Guards against a real data-loss bug: if anything writes to `downloads`
  // (via updateMediaState) before the initial AsyncStorage read finishes,
  // that write's `prev` is still the component's empty initial state, and
  // persisting it stomps the real saved data with `[]`. Every write path
  // awaits this same cached promise first, so it always runs after the
  // real data (if any) has been loaded into state — no matter which of the
  // two fires first in practice.
  const initialMediaLoadRef = useRef<Promise<void> | null>(null);
  const ensureMediaLoaded = () => {
    if (!initialMediaLoadRef.current) {
      initialMediaLoadRef.current = (async () => {
        try {
          const stored = await AsyncStorage.getItem("@media_items");
          if (stored) setDownloads(JSON.parse(stored));
        } catch (e) {
          console.error("Failed to load local media", e);
        }
      })();
    }
    return initialMediaLoadRef.current;
  };

  // Same race-condition guard as ensureMediaLoaded, for playlists.
  const initialPlaylistsLoadRef = useRef<Promise<void> | null>(null);
  const ensurePlaylistsLoaded = () => {
    if (!initialPlaylistsLoadRef.current) {
      initialPlaylistsLoadRef.current = (async () => {
        try {
          const stored = await AsyncStorage.getItem("@playlists");
          if (stored) setPlaylists(JSON.parse(stored));
        } catch (e) {
          console.error("Failed to load playlists", e);
        }
      })();
    }
    return initialPlaylistsLoadRef.current;
  };

  useEffect(() => {
    // Configure background execution for expo-audio
    setAudioModeAsync({
      playsInSilentMode: true,
      shouldPlayInBackground: true,
      interruptionMode: "doNotMix",
    });

    ensureMediaLoaded();
    ensurePlaylistsLoaded();
  }, []);

  /** Applies a functional update to downloads and persists the result, avoiding stale-closure overwrites when multiple downloads finish concurrently. */
  const updateMediaState = async (
    updater: (items: MediaItem[]) => MediaItem[],
  ) => {
    // Never let a write race ahead of the initial load — see ensureMediaLoaded.
    await ensureMediaLoaded();
    let next: MediaItem[] = [];
    setDownloads((prev) => {
      next = updater(prev);
      return next;
    });
    await AsyncStorage.setItem("@media_items", JSON.stringify(next));
  };

  const savePlaylistState = async (items: Playlist[]) => {
    setPlaylists(items);
    await AsyncStorage.setItem("@playlists", JSON.stringify(items));
  };

  /** Downloads the finished file to the device and adds it to the library. */
  const finishDownload = async (
    statusData: any,
    sourceUrl: string,
    meta?: DownloadMetadata,
  ) => {
    // The server returns an absolute downloadUrl built from ITS OWN
    // PUBLIC_BASE_URL, which may not be reachable from this device (e.g. a
    // physical phone can't resolve the server's "localhost"). Build the URL
    // from the same SERVER_ORIGIN this client already used to reach the
    // server, instead of trusting the server's value — client and server
    // only need to agree on the file name.
    const remoteUrl = `${SERVER_ORIGIN}/downloads/${encodeURIComponent(statusData.result.fileName)}`;
    const destination = new File(Paths.document, statusData.result.fileName);
    const localFile = await File.downloadFileAsync(remoteUrl, destination, {
      idempotent: true,
    });

    const newItem: MediaItem = {
      id: statusData.jobId,
      title: meta?.title || statusData.result.fileName,
      // Just the filename, not the full absolute path — see resolveMediaUri
      // for why. `localFile.name` already gives us that.
      uri: localFile.name,
      type: statusData.type === "audio" ? "audio" : "video",
      tags: [],
      fileSize: statusData.result.fileSize,
      thumbnail: meta?.thumbnail ?? null,
      channel: meta?.channel ?? null,
      webpageUrl: meta?.webpageUrl ?? sourceUrl,
      durationSeconds: meta?.durationSeconds ?? null,
      downloadedAt: Date.now(),
    };

    updateMediaState((items) => [...items, newItem]);

    // Best-effort: tell the server it can delete its copy now that the file
    // is safely on-device. Never blocks or fails the download on the FE's
    // behalf — the local file is already saved either way.
    authFetch(`${API_BASE_URL}/jobs/${statusData.jobId}`, { method: "DELETE" }).catch(
      () => {},
    );
  };

  const searchAndDownload = async (
    url: string,
    formatId: string,
    meta?: DownloadMetadata,
  ) => {
    const type: "video" | "audio" = formatId.startsWith("audio-")
      ? "audio"
      : "video";

    try {
      const res = await authFetch(`${API_BASE_URL}/download`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        // `meta` is display-only (title/channel/thumbnail/etc, already
        // fetched from a prior /api/info call) — the server stores it
        // alongside the download for the account-wide History screen.
        body: JSON.stringify({ url, formatId, meta }),
      });
      const data = await res.json();
      if (!res.ok) {
        if (data.code === "PAYMENT_REQUIRED") {
          // Out of free downloads and credits — go straight to the
          // buy-credits modal rather than leaving the user at a dead-end
          // alert with nowhere to act on it.
          openBuyModal();
          return;
        }
        throw new Error(data.error);
      }

      const jobId: string = data.jobId;
      setPendingDownloads((prev) => [
        ...prev,
        {
          jobId,
          title: meta?.title || url,
          thumbnail: meta?.thumbnail ?? null,
          channel: meta?.channel ?? null,
          webpageUrl: meta?.webpageUrl ?? url,
          type,
          status: "queued",
        },
      ]);

      const pollInterval = setInterval(async () => {
        try {
          // data.statusUrl already starts with /api, so it's joined onto the server origin, not API_BASE_URL.
          const statusRes = await authFetch(`${SERVER_ORIGIN}${data.statusUrl}`);
          const statusData = await statusRes.json();

          if (statusData.status === "completed") {
            clearInterval(pollInterval);
            await finishDownload(statusData, url, meta);
            setPendingDownloads((prev) => prev.filter((p) => p.jobId !== jobId));
          } else if (statusData.status === "failed") {
            clearInterval(pollInterval);
            setPendingDownloads((prev) => prev.filter((p) => p.jobId !== jobId));
            // A real yt-dlp failure just happened — this is the actual
            // signal healthMonitor cares about, so re-check status right
            // away rather than waiting for the next screen focus.
            refreshServiceStatus();
            alert(`Download failed: ${statusData.error}`);
          } else if (statusData.status === "processing") {
            setPendingDownloads((prev) =>
              prev.map((p) =>
                p.jobId === jobId ? { ...p, status: "processing" } : p,
              ),
            );
          }
        } catch (pollErr: any) {
          // A poll tick that throws (network hiccup, or the on-device save
          // itself failing) would otherwise retry forever with the error
          // silently swallowed inside the interval — surface it and stop.
          clearInterval(pollInterval);
          setPendingDownloads((prev) => prev.filter((p) => p.jobId !== jobId));
          alert(
            `Download failed: ${pollErr?.message || "lost connection to the server while downloading"}`,
          );
        }
      }, 3000);
    } catch (err: any) {
      refreshServiceStatus();
      alert(err.message || "Failed to trigger download");
    }
  };

  /** Core player-swap logic, shared by playMedia/advanceQueue. Reads/writes only refs + activeTrack/isPlaying state — no queue math here. */
  const playItemInternal = async (item: MediaItem) => {
    // resolveMediaUri already accounts for the app's sandbox container path
    // changing (reinstall, rebuild, device restore) — this check is for the
    // rarer case where the file itself is genuinely gone (e.g. deleted
    // outside the app, or storage was cleared). Without it, the player
    // would optimistically flip to "playing" and then get immediately
    // reverted by the native status listener reporting playing:false, with
    // no indication to the user of what went wrong.
    if (!new File(resolveMediaUri(item.uri)).exists) {
      alert(
        `"${item.title}" is missing on this device — this usually happens after the app is reinstalled or rebuilt. Delete it and download it again.`,
      );
      return;
    }
    // Claim a fresh epoch for whatever player this call ends up creating,
    // before starting teardown of the old one — see playbackEpochRef.
    const myEpoch = ++playbackEpochRef.current;

    if (playerRef.current) {
      // Pausing explicitly before remove() matters: remove()'s native
      // teardown isn't guaranteed to be synchronous (confirmed on a real
      // device build — under Expo Go, setActiveForLockScreen throws and is
      // swallowed below, so this native-session gap never showed up there).
      // Without the explicit pause, the outgoing track could keep outputting
      // audio for a moment while the new one already started, i.e. both
      // playing at once.
      try {
        playerRef.current.pause();
      } catch {
        // no-op
      }
      try {
        playerRef.current.clearLockScreenControls();
      } catch {
        // no-op: not available in this build
      }
      playerRef.current.remove();
      playerRef.current = null;
    }
    setAudioProgress(null);

    if (item.type === "audio") {
      const newPlayer = createAudioPlayer({ uri: resolveMediaUri(item.uri) });

      newPlayer.addListener("playbackStatusUpdate", (status) => {
        if (playbackEpochRef.current !== myEpoch) return; // stale event from a superseded player
        setIsPlaying(status.playing);
        setAudioProgress({
          currentTime: status.currentTime,
          duration: status.duration,
        });
        if (status.didJustFinish) {
          if (repeatModeRef.current === "one") {
            newPlayer.seekTo(0);
            newPlayer.play();
          } else {
            advanceQueueRef.current(1);
          }
        }
      });

      // expo-audio has no native concept of a playlist/queue (patched in
      // locally — see patches/expo-audio@*.patch and
      // docs/LOCK_SCREEN_NEXT_PREV.md), so these just bridge the lock-screen
      // button presses back to this app's own queue navigation.
      newPlayer.addListener("onRemoteNextTrack", () => {
        if (playbackEpochRef.current !== myEpoch) return;
        advanceQueueRef.current(1);
      });
      newPlayer.addListener("onRemotePreviousTrack", () => {
        if (playbackEpochRef.current !== myEpoch) return;
        advanceQueueRef.current(-1);
      });

      try {
        newPlayer.setActiveForLockScreen(
          true,
          {
            title: item.title,
            artist: item.channel ?? undefined,
            artworkUrl: item.thumbnail ?? undefined,
          },
          { showNextTrack: true, showPreviousTrack: true },
        );
      } catch {
        // no-op: not available in this build (e.g. Expo Go)
      }

      newPlayer.play();
      playerRef.current = newPlayer;
    }
    // Video playback is handled by the VideoView/useVideoPlayer instance
    // rendered in the player screen, keyed off activeTrack — no audio
    // player is created here so the two don't fight over playback state.

    setActiveTrack(item);
    setIsPlaying(true);
  };

  const playMedia = async (
    item: MediaItem,
    newQueue?: MediaItem[],
    playlistId?: string | null,
  ) => {
    const q = newQueue && newQueue.length > 0 ? newQueue : [item];
    const idx = q.findIndex((i) => i.id === item.id);
    const safeIdx = idx >= 0 ? idx : 0;
    const newOrder = shuffleEnabled
      ? shuffledIndices(q.length, safeIdx)
      : q.map((_, i) => i);
    const pos = newOrder.indexOf(safeIdx);

    setQueue(q);
    setOrder(newOrder);
    setQueuePosition(pos);
    setActivePlaylistId(playlistId ?? null);
    if (playlistId) {
      savePlaylistState(
        playlists.map((p) =>
          p.id === playlistId ? { ...p, lastPlayedAt: Date.now() } : p,
        ),
      );
    }
    // Refs are normally synced by the mirroring effects on the next render,
    // but advanceQueue can run synchronously right after this (e.g. rapid
    // next-track taps) before that render happens — set them eagerly too.
    queueRef.current = q;
    orderRef.current = newOrder;
    queuePositionRef.current = pos;

    await playItemInternal(item);
  };

  /** Moves `delta` steps through the current play order (respecting repeat), using only refs so it's safe to call from long-lived listeners. delta is +1 for next, -1 for previous. */
  const advanceQueue = async (delta: 1 | -1) => {
    const q = queueRef.current;
    const ord = orderRef.current;
    if (q.length === 0 || queuePositionRef.current < 0) return;
    let pos = queuePositionRef.current + delta;

    if (pos >= ord.length) {
      if (repeatModeRef.current === "all") pos = 0;
      else {
        // End of queue with repeat off: stop playback rather than looping silently.
        playerRef.current?.pause();
        setIsPlaying(false);
        return;
      }
    } else if (pos < 0) {
      pos = repeatModeRef.current === "all" ? ord.length - 1 : 0;
    }

    const item = q[ord[pos]];
    if (!item) return;
    setQueuePosition(pos);
    queuePositionRef.current = pos;
    await playItemInternal(item);
  };

  // playItemInternal's audio listener is created once per track and needs to
  // call the *current* advanceQueue implementation; route through a ref so
  // it always calls the latest closure instead of whichever one existed
  // when that particular listener was attached.
  const advanceQueueRef = useRef(advanceQueue);
  useEffect(() => {
    advanceQueueRef.current = advanceQueue;
  });

  const playNext = () => {
    advanceQueue(1);
  };
  const playPrevious = () => {
    advanceQueue(-1);
  };

  const toggleShuffle = () => {
    setShuffleEnabled((prev) => {
      const next = !prev;
      if (queue.length > 0 && queuePosition >= 0) {
        const currentIdx = order[queuePosition];
        const newOrder = next
          ? shuffledIndices(queue.length, currentIdx)
          : queue.map((_, i) => i);
        setOrder(newOrder);
        const newPos = newOrder.indexOf(currentIdx);
        setQueuePosition(newPos);
        orderRef.current = newOrder;
        queuePositionRef.current = newPos;
      }
      return next;
    });
  };

  const cycleRepeatMode = () => {
    setRepeatMode((prev) =>
      prev === "off" ? "all" : prev === "all" ? "one" : "off",
    );
  };

  /** The video player (owned by the Player screen) calls this when it reaches the end of its source. Repeat-one is handled by the video screen itself (it just replays); this only needs to advance for 'off'/'all'. */
  const notifyTrackEnded = () => {
    if (repeatModeRef.current === "one") return;
    advanceQueue(1);
  };

  const togglePlayPause = () => {
    if (!playerRef.current) return;
    if (isPlaying) {
      playerRef.current.pause();
    } else {
      playerRef.current.play();
    }
  };

  const reportVideoPlaying = (playing: boolean) => setVideoPlaying(playing);

  const requestPause = () => {
    if (activeTrack?.type === "video") {
      setVideoPauseToken((n) => n + 1);
    } else if (isPlaying) {
      playerRef.current?.pause();
    }
  };

  const requestResume = () => {
    if (activeTrack?.type === "video") {
      setVideoResumeToken((n) => n + 1);
    } else {
      playerRef.current?.play();
    }
  };

  const seekAudioTo = (seconds: number) => {
    playerRef.current?.seekTo(seconds);
  };

  const normalizeName = (raw: string) => raw.trim().replace(/\s+/g, " ");

  const deleteMedia = async (id: string) => {
    const target = downloads.find((d) => d.id === id);
    if (target) {
      deleteFileIfExists(resolveMediaUri(target.uri));
      if (activeTrack?.id === id) {
        if (playerRef.current) {
          playerRef.current.remove();
          playerRef.current = null;
        }
        setActiveTrack(null);
        setActivePlaylistId(null);
        setIsPlaying(false);
        setAudioProgress(null);
      }
      updateMediaState((items) => items.filter((d) => d.id !== id));
      savePlaylistState(
        playlists.map((p) => ({
          ...p,
          mediaIds: p.mediaIds.filter((mid) => mid !== id),
        })),
      );
    }
  };

  const clearAllMedia = async () => {
    if (playerRef.current) {
      playerRef.current.remove();
      playerRef.current = null;
    }
    setActiveTrack(null);
    setActivePlaylistId(null);
    setIsPlaying(false);
    setAudioProgress(null);
    setQueue([]);
    setOrder([]);
    setQueuePosition(-1);
    for (const item of downloads) deleteFileIfExists(resolveMediaUri(item.uri));
    await updateMediaState(() => []);
    await savePlaylistState([]);
  };

  const createPlaylist = (name: string) => {
    const clean = normalizeName(name);
    if (!clean) return;
    const newPlaylist: Playlist = {
      id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
      name: clean,
      mediaIds: [],
      createdAt: Date.now(),
    };
    savePlaylistState([...playlists, newPlaylist]);
  };

  const deletePlaylist = (id: string) => {
    savePlaylistState(playlists.filter((p) => p.id !== id));
  };

  const addToPlaylist = (playlistId: string, mediaId: string) => {
    savePlaylistState(
      playlists.map((p) =>
        p.id === playlistId && !p.mediaIds.includes(mediaId)
          ? { ...p, mediaIds: [...p.mediaIds, mediaId] }
          : p,
      ),
    );
  };

  const removeFromPlaylist = (playlistId: string, mediaId: string) => {
    savePlaylistState(
      playlists.map((p) =>
        p.id === playlistId
          ? { ...p, mediaIds: p.mediaIds.filter((id) => id !== mediaId) }
          : p,
      ),
    );
  };

  return (
    <MediaContext.Provider
      value={{
        downloads,
        pendingDownloads,
        activeTrack,
        activePlaylistId,
        isPlaying,
        audioProgress,
        playlists,
        queue,
        upcomingQueue,
        shuffleEnabled,
        repeatMode,
        searchAndDownload,
        playMedia,
        togglePlayPause,
        seekAudioTo,
        videoPlaying,
        reportVideoPlaying,
        videoPauseToken,
        videoResumeToken,
        requestPause,
        requestResume,
        playNext,
        playPrevious,
        toggleShuffle,
        cycleRepeatMode,
        notifyTrackEnded,
        deleteMedia,
        clearAllMedia,
        createPlaylist,
        deletePlaylist,
        addToPlaylist,
        removeFromPlaylist,
      }}
    >
      {children}
    </MediaContext.Provider>
  );
};

export const useMedia = () => useContext(MediaContext)!;
