///|
/// Audio subsystem contracts.
///
/// Reference:
/// - Ebiten audio package (Context, Player, stream)
/// - Web Audio API / native audio backends

///|
pub struct AudioFormat {
  sample_rate : Int
  channels : Int
  bits_per_sample : Int
} derive(Debug)

///|
pub impl Show for AudioFormat with output(self, logger) {
  logger.write_object(to_repr(self))
}

///|
pub fn default_audio_format() -> AudioFormat {
  { sample_rate: 44100, channels: 2, bits_per_sample: 16 }
}

///|
pub fn cd_quality_format() -> AudioFormat {
  { sample_rate: 44100, channels: 2, bits_per_sample: 16 }
}

///|
pub fn mono_format(sample_rate : Int) -> AudioFormat {
  { sample_rate, channels: 1, bits_per_sample: 16 }
}

///|
pub fn pcm16_format(sample_rate : Int, channels : Int) -> AudioFormat {
  { sample_rate, channels, bits_per_sample: 16 }
}

///|
pub fn bytes_per_sample(format : AudioFormat) -> Int {
  format.bits_per_sample / 8 * format.channels
}

///|
pub fn samples_to_bytes(format : AudioFormat, sample_count : Int) -> Int {
  sample_count * bytes_per_sample(format)
}

///|
pub fn bytes_to_samples(format : AudioFormat, byte_count : Int) -> Int {
  let bps = bytes_per_sample(format)
  if bps <= 0 {
    0
  } else {
    byte_count / bps
  }
}

///|
pub fn duration_to_samples(format : AudioFormat, seconds : Double) -> Int {
  (format.sample_rate.to_double() * seconds).to_int()
}

///|
pub fn samples_to_duration(format : AudioFormat, sample_count : Int) -> Double {
  if format.sample_rate <= 0 {
    0.0
  } else {
    sample_count.to_double() / format.sample_rate.to_double()
  }
}

///|
pub enum PlayerState {
  Stopped
  Playing
  Paused
} derive(Debug)

///|
pub impl Show for PlayerState with output(self, logger) {
  logger.write_object(to_repr(self))
}

///|
pub(all) struct PlayerId {
  value : Int
} derive(Debug)

///|
pub impl Show for PlayerId with output(self, logger) {
  logger.write_object(to_repr(self))
}

///|
pub struct AudioClip {
  format : AudioFormat
  data : Bytes
  loop_ : Bool
} derive(Debug)

///|
pub impl Show for AudioClip with output(self, logger) {
  logger.write_object(to_repr(self))
}

///|
pub fn new_audio_clip(
  format : AudioFormat,
  data : Bytes,
  loop_ : Bool,
) -> AudioClip {
  { format, data, loop_ }
}

///|
pub fn clip_sample_count(clip : AudioClip) -> Int {
  bytes_to_samples(clip.format, clip.data.length())
}

///|
pub fn clip_duration(clip : AudioClip) -> Double {
  samples_to_duration(clip.format, clip_sample_count(clip))
}

///|
pub trait AudioStream {
  /// Read PCM samples into the buffer. Returns number of bytes read.
  read(Self, buf : FixedArray[Byte], length : Int) -> Int
  /// Total length in bytes, or -1 if unknown (e.g., streaming).
  content_length(Self) -> Int
}

///|
pub trait AudioContext {
  /// Create a player for the given clip.
  create_player(Self, clip : AudioClip) -> PlayerId raise
  /// Start playback.
  play(Self, id : PlayerId) -> Unit raise
  /// Pause playback.
  pause(Self, id : PlayerId) -> Unit raise
  /// Stop and rewind.
  stop(Self, id : PlayerId) -> Unit raise
  /// Get current state.
  state(Self, id : PlayerId) -> PlayerState
  /// Set volume (0.0 to 1.0).
  set_volume(Self, id : PlayerId, volume : Double) -> Unit
  /// Get current playback position in seconds.
  current_position(Self, id : PlayerId) -> Double
  /// Seek to position in seconds.
  seek(Self, id : PlayerId, position : Double) -> Unit raise
  /// Set stereo pan (-1.0 left, 0.0 center, 1.0 right).
  set_pan(Self, id : PlayerId, pan : Double) -> Unit
  /// Dispose player.
  dispose(Self, id : PlayerId) -> Unit
}

