-- libmusic: list and play dfpwm music tracks from a directory.
--
-- A factory: `local createMusic = require('/apis/libmusic'); local music = createMusic();`
--
-- Non-UI logic lives here so the `music` program stays thin and the listing is
-- unit-testable with an injected `fs`. Dependencies are injectable to keep the
-- module testable outside the CC runtime (mirrors libccpm/libversion style).

local DEFAULT_DIR = '/data/musics';
local EXTENSION = '.dfpwm';
local CHUNK_SIZE = 16 * 1024;

local function createMusic(opts)
  opts = opts or {};
  local fsLib = opts.fs or fs;
  local peripheralLib = opts.peripheral or peripheral;
  local osLib = opts.os or os;
  local ioLib = opts.io or io;
  local dir = opts.dir or DEFAULT_DIR;

  local api = {};

  -- Returns a sorted list of `{ name = <display>, path = <full path> }` for every
  -- `.dfpwm` file in the music directory; `{}` when the directory is missing.
  function api.listTracks()
    local tracks = {};
    if not fsLib.isDir(dir) then return tracks; end
    for _, entry in ipairs(fsLib.list(dir)) do
      if entry:sub(-#EXTENSION) == EXTENSION then
        tracks[#tracks + 1] = {
          name = entry:sub(1, #entry - #EXTENSION),
          path = dir .. '/' .. entry,
        };
      end
    end
    table.sort(tracks, function(a, b) return a.name < b.name; end);
    return tracks;
  end

  -- Plays a dfpwm file through the first speaker peripheral, blocking until the
  -- whole track has been queued and drained. Errors if no speaker is attached.
  function api.play(path)
    local speaker = peripheralLib.find('speaker');
    if not speaker then
      error('no speaker attached', 0);
    end

    local dfpwm = opts.dfpwm or require('cc.audio.dfpwm');
    local decoder = dfpwm.make_decoder();

    for chunk in ioLib.lines(path, CHUNK_SIZE) do
      local buffer = decoder(chunk);
      while not speaker.playAudio(buffer) do
        osLib.pullEvent('speaker_audio_empty');
      end
    end
  end

  -- Creates a non-blocking, event-driven player bound to an `eventloop`.
  --
  -- Unlike `api.play`, this never blocks: it feeds the speaker one batch of
  -- chunks at a time and yields back to the loop, refilling on each
  -- `speaker_audio_empty` event. That keeps a TUI responsive and makes
  -- pause/stop/track-switching possible while a song is playing.
  function api.createPlayer(eventloop)
    assert(type(eventloop) == 'table', 'bad argument #1 (eventloop expected)');

    local dfpwm = opts.dfpwm or require('cc.audio.dfpwm');

    local player = {};

    local status = 'stopped'; -- 'stopped' | 'playing' | 'paused'
    local handle = nil;
    local decoder = nil;
    local pendingBuffer = nil; -- buffer that playAudio refused, replayed first
    local currentPath = nil;
    local onFinishCb = nil;

    local function getSpeaker()
      local speaker = peripheralLib.find('speaker');
      if not speaker then
        error('no speaker attached', 0);
      end
      return speaker;
    end

    -- Releases the file handle / decoder without touching the speaker queue.
    local function release()
      if handle then
        handle:close();
        handle = nil;
      end
      decoder = nil;
      pendingBuffer = nil;
    end

    -- Reached the natural end of the track. Leave the player stopped and let
    -- the caller decide what happens next (e.g. autoplay).
    local function finish()
      release();
      status = 'stopped';
      currentPath = nil;
      if onFinishCb then
        onFinishCb();
      end
    end

    -- Feeds the speaker until its queue is full, then returns to the loop.
    -- A no-op unless we are actively playing.
    local function pump()
      if status ~= 'playing' then
        return;
      end

      local speaker = getSpeaker();

      if pendingBuffer then
        if not speaker.playAudio(pendingBuffer) then
          return; -- still full; wait for the next speaker_audio_empty
        end
        pendingBuffer = nil;
      end

      while true do
        local chunk = handle:read(CHUNK_SIZE);
        if not chunk then
          finish();
          return;
        end

        local buffer = decoder(chunk);
        if not speaker.playAudio(buffer) then
          pendingBuffer = buffer;
          return;
        end
      end
    end

    -- The loop drives refills off this event for the player's whole lifetime.
    eventloop.register('speaker_audio_empty', pump);

    -- Stops playback immediately: clears the speaker queue and resets state.
    function player.stop()
      if status ~= 'stopped' then
        getSpeaker().stop();
      end
      release();
      status = 'stopped';
      currentPath = nil;
    end

    -- Switches to `path` from a clean state and primes the speaker queue.
    function player.play(path)
      player.stop();
      handle = assert(ioLib.open(path, 'rb'), 'cannot open ' .. tostring(path));
      decoder = dfpwm.make_decoder();
      currentPath = path;
      status = 'playing';
      pump();
    end

    -- Gapless pause: stop feeding and let already-queued audio drain. The
    -- decoder/handle/pendingBuffer are kept so resume loses no content.
    function player.pause()
      if status == 'playing' then
        status = 'paused';
      end
    end

    function player.resume()
      if status == 'paused' then
        status = 'playing';
        pump();
      end
    end

    function player.togglePause()
      if status == 'playing' then
        player.pause();
      elseif status == 'paused' then
        player.resume();
      end
    end

    function player.getStatus()
      return status;
    end

    function player.getCurrentPath()
      return currentPath;
    end

    function player.onFinish(cb)
      assert(type(cb) == 'function', 'bad argument #1 (function expected)');
      onFinishCb = cb;
    end

    return player;
  end

  return api;
end

return createMusic;
