/**
 * Platform music.
 *
 * ## Autoplay does not work the way the word suggests
 *
 * Every current browser blocks audio *with sound* until the page has received
 * a real user gesture. Chrome will sometimes allow it on a site the user
 * visits constantly (its Media Engagement Index); Safari essentially never
 * does on a first visit. So `audio.play()` on mount returns a promise that
 * REJECTS, and a naive implementation looks like a bug: the toggle says
 * playing, nothing is audible, and the console has one line about it.
 *
 * The honest behaviour, and what this does: try immediately, and if the browser
 * refuses, arm a one-shot listener and start on the first click or keypress.
 * The effect is that music begins when someone actually starts using the app
 * rather than the instant a tab opens — which is also the kinder behaviour for
 * anyone who opened five tabs at once.
 *
 * ## Per-device, not per-account
 *
 * Stored in localStorage and deliberately not mirrored to the server, for the
 * same reason the sidebar collapse is not: whether you want music depends on
 * whether you are wearing headphones, not on who you are. Theme is mirrored;
 * this is not. See lib/layout.tsx.
 */
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ReactNode,
} from 'react';

export const PLATFORM_TRACKS = [
  { id: 'ambient', label: 'Ambient', src: '/audio/pig-ambient.mp3' },
  { id: 'jazz', label: 'Jazz', src: '/audio/pig-jazz.mp3' },
  { id: 'tech', label: 'Tech', src: '/audio/pig-tech.mp3' },
] as const;

export type PlatformTrackId = (typeof PLATFORM_TRACKS)[number]['id'];

const ENABLED_KEY = 'pig.audio.enabled';
const TRACK_KEY = 'pig.audio.track';

/**
 * Low, and deliberately so. The source files are mastered at about -26 LUFS
 * and this sits under them, because the failure mode of background music in a
 * tool someone has open for eight hours is not "too quiet".
 */
const VOLUME = 0.14;

/**
 * Ramp length. Music that snaps to full volume on the first click reads as a
 * glitch; a second of ramp reads as the room having been there all along.
 * Short enough that muting feels immediate.
 */
const FADE_IN_MS = 1100;
const FADE_OUT_MS = 420;

/**
 * Ease the element's volume, resolving when it arrives. Returns a canceller so
 * a fade that is superseded (mute pressed mid-fade-in) stops rather than
 * fighting the new one.
 */
function ramp(audio: HTMLAudioElement, to: number, ms: number): () => void {
  const from = audio.volume;
  const started = performance.now();
  let frame = 0;
  const step = () => {
    const t = Math.min(1, (performance.now() - started) / ms);
    // easeOutQuad: most of the travel early, so a fade-out feels prompt.
    audio.volume = Math.max(0, Math.min(1, from + (to - from) * (1 - (1 - t) * (1 - t))));
    if (t < 1) frame = requestAnimationFrame(step);
  };
  frame = requestAnimationFrame(step);
  return () => cancelAnimationFrame(frame);
}

function storedTrack(): PlatformTrackId {
  try {
    const raw = localStorage.getItem(TRACK_KEY);
    if (PLATFORM_TRACKS.some((track) => track.id === raw)) return raw as PlatformTrackId;
  } catch {
    /* Private browsing throws on localStorage. The default is fine. */
  }
  return 'ambient';
}

function storedEnabled(): boolean {
  try {
    // Default ON. An explicit "false" is the only thing that turns it off, so a
    // cleared storage does not silently change the product's character.
    return localStorage.getItem(ENABLED_KEY) !== 'false';
  } catch {
    return true;
  }
}

interface PlatformAudio {
  enabled: boolean;
  /** True only when sound is actually coming out — see the autoplay note. */
  playing: boolean;
  track: PlatformTrackId;
  toggle: () => void;
  setTrack: (id: PlatformTrackId) => void;
}

const Context = createContext<PlatformAudio | null>(null);