///|
pub struct SimpleAudioPlayer {
  id : PlayerId
  clip : AudioClip
  mut state : PlayerState
  mut volume : Double
  mut position_samples : Int
}

///|
pub struct SimpleAudioContext {
  mut players : Array[SimpleAudioPlayer]
  mut next_id : Int
  format : AudioFormat
}

///|
pub fn SimpleAudioContext::new(format : AudioFormat) -> SimpleAudioContext {
  { players: [], next_id: 1, format }
}

///|
fn SimpleAudioContext::find_player(
  self : SimpleAudioContext,
  id : PlayerId,
) -> SimpleAudioPlayer? {
  for player in self.players {
    if player.id.value == id.value {
      return Some(player)
    }
  }
  None
}

///|
pub impl AudioContext for SimpleAudioContext with create_player(self, clip) {
  let id : PlayerId = { value: self.next_id }
  self.next_id = self.next_id + 1
  self.players.push({
    id,
    clip,
    state: PlayerState::Stopped,
    volume: 1.0,
    position_samples: 0,
  })
  id
}

///|
pub impl AudioContext for SimpleAudioContext with play(self, id) {
  match self.find_player(id) {
    Some(player) => player.state = PlayerState::Playing
    None => ()
  }
}

///|
pub impl AudioContext for SimpleAudioContext with pause(self, id) {
  match self.find_player(id) {
    Some(player) => player.state = PlayerState::Paused
    None => ()
  }
}

///|
pub impl AudioContext for SimpleAudioContext with stop(self, id) {
  match self.find_player(id) {
    Some(player) => {
      player.state = PlayerState::Stopped
      player.position_samples = 0
    }
    None => ()
  }
}

///|
pub impl AudioContext for SimpleAudioContext with state(self, id) {
  match self.find_player(id) {
    Some(player) => player.state
    None => PlayerState::Stopped
  }
}

///|
pub impl AudioContext for SimpleAudioContext with set_volume(self, id, volume) {
  match self.find_player(id) {
    Some(player) =>
      player.volume = if volume < 0.0 {
        0.0
      } else if volume > 1.0 {
        1.0
      } else {
        volume
      }
    None => ()
  }
}

///|
pub impl AudioContext for SimpleAudioContext with set_pan(_self, _id, _pan) {
  // SimpleAudioContext does not support stereo panning
}

///|
pub impl AudioContext for SimpleAudioContext with current_position(self, id) {
  match self.find_player(id) {
    Some(player) =>
      samples_to_duration(player.clip.format, player.position_samples)
    None => 0.0
  }
}

///|
pub impl AudioContext for SimpleAudioContext with seek(self, id, position) {
  match self.find_player(id) {
    Some(player) =>
      player.position_samples = duration_to_samples(
        player.clip.format,
        position,
      )
    None => ()
  }
}

///|
pub impl AudioContext for SimpleAudioContext with dispose(self, id) {
  self.players = self.players.filter(fn(p) { p.id.value != id.value })
}

///|
/// Advance all playing players by the given number of samples.
pub fn SimpleAudioContext::tick(
  self : SimpleAudioContext,
  samples : Int,
) -> Unit {
  for player in self.players {
    match player.state {
      PlayerState::Playing => {
        player.position_samples = player.position_samples + samples
        let total = clip_sample_count(player.clip)
        if player.position_samples >= total {
          if player.clip.loop_ {
            player.position_samples = player.position_samples % total
          } else {
            player.position_samples = total
            player.state = PlayerState::Stopped
          }
        }
      }
      _ => ()
    }
  }
}

///|
pub fn SimpleAudioContext::player_count(self : SimpleAudioContext) -> Int {
  self.players.length()
}

///|
/// Convert a mizchi/audio AudioBuffer to an AudioClip.
/// Converts Float samples to 16-bit PCM Bytes.
pub fn audio_buffer_to_clip(
  buffer : @ma.AudioBuffer,
  loop_ : Bool,
) -> AudioClip {
  let format : AudioFormat = {
    sample_rate: buffer.sample_rate,
    channels: buffer.channels,
    bits_per_sample: 16,
  }
  let sample_count = @ma.frame_count(buffer) * buffer.channels
  let bytes_arr : Array[Byte] = []
  for i = 0; i < sample_count; i = i + 1 {
    let frame = i / buffer.channels
    let ch = i % buffer.channels
    let sample = @ma.get_sample(buffer, frame, ch)
    // Clamp to [-1.0, 1.0] and convert to 16-bit signed
    let clamped : Float = if sample < -1.0 {
      -1.0
    } else if sample > 1.0 {
      1.0
    } else {
      sample
    }
    let i16_val = (clamped * 32767.0).to_int()
    bytes_arr.push((i16_val & 0xFF).to_byte())
    bytes_arr.push(((i16_val >> 8) & 0xFF).to_byte())
  }
  let data = Bytes::from_array(bytes_arr)
  new_audio_clip(format, data, loop_)
}

///|
/// Decode WAV bytes into an AudioClip.
pub fn decode_wav_clip(
  wav_bytes : Bytes,
  loop_ : Bool,
) -> AudioClip raise @ma.AudioError {
  let buffer = @ma.decode_wav(wav_bytes)
  audio_buffer_to_clip(buffer, loop_)
}

///|
/// Decode OGG/Vorbis bytes into an AudioClip.
pub fn decode_ogg_clip(
  ogg_bytes : Bytes,
  loop_ : Bool,
) -> AudioClip raise @ma.AudioError {
  let buffer = @ma.decode_ogg(ogg_bytes)
  audio_buffer_to_clip(buffer, loop_)
}

///|
/// Detect audio format from magic bytes.
/// Returns "wav", "ogg", or empty string if unknown.
pub fn detect_audio_format(data : Bytes) -> String {
  if data.length() >= 4 {
    if data[0] == b'R' && data[1] == b'I' && data[2] == b'F' && data[3] == b'F' {
      return "wav"
    }
    if data[0] == b'O' && data[1] == b'g' && data[2] == b'g' && data[3] == b'S' {
      return "ogg"
    }
  }
  ""
}

///|
/// Detect audio format from bytes and decode accordingly.
/// Returns None if format is not recognized.
pub fn decode_audio_clip_auto(
  data : Bytes,
  loop_ : Bool,
) -> AudioClip? raise @ma.AudioError {
  match detect_audio_format(data) {
    "wav" => Some(decode_wav_clip(data, loop_))
    "ogg" => Some(decode_ogg_clip(data, loop_))
    _ => None
  }
}

///|
/// Convert an AudioClip back to a mizchi/audio AudioBuffer.
/// Assumes 16-bit PCM format.
pub fn clip_to_audio_buffer(clip : AudioClip) -> @ma.AudioBuffer {
  let frames = clip_sample_count(clip)
  let buffer = @ma.new_audio_buffer(
    clip.format.channels,
    clip.format.sample_rate,
    frames,
  )
  let channels = clip.format.channels
  for frame = 0; frame < frames; frame = frame + 1 {
    for ch = 0; ch < channels; ch = ch + 1 {
      let byte_offset = (frame * channels + ch) * 2
      let lo = clip.data[byte_offset].to_int()
      let hi = clip.data[byte_offset + 1].to_int()
      let i16_val = lo | (hi << 8)
      // Sign extend 16-bit
      let signed_val = if i16_val >= 32768 { i16_val - 65536 } else { i16_val }
      let sample = Float::from_int(signed_val) / 32767.0
      @ma.set_sample(buffer, frame, ch, sample)
    }
  }
  buffer
}

///|
/// Stream that reads sequentially from an AudioClip.
pub struct AudioClipStream {
  clip : AudioClip
  mut position : Int
}

///|
pub fn AudioClipStream::new(clip : AudioClip) -> AudioClipStream {
  { clip, position: 0 }
}

///|
pub fn AudioClipStream::reset(self : AudioClipStream) -> Unit {
  self.position = 0
}

///|
pub fn AudioClipStream::remaining(self : AudioClipStream) -> Int {
  self.clip.data.length() - self.position
}

///|
pub impl AudioStream for AudioClipStream with read(self, buf, length) {
  let available = self.clip.data.length() - self.position
  let to_read = if length < available { length } else { available }
  if to_read > buf.length() {
    let actual = buf.length()
    for i = 0; i < actual; i = i + 1 {
      buf[i] = self.clip.data[self.position + i]
    }
    self.position = self.position + actual
    return actual
  }
  for i = 0; i < to_read; i = i + 1 {
    buf[i] = self.clip.data[self.position + i]
  }
  self.position = self.position + to_read
  to_read
}