export function PlatformAudioProvider({ children }: { children: ReactNode }) {
  const [enabled, setEnabled] = useState(storedEnabled);
  const [track, setTrackState] = useState<PlatformTrackId>(storedTrack);
  const [playing, setPlaying] = useState(false);
  const elementRef = useRef<HTMLAudioElement | null>(null);

  const source = PLATFORM_TRACKS.find((entry) => entry.id === track) ?? PLATFORM_TRACKS[0];

  useEffect(() => {
    const audio = elementRef.current;
    if (!audio) return;

    if (!enabled) {
      const cancel = ramp(audio, 0, FADE_OUT_MS);
      const stop = window.setTimeout(() => {
        audio.pause();
        audio.currentTime = 0;
      }, FADE_OUT_MS);
      setPlaying(false);
      return () => {
        cancel();
        window.clearTimeout(stop);
      };
    }

    // Start silent so the ramp has somewhere to come from.
    audio.volume = 0;
    let cancelled = false;
    let armed: (() => void) | null = null;

    let cancelRamp: (() => void) | null = null;

    /*
     * Drive state from the ELEMENT'S OWN EVENTS, and always arm a gesture.
     *
     * Neither the play() promise nor a synchronous `paused` check is
     * trustworthy. Chrome rejects the promise when autoplay is blocked;
     * WebKit RESOLVES it, reports `paused === false` for an instant, and then
     * quietly pauses the element a moment later. Trusting either signal meant
     * Safari believed it was playing, faded the volume up on a silent element,
     * and never armed the gesture listener — so music never started on any
     * Apple device, while Chrome was fine.
     *
     * So: attempt playback, and ALSO arm a one-shot gesture listener
     * unconditionally. Calling play() on an element that is already playing is
     * a no-op, which makes the redundant case free and the broken case fixed.
     */
    const onPlaying = () => setPlaying(true);
    const onPause = () => setPlaying(false);
    audio.addEventListener('playing', onPlaying);
    audio.addEventListener('pause', onPause);

    const start = () =>
      audio
        .play()
        .then(() => {
          if (cancelled || audio.paused) return;
          cancelRamp?.();
          cancelRamp = ramp(audio, VOLUME, FADE_IN_MS);
        })
        .catch(() => {});

    void start();

    const events = ['pointerdown', 'touchend', 'click', 'keydown'] as const;
    const onGesture = () => {
      if (cancelled) return;
      void start();
      detach();
    };
    const detach = () => {
      for (const name of events) window.removeEventListener(name, onGesture, true);
    };
    for (const name of events) {
      window.addEventListener(name, onGesture, { capture: true, once: true });
    }

    return () => {
      cancelled = true;
      detach();
      cancelRamp?.();
      audio.removeEventListener('playing', onPlaying);
      audio.removeEventListener('pause', onPause);
    };
  }, [enabled, source.src]);

  // Pause when the tab is hidden. Music from a tab nobody is looking at is the
  // thing people hunt through twenty tabs to kill.
  useEffect(() => {
    const onVisibility = () => {
      const audio = elementRef.current;
      if (!audio || !enabled) return;
      if (document.hidden) {
        audio.pause();
        setPlaying(false);
      } else {
        void audio.play().then(() => setPlaying(true)).catch(() => {});
      }
    };
    document.addEventListener('visibilitychange', onVisibility);
    return () => document.removeEventListener('visibilitychange', onVisibility);
  }, [enabled]);

  useEffect(() => () => elementRef.current?.pause(), []);

  const toggle = useCallback(() => {
    setEnabled((current) => {
      const next = !current;
      try {
        localStorage.setItem(ENABLED_KEY, String(next));
      } catch {
        /* Nothing to do; the session still honours the choice. */
      }
      return next;
    });
  }, []);

  const setTrack = useCallback((id: PlatformTrackId) => {
    setTrackState(id);
    try {
      localStorage.setItem(TRACK_KEY, id);
    } catch {
      /* As above. */
    }
  }, []);

  const value = useMemo(
    () => ({ enabled, playing, track, toggle, setTrack }),
    [enabled, playing, track, toggle, setTrack],
  );

  return (
    <Context.Provider value={value}>
      {/*
        A real element in the tree rather than `new Audio()`, so it is
        inspectable in devtools and in a test. `preload="none"` keeps a user
        who never enables music from downloading a track.
      */}
      <audio
        ref={elementRef}
        src={source.src}
        loop
        preload="none"
        aria-hidden
        className="hidden"
      />
      {children}
    </Context.Provider>
  );
}

/** Null outside the provider, so the anonymous Learn page stays silent. */
export function usePlatformAudio(): PlatformAudio | null {
  return useContext(Context);
}