///|
pub impl AudioStream for AudioClipStream with content_length(self) {
  self.clip.data.length()
}

///|
/// Buffered stream that accumulates audio data chunks progressively.
/// Useful for network streaming or progressive decoding.
pub struct BufferedAudioStream {
  chunks : Array[Bytes]
  mut total_bytes : Int
  mut read_chunk_idx : Int
  mut read_byte_idx : Int
  mut finalized : Bool
  mut final_length : Int
}

///|
pub fn BufferedAudioStream::new() -> BufferedAudioStream {
  {
    chunks: [],
    total_bytes: 0,
    read_chunk_idx: 0,
    read_byte_idx: 0,
    finalized: false,
    final_length: -1,
  }
}

///|
/// Append a data chunk to the stream buffer.
pub fn BufferedAudioStream::push_chunk(
  self : BufferedAudioStream,
  data : Bytes,
) -> Unit {
  self.total_bytes = self.total_bytes + data.length()
  self.chunks.push(data)
}

///|
/// Mark the stream as complete with known total length.
pub fn BufferedAudioStream::finalize(self : BufferedAudioStream) -> Unit {
  self.finalized = true
  self.final_length = self.total_bytes
}

///|
/// Check if more data is available to read.
pub fn BufferedAudioStream::has_data(self : BufferedAudioStream) -> Bool {
  self.buffered_bytes() > 0
}

///|
/// Get the number of bytes available for reading.
pub fn BufferedAudioStream::buffered_bytes(self : BufferedAudioStream) -> Int {
  let mut read_pos = 0
  for i = 0; i < self.read_chunk_idx; i = i + 1 {
    read_pos = read_pos + self.chunks[i].length()
  }
  read_pos = read_pos + self.read_byte_idx
  self.total_bytes - read_pos
}

///|
pub fn BufferedAudioStream::is_finalized(self : BufferedAudioStream) -> Bool {
  self.finalized
}

///|
pub fn BufferedAudioStream::reset(self : BufferedAudioStream) -> Unit {
  self.read_chunk_idx = 0
  self.read_byte_idx = 0
}

///|
pub impl AudioStream for BufferedAudioStream with read(self, buf, length) {
  let mut written = 0
  let to_read = if length < buf.length() { length } else { buf.length() }
  while written < to_read && self.read_chunk_idx < self.chunks.length() {
    let chunk = self.chunks[self.read_chunk_idx]
    let chunk_remaining = chunk.length() - self.read_byte_idx
    let need = to_read - written
    let take = if need < chunk_remaining { need } else { chunk_remaining }
    for i = 0; i < take; i = i + 1 {
      buf[written + i] = chunk[self.read_byte_idx + i]
    }
    written = written + take
    self.read_byte_idx = self.read_byte_idx + take
    if self.read_byte_idx >= chunk.length() {
      self.read_chunk_idx = self.read_chunk_idx + 1
      self.read_byte_idx = 0
    }
  }
  written
}

///|
pub impl AudioStream for BufferedAudioStream with content_length(self) {
  if self.finalized {
    self.final_length
  } else {
    -1
  }
}

///|
/// MixerAudioContext wraps mizchi/audio's Mixer to implement AudioContext.
pub struct MixerAudioContext {
  mixer : @ma.Mixer
  mut player_map : Array[(PlayerId, @ma.VoiceId)]
  mut next_id : Int
  mut bgm_player_id : PlayerId?
  mut bgm_fade_target : Double
  mut bgm_fade_speed : Double
  mut bgm_current_volume : Double
  mut bgm_outgoing_id : PlayerId?
  mut bgm_outgoing_volume : Double
  mut bgm_outgoing_fade_speed : Double
}

///|
pub fn MixerAudioContext::new(sample_rate : Int) -> MixerAudioContext {
  {
    mixer: @ma.new_mixer(sample_rate),
    player_map: [],
    next_id: 1,
    bgm_player_id: None,
    bgm_fade_target: 1.0,
    bgm_fade_speed: 0.0,
    bgm_current_volume: 1.0,
    bgm_outgoing_id: None,
    bgm_outgoing_volume: 0.0,
    bgm_outgoing_fade_speed: 0.0,
  }
}

///|
fn MixerAudioContext::find_voice_id(
  self : MixerAudioContext,
  id : PlayerId,
) -> @ma.VoiceId? {
  for pair in self.player_map {
    let (pid, vid) = pair
    if pid.value == id.value {
      return Some(vid)
    }
  }
  None
}

///|
pub impl AudioContext for MixerAudioContext with create_player(self, clip) {
  let buffer = clip_to_audio_buffer(clip)
  let voice = @ma.new_voice_from_buffer(self.mixer, buffer, looping=clip.loop_)
  let voice_id = @ma.add_voice(self.mixer, voice)
  // Pause immediately - AudioContext contract expects Stopped initial state.
  // Using pause (not stop) so the voice is not garbage-collected by collect_stopped.
  let _ = @ma.pause_voice(self.mixer, voice_id)
  let player_id : PlayerId = { value: self.next_id }
  self.next_id = self.next_id + 1
  self.player_map.push((player_id, voice_id))
  player_id
}

///|
pub impl AudioContext for MixerAudioContext with play(self, id) {
  match self.find_voice_id(id) {
    Some(vid) =>
      match @ma.find_voice(self.mixer, vid) {
        Some(voice) => voice.state = @ma.VoiceState::Playing
        None => ()
      }
    None => ()
  }
}

///|
pub impl AudioContext for MixerAudioContext with pause(self, id) {
  match self.find_voice_id(id) {
    Some(vid) => {
      let _ = @ma.pause_voice(self.mixer, vid)
    }
    None => ()
  }
}

///|
pub impl AudioContext for MixerAudioContext with stop(self, id) {
  match self.find_voice_id(id) {
    Some(vid) => {
      let _ = @ma.stop_voice(self.mixer, vid)
    }
    None => ()
  }
}

///|
pub impl AudioContext for MixerAudioContext with state(self, id) {
  match self.find_voice_id(id) {
    Some(vid) =>
      match @ma.voice_state(self.mixer, vid) {
        Some(@ma.VoiceState::Playing) => PlayerState::Playing
        Some(@ma.VoiceState::Paused) => PlayerState::Paused
        _ => PlayerState::Stopped
      }
    None => PlayerState::Stopped
  }
}

///|
pub impl AudioContext for MixerAudioContext with set_volume(self, id, volume) {
  match self.find_voice_id(id) {
    Some(vid) =>
      match @ma.find_voice(self.mixer, vid) {
        Some(voice) => {
          let clamped = if volume < 0.0 {
            0.0
          } else if volume > 1.0 {
            1.0
          } else {
            volume
          }
          voice.gain = Float::from_double(clamped)
        }
        None => ()
      }
    None => ()
  }
}

///|
pub impl AudioContext for MixerAudioContext with set_pan(self, id, pan) {
  match self.find_voice_id(id) {
    Some(vid) =>
      match @ma.find_voice(self.mixer, vid) {
        Some(voice) => {
          let clamped = if pan < -1.0 {
            -1.0
          } else if pan > 1.0 {
            1.0
          } else {
            pan
          }
          voice.pan = Float::from_double(clamped)
        }
        None => ()
      }
    None => ()
  }
}

///|
pub impl AudioContext for MixerAudioContext with current_position(self, id) {
  match self.find_voice_id(id) {
    Some(vid) =>
      match @ma.voice_position(self.mixer, vid) {
        Some(pos) => pos.to_double()
        None => 0.0
      }
    None => 0.0
  }
}

///|
pub impl AudioContext for MixerAudioContext with seek(self, id, position) {
  match self.find_voice_id(id) {
    Some(vid) =>
      match @ma.find_voice(self.mixer, vid) {
        Some(voice) => {
          let frame = (position * voice.sample_rate.to_double()).to_int()
          let _ = @ma.seek_voice(self.mixer, vid, frame)
        }
        None => ()
      }
    None => ()
  }
}

///|
pub impl AudioContext for MixerAudioContext with dispose(self, id) {
  match self.find_voice_id(id) {
    Some(vid) => {
      let _ = @ma.stop_voice(self.mixer, vid)
      self.player_map = self.player_map.filter(fn(pair) {
        let (pid, _) = pair
        pid.value != id.value
      })
    }
    None => ()
  }
}

///|
/// Render audio frames into an output buffer.
pub fn MixerAudioContext::render(
  self : MixerAudioContext,
  frames : Int,
  output : FixedArray[Float],
) -> Unit {
  @ma.tick(self.mixer, frames, output)
  // Protect managed voices from GC: set Stopped → Paused before collect
  for pair in self.player_map {
    let (_, vid) = pair
    match @ma.find_voice(self.mixer, vid) {
      Some(voice) =>
        if voice.state == @ma.VoiceState::Stopped {
          voice.state = @ma.VoiceState::Paused
        }
      None => ()
    }
  }
  @ma.collect_stopped(self.mixer)
}

///|
/// Play a BGM clip with looping. Stops any currently playing BGM.
pub fn MixerAudioContext::play_bgm(
  self : MixerAudioContext,
  clip : AudioClip,
  volume? : Double = 1.0,
) -> Unit {
  // Stop existing BGM
  self.stop_bgm()
  // Create a looping clip
  let looping_clip = new_audio_clip(clip.format, clip.data, true)
  let id = self.create_player(looping_clip) catch { _ => return }
  let vol = clamp_volume(volume)
  self.set_volume(id, vol)
  self.play(id) catch {
    _ => ()
  }
  self.bgm_player_id = Some(id)
  self.bgm_current_volume = vol
  self.bgm_fade_target = vol
  self.bgm_fade_speed = 0.0
}

///|
/// Stop and dispose the current BGM and any outgoing BGM.
pub fn MixerAudioContext::stop_bgm(self : MixerAudioContext) -> Unit {
  self.dispose_outgoing_bgm()
  match self.bgm_player_id {
    Some(id) => {
      self.stop(id) catch {
        _ => ()
      }
      self.dispose(id)
      self.bgm_player_id = None
      self.bgm_fade_speed = 0.0
    }
    None => ()
  }
}

///|
/// Pause the current BGM.
pub fn MixerAudioContext::pause_bgm(self : MixerAudioContext) -> Unit {
  match self.bgm_player_id {
    Some(id) => self.pause(id) catch { _ => () }
    None => ()
  }
}

///|
/// Resume the current BGM.
pub fn MixerAudioContext::resume_bgm(self : MixerAudioContext) -> Unit {
  match self.bgm_player_id {
    Some(id) => self.play(id) catch { _ => () }
    None => ()
  }
}

///|
/// Set BGM volume immediately.
pub fn MixerAudioContext::set_bgm_volume(
  self : MixerAudioContext,
  volume : Double,
) -> Unit {
  let vol = clamp_volume(volume)
  self.bgm_current_volume = vol
  self.bgm_fade_target = vol
  self.bgm_fade_speed = 0.0
  match self.bgm_player_id {
    Some(id) => self.set_volume(id, vol)
    None => ()
  }
}

///|
/// Start a volume fade on the BGM.
pub fn MixerAudioContext::fade_bgm(
  self : MixerAudioContext,
  target_volume : Double,
  duration_seconds : Double,
) -> Unit {
  let target = clamp_volume(target_volume)
  self.bgm_fade_target = target
  if duration_seconds <= 0.0 {
    self.set_bgm_volume(target)
  } else {
    let diff = (target - self.bgm_current_volume).abs()
    self.bgm_fade_speed = diff / duration_seconds
  }
}

///|
/// Advance BGM fade by the given number of audio frames.
/// Call this once per audio tick from the engine loop.
pub fn MixerAudioContext::tick_bgm(
  self : MixerAudioContext,
  frames : Int,
) -> Unit {
  let sample_rate = self.mixer.sample_rate
  let dt = frames.to_double() / sample_rate.to_double()
  // Process incoming BGM fade
  if self.bgm_fade_speed > 0.0 {
    match self.bgm_player_id {
      Some(id) => {
        let delta = self.bgm_fade_speed * dt
        let current = self.bgm_current_volume
        let target = self.bgm_fade_target
        let new_vol = if current < target {
          let v = current + delta
          if v >= target {
            target
          } else {
            v
          }
        } else {
          let v = current - delta
          if v <= target {
            target
          } else {
            v
          }
        }
        self.bgm_current_volume = new_vol
        self.set_volume(id, new_vol)
        if (new_vol - target).abs() < 0.0001 {
          self.bgm_current_volume = target
          self.bgm_fade_speed = 0.0
        }
      }
      None => self.bgm_fade_speed = 0.0
    }
  }
  // Process outgoing BGM fade
  if self.bgm_outgoing_fade_speed > 0.0 {
    match self.bgm_outgoing_id {
      Some(id) => {
        let delta = self.bgm_outgoing_fade_speed * dt
        let new_vol = self.bgm_outgoing_volume - delta
        if new_vol <= 0.0 {
          self.dispose_outgoing_bgm()
        } else {
          self.bgm_outgoing_volume = new_vol
          self.set_volume(id, new_vol)
        }
      }
      None => self.bgm_outgoing_fade_speed = 0.0
    }
  }
}

///|
/// Dispose the outgoing BGM player.
pub fn MixerAudioContext::dispose_outgoing_bgm(
  self : MixerAudioContext,
) -> Unit {
  match self.bgm_outgoing_id {
    Some(id) => {
      self.stop(id) catch {
        _ => ()
      }
      self.dispose(id)
      self.bgm_outgoing_id = None
      self.bgm_outgoing_volume = 0.0
      self.bgm_outgoing_fade_speed = 0.0
    }
    None => ()
  }
}

///|
/// Crossfade from the current BGM to a new BGM clip.
/// The old BGM fades out and the new BGM fades in over duration_seconds.
pub fn MixerAudioContext::crossfade_bgm(
  self : MixerAudioContext,
  clip : AudioClip,
  duration_seconds : Double,
  volume? : Double = 1.0,
) -> Unit {
  let target_vol = clamp_volume(volume)
  // If there's already an outgoing BGM, dispose it immediately
  self.dispose_outgoing_bgm()
  // Move current BGM to outgoing slot
  match self.bgm_player_id {
    Some(id) => {
      self.bgm_outgoing_id = Some(id)
      self.bgm_outgoing_volume = self.bgm_current_volume
      if duration_seconds > 0.0 {
        self.bgm_outgoing_fade_speed = self.bgm_current_volume /
          duration_seconds
      } else {
        self.dispose_outgoing_bgm()
      }
    }
    None => ()
  }
  // Start new BGM at volume 0, fade in
  let looping_clip = new_audio_clip(clip.format, clip.data, true)
  let id = self.create_player(looping_clip) catch { _ => return }
  self.set_volume(id, 0.0)
  self.play(id) catch {
    _ => ()
  }
  self.bgm_player_id = Some(id)
  self.bgm_current_volume = 0.0
  self.bgm_fade_target = target_vol
  if duration_seconds > 0.0 {
    self.bgm_fade_speed = target_vol / duration_seconds
  } else {
    self.bgm_current_volume = target_vol
    self.set_volume(id, target_vol)
    self.bgm_fade_speed = 0.0
  }
}

///|
fn clamp_volume(v : Double) -> Double {
  if v < 0.0 {
    0.0
  } else if v > 1.0 {
    1.0
  } else {
    v
  }
}

///|
/// Audio output hooks for backend integration (Web Audio API / native audio).
pub struct AudioOutputHooks {
  /// Initialize the audio output device with the given format. Returns true on success.
  try_initialize : (AudioFormat) -> Bool
  /// Write PCM frames to the output device. Returns number of frames written.
  write_frames : (FixedArray[Float], Int) -> Int
  /// Suspend audio output (e.g., tab hidden, app backgrounded).
  suspend : () -> Unit
  /// Resume audio output after suspend.
  resume_playback : () -> Unit
  /// Close the audio output device and release resources.
  close : () -> Unit
  /// Query the actual output latency in seconds. Returns 0.0 if unknown.
  output_latency : () -> Double
}

///|
pub fn new_audio_output_hooks(
  try_initialize : (AudioFormat) -> Bool,
  write_frames : (FixedArray[Float], Int) -> Int,
  suspend : () -> Unit,
  resume_playback : () -> Unit,
  close : () -> Unit,
) -> AudioOutputHooks {
  {
    try_initialize,
    write_frames,
    suspend,
    resume_playback,
    close,
    output_latency: default_audio_output_latency,
  }
}

///|
pub fn new_audio_output_hooks_full(
  try_initialize : (AudioFormat) -> Bool,
  write_frames : (FixedArray[Float], Int) -> Int,
  suspend : () -> Unit,
  resume_playback : () -> Unit,
  close : () -> Unit,
  output_latency : () -> Double,
) -> AudioOutputHooks {
  {
    try_initialize,
    write_frames,
    suspend,
    resume_playback,
    close,
    output_latency,
  }
}

///|
fn default_audio_try_initialize(_format : AudioFormat) -> Bool {
  false
}

///|
fn default_audio_write_frames(
  _output : FixedArray[Float],
  _frames : Int,
) -> Int {
  0
}

///|
fn default_audio_suspend() -> Unit {
  ()
}

///|
fn default_audio_resume() -> Unit {
  ()
}

///|
fn default_audio_close() -> Unit {
  ()
}

///|
fn default_audio_output_latency() -> Double {
  0.0
}

///|
fn default_audio_output_hooks() -> AudioOutputHooks {
  new_audio_output_hooks(
    default_audio_try_initialize, default_audio_write_frames, default_audio_suspend,
    default_audio_resume, default_audio_close,
  )
}

///|
let audio_output_hooks : Ref[AudioOutputHooks] = Ref(
  default_audio_output_hooks(),
)

///|
pub fn set_audio_output_hooks(hooks : AudioOutputHooks) -> Unit {
  audio_output_hooks.val = hooks
}

///|
pub fn reset_audio_output_hooks() -> Unit {
  audio_output_hooks.val = default_audio_output_hooks()
}

///|
pub fn audio_try_initialize(format : AudioFormat) -> Bool {
  (audio_output_hooks.val.try_initialize)(format)
}

///|
pub fn audio_write_frames(output : FixedArray[Float], frames : Int) -> Int {
  (audio_output_hooks.val.write_frames)(output, frames)
}

///|
pub fn audio_suspend() -> Unit {
  (audio_output_hooks.val.suspend)()
}

///|
pub fn audio_resume() -> Unit {
  (audio_output_hooks.val.resume_playback)()
}

///|
pub fn audio_close() -> Unit {
  (audio_output_hooks.val.close)()
}

///|
pub fn audio_output_latency() -> Double {
  (audio_output_hooks.val.output_latency)()
}

///|
pub struct AudioOutputStats {
  initialized : Bool
  frames_written : Int
  suspended : Bool
} derive(Debug)

///|
pub impl Show for AudioOutputStats with output(self, logger) {
  logger.write_object(to_repr(self))
}

///|
/// Render audio from a MixerAudioContext and write to output hooks.
/// Returns the number of frames actually written.
pub fn audio_render_and_write(mixer : MixerAudioContext, frames : Int) -> Int {
  let channels = 2 // stereo
  let buf : FixedArray[Float] = FixedArray::make(frames * channels, 0.0)
  mixer.render(frames, buf)
  audio_write_frames(buf, frames)
}

///|
/// Create default AudioOutputStats.
pub fn default_audio_output_stats() -> AudioOutputStats {
  { initialized: false, frames_written: 0, suspended: false }
}

///|
/// Create a tracked AudioOutputHooks wrapper that updates stats.
pub fn new_tracked_audio_output_hooks(
  inner : AudioOutputHooks,
  stats : Ref[AudioOutputStats],
) -> AudioOutputHooks {
  {
    try_initialize: fn(fmt) {
      let ok = (inner.try_initialize)(fmt)
      if ok {
        stats.val = { ..stats.val, initialized: true }
      }
      ok
    },
    write_frames: fn(output, frames) {
      let written = (inner.write_frames)(output, frames)
      stats.val = {
        ..stats.val,
        frames_written: stats.val.frames_written + written,
      }
      written
    },
    suspend: fn() {
      (inner.suspend)()
      stats.val = { ..stats.val, suspended: true }
    },
    resume_playback: fn() {
      (inner.resume_playback)()
      stats.val = { ..stats.val, suspended: false }
    },
    close: inner.close,
    output_latency: inner.output_latency,
  }
}